```
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_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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/")!
|
||||
|
||||
@@ -17,6 +17,7 @@ struct KangkangApp: App {
|
||||
MetricReminder.self,
|
||||
CustomMonitorMetric.self,
|
||||
HealthExport.self,
|
||||
CustomReminder.self,
|
||||
])
|
||||
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
|
||||
do {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
@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)
|
||||
}
|
||||
|
||||
@@ -11588,6 +11588,270 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"做点什么?例:跑步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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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<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