docs(health-profile): 添加防编造加固修订记录到导出健康档案设计文档

补充了关于导出摘要出现虚构病例问题的详细分析和修复方案,
包括检索策略优化、空数据兜底处理和prompt重写等三层防护措施。
```
This commit is contained in:
link2026
2026-05-30 20:06:12 +08:00
parent dad9d43486
commit 7ad41c5f09
26 changed files with 9062 additions and 7697 deletions

View File

@@ -413,3 +413,18 @@ Output:
| **合计** | **~14h ≈ 2 个工作日** | | **合计** | **~14h ≈ 2 个工作日** |
也是 W3「AskService 基础 RAG」的前置铺路工作,工程上一举两得。 也是 W3「AskService 基础 RAG」的前置铺路工作,工程上一举两得。
---
## 14. 修订记录:防编造加固(2026-05-30)
**现象**:导出摘要出现整份虚构病例(疲劳/盗汗/血红蛋白98/阿司匹林…),不符任何真实记录。
**根因(双重)**:① §数据范围里「Diary 由关键词过滤后入 prompt」在泛化请求(无症状词,如「最近身体异常」)下把日记**全部清空** → 真实记录没进 prompt;② 数据稀疏时,1.7B 在固定 6 段模板上**凭训练先验脑补**完整病例(对「只用数据/缺失写无记录」这类约束遵循差)。
**修复(三层,客户端硬保证为主)**:
1. **检索**:`retrieve` 改为——有症状词→按词过滤(保留隐私);无症状词→纳入时间窗内最近 5 条日记,确保真实记录进 prompt。
2. **空数据硬兜底**:`isEffectivelyEmpty` 判定无任何记录且 profile 空时,**跳过 LLM**,用 `fallbackReport` 产出确定性「6 段全无记录、主诉仅照搬原话」的摘要,从根上杜绝空数据编造。
3. **prompt 重写**:从「撰写」改为「抽取/搬运」框架;反编造铁律首尾各一遍;加一条**稀疏 few-shot** 教模型「缺失写无记录、数值原样照搬」。
**残留限制**:部分数据(如仅 1 条日记)仍走 LLM,强约束 + few-shot 大幅降低但不能 100% 杜绝小模型臆造;后续可加生成后数值校验。

View File

@@ -18,7 +18,8 @@
| 决策点 | 选择 | | 决策点 | 选择 |
|---|---| |---|---|
| 模型 | 新建独立 `CustomReminder` @Model,不动现有 `MetricReminder` | | 模型 | 新建独立 `CustomReminder` @Model,不动现有 `MetricReminder` |
| 周期粒度 | 每天 / 每周选几天(复用 weekday 约定,覆盖示例)。不做间隔/按月/一次性 | | 周期粒度 | **每日 / 每周选几天 / 每月某日 / 每年某月某日**(2026-05-30 用户反转原「不做按月/按年」决策)。不做「每 N 天间隔」/一次性 |
| 时间选择 | 常用时间快捷预设(8:00/12:00/18:00/22:00 chip)+ 保留 `DatePicker` 精调 |
| 入口 | 新建 → 开启一个提醒 → `RemindersListView`(提醒中心),顶部「+ 新建提醒」打开编辑 sheet | | 入口 | 新建 → 开启一个提醒 → `RemindersListView`(提醒中心),顶部「+ 新建提醒」打开编辑 sheet |
| 列表范围 | 自由提醒 + 指标提醒**合展**(上次删了「我的」入口,指标提醒也只能从这里管) | | 列表范围 | 自由提醒 + 指标提醒**合展**(上次删了「我的」入口,指标提醒也只能从这里管) |
| 量词(5公里/2片) | 写在自由文本 `title` 里,不单设字段 | | 量词(5公里/2片) | 写在自由文本 `title` 里,不单设字段 |
@@ -32,20 +33,34 @@
```swift ```swift
@Model final class CustomReminder { @Model final class CustomReminder {
enum Frequency: String { case daily, weekly, monthly, yearly } //
@Attribute(.unique) var id: UUID @Attribute(.unique) var id: UUID
var title: String // :"5" var title: String // :"5"
var note: String // var note: String //
var hour: Int // 0...23 var hour: Int // 0...23
var minute: Int // 0...59 var minute: Int // 0...59
var weekdays: [Int] // 1=7=, 7 = ( MetricReminder ) 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 enabled: Bool
var createdAt: Date var createdAt: Date
var updatedAt: Date var updatedAt: Date
// computed: isEveryDay / frequencyLabel / timeLabel( MetricReminder , key) // computed: frequency(get/set frequencyRaw)/ isEveryDay / frequencyLabel()/ timeLabel
} }
``` ```
Schema 注册:`App/KangkangApp.swift``CustomReminder.self`(additive 变更,无需迁移)。 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 给提示。
--- ---
@@ -95,12 +110,12 @@ static func sync(_ metric: MetricReminder) async // 现有,内部改走共享
| 文件 | 改动 | | 文件 | 改动 |
|---|---| |---|---|
| `Models/Models.swift` | +`CustomReminder` | | `Models/Models.swift` | `CustomReminder` +`Frequency` 枚举 +`frequencyRaw/dayOfMonth/month`(均带内联默认)+ 分档 `frequencyLabel` |
| `App/KangkangApp.swift` | schema +`CustomReminder.self` | | `App/KangkangApp.swift` | **持久化兜底改造**:迁移失败时由「删库」改为「挪到 `StoreBackups/<时间戳>/` 再重建」(见 §10) |
| `Services/ReminderService.swift` | 泛化共享核心 + custom sync/cancel | | `Services/ReminderService.swift` | 调度核心泛化为 `Slot(suffix,DateComponents)` 列表;custom sync 按 frequency 分档;`cancelBase` 覆盖 daily/monthly/yearly/w1-7 |
| `Features/Me/CustomReminderEditSheet.swift` | **新增** 编辑表单 | | `Features/Me/CustomReminderEditSheet.swift` | 频率分段 Picker + 各档子控件(周几 / 日 / 月+日)+ 时间快捷预设行 |
| `Features/Me/RemindersListView.swift` | 提醒中心:新建按钮 + 合展两类 | | `Features/Me/RemindersListView.swift` | 不变(`frequencyLabel` 来自模型) |
| `Localizable.xcstrings` | 新增文案四语言 | | `Localizable.xcstrings` | 新增 11 个 key × en/ja/ko |
--- ---
@@ -114,4 +129,18 @@ static func sync(_ metric: MetricReminder) async // 现有,内部改走共享
## 9. 验收(真机) ## 9. 验收(真机)
① 新建「每天 20:00 跑步 5 公里」→ 列表出现 → 到点收到本地通知(标题=跑步5公里);② 改时间/周几即时重排;③ 关闭 Toggle 取消通知;④ 删除清除 pending;⑤ 切换语言后固定文案随之变化(用户输入文案不变);⑥ 指标提醒仍在同一列表可管 ① 新建「每天 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` 的正式迁移(注释已就地标注)。

View File

