feat: 添加自定义提醒功能并优化项目配置

- 添加 CustomReminder 模型支持自由文案周期性提醒功能
- 实现自定义提醒的 UI 界面,包括新建、编辑和列表展示
- 集成本地通知服务支持自定义提醒的时间触发
- 更新项目配置文件添加应用显示名称和加密声明
- 修正 iOS 部署目标版本从 26.0 到 17.0
- 修复 FileDownloader 中的线程安全问题
- 优化 ModelManifest 和 Localization 的并发安全性
- 扩展本地化字符串支持多语言提醒相关文本
- 调整项目支持平台范围仅保留 iphoneos 和 iphonesimulator
```
This commit is contained in:
link2026
2026-05-30 11:36:29 +08:00
parent d2c77d5c51
commit dad9d43486
12 changed files with 861 additions and 84 deletions

View 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;⑤ 切换语言后固定文案随之变化(用户输入文案不变);⑥ 指标提醒仍在同一列表可管。

View File

@@ -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;
}; };

View File

@@ -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 {

View File

@@ -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/")!

View File

@@ -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 {

View File

@@ -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.errorDescriptionnonisolated labelstatic )
/// `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)
} }
} }

View 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)
}

View File

@@ -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)
} }

View File

@@ -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"
} }

View File

@@ -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

View File

@@ -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)
} }
} }
) )

View File

@@ -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)
} }
} }