From dad9d434862f86d7571cae3ae64eb26be904f181 Mon Sep 17 00:00:00 2001 From: link2026 Date: Sat, 30 May 2026 11:36:29 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat:=20=E6=B7=BB=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=8F=90=E9=86=92=E5=8A=9F=E8=83=BD=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A1=B9=E7=9B=AE=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 CustomReminder 模型支持自由文案周期性提醒功能 - 实现自定义提醒的 UI 界面,包括新建、编辑和列表展示 - 集成本地通知服务支持自定义提醒的时间触发 - 更新项目配置文件添加应用显示名称和加密声明 - 修正 iOS 部署目标版本从 26.0 到 17.0 - 修复 FileDownloader 中的线程安全问题 - 优化 ModelManifest 和 Localization 的并发安全性 - 扩展本地化字符串支持多语言提醒相关文本 - 调整项目支持平台范围仅保留 iphoneos 和 iphonesimulator ``` --- .../2026-05-30-custom-reminder-design.md | 117 ++++++++ 康康.xcodeproj/project.pbxproj | 52 ++-- 康康/AI/FileDownloader.swift | 12 +- 康康/AI/ModelManifest.swift | 2 +- 康康/App/KangkangApp.swift | 1 + 康康/App/Localization.swift | 14 +- .../Features/Me/CustomReminderEditSheet.swift | 186 ++++++++++++ 康康/Features/Me/RemindersListView.swift | 141 ++++++++-- 康康/Localizable.xcstrings | 266 +++++++++++++++++- 康康/Models/Models.swift | 52 ++++ 康康/Services/ModelDownloadService.swift | 3 +- 康康/Services/ReminderService.swift | 99 +++++-- 12 files changed, 861 insertions(+), 84 deletions(-) create mode 100644 docs/superpowers/specs/2026-05-30-custom-reminder-design.md create mode 100644 康康/Features/Me/CustomReminderEditSheet.swift diff --git a/docs/superpowers/specs/2026-05-30-custom-reminder-design.md b/docs/superpowers/specs/2026-05-30-custom-reminder-design.md new file mode 100644 index 0000000..dd45691 --- /dev/null +++ b/docs/superpowers/specs/2026-05-30-custom-reminder-design.md @@ -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..w`(与指标的 `kangkang.reminder..w` 不冲突)。 +- 保存时调 `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;⑤ 切换语言后固定文案随之变化(用户输入文案不变);⑥ 指标提醒仍在同一列表可管。 diff --git a/康康.xcodeproj/project.pbxproj b/康康.xcodeproj/project.pbxproj index 50cbe91..1411404 100644 --- a/康康.xcodeproj/project.pbxproj +++ b/康康.xcodeproj/project.pbxproj @@ -416,6 +416,8 @@ ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "康康"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。"; INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。"; @@ -430,24 +432,24 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown 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[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--"; + PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 26.0; }; name = Debug; @@ -465,6 +467,8 @@ ENABLE_PREVIEWS = YES; ENABLE_USER_SELECTED_FILES = readonly; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "康康"; + INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_NSCameraUsageDescription = "康康需要使用相机来扫描你的体检/化验报告。识别全程在本地完成,图片不会上传。"; INFOPLIST_KEY_NSFaceIDUsageDescription = "用于解锁你的健康档案,数据始终保留在本机。"; INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "康康会把识别后的报告原图加密保存到 App 沙盒,不会写入你的相册。"; @@ -479,24 +483,24 @@ "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown 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[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--"; + PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = YES; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; XROS_DEPLOYMENT_TARGET = 26.0; }; name = Release; @@ -509,20 +513,20 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = F2C8C774FG; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--Tests"; + PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = NO; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; 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)/康康"; XROS_DEPLOYMENT_TARGET = 26.0; }; @@ -536,20 +540,20 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = F2C8C774FG; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--Tests"; + PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.Tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = NO; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; 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)/康康"; XROS_DEPLOYMENT_TARGET = 26.0; }; @@ -562,20 +566,20 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = F2C8C774FG; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--UITests"; + PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = NO; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = "康康"; XROS_DEPLOYMENT_TARGET = 26.0; }; @@ -588,20 +592,20 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = F2C8C774FG; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 26.0; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; MACOSX_DEPLOYMENT_TARGET = 26.0; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = "kangkang.--UITests"; + PRODUCT_BUNDLE_IDENTIFIER = com.xuhuayong.kangkang.UITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; STRING_CATALOG_GENERATE_SYMBOLS = NO; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = "康康"; XROS_DEPLOYMENT_TARGET = 26.0; }; diff --git a/康康/AI/FileDownloader.swift b/康康/AI/FileDownloader.swift index cc1e4e7..5c4a514 100644 --- a/康康/AI/FileDownloader.swift +++ b/康康/AI/FileDownloader.swift @@ -74,12 +74,12 @@ final class FileDownloader: NSObject, URLSessionDataDelegate, @unchecked Sendabl let fileHandle = try FileHandle(forWritingTo: part) try fileHandle.seekToEnd() - lock.lock() - self.handle = fileHandle - self.written = offset - self.onProgress = onProgress - self.responseError = nil - lock.unlock() + lock.withLock { + self.handle = fileHandle + self.written = offset + self.onProgress = onProgress + self.responseError = nil + } var request = URLRequest(url: url) if offset > 0 { diff --git a/康康/AI/ModelManifest.swift b/康康/AI/ModelManifest.swift index b651856..105d525 100644 --- a/康康/AI/ModelManifest.swift +++ b/康康/AI/ModelManifest.swift @@ -10,7 +10,7 @@ struct ModelFile: Equatable, Sendable { /// 只列加载必需的功能文件,排除 README.md / .gitattributes(省下载)。 /// 字节数与服务器素材逐一核对一致,见 /// docs/superpowers/specs/2026-05-29-model-download-design.md 附录 A。 -enum ModelManifest { +nonisolated enum ModelManifest { /// 自建 Caddy 静态服务(用户自建 HTTPS 反代)。 /// 备选纯 IP(需 App 端 ATS 例外): http://101.132.124.52:5244/ static let baseURL = URL(string: "https://file.myv0.com/")! diff --git a/康康/App/KangkangApp.swift b/康康/App/KangkangApp.swift index f744b37..8ffc830 100644 --- a/康康/App/KangkangApp.swift +++ b/康康/App/KangkangApp.swift @@ -17,6 +17,7 @@ struct KangkangApp: App { MetricReminder.self, CustomMonitorMetric.self, HealthExport.self, + CustomReminder.self, ]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) do { diff --git a/康康/App/Localization.swift b/康康/App/Localization.swift index 8394768..6e3e22b 100644 --- a/康康/App/Localization.swift +++ b/康康/App/Localization.swift @@ -80,16 +80,24 @@ final class LanguageManager { lprojBundle = .main } 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 { /// 尊重「我的 · 语言」选择的本地化(可即时切换)。 /// 等价 `String(localized:)`,但显式绑定当前所选语言的 bundle + locale, /// 因此不受 `Locale.current`(系统/启动时语言)限制。 - init(appLoc key: String.LocalizationValue) { - let m = LanguageManager.shared - self = String(localized: key, bundle: m.lprojBundle, locale: m.resolvedLocale) + nonisolated init(appLoc key: String.LocalizationValue) { + self = String(localized: key, bundle: appLocBundle, locale: appLocLocale) } } diff --git a/康康/Features/Me/CustomReminderEditSheet.swift b/康康/Features/Me/CustomReminderEditSheet.swift new file mode 100644 index 0000000..9f767fe --- /dev/null +++ b/康康/Features/Me/CustomReminderEditSheet.swift @@ -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 = 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) +} diff --git a/康康/Features/Me/RemindersListView.swift b/康康/Features/Me/RemindersListView.swift index 1fa9a2a..9cc986c 100644 --- a/康康/Features/Me/RemindersListView.swift +++ b/康康/Features/Me/RemindersListView.swift @@ -4,31 +4,50 @@ import SwiftData struct RemindersListView: View { @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss + @Query(sort: \CustomReminder.updatedAt, order: .reverse) + private var customReminders: [CustomReminder] @Query(sort: \MetricReminder.updatedAt, order: .reverse) private var reminders: [MetricReminder] /// 以 sheet 形态呈现(从「新建」入口进入)时补一个「完成」按钮关闭; - /// push 形态(我的 → 记录提醒)有系统返回,默认 false。 + /// push 形态有系统返回,默认 false。 var presentedAsSheet = false @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 { ScrollView { VStack(alignment: .leading, spacing: 12) { header - if reminders.isEmpty { + createButton + + if isEmpty { emptyState } else { - ForEach(reminders) { r in - ReminderRow( + ForEach(customReminders) { r in + CustomReminderRow( reminder: r, - isEditing: editingId == r.metricId, - onTapEdit: { toggleEdit(r.metricId) }, - onChange: { Task { await sync(r) } }, - onDelete: { delete(r) } + onTapEdit: { editingCustom = r }, + onToggle: { Task { await syncCustom(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) @@ -36,40 +55,65 @@ struct RemindersListView: View { .padding(.bottom, 32) } .background(Tj.Palette.sand.ignoresSafeArea()) - .navigationTitle("记录提醒") + .navigationTitle("提醒") .navigationBarTitleDisplayMode(.inline) .toolbar { if presentedAsSheet { 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 { - VStack(alignment: .leading, spacing: 4) { - Text("\(enabledCount) / \(reminders.count) 项启用") - .font(.system(size: 12)) - .foregroundStyle(Tj.Palette.text3) - Text("提醒在录入「指标记录 · 长期监测」时开启") - .font(.system(size: 12)) - .foregroundStyle(Tj.Palette.text3) + Text("新建提醒,或在记录指标时开启") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + .frame(maxWidth: .infinity, alignment: .leading) + } + + 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 { VStack(spacing: 12) { Spacer(minLength: 40) - TjPlaceholder(label: String(appLoc: "还没有记录提醒\n去「+ 指标记录」录入时打开")) + TjPlaceholder(label: String(appLoc: "还没有提醒,点上方新建")) .frame(width: 240, height: 140) Spacer() } .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) { 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 { @Bindable var reminder: MetricReminder let isEditing: Bool @@ -233,5 +332,5 @@ private struct ReminderRow: View { NavigationStack { RemindersListView() } - .modelContainer(for: [MetricReminder.self], inMemory: true) + .modelContainer(for: [MetricReminder.self, CustomReminder.self], inMemory: true) } diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index 33c21f4..6316470 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -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" -} \ No newline at end of file +} diff --git a/康康/Models/Models.swift b/康康/Models/Models.swift index 475032e..88eef9f 100644 --- a/康康/Models/Models.swift +++ b/康康/Models/Models.swift @@ -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 final class ChatTurn { var question: String diff --git a/康康/Services/ModelDownloadService.swift b/康康/Services/ModelDownloadService.swift index b56f872..260a219 100644 --- a/康康/Services/ModelDownloadService.swift +++ b/康康/Services/ModelDownloadService.swift @@ -93,8 +93,9 @@ final class ModelDownloadService { to: destination, expectedBytes: file.bytes, onProgress: { [weak self] received in + guard let self else { return } Task { @MainActor in - self?.applyProgress(kind, currentTotal: base + received) + self.applyProgress(kind, currentTotal: base + received) } } ) diff --git a/康康/Services/ReminderService.swift b/康康/Services/ReminderService.swift index a5d03a9..17da89d 100644 --- a/康康/Services/ReminderService.swift +++ b/康康/Services/ReminderService.swift @@ -10,6 +10,7 @@ import UserNotifications enum ReminderService { static let idPrefix = "kangkang.reminder." + static let customIdPrefix = "kangkang.custom." enum AuthState: String { case granted, denied, notDetermined, provisional @@ -51,34 +52,45 @@ enum ReminderService { /// 调用方在 `MetricReminder` save 之后调用。 static func sync(_ reminder: MetricReminder) async { cancel(metricId: reminder.metricId) - guard reminder.enabled, !reminder.weekdays.isEmpty else { return } - - let center = UNUserNotificationCenter.current() - let content = UNMutableNotificationContent() - content.title = String(appLoc: "该测\(reminder.displayName)了") - content.body = String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次") - content.sound = .default - content.threadIdentifier = "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) - } + guard reminder.enabled else { return } + await schedule( + idBase: "\(idPrefix)\(reminder.metricId)", + title: String(appLoc: "该测\(reminder.displayName)了"), + body: String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"), + hour: reminder.hour, + minute: reminder.minute, + weekdays: reminder.weekdays, + thread: "kangkang.reminder.\(reminder.metricId)" + ) } /// 取消某个 metric 的所有 pending 通知(7 个 weekday 一并取消,不漏)。 static func cancel(metricId: String) { - let center = UNUserNotificationCenter.current() - let ids = (1...7).map { identifier(metricId: metricId, weekday: $0) } - center.removePendingNotificationRequests(withIdentifiers: ids) + cancelBase("\(idPrefix)\(metricId)") + } + + // 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 一键关闭所有提醒时用。 @@ -86,9 +98,42 @@ enum ReminderService { UNUserNotificationCenter.current().removeAllPendingNotificationRequests() } - // MARK: - helpers + // MARK: - 共享调度核心 - private static func identifier(metricId: String, weekday: Int) -> String { - "\(idPrefix)\(metricId).w\(weekday)" + /// 把一条提醒按 weekdays 展开成 N 条 weekly-repeats 通知。 + /// `idBase` 是不含 `.w` 后缀的稳定前缀;两类提醒共用本核心。 + 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) } }