```
feat: 添加自定义提醒功能并优化项目配置 - 添加 CustomReminder 模型支持自由文案周期性提醒功能 - 实现自定义提醒的 UI 界面,包括新建、编辑和列表展示 - 集成本地通知服务支持自定义提醒的时间触发 - 更新项目配置文件添加应用显示名称和加密声明 - 修正 iOS 部署目标版本从 26.0 到 17.0 - 修复 FileDownloader 中的线程安全问题 - 优化 ModelManifest 和 Localization 的并发安全性 - 扩展本地化字符串支持多语言提醒相关文本 - 调整项目支持平台范围仅保留 iphoneos 和 iphonesimulator ```
This commit is contained in:
117
docs/superpowers/specs/2026-05-30-custom-reminder-design.md
Normal file
117
docs/superpowers/specs/2026-05-30-custom-reminder-design.md
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
# 自由周期提醒(CustomReminder)— 设计文档
|
||||||
|
|
||||||
|
**日期**:2026-05-30(W2)
|
||||||
|
**作者**:link2026 + Claude
|
||||||
|
**关联卖点**:#4 隐私三件套之外的实用粘性功能(本地通知,无云)
|
||||||
|
**优先级**:用户明确要求(注:§10.6「用药提醒」原列默认不做,本轮经讨论确认要做,按最小可用实现)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 一句话定位
|
||||||
|
|
||||||
|
让用户新建**自由文案的周期性本地提醒**(如「每天 20:00 跑步 5 公里」「每天 12:30 吃 2 片护肝片」),与现有「指标记录提醒」(去录某项指标)并存但相互独立。完全本地 `UserNotifications`,不引云。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 已确认的设计决策
|
||||||
|
|
||||||
|
| 决策点 | 选择 |
|
||||||
|
|---|---|
|
||||||
|
| 模型 | 新建独立 `CustomReminder` @Model,不动现有 `MetricReminder` |
|
||||||
|
| 周期粒度 | 每天 / 每周选几天(复用 weekday 约定,覆盖示例)。不做间隔/按月/一次性 |
|
||||||
|
| 入口 | 新建 → 开启一个提醒 → `RemindersListView`(提醒中心),顶部「+ 新建提醒」打开编辑 sheet |
|
||||||
|
| 列表范围 | 自由提醒 + 指标提醒**合展**(上次删了「我的」入口,指标提醒也只能从这里管) |
|
||||||
|
| 量词(5公里/2片) | 写在自由文本 `title` 里,不单设字段 |
|
||||||
|
| 多语言 | 所有固定文案走 `String(appLoc:)`,新增中文 key 补 en/ja/ko 到 `Localizable.xcstrings` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 数据模型
|
||||||
|
|
||||||
|
`Models/Models.swift` 新增:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
@Model final class CustomReminder {
|
||||||
|
@Attribute(.unique) var id: UUID
|
||||||
|
var title: String // 用户文案:"跑步5公里"
|
||||||
|
var note: String // 可选备注 → 通知正文
|
||||||
|
var hour: Int // 0...23
|
||||||
|
var minute: Int // 0...59
|
||||||
|
var weekdays: [Int] // 1=日…7=六,全 7 = 每天(复用 MetricReminder 约定)
|
||||||
|
var enabled: Bool
|
||||||
|
var createdAt: Date
|
||||||
|
var updatedAt: Date
|
||||||
|
// computed: isEveryDay / frequencyLabel / timeLabel(与 MetricReminder 同款,复用同一批本地化 key)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema 注册:`App/KangkangApp.swift` 加 `CustomReminder.self`(additive 变更,无需迁移)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 通知调度(ReminderService 泛化)
|
||||||
|
|
||||||
|
抽出私有共享核心,两种提醒复用:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
private static func schedule(idBase:title:body:hour:minute:weekdays:thread:) async
|
||||||
|
static func sync(_ custom: CustomReminder) async // 新增
|
||||||
|
static func cancel(customId: UUID) // 新增
|
||||||
|
static func sync(_ metric: MetricReminder) async // 现有,内部改走共享核心,行为不变
|
||||||
|
```
|
||||||
|
|
||||||
|
- custom 通知:`title` = 提醒标题,`body` = 备注(空则用默认文案「到点啦,记得完成」)。
|
||||||
|
- id 前缀 `kangkang.custom.<uuid>.w<weekday>`(与指标的 `kangkang.reminder.<metricId>.w<weekday>` 不冲突)。
|
||||||
|
- 保存时调 `requestAuthorization()`;被拒则提示去系统设置。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UI
|
||||||
|
|
||||||
|
### 5.1 `CustomReminderEditSheet`(新增)
|
||||||
|
创建 / 编辑共用。字段:
|
||||||
|
- 标题 TextField(占位:「做点什么?例:跑步5公里 / 吃2片护肝片」),空标题禁用保存。
|
||||||
|
- 备注 TextField(可选)。
|
||||||
|
- 时间 DatePicker(.hourAndMinute)。
|
||||||
|
- 周几选择(复用 RemindersListView 的 chip 行)。
|
||||||
|
- 保存 / 取消;编辑态多一个「删除提醒」。
|
||||||
|
保存:写 SwiftData → 请求通知权限 → `ReminderService.sync(custom)`。
|
||||||
|
|
||||||
|
### 5.2 `RemindersListView`(改造为提醒中心)
|
||||||
|
- 顶部「+ 新建提醒」按钮 → 打开 `CustomReminderEditSheet`(create)。
|
||||||
|
- 「我的提醒」区:`@Query CustomReminder`,每行点开走编辑 sheet,行上 Toggle 控 enabled。
|
||||||
|
- 「指标记录提醒」区:`@Query MetricReminder`,保持现有内联编辑不变(仅非空时显示区头)。
|
||||||
|
- 表头副文案、空状态文案更新。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 多语言
|
||||||
|
|
||||||
|
新增中文 key + en/ja/ko 译文写入 `Localizable.xcstrings`(源语言 zh-Hans,key 即中文)。脚本只增不改,已存在的 key 跳过。复用已有 key:时间/保存/取消/删除提醒/每天/已关闭/周几名等。用户输入的标题/备注是数据,不翻译。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 文件清单
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|---|---|
|
||||||
|
| `Models/Models.swift` | +`CustomReminder` |
|
||||||
|
| `App/KangkangApp.swift` | schema +`CustomReminder.self` |
|
||||||
|
| `Services/ReminderService.swift` | 泛化共享核心 + custom sync/cancel |
|
||||||
|
| `Features/Me/CustomReminderEditSheet.swift` | **新增** 编辑表单 |
|
||||||
|
| `Features/Me/RemindersListView.swift` | 提醒中心:新建按钮 + 合展两类 |
|
||||||
|
| `Localizable.xcstrings` | 新增文案四语言 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 红线对齐
|
||||||
|
|
||||||
|
- 不引云、不碰密码学(纯本地通知)✅
|
||||||
|
- 不重构 Tab/RecordSheet 骨架 ✅
|
||||||
|
- §10.6「用药提醒默认不做」→ 已讨论确认,最小实现(无贪睡/铃声/间隔)✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 验收(真机)
|
||||||
|
|
||||||
|
① 新建「每天 20:00 跑步 5 公里」→ 列表出现 → 到点收到本地通知(标题=跑步5公里);② 改时间/周几即时重排;③ 关闭 Toggle 取消通知;④ 删除清除 pending;⑤ 切换语言后固定文案随之变化(用户输入文案不变);⑥ 指标提醒仍在同一列表可管。
|
||||||
@@ -416,6 +416,8 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "康康";
|
||||||
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
|
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
|
||||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
||||||
@@ -430,24 +432,24 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--";
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
@@ -465,6 +467,8 @@
|
|||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_CFBundleDisplayName = "康康";
|
||||||
|
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
|
||||||
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
|
INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。";
|
||||||
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。";
|
||||||
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。";
|
||||||
@@ -479,24 +483,24 @@
|
|||||||
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
"INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault;
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
|
||||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--";
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
@@ -509,20 +513,20 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--Tests";
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/康康.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/康康";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/康康.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/康康";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
@@ -536,20 +540,20 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--Tests";
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/康康.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/康康";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/康康.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/康康";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
@@ -562,20 +566,20 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--UITests";
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_TARGET_NAME = "康康";
|
TEST_TARGET_NAME = "康康";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
@@ -588,20 +592,20 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = F2C8C774FG;
|
DEVELOPMENT_TEAM = F2C8C774FG;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 26.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||||
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
MACOSX_DEPLOYMENT_TARGET = 26.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--UITests";
|
PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = auto;
|
SDKROOT = auto;
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES;
|
||||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2,7";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
TEST_TARGET_NAME = "康康";
|
TEST_TARGET_NAME = "康康";
|
||||||
XROS_DEPLOYMENT_TARGET = 26.0;
|
XROS_DEPLOYMENT_TARGET = 26.0;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -74,12 +74,12 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl
|
|||||||
let fileHandle = try FileHandle(forWritingTo: part)
|
let fileHandle = try FileHandle(forWritingTo: part)
|
||||||
try fileHandle.seekToEnd()
|
try fileHandle.seekToEnd()
|
||||||
|
|
||||||
lock.lock()
|
lock.withLock {
|
||||||
self.handle = fileHandle
|
self.handle = fileHandle
|
||||||
self.written = offset
|
self.written = offset
|
||||||
self.onProgress = onProgress
|
self.onProgress = onProgress
|
||||||
self.responseError = nil
|
self.responseError = nil
|
||||||
lock.unlock()
|
}
|
||||||
|
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
if offset > 0 {
|
if offset > 0 {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ struct ModelFile: Equatable, Sendable {
|
|||||||
/// 只列加载必需的功能文件,排除 README.md / .gitattributes(省下载)。
|
/// 只列加载必需的功能文件,排除 README.md / .gitattributes(省下载)。
|
||||||
/// 字节数与服务器素材逐一核对一致,见
|
/// 字节数与服务器素材逐一核对一致,见
|
||||||
/// docs/superpowers/specs/2026-05-29-model-download-design.md 附录 A。
|
/// docs/superpowers/specs/2026-05-29-model-download-design.md 附录 A。
|
||||||
enum ModelManifest {
|
nonisolated enum ModelManifest {
|
||||||
/// 自建 Caddy 静态服务(用户自建 HTTPS 反代)。
|
/// 自建 Caddy 静态服务(用户自建 HTTPS 反代)。
|
||||||
/// 备选纯 IP(需 App 端 ATS 例外): http://101.132.124.52:5244/
|
/// 备选纯 IP(需 App 端 ATS 例外): http://101.132.124.52:5244/
|
||||||
static let baseURL = URL(string: "https://file.myv0.com/")!
|
static let baseURL = URL(string: "https://file.myv0.com/")!
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ struct KangkangApp: App {
|
|||||||
MetricReminder.self,
|
MetricReminder.self,
|
||||||
CustomMonitorMetric.self,
|
CustomMonitorMetric.self,
|
||||||
HealthExport.self,
|
HealthExport.self,
|
||||||
|
CustomReminder.self,
|
||||||
])
|
])
|
||||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||||
do {
|
do {
|
||||||
|
|||||||
@@ -80,16 +80,24 @@ final class LanguageManager {
|
|||||||
lprojBundle = .main
|
lprojBundle = .main
|
||||||
}
|
}
|
||||||
Bundle.redirectMain(to: current.localeIdentifier)
|
Bundle.redirectMain(to: current.localeIdentifier)
|
||||||
|
// 同步 nonisolated 快照,供 String(appLoc:) 在非 MainActor 上下文读取。
|
||||||
|
appLocBundle = lprojBundle
|
||||||
|
appLocLocale = resolvedLocale
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// nonisolated 快照:`String(appLoc:)` 可能在非 MainActor 上下文被调用
|
||||||
|
/// (LocalizedError.errorDescription、nonisolated 枚举 label、static 解析器…)。
|
||||||
|
/// 只由 `LanguageManager.apply()`(MainActor)写入,切换语言时刷新;读为快照,无竞态影响。
|
||||||
|
nonisolated(unsafe) private var appLocBundle: Bundle = .main
|
||||||
|
nonisolated(unsafe) private var appLocLocale: Locale = .autoupdatingCurrent
|
||||||
|
|
||||||
extension String {
|
extension String {
|
||||||
/// 尊重「我的 · 语言」选择的本地化(可即时切换)。
|
/// 尊重「我的 · 语言」选择的本地化(可即时切换)。
|
||||||
/// 等价 `String(localized:)`,但显式绑定当前所选语言的 bundle + locale,
|
/// 等价 `String(localized:)`,但显式绑定当前所选语言的 bundle + locale,
|
||||||
/// 因此不受 `Locale.current`(系统/启动时语言)限制。
|
/// 因此不受 `Locale.current`(系统/启动时语言)限制。
|
||||||
init(appLoc key: String.LocalizationValue) {
|
nonisolated init(appLoc key: String.LocalizationValue) {
|
||||||
let m = LanguageManager.shared
|
self = String(localized: key, bundle: appLocBundle, locale: appLocLocale)
|
||||||
self = String(localized: key, bundle: m.lprojBundle, locale: m.resolvedLocale)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
186
康康/Features/Me/CustomReminderEditSheet.swift
Normal file
186
康康/Features/Me/CustomReminderEditSheet.swift
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 自由周期提醒的创建 / 编辑表单。
|
||||||
|
/// `reminder == nil` 为新建;否则为编辑(多一个删除按钮)。
|
||||||
|
/// 本地 @State 暂存,保存时才写 SwiftData + 调度通知;取消即丢弃。
|
||||||
|
struct CustomReminderEditSheet: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
/// nil = 新建模式。
|
||||||
|
let reminder: CustomReminder?
|
||||||
|
|
||||||
|
@State private var title = ""
|
||||||
|
@State private var note = ""
|
||||||
|
@State private var pickedTime: Date = .now
|
||||||
|
@State private var weekdays: Set<Int> = Set(1...7)
|
||||||
|
@State private var hydrated = false
|
||||||
|
@State private var showAuthDeniedAlert = false
|
||||||
|
|
||||||
|
init(reminder: CustomReminder? = nil) {
|
||||||
|
self.reminder = reminder
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isEditing: Bool { reminder != nil }
|
||||||
|
private var trimmedTitle: String {
|
||||||
|
title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
private var canSave: Bool { !trimmedTitle.isEmpty && !weekdays.isEmpty }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section {
|
||||||
|
TextField(String(appLoc: "做点什么?例:跑步5公里 / 吃2片护肝片"),
|
||||||
|
text: $title, axis: .vertical)
|
||||||
|
.lineLimit(1...3)
|
||||||
|
TextField(String(appLoc: "备注(可选)"), text: $note, axis: .vertical)
|
||||||
|
.lineLimit(1...3)
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
DatePicker(String(appLoc: "时间"), selection: $pickedTime,
|
||||||
|
displayedComponents: .hourAndMinute)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section {
|
||||||
|
weekdayRow
|
||||||
|
} header: {
|
||||||
|
Text("重复")
|
||||||
|
}
|
||||||
|
|
||||||
|
if isEditing {
|
||||||
|
Section {
|
||||||
|
Button(role: .destructive) { deleteReminder() } label: {
|
||||||
|
Label(String(appLoc: "删除提醒"), systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle(isEditing ? String(appLoc: "编辑提醒") : String(appLoc: "新建提醒"))
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button(String(appLoc: "取消")) { dismiss() }
|
||||||
|
}
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(String(appLoc: "保存")) { save() }
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.disabled(!canSave)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear(perform: hydrate)
|
||||||
|
.alert(String(appLoc: "通知未开启"), isPresented: $showAuthDeniedAlert) {
|
||||||
|
Button(String(appLoc: "好")) { dismiss() }
|
||||||
|
} message: {
|
||||||
|
Text("提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 周几选择(与 RemindersListView 同款)
|
||||||
|
|
||||||
|
private var weekdayRow: some View {
|
||||||
|
let names = [
|
||||||
|
String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"),
|
||||||
|
String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"),
|
||||||
|
String(appLoc: "日"),
|
||||||
|
]
|
||||||
|
let values = [2, 3, 4, 5, 6, 7, 1]
|
||||||
|
return HStack(spacing: 6) {
|
||||||
|
ForEach(Array(values.enumerated()), id: \.offset) { idx, w in
|
||||||
|
let on = weekdays.contains(w)
|
||||||
|
Button {
|
||||||
|
if on { weekdays.remove(w) } else { weekdays.insert(w) }
|
||||||
|
} label: {
|
||||||
|
Text(names[idx])
|
||||||
|
.font(.system(size: 13, weight: on ? .semibold : .regular))
|
||||||
|
.foregroundStyle(on ? Tj.Palette.paper : Tj.Palette.text)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 30)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.fill(on ? Tj.Palette.ink : Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.line, lineWidth: on ? 0 : 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.listRowBackground(Color.clear)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 数据
|
||||||
|
|
||||||
|
private func hydrate() {
|
||||||
|
guard !hydrated else { return }
|
||||||
|
hydrated = true
|
||||||
|
if let r = reminder {
|
||||||
|
title = r.title
|
||||||
|
note = r.note
|
||||||
|
weekdays = Set(r.weekdays)
|
||||||
|
pickedTime = Calendar.current.date(
|
||||||
|
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
|
||||||
|
) ?? .now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func save() {
|
||||||
|
guard canSave else { return }
|
||||||
|
let cal = Calendar.current
|
||||||
|
let hour = cal.component(.hour, from: pickedTime)
|
||||||
|
let minute = cal.component(.minute, from: pickedTime)
|
||||||
|
let sortedDays = weekdays.sorted()
|
||||||
|
|
||||||
|
let target: CustomReminder
|
||||||
|
if let r = reminder {
|
||||||
|
r.title = trimmedTitle
|
||||||
|
r.note = note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
r.hour = hour
|
||||||
|
r.minute = minute
|
||||||
|
r.weekdays = sortedDays
|
||||||
|
r.updatedAt = .now
|
||||||
|
target = r
|
||||||
|
} else {
|
||||||
|
let new = CustomReminder(
|
||||||
|
title: trimmedTitle,
|
||||||
|
note: note.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
hour: hour,
|
||||||
|
minute: minute,
|
||||||
|
weekdays: sortedDays
|
||||||
|
)
|
||||||
|
ctx.insert(new)
|
||||||
|
target = new
|
||||||
|
}
|
||||||
|
try? ctx.save()
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
let state = await ReminderService.requestAuthorization()
|
||||||
|
await ReminderService.sync(target)
|
||||||
|
if state == .denied {
|
||||||
|
showAuthDeniedAlert = true
|
||||||
|
} else {
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deleteReminder() {
|
||||||
|
guard let r = reminder else { return }
|
||||||
|
ReminderService.cancel(customId: r.id)
|
||||||
|
ctx.delete(r)
|
||||||
|
try? ctx.save()
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview("新建") {
|
||||||
|
CustomReminderEditSheet()
|
||||||
|
.modelContainer(for: [CustomReminder.self], inMemory: true)
|
||||||
|
}
|
||||||
@@ -4,31 +4,50 @@ import SwiftData
|
|||||||
struct RemindersListView: View {
|
struct RemindersListView: View {
|
||||||
@Environment(\.modelContext) private var ctx
|
@Environment(\.modelContext) private var ctx
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@Query(sort: \CustomReminder.updatedAt, order: .reverse)
|
||||||
|
private var customReminders: [CustomReminder]
|
||||||
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
|
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
|
||||||
private var reminders: [MetricReminder]
|
private var reminders: [MetricReminder]
|
||||||
|
|
||||||
/// 以 sheet 形态呈现(从「新建」入口进入)时补一个「完成」按钮关闭;
|
/// 以 sheet 形态呈现(从「新建」入口进入)时补一个「完成」按钮关闭;
|
||||||
/// push 形态(我的 → 记录提醒)有系统返回,默认 false。
|
/// push 形态有系统返回,默认 false。
|
||||||
var presentedAsSheet = false
|
var presentedAsSheet = false
|
||||||
|
|
||||||
@State private var editingId: String?
|
@State private var editingId: String?
|
||||||
|
@State private var creatingNew = false
|
||||||
|
@State private var editingCustom: CustomReminder?
|
||||||
|
|
||||||
|
private var isEmpty: Bool { customReminders.isEmpty && reminders.isEmpty }
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
header
|
header
|
||||||
if reminders.isEmpty {
|
createButton
|
||||||
|
|
||||||
|
if isEmpty {
|
||||||
emptyState
|
emptyState
|
||||||
} else {
|
} else {
|
||||||
ForEach(reminders) { r in
|
ForEach(customReminders) { r in
|
||||||
ReminderRow(
|
CustomReminderRow(
|
||||||
reminder: r,
|
reminder: r,
|
||||||
isEditing: editingId == r.metricId,
|
onTapEdit: { editingCustom = r },
|
||||||
onTapEdit: { toggleEdit(r.metricId) },
|
onToggle: { Task { await syncCustom(r) } }
|
||||||
onChange: { Task { await sync(r) } },
|
|
||||||
onDelete: { delete(r) }
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !reminders.isEmpty {
|
||||||
|
sectionLabel(String(appLoc: "指标记录提醒"))
|
||||||
|
ForEach(reminders) { r in
|
||||||
|
ReminderRow(
|
||||||
|
reminder: r,
|
||||||
|
isEditing: editingId == r.metricId,
|
||||||
|
onTapEdit: { toggleEdit(r.metricId) },
|
||||||
|
onChange: { Task { await sync(r) } },
|
||||||
|
onDelete: { delete(r) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
@@ -36,40 +55,65 @@ struct RemindersListView: View {
|
|||||||
.padding(.bottom, 32)
|
.padding(.bottom, 32)
|
||||||
}
|
}
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
.navigationTitle("记录提醒")
|
.navigationTitle("提醒")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
if presentedAsSheet {
|
if presentedAsSheet {
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
Button("完成") { dismiss() }
|
Button(String(appLoc: "完成")) { dismiss() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $creatingNew) {
|
||||||
|
CustomReminderEditSheet()
|
||||||
|
}
|
||||||
|
.sheet(item: $editingCustom) { r in
|
||||||
|
CustomReminderEditSheet(reminder: r)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var header: some View {
|
private var header: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
Text("新建提醒,或在记录指标时开启")
|
||||||
Text("\(enabledCount) / \(reminders.count) 项启用")
|
.font(.system(size: 12))
|
||||||
.font(.system(size: 12))
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
Text("提醒在录入「指标记录 · 长期监测」时开启")
|
}
|
||||||
.font(.system(size: 12))
|
|
||||||
.foregroundStyle(Tj.Palette.text3)
|
private var createButton: some View {
|
||||||
|
Button { creatingNew = true } label: {
|
||||||
|
Label(String(appLoc: "新建提醒"), systemImage: "plus")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.buttonStyle(TjPrimaryButton(height: 46, fontSize: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sectionLabel(_ text: String) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.top, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var emptyState: some View {
|
private var emptyState: some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: 12) {
|
||||||
Spacer(minLength: 40)
|
Spacer(minLength: 40)
|
||||||
TjPlaceholder(label: String(appLoc: "还没有记录提醒\n去「+ 指标记录」录入时打开"))
|
TjPlaceholder(label: String(appLoc: "还没有提醒,点上方新建"))
|
||||||
.frame(width: 240, height: 140)
|
.frame(width: 240, height: 140)
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var enabledCount: Int { reminders.filter(\.enabled).count }
|
// MARK: - 自由提醒
|
||||||
|
|
||||||
|
private func syncCustom(_ r: CustomReminder) async {
|
||||||
|
r.updatedAt = .now
|
||||||
|
try? ctx.save()
|
||||||
|
await ReminderService.sync(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 指标提醒(沿用原逻辑)
|
||||||
|
|
||||||
private func toggleEdit(_ id: String) {
|
private func toggleEdit(_ id: String) {
|
||||||
editingId = (editingId == id) ? nil : id
|
editingId = (editingId == id) ? nil : id
|
||||||
@@ -88,6 +132,61 @@ struct RemindersListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 自由提醒行:点空白区进编辑 sheet;行上 Toggle 控开关。
|
||||||
|
private struct CustomReminderRow: View {
|
||||||
|
@Bindable var reminder: CustomReminder
|
||||||
|
let onTapEdit: () -> Void
|
||||||
|
let onToggle: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Button(action: onTapEdit) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2)
|
||||||
|
Image(systemName: "bell.fill")
|
||||||
|
.font(.system(size: 16))
|
||||||
|
.foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(reminder.title)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
.lineLimit(1)
|
||||||
|
Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)")
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
|
||||||
|
Toggle("", isOn: $reminder.enabled)
|
||||||
|
.labelsHidden()
|
||||||
|
.tint(Tj.Palette.ink)
|
||||||
|
.onChange(of: reminder.enabled) { _, _ in onToggle() }
|
||||||
|
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.fill(Tj.Palette.paper)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
|
||||||
|
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct ReminderRow: View {
|
private struct ReminderRow: View {
|
||||||
@Bindable var reminder: MetricReminder
|
@Bindable var reminder: MetricReminder
|
||||||
let isEditing: Bool
|
let isEditing: Bool
|
||||||
@@ -233,5 +332,5 @@ private struct ReminderRow: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
RemindersListView()
|
RemindersListView()
|
||||||
}
|
}
|
||||||
.modelContainer(for: [MetricReminder.self], inMemory: true)
|
.modelContainer(for: [MetricReminder.self, CustomReminder.self], inMemory: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11588,7 +11588,271 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"做点什么?例:跑步5公里 / 吃2片护肝片": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "What to do? e.g. Run 5 km / Take 2 pills"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "何をしますか?例:5km走る / 薬を2錠飲む"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "무엇을 하나요? 예: 5km 달리기 / 약 2알 복용"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"重复": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Repeat"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "繰り返し"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "반복"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"编辑提醒": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Edit Reminder"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "リマインダーを編集"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "알림 편집"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"新建提醒": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "New Reminder"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "新しいリマインダー"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "새 알림"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"通知未开启": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Notifications Off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "通知がオフです"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "알림이 꺼져 있음"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"好": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "OK"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "OK"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "확인"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"提醒已保存,但系统通知权限未开启,到点不会弹出。请在「设置 · 通知 · 康康」中允许。": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "The reminder is saved, but notifications are off so it won't alert you. Allow them in Settings · Notifications · Kangkang."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "リマインダーは保存されましたが、通知が許可されていないため表示されません。「設定 · 通知 · 康康」で許可してください。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "알림이 저장되었지만 시스템 알림 권한이 꺼져 있어 표시되지 않습니다. '설정 · 알림 · 康康'에서 허용하세요."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"提醒": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Reminders"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "リマインダー"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "알림"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"指标记录提醒": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Metric Reminders"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "指標リマインダー"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "지표 알림"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"新建提醒,或在记录指标时开启": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Create a reminder, or enable one when logging a metric."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "リマインダーを作成、または指標の記録時に有効化。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "알림을 만들거나 지표 기록 시 설정하세요."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"还没有提醒,点上方新建": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "No reminders yet. Tap + above to add one."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "リマインダーはまだありません。上の+で追加。"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "아직 알림이 없습니다. 위의 +로 추가하세요."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"到点啦,记得完成": {
|
||||||
|
"localizations": {
|
||||||
|
"en": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "Time's up — don't forget!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "時間です。お忘れなく!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko": {
|
||||||
|
"stringUnit": {
|
||||||
|
"state": "translated",
|
||||||
|
"value": "시간이 되었어요. 잊지 마세요!"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"version": "1.0"
|
"version": "1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,6 +263,58 @@ final class MetricReminder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 自由文案的周期性提醒(如「每天 20:00 跑步 5 公里」「每天 12:30 吃 2 片护肝片」)。
|
||||||
|
/// 与 `MetricReminder`(去记录某指标)语义独立:这里是用户自定义的动作提醒,
|
||||||
|
/// 量词(5 公里 / 2 片)直接写在 `title` 自由文本里。
|
||||||
|
/// 周期粒度沿用 weekday 约定(全 7 = 每天);本地通知调度见 `ReminderService`。
|
||||||
|
@Model
|
||||||
|
final class CustomReminder {
|
||||||
|
@Attribute(.unique) var id: UUID
|
||||||
|
var title: String // 用户文案,如 "跑步5公里"
|
||||||
|
var note: String // 可选备注 → 通知正文
|
||||||
|
var hour: Int // 0...23
|
||||||
|
var minute: Int // 0...59
|
||||||
|
var weekdays: [Int] // iOS Calendar 约定:1=日, 2=一, ..., 7=六。全 7 个 = 每天
|
||||||
|
var enabled: Bool
|
||||||
|
var createdAt: Date
|
||||||
|
var updatedAt: Date
|
||||||
|
|
||||||
|
init(id: UUID = UUID(),
|
||||||
|
title: String,
|
||||||
|
note: String = "",
|
||||||
|
hour: Int = 8,
|
||||||
|
minute: Int = 0,
|
||||||
|
weekdays: [Int] = [1, 2, 3, 4, 5, 6, 7],
|
||||||
|
enabled: Bool = true,
|
||||||
|
createdAt: Date = .now) {
|
||||||
|
self.id = id
|
||||||
|
self.title = title
|
||||||
|
self.note = note
|
||||||
|
self.hour = max(0, min(23, hour))
|
||||||
|
self.minute = max(0, min(59, minute))
|
||||||
|
self.weekdays = weekdays
|
||||||
|
self.enabled = enabled
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.updatedAt = createdAt
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEveryDay: Bool { Set(weekdays) == Set(1...7) }
|
||||||
|
|
||||||
|
/// 与 MetricReminder.frequencyLabel 同款,复用同一批本地化 key。
|
||||||
|
var frequencyLabel: String {
|
||||||
|
if !enabled { return String(appLoc: "已关闭") }
|
||||||
|
if isEveryDay { return String(appLoc: "每天") }
|
||||||
|
if weekdays.isEmpty { return String(appLoc: "未选日") }
|
||||||
|
let names = [String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六")]
|
||||||
|
let sorted = weekdays.sorted()
|
||||||
|
return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
var timeLabel: String {
|
||||||
|
String(format: "%02d:%02d", hour, minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Model
|
@Model
|
||||||
final class ChatTurn {
|
final class ChatTurn {
|
||||||
var question: String
|
var question: String
|
||||||
|
|||||||
@@ -93,8 +93,9 @@ final class ModelDownloadService {
|
|||||||
to: destination,
|
to: destination,
|
||||||
expectedBytes: file.bytes,
|
expectedBytes: file.bytes,
|
||||||
onProgress: { [weak self] received in
|
onProgress: { [weak self] received in
|
||||||
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self?.applyProgress(kind, currentTotal: base + received)
|
self.applyProgress(kind, currentTotal: base + received)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import UserNotifications
|
|||||||
enum ReminderService {
|
enum ReminderService {
|
||||||
|
|
||||||
static let idPrefix = "kangkang.reminder."
|
static let idPrefix = "kangkang.reminder."
|
||||||
|
static let customIdPrefix = "kangkang.custom."
|
||||||
|
|
||||||
enum AuthState: String {
|
enum AuthState: String {
|
||||||
case granted, denied, notDetermined, provisional
|
case granted, denied, notDetermined, provisional
|
||||||
@@ -51,34 +52,45 @@ enum ReminderService {
|
|||||||
/// 调用方在 `MetricReminder` save 之后调用。
|
/// 调用方在 `MetricReminder` save 之后调用。
|
||||||
static func sync(_ reminder: MetricReminder) async {
|
static func sync(_ reminder: MetricReminder) async {
|
||||||
cancel(metricId: reminder.metricId)
|
cancel(metricId: reminder.metricId)
|
||||||
guard reminder.enabled, !reminder.weekdays.isEmpty else { return }
|
guard reminder.enabled else { return }
|
||||||
|
await schedule(
|
||||||
let center = UNUserNotificationCenter.current()
|
idBase: "\(idPrefix)\(reminder.metricId)",
|
||||||
let content = UNMutableNotificationContent()
|
title: String(appLoc: "该测\(reminder.displayName)了"),
|
||||||
content.title = String(appLoc: "该测\(reminder.displayName)了")
|
body: String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"),
|
||||||
content.body = String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次")
|
hour: reminder.hour,
|
||||||
content.sound = .default
|
minute: reminder.minute,
|
||||||
content.threadIdentifier = "kangkang.reminder.\(reminder.metricId)"
|
weekdays: reminder.weekdays,
|
||||||
|
thread: "kangkang.reminder.\(reminder.metricId)"
|
||||||
for weekday in reminder.weekdays {
|
)
|
||||||
var comps = DateComponents()
|
|
||||||
comps.hour = reminder.hour
|
|
||||||
comps.minute = reminder.minute
|
|
||||||
comps.weekday = weekday
|
|
||||||
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
|
|
||||||
let id = identifier(metricId: reminder.metricId, weekday: weekday)
|
|
||||||
let request = UNNotificationRequest(identifier: id,
|
|
||||||
content: content,
|
|
||||||
trigger: trigger)
|
|
||||||
try? await center.add(request)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 取消某个 metric 的所有 pending 通知(7 个 weekday 一并取消,不漏)。
|
/// 取消某个 metric 的所有 pending 通知(7 个 weekday 一并取消,不漏)。
|
||||||
static func cancel(metricId: String) {
|
static func cancel(metricId: String) {
|
||||||
let center = UNUserNotificationCenter.current()
|
cancelBase("\(idPrefix)\(metricId)")
|
||||||
let ids = (1...7).map { identifier(metricId: metricId, weekday: $0) }
|
}
|
||||||
center.removePendingNotificationRequests(withIdentifiers: ids)
|
|
||||||
|
// MARK: - 自由提醒(CustomReminder)
|
||||||
|
|
||||||
|
/// 取消并按当前设置重排一条自由提醒。调用方在 `CustomReminder` save 之后调用。
|
||||||
|
static func sync(_ reminder: CustomReminder) async {
|
||||||
|
cancel(customId: reminder.id)
|
||||||
|
guard reminder.enabled else { return }
|
||||||
|
let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
await schedule(
|
||||||
|
idBase: "\(customIdPrefix)\(reminder.id.uuidString)",
|
||||||
|
title: title.isEmpty ? String(appLoc: "提醒") : title,
|
||||||
|
body: body.isEmpty ? String(appLoc: "到点啦,记得完成") : body,
|
||||||
|
hour: reminder.hour,
|
||||||
|
minute: reminder.minute,
|
||||||
|
weekdays: reminder.weekdays,
|
||||||
|
thread: "\(customIdPrefix)\(reminder.id.uuidString)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消某条自由提醒的所有 pending 通知。
|
||||||
|
static func cancel(customId: UUID) {
|
||||||
|
cancelBase("\(customIdPrefix)\(customId.uuidString)")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 全清。Me Tab 一键关闭所有提醒时用。
|
/// 全清。Me Tab 一键关闭所有提醒时用。
|
||||||
@@ -86,9 +98,42 @@ enum ReminderService {
|
|||||||
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - helpers
|
// MARK: - 共享调度核心
|
||||||
|
|
||||||
private static func identifier(metricId: String, weekday: Int) -> String {
|
/// 把一条提醒按 weekdays 展开成 N 条 weekly-repeats 通知。
|
||||||
"\(idPrefix)\(metricId).w\(weekday)"
|
/// `idBase` 是不含 `.w<weekday>` 后缀的稳定前缀;两类提醒共用本核心。
|
||||||
|
private static func schedule(idBase: String,
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
hour: Int,
|
||||||
|
minute: Int,
|
||||||
|
weekdays: [Int],
|
||||||
|
thread: String) async {
|
||||||
|
guard !weekdays.isEmpty else { return }
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let content = UNMutableNotificationContent()
|
||||||
|
content.title = title
|
||||||
|
content.body = body
|
||||||
|
content.sound = .default
|
||||||
|
content.threadIdentifier = thread
|
||||||
|
|
||||||
|
for weekday in weekdays {
|
||||||
|
var comps = DateComponents()
|
||||||
|
comps.hour = hour
|
||||||
|
comps.minute = minute
|
||||||
|
comps.weekday = weekday
|
||||||
|
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
|
||||||
|
let request = UNNotificationRequest(identifier: "\(idBase).w\(weekday)",
|
||||||
|
content: content,
|
||||||
|
trigger: trigger)
|
||||||
|
try? await center.add(request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消某个 idBase 下 7 个 weekday 的全部 pending 通知(不漏)。
|
||||||
|
private static func cancelBase(_ idBase: String) {
|
||||||
|
let center = UNUserNotificationCenter.current()
|
||||||
|
let ids = (1...7).map { "\(idBase).w\($0)" }
|
||||||
|
center.removePendingNotificationRequests(withIdentifiers: ids)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user