@@ -21,10 +21,13 @@ enum DiaryAssistPrompts {
/// ///
static func suggest(content: String, coveredDimensions: [String] = []) -> String { static func suggest(content: String, coveredDimensions: [String] = []) -> String {
let covered = coveredDimensions.filter { !$0.isEmpty } let covered = coveredDimensions.filter { !$0.isEmpty }
let coveredLine = covered.isEmpty ? "" : covered.joined(separator: "") let coveredSet = Set(covered)
let excludeRule = covered.isEmpty let allowed = dimensions.filter { !coveredSet.contains($0) }
let allowedLine = allowed.isEmpty ? "(已基本问全)" : allowed.joined(separator: "")
// :1.7B
let scopeRule = covered.isEmpty
? "" ? ""
: "\n- 本轮【严禁】选择这些已覆盖维度:\(covered.joined(separator: ""));只能从其余维度里挑" : "\n- 已问过的维度【不要再问】:\(covered.joined(separator: ""))。本轮只能从这些还没问的维度里挑:\(allowedLine)"
return """ return """
你是社区医生的小助手。患者写了一段身体状态的健康记录,信息可能不够完整。 你是社区医生的小助手。患者写了一段身体状态的健康记录,信息可能不够完整。
@@ -41,7 +44,7 @@ enum DiaryAssistPrompts {
8. 生活方式 —— 睡眠、饮食、运动习惯、压力 8. 生活方式 —— 睡眠、饮食、运动习惯、压力
硬性规则: 硬性规则:
- 本轮每个问题必须来自【不同】维度,严禁两条落在同一维度(例如不能两条都问"")。\(excludeRule) - 本轮每个问题必须来自【不同】维度,严禁两条落在同一维度(例如不能两条都问"")。\(scopeRule)
- 只问【最新记录】里还没写明的事。方括号 `[xxx]` 表示该话题已被提出、只是细节待填,【不要】再作为新问题重复它。 - 只问【最新记录】里还没写明的事。方括号 `[xxx]` 表示该话题已被提出、只是细节待填,【不要】再作为新问题重复它。
- 不给诊断、不给用药建议、不写「建议就医」。 - 不给诊断、不给用药建议、不写「建议就医」。
- q ≤ 20 字,像真人医生在问;fill 是采纳后追加到原文的中文补充句,可含方括号占位符如 [时间] [部位]。 - q ≤ 20 字,像真人医生在问;fill 是采纳后追加到原文的中文补充句,可含方括号占位符如 [时间] [部位]。
@@ -66,7 +69,7 @@ enum DiaryAssistPrompts {
]} ]}
现在输出 JSON。 现在输出 JSON。
已覆盖维度(必须避开):\(coveredLine) 本轮可选维度:\(allowedLine)
【最新记录】: 【最新记录】:
\(content) \(content)

View File

@@ -62,15 +62,20 @@ enum HealthExportPrompts {
? "# 就诊摘要" ? "# 就诊摘要"
: "# 就诊摘要 — \(intentLabelCN)" : "# 就诊摘要 — \(intentLabelCN)"
return """ return """
正在帮患者撰写一份给社区医生看的就诊摘要。要求: 是健康数据整理员。任务是把下面【真实数据】(JSON)里**已经存在**的内容,
- 严格输出 Markdown,标题用 # / ##,不要 markdown 围栏 原样整理成一份给社区医生看的就诊摘要。这是**抽取 / 搬运**任务,不是创作。
- 只用「数据」中给出的信息,数据缺失就写「无记录」
- 不要给诊断意见、用药建议或「建议就医」之类的话
- 引用数值时保留单位 + 参考范围,异常项前加 ⚠️
- 全文中文,简洁,医生 30 秒内能扫完
- 不要复述「数据」二字,不要输出 JSON
结构(严格按以下 6 段): 【最重要的铁律 —— 违反即失败】
- 只能使用【真实数据】JSON 里**真实出现过**的内容。
- 严禁编造或推测任何数字、日期、症状、药物、检查结果、诊断,哪怕看起来很合理。
- JSON 里没有的信息,对应小节一律写「无记录」,不要补全、不要举例、不要套用常见病例模板。
- 数值必须原样照搬(含单位与参考范围);status 为 high/low/abnormal 的指标前加 ⚠️。
- 「主诉」「患者疑问」可参考【患者原话】,但不得加入原话与数据里都没有的症状。
输出格式:
- 严格 Markdown,标题用 # / ##,不要 markdown 围栏,不要输出 JSON,不写「数据」二字。
- 不给诊断意见、用药建议或「建议就医」。全文中文,简洁,医生 30 秒能扫完。
- 严格按以下 6 段(顺序与标题固定):
\(labelLine) \(labelLine)
## 主诉 ## 主诉
## 患者背景 ## 患者背景
@@ -79,12 +84,33 @@ enum HealthExportPrompts {
## 在服药与过敏 ## 在服药与过敏
## 患者疑问 ## 患者疑问
数据: —— 格式示例(只示范「无记录」与数值写法,内容请勿照抄)——
真实数据:{"profile":{},"symptoms":[],"indicators":[{"name":"","value":"38.5","unit":"","range":"36-37.2","status":"high","date":"2026-05-01"}],"reports":[],"diaries":[],"time_window":{"from":"2026-04-02","to":"2026-05-02"}}
输出:
# 就诊摘要 — 近期健康摘要
## 主诉
无记录
## 患者背景
无记录
## 近期症状(按时间倒序)
无记录
## 关键指标(异常项优先)
⚠️ 体温 38.5 ℃(参考 36-37.2,2026-05-01)
## 在服药与过敏
无记录
## 患者疑问
无记录
—— 示例结束(以上咳嗽/体温等仅示范格式,切勿出现在你的输出里)——
现在,严格根据下面这份【真实数据】生成;数据里没有的就写「无记录」,**禁止编造**:
【真实数据】:
\(dataJSON) \(dataJSON)
患者原话:\(userPrompt) 患者原话:\(userPrompt)
现在请生成 Markdown(直接输出,不要思考过程,不要 <think> 标签): 再次强调:只整理上面【真实数据】里真实出现过的内容,禁止编造任何数字/日期/症状/药物。
直接输出 Markdown,不要思考过程,不要 <think> 标签:
/no_think /no_think
""" """
} }

View File

@@ -23,23 +23,46 @@ struct KangkangApp: App {
do { do {
return try ModelContainer(for: schema, configurations: [config]) return try ModelContainer(for: schema, configurations: [config])
} catch { } catch {
// Demo schema : store schema , // Demo schema : SwiftData
// store () // (: @Model ),
// SwiftData , // , store -wal/-shm
print("⚠️ ModelContainer 创建失败,重置本地 store 重建: \(error)") // App ,()
let fm = FileManager.default // VersionedSchema + SchemaMigrationPlan
let storePath = config.url.path // : @Model ,
for path in [storePath, storePath + "-wal", storePath + "-shm"] { print("⚠️ ModelContainer 创建失败,备份旧 store 后重建: \(error)")
try? fm.removeItem(atPath: path) KangkangApp.backupIncompatibleStore(at: config.url)
}
do { do {
return try ModelContainer(for: schema, configurations: [config]) return try ModelContainer(for: schema, configurations: [config])
} catch { } catch {
fatalError("Could not create ModelContainer even after reset: \(error)") fatalError("Could not create ModelContainer even after store reset: \(error)")
} }
} }
}() }()
/// schema store( `-wal` / `-shm`)
/// `Application Support/StoreBackups/<>/`,
/// ,;
private static func backupIncompatibleStore(at storeURL: URL) {
let fm = FileManager.default
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.dateFormat = "yyyyMMdd-HHmmss"
let stamp = fmt.string(from: Date())
let backupDir = storeURL.deletingLastPathComponent()
.appendingPathComponent("StoreBackups/\(stamp)", isDirectory: true)
try? fm.createDirectory(at: backupDir, withIntermediateDirectories: true)
for suffix in ["", "-wal", "-shm"] {
let src = URL(fileURLWithPath: storeURL.path + suffix)
guard fm.fileExists(atPath: src.path) else { continue }
let dst = backupDir.appendingPathComponent(src.lastPathComponent)
do {
try fm.moveItem(at: src, to: dst)
} catch {
try? fm.removeItem(at: src) // ,
}
}
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
AppLockContainer { AppLockContainer {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 807 KiB

After

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 807 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 807 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -19,6 +19,7 @@ struct ArchiveListView: View {
@State private var filter: TimelineKind? = nil @State private var filter: TimelineKind? = nil
@State private var endingSymptom: Symptom? @State private var endingSymptom: Symptom?
@State private var selectedEntry: TimelineEntry?
@State private var showExportSheet = false @State private var showExportSheet = false
@State private var showExportList = false @State private var showExportList = false
@@ -85,6 +86,11 @@ struct ArchiveListView: View {
.sheet(item: $endingSymptom) { sym in .sheet(item: $endingSymptom) { sym in
SymptomEndSheet(symptom: sym) SymptomEndSheet(symptom: sym)
} }
.sheet(item: $selectedEntry) { entry in
if let d = detail(for: entry) {
TimelineEntryDetailView(detail: d)
}
}
.fullScreenCover(isPresented: $showExportSheet) { .fullScreenCover(isPresented: $showExportSheet) {
HealthExportSheet() HealthExportSheet()
} }
@@ -94,6 +100,7 @@ struct ArchiveListView: View {
private func rowView(for entry: TimelineEntry) -> some View { private func rowView(for entry: TimelineEntry) -> some View {
if entry.kind == .symptom, entry.isOngoing, if entry.kind == .symptom, entry.isOngoing,
let sym = symptoms.first(where: { "symptom-\($0.persistentModelID)" == entry.id }) { let sym = symptoms.first(where: { "symptom-\($0.persistentModelID)" == entry.id }) {
// : sheet(沿)
Button { Button {
endingSymptom = sym endingSymptom = sym
} label: { } label: {
@@ -101,7 +108,42 @@ struct ArchiveListView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} else { } else {
TimelineRow(entry: entry) // (///):
Button {
if detail(for: entry) != nil { selectedEntry = entry }
} label: {
TimelineRow(entry: entry)
}
.buttonStyle(.plain)
}
}
/// 线(id `<kind>-<persistentModelID>` / `bp-<sys>-<dia>`)
private func detail(for entry: TimelineEntry) -> TimelineDetail? {
switch entry.kind {
case .report:
return reports.first { "report-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.report)
case .diary:
return diaries.first { "diary-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.diary)
case .symptom:
return symptoms.first { "symptom-\($0.persistentModelID)" == entry.id }
.map(TimelineDetail.symptom)
case .indicator:
if let i = indicators.first(where: { "indicator-\($0.persistentModelID)" == entry.id }) {
return .indicator(i)
}
// :bp-<sysID>-<diaID>
if entry.id.hasPrefix("bp-"),
let sys = indicators.first(where: { entry.id.hasPrefix("bp-\($0.persistentModelID)-") }) {
let dia = indicators.first {
$0.seriesKey == "bp.diastolic" &&
abs($0.capturedAt.timeIntervalSince(sys.capturedAt)) <= 5
}
return .bloodPressure(sys: sys, dia: dia)
}
return nil
} }
} }

View File

@@ -26,6 +26,12 @@ struct DiaryQuickSheet: View {
/// (question.dim), prompt /// (question.dim), prompt
@State private var coveredDims: Set<String> = [] @State private var coveredDims: Set<String> = []
@State private var suggestTask: Task<Void, Never>? @State private var suggestTask: Task<Void, Never>?
/// question id;nil =
@State private var fillingId: UUID?
/// , =
@State private var fillValues: [String] = []
/// () true,
@State private var exhaustedNote = false
/// sheet detent large, /// sheet detent large,
/// medium,() /// medium,()
@State private var detent: PresentationDetent = .large @State private var detent: PresentationDetent = .large
@@ -76,6 +82,7 @@ struct DiaryQuickSheet: View {
text: $content, axis: .vertical) text: $content, axis: .vertical)
.lineLimit(3...8) .lineLimit(3...8)
.focused($contentFocused) .focused($contentFocused)
.onChange(of: content) { _, _ in exhaustedNote = false }
.padding(.horizontal, 14) .padding(.horizontal, 14)
.padding(.vertical, 12) .padding(.vertical, 12)
.background( .background(
@@ -177,6 +184,19 @@ struct DiaryQuickSheet: View {
} }
} }
if exhaustedNote {
HStack(spacing: 6) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.leaf)
Text("已覆盖主要问诊维度;补充原文后可再追问")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 0)
}
.padding(.vertical, 2)
}
// () // ()
phaseFooter phaseFooter
} }
@@ -318,6 +338,7 @@ struct DiaryQuickSheet: View {
private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View { private func questionRow(index: Int, question: DiaryAssistService.Question) -> some View {
let adopted = question.adopted let adopted = question.adopted
let filling = fillingId == question.id
return VStack(alignment: .leading, spacing: 6) { return VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 8) { HStack(alignment: .top, spacing: 8) {
Text("\(index).") Text("\(index).")
@@ -341,7 +362,7 @@ struct DiaryQuickSheet: View {
.padding(.horizontal, 8) .padding(.horizontal, 8)
.padding(.vertical, 5) .padding(.vertical, 5)
.background(Capsule().fill(Tj.Palette.leafSoft)) .background(Capsule().fill(Tj.Palette.leafSoft))
} else { } else if !filling {
Button { adopt(question) } label: { Button { adopt(question) } label: {
HStack(spacing: 4) { HStack(spacing: 4) {
Image(systemName: "plus.circle.fill") Image(systemName: "plus.circle.fill")
@@ -357,7 +378,14 @@ struct DiaryQuickSheet: View {
.buttonStyle(.plain) .buttonStyle(.plain)
} }
} }
if !question.fill.isEmpty && !adopted { if filling {
QuestionFillPanel(
template: question.fill,
values: $fillValues,
onCommit: { assembled in commitAdoption(question, text: assembled) },
onCancel: { closeFill() }
)
} else if !question.fill.isEmpty && !adopted {
HStack(alignment: .top, spacing: 4) { HStack(alignment: .top, spacing: 4) {
Text("将追加:") Text("将追加:")
.font(.system(size: 11)) .font(.system(size: 11))
@@ -405,6 +433,7 @@ struct DiaryQuickSheet: View {
detent = .large detent = .large
} }
} }
exhaustedNote = false
phase = .loading phase = .loading
suggestTask = Task { @MainActor in suggestTask = Task { @MainActor in
do { do {
@@ -413,21 +442,34 @@ struct DiaryQuickSheet: View {
coveredDimensions: covered coveredDimensions: covered
) )
if Task.isCancelled { return } if Task.isCancelled { return }
// ( LLM ); prompt // ( 1.7B ):
let existing = Set(questions.map { Self.normalize($0.q) }) // ; ;
let coveredSnapshot = coveredDims
var acceptedNorms = questions.map { Self.normalize($0.q) }
var batchDims = Set<String>()
let nextRound = currentRound + 1 let nextRound = currentRound + 1
let fresh = result.questions let fresh = result.questions.compactMap { q -> DiaryAssistService.Question? in
.filter { !existing.contains(Self.normalize($0.q)) } let dim = q.dim.trimmingCharacters(in: .whitespacesAndNewlines)
.map { q -> DiaryAssistService.Question in let norm = Self.normalize(q.q)
var stamped = q if !dim.isEmpty, coveredSnapshot.contains(dim) { return nil }
stamped.round = nextRound if !dim.isEmpty, batchDims.contains(dim) { return nil }
return stamped if acceptedNorms.contains(where: { Self.isSimilar($0, norm) }) { return nil }
} if !dim.isEmpty { batchDims.insert(dim) }
acceptedNorms.append(norm)
var stamped = q
stamped.round = nextRound
return stamped
}
withAnimation(.snappy(duration: 0.2)) { withAnimation(.snappy(duration: 0.2)) {
questions.append(contentsOf: fresh) if fresh.isEmpty {
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) } exhaustedNote = true //
} else {
questions.append(contentsOf: fresh)
for q in fresh where !q.dim.isEmpty { coveredDims.insert(q.dim) }
currentRound = nextRound
exhaustedNote = false
}
lastRate = result.decodeRate lastRate = result.decodeRate
currentRound = nextRound
phase = .ready phase = .ready
} }
} catch is CancellationError { } catch is CancellationError {
@@ -449,20 +491,59 @@ struct DiaryQuickSheet: View {
.replacingOccurrences(of: "?", with: "?") .replacingOccurrences(of: "?", with: "?")
} }
/// :, Jaccard 0.8(/)
private static func isSimilar(_ a: String, _ b: String) -> Bool {
if a == b { return true }
let sa = Set(a), sb = Set(b)
guard !sa.isEmpty, !sb.isEmpty else { return false }
let inter = sa.intersection(sb).count
let union = sa.union(sb).count
return union > 0 && Double(inter) / Double(union) >= 0.8
}
private func cancelSuggestions() { private func cancelSuggestions() {
suggestTask?.cancel() suggestTask?.cancel()
phase = hasQuestions ? .ready : .idle phase = hasQuestions ? .ready : .idle
} }
/// question.fill textfield , question adopted /// : `[]` ;( adopted)
/// q ; coveredDims, prompt /// q ; coveredDims, prompt
private func adopt(_ question: DiaryAssistService.Question) { private func adopt(_ question: DiaryAssistService.Question) {
guard !question.fill.isEmpty, DiaryFillTemplate.slotCount(question.fill) > 0 else {
// :( fill 退)
commitAdoption(question, text: question.fill.isEmpty ? question.q : question.fill)
return
}
withAnimation(.snappy(duration: 0.18)) {
fillingId = question.id
fillValues = Array(repeating: "", count: DiaryFillTemplate.slotCount(question.fill))
}
}
/// ()
private func closeFill() {
withAnimation(.snappy(duration: 0.18)) {
fillingId = nil
fillValues = []
}
}
/// :(), adopted,
private func commitAdoption(_ question: DiaryAssistService.Question, text: String) {
if let idx = questions.firstIndex(where: { $0.id == question.id }) { if let idx = questions.firstIndex(where: { $0.id == question.id }) {
withAnimation(.snappy(duration: 0.18)) { withAnimation(.snappy(duration: 0.18)) {
questions[idx].adopted = true questions[idx].adopted = true
} }
} }
let toAppend = question.fill.isEmpty ? question.q : question.fill appendToContent(text)
fillingId = nil
fillValues = []
}
/// (,)
private func appendToContent(_ text: String) {
let toAppend = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !toAppend.isEmpty else { return }
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { if trimmed.isEmpty {
content = toAppend content = toAppend

View File

@@ -0,0 +1,235 @@
import SwiftUI
/// AI ( [] ,):
enum FillSegment: Equatable {
case literal(String)
/// `label` ( "" / "/");
/// `options` (`/` ,)
case slot(label: String, options: [String])
}
/// `fill` ,便
enum DiaryFillTemplate {
/// `.literal`
static func parse(_ template: String) -> [FillSegment] {
let chars = Array(template)
var segs: [FillSegment] = []
var i = 0
var literalStart = 0
func flushLiteral(upTo end: Int) {
if end > literalStart { segs.append(.literal(String(chars[literalStart..<end]))) }
}
while i < chars.count {
if chars[i] == "[",
let close = (i + 1 ..< chars.count).first(where: { chars[$0] == "]" }) {
flushLiteral(upTo: i)
let inner = String(chars[(i + 1)..<close])
segs.append(.slot(label: inner, options: options(from: inner)))
i = close + 1
literalStart = i
} else {
i += 1
}
}
flushLiteral(upTo: chars.count)
return segs
}
/// `/` (5 ) 2 ,
private static func options(from inner: String) -> [String] {
let tokens = inner.split(separator: "/")
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
guard tokens.count >= 2, tokens.allSatisfy({ $0.count <= 5 }) else { return [] }
return tokens
}
///
static func slotCount(_ template: String) -> Int {
parse(template).reduce(0) { acc, seg in
if case .slot = seg { return acc + 1 }
return acc
}
}
/// `values` :,退(,)
static func assemble(_ template: String, values: [String]) -> String {
var out = ""
var idx = 0
for seg in parse(template) {
switch seg {
case .literal(let t):
out += t
case .slot(let label, _):
let v = idx < values.count
? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : ""
out += v.isEmpty ? label : v
idx += 1
}
}
return out
}
}
/// : `[]` + chip,,
/// / ****
struct QuestionFillPanel: View {
let template: String
@Binding var values: [String]
let onCommit: (String) -> Void
let onCancel: () -> Void
private var segments: [FillSegment] { DiaryFillTemplate.parse(template) }
/// + values
private var slots: [(index: Int, label: String, options: [String])] {
var result: [(Int, String, [String])] = []
var i = 0
for seg in segments {
if case let .slot(label, options) = seg {
result.append((i, label, options))
i += 1
}
}
return result
}
var body: some View {
VStack(alignment: .leading, spacing: 10) {
// :,线
previewText
.font(.system(size: 13))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.sand2)
)
ForEach(slots, id: \.index) { slot in
slotEditor(index: slot.index, label: slot.label, options: slot.options)
}
HStack(spacing: 8) {
Button(action: onCancel) {
Text("取消")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Tj.Palette.text2)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
// :.plain ,
// contentShape
.contentShape(Rectangle())
}
.buttonStyle(.plain)
Button {
onCommit(DiaryFillTemplate.assemble(template, values: values))
} label: {
HStack(spacing: 5) {
Image(systemName: "text.append")
.font(.system(size: 12, weight: .semibold))
Text("加入记录")
.font(.system(size: 13, weight: .semibold))
}
.foregroundStyle(Tj.Palette.paper)
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.ink)
)
}
.buttonStyle(.plain)
}
}
.padding(.leading, 22)
.padding(.top, 2)
}
// MARK: -
/// :literal , brick ,线
private var previewText: Text {
var result = Text("")
var idx = 0
for seg in segments {
switch seg {
case .literal(let t):
result = result + Text(t).foregroundStyle(Tj.Palette.text)
case .slot(let label, _):
let v = idx < values.count
? values[idx].trimmingCharacters(in: .whitespacesAndNewlines) : ""
if v.isEmpty {
result = result + Text(label).foregroundStyle(Tj.Palette.text3).underline()
} else {
result = result + Text(v).foregroundStyle(Tj.Palette.brick).fontWeight(.semibold)
}
idx += 1
}
}
return result
}
private func slotEditor(index: Int, label: String, options: [String]) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(label)
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Tj.Palette.text3)
if !options.isEmpty {
HStack(spacing: 6) {
ForEach(options, id: \.self) { opt in
let picked = bindingValue(index) == opt
Button { values[index] = opt } label: {
Text(opt)
.font(.system(size: 12, weight: picked ? .semibold : .regular))
.foregroundStyle(picked ? Tj.Palette.paper : Tj.Palette.text)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(
Capsule().fill(picked ? Tj.Palette.ink : Tj.Palette.paper)
)
.overlay(
Capsule().strokeBorder(Tj.Palette.line,
lineWidth: picked ? 0 : 1)
)
}
.buttonStyle(.plain)
}
Spacer(minLength: 0)
}
}
TextField(String(appLoc: "填写\(label)"), text: binding(index))
.font(.system(size: 13))
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.line, lineWidth: 1)
)
}
}
private func bindingValue(_ i: Int) -> String {
i < values.count ? values[i] : ""
}
private func binding(_ i: Int) -> Binding<String> {
Binding(
get: { i < values.count ? values[i] : "" },
set: { if i < values.count { values[i] = $0 } }
)
}
}

View File

@@ -37,6 +37,8 @@ struct HomeView: View {
.padding(.top, 4) .padding(.top, 4)
.padding(.bottom, 18) .padding(.bottom, 18)
TodayRemindersCard()
OngoingSymptomsCard() OngoingSymptomsCard()
.padding(.bottom, 18) .padding(.bottom, 18)

View File

@@ -0,0 +1,118 @@
import SwiftUI
import SwiftData
import Combine
/// :(CustomReminder)+ (MetricReminder),
/// ;(,)
/// ( EmptyView,)
/// ; (RemindersListView)
struct TodayRemindersCard: View {
@Query(sort: \CustomReminder.updatedAt, order: .reverse)
private var customReminders: [CustomReminder]
@Query(sort: \MetricReminder.updatedAt, order: .reverse)
private var metricReminders: [MetricReminder]
@State private var showingCenter = false
/// ,( OngoingSymptomsCard )
@State private var tick: Date = .now
private let timer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
/// , + ,
private var items: [TodayItem] {
let cal = Calendar.current
var arr: [TodayItem] = []
for r in customReminders where r.occurs(on: tick, calendar: cal) {
arr.append(TodayItem(id: "c-\(r.id.uuidString)",
hour: r.hour, minute: r.minute, title: r.title))
}
for r in metricReminders where r.occurs(on: tick, calendar: cal) {
arr.append(TodayItem(id: "m-\(r.metricId)",
hour: r.hour, minute: r.minute, title: r.displayName))
}
return arr.sorted { ($0.hour, $0.minute) < ($1.hour, $1.minute) }
}
var body: some View {
let rows = items
if rows.isEmpty {
EmptyView()
} else {
VStack(alignment: .leading, spacing: 10) {
header(count: rows.count)
VStack(spacing: 8) {
ForEach(rows) { row($0) }
}
}
.padding(.bottom, 18)
.onReceive(timer) { now in tick = now }
.sheet(isPresented: $showingCenter) {
// NavigationStack ;sheet
NavigationStack { RemindersListView(presentedAsSheet: true) }
}
}
}
private func header(count: Int) -> some View {
HStack(spacing: 8) {
Circle()
.fill(Tj.Palette.amber)
.frame(width: 7, height: 7)
Text("今日提醒")
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Text("\(count)")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
Spacer()
Button { showingCenter = true } label: {
Text("全部 ")
.font(.system(size: 12))
.foregroundStyle(Tj.Palette.text3)
}
.buttonStyle(.plain)
}
}
private func row(_ item: TodayItem) -> some View {
let isPast = item.isPast(now: tick)
return HStack(spacing: 12) {
Text(item.timeLabel)
.font(.system(size: 14, weight: .semibold).monospacedDigit())
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.ink)
.frame(width: 46, alignment: .leading)
Image(systemName: "bell.fill")
.font(.system(size: 12))
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.amber)
Text(item.title)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(isPast ? Tj.Palette.text3 : Tj.Palette.text)
.lineLimit(1)
Spacer(minLength: 0)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.shadow(color: Color(red: 0.196, green: 0.157, blue: 0.098).opacity(0.04),
radius: 2, x: 0, y: 1)
}
}
/// ()
private struct TodayItem: Identifiable {
let id: String
let hour: Int
let minute: Int
let title: String
var timeLabel: String { String(format: "%02d:%02d", hour, minute) }
/// ()
func isPast(now: Date) -> Bool {
let c = Calendar.current.dateComponents([.hour, .minute], from: now)
let nowMinutes = (c.hour ?? 0) * 60 + (c.minute ?? 0)
return hour * 60 + minute < nowMinutes
}
}

View File

@@ -14,10 +14,16 @@ struct CustomReminderEditSheet: View {
@State private var title = "" @State private var title = ""
@State private var note = "" @State private var note = ""
@State private var pickedTime: Date = .now @State private var pickedTime: Date = .now
@State private var frequency: CustomReminder.Frequency = .daily
@State private var weekdays: Set<Int> = Set(1...7) @State private var weekdays: Set<Int> = Set(1...7)
@State private var dayOfMonth = 1
@State private var month = 1
@State private var hydrated = false @State private var hydrated = false
@State private var showAuthDeniedAlert = false @State private var showAuthDeniedAlert = false
/// (, ): / / /
private let timePresets: [(h: Int, m: Int)] = [(8, 0), (12, 0), (18, 0), (22, 0)]
init(reminder: CustomReminder? = nil) { init(reminder: CustomReminder? = nil) {
self.reminder = reminder self.reminder = reminder
} }
@@ -26,7 +32,11 @@ struct CustomReminderEditSheet: View {
private var trimmedTitle: String { private var trimmedTitle: String {
title.trimmingCharacters(in: .whitespacesAndNewlines) title.trimmingCharacters(in: .whitespacesAndNewlines)
} }
private var canSave: Bool { !trimmedTitle.isEmpty && !weekdays.isEmpty } private var canSave: Bool {
guard !trimmedTitle.isEmpty else { return false }
if frequency == .weekly { return !weekdays.isEmpty }
return true
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -41,14 +51,26 @@ struct CustomReminderEditSheet: View {
} }
Section { Section {
DatePicker(String(appLoc: "时间"), selection: $pickedTime, Picker(String(appLoc: "重复"), selection: $frequency) {
displayedComponents: .hourAndMinute) Text(String(appLoc: "每日")).tag(CustomReminder.Frequency.daily)
Text(String(appLoc: "每周")).tag(CustomReminder.Frequency.weekly)
Text(String(appLoc: "每月")).tag(CustomReminder.Frequency.monthly)
Text(String(appLoc: "每年")).tag(CustomReminder.Frequency.yearly)
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
frequencyDetail
} header: {
Text("重复")
} }
Section { Section {
weekdayRow timePresetRow
DatePicker(String(appLoc: "时间"), selection: $pickedTime,
displayedComponents: .hourAndMinute)
} header: { } header: {
Text("重复") Text("时间")
} }
if isEditing { if isEditing {
@@ -74,6 +96,11 @@ struct CustomReminderEditSheet: View {
} }
} }
.onAppear(perform: hydrate) .onAppear(perform: hydrate)
.onChange(of: month) { _, newMonth in
// ,(231)
let maxD = Self.daysInMonth(newMonth)
if dayOfMonth > maxD { dayOfMonth = maxD }
}
.alert(String(appLoc: "通知未开启"), isPresented: $showAuthDeniedAlert) { .alert(String(appLoc: "通知未开启"), isPresented: $showAuthDeniedAlert) {
Button(String(appLoc: "")) { dismiss() } Button(String(appLoc: "")) { dismiss() }
} message: { } message: {
@@ -82,6 +109,84 @@ struct CustomReminderEditSheet: View {
} }
} }
// MARK: -
@ViewBuilder
private var frequencyDetail: some View {
switch frequency {
case .daily:
EmptyView()
case .weekly:
weekdayRow
case .monthly:
Picker(String(appLoc: "日期"), selection: $dayOfMonth) {
ForEach(1...31, id: \.self) { d in
Text(String(appLoc: "\(d)")).tag(d)
}
}
if dayOfMonth >= 29 { skipHint }
case .yearly:
Picker(String(appLoc: "月份"), selection: $month) {
ForEach(1...12, id: \.self) { mo in
Text(String(appLoc: "\(mo)")).tag(mo)
}
}
Picker(String(appLoc: "日期"), selection: $dayOfMonth) {
ForEach(1...Self.daysInMonth(month), id: \.self) { d in
Text(String(appLoc: "\(d)")).tag(d)
}
}
if month == 2 && dayOfMonth == 29 { skipHint } // 2/29
}
}
private var skipHint: some View {
Text(String(appLoc: "部分月份无此日,该月将跳过"))
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
/// (2 29, 2/29)
private static func daysInMonth(_ month: Int) -> Int {
switch month {
case 2: return 29
case 4, 6, 9, 11: return 30
default: return 31
}
}
// MARK: -
private var timePresetRow: some View {
let cal = Calendar.current
let curH = cal.component(.hour, from: pickedTime)
let curM = cal.component(.minute, from: pickedTime)
return HStack(spacing: 8) {
ForEach(Array(timePresets.enumerated()), id: \.offset) { _, preset in
let on = curH == preset.h && curM == preset.m
Button {
pickedTime = cal.date(bySettingHour: preset.h, minute: preset.m,
second: 0, of: pickedTime) ?? pickedTime
} label: {
Text(String(format: "%d:%02d", preset.h, preset.m))
.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: - ( RemindersListView ) // MARK: - ( RemindersListView )
private var weekdayRow: some View { private var weekdayRow: some View {
@@ -124,7 +229,10 @@ struct CustomReminderEditSheet: View {
if let r = reminder { if let r = reminder {
title = r.title title = r.title
note = r.note note = r.note
frequency = r.frequency
weekdays = Set(r.weekdays) weekdays = Set(r.weekdays)
dayOfMonth = r.dayOfMonth
month = r.month
pickedTime = Calendar.current.date( pickedTime = Calendar.current.date(
bySettingHour: r.hour, minute: r.minute, second: 0, of: .now bySettingHour: r.hour, minute: r.minute, second: 0, of: .now
) ?? .now ) ?? .now
@@ -145,6 +253,9 @@ struct CustomReminderEditSheet: View {
r.hour = hour r.hour = hour
r.minute = minute r.minute = minute
r.weekdays = sortedDays r.weekdays = sortedDays
r.frequency = frequency
r.dayOfMonth = dayOfMonth
r.month = month
r.updatedAt = .now r.updatedAt = .now
target = r target = r
} else { } else {
@@ -153,7 +264,10 @@ struct CustomReminderEditSheet: View {
note: note.trimmingCharacters(in: .whitespacesAndNewlines), note: note.trimmingCharacters(in: .whitespacesAndNewlines),
hour: hour, hour: hour,
minute: minute, minute: minute,
weekdays: sortedDays weekdays: sortedDays,
frequency: frequency,
dayOfMonth: dayOfMonth,
month: month
) )
ctx.insert(new) ctx.insert(new)
target = new target = new

View File

@@ -0,0 +1,295 @@
import SwiftUI
import SwiftData
/// 线, sheet
/// : W2 ;W4 C2 `ReportDetailView`( Tab + ),
/// 线 C2 ,
enum TimelineDetail {
case indicator(Indicator)
case bloodPressure(sys: Indicator, dia: Indicator?)
case report(Report)
case diary(DiaryEntry)
case symptom(Symptom)
}
/// 线:,
struct TimelineEntryDetailView: View {
@Environment(\.dismiss) private var dismiss
let detail: TimelineDetail
var body: some View {
VStack(spacing: 0) {
header
ScrollView {
VStack(alignment: .leading, spacing: 16) {
bodyContent
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.background(Tj.Palette.sand.ignoresSafeArea())
.presentationDetents([.medium, .large])
.presentationDragIndicator(.visible)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
}
// MARK: - Header
private var header: some View {
HStack(spacing: 12) {
Button { dismiss() } label: {
Image(systemName: "xmark")
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
.frame(width: 32, height: 32)
.background(Circle().fill(Tj.Palette.sand2))
}
Text(titleText)
.font(.tjH2())
.foregroundStyle(Tj.Palette.text)
Spacer()
TjLockChip()
}
.padding(.horizontal, 20)
.padding(.vertical, 14)
.background(Tj.Palette.sand)
.overlay(alignment: .bottom) {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
}
private var titleText: String {
switch detail {
case .indicator: return String(appLoc: "指标详情")
case .bloodPressure: return String(appLoc: "血压详情")
case .report: return String(appLoc: "报告详情")
case .diary: return String(appLoc: "日记详情")
case .symptom: return String(appLoc: "症状详情")
}
}
@ViewBuilder
private var bodyContent: some View {
switch detail {
case .indicator(let i): indicatorBody(i)
case .bloodPressure(let s, let d): bpBody(sys: s, dia: d)
case .report(let r): reportBody(r)
case .diary(let d): diaryBody(d)
case .symptom(let s): symptomBody(s)
}
}
// MARK: -
private func indicatorBody(_ i: Indicator) -> some View {
card {
HStack(alignment: .firstTextBaseline) {
Text(i.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(i.status)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(i.value)
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(i.status == .normal ? Tj.Palette.text : Tj.Palette.brick)
if !i.unit.isEmpty {
Text(i.unit).font(.system(size: 14)).foregroundStyle(Tj.Palette.text3)
}
}
divider
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
field(String(appLoc: "来源"), i.report?.title ?? String(appLoc: "异常项快拍"))
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
}
}
// MARK: - ()
private func bpBody(sys: Indicator, dia: Indicator?) -> some View {
let combined: IndicatorStatus = sys.status != .normal
? sys.status
: (dia?.status ?? .normal)
return card {
HStack(alignment: .firstTextBaseline) {
Text(String(appLoc: "血压")).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
statusChip(combined)
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
Text("\(sys.value)/\(dia?.value ?? "")")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(combined == .normal ? Tj.Palette.text : Tj.Palette.brick)
Text("mmHg").font(.system(size: 14)).foregroundStyle(Tj.Palette.text3)
}
divider
if !sys.range.isEmpty { field(String(appLoc: "参考范围"), sys.range) }
field(String(appLoc: "记录时间"), Self.dateTimeText(sys.capturedAt))
}
}
// MARK: -
private func reportBody(_ r: Report) -> some View {
let sorted = r.indicators.sorted {
($0.status == .normal ? 1 : 0) < ($1.status == .normal ? 1 : 0)
}
return VStack(alignment: .leading, spacing: 16) {
card {
Text(r.title).font(.tjH2()).foregroundStyle(Tj.Palette.text)
HStack(spacing: 8) {
TjBadge(text: r.type.label, style: .neutral)
Text(Self.dateText(r.reportDate))
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
if !r.assets.isEmpty {
Text(String(appLoc: "原图\(r.assets.count)"))
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
}
}
if let inst = r.institution, !inst.isEmpty {
field(String(appLoc: "机构"), inst)
}
}
if let sum = r.summary, !sum.isEmpty {
card {
Text(String(appLoc: "摘要"))
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
Text(sum).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
.fixedSize(horizontal: false, vertical: true)
}
}
if !r.indicators.isEmpty {
card {
Text(String(appLoc: "指标"))
.font(.system(size: 12, weight: .semibold)).foregroundStyle(Tj.Palette.text2)
ForEach(sorted) { ind in
HStack {
Text(ind.name).font(.system(size: 14)).foregroundStyle(Tj.Palette.text)
Spacer(minLength: 8)
Text(ind.unit.isEmpty ? ind.value : "\(ind.value) \(ind.unit)")
.font(.system(size: 13, design: .monospaced))
.foregroundStyle(ind.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
statusChip(ind.status)
}
}
}
}
if let note = r.note, !note.isEmpty {
card { field(String(appLoc: "备注"), note) }
}
}
}
// MARK: -
private func diaryBody(_ d: DiaryEntry) -> some View {
VStack(alignment: .leading, spacing: 16) {
card {
Text(Self.dateTimeText(d.createdAt))
.font(.system(size: 12)).foregroundStyle(Tj.Palette.text3)
Text(d.content)
.font(.system(size: 15))
.foregroundStyle(Tj.Palette.text)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
.fixedSize(horizontal: false, vertical: true)
if !d.tags.isEmpty {
field(String(appLoc: "标签"), d.tags.map { "#\($0)" }.joined(separator: " "))
}
}
}
}
// MARK: -
private func symptomBody(_ s: Symptom) -> some View {
card {
HStack(alignment: .firstTextBaseline) {
Text(s.name).font(.tjH2()).foregroundStyle(Tj.Palette.text)
Spacer()
if s.isOngoing {
Text(String(appLoc: "进行中"))
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.brick)
.padding(.horizontal, 8).padding(.vertical, 4)
.background(Capsule().fill(Tj.Palette.brick.opacity(0.14)))
}
}
divider
field(String(appLoc: "程度"), "\(s.severity) / 5")
field(String(appLoc: "开始"), Self.dateTimeText(s.startedAt))
field(String(appLoc: "结束"), s.endedAt.map(Self.dateTimeText) ?? String(appLoc: "进行中"))
field(String(appLoc: "持续"), formatDuration(s.duration))
if let note = s.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
if !s.tags.isEmpty {
field(String(appLoc: "标签"), s.tags.map { "#\($0)" }.joined(separator: " "))
}
}
}
// MARK: -
@ViewBuilder
private func card<Content: View>(@ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 10) { content() }
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.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 func field(_ label: String, _ value: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Text(label).font(.system(size: 13)).foregroundStyle(Tj.Palette.text3)
Spacer(minLength: 12)
Text(value)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(Tj.Palette.text)
.multilineTextAlignment(.trailing)
.fixedSize(horizontal: false, vertical: true)
}
}
private var divider: some View {
Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1)
}
private func statusChip(_ s: IndicatorStatus) -> some View {
let text: String
let color: Color
let arrow: String
switch s {
case .high: text = String(appLoc: "偏高"); color = Tj.Palette.brick; arrow = ""
case .low: text = String(appLoc: "偏低"); color = Tj.Palette.brick; arrow = ""
case .normal: text = String(appLoc: "正常"); color = Tj.Palette.leaf; arrow = ""
}
return HStack(spacing: 3) {
if !arrow.isEmpty { Text(arrow).font(.system(size: 11, weight: .bold)) }
Text(text).font(.system(size: 12, weight: .semibold))
}
.foregroundStyle(color)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Capsule().fill(color.opacity(0.14)))
}
private nonisolated static func dateTimeText(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day().hour().minute())
}
private nonisolated static func dateText(_ d: Date) -> String {
d.formatted(.dateTime.year().month().day())
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -261,6 +261,13 @@ final class MetricReminder {
var timeLabel: String { var timeLabel: String {
String(format: "%02d:%02d", hour, minute) String(format: "%02d:%02d", hour, minute)
} }
/// (weekday , 7 = ); false
///
func occurs(on date: Date, calendar: Calendar = .current) -> Bool {
guard enabled else { return false }
return weekdays.contains(calendar.component(.weekday, from: date))
}
} }
/// ( 20:00 5 12:30 2 ) /// ( 20:00 5 12:30 2 )
@@ -269,12 +276,20 @@ final class MetricReminder {
/// 沿 weekday ( 7 = ); `ReminderService` /// 沿 weekday ( 7 = ); `ReminderService`
@Model @Model
final class CustomReminder { final class CustomReminder {
/// ; weekdays; dayOfMonth; month + dayOfMonth
enum Frequency: String, CaseIterable, Sendable {
case daily, weekly, monthly, yearly
}
@Attribute(.unique) var id: UUID @Attribute(.unique) var id: UUID
var title: String // , "5" var title: String // , "5"
var note: String // var note: String //
var hour: Int // 0...23 var hour: Int // 0...23
var minute: Int // 0...59 var minute: Int // 0...59
var weekdays: [Int] // iOS Calendar :1=, 2=, ..., 7= 7 = var weekdays: [Int] // iOS Calendar :1=, 2=, ..., 7= 7 =
var frequencyRaw: String = "daily" // CustomReminder.Frequency
var dayOfMonth: Int = 1 // monthly / yearly ,1...31
var month: Int = 1 // yearly ,1...12
var enabled: Bool var enabled: Bool
var createdAt: Date var createdAt: Date
var updatedAt: Date var updatedAt: Date
@@ -285,6 +300,9 @@ final class CustomReminder {
hour: Int = 8, hour: Int = 8,
minute: Int = 0, minute: Int = 0,
weekdays: [Int] = [1, 2, 3, 4, 5, 6, 7], weekdays: [Int] = [1, 2, 3, 4, 5, 6, 7],
frequency: Frequency = .daily,
dayOfMonth: Int = 1,
month: Int = 1,
enabled: Bool = true, enabled: Bool = true,
createdAt: Date = .now) { createdAt: Date = .now) {
self.id = id self.id = id
@@ -293,6 +311,9 @@ final class CustomReminder {
self.hour = max(0, min(23, hour)) self.hour = max(0, min(23, hour))
self.minute = max(0, min(59, minute)) self.minute = max(0, min(59, minute))
self.weekdays = weekdays self.weekdays = weekdays
self.frequencyRaw = frequency.rawValue
self.dayOfMonth = max(1, min(31, dayOfMonth))
self.month = max(1, min(12, month))
self.enabled = enabled self.enabled = enabled
self.createdAt = createdAt self.createdAt = createdAt
self.updatedAt = createdAt self.updatedAt = createdAt
@@ -300,19 +321,46 @@ final class CustomReminder {
var isEveryDay: Bool { Set(weekdays) == Set(1...7) } var isEveryDay: Bool { Set(weekdays) == Set(1...7) }
/// MetricReminder.frequencyLabel , key var frequency: Frequency {
get { Frequency(rawValue: frequencyRaw) ?? .daily }
set { frequencyRaw = newValue.rawValue }
}
/// : / / 15 / 315
var frequencyLabel: String { var frequencyLabel: String {
if !enabled { return String(appLoc: "已关闭") } if !enabled { return String(appLoc: "已关闭") }
if isEveryDay { return String(appLoc: "每天") } switch frequency {
if weekdays.isEmpty { return String(appLoc: "未选日") } case .daily:
let names = [String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: ""), String(appLoc: "")] return String(appLoc: "每天")
let sorted = weekdays.sorted() case .weekly:
return String(appLoc: "") + sorted.map { names[$0 - 1] }.joined() 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: "")]
return String(appLoc: "每周 ") + weekdays.sorted().map { names[$0 - 1] }.joined()
case .monthly:
return String(appLoc: "每月\(dayOfMonth)")
case .yearly:
return String(appLoc: "每年\(month)\(dayOfMonth)")
}
} }
var timeLabel: String { var timeLabel: String {
String(format: "%02d:%02d", hour, minute) String(format: "%02d:%02d", hour, minute)
} }
/// (,); false
/// monthly/yearly ( 31 ) false,
/// iOS
func occurs(on date: Date, calendar: Calendar = .current) -> Bool {
guard enabled else { return false }
let c = calendar.dateComponents([.weekday, .day, .month], from: date)
switch frequency {
case .daily: return true
case .weekly: return weekdays.contains(c.weekday ?? -1)
case .monthly: return dayOfMonth == (c.day ?? -1)
case .yearly: return month == (c.month ?? -1) && dayOfMonth == (c.day ?? -1)
}
}
} }
@Model @Model

View File

@@ -85,40 +85,49 @@ struct HealthExportService {
// Phase 3: // Phase 3:
continuation.yield(.phaseChanged(.generating)) continuation.yield(.phaseChanged(.generating))
let dataJSON = Self.serializeData(snapshot: snapshot) let dataJSON = Self.serializeData(snapshot: snapshot)
let genPrompt = HealthExportPrompts.reportGeneration(
userPrompt: prompt,
intentLabelCN: intent.labelCN,
dataJSON: dataJSON
)
// <think>...</think>
// Prompt Qwen3 `/no_think`, thinking
// + chunk + diff yield:
// - thinking ,UI generated
// - </think> ,
var rawAccum = ""
var generated = "" var generated = ""
var lastRate: Double = 0 var lastRate: Double = 0
let stream = await AIRuntime.shared.generate(
prompt: genPrompt, if Self.isEffectivelyEmpty(snapshot) {
maxTokens: 1024 // : LLM,,
) // (线:)
for try await chunk in stream { generated = Self.fallbackReport(label: intent.labelCN, userPrompt: prompt)
try Task.checkCancellation() continuation.yield(.token(TokenChunk(text: generated, decodeRate: 0)))
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate } } else {
rawAccum += chunk.text let genPrompt = HealthExportPrompts.reportGeneration(
let clean = Self.stripThinkBlocks(rawAccum) userPrompt: prompt,
if clean.count > generated.count, clean.hasPrefix(generated) { intentLabelCN: intent.labelCN,
let delta = String(clean.dropFirst(generated.count)) dataJSON: dataJSON
generated = clean )
continuation.yield(.token(TokenChunk(
text: delta, // <think>...</think>
decodeRate: chunk.decodeRate // Prompt Qwen3 `/no_think`, thinking
))) // + chunk + diff yield:
} else if clean != generated { // - thinking ,UI generated
// :() UI 退, // - </think> ,
// generated = clean yield(退) var rawAccum = ""
generated = clean let stream = await AIRuntime.shared.generate(
prompt: genPrompt,
maxTokens: 1024
)
for try await chunk in stream {
try Task.checkCancellation()
if chunk.decodeRate > 0 { lastRate = chunk.decodeRate }
rawAccum += chunk.text
let clean = Self.stripThinkBlocks(rawAccum)
if clean.count > generated.count, clean.hasPrefix(generated) {
let delta = String(clean.dropFirst(generated.count))
generated = clean
continuation.yield(.token(TokenChunk(
text: delta,
decodeRate: chunk.decodeRate
)))
} else if clean != generated {
// :() UI 退,
// generated = clean yield(退)
generated = clean
}
} }
} }
@@ -292,18 +301,21 @@ struct HealthExportService {
) )
let reports = Array(((try? ctx.fetch(reportDesc)) ?? []).prefix(8)) let reports = Array(((try? ctx.fetch(reportDesc)) ?? []).prefix(8))
// Diary(: symptom_keyword , prompt) // Diary
// (targeted,);
// (,) 5
// prompt , bug
let diaryDesc = FetchDescriptor<DiaryEntry>(
predicate: #Predicate { $0.createdAt >= fromDate && $0.createdAt <= toDate },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
let allDiaries = (try? ctx.fetch(diaryDesc)) ?? []
let diaries: [DiaryEntry] let diaries: [DiaryEntry]
if intent.symptomKeywords.isEmpty { if intent.symptomKeywords.isEmpty {
diaries = [] diaries = Array(allDiaries.prefix(5))
} else { } else {
let diaryDesc = FetchDescriptor<DiaryEntry>(
predicate: #Predicate { $0.createdAt >= fromDate && $0.createdAt <= toDate },
sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
)
let all = (try? ctx.fetch(diaryDesc)) ?? []
diaries = Array( diaries = Array(
all.filter { d in allDiaries.filter { d in
intent.symptomKeywords.contains { kw in intent.symptomKeywords.contains { kw in
d.content.localizedCaseInsensitiveContains(kw) d.content.localizedCaseInsensitiveContains(kw)
} }
@@ -416,6 +428,56 @@ struct HealthExportService {
return str return str
} }
// MARK: - ()
/// :///, profile
/// LLM,,
static func isEffectivelyEmpty(_ s: Snapshot) -> Bool {
guard s.symptoms.isEmpty, s.indicators.isEmpty, s.reports.isEmpty, s.diaries.isEmpty else {
return false
}
let p = s.profile
return p.age == nil
&& p.sex == .undisclosed
&& p.heightCM == nil
&& p.weightKG == nil
&& p.bloodTypeRaw.isEmpty
&& p.allergies.isEmpty
&& p.chronicConditions.isEmpty
&& p.familyHistory.isEmpty
&& p.currentMedications.isEmpty
}
/// :6 ,,
static func fallbackReport(label: String, userPrompt: String) -> String {
let title = label.isEmpty ? "# 就诊摘要" : "# 就诊摘要 — \(label)"
let complaint = userPrompt.trimmingCharacters(in: .whitespacesAndNewlines)
let complaintLine = complaint.isEmpty ? "无记录" : complaint
return """
\(title)
> 本次未检索到可用的健康记录(指标 / 症状 / 报告 / 日记均为空),以下仅据患者原话,未做任何推断。
## 主诉
\(complaintLine)
## 患者背景
无记录
## 近期症状(按时间倒序)
无记录
## 关键指标(异常项优先)
无记录
## 在服药与过敏
无记录
## 患者疑问
无记录
"""
}
// MARK: - Helpers // MARK: - Helpers
/// SwiftData persistentModelID /// SwiftData persistentModelID

View File

@@ -53,14 +53,16 @@ enum ReminderService {
static func sync(_ reminder: MetricReminder) async { static func sync(_ reminder: MetricReminder) async {
cancel(metricId: reminder.metricId) cancel(metricId: reminder.metricId)
guard reminder.enabled else { return } guard reminder.enabled else { return }
let slots = reminder.weekdays.map { wd in
Slot(suffix: "w\(wd)",
dc: DateComponents(hour: reminder.hour, minute: reminder.minute, weekday: wd))
}
await schedule( await schedule(
idBase: "\(idPrefix)\(reminder.metricId)", idBase: "\(idPrefix)\(reminder.metricId)",
title: String(appLoc: "该测\(reminder.displayName)"), title: String(appLoc: "该测\(reminder.displayName)"),
body: String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"), body: String(appLoc: "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次"),
hour: reminder.hour, thread: "kangkang.reminder.\(reminder.metricId)",
minute: reminder.minute, slots: slots
weekdays: reminder.weekdays,
thread: "kangkang.reminder.\(reminder.metricId)"
) )
} }
@@ -77,14 +79,28 @@ enum ReminderService {
guard reminder.enabled else { return } guard reminder.enabled else { return }
let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines) let title = reminder.title.trimmingCharacters(in: .whitespacesAndNewlines)
let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines) let body = reminder.note.trimmingCharacters(in: .whitespacesAndNewlines)
let h = reminder.hour, m = reminder.minute
let slots: [Slot]
switch reminder.frequency {
case .daily:
slots = [Slot(suffix: "daily", dc: DateComponents(hour: h, minute: m))]
case .weekly:
slots = reminder.weekdays.map { wd in
Slot(suffix: "w\(wd)", dc: DateComponents(hour: h, minute: m, weekday: wd))
}
case .monthly:
slots = [Slot(suffix: "monthly",
dc: DateComponents(day: reminder.dayOfMonth, hour: h, minute: m))]
case .yearly:
slots = [Slot(suffix: "yearly",
dc: DateComponents(month: reminder.month, day: reminder.dayOfMonth, hour: h, minute: m))]
}
await schedule( await schedule(
idBase: "\(customIdPrefix)\(reminder.id.uuidString)", idBase: "\(customIdPrefix)\(reminder.id.uuidString)",
title: title.isEmpty ? String(appLoc: "提醒") : title, title: title.isEmpty ? String(appLoc: "提醒") : title,
body: body.isEmpty ? String(appLoc: "到点啦,记得完成") : body, body: body.isEmpty ? String(appLoc: "到点啦,记得完成") : body,
hour: reminder.hour, thread: "\(customIdPrefix)\(reminder.id.uuidString)",
minute: reminder.minute, slots: slots
weekdays: reminder.weekdays,
thread: "\(customIdPrefix)\(reminder.id.uuidString)"
) )
} }
@@ -100,16 +116,20 @@ enum ReminderService {
// MARK: - // MARK: -
/// weekdays N weekly-repeats /// :`suffix` id(`<idBase>.<suffix>`,
/// `idBase` `.w<weekday>` ; /// `.daily` / `.w2` / `.monthly` / `.yearly`),`dc`
private struct Slot {
let suffix: String
let dc: DateComponents
}
/// `Slot` N repeats ///
private static func schedule(idBase: String, private static func schedule(idBase: String,
title: String, title: String,
body: String, body: String,
hour: Int, thread: String,
minute: Int, slots: [Slot]) async {
weekdays: [Int], guard !slots.isEmpty else { return }
thread: String) async {
guard !weekdays.isEmpty else { return }
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.title = title content.title = title
@@ -117,23 +137,20 @@ enum ReminderService {
content.sound = .default content.sound = .default
content.threadIdentifier = thread content.threadIdentifier = thread
for weekday in weekdays { for slot in slots {
var comps = DateComponents() let trigger = UNCalendarNotificationTrigger(dateMatching: slot.dc, repeats: true)
comps.hour = hour let request = UNNotificationRequest(identifier: "\(idBase).\(slot.suffix)",
comps.minute = minute
comps.weekday = weekday
let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)
let request = UNNotificationRequest(identifier: "\(idBase).w\(weekday)",
content: content, content: content,
trigger: trigger) trigger: trigger)
try? await center.add(request) try? await center.add(request)
} }
} }
/// idBase 7 weekday pending () /// idBase pending (daily/monthly/yearly + 7 weekday,)
private static func cancelBase(_ idBase: String) { private static func cancelBase(_ idBase: String) {
let center = UNUserNotificationCenter.current() let center = UNUserNotificationCenter.current()
let ids = (1...7).map { "\(idBase).w\($0)" } var ids = ["\(idBase).daily", "\(idBase).monthly", "\(idBase).yearly"]
ids += (1...7).map { "\(idBase).w\($0)" }
center.removePendingNotificationRequests(withIdentifiers: ids) center.removePendingNotificationRequests(withIdentifiers: ids)
} }
} }

View File

@@ -0,0 +1,81 @@
import Testing
import Foundation
@testable import
/// (`occurs(on:)`)
/// Gregorian , `Date.now` /
struct TodayRemindersLogicTests {
private var cal: Calendar {
var c = Calendar(identifier: .gregorian)
c.timeZone = TimeZone(identifier: "Asia/Shanghai")!
return c
}
private func date(_ y: Int, _ mo: Int, _ d: Int) -> Date {
cal.date(from: DateComponents(year: y, month: mo, day: d, hour: 12))!
}
// MARK: - CustomReminder
@Test func dailyOccursEveryDay() {
let r = CustomReminder(title: "跑步", frequency: .daily)
#expect(r.occurs(on: date(2026, 5, 30), calendar: cal))
#expect(r.occurs(on: date(2026, 1, 1), calendar: cal))
}
@Test func disabledNeverOccurs() {
let r = CustomReminder(title: "跑步", frequency: .daily, enabled: false)
#expect(!r.occurs(on: date(2026, 5, 30), calendar: cal))
}
@Test func weeklyOccursOnlyOnSelectedWeekdays() {
let d = date(2026, 5, 30)
let wd = cal.component(.weekday, from: d)
let other = wd == 1 ? 2 : 1
let hit = CustomReminder(title: "x", weekdays: [wd], frequency: .weekly)
#expect(hit.occurs(on: d, calendar: cal))
let miss = CustomReminder(title: "x", weekdays: [other], frequency: .weekly)
#expect(!miss.occurs(on: d, calendar: cal))
}
@Test func monthlyOccursOnlyOnMatchingDay() {
let d = date(2026, 5, 30) // 30
#expect(CustomReminder(title: "x", frequency: .monthly, dayOfMonth: 30).occurs(on: d, calendar: cal))
#expect(!CustomReminder(title: "x", frequency: .monthly, dayOfMonth: 15).occurs(on: d, calendar: cal))
}
@Test func monthlyDay31SkipsShortMonths() {
// 4 30 :31 4/30 ( 4/31,)
let apr30 = date(2026, 4, 30)
let r = CustomReminder(title: "x", frequency: .monthly, dayOfMonth: 31)
#expect(!r.occurs(on: apr30, calendar: cal))
}
@Test func yearlyOccursOnlyOnMatchingMonthAndDay() {
let d = date(2026, 5, 30)
#expect(CustomReminder(title: "x", frequency: .yearly, dayOfMonth: 30, month: 5).occurs(on: d, calendar: cal))
#expect(!CustomReminder(title: "x", frequency: .yearly, dayOfMonth: 30, month: 6).occurs(on: d, calendar: cal))
#expect(!CustomReminder(title: "x", frequency: .yearly, dayOfMonth: 29, month: 5).occurs(on: d, calendar: cal))
}
// MARK: - MetricReminder
@Test func metricReminderOccursOnSelectedWeekday() {
let d = date(2026, 5, 30)
let wd = cal.component(.weekday, from: d)
let other = wd == 1 ? 2 : 1
#expect(MetricReminder(metricId: "bp", displayName: "血压", weekdays: [wd]).occurs(on: d, calendar: cal))
#expect(!MetricReminder(metricId: "bp2", displayName: "血压", weekdays: [other]).occurs(on: d, calendar: cal))
}
@Test func disabledMetricReminderNeverOccurs() {
let d = date(2026, 5, 30)
let wd = cal.component(.weekday, from: d)
let r = MetricReminder(metricId: "bp", displayName: "血压", weekdays: [wd], enabled: false)
#expect(!r.occurs(on: d, calendar: cal))
}
}