# 自由周期提醒(CustomReminder)— 设计文档

**日期**:2026-05-30(W2)
**作者**:link2026 + Claude
**关联卖点**:#4 隐私三件套之外的实用粘性功能(本地通知,无云)
**优先级**:用户明确要求(注:§10.6「用药提醒」原列默认不做,本轮经讨论确认要做,按最小可用实现)

---

## 1. 一句话定位

让用户新建**自由文案的周期性本地提醒**(如「每天 20:00 跑步 5 公里」「每天 12:30 吃 2 片护肝片」),与现有「指标记录提醒」(去录某项指标)并存但相互独立。完全本地 `UserNotifications`,不引云。

---

## 2. 已确认的设计决策

| 决策点 | 选择 |
|---|---|
| 模型 | 新建独立 `CustomReminder` @Model,不动现有 `MetricReminder` |
| 周期粒度 | **每日 / 每周选几天 / 每月某日 / 每年某月某日**(2026-05-30 用户反转原「不做按月/按年」决策)。仍不做「每 N 天间隔」/一次性 |
| 时间选择 | 常用时间快捷预设(8:00/12:00/18:00/22:00 chip)+ 保留 `DatePicker` 精调 |
| 入口 | 新建 → 开启一个提醒 → `RemindersListView`(提醒中心),顶部「+ 新建提醒」打开编辑 sheet |
| 列表范围 | 自由提醒 + 指标提醒**合展**(上次删了「我的」入口,指标提醒也只能从这里管) |
| 量词(5公里/2片) | 写在自由文本 `title` 里,不单设字段 |
| 多语言 | 所有固定文案走 `String(appLoc:)`,新增中文 key 补 en/ja/ko 到 `Localizable.xcstrings` |

---

## 3. 数据模型

`Models/Models.swift` 新增:

```swift
@Model final class CustomReminder {
    enum Frequency: String { case daily, weekly, monthly, yearly }   // 嵌套枚举
    @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=六,仅 weekly 用(复用 MetricReminder 约定)
    var frequencyRaw: String = "daily"   // Frequency 原始值(内联默认 → 走轻量迁移)
    var dayOfMonth: Int = 1              // monthly / yearly 用,1...31
    var month: Int = 1                   // yearly 用,1...12
    var enabled: Bool
    var createdAt: Date
    var updatedAt: Date
    // computed: frequency(get/set 包 frequencyRaw)/ isEveryDay / frequencyLabel(分档)/ timeLabel
}
```

Schema 已含 `CustomReminder.self`。**本轮只给已存在的 `CustomReminder` 加 3 个带内联默认值的属性 → SwiftData 自动轻量迁移,不触发删库兜底(见 §10)。**

四档语义 → iOS `UNCalendarNotificationTrigger(repeats:true)`:
| 频率 | DateComponents | 通知数 | id 后缀 |
|---|---|---|---|
| daily | hour,minute | 1 | `.daily` |
| weekly | hour,minute,weekday ×N | N | `.w<weekday>` |
| monthly | day,hour,minute | 1 | `.monthly` |
| yearly | month,day,hour,minute | 1 | `.yearly` |

边界:iOS 重复触发**不顺延**。monthly 选 29/30/31 → 无此日的月份跳过(UI 给浅色提示);yearly 的「日」选项按所选月份最大天数动态收口(避免「4月31日」永不触发),仅闰年 2/29 给提示。

---

## 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` +`Frequency` 枚举 +`frequencyRaw/dayOfMonth/month`(均带内联默认)+ 分档 `frequencyLabel` |
| `App/KangkangApp.swift` | **持久化兜底改造**:迁移失败时由「删库」改为「挪到 `StoreBackups/<时间戳>/` 再重建」(见 §10) |
| `Services/ReminderService.swift` | 调度核心泛化为 `Slot(suffix,DateComponents)` 列表;custom sync 按 frequency 分档;`cancelBase` 覆盖 daily/monthly/yearly/w1-7 |
| `Features/Me/CustomReminderEditSheet.swift` | 频率分段 Picker + 各档子控件(周几 / 日 / 月+日)+ 时间快捷预设行 |
| `Features/Me/RemindersListView.swift` | 不变(`frequencyLabel` 来自模型) |
| `Localizable.xcstrings` | 新增 11 个 key × en/ja/ko |

---

## 8. 红线对齐

- 不引云、不碰密码学(纯本地通知)✅
- 不重构 Tab/RecordSheet 骨架 ✅
- §10.6「用药提醒默认不做」→ 已讨论确认,最小实现(无贪睡/铃声/间隔)✅

---

## 9. 验收(真机)

① 新建「每天 20:00 跑步 5 公里」→ 列表出现 → 到点收到本地通知(标题=跑步5公里);② 改时间/周几即时重排;③ 关闭 Toggle 取消通知;④ 删除清除 pending;⑤ 切换语言后固定文案随之变化(用户输入文案不变);⑥ 指标提醒仍在同一列表可管;⑦ **每月/每年**:切频率后子控件随之变化,边界提示出现;改频率后旧档 pending 通知被清掉(不留孤儿);⑧ **时间预设**:点 8:00/12:00/18:00/22:00 即填,精调仍可用。

---

## 10. 顺带修复:重打包数据丢失(根因 + 方案)

**问题**:Demo 期每次改 schema 重打包,SwiftData 数据被清空。

**根因(单点)**:`App/KangkangApp.swift` 的 `ModelContainer` 创建 catch 块**直接删 store 文件**。SwiftData 只对**纯增量**改动自动轻量迁移;一旦某次改动超纲(最常见:给已存在的 `@Model` 新增「非可选且无内联默认值」的属性),自动迁移抛错 → 落入 catch → 删库。W2 几乎每次都在改 schema,故体感「每次都丢」。

**方案(两层)**:
1. **治本**:新增 `@Model` 属性一律「可选」或「内联默认值」(本轮 3 个新字段都给了 `= "daily"` / `= 1`)→ 走轻量迁移、不进 catch、数据保留。
2. **兜底**:catch 不再删库,改为把旧 store(含 `-wal`/`-shm`)**挪到 `Application Support/StoreBackups/<时间戳>/`** 再重建——App 仍能启动,旧数据可手动恢复;挪不动才降级删除。

⚠️ 正式发布前仍应升级为 `VersionedSchema` + `SchemaMigrationPlan` 的正式迁移(注释已就地标注)。
