From 6c6a9501404da149be5d7ff495d3b6d69dc0da6b Mon Sep 17 00:00:00 2001 From: link2026 Date: Sat, 13 Jun 2026 09:16:25 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat:=20=E6=B7=BB=E5=8A=A0=E6=8B=8D?= =?UTF-8?q?=E8=8D=AF=E7=9B=92=E5=8A=9F=E8=83=BD=E5=92=8C=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E7=9B=B4=E8=BE=BE=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现拍药盒扫描流程,支持本地OCR识别药品信息 - 在日记页面添加拍药盒和记症状的三选一入口 - 优化按钮点击区域,确保符合苹果HIG最小命中区标准 - 添加用药记录到时间线的独立分类显示 - 实现长按+号语音直达功能,支持语音意图分类跳转 - 更新项目配置文件,启用代码分析和死代码剥离选项 - 增加多项本地化字符串支持新功能 ``` --- KangkangWidget-src/KangkangWidgetBundle.swift | 11 + .../PinnedIndicatorsWidget.swift | 249 +++++++++++++++ docs/Widget接入步骤.md | 52 ++++ 康康.xcodeproj/project.pbxproj | 14 +- .../xcshareddata/xcschemes/康康.xcscheme | 2 +- 康康/AI/Prompts/IntentPrompts.swift | 43 +++ 康康/AI/Prompts/MedicationPrompts.swift | 51 ++++ 康康/Features/Capture/PhotoPickerSheet.swift | 10 +- .../Features/Capture/UnifiedCaptureFlow.swift | 14 +- 康康/Features/Diary/DiaryQuickSheet.swift | 71 +++++ .../Features/Profile/MedicationScanFlow.swift | 285 ++++++++++++++++++ 康康/Features/Profile/ProfileEditView.swift | 31 +- .../Features/Quick/QuickRegionCaptureFlow.swift | 4 +- 康康/Features/Quick/RegionAdjustView.swift | 12 +- 康康/Features/Quick/RegionCameraView.swift | 8 +- 康康/Features/Record/RecordSheet.swift | 46 ++- 康康/Features/Record/VoiceCommandSheet.swift | 276 +++++++++++++++++ 康康/Features/Timeline/TimelineEntry.swift | 38 ++- .../Timeline/TimelineEntryDetailView.swift | 3 +- 康康/Localizable.xcstrings | 118 ++++++++ 康康/Models/Models.swift | 8 + 康康/Persistence/WidgetSnapshot.swift | 44 +++ .../Persistence/WidgetSnapshotRefresher.swift | 42 +++ 康康/RootView.swift | 114 +++++-- 康康/Services/MedicationScanService.swift | 114 +++++++ 康康/Services/VoiceIntentService.swift | 96 ++++++ 康康Tests/MedicationScanServiceTests.swift | 85 ++++++ 康康Tests/SpeechDictationMergeTests.swift | 22 ++ 康康Tests/TimelineGroupingTests.swift | 4 + 康康Tests/VoiceIntentServiceTests.swift | 53 ++++ 30 files changed, 1856 insertions(+), 64 deletions(-) create mode 100644 KangkangWidget-src/KangkangWidgetBundle.swift create mode 100644 KangkangWidget-src/PinnedIndicatorsWidget.swift create mode 100644 docs/Widget接入步骤.md create mode 100644 康康/AI/Prompts/IntentPrompts.swift create mode 100644 康康/AI/Prompts/MedicationPrompts.swift create mode 100644 康康/Features/Profile/MedicationScanFlow.swift create mode 100644 康康/Features/Record/VoiceCommandSheet.swift create mode 100644 康康/Persistence/WidgetSnapshot.swift create mode 100644 康康/Persistence/WidgetSnapshotRefresher.swift create mode 100644 康康/Services/MedicationScanService.swift create mode 100644 康康/Services/VoiceIntentService.swift create mode 100644 康康Tests/MedicationScanServiceTests.swift create mode 100644 康康Tests/SpeechDictationMergeTests.swift create mode 100644 康康Tests/VoiceIntentServiceTests.swift diff --git a/KangkangWidget-src/KangkangWidgetBundle.swift b/KangkangWidget-src/KangkangWidgetBundle.swift new file mode 100644 index 0000000..93f8486 --- /dev/null +++ b/KangkangWidget-src/KangkangWidgetBundle.swift @@ -0,0 +1,11 @@ +import WidgetKit +import SwiftUI + +/// KangkangWidget extension 入口。 +/// W5 做 Live Activity 时,把 ActivityConfiguration 也注册进这个 Bundle。 +@main +struct KangkangWidgetBundle: WidgetBundle { + var body: some Widget { + PinnedIndicatorsWidget() + } +} diff --git a/KangkangWidget-src/PinnedIndicatorsWidget.swift b/KangkangWidget-src/PinnedIndicatorsWidget.swift new file mode 100644 index 0000000..478097e --- /dev/null +++ b/KangkangWidget-src/PinnedIndicatorsWidget.swift @@ -0,0 +1,249 @@ +import WidgetKit +import SwiftUI + +// MARK: - 快照模型(主 App 的独立拷贝) +// +// ⚠️ 同步契约:与主 App `康康/Persistence/WidgetSnapshot.swift` 字段必须一致。 +// extension 不引主 App 代码(免去 target membership 配置),改字段时两边一起改。 + +private struct WidgetSnapshot: Codable, Equatable { + struct Item: Codable, Equatable { + var name: String + var value: String + var unit: String + var statusRaw: String // high|low|normal + var capturedAt: Date + } + + var updatedAt: Date + var items: [Item] + + static let appGroupID = "group.com.xuhuayong.kangkang" + static let storeKey = "kk.widget.snapshot.v1" + + static func load() -> WidgetSnapshot? { + guard let defaults = UserDefaults(suiteName: appGroupID), + let data = defaults.data(forKey: storeKey) else { return nil } + return try? JSONDecoder().decode(WidgetSnapshot.self, from: data) + } +} + +// MARK: - 调色(镜像主 App Tj.Palette,extension 不引 DesignSystem) + +private enum KkColor { + static let sand = Color(red: 0.976, green: 0.969, blue: 0.949) + static let ink = Color(red: 0.165, green: 0.153, blue: 0.137) + static let text = Color(red: 0.149, green: 0.137, blue: 0.118) + static let text2 = Color(red: 0.420, green: 0.408, blue: 0.384) + static let text3 = Color(red: 0.616, green: 0.604, blue: 0.580) + static let brick = Color(red: 0.886, green: 0.388, blue: 0.314) // high + static let amber = Color(red: 0.871, green: 0.627, blue: 0.314) // low + static let leaf = Color(red: 0.180, green: 0.357, blue: 0.518) // normal +} + +private func statusColor(_ raw: String) -> Color { + switch raw { + case "high": return KkColor.brick + case "low": return KkColor.amber + default: return KkColor.leaf + } +} + +// MARK: - Timeline + +private struct PinnedEntry: TimelineEntry { + let date: Date + let items: [WidgetSnapshot.Item] + let updatedAt: Date? +} + +private struct PinnedProvider: TimelineProvider { + func placeholder(in context: Context) -> PinnedEntry { + PinnedEntry(date: .now, items: Self.sampleItems, updatedAt: .now) + } + + func getSnapshot(in context: Context, completion: @escaping (PinnedEntry) -> Void) { + if context.isPreview { + completion(placeholder(in: context)) + } else { + completion(currentEntry()) + } + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + // 数据由主 App 写快照后 reloadAllTimelines 主动推;这里 30 分钟兜底刷一次 + // (只为让"x 天前"的相对时间不至于太陈旧)。 + let entry = currentEntry() + let next = Calendar.current.date(byAdding: .minute, value: 30, to: .now) ?? .now + completion(Timeline(entries: [entry], policy: .after(next))) + } + + private func currentEntry() -> PinnedEntry { + let snap = WidgetSnapshot.load() + return PinnedEntry(date: .now, items: snap?.items ?? [], updatedAt: snap?.updatedAt) + } + + static let sampleItems: [WidgetSnapshot.Item] = [ + .init(name: "收缩压", value: "128", unit: "mmHg", statusRaw: "normal", + capturedAt: .now.addingTimeInterval(-3600 * 5)), + .init(name: "空腹血糖", value: "6.4", unit: "mmol/L", statusRaw: "high", + capturedAt: .now.addingTimeInterval(-3600 * 30)), + .init(name: "体重", value: "68.5", unit: "kg", statusRaw: "normal", + capturedAt: .now.addingTimeInterval(-3600 * 50)), + .init(name: "尿酸", value: "486", unit: "μmol/L", statusRaw: "high", + capturedAt: .now.addingTimeInterval(-3600 * 80)), + ] +} + +// MARK: - Views + +private struct PinnedIndicatorsView: View { + @Environment(\.widgetFamily) private var family + let entry: PinnedEntry + + var body: some View { + Group { + if entry.items.isEmpty { + emptyView + } else { + switch family { + case .systemMedium: mediumView + default: smallView + } + } + } + .containerBackground(for: .widget) { KkColor.sand } + } + + private var emptyView: some View { + VStack(spacing: 6) { + Image(systemName: "chart.line.uptrend.xyaxis") + .font(.system(size: 22)) + .foregroundStyle(KkColor.text3) + Text("在康康里关注指标后\n这里会显示最新值") + .font(.system(size: 11)) + .multilineTextAlignment(.center) + .foregroundStyle(KkColor.text3) + } + } + + /// 小尺寸:首条放大 + 其余最多 2 条小行。 + private var smallView: some View { + VStack(alignment: .leading, spacing: 6) { + header + if let first = entry.items.first { + VStack(alignment: .leading, spacing: 1) { + Text(first.name) + .font(.system(size: 11)) + .foregroundStyle(KkColor.text2) + HStack(alignment: .firstTextBaseline, spacing: 3) { + Text(first.value) + .font(.system(size: 24, weight: .semibold, design: .rounded)) + .foregroundStyle(statusColor(first.statusRaw)) + Text(first.unit) + .font(.system(size: 10)) + .foregroundStyle(KkColor.text3) + } + } + } + ForEach(entry.items.dropFirst().prefix(2), id: \.name) { item in + compactRow(item) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + /// 中尺寸:两列网格,最多 6 条。 + private var mediumView: some View { + VStack(alignment: .leading, spacing: 8) { + header + LazyVGrid(columns: [GridItem(.flexible(), spacing: 12), GridItem(.flexible())], + alignment: .leading, spacing: 8) { + ForEach(entry.items.prefix(6), id: \.name) { item in + gridCell(item) + } + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var header: some View { + HStack(spacing: 4) { + Text("康康 · 长期监测") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(KkColor.text3) + Spacer() + if let updatedAt = entry.updatedAt { + Text(updatedAt, style: .relative) + .font(.system(size: 9)) + .foregroundStyle(KkColor.text3) + } + } + } + + private func compactRow(_ item: WidgetSnapshot.Item) -> some View { + HStack(spacing: 4) { + Circle() + .fill(statusColor(item.statusRaw)) + .frame(width: 5, height: 5) + Text(item.name) + .font(.system(size: 10)) + .foregroundStyle(KkColor.text2) + .lineLimit(1) + Spacer(minLength: 2) + Text(item.value) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(KkColor.text) + } + } + + private func gridCell(_ item: WidgetSnapshot.Item) -> some View { + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Circle() + .fill(statusColor(item.statusRaw)) + .frame(width: 5, height: 5) + Text(item.name) + .font(.system(size: 10)) + .foregroundStyle(KkColor.text2) + .lineLimit(1) + } + HStack(alignment: .firstTextBaseline, spacing: 2) { + Text(item.value) + .font(.system(size: 15, weight: .semibold, design: .rounded)) + .foregroundStyle(KkColor.text) + Text(item.unit) + .font(.system(size: 8)) + .foregroundStyle(KkColor.text3) + .lineLimit(1) + } + } + } +} + +// MARK: - Widget + +struct PinnedIndicatorsWidget: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "PinnedIndicatorsWidget", provider: PinnedProvider()) { entry in + PinnedIndicatorsView(entry: entry) + } + .configurationDisplayName("长期监测") + .description("展示你关注的健康指标最新值。数据 100% 在本机。") + .supportedFamilies([.systemSmall, .systemMedium]) + } +} + +#Preview("small", as: .systemSmall) { + PinnedIndicatorsWidget() +} timeline: { + PinnedEntry(date: .now, items: PinnedProvider.sampleItems, updatedAt: .now) +} + +#Preview("medium", as: .systemMedium) { + PinnedIndicatorsWidget() +} timeline: { + PinnedEntry(date: .now, items: PinnedProvider.sampleItems, updatedAt: .now) +} diff --git a/docs/Widget接入步骤.md b/docs/Widget接入步骤.md new file mode 100644 index 0000000..5360d04 --- /dev/null +++ b/docs/Widget接入步骤.md @@ -0,0 +1,52 @@ +# 桌面 Widget 接入步骤(约 3 分钟,Xcode 操作) + +代码已全部写好。主 App 侧(快照写入 + RootView hook)已自动编译生效; +Widget extension 需要你在 Xcode 里建一次 target,再放入两个源文件。 + +## 1. 创建 Widget Extension target + +1. Xcode 打开 `康康.xcodeproj` → 菜单 **File → New → Target…** +2. 选 **iOS → Widget Extension**,点 Next +3. Product Name 填 **`KangkangWidget`** + - ❌ 不勾 "Include Live Activity"(W5 做 Live Activity 时再往这个 target 里加,Bundle 入口已留好注释) + - ❌ 不勾 "Include Configuration App Intent"(我们用 StaticConfiguration) +4. 点 Finish;弹出 "Activate scheme?" 选 **Activate** + +## 2. 替换模板代码 + +Xcode 会在工程根目录生成 `KangkangWidget/` 文件夹(含模板 swift 文件)。 + +1. 删除模板生成的所有 `.swift` 文件(`KangkangWidget.swift`、`KangkangWidgetBundle.swift`、`AppIntent.swift` 等,**保留 `Info.plist` 和 Assets**),选 "Move to Trash" +2. 把 `KangkangWidget-src/` 里的两个文件拖进 Xcode 的 `KangkangWidget` 文件夹(勾选 target:KangkangWidget): + - `KangkangWidgetBundle.swift` + - `PinnedIndicatorsWidget.swift` +3. 拖完后可删掉暂存目录 `KangkangWidget-src/` + +## 3. 配置 App Group(两个 target 都要) + +数据通过 App Group UserDefaults 传递,ID 固定为 **`group.com.xuhuayong.kangkang`**。 + +1. 选中工程 → target **康康** → Signing & Capabilities → **+ Capability → App Groups** → + 添加 `group.com.xuhuayong.kangkang` +2. target **KangkangWidget** → 同样添加 App Groups → 勾选同一个 `group.com.xuhuayong.kangkang` +3. KangkangWidget 的 **iOS Deployment Target 改成 17.0**(模板默认可能更高) + +> 个人开发者账号下 App Group 会自动注册;如签名报错,在两个 target 的 Signing 里确认 Team 一致。 + +## 4. 验证 + +1. scheme 切回 **康康**,跑真机/模拟器 +2. 进 App(首页出现即写入快照),回到桌面 → 长按 → 添加小组件 → 找 **康康 · 长期监测** +3. 小/中两个尺寸都支持。没有任何 pinned 指标时显示引导文案; + 在趋势页关注指标(或 C2「关联到趋势」)后,回桌面即可看到最新值 + +## 故障排查 + +- **小组件空白/不出现**:先确认两个 target 的 App Group 勾的是同一个 ID;再确认主 App 至少前台打开过一次(快照由主 App 写) +- **数据不更新**:快照在 App 进后台时刷新;强杀 App 不触发 `scenePhase == .background`,正常 Home 手势退出即可 +- **编译报 `containerBackground` 不存在**:KangkangWidget 的 Deployment Target 没改成 17.0 + +## 架构备忘(给后续会话) + +- 主 App 写快照:`康康/Persistence/WidgetSnapshot.swift`(数据契约)+ `WidgetSnapshotRefresher.swift`(pinned 指标 → App Group,RootView 在启动和进后台时调用) +- Widget 读快照:`KangkangWidget/PinnedIndicatorsWidget.swift` 内有 `WidgetSnapshot` 的**独立拷贝**(extension 不引主 App 代码)。⚠️ 改字段两边同步 +- Widget 不读 SwiftData:store 有文件保护且在主 App 沙盒,extension 锁屏时读不到;快照 = 最后一次看到的值,锁屏也能显示 diff --git a/康康.xcodeproj/project.pbxproj b/康康.xcodeproj/project.pbxproj index 33a54a5..30b9819 100644 --- a/康康.xcodeproj/project.pbxproj +++ b/康康.xcodeproj/project.pbxproj @@ -187,7 +187,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2600; - LastUpgradeCheck = 2600; + LastUpgradeCheck = 2650; TargetAttributes = { 5E463CF82FC403BB0089145B = { CreatedOnToolsVersion = 26.0.1; @@ -296,6 +296,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -325,6 +326,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = F2C8C774FG; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -348,6 +350,7 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; @@ -358,6 +361,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -387,6 +391,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = F2C8C774FG; ENABLE_NS_ASSERTIONS = NO; @@ -403,6 +408,7 @@ LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; }; name = Release; @@ -415,6 +421,7 @@ CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = F2C8C774FG; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -473,6 +480,7 @@ CODE_SIGN_ENTITLEMENTS = "康康/康康.entitlements"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = F2C8C774FG; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -529,6 +537,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = F2C8C774FG; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -556,6 +565,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = F2C8C774FG; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -582,6 +592,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = F2C8C774FG; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; @@ -608,6 +619,7 @@ buildSettings = { CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 5; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = F2C8C774FG; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; diff --git a/康康.xcodeproj/xcshareddata/xcschemes/康康.xcscheme b/康康.xcodeproj/xcshareddata/xcschemes/康康.xcscheme index 162559b..023ec5b 100644 --- a/康康.xcodeproj/xcshareddata/xcschemes/康康.xcscheme +++ b/康康.xcodeproj/xcshareddata/xcschemes/康康.xcscheme @@ -1,6 +1,6 @@ String { + classifyTemplate.replacingOccurrences(of: "{{TEXT}}", with: String(utterance.prefix(120))) + } + + private static let classifyTemplate: String = #""" +你是健康 App 的语音意图分类器。用户长按「新建」按钮说了一句话,判断 ta 想打开哪个功能。 +请只输出一段合法 JSON,格式 {"intent":"<分类>"},不要解释、不要 markdown 围栏、不要任何前后缀文字。 + +分类(只能选下面其中一个): +- "diary" 写日记,记录今天的感受、饮食、睡眠、身体状态 +- "medication" 记录用药、拍药盒、吃了什么药 +- "symptom" 记录症状,哪里不舒服(头疼、咳嗽、发烧、头晕…) +- "indicator" 记录指标数值(血压、血糖、体重、心率、体温…) +- "archive" 归档整份体检报告/化验单(拍报告存档) +- "export" 生成给医生看的身体档案/健康总结 +- "reminder" 设置周期提醒 +- "unknown" 无法判断 + +规则: +- 说到「提醒我…」一律 "reminder",即使内容涉及吃药或量血压。 +- 只是陈述吃了什么药 → "medication";只是陈述哪里不舒服 → "symptom"。 +- 既像日记又提到具体数值时,以数值为准 → "indicator"。 + +示例: +"帮我记一下今天的血压,高压128低压85" → {"intent":"indicator"} +"我今天有点头疼,想记录一下" → {"intent":"symptom"} +"刚买了一盒降压药,拍一下存进去" → {"intent":"medication"} +"今天睡得不错,写个日记" → {"intent":"diary"} +"把这份体检报告存档" → {"intent":"archive"} +"每天早上八点提醒我量血压" → {"intent":"reminder"} +"整理一份给医生看的健康总结" → {"intent":"export"} + +现在判断下面这句话,只输出 JSON。/no_think + +用户的话:{{TEXT}} +"""# +} diff --git a/康康/AI/Prompts/MedicationPrompts.swift b/康康/AI/Prompts/MedicationPrompts.swift new file mode 100644 index 0000000..470139f --- /dev/null +++ b/康康/AI/Prompts/MedicationPrompts.swift @@ -0,0 +1,51 @@ +import Foundation + +/// 「拍药盒入档」prompt:Vision OCR 出药盒/说明书/处方文字后, +/// 交 LLM(Qwen,MNN/SME2 主链路)结构化抽药品名 + 规格 + 用法。 +/// 输出契约:严格 JSON;解析失败 → UI 回退手动录入(§3.2 失败回退红线)。 +/// 注意:只做"识别入档",不做剂量推荐/用药提醒(§1 明确不做)。 +nonisolated enum MedicationPrompts { + + static func medicationsFromText(_ ocrText: String) -> String { + medicationsFromTextTemplate + .replacingOccurrences(of: "{{OCR_TEXT}}", with: VLPrompts.clipOCR(ocrText, limit: 1200)) + } + + private static let medicationsFromTextTemplate: String = #""" +你是药品包装识别助手。下面是对一张药盒、药品说明书或处方单做 OCR 得到的纯文本,可能有错字、换行混乱或无关噪声。 +请从中提取药品信息,只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。 + +JSON schema(严格): +{ + "medications": [ + { + "name": string, // 药品通用名或商品名,如 "缬沙坦胶囊" + "strength": string, // 规格,如 "80mg"、"0.5g×24片";识别不出填 "" + "usage": string // 用法用量,如 "每日一次,一次一粒";包装上没有就填 "" + } + ] +} + +规则: +- 只提取药品本身;"国药准字"批准文号、生产厂家、批号、有效期、条形码一律忽略。 +- 一张药盒通常只有 1 种药;处方单可能有多种,都要提取。 +- 不要发明药品。名称读不清的整条跳过;strength / usage 读不清就填 "",不要编造。 +- 不要输出任何服药建议或剂量调整建议,只抄录包装上已有的文字。 +- 同一药品只输出一次。 + +示例 1(药盒): +输入 OCR 文本: 缬沙坦胶囊 80mg×7粒 国药准字H20103521 XX药业有限公司 +输出: +{"medications":[{"name":"缬沙坦胶囊","strength":"80mg×7粒","usage":""}]} + +示例 2(说明书含用法): +输入 OCR 文本: 二甲双胍缓释片 0.5g×30片 用法用量:口服,一次1片,一日2次,随餐服用 +输出: +{"medications":[{"name":"二甲双胍缓释片","strength":"0.5g×30片","usage":"口服,一次1片,一日2次,随餐服用"}]} + +现在请解析下面这段 OCR 文本,只输出 JSON。/no_think + +OCR 文本: +{{OCR_TEXT}} +"""# +} diff --git a/康康/Features/Capture/PhotoPickerSheet.swift b/康康/Features/Capture/PhotoPickerSheet.swift index 5ae7949..f25c31e 100644 --- a/康康/Features/Capture/PhotoPickerSheet.swift +++ b/康康/Features/Capture/PhotoPickerSheet.swift @@ -32,8 +32,14 @@ struct PhotoPickerSheet: View { .clipShape(Capsule()) } - Button("取消", action: onCancel) - .foregroundStyle(Tj.Palette.text3) + Button(action: onCancel) { + Text("取消") + .foregroundStyle(Tj.Palette.text3) + .padding(.horizontal, 24) + .frame(minHeight: 44) // HIG 最小命中区 + .contentShape(Rectangle()) + } + .buttonStyle(.plain) if loading { ProgressView().tint(Tj.Palette.ink) diff --git a/康康/Features/Capture/UnifiedCaptureFlow.swift b/康康/Features/Capture/UnifiedCaptureFlow.swift index 05bdcd3..4dff4bb 100644 --- a/康康/Features/Capture/UnifiedCaptureFlow.swift +++ b/康康/Features/Capture/UnifiedCaptureFlow.swift @@ -362,10 +362,16 @@ private struct AnalyzingView: View { .foregroundStyle(Tj.Palette.amber) } } - Button("取消识别 · 改为手动录入", action: onCancel) - .font(.tjScaled( 13, weight: .medium)) - .foregroundStyle(Tj.Palette.text3) - .padding(.top, 4) + Button(action: onCancel) { + Text("取消识别 · 改为手动录入") + .font(.tjScaled( 13, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + .padding(.horizontal, 20) + .frame(minHeight: 44) // HIG 最小命中区 + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .padding(.top, 4) Spacer() } .padding(.horizontal, 20) diff --git a/康康/Features/Diary/DiaryQuickSheet.swift b/康康/Features/Diary/DiaryQuickSheet.swift index 1e05ded..f88f7f1 100644 --- a/康康/Features/Diary/DiaryQuickSheet.swift +++ b/康康/Features/Diary/DiaryQuickSheet.swift @@ -11,6 +11,10 @@ struct DiaryQuickSheet: View { @State private var content: String = "" @State private var createdAt: Date = .now + /// 「拍药盒」分支:全屏扫描流程,确认后存为带「用药」tag 的日记。 + @State private var showMedicationScan = false + /// 「记症状」分支:嵌套弹出 SymptomStartSheet(自带保存/取消,关闭后回到本页)。 + @State private var showSymptomStart = false /// AI 辅助状态 enum AssistPhase { @@ -92,6 +96,24 @@ struct DiaryQuickSheet: View { .foregroundStyle(Tj.Palette.text3) } .padding(.horizontal, 20) + .padding(.bottom, 10) + + // 入口三选一:写日记(本页)/ 拍药盒(存「用药」日记)/ 记症状(SymptomStartSheet) + HStack(spacing: 10) { + modeCard(icon: "pencil", title: String(appLoc: "写日记"), + subtitle: String(appLoc: "文字或语音"), active: true) { + contentFocused = true + } + modeCard(icon: "pills.fill", title: String(appLoc: "拍药盒"), + subtitle: String(appLoc: "识别用药"), active: false) { + showMedicationScan = true + } + modeCard(icon: "waveform.path.ecg", title: String(appLoc: "记症状"), + subtitle: String(appLoc: "持续追踪"), active: false) { + showSymptomStart = true + } + } + .padding(.horizontal, 20) .padding(.bottom, 14) ScrollViewReader { proxy in @@ -228,6 +250,20 @@ struct DiaryQuickSheet: View { .presentationDragIndicator(.hidden) .presentationBackground(Tj.Palette.sand) .presentationCornerRadius(Tj.Radius.xl) + .fullScreenCover(isPresented: $showMedicationScan) { + MedicationScanFlow( + onSave: { entries in + // 落库:「用药」日记(进记录时间线)+ 同步个人资料·当前用药。 + MedicationArchiver.archive(entries: entries, in: ctx) + dismiss() + }, + onClose: { showMedicationScan = false } + ) + } + .sheet(isPresented: $showSymptomStart) { + // 嵌套 sheet:症状表单自带保存/取消;取消回到日记,不强行关闭。 + SymptomStartSheet() + } .onDisappear { suggestTask?.cancel() voiceFlowTask?.cancel() @@ -555,6 +591,41 @@ struct DiaryQuickSheet: View { .foregroundStyle(Tj.Palette.text2) } + /// 顶部入口三选一卡片(写日记 / 拍药盒 / 记症状)。active 表示当前所在模式。 + /// 竖排紧凑布局:三卡并排在 iPhone 宽度下横排放不下完整文案。 + private func modeCard(icon: String, title: String, subtitle: String, + active: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 5) { + Image(systemName: icon) + .font(.tjScaled( 15, weight: .medium)) + .foregroundStyle(active ? Tj.Palette.paper : Tj.Palette.ink) + .frame(width: 28, height: 28) + .background(Circle().fill(active ? Tj.Palette.ink : Tj.Palette.sand2)) + Text(title) + .font(.tjScaled( 13, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text(subtitle) + .font(.tjScaled( 10)) + .foregroundStyle(Tj.Palette.text3) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(active ? Tj.Palette.ink : Tj.Palette.line, + lineWidth: active ? 1.5 : 1) + ) + .contentShape(RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)) + } + .buttonStyle(.plain) + } + // MARK: 语音输入流程 private func startVoice() { diff --git a/康康/Features/Profile/MedicationScanFlow.swift b/康康/Features/Profile/MedicationScanFlow.swift new file mode 100644 index 0000000..064a586 --- /dev/null +++ b/康康/Features/Profile/MedicationScanFlow.swift @@ -0,0 +1,285 @@ +import SwiftUI +import SwiftData +import UIKit + +/// 「拍药盒入档」流程:拍药盒/说明书 → Vision OCR → LLM 结构化 → 核对 → 落库。 +/// 入口:「+ 新建 · 健康日记 · 拍药盒」与「我的 · 个人资料 · 当前用药」。 +/// 两个入口确认后都走 `MedicationArchiver`:记一条「用药」日记(进记录时间线)+ 同步当前用药。 +/// 只识别入档,不做用药提醒/剂量建议(§1)。 +/// +/// 状态机(与 QuickRegionCaptureFlow 同构): +/// ``` +/// idle(相机/相册) → recognizing(OCR + LLM) → confirm(核对可编辑) → onSave → 关闭 +/// │ 失败/没读出 ──────► confirm(空行 + 警示文案,手动补) +/// ``` +struct MedicationScanFlow: View { + /// 用户确认后回传条目文本(非空,如 "缬沙坦胶囊 80mg · 一日一次")。落库由调用方做。 + let onSave: ([String]) -> Void + let onClose: () -> Void + + @State private var phase: Phase = .idle + /// 识别任务句柄:识别中点「取消」要能立刻中断,不留后台推理。 + @State private var recognitionTask: Task? + + enum Phase { + case idle + case recognizing(image: UIImage) + case confirm(items: [EditableMedication], warning: String?) + } + + struct EditableMedication: Identifiable { + let id = UUID() + var name: String + var strength: String + var usage: String + var include: Bool = true + } + + var body: some View { + content + .background(Tj.Palette.sand.ignoresSafeArea()) + } + + @ViewBuilder + private var content: some View { + switch phase { + case .idle: + // 不整体 ignoresSafeArea:相机内部已全屏黑底,忽略安全区会让「取消」顶进灵动岛。 + captureEntry + + case .recognizing(let image): + recognizingView(image: image) + + case .confirm(let items, let warning): + NavigationStack { + MedicationConfirmView( + items: items, + warning: warning, + onSave: { saveItems($0) }, + onRetake: { phase = .idle } + ) + .navigationTitle("核对药品") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("取消") { onClose() } + .foregroundStyle(Tj.Palette.text) + } + } + } + } + } + + // MARK: - 入口:拍照(真机)/ 相册(模拟器) + + @ViewBuilder + private var captureEntry: some View { + #if targetEnvironment(simulator) + PhotoPickerSheet( + onFinish: { images in + if let first = images.first { startRecognition(first) } else { onClose() } + }, + onCancel: onClose + ) + #else + SingleShotCameraView( + onCapture: { startRecognition($0) }, + onCancel: onClose + ) + #endif + } + + private func recognizingView(image: UIImage) -> some View { + VStack(spacing: 18) { + Image(uiImage: image) + .resizable() + .scaledToFit() + .frame(maxHeight: 320) + .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)) + .padding(.horizontal, 24) + ProgressView().tint(Tj.Palette.ink) + Text("正在本地识别药品…") + .font(.tjScaled(14)) + .foregroundStyle(Tj.Palette.text2) + Text("照片与文字均不离开设备") + .font(.tjScaled(12)) + .foregroundStyle(Tj.Palette.text3) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + // 识别中也要能退出,不能让用户干等(§3.2 不卡死) + .overlay(alignment: .topLeading) { + Button { + recognitionTask?.cancel() + onClose() + } label: { + Text("取消") + .font(.tjScaled( 16, weight: .medium)) + .foregroundStyle(Tj.Palette.text) + .padding(.horizontal, 18) + .frame(minHeight: 44) + .background(Capsule().fill(Tj.Palette.paper)) + .overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .padding(.leading, 16) + .padding(.top, 8) + } + } + + // MARK: - 识别(整图 OCR → LLM 结构化) + + private func startRecognition(_ image: UIImage) { + phase = .recognizing(image: image) + recognitionTask = Task { + let (items, warning) = await recognize(image) + guard !Task.isCancelled else { return } // 识别中点了取消:不再回写 phase + await MainActor.run { + // 全失败也不卡死:给一条空行让用户手填(§3.2 失败回退红线)。 + if items.isEmpty { + phase = .confirm(items: [EditableMedication(name: "", strength: "", usage: "")], + warning: warning ?? String(appLoc: "没读出药品,可以手动填写")) + } else { + phase = .confirm(items: items, warning: warning) + } + } + } + } + + private func recognize(_ image: UIImage) async -> (items: [EditableMedication], warning: String?) { + do { + let text = try await OCRService.recognizeText(in: image) + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return ([], String(appLoc: "没识别到文字,拍清楚一点再试")) + } + let parsed = try await MedicationScanService.shared.recognizeMedications(fromOCRText: trimmed) + let items = parsed.map { + EditableMedication(name: $0.name, strength: $0.strength, usage: $0.usage) + } + return (items, items.isEmpty ? String(appLoc: "没读出药品,可以手动填写") : nil) + } catch CaptureError.modelNotReady { + return ([], String(appLoc: "AI 模型未就绪,可以手动填写")) + } catch let CaptureError.parseFailed(msg) { + return ([], String(appLoc: "解析失败:\(msg)")) + } catch let CaptureError.inferenceFailed(msg) { + return ([], String(appLoc: "识别失败:\(msg)")) + } catch { + return ([], String(appLoc: "未知错误:\(error.localizedDescription)")) + } + } + + // MARK: - 保存 + + private func saveItems(_ items: [EditableMedication]) { + let entries = items + .filter { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty } + .map { + ParsedMedication(name: $0.name, strength: $0.strength, usage: $0.usage).entryText + } + onSave(entries) + onClose() + } +} + +// MARK: - 统一落库(MainActor,SwiftData 写主上下文必须由 View 侧持有的 ctx 来做,§3.1) + +/// 拍药盒确认后的统一落库,两个入口共用: +/// 1. 记一条带「用药」tag 的 DiaryEntry → 出现在「记录」时间线的「用药」分类 +/// 2. 同步到 UserProfile.currentMedications(去重)→ AI 解读 / 身体档案 prompt 背景 +@MainActor +enum MedicationArchiver { + static func archive(entries: [String], in ctx: ModelContext) { + guard !entries.isEmpty else { return } + let diary = DiaryEntry(content: entries.joined(separator: "\n"), + tags: [DiaryEntry.medicationTag]) + ctx.insert(diary) + + let profile = UserProfileStore.loadOrCreate(in: ctx) + for entry in entries where !profile.currentMedications.contains(entry) { + profile.currentMedications.append(entry) + } + profile.updatedAt = .now + try? ctx.save() + } +} + +// MARK: - 核对页 + +private struct MedicationConfirmView: View { + @State var items: [MedicationScanFlow.EditableMedication] + let warning: String? + let onSave: ([MedicationScanFlow.EditableMedication]) -> Void + let onRetake: () -> Void + + private var canSave: Bool { + items.contains { + $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty + } + } + + var body: some View { + VStack(spacing: 0) { + Form { + if let warning { + Section { + Label(warning, systemImage: "exclamationmark.triangle") + .font(.tjScaled(13)) + .foregroundStyle(Tj.Palette.amber) + } + } + + ForEach($items) { $item in + Section { + HStack { + TextField(String(appLoc: "药品名,如:缬沙坦胶囊"), text: $item.name) + .foregroundStyle(Tj.Palette.text) + Toggle("", isOn: $item.include) + .labelsHidden() + .tint(Tj.Palette.ink) + } + TextField(String(appLoc: "规格,如:80mg×7粒"), text: $item.strength) + .foregroundStyle(Tj.Palette.text2) + TextField(String(appLoc: "用法,如:一日一次,一次一粒"), text: $item.usage) + .foregroundStyle(Tj.Palette.text2) + } + } + + Section { + Button { + items.append(.init(name: "", strength: "", usage: "")) + } label: { + Label("再加一种", systemImage: "plus.circle") + .foregroundStyle(Tj.Palette.ink) + } + Button { + onRetake() + } label: { + Label("重拍", systemImage: "camera") + .foregroundStyle(Tj.Palette.ink) + } + } footer: { + Text("将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。") + } + } + .scrollContentBackground(.hidden) + + Button { + onSave(items) + } label: { + Text("保存用药记录") + .frame(maxWidth: .infinity) + } + .buttonStyle(TjPrimaryButton()) + .disabled(!canSave) + .opacity(canSave ? 1 : 0.4) + .padding(.horizontal, 18) + .padding(.bottom, 12) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + } +} + +#Preview { + MedicationScanFlow(onSave: { print($0) }, onClose: {}) +} diff --git a/康康/Features/Profile/ProfileEditView.swift b/康康/Features/Profile/ProfileEditView.swift index 0ad90a4..1774ed5 100644 --- a/康康/Features/Profile/ProfileEditView.swift +++ b/康康/Features/Profile/ProfileEditView.swift @@ -38,6 +38,7 @@ private struct ProfileEditForm: View { @State private var healthImportDraft: HealthProfileImportDraft? @State private var healthImportError: String? @State private var isImportingHealthProfile = false + @State private var showMedicationScan = false var body: some View { Form { @@ -88,7 +89,8 @@ private struct ProfileEditForm: View { StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"), items: $profile.familyHistory) StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"), - items: $profile.currentMedications) + items: $profile.currentMedications, + onScan: { showMedicationScan = true }) } .navigationTitle("个人资料") .navigationBarTitleDisplayMode(.inline) @@ -98,6 +100,16 @@ private struct ProfileEditForm: View { profile.updatedAt = .now try? ctx.save() } + .fullScreenCover(isPresented: $showMedicationScan) { + // 拍药盒 → 本地 OCR + LLM 识别 → 核对 → 统一落库: + // 记一条「用药」日记(进记录时间线)+ 同步当前用药(去重)。 + MedicationScanFlow( + onSave: { entries in + MedicationArchiver.archive(entries: entries, in: ctx) + }, + onClose: { showMedicationScan = false } + ) + } .sheet(item: $healthImportDraft) { draft in HealthProfileImportPreviewSheet( draft: draft, @@ -456,10 +468,27 @@ private struct StringListSection: View { let title: String let placeholder: String @Binding var items: [String] + /// 非 nil 时在节内显示「拍药盒自动识别」入口(目前仅「当前用药」用)。 + var onScan: (() -> Void)? = nil @State private var newInput = "" var body: some View { Section(title) { + if let onScan { + Button(action: onScan) { + HStack(spacing: 10) { + Image(systemName: "camera.viewfinder") + .foregroundStyle(Tj.Palette.ink) + VStack(alignment: .leading, spacing: 2) { + Text("拍药盒自动识别") + .foregroundStyle(Tj.Palette.text) + Text("拍药盒或说明书,本地识别药名与规格") + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text3) + } + } + } + } ForEach(items, id: \.self) { item in HStack { Text(item) diff --git a/康康/Features/Quick/QuickRegionCaptureFlow.swift b/康康/Features/Quick/QuickRegionCaptureFlow.swift index 1103dc5..28e2cff 100644 --- a/康康/Features/Quick/QuickRegionCaptureFlow.swift +++ b/康康/Features/Quick/QuickRegionCaptureFlow.swift @@ -32,8 +32,9 @@ struct QuickRegionCaptureFlow: View { private var content: some View { switch phase { case .idle: + // 不再整体 ignoresSafeArea:相机/框选内部已各自做全屏黑底, + // 这里再忽略安全区会把「取消」顶进灵动岛,几乎点不到。 captureEntry - .ignoresSafeArea() case .adjust(let image): RegionAdjustView( @@ -45,7 +46,6 @@ struct QuickRegionCaptureFlow: View { onRetake: { phase = .idle }, onCancel: { onClose() } ) - .ignoresSafeArea() case .confirm(let image, let items, let warning): NavigationStack { diff --git a/康康/Features/Quick/RegionAdjustView.swift b/康康/Features/Quick/RegionAdjustView.swift index 2c5c481..66e302f 100644 --- a/康康/Features/Quick/RegionAdjustView.swift +++ b/康康/Features/Quick/RegionAdjustView.swift @@ -50,7 +50,11 @@ struct RegionAdjustView: View { Text("取消") .font(.tjScaled( 16, weight: .medium)) .foregroundStyle(.white) + .padding(.horizontal, 12) + .frame(minWidth: 60, minHeight: 44) // HIG 最小命中区,命中整块而非文字 + .contentShape(Rectangle()) } + .buttonStyle(.plain) Spacer() Text("框住异常指标") .font(.tjScaled( 16, weight: .semibold)) @@ -63,10 +67,14 @@ struct RegionAdjustView: View { Text("重拍") .font(.tjScaled( 16, weight: .medium)) .foregroundStyle(.white) + .padding(.horizontal, 12) + .frame(minWidth: 60, minHeight: 44) + .contentShape(Rectangle()) } + .buttonStyle(.plain) } - .padding(.horizontal, 18) - .padding(.vertical, 12) + .padding(.horizontal, 8) + .padding(.vertical, 4) .background(Color.black) } diff --git a/康康/Features/Quick/RegionCameraView.swift b/康康/Features/Quick/RegionCameraView.swift index 3951adb..929a89d 100644 --- a/康康/Features/Quick/RegionCameraView.swift +++ b/康康/Features/Quick/RegionCameraView.swift @@ -49,13 +49,15 @@ struct SingleShotCameraView: View { Text("取消") .font(.tjScaled( 16, weight: .medium)) .foregroundStyle(.white) - .padding(.horizontal, 14) - .padding(.vertical, 8) + .padding(.horizontal, 18) + .frame(minHeight: 44) // 苹果 HIG 最小命中区 .background(Capsule().fill(.black.opacity(0.35))) + .contentShape(Capsule()) } + .buttonStyle(.plain) Spacer() } - .padding(.horizontal, 18) + .padding(.horizontal, 16) .padding(.top, 8) Spacer() diff --git a/康康/Features/Record/RecordSheet.swift b/康康/Features/Record/RecordSheet.swift index a116246..3d3eeac 100644 --- a/康康/Features/Record/RecordSheet.swift +++ b/康康/Features/Record/RecordSheet.swift @@ -5,8 +5,15 @@ enum RecordKind: String, Identifiable, CaseIterable { var id: String { rawValue } /// RecordSheet 列表的展示顺序(从上到下)。与 enum 声明序解耦,改顺序只动这里。 - /// 注:`.quick`(指标速记)已并入 `.indicator`(记录指标)内的「拍照识别」,不再单列。 - static let displayOrder: [RecordKind] = [.diary, .reminder, .symptom, .indicator, .healthExport, .archive] + /// 注:`.quick`(指标速记)已并入 `.indicator`(记录指标)内的「拍照识别」; + /// `.symptom`(记录症状)与拍药盒一起并入 `.diary`(健康日记)顶部三选一,不再单列。 + static let displayOrder: [RecordKind] = [.diary, .reminder, .indicator, .healthExport, .archive] + + /// 健康日记行的功能提示 pill(代替 subtitle,让"症状/药盒在日记里"一眼可见)。 + /// 计算属性:每次按当前语言解析,语言切换即时更新(同 ProfileEditView 的 presets 约定)。 + static var diaryFeaturePills: [String] { + [String(appLoc: "写日记"), String(appLoc: "拍药盒"), String(appLoc: "记症状")] + } var title: String { switch self { @@ -25,7 +32,7 @@ enum RecordKind: String, Identifiable, CaseIterable { case .indicator: return String(appLoc: "手动填写,或拍照自动识别") case .healthExport: return String(appLoc: "多轮问答后生成给医生看的整理报告") case .archive: return String(appLoc: "完整保存整份报告(可多页)") - case .diary: return String(appLoc: "记录身体状态、用药、感受 · 可让 AI 辅助") + case .diary: return String(appLoc: "写日记或拍药盒记录用药 · 可让 AI 辅助") case .symptom: return String(appLoc: "开始一个持续症状,结束时再点结束") case .reminder: return String(appLoc: "管理用药、复查、监测的周期提醒") } @@ -93,13 +100,27 @@ struct RecordSheet: View { } .frame(width: 44, height: 44) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 3) { Text(kind.title) .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) - Text(kind.subtitle) - .font(.tjScaled( 12)) - .foregroundStyle(Tj.Palette.text3) + if kind == .diary { + // 醒目提示:症状/药盒已并入日记,用 pill 直接点名 + HStack(spacing: 5) { + ForEach(RecordKind.diaryFeaturePills, id: \.self) { pill in + Text(pill) + .font(.tjScaled( 10, weight: .medium)) + .foregroundStyle(Tj.Palette.ink) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(Capsule().fill(Tj.Palette.sand2)) + } + } + } else { + Text(kind.subtitle) + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text3) + } } Spacer() Image(systemName: "chevron.right") @@ -111,6 +132,17 @@ struct RecordSheet: View { } .buttonStyle(.plain) } + + // 语音直达提示:长按 + 即可说话,不用翻菜单 + HStack(spacing: 5) { + Image(systemName: "mic.fill") + .font(.tjScaled( 10)) + Text("下次试试长按 + ,直接说出想记的内容") + .font(.tjScaled( 11)) + } + .foregroundStyle(Tj.Palette.text3) + .frame(maxWidth: .infinity) + .padding(.top, 6) } .padding(.bottom, 22) } diff --git a/康康/Features/Record/VoiceCommandSheet.swift b/康康/Features/Record/VoiceCommandSheet.swift new file mode 100644 index 0000000..15de736 --- /dev/null +++ b/康康/Features/Record/VoiceCommandSheet.swift @@ -0,0 +1,276 @@ +import SwiftUI +import UIKit + +/// 「长按 + 语音直达」面板:开口说想记什么 → 端侧转写(SpeechDictationService) +/// → LLM 意图分类(VoiceIntentService)→ 回调 RootView 打开对应新建入口。 +/// +/// 状态机: +/// ``` +/// requesting(权限) → recording(实时字幕) → classifying → onResolve(intent) 关闭 +/// │ 拒绝 → denied │ 没听到/没听懂 → failed(再说一次 / 打开菜单) +/// ``` +/// 全程本机:转写 requiresOnDeviceRecognition,分类走端侧 LLM。 +struct VoiceCommandSheet: View { + /// 识别成功:RootView 负责关闭本 sheet 并路由。 + let onResolve: (VoiceIntent) -> Void + /// 兜底:打开普通新建菜单(RecordSheet)。 + let onOpenMenu: () -> Void + @Environment(\.dismiss) private var dismiss + + enum Phase: Equatable { + case requesting + case denied + case recording + case classifying + case failed(message: String) + } + + @State private var phase: Phase = .requesting + @State private var transcript = "" + @State private var seconds = 0 + /// @State 保证视图身份期内实例唯一(同 DiaryQuickSheet 的注释,防止重建后麦克风悬挂)。 + @State private var dictation = SpeechDictationService() + @State private var ticker: Task? + + /// 录音超过 20s 自动结束:语音直达说的都是短句,长录是忘了点完成。 + private let maxSeconds = 20 + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Tj.Palette.line) + .frame(width: 40, height: 4) + .padding(.top, 10) + .padding(.bottom, 16) + + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("说出想记的内容") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + Text("比如:记一下血压 / 我头疼 / 拍个药盒") + .font(.tjScaled( 11)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer() + Text("全程本机") + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(.horizontal, 20) + .padding(.bottom, 16) + + content + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.horizontal, 20) + + buttons + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + .background( + Tj.Palette.sand + .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) + .ignoresSafeArea(edges: .bottom) + ) + .presentationDetents([.fraction(0.5)]) + .presentationDragIndicator(.hidden) + .presentationBackground(Tj.Palette.sand) + .presentationCornerRadius(Tj.Radius.xl) + .task { await begin() } + .onDisappear { + ticker?.cancel() + dictation.abort() + } + } + + // MARK: - 分阶段内容 + + @ViewBuilder + private var content: some View { + switch phase { + case .requesting: + ProgressView().tint(Tj.Palette.ink) + .frame(maxWidth: .infinity) + .padding(.top, 30) + + case .denied: + VStack(spacing: 10) { + Image(systemName: "mic.slash") + .font(.tjScaled( 30)) + .foregroundStyle(Tj.Palette.text3) + Text("需要麦克风与语音识别权限") + .font(.tjScaled( 14, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text("语音和文字都只在本机处理,不会上传。") + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text3) + Button("前往设置") { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + } + .font(.tjScaled( 13, weight: .semibold)) + .foregroundStyle(Tj.Palette.ink) + } + .frame(maxWidth: .infinity) + .padding(.top, 16) + + case .recording: + VStack(spacing: 14) { + HStack(spacing: 8) { + Circle() + .fill(Tj.Palette.brick) + .frame(width: 8, height: 8) + Text("正在听 · \(seconds)s") + .font(.tjScaled( 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.brick) + } + transcriptBox(placeholder: String(appLoc: "请开口说话…")) + } + + case .classifying: + VStack(spacing: 14) { + HStack(spacing: 8) { + ProgressView().tint(Tj.Palette.ink) + Text("正在理解…") + .font(.tjScaled( 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.text2) + } + transcriptBox(placeholder: "") + } + + case .failed(let message): + VStack(spacing: 10) { + Image(systemName: "questionmark.bubble") + .font(.tjScaled( 28)) + .foregroundStyle(Tj.Palette.text3) + Text(message) + .font(.tjScaled( 13)) + .foregroundStyle(Tj.Palette.text2) + .multilineTextAlignment(.center) + if !transcript.isEmpty { + Text("“\(transcript)”") + .font(.tjScaled( 12)) + .foregroundStyle(Tj.Palette.text3) + .lineLimit(2) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 12) + } + } + + private func transcriptBox(placeholder: String) -> some View { + ScrollView(showsIndicators: false) { + Text(transcript.isEmpty ? placeholder : transcript) + .font(.tjScaled( 15)) + .foregroundStyle(transcript.isEmpty ? Tj.Palette.text3 : Tj.Palette.text) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(minHeight: 64, maxHeight: 110) + .padding(.horizontal, 14) + .padding(.vertical, 12) + .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) + ) + } + + // MARK: - 底部按钮 + + @ViewBuilder + private var buttons: some View { + switch phase { + case .recording: + HStack(spacing: 12) { + Button("取消") { dismiss() } + .buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18)) + Button("说完了") { finishRecording() } + .buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18)) + } + case .failed: + HStack(spacing: 12) { + Button("打开新建菜单") { onOpenMenu() } + .buttonStyle(TjGhostButton(height: 44, fontSize: 14, horizontalPadding: 14)) + Button("再说一次") { Task { await begin() } } + .buttonStyle(TjPrimaryButton(height: 44, fontSize: 14, horizontalPadding: 18)) + } + case .denied: + Button("取消") { dismiss() } + .buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18)) + case .requesting, .classifying: + Button("取消") { dismiss() } + .buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18)) + } + } + + // MARK: - 流程 + + private func begin() async { + ticker?.cancel() + transcript = "" + seconds = 0 + guard SpeechDictationService.isAvailable else { + phase = .failed(message: String(appLoc: "本机不支持端侧语音识别,试试下面的新建菜单")) + return + } + phase = .requesting + guard await dictation.requestAuthorization() else { + phase = .denied + return + } + do { + try dictation.start { transcript = $0 } + phase = .recording + startTicker() + } catch { + phase = .failed(message: error.localizedDescription) + } + } + + private func startTicker() { + ticker = Task { @MainActor in + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard phase == .recording else { return } + seconds += 1 + if seconds >= maxSeconds { + finishRecording() + return + } + } + } + } + + private func finishRecording() { + guard phase == .recording else { return } + ticker?.cancel() + phase = .classifying + Task { + let text = await dictation.stop() + transcript = text + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + phase = .failed(message: String(appLoc: "没听到内容,再试一次?")) + return + } + if let intent = await VoiceIntentService.classify(trimmed) { + onResolve(intent) + } else { + phase = .failed(message: String(appLoc: "没听懂想记什么,再说一次,或直接选菜单")) + } + } + } +} + +#Preview { + Text("bg") + .sheet(isPresented: .constant(true)) { + VoiceCommandSheet(onResolve: { print($0) }, onOpenMenu: {}) + } +} diff --git a/康康/Features/Timeline/TimelineEntry.swift b/康康/Features/Timeline/TimelineEntry.swift index ef639e9..c83ab9d 100644 --- a/康康/Features/Timeline/TimelineEntry.swift +++ b/康康/Features/Timeline/TimelineEntry.swift @@ -3,33 +3,36 @@ import SwiftData import Foundation enum TimelineKind: String, CaseIterable, Identifiable { - case indicator, report, symptom, diary + case diary, symptom, indicator, medication, report var id: String { rawValue } var label: String { switch self { - case .indicator: return String(appLoc: "指标") - case .report: return String(appLoc: "报告") - case .symptom: return String(appLoc: "症状") - case .diary: return String(appLoc: "日记") + case .indicator: return String(appLoc: "指标") + case .report: return String(appLoc: "报告") + case .symptom: return String(appLoc: "症状") + case .diary: return String(appLoc: "日记") + case .medication: return String(appLoc: "用药") } } var icon: String { switch self { - case .indicator: return "drop.fill" - case .report: return "doc.fill" - case .symptom: return "waveform.path.ecg" - case .diary: return "pencil" + case .indicator: return "drop.fill" + case .report: return "doc.fill" + case .symptom: return "waveform.path.ecg" + case .diary: return "pencil" + case .medication: return "pills.fill" } } var accent: Color { switch self { - case .indicator: return Tj.Palette.brick - case .report: return Tj.Palette.ink2 - case .symptom: return Tj.Palette.amber - case .diary: return Tj.Palette.leaf + case .indicator: return Tj.Palette.brick + case .report: return Tj.Palette.ink2 + case .symptom: return Tj.Palette.amber + case .diary: return Tj.Palette.leaf + case .medication: return Tj.Palette.ink } } } @@ -132,13 +135,16 @@ struct TimelineEntry: Identifiable, Hashable { } } + /// 带「用药」tag 的日记(拍药盒入档)归到 .medication 分类,其余是普通文字日记。 + /// id 统一用 "diary-" 前缀:TimelineDetail.resolve 两个分类都反查 diaries。 static func from(diary d: DiaryEntry) -> TimelineEntry { - TimelineEntry( + let isMed = d.isMedicationLog + return TimelineEntry( id: "diary-\(d.persistentModelID)", - kind: .diary, + kind: isMed ? .medication : .diary, date: d.createdAt, title: d.content.firstLine(), - subtitle: String(appLoc: "文字日记"), + subtitle: isMed ? String(appLoc: "用药记录") : String(appLoc: "文字日记"), trailing: nil, trailingIsAlert: false, isOngoing: false diff --git a/康康/Features/Timeline/TimelineEntryDetailView.swift b/康康/Features/Timeline/TimelineEntryDetailView.swift index 3a75a83..c150c1e 100644 --- a/康康/Features/Timeline/TimelineEntryDetailView.swift +++ b/康康/Features/Timeline/TimelineEntryDetailView.swift @@ -22,7 +22,8 @@ enum TimelineDetail { case .report: return reports.first { "report-\($0.persistentModelID)" == entry.id } .map(TimelineDetail.report) - case .diary: + case .diary, .medication: + // 用药记录本质是带「用药」tag 的 DiaryEntry,详情同日记。 return diaries.first { "diary-\($0.persistentModelID)" == entry.id } .map(TimelineDetail.diary) case .symptom: diff --git a/康康/Localizable.xcstrings b/康康/Localizable.xcstrings index bec6f03..cacc157 100644 --- a/康康/Localizable.xcstrings +++ b/康康/Localizable.xcstrings @@ -54,6 +54,9 @@ } } } + }, + "“%@”" : { + }, "(偏瘦)" : { "localizations" : { @@ -1205,6 +1208,9 @@ } } } + }, + "AI 模型未就绪,可以手动填写" : { + }, "AI 模型未就绪,手动补充" : { @@ -1691,6 +1697,9 @@ } } } + }, + "下次试试长按 + ,直接说出想记的内容" : { + }, "下载中" : { "localizations" : { @@ -2832,6 +2841,9 @@ } } } + }, + "保存用药记录" : { + }, "偏低" : { "localizations" : { @@ -3065,6 +3077,9 @@ } } } + }, + "全程本机" : { + }, "全部" : { "localizations" : { @@ -3335,6 +3350,9 @@ } } } + }, + "再加一种" : { + }, "再拍一项" : { "extractionState" : "stale", @@ -3358,6 +3376,9 @@ } } } + }, + "再说一次" : { + }, "再问一轮 · 让 AI 从新角度追问" : { "localizations" : { @@ -3411,6 +3432,12 @@ }, "写下要整理什么,或先提问补充情况…" : { + }, + "写日记" : { + + }, + "写日记或拍药盒记录用药 · 可让 AI 辅助" : { + }, "冠心病" : { "localizations" : { @@ -5115,6 +5142,9 @@ }, "导出历史" : { + }, + "将记入健康日记(记录页可查),并同步到「当前用药」供 AI 解读参考。不提供任何用药建议。" : { + }, "将追加:" : { "localizations" : { @@ -6579,6 +6609,9 @@ }, "手动记录" : { + }, + "打开新建菜单" : { + }, "抑郁/焦虑" : { "localizations" : { @@ -6840,6 +6873,15 @@ } } } + }, + "拍药盒" : { + + }, + "拍药盒或说明书,本地识别药名与规格" : { + + }, + "拍药盒自动识别" : { + }, "拖动方框对准要识别的指标,可拖右下角缩放" : { @@ -6909,6 +6951,9 @@ } } } + }, + "持续追踪" : { + }, "指标" : { "localizations" : { @@ -7680,6 +7725,9 @@ } } } + }, + "文字或语音" : { + }, "文字日记" : { "localizations" : { @@ -8584,6 +8632,9 @@ }, "本机不支持端侧语音识别" : { + }, + "本机不支持端侧语音识别,试试下面的新建菜单" : { + }, "本机保存" : { "localizations" : { @@ -8892,6 +8943,9 @@ }, "核对指标" : { + }, + "核对药品" : { + }, "核对识别结果" : { "localizations" : { @@ -9071,6 +9125,9 @@ } } } + }, + "正在听 · %llds" : { + }, "正在听 · 识别在本机完成" : { @@ -9143,12 +9200,18 @@ } } } + }, + "正在本地识别药品…" : { + }, "正在查看本地记录…" : { }, "正在根据这些记录回答…" : { + }, + "正在理解…" : { + }, "正常" : { "localizations" : { @@ -9263,6 +9326,9 @@ }, "每月%lld日" : { + }, + "比如:记一下血压 / 我头疼 / 拍个药盒" : { + }, "永久删除" : { "localizations" : { @@ -9329,6 +9395,12 @@ } } } + }, + "没听到内容,再试一次?" : { + + }, + "没听懂想记什么,再说一次,或直接选菜单" : { + }, "没听清,再试一次" : { @@ -9357,12 +9429,18 @@ }, "没有识别到指标,点「加一项」手动补充,或返回重拍" : { + }, + "没识别到文字,拍清楚一点再试" : { + }, "没识别到文字,挪一下框再试" : { }, "没读出指标,挪一下框再试" : { + }, + "没读出药品,可以手动填写" : { + }, "测试 PROMPT" : { "localizations" : { @@ -9501,6 +9579,9 @@ } } } + }, + "照片与文字均不离开设备" : { + }, "特大" : { @@ -9783,6 +9864,15 @@ } } } + }, + "用法,如:一日一次,一次一粒" : { + + }, + "用药" : { + + }, + "用药记录" : { + }, "甲状腺疾病" : { "localizations" : { @@ -10764,6 +10854,9 @@ } } } + }, + "药品名,如:缬沙坦胶囊" : { + }, "血压" : { "localizations" : { @@ -10833,6 +10926,9 @@ } } } + }, + "规格,如:80mg×7粒" : { + }, "解析失败:%@" : { @@ -11043,6 +11139,7 @@ } }, "记录身体状态、用药、感受 · 可让 AI 辅助" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -11063,6 +11160,9 @@ } } } + }, + "记症状" : { + }, "设备上的 AI 模型会尝试把专业指标转述为通俗说明,帮你记录并回顾自己的健康变化。" : { "localizations" : { @@ -11205,6 +11305,9 @@ } } } + }, + "识别用药" : { + }, "识别超时,挪一下框再试或手动补充" : { @@ -11318,12 +11421,21 @@ } } } + }, + "语音和文字都只在本机处理,不会上传。" : { + }, "语音记录全程在本机完成,声音和文字都不会上传。请在设置中允许麦克风和语音识别。" : { }, "说一段" : { + }, + "说出想记的内容" : { + + }, + "说完了" : { + }, "说完了,整理成日记" : { @@ -11350,6 +11462,9 @@ } } } + }, + "请开口说话…" : { + }, "请选择名为 %@ 的文件夹" : { "localizations" : { @@ -11677,6 +11792,9 @@ } } } + }, + "轻点打开新建菜单,长按语音直达" : { + }, "载脂蛋白 A1" : { "extractionState" : "stale", diff --git a/康康/Models/Models.swift b/康康/Models/Models.swift index ce6f953..5706877 100644 --- a/康康/Models/Models.swift +++ b/康康/Models/Models.swift @@ -178,6 +178,14 @@ final class DiaryEntry { } } +extension DiaryEntry { + /// 「拍药盒入档」落库时打的 tag。是数据标识不是 UI 文案,**不要**走 appLoc 本地化 + /// (语言切换后旧数据要还能被识别)。时间线据此把该日记归到「用药」分类。 + static let medicationTag = "用药" + + var isMedicationLog: Bool { tags.contains(Self.medicationTag) } +} + @Model final class Asset { var relativePath: String diff --git a/康康/Persistence/WidgetSnapshot.swift b/康康/Persistence/WidgetSnapshot.swift new file mode 100644 index 0000000..6d438bb --- /dev/null +++ b/康康/Persistence/WidgetSnapshot.swift @@ -0,0 +1,44 @@ +import Foundation + +/// 主 App → 桌面 Widget 的数据快照(经 App Group UserDefaults 传递)。 +/// +/// 为什么不让 Widget 直接读 SwiftData:store 在 App 沙盒且开了文件保护, +/// extension 进程独立、锁屏时不可读;快照是「最后一次看到的值」,锁屏也能显示。 +/// +/// ⚠️ 同步契约:`KangkangWidget` extension 里有本结构的独立拷贝 +/// (extension 不引主 App 代码,避免 Xcode target membership 配置成本)。 +/// 改字段时两边一起改:KangkangWidget/PinnedIndicatorsWidget.swift。 +struct WidgetSnapshot: Codable, Equatable { + struct Item: Codable, Equatable { + var name: String // "收缩压" + var value: String // "128" + var unit: String // "mmHg" + var statusRaw: String // IndicatorStatus.rawValue: high|low|normal + var capturedAt: Date + } + + var updatedAt: Date + var items: [Item] + + // MARK: - App Group 存取 + + /// App Group ID。两个 target 的 App Groups capability 都要勾这一个。 + static let appGroupID = "group.com.xuhuayong.kangkang" + static let storeKey = "kk.widget.snapshot.v1" + + /// App Group 未配置(capability 没加)时返回 nil → 调用方静默跳过,App 照常运行。 + static var sharedDefaults: UserDefaults? { + UserDefaults(suiteName: appGroupID) + } + + func save(to defaults: UserDefaults? = WidgetSnapshot.sharedDefaults) { + guard let defaults, let data = try? JSONEncoder().encode(self) else { return } + defaults.set(data, forKey: Self.storeKey) + } + + static func load(from defaults: UserDefaults? = WidgetSnapshot.sharedDefaults) -> WidgetSnapshot? { + guard let defaults, + let data = defaults.data(forKey: storeKey) else { return nil } + return try? JSONDecoder().decode(WidgetSnapshot.self, from: data) + } +} diff --git a/康康/Persistence/WidgetSnapshotRefresher.swift b/康康/Persistence/WidgetSnapshotRefresher.swift new file mode 100644 index 0000000..c33f65b --- /dev/null +++ b/康康/Persistence/WidgetSnapshotRefresher.swift @@ -0,0 +1,42 @@ +import Foundation +import SwiftData +import WidgetKit + +/// 把 pinned 指标的最新值写进 App Group 快照,并请求 WidgetKit 刷新。 +/// 调用时机:App 进后台 / 启动完成(RootView)。读库很轻(只取 pinned),无 AI、无网络。 +/// App Group capability 未配置时整体静默 no-op,不影响主 App。 +enum WidgetSnapshotRefresher { + + /// 每个系列(seriesKey,无则按 name)只取最新一条,最多 6 条。 + @MainActor + static func refresh(in ctx: ModelContext) { + let pinnedPredicate = #Predicate { $0.pinned == true } + var descriptor = FetchDescriptor( + predicate: pinnedPredicate, + sortBy: [SortDescriptor(\.capturedAt, order: .reverse)] + ) + descriptor.fetchLimit = 200 // pinned 总量不大,设上限只是兜底 + guard let pinned = try? ctx.fetch(descriptor) else { return } + + var seenSeries = Set() + var items: [WidgetSnapshot.Item] = [] + for ind in pinned { // 已按 capturedAt 降序,首见即该系列最新 + let key = ind.seriesKey ?? ind.name + guard seenSeries.insert(key).inserted else { continue } + items.append(.init( + name: ind.name, + value: ind.value, + unit: ind.unit, + statusRaw: ind.statusRaw, + capturedAt: ind.capturedAt + )) + if items.count >= 6 { break } + } + + let snapshot = WidgetSnapshot(updatedAt: .now, items: items) + // 内容没变就不写、不刷新,省 WidgetKit 的刷新预算。 + if let old = WidgetSnapshot.load(), old.items == snapshot.items { return } + snapshot.save() + WidgetCenter.shared.reloadAllTimelines() + } +} diff --git a/康康/RootView.swift b/康康/RootView.swift index 862671c..c6e2f37 100644 --- a/康康/RootView.swift +++ b/康康/RootView.swift @@ -1,4 +1,6 @@ import SwiftUI +import SwiftData +import UIKit enum TjTab: String, Hashable, CaseIterable { case home, records, trend, me @@ -35,6 +37,8 @@ enum ActiveFlow: Identifiable { } struct RootView: View { + @Environment(\.modelContext) private var ctx + @Environment(\.scenePhase) private var scenePhase @State private var tab: TjTab = .home /// 页面 push 过渡的来向:切到右侧 tab 时从 trailing 推入,切到左侧时从 leading 推入。 @State private var pushEdge: Edge = .trailing @@ -45,6 +49,23 @@ struct RootView: View { @State private var showIndicator = false @State private var showReminders = false @State private var showHealthExport = false + /// 长按 + :语音直达(说一句话 → LLM 意图分类 → 打开对应入口)。 + @State private var showVoiceCommand = false + /// 语音直达「拍药盒」:RootView 层直接弹 MedicationScanFlow,不绕日记 sheet。 + @State private var showMedicationScan = false + + /// 语音意图 → 打开对应新建入口(与 RecordSheet onPick 的路由一一对应)。 + private func route(_ intent: VoiceIntent) { + switch intent { + case .diary: showDiary = true + case .medication: showMedicationScan = true + case .symptom: showSymptomStart = true + case .indicator: showIndicator = true + case .archive: activeFlow = .archive + case .export: showHealthExport = true + case .reminder: showReminders = true + } + } /// 统一的 tab 切换入口:按方向设定 pushEdge,再带动画改 tab。 /// 所有改 tab 的地方都走这里,保证过渡方向正确。 @@ -70,9 +91,15 @@ struct RootView: View { TabBar(active: tab, onTap: { select($0) }, - onTapRecord: { showRecordSheet = true }) + onTapRecord: { showRecordSheet = true }, + onLongPressRecord: { showVoiceCommand = true }) } .background(Tj.Palette.sand.ignoresSafeArea()) + // 桌面 Widget 快照:启动后写一次,进后台时再写一次(轻量读库,App Group 未配置则 no-op)。 + .task { WidgetSnapshotRefresher.refresh(in: ctx) } + .onChange(of: scenePhase) { _, phase in + if phase == .background { WidgetSnapshotRefresher.refresh(in: ctx) } + } .sheet(isPresented: $showRecordSheet) { RecordSheet { kind in showRecordSheet = false @@ -111,6 +138,30 @@ struct RootView: View { .fullScreenCover(isPresented: $showHealthExport) { HealthExportSheet() } + .sheet(isPresented: $showVoiceCommand) { + VoiceCommandSheet( + onResolve: { intent in + showVoiceCommand = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + route(intent) + } + }, + onOpenMenu: { + showVoiceCommand = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { + showRecordSheet = true + } + } + ) + } + .fullScreenCover(isPresented: $showMedicationScan) { + MedicationScanFlow( + onSave: { entries in + MedicationArchiver.archive(entries: entries, in: ctx) + }, + onClose: { showMedicationScan = false } + ) + } #if os(iOS) .fullScreenCover(item: $activeFlow) { flow in switch flow { @@ -137,8 +188,11 @@ private struct TabBar: View { let active: TjTab let onTap: (TjTab) -> Void let onTapRecord: () -> Void + let onLongPressRecord: () -> Void @Namespace private var indicatorNS + /// + 号按压态(长按手势驱动的缩放视觉,代替 ButtonStyle)。 + @State private var recordPressing = false private let cornerRadius: CGFloat = 22 private let slotHeight: CGFloat = 34 @@ -201,33 +255,45 @@ private struct TabBar: View { .buttonStyle(TabPressStyle()) } + /// + 号:点按 → 新建菜单;长按 → 语音直达。 + /// 不用 Button + simultaneousGesture(长按成功后松手仍可能触发 tap 二次弹菜单), + /// 改为 tap / longPress 双手势 + onPressingChanged 驱动按压缩放。 private var recordSlot: some View { - Button(action: onTapRecord) { - VStack(spacing: 4) { - ZStack { - Circle() - .fill(Tj.Palette.ink) - .overlay( - Circle() - .strokeBorder(Tj.Palette.paper, lineWidth: 2) - ) - .shadow(color: Tj.Palette.ink.opacity(0.18), - radius: 4, x: 0, y: 2) + VStack(spacing: 4) { + ZStack { + Circle() + .fill(Tj.Palette.ink) + .overlay( + Circle() + .strokeBorder(Tj.Palette.paper, lineWidth: 2) + ) + .shadow(color: Tj.Palette.ink.opacity(0.18), + radius: 4, x: 0, y: 2) - Image(systemName: "plus") - .font(.tjScaled( 16, weight: .semibold)) - .foregroundStyle(Tj.Palette.paper) - } - .frame(width: slotHeight, height: slotHeight) - - Text("新建") - .font(.tjScaled( 11, weight: .semibold)) - .foregroundStyle(Tj.Palette.ink) + Image(systemName: "plus") + .font(.tjScaled( 16, weight: .semibold)) + .foregroundStyle(Tj.Palette.paper) } - .frame(maxWidth: .infinity) - .contentShape(Rectangle()) + .frame(width: slotHeight, height: slotHeight) + + Text("新建") + .font(.tjScaled( 11, weight: .semibold)) + .foregroundStyle(Tj.Palette.ink) } - .buttonStyle(TabPressStyle()) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + .scaleEffect(recordPressing ? 0.92 : 1.0) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: recordPressing) + .onTapGesture { onTapRecord() } + .onLongPressGesture(minimumDuration: 0.45) { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + onLongPressRecord() + } onPressingChanged: { pressing in + recordPressing = pressing + } + .accessibilityElement(children: .combine) + .accessibilityLabel("新建") + .accessibilityHint("轻点打开新建菜单,长按语音直达") } } // 你好 diff --git a/康康/Services/MedicationScanService.swift b/康康/Services/MedicationScanService.swift new file mode 100644 index 0000000..fe553c4 --- /dev/null +++ b/康康/Services/MedicationScanService.swift @@ -0,0 +1,114 @@ +import Foundation + +/// 药盒识别结果(结构化,与 UserProfile.currentMedications 的字符串条目解耦)。 +struct ParsedMedication: Sendable, Identifiable { + let id = UUID() + var name: String + var strength: String // 规格,如 "80mg×7粒" + var usage: String // 用法,如 "口服,一次1片,一日2次" + + /// 写入 UserProfile.currentMedications 的单行文本, + /// 与手动录入习惯一致(placeholder "如:缬沙坦 80mg qd")。 + var entryText: String { + var s = name.trimmingCharacters(in: .whitespaces) + let st = strength.trimmingCharacters(in: .whitespaces) + let u = usage.trimmingCharacters(in: .whitespaces) + if !st.isEmpty { s += " \(st)" } + if !u.isEmpty { s += " · \(u)" } + return s + } +} + +/// 「拍药盒入档」服务:OCR 文本 → LLM(MNN/SME2 主链路)结构化抽药品。 +/// 与 CaptureService.recognizeIndicators 同构:UI 不直接碰 AIRuntime(§3.1), +/// 失败抛 CaptureError,UI 回退手动录入(§3.2)。 +/// actor 原因同 CaptureService:方法要等 AIRuntime(actor),自身无可变状态。 +actor MedicationScanService { + static let shared = MedicationScanService() + private init() {} + + /// 药盒/说明书/处方的 OCR 文本 → [ParsedMedication]。 + /// 调用方(MainActor)先做 OCR 再传文本进来,避免 UIImage 跨 actor。 + func recognizeMedications(fromOCRText text: String) async throws -> [ParsedMedication] { + do { + try await AIRuntime.shared.prepare() // 载 LLM(与 VL 互斥卸载由 AIRuntime 闸门处理) + } catch { + throw CaptureError.modelNotReady + } + + let prompt = MedicationPrompts.medicationsFromText(text) + var collected = "" + do { + // 药盒一般 1-2 种药,512 token 足够;与其他推理由 AIRuntime 闸门串行。 + let stream = await AIRuntime.shared.generate(prompt: prompt, maxTokens: 512) + for try await chunk in stream { + collected += chunk.text + } + } catch { + throw CaptureError.inferenceFailed("\(error)") + } + + let cleaned = CaptureService.stripThink(collected) + do { + return try Self.parseMedicationsJSON(cleaned) + } catch let CaptureError.parseFailed(msg) { + let preview = cleaned.isEmpty ? "(strip 后为空)" : String(cleaned.prefix(60)) + throw CaptureError.parseFailed("\(msg)〔前缀:\(preview)〕") + } catch { + throw CaptureError.parseFailed("\(error)") + } + } + + // MARK: - JSON parse(static 纯函数 → 方便单测) + + /// 兼容 `{"medications":[...]}` 与裸数组 `[...]`。 + /// 解析不到任何药品返回空数组(不抛),UI 据此走「手动补充」分支;JSON 不合法才抛。 + static func parseMedicationsJSON(_ raw: String) throws -> [ParsedMedication] { + let jsonString = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: raw)) + guard let data = jsonString.data(using: .utf8) else { + throw CaptureError.parseFailed("非 UTF-8 输出") + } + let obj: Any + do { + obj = try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) + } catch { + throw CaptureError.parseFailed("JSON 不合法:\(error.localizedDescription)") + } + let rawList: [[String: Any]] + if let dict = obj as? [String: Any] { + rawList = arrayValue(dict, keys: ["medications", "meds", "drugs", "药品", "用药", "items"]) + } else if let arr = obj as? [[String: Any]] { + rawList = arr + } else { + throw CaptureError.parseFailed("根节点既不是对象也不是数组") + } + var seen = Set() + return rawList.compactMap { parseMedication($0) }.filter { seen.insert($0.name).inserted } + } + + private static func parseMedication(_ d: [String: Any]) -> ParsedMedication? { + guard let name = stringValue(d, keys: ["name", "drug", "medication", "药名", "药品", "名称"])? + .trimmingCharacters(in: .whitespaces), + !name.isEmpty else { return nil } + let strength = stringValue(d, keys: ["strength", "spec", "specification", "规格", "剂量"]) ?? "" + let usage = stringValue(d, keys: ["usage", "dosage", "用法", "用量", "用法用量"]) ?? "" + return ParsedMedication(name: name, + strength: strength.trimmingCharacters(in: .whitespaces), + usage: usage.trimmingCharacters(in: .whitespaces)) + } + + private static func stringValue(_ d: [String: Any], keys: [String]) -> String? { + for key in keys { + if let s = d[key] as? String { return s } + if let n = d[key] as? NSNumber { return n.stringValue } + } + return nil + } + + private static func arrayValue(_ d: [String: Any], keys: [String]) -> [[String: Any]] { + for key in keys { + if let arr = d[key] as? [[String: Any]] { return arr } + } + return [] + } +} diff --git a/康康/Services/VoiceIntentService.swift b/康康/Services/VoiceIntentService.swift new file mode 100644 index 0000000..4b9b04a --- /dev/null +++ b/康康/Services/VoiceIntentService.swift @@ -0,0 +1,96 @@ +import Foundation + +/// 「长按 + 语音直达」可路由到的新建入口。rawValue 与 IntentPrompts 的分类 token 一致。 +enum VoiceIntent: String, CaseIterable, Sendable { + case diary, medication, symptom, indicator, archive, export, reminder +} + +/// 语音意图分类服务:LLM(MNN/SME2 主链路)优先,6 秒超时或失败回退到关键词匹配(§3.2)。 +/// 两路都不中返回 nil,UI 走「没听懂 → 再说一次 / 打开新建菜单」。 +/// 无状态,与 OCRService 同款 enum 形态;UI 不直接碰 AIRuntime(§3.1)。 +/// nonisolated:模块默认 MainActor,这里全是纯函数 + await,不需要主线程(测试也好调)。 +nonisolated enum VoiceIntentService { + + static func classify(_ utterance: String) async -> VoiceIntent? { + let text = utterance.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + // 模型冷启动可能要载入十几秒,语音直达等不起:6s 拿不到就走关键词。 + if let intent = try? await withTimeout(seconds: 6, operation: { + try await classifyWithLLM(text) + }) { + return intent + } + return keywordMatch(text) + } + + // MARK: - LLM 分类 + + private static func classifyWithLLM(_ text: String) async throws -> VoiceIntent { + try await AIRuntime.shared.prepare() + let stream = await AIRuntime.shared.generate(prompt: IntentPrompts.classify(text), + maxTokens: 48) + var collected = "" + for try await chunk in stream { + collected += chunk.text + } + guard let intent = parseIntent(from: collected) else { + throw CaptureError.parseFailed("intent") + } + return intent + } + + /// 从模型输出抠 `{"intent":"…"}`。容错:think 块、围栏、裸词。"unknown"/未知值返回 nil。 + static func parseIntent(from raw: String) -> VoiceIntent? { + let cleaned = CaptureService.stripThink(raw) + let jsonString = CaptureService.repairJSON(CaptureService.extractBalancedJSON(from: cleaned)) + if let data = jsonString.data(using: .utf8), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let token = obj["intent"] as? String { + return VoiceIntent(rawValue: token.trimmingCharacters(in: .whitespaces).lowercased()) + } + // 兜底:模型偶尔只吐裸词(diary / symptom …) + let bare = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"'`。.")) + .lowercased() + return VoiceIntent(rawValue: bare) + } + + // MARK: - 关键词回退(纯函数,单测覆盖) + + /// 规则有序:先命中先赢。「提醒我吃药」必须归 reminder,所以 reminder 排最前。 + static func keywordMatch(_ text: String) -> VoiceIntent? { + let t = text.lowercased() + let rules: [(VoiceIntent, [String])] = [ + (.reminder, ["提醒", "别忘", "闹钟"]), + (.medication, ["药盒", "用药", "吃药", "吃了药", "服药", "药品", "降压药", "胰岛素"]), + (.archive, ["报告", "化验单", "体检", "归档"]), + (.export, ["身体档案", "给医生", "健康总结", "导出"]), + (.indicator, ["血压", "血糖", "体重", "心率", "体温", "尿酸", "血脂", "指标", + "高压", "低压"]), + (.symptom, ["症状", "头疼", "头痛", "肚子疼", "胃疼", "牙疼", "嗓子疼", "疼", "痛", + "咳嗽", "发烧", "发热", "头晕", "恶心", "不舒服", "难受", "拉肚子", "失眠"]), + (.diary, ["日记", "今天", "心情", "感觉", "睡得", "吃了"]), + ] + for (intent, keys) in rules where keys.contains(where: { t.contains($0) }) { + return intent + } + return nil + } +} + +/// 简单超时竞速:operation 与 sleep 赛跑,超时抛 CancellationError 并取消未完成方。 +nonisolated private func withTimeout( + seconds: Double, + operation: @escaping @Sendable () async throws -> T +) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000)) + throw CancellationError() + } + guard let result = try await group.next() else { throw CancellationError() } + group.cancelAll() + return result + } +} diff --git a/康康Tests/MedicationScanServiceTests.swift b/康康Tests/MedicationScanServiceTests.swift new file mode 100644 index 0000000..2866129 --- /dev/null +++ b/康康Tests/MedicationScanServiceTests.swift @@ -0,0 +1,85 @@ +import Testing +import Foundation +import SwiftData +@testable import 康康 + +/// MedicationScanService.parseMedicationsJSON 纯函数单测(JSON 容错与去重)。 +struct MedicationScanServiceTests { + + @Test func parsesStandardObject() throws { + let raw = """ + {"medications":[{"name":"缬沙坦胶囊","strength":"80mg×7粒","usage":""}]} + """ + let meds = try MedicationScanService.parseMedicationsJSON(raw) + #expect(meds.count == 1) + #expect(meds[0].name == "缬沙坦胶囊") + #expect(meds[0].strength == "80mg×7粒") + #expect(meds[0].entryText == "缬沙坦胶囊 80mg×7粒") + } + + @Test func parsesBareArrayWithFence() throws { + let raw = """ + ```json + [{"name":"二甲双胍缓释片","strength":"0.5g×30片","usage":"口服,一次1片,一日2次"}] + ``` + """ + let meds = try MedicationScanService.parseMedicationsJSON(raw) + #expect(meds.count == 1) + #expect(meds[0].entryText == "二甲双胍缓释片 0.5g×30片 · 口服,一次1片,一日2次") + } + + @Test func parsesChineseKeysAndDedupes() throws { + let raw = """ + {"medications":[ + {"药名":"阿司匹林肠溶片","规格":"100mg","用法":""}, + {"name":"阿司匹林肠溶片","strength":"100mg","usage":""} + ]} + """ + let meds = try MedicationScanService.parseMedicationsJSON(raw) + #expect(meds.count == 1) + } + + @Test func emptyNameRowsAreDropped() throws { + let raw = #"{"medications":[{"name":"","strength":"10mg","usage":""}]}"# + let meds = try MedicationScanService.parseMedicationsJSON(raw) + #expect(meds.isEmpty) + } + + @Test func trailingCommaIsRepaired() throws { + let raw = #"{"medications":[{"name":"氯雷他定片","strength":"10mg×6片","usage":"",},]}"# + let meds = try MedicationScanService.parseMedicationsJSON(raw) + #expect(meds.count == 1) + #expect(meds[0].name == "氯雷他定片") + } + + @Test func invalidJSONThrows() { + #expect(throws: (any Error).self) { + try MedicationScanService.parseMedicationsJSON("识别不出来,抱歉") + } + } +} + +/// 「用药」日记 → 时间线分类映射(拍药盒入档落库后在「记录」tab 的归类)。 +@MainActor +struct MedicationTimelineTests { + + private func makeContext() throws -> ModelContext { + let schema = Schema([DiaryEntry.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return ModelContext(try ModelContainer(for: schema, configurations: [config])) + } + + @Test func medicationTaggedDiaryMapsToMedicationKind() throws { + let ctx = try makeContext() + let med = DiaryEntry(content: "缬沙坦胶囊 80mg×7粒", tags: [DiaryEntry.medicationTag]) + let plain = DiaryEntry(content: "今天睡得不错") + ctx.insert(med); ctx.insert(plain) + try ctx.save() + + let medEntry = TimelineEntry.from(diary: med) + #expect(medEntry.kind == .medication) + #expect(medEntry.title == "缬沙坦胶囊 80mg×7粒") + + #expect(TimelineEntry.from(diary: plain).kind == .diary) + } +} diff --git a/康康Tests/SpeechDictationMergeTests.swift b/康康Tests/SpeechDictationMergeTests.swift new file mode 100644 index 0000000..6bafe37 --- /dev/null +++ b/康康Tests/SpeechDictationMergeTests.swift @@ -0,0 +1,22 @@ +import Testing +@testable import 康康 + +struct SpeechDictationMergeTests { + @Test func emptyPrefixReturnsPartial() { + #expect(SpeechDictationService.merge(prefix: "", partial: "今天头晕") == "今天头晕") + } + + @Test func plainPrefixJoinsWithSpace() { + #expect(SpeechDictationService.merge(prefix: "已有内容", partial: "新听写") + == "已有内容 新听写") + } + + @Test func whitespaceTerminatedPrefixConcatsDirectly() { + #expect(SpeechDictationService.merge(prefix: "第一行\n", partial: "新听写") + == "第一行\n新听写") + } + + @Test func emptyPartialKeepsPrefix() { + #expect(SpeechDictationService.merge(prefix: "已有内容", partial: "") == "已有内容") + } +} diff --git a/康康Tests/TimelineGroupingTests.swift b/康康Tests/TimelineGroupingTests.swift index 1f5edce..5c764c8 100644 --- a/康康Tests/TimelineGroupingTests.swift +++ b/康康Tests/TimelineGroupingTests.swift @@ -10,6 +10,10 @@ struct TimelineGroupingTests { return Calendar(identifier: .gregorian).date(from: c)! }() + @Test func timelineKindOrderMatchesRecordFilterChips() { + #expect(TimelineKind.allCases == [.diary, .symptom, .indicator, .medication, .report]) + } + @Test func todaySection() { #expect(TimelineGrouping.section(for: now, now: now) == .today) } diff --git a/康康Tests/VoiceIntentServiceTests.swift b/康康Tests/VoiceIntentServiceTests.swift new file mode 100644 index 0000000..074314b --- /dev/null +++ b/康康Tests/VoiceIntentServiceTests.swift @@ -0,0 +1,53 @@ +import Testing +import Foundation +@testable import 康康 + +/// 语音直达的两个纯函数:LLM 输出解析 + 关键词回退。 +struct VoiceIntentServiceTests { + + // MARK: - parseIntent(LLM 输出容错) + + @Test func parsesStandardJSON() { + #expect(VoiceIntentService.parseIntent(from: #"{"intent":"indicator"}"#) == .indicator) + } + + @Test func parsesFencedAndThinkWrapped() { + let raw = """ + 用户想记血压 + ```json + {"intent": "Indicator"} + ``` + """ + #expect(VoiceIntentService.parseIntent(from: raw) == .indicator) + } + + @Test func parsesBareWord() { + #expect(VoiceIntentService.parseIntent(from: "symptom") == .symptom) + #expect(VoiceIntentService.parseIntent(from: "\"diary\"。") == .diary) + } + + @Test func unknownReturnsNil() { + #expect(VoiceIntentService.parseIntent(from: #"{"intent":"unknown"}"#) == nil) + #expect(VoiceIntentService.parseIntent(from: "我不知道") == nil) + } + + // MARK: - keywordMatch(回退规则与优先级) + + @Test func reminderBeatsMedication() { + // 「提醒我吃药」是设提醒,不是记用药 —— reminder 规则必须排最前 + #expect(VoiceIntentService.keywordMatch("每天八点提醒我吃药") == .reminder) + } + + @Test func commonUtterances() { + #expect(VoiceIntentService.keywordMatch("记一下血压,高压128") == .indicator) + #expect(VoiceIntentService.keywordMatch("我有点头疼") == .symptom) + #expect(VoiceIntentService.keywordMatch("拍个药盒") == .medication) + #expect(VoiceIntentService.keywordMatch("把体检报告存进去") == .archive) + #expect(VoiceIntentService.keywordMatch("整理一份给医生看的") == .export) + #expect(VoiceIntentService.keywordMatch("写个日记") == .diary) + } + + @Test func gibberishReturnsNil() { + #expect(VoiceIntentService.keywordMatch("啦啦啦啦") == nil) + } +}