From 1b01923c8e6a2272f70d2822bce33084ee71d134 Mon Sep 17 00:00:00 2001 From: link2026 Date: Tue, 26 May 2026 11:18:00 +0800 Subject: [PATCH] =?UTF-8?q?feat(capture):=20=E7=BB=9F=E4=B8=80=E6=8A=A5?= =?UTF-8?q?=E5=91=8A=E6=8D=95=E8=8E=B7=E6=B5=81=E7=A8=8B=E5=B9=B6=E9=9B=86?= =?UTF-8?q?=E6=88=90=E8=A7=86=E8=A7=89=E8=AF=AD=E8=A8=80=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 替换 QuickCaptureFlow 和 ArchiveFlow 为 UnifiedCaptureFlow 统一流程 - 新增 VLSession 封装 Qwen2.5-VL 模型进行图像文本推理 - 实现 AIRuntime 中 VL 模型的准备和分析功能 - 添加 VLPrompts 定义体检化验单识别的 JSON 输出模板 - 创建 CaptureReviewForm 提供 VL 解析结果的可编辑表单界面 - 集成 VisionKit 文档扫描器支持真机多页文档扫描 - 为模拟器实现 PhotosPicker 回退方案选择已有照片 - 在 RootView 中统一使用 UnifiedCaptureFlow 处理快速和归档流程 - 添加 CustomMetricEditor 支持自定义监测指标的创建编辑删除 - 扩展 KangkangApp 模型配置以支持新数据类型 - 实现档案列表中症状结束功能通过时间线行点击触发 --- 康康.xcodeproj/project.pbxproj | 8 + 康康/AI/AIRuntime.swift | 51 +++ 康康/AI/Prompts/VLPrompts.swift | 71 +++ 康康/AI/VLSession.swift | 72 +++ 康康/App/KangkangApp.swift | 2 + 康康/Features/Archive/ArchiveListView.swift | 21 +- 康康/Features/Capture/CaptureReviewForm.swift | 250 +++++++++++ 康康/Features/Capture/DocumentScanner.swift | 68 +++ 康康/Features/Capture/PhotoPickerSheet.swift | 68 +++ .../Features/Capture/UnifiedCaptureFlow.swift | 228 ++++++++++ .../Features/Indicator/CustomMetricEditor.swift | 329 ++++++++++++++ .../Indicator/IndicatorQuickSheet.swift | 421 +++++++++++++++++- 康康/Features/Me/CustomMetricsListView.swift | 153 +++++++ 康康/Features/Me/MeView.swift | 87 +++- 康康/Features/Me/RemindersListView.swift | 221 +++++++++ 康康/Features/Trends/SeriesBucket.swift | 33 +- 康康/Features/Trends/SeriesChartCard.swift | 179 ++++++++ 康康/Features/Trends/TrendsView.swift | 38 ++ 康康/Models/Models.swift | 92 ++++ 康康/RootView.swift | 8 +- 康康/Services/CaptureService.swift | 218 +++++++++ 康康/Services/ReminderService.swift | 94 ++++ 康康Tests/CaptureServiceJSONTests.swift | 112 +++++ 康康Tests/CustomMonitorMetricTests.swift | 145 ++++++ 康康Tests/MetricReminderTests.swift | 79 ++++ 康康Tests/ModelsSchemaTests.swift | 2 + 康康Tests/SeriesBucketTests.swift | 107 +++++ 27 files changed, 3128 insertions(+), 29 deletions(-) create mode 100644 康康/AI/Prompts/VLPrompts.swift create mode 100644 康康/AI/VLSession.swift create mode 100644 康康/Features/Capture/CaptureReviewForm.swift create mode 100644 康康/Features/Capture/DocumentScanner.swift create mode 100644 康康/Features/Capture/PhotoPickerSheet.swift create mode 100644 康康/Features/Capture/UnifiedCaptureFlow.swift create mode 100644 康康/Features/Indicator/CustomMetricEditor.swift create mode 100644 康康/Features/Me/CustomMetricsListView.swift create mode 100644 康康/Features/Me/RemindersListView.swift create mode 100644 康康/Features/Trends/SeriesChartCard.swift create mode 100644 康康/Services/CaptureService.swift create mode 100644 康康/Services/ReminderService.swift create mode 100644 康康Tests/CaptureServiceJSONTests.swift create mode 100644 康康Tests/CustomMonitorMetricTests.swift create mode 100644 康康Tests/MetricReminderTests.swift create mode 100644 康康Tests/SeriesBucketTests.swift diff --git a/康康.xcodeproj/project.pbxproj b/康康.xcodeproj/project.pbxproj index 0c73558..a28849a 100644 --- a/康康.xcodeproj/project.pbxproj +++ b/康康.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ FEED000000000000DEAD0001 /* MLXLLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0003 /* MLXLLM */; }; FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0004 /* MLXLMCommon */; }; + FEED000000000000DEAD0005 /* MLXVLM in Frameworks */ = {isa = PBXBuildFile; productRef = FEED000000000000DEAD0006 /* MLXVLM */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -59,6 +60,7 @@ files = ( FEED000000000000DEAD0001 /* MLXLLM in Frameworks */, FEED000000000000DEAD0002 /* MLXLMCommon in Frameworks */, + FEED000000000000DEAD0005 /* MLXVLM in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -121,6 +123,7 @@ packageProductDependencies = ( FEED000000000000DEAD0003 /* MLXLLM */, FEED000000000000DEAD0004 /* MLXLMCommon */, + FEED000000000000DEAD0006 /* MLXVLM */, ); productName = "康康"; productReference = 5E463CF92FC403BB0089145B /* 康康.app */; @@ -656,6 +659,11 @@ package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */; productName = MLXLMCommon; }; + FEED000000000000DEAD0006 /* MLXVLM */ = { + isa = XCSwiftPackageProductDependency; + package = 5E9A1F872FC43C9A0097DD29 /* XCRemoteSwiftPackageReference "mlx-swift-examples" */; + productName = MLXVLM; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 5E463CF12FC403BB0089145B /* Project object */; diff --git a/康康/AI/AIRuntime.swift b/康康/AI/AIRuntime.swift index e061c0f..42cf6a9 100644 --- a/康康/AI/AIRuntime.swift +++ b/康康/AI/AIRuntime.swift @@ -25,9 +25,11 @@ actor AIRuntime { } private(set) var status: Status = .notReady + private(set) var vlStatus: Status = .notReady private(set) var lastDecodeRate: Double = 0 private var llmSession: LLMSession? + private var vlSession: VLSession? private init() {} @@ -96,4 +98,53 @@ actor AIRuntime { private func recordRate(_ rate: Double) { if rate > 0 { lastDecodeRate = rate } } + + // MARK: - VL + + /// 加载 VL 模型。幂等,首调真正 load。 + func prepareVL() async throws { + switch vlStatus { + case .ready, .loading: + return + case .error, .notReady: + break + } + + guard ModelStore.shared.isReady(.vl) else { + vlStatus = .error("VL 模型未就绪") + throw AIRuntimeError.notReady + } + + vlStatus = .loading + do { + let session = try await VLSession.load( + folderURL: ModelStore.shared.localURL(for: .vl) + ) + self.vlSession = session + vlStatus = .ready + } catch { + vlStatus = .error("\(error)") + throw AIRuntimeError.modelLoadFailed("\(error)") + } + } + + /// 图像 → JSON 字符串(由 VLPrompts.reportExtraction 引导)。 + /// 调用方负责解析 + 失败回退(§3.2)。 + /// AIRuntime 是 actor,本调用与 LLM.generate() 自然串行,不会 OOM。 + func analyzeReport(imageURLs: [URL], + prompt: String, + maxTokens: Int = 512) async throws -> String { + guard vlStatus == .ready, let session = vlSession else { + throw AIRuntimeError.notReady + } + do { + return try await session.analyze( + imageURLs: imageURLs, + prompt: prompt, + maxTokens: maxTokens + ) + } catch { + throw AIRuntimeError.inferenceFailed("\(error)") + } + } } diff --git a/康康/AI/Prompts/VLPrompts.swift b/康康/AI/Prompts/VLPrompts.swift new file mode 100644 index 0000000..cc18dc9 --- /dev/null +++ b/康康/AI/Prompts/VLPrompts.swift @@ -0,0 +1,71 @@ +import Foundation + +/// VL 模型(Qwen2.5-VL)用于体检 / 化验单识别的 prompt 模板。 +/// 输出契约:严格 JSON,无任何解释文字、markdown 围栏或前后缀。 +/// 解析失败 → CaptureService 回退到手动录入(§3.2 失败回退红线)。 +enum VLPrompts { + + /// 输出 JSON 的字段定义(写进 prompt 里教模型): + /// ``` + /// { + /// "title": "春季年度体检", // 报告抬头,无则 "拍摄识别" + /// "type": "checkup|lab|imaging|prescription|other", + /// "report_date": "YYYY-MM-DD", // 报告日期(无则今天) + /// "institution": "XX 医院", // 可空字符串 + /// "page_count": 1, + /// "summary": "整体趋势短句", // 可空字符串 + /// "indicators": [ + /// { + /// "name": "低密度脂蛋白", + /// "value": "3.84", + /// "unit": "mmol/L", + /// "range": "< 3.40", + /// "status": "high|low|normal" + /// } + /// ] + /// } + /// ``` + /// `kind` 字段省略 —— UI 由 indicators 数量决定走 A2(单项)或 B3(多项)。 + + static let reportExtraction: String = #""" +你是一个医学体检报告识别助手。请只输出一段合法 JSON,不要解释、不要 markdown 围栏、不要任何前后缀文字。 + +JSON schema(严格): +{ + "title": string, + "type": "checkup" | "lab" | "imaging" | "prescription" | "other", + "report_date": "YYYY-MM-DD", + "institution": string, + "page_count": number, + "summary": string, + "indicators": [ + { + "name": string, + "value": string, + "unit": string, + "range": string, + "status": "high" | "low" | "normal" + } + ] +} + +规则: +- status 根据 value 与 range 自己判断:value > range 上限 → "high",< 下限 → "low",否则 → "normal"。 +- range 字段保留原文(如 "< 3.40"、"3.9 - 6.1"、"0 - 5"),不要解析成区间对象。 +- 无法识别的字段填空字符串(institution / summary)或合理默认值(report_date 用今天)。 +- 不要发明指标。看不清的整行跳过。 +- 化验单一般 type = "lab",体检套餐 = "checkup"。 + +示例 1(化验单 · 单项): +输入: 一张化验单照片,只能看清「低密度脂蛋白 3.84 mmol/L 参考 <3.40」 +输出: +{"title":"低密度脂蛋白单项","type":"lab","report_date":"2026-05-25","institution":"","page_count":1,"summary":"","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]} + +示例 2(体检 · 多项): +输入: 一份春季体检,3 项可读 +输出: +{"title":"春季年度体检","type":"checkup","report_date":"2026-04-12","institution":"协和医院","page_count":1,"summary":"血脂偏高、其他正常","indicators":[{"name":"低密度脂蛋白","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"},{"name":"谷丙转氨酶","value":"32","unit":"U/L","range":"9 - 50","status":"normal"},{"name":"空腹血糖","value":"5.2","unit":"mmol/L","range":"3.9 - 6.1","status":"normal"}]} + +现在请识别图片并输出 JSON: +"""# +} diff --git a/康康/AI/VLSession.swift b/康康/AI/VLSession.swift new file mode 100644 index 0000000..db16184 --- /dev/null +++ b/康康/AI/VLSession.swift @@ -0,0 +1,72 @@ +import Foundation +import MLX +import MLXVLM +import MLXLMCommon + +/// 封装 MLX VL 模型(Qwen2.5-VL)的图像 → 文本推理。 +/// 与 LLMSession 同款 actor 隔离,串行化由上游 AIRuntime 统一保证。 +actor VLSession { + let container: ModelContainer + + init(container: ModelContainer) { + self.container = container + } + + private static func withDeviceOverride( + _ body: () async throws -> R + ) async rethrows -> R { + #if targetEnvironment(simulator) + return try await Device.withDefaultDevice(.cpu, body) + #else + return try await body() + #endif + } + + /// 从本地目录加载 VL 模型(包含 config.json + weights + tokenizer + processor)。 + static func load(folderURL: URL) async throws -> VLSession { + let configuration = ModelConfiguration(directory: folderURL) + let container = try await withDeviceOverride { + try await VLMModelFactory.shared.loadContainer( + configuration: configuration + ) + } + return VLSession(container: container) + } + + /// 一次性生成(等收完所有 token 再返回完整字符串)。 + /// VL 用于结构化 JSON 抽取,不需要流式 — 也避免半成品 JSON 抖动 UI。 + /// - Parameters: + /// - imageURLs: 本地 file:// URL,从 FileVault 拿 + /// - prompt: 文本指令(VLPrompts.reportExtraction) + /// - maxTokens: 默认 512(JSON 体量 ≈ 200-400) + func analyze(imageURLs: [URL], + prompt: String, + maxTokens: Int = 512) async throws -> String { + try await Self.withDeviceOverride { + try await container.perform { (context: ModelContext) in + let images = imageURLs.map { UserInput.Image.url($0) } + let userInput = UserInput(prompt: prompt, images: images) + let lmInput = try await context.processor.prepare(input: userInput) + + let parameters = GenerateParameters( + maxTokens: maxTokens, + temperature: Float(0.2), // JSON 要稳,温度低 + topP: Float(0.9) + ) + + var collected = "" + for await event in try MLXLMCommon.generate( + input: lmInput, + parameters: parameters, + context: context + ) { + if Task.isCancelled { break } + if case .chunk(let text) = event { + collected.append(text) + } + } + return collected + } + } + } +} diff --git a/康康/App/KangkangApp.swift b/康康/App/KangkangApp.swift index cb25cbf..b254252 100644 --- a/康康/App/KangkangApp.swift +++ b/康康/App/KangkangApp.swift @@ -12,6 +12,8 @@ struct KangkangApp: App { ChatTurn.self, Symptom.self, UserProfile.self, + MetricReminder.self, + CustomMonitorMetric.self, ]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false) do { diff --git a/康康/Features/Archive/ArchiveListView.swift b/康康/Features/Archive/ArchiveListView.swift index 4b504a2..93e62bc 100644 --- a/康康/Features/Archive/ArchiveListView.swift +++ b/康康/Features/Archive/ArchiveListView.swift @@ -15,6 +15,7 @@ struct ArchiveListView: View { private var symptoms: [Symptom] @State private var filter: TimelineKind? = nil + @State private var endingSymptom: Symptom? @MainActor private var allEntries: [TimelineEntry] { @@ -52,7 +53,7 @@ struct ArchiveListView: View { Section { VStack(spacing: 10) { ForEach(group.items) { entry in - TimelineRow(entry: entry) + rowView(for: entry) } } .padding(.horizontal, 20) @@ -67,6 +68,24 @@ struct ArchiveListView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Tj.Palette.sand.ignoresSafeArea()) + .sheet(item: $endingSymptom) { sym in + SymptomEndSheet(symptom: sym) + } + } + + @ViewBuilder + private func rowView(for entry: TimelineEntry) -> some View { + if entry.kind == .symptom, entry.isOngoing, + let sym = symptoms.first(where: { "symptom-\($0.persistentModelID)" == entry.id }) { + Button { + endingSymptom = sym + } label: { + TimelineRow(entry: entry) + } + .buttonStyle(.plain) + } else { + TimelineRow(entry: entry) + } } private var header: some View { diff --git a/康康/Features/Capture/CaptureReviewForm.swift b/康康/Features/Capture/CaptureReviewForm.swift new file mode 100644 index 0000000..d8139d2 --- /dev/null +++ b/康康/Features/Capture/CaptureReviewForm.swift @@ -0,0 +1,250 @@ +import SwiftUI + +/// VL 解析后的可编辑表单。 +/// 用户可改 title / type / reportDate / institution / summary / 各 indicator; +/// 也可删除识别错的 indicator,或手加一行。 +/// 「保存」回调写 SwiftData + 关联已写入 Vault 的 assets。 +struct CaptureReviewForm: View { + @State var parsed: ParsedReport + let assets: [FileVault.SavedAsset] + let warning: String? + let onSave: (ParsedReport) -> Void + let onCancel: () -> Void + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if let warning { + warningBanner(warning) + } + if !assets.isEmpty { + pageThumbnails + } + metaSection + indicatorSection + Spacer(minLength: 8) + actions + } + .padding(.horizontal, 18) + .padding(.bottom, 24) + } + } + + // MARK: - 顶部 warning + + private func warningBanner(_ text: String) -> some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Tj.Palette.amber) + Text(text) + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text2) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.brickSoft.opacity(0.5)) + ) + } + + // MARK: - 缩略图 + + private var pageThumbnails: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("已保存 \(assets.count) 页(端侧加密)") + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(Array(assets.enumerated()), id: \.offset) { _, asset in + if let img = try? FileVault.shared.loadImage(relativePath: asset.relativePath) { + Image(uiImage: img) + .resizable() + .scaledToFill() + .frame(width: 84, height: 110) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: 1) + ) + } + } + } + } + } + } + + // MARK: - meta(title / type / date / institution / summary) + + private var metaSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionLabel("基本信息") + VStack(spacing: 10) { + labeledField("标题") { + TextField("如:春季年度体检", text: $parsed.title) + .textFieldStyle(.plain) + } + labeledField("类型") { + Picker("", selection: $parsed.typeRaw) { + ForEach(ReportType.allCases, id: \.rawValue) { t in + Text(t.label).tag(t.rawValue) + } + } + .pickerStyle(.segmented) + } + labeledField("报告日期") { + DatePicker("", selection: $parsed.reportDate, + in: ...Date.now, + displayedComponents: .date) + .datePickerStyle(.compact) + .labelsHidden() + .environment(\.locale, Locale(identifier: "zh_CN")) + } + labeledField("机构(可选)") { + TextField("如:协和医院", text: $parsed.institution) + } + labeledField("摘要(可选)") { + TextField("一句话总结", text: $parsed.summary, axis: .vertical) + .lineLimit(1...3) + } + } + .padding(12) + .background(fieldBg) + .overlay(fieldBorder) + } + } + + private func labeledField(_ label: String, @ViewBuilder content: () -> C) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + content() + } + } + + // MARK: - indicators + + private var indicatorSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + sectionLabel("指标(\(parsed.indicators.count) 项)") + Spacer() + Button { + parsed.indicators.append( + .init(name: "", value: "", unit: "", range: "", status: .normal) + ) + } label: { + Label("加一项", systemImage: "plus.circle") + .font(.system(size: 12, weight: .medium)) + } + .buttonStyle(.plain) + .foregroundStyle(Tj.Palette.ink) + } + if parsed.indicators.isEmpty { + Text("没有指标 — 点上方「加一项」补一行,或直接保存只存图片") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + .padding(.vertical, 8) + } else { + VStack(spacing: 10) { + ForEach(parsed.indicators.indices, id: \.self) { idx in + indicatorRow(idx) + } + } + } + } + } + + private func indicatorRow(_ idx: Int) -> some View { + let binding = $parsed.indicators[idx] + return VStack(spacing: 8) { + HStack(spacing: 8) { + TextField("指标名", text: binding.name) + .font(.system(size: 14, weight: .medium)) + Button(role: .destructive) { + parsed.indicators.remove(at: idx) + } label: { + Image(systemName: "minus.circle.fill") + .foregroundStyle(Tj.Palette.text3) + } + .buttonStyle(.plain) + } + HStack(spacing: 8) { + TextField("数值", text: binding.value) + .keyboardType(.decimalPad) + .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .frame(maxWidth: 90) + TextField("单位", text: binding.unit) + .frame(maxWidth: 80) + .autocorrectionDisabled() + TextField("参考", text: binding.range) + .autocorrectionDisabled() + } + Picker("", selection: binding.status) { + Text("正常").tag(IndicatorStatus.normal) + Text("偏高 ↑").tag(IndicatorStatus.high) + Text("偏低 ↓").tag(IndicatorStatus.low) + } + .pickerStyle(.segmented) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(statusColor(binding.status.wrappedValue).opacity(0.4), + lineWidth: 1) + ) + } + + private func statusColor(_ s: IndicatorStatus) -> Color { + switch s { + case .normal: return Tj.Palette.leaf + case .high: return Tj.Palette.brick + case .low: return Tj.Palette.amber + } + } + + // MARK: - actions + + private var actions: some View { + VStack(spacing: 10) { + Button { + onSave(parsed) + } label: { + Text("保存到记录") + .frame(maxWidth: .infinity) + } + .buttonStyle(TjPrimaryButton()) + + Button(action: onCancel) { + Text("取消(图片不保留)") + .frame(maxWidth: .infinity) + .foregroundStyle(Tj.Palette.text3) + } + .buttonStyle(.plain) + } + } + + // MARK: - helpers + + private func sectionLabel(_ t: String) -> some View { + Text(t) + .font(.system(size: 12, weight: .semibold)) + .tracking(0.3) + .foregroundStyle(Tj.Palette.text2) + } + + private var fieldBg: some View { + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + } + + private var fieldBorder: some View { + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: 1) + } +} diff --git a/康康/Features/Capture/DocumentScanner.swift b/康康/Features/Capture/DocumentScanner.swift new file mode 100644 index 0000000..b9671b4 --- /dev/null +++ b/康康/Features/Capture/DocumentScanner.swift @@ -0,0 +1,68 @@ +import SwiftUI +import VisionKit +import UIKit + +#if canImport(VisionKit) && os(iOS) + +/// VisionKit 文档扫描器的 SwiftUI 包装。 +/// - 真机:全屏多页文档扫描,自动透视校正 +/// - 模拟器:`VNDocumentCameraViewController.isSupported == false`, +/// 父 View 不要 present 这个,改走 PhotosPicker 回退(见 PhotoPickerSheet) +struct DocumentScannerView: UIViewControllerRepresentable { + let onFinish: ([UIImage]) -> Void + let onCancel: () -> Void + + func makeUIViewController(context: Context) -> VNDocumentCameraViewController { + let vc = VNDocumentCameraViewController() + vc.delegate = context.coordinator + return vc + } + + func updateUIViewController(_ uiViewController: VNDocumentCameraViewController, + context: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator(onFinish: onFinish, onCancel: onCancel) + } + + final class Coordinator: NSObject, VNDocumentCameraViewControllerDelegate { + let onFinish: ([UIImage]) -> Void + let onCancel: () -> Void + + init(onFinish: @escaping ([UIImage]) -> Void, + onCancel: @escaping () -> Void) { + self.onFinish = onFinish + self.onCancel = onCancel + } + + func documentCameraViewController( + _ controller: VNDocumentCameraViewController, + didFinishWith scan: VNDocumentCameraScan + ) { + var images: [UIImage] = [] + for i in 0.. Void + let onCancel: () -> Void + + @State private var selection: [PhotosPickerItem] = [] + @State private var loading = false + + var body: some View { + VStack(spacing: 20) { + Image(systemName: "photo.on.rectangle.angled") + .font(.system(size: 56)) + .foregroundStyle(Tj.Palette.text3) + Text("模拟器没有摄像头,从相册选一张化验单/体检报告") + .font(.system(size: 13)) + .foregroundStyle(Tj.Palette.text2) + .multilineTextAlignment(.center) + + PhotosPicker(selection: $selection, + maxSelectionCount: 5, + matching: .images) { + Text("从相册选 ≤5 张") + .font(.system(size: 14, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Tj.Palette.ink) + .foregroundStyle(Tj.Palette.paper) + .clipShape(Capsule()) + } + + Button("取消", action: onCancel) + .foregroundStyle(Tj.Palette.text3) + + if loading { + ProgressView().tint(Tj.Palette.ink) + } + } + .padding(28) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Tj.Palette.sand.ignoresSafeArea()) + .onChange(of: selection) { _, newValue in + guard !newValue.isEmpty else { return } + loadImages(from: newValue) + } + } + + private func loadImages(from items: [PhotosPickerItem]) { + loading = true + Task { + var images: [UIImage] = [] + for item in items { + if let data = try? await item.loadTransferable(type: Data.self), + let img = UIImage(data: data) { + images.append(img) + } + } + await MainActor.run { + loading = false + if images.isEmpty { onCancel() } + else { onFinish(images) } + } + } + } +} diff --git a/康康/Features/Capture/UnifiedCaptureFlow.swift b/康康/Features/Capture/UnifiedCaptureFlow.swift new file mode 100644 index 0000000..aa1b4fa --- /dev/null +++ b/康康/Features/Capture/UnifiedCaptureFlow.swift @@ -0,0 +1,228 @@ +import SwiftUI +import SwiftData +import UIKit + +/// 拍报告 → VL 识别 → 编辑 → 保存(图 + 结构化文本) +/// 一条统一流程,替代原 A1-A3 / B1-B5 两套 mockup。 +/// +/// 状态机: +/// ``` +/// idle → captured(images) → analyzing → editing(parsed, assets) +/// ↓ 失败 +/// editing(empty, assets) +/// editing → saved → dismiss +/// ``` +struct UnifiedCaptureFlow: View { + @Environment(\.modelContext) private var ctx + let onClose: () -> Void + + @State private var phase: Phase = .idle + + enum Phase { + case idle + case analyzing(images: [UIImage]) + case editing(parsed: ParsedReport, + assets: [FileVault.SavedAsset], + warning: String?) + } + + var body: some View { + NavigationStack { + content + .background(Tj.Palette.sand.ignoresSafeArea()) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button("取消") { onClose() } + .foregroundStyle(Tj.Palette.text) + } + } + .navigationTitle(phaseTitle) + .navigationBarTitleDisplayMode(.inline) + } + } + + private var phaseTitle: String { + switch phase { + case .idle: return "拍摄报告" + case .analyzing: return "本地识别中…" + case .editing: return "核对识别结果" + } + } + + @ViewBuilder + private var content: some View { + switch phase { + case .idle: + captureEntry + case .analyzing(let images): + AnalyzingView(images: images) + case .editing(let parsed, let assets, let warning): + CaptureReviewForm( + parsed: parsed, + assets: assets, + warning: warning, + onSave: { final in saveAll(parsed: final, assets: assets) }, + onCancel: onClose + ) + } + } + + // MARK: - 入口:相机 / 相册 + + private var captureEntry: some View { + #if targetEnvironment(simulator) + PhotoPickerSheet( + onFinish: { startAnalyze(images: $0) }, + onCancel: onClose + ) + #else + if DocumentScannerView.isSupported { + DocumentScannerView( + onFinish: { startAnalyze(images: $0) }, + onCancel: onClose + ) + .ignoresSafeArea() + } else { + PhotoPickerSheet( + onFinish: { startAnalyze(images: $0) }, + onCancel: onClose + ) + } + #endif + } + + // MARK: - 启动识别 + + private func startAnalyze(images: [UIImage]) { + guard !images.isEmpty else { onClose(); return } + phase = .analyzing(images: images) + Task { + do { + let result = try await CaptureService.shared.analyze(images: images) + await MainActor.run { + phase = .editing( + parsed: result.parsed, + assets: result.assets, + warning: result.parsed.isEmpty + ? "识别没有读出指标,请手动补充" + : nil + ) + } + } catch let CaptureError.parseFailed(msg) { + // 解析失败:仍然展示编辑表单,只是 indicators 为空,assets 已保存 + await fallbackToManual(images: images, msg: "VL 输出无法解析:\(msg)") + } catch let CaptureError.inferenceFailed(msg) { + await fallbackToManual(images: images, msg: "推理失败:\(msg)") + } catch let CaptureError.modelNotReady { + await fallbackToManual(images: images, msg: "VL 模型未就绪,先手动录入") + } catch CaptureError.writeAssetFailed { + await MainActor.run { + phase = .editing( + parsed: .empty(), + assets: [], + warning: "图片保存失败,手动录入并保留文本" + ) + } + } catch { + await fallbackToManual(images: images, msg: "未知错误:\(error.localizedDescription)") + } + } + } + + private func fallbackToManual(images: [UIImage], msg: String) async { + // 即便 VL 失败,图片应当已经写入了 Vault(在 CaptureService.analyze 第 1 步)。 + // 但若是 writeAsset 之前的失败(modelNotReady / inferenceFailed), + // 这里再补一次写,保证图不丢。 + var assets: [FileVault.SavedAsset] = [] + for img in images { + if let a = try? FileVault.shared.writeJPEG(img) { assets.append(a) } + } + await MainActor.run { + phase = .editing( + parsed: .empty(), + assets: assets, + warning: msg + ) + } + } + + // MARK: - 持久化 + + private func saveAll(parsed final: ParsedReport, + assets: [FileVault.SavedAsset]) { + let report = Report( + title: final.title.isEmpty ? "拍摄识别" : final.title, + type: ReportType(rawValue: final.typeRaw) ?? .other, + reportDate: final.reportDate, + institution: final.institution.isEmpty ? nil : final.institution, + summary: final.summary.isEmpty ? nil : final.summary, + pageCount: final.pageCount + ) + ctx.insert(report) + + // 关联 Asset + for a in assets { + let asset = Asset(relativePath: a.relativePath, bytes: a.bytes) + ctx.insert(asset) + report.assets.append(asset) + } + + // 关联 Indicator + for ind in final.indicators { + let i = Indicator( + name: ind.name, + value: ind.value, + unit: ind.unit, + range: ind.range, + status: ind.status, + capturedAt: final.reportDate, + report: report + ) + ctx.insert(i) + } + + try? ctx.save() + onClose() + } +} + +// MARK: - 分析中视图 + +private struct AnalyzingView: View { + let images: [UIImage] + + var body: some View { + VStack(spacing: 20) { + Spacer() + if let first = images.first { + Image(uiImage: first) + .resizable() + .scaledToFit() + .frame(maxHeight: 240) + .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: 1) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + ProgressView().tint(Tj.Palette.ink).scaleEffect(1.4) + ) + ) + } + VStack(spacing: 6) { + Text("本地识别中") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + Text("\(images.count) 页 · 100% 本地推理") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer() + } + .padding(.horizontal, 20) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/康康/Features/Indicator/CustomMetricEditor.swift b/康康/Features/Indicator/CustomMetricEditor.swift new file mode 100644 index 0000000..59ff812 --- /dev/null +++ b/康康/Features/Indicator/CustomMetricEditor.swift @@ -0,0 +1,329 @@ +import SwiftUI +import SwiftData + +let customMetricIconChoices: [String] = [ + "circle.fill", + "drop.fill", + "flame.fill", + "bolt.fill", + "leaf.fill", + "pills.fill", + "gauge.high", + "moon.fill", +] + +/// 名称冲突判定结果。`detectNameConflict` 返回此值用于 UI 警告。 +enum CustomMetricNameConflict: Equatable { + case none + case builtin(String) // 撞到 MonitorMetric.displayName + case existingCustom(String) // 撞到其他 CustomMonitorMetric.name + + var warningText: String { + switch self { + case .none: return "" + case .builtin(let n): return "「\(n)」是内置指标的名字 — 录入 grid 里会出现两个同名块" + case .existingCustom(let n):return "已经有一个叫「\(n)」的自定义指标" + } + } +} + +/// 纯函数:给定 candidate name + 现有 customs + 编辑时排除的 seriesKey,返回冲突类型。 +/// 抽离方便单测,不依赖 SwiftData 上下文。 +func detectNameConflict( + candidate: String, + customs: [CustomMonitorMetric], + excludingSeriesKey: String? = nil +) -> CustomMetricNameConflict { + let trimmed = candidate.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return .none } + + if MonitorMetric.allCases.contains(where: { $0.displayName == trimmed }) { + return .builtin(trimmed) + } + for c in customs where c.seriesKey != excludingSeriesKey && c.name == trimmed { + return .existingCustom(trimmed) + } + return .none +} + +/// 自定义长期监测指标的 create / edit / delete sheet。 +struct CustomMetricEditor: View { + @Environment(\.modelContext) private var ctx + @Environment(\.dismiss) private var dismiss + + /// nil = 新建;非 nil = 编辑现有 + let existing: CustomMonitorMetric? + /// 保存或删除后回调,parent 可借此 setSelectedCustom(metric? ) 触发后续 UI + var onSaved: (CustomMonitorMetric?) -> Void + + @Query private var allCustoms: [CustomMonitorMetric] + + @State private var name: String = "" + @State private var unit: String = "" + @State private var lower: String = "" + @State private var upper: String = "" + @State private var icon: String = "circle.fill" + @State private var hydrated = false + + private var trimmedName: String { name.trimmingCharacters(in: .whitespaces) } + private var trimmedUnit: String { unit.trimmingCharacters(in: .whitespaces) } + private var canSubmit: Bool { !trimmedName.isEmpty } + + private var nameConflict: CustomMetricNameConflict { + detectNameConflict( + candidate: name, + customs: allCustoms, + excludingSeriesKey: existing?.seriesKey + ) + } + + var body: some View { + VStack(spacing: 0) { + Capsule() + .fill(Tj.Palette.line) + .frame(width: 40, height: 4) + .padding(.top, 10) + .padding(.bottom, 14) + + header + .padding(.horizontal, 20) + .padding(.bottom, 16) + + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 18) { + nameSection + unitSection + rangeRow + iconSection + if existing != nil { + deleteButton + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + + footer + } + .background( + Tj.Palette.sand + .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) + .ignoresSafeArea(edges: .bottom) + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.hidden) + .presentationBackground(Tj.Palette.sand) + .presentationCornerRadius(Tj.Radius.xl) + .onAppear { hydrate() } + } + + private var header: some View { + HStack { + Text(existing == nil ? "新建自定义指标" : "编辑「\(existing!.name)」") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + Spacer() + if existing == nil { + Text("保存后会出现在录入选项里") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + } + } + + private var nameSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("名称") + TextField("例如:腰围 / 步数 / 睡眠时长", text: $name) + .padding(.horizontal, 14).padding(.vertical, 12) + .background(fieldBg) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder( + nameConflict == .none ? Tj.Palette.line : Tj.Palette.amber, + lineWidth: 1 + ) + ) + if nameConflict != .none { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.amber) + Text(nameConflict.warningText) + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.amber) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + } + } + } + + private var unitSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("单位(可选)") + TextField("例如:cm / 步 / 小时", text: $unit) + .autocorrectionDisabled() + .padding(.horizontal, 14).padding(.vertical, 12) + .background(fieldBg).overlay(fieldBorder) + } + } + + private var rangeRow: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + sectionLabel("参考范围(可选)") + Spacer() + Text("用于自动判定 正常/偏高/偏低") + .font(.system(size: 10)) + .foregroundStyle(Tj.Palette.text3) + } + HStack(spacing: 12) { + rangeField(label: "下限", value: $lower, placeholder: "70") + Text("—").foregroundStyle(Tj.Palette.text3) + rangeField(label: "上限", value: $upper, placeholder: "90") + } + } + } + + private func rangeField(label: String, value: Binding, placeholder: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3) + TextField(placeholder, text: value) + .keyboardType(.decimalPad) + .font(.system(size: 16, weight: .medium, design: .monospaced)) + .padding(.horizontal, 12).padding(.vertical, 10) + .background(fieldBg).overlay(fieldBorder) + } + } + + private var iconSection: some View { + VStack(alignment: .leading, spacing: 8) { + sectionLabel("图标") + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 8), count: 4), + spacing: 8) { + ForEach(customMetricIconChoices, id: \.self) { sf in + Button { + icon = sf + } label: { + Image(systemName: sf) + .font(.system(size: 20, weight: .medium)) + .foregroundStyle(icon == sf ? Tj.Palette.paper : Tj.Palette.ink) + .frame(maxWidth: .infinity, minHeight: 44) + .background( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .fill(icon == sf ? Tj.Palette.ink : Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: icon == sf ? 0 : 1) + ) + } + .buttonStyle(.plain) + } + } + } + } + + private var deleteButton: some View { + Button(role: .destructive) { + if let m = existing { + ReminderService.cancel(metricId: m.seriesKey) + ctx.delete(m) + try? ctx.save() + onSaved(nil) + dismiss() + } + } label: { + HStack { + Image(systemName: "trash") + Text("删除这项自定义指标") + } + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Tj.Palette.brick) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.brickSoft.opacity(0.5)) + ) + } + .buttonStyle(.plain) + .padding(.top, 8) + } + + private var footer: some View { + HStack(spacing: 12) { + Button("取消") { dismiss() } + .buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18)) + Button(existing == nil ? "新建" : "保存") { submit() } + .buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18)) + .disabled(!canSubmit) + .opacity(canSubmit ? 1 : 0.4) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + .background( + Tj.Palette.sand + .overlay(alignment: .top) { + Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) + } + ) + } + + // MARK: - helpers + + private var fieldBg: some View { + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.paper) + } + private var fieldBorder: some View { + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: 1) + } + private func sectionLabel(_ t: String) -> some View { + Text(t).font(.system(size: 12, weight: .semibold)).tracking(0.3) + .foregroundStyle(Tj.Palette.text2) + } + + private func hydrate() { + guard !hydrated, let m = existing else { hydrated = true; return } + name = m.name; unit = m.unit; icon = m.icon + lower = m.lowerBound.map { fmt($0) } ?? "" + upper = m.upperBound.map { fmt($0) } ?? "" + hydrated = true + } + + private func submit() { + guard canSubmit else { return } + let lo = Double(lower.trimmingCharacters(in: .whitespaces)) + let hi = Double(upper.trimmingCharacters(in: .whitespaces)) + if let m = existing { + m.name = trimmedName + m.unit = trimmedUnit + m.lowerBound = lo + m.upperBound = hi + m.icon = icon + try? ctx.save() + onSaved(m) + } else { + let m = CustomMonitorMetric( + name: trimmedName, + unit: trimmedUnit, + lowerBound: lo, + upperBound: hi, + icon: icon + ) + ctx.insert(m) + try? ctx.save() + onSaved(m) + } + dismiss() + } + + private func fmt(_ v: Double) -> String { + v.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", v) + : String(format: "%.1f", v) + } +} diff --git a/康康/Features/Indicator/IndicatorQuickSheet.swift b/康康/Features/Indicator/IndicatorQuickSheet.swift index c297394..627458a 100644 --- a/康康/Features/Indicator/IndicatorQuickSheet.swift +++ b/康康/Features/Indicator/IndicatorQuickSheet.swift @@ -48,9 +48,41 @@ struct IndicatorQuickSheet: View { @State private var systolic: String = "" @State private var diastolic: String = "" + // 周期性提醒(仅长期监测可用) + @Query private var allReminders: [MetricReminder] + @State private var reminderEnabled: Bool = false + @State private var reminderTime: Date = Self.defaultReminderTime + @State private var reminderWeekdays: Set = Set(1...7) + @State private var reminderHydratedFor: String? = nil + @State private var notifAuthBlocked: Bool = false + + // 自定义指标 + @Query(sort: \CustomMonitorMetric.createdAt, order: .reverse) + private var customMetrics: [CustomMonitorMetric] + @State private var selectedCustom: CustomMonitorMetric? + @State private var editingCustom: CustomMetricEditTarget? + + private static var defaultReminderTime: Date { + Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now + } + private var profile: UserProfile? { profiles.first } private var isBP: Bool { selectedMonitor == .bloodPressure } + private var isLongTermMetric: Bool { selectedMonitor != nil || selectedCustom != nil } + private var isCustomMonitor: Bool { selectedCustom != nil } + + /// 当前长期监测的稳定 key,用于 reminder 关联和 .task(id:) hydrate 触发。 + /// 血压用 metric.rawValue;custom 用 seriesKey;其他单字段 monitor 用 rawValue;非长期 nil。 + private var longTermKey: String? { + if let m = selectedMonitor { return m.rawValue } + if let cm = selectedCustom { return cm.seriesKey } + return nil + } + + private var longTermDisplayName: String? { + selectedMonitor?.displayName ?? selectedCustom?.name + } private var canSubmit: Bool { if isBP { @@ -78,16 +110,18 @@ struct IndicatorQuickSheet: View { nameSection valueRow rangeSection - if selectedMonitor == nil { - // 自由输入或 lab preset 时 status 手动;monitor 单字段自动 - statusSection - } else { + if isLongTermMetric { autoStatusHint + } else { + statusSection } } timeSection noteSection + if isLongTermMetric { + reminderSection + } } .padding(.horizontal, 20) .padding(.bottom, 20) @@ -95,6 +129,7 @@ struct IndicatorQuickSheet: View { footer } + .task(id: longTermKey) { hydrateReminder() } .background( Tj.Palette.sand .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) @@ -138,8 +173,105 @@ struct IndicatorQuickSheet: View { ForEach(MonitorMetric.allCases) { m in monitorTile(m) } + ForEach(customMetrics) { cm in + customTile(cm) + } + addCustomTile } } + .sheet(item: $editingCustom) { target in + CustomMetricEditor(existing: target.metric) { saved in + // 新建后自动选中,删除后清空选择 + if let saved { + selectedCustom = saved + selectedMonitor = nil + selectedLabPreset = nil + fillFromCustom(saved) + } else if selectedCustom?.seriesKey == target.metric?.seriesKey { + selectedCustom = nil + clearAllFields() + } + } + } + } + + private func customTile(_ cm: CustomMonitorMetric) -> some View { + let selected = selectedCustom?.seriesKey == cm.seriesKey + return Button { + applyCustom(cm) + } label: { + HStack(spacing: 10) { + Image(systemName: cm.icon) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink) + .frame(width: 32, height: 32) + .background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft)) + + VStack(alignment: .leading, spacing: 1) { + Text(cm.name) + .font(.system(size: 14, weight: selected ? .semibold : .medium)) + .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text) + .lineLimit(1) + Text("自定义") + .font(.system(size: 9, design: .monospaced)) + .foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3) + } + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(selected ? Tj.Palette.ink : Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1) + ) + } + .buttonStyle(.plain) + .contextMenu { + Button { editingCustom = CustomMetricEditTarget(metric: cm) } label: { + Label("编辑", systemImage: "pencil") + } + Button(role: .destructive) { + editingCustom = CustomMetricEditTarget(metric: cm) + } label: { + Label("编辑/删除", systemImage: "trash") + } + } + } + + private var addCustomTile: some View { + Button { + editingCustom = CustomMetricEditTarget(metric: nil) + } label: { + HStack(spacing: 10) { + Image(systemName: "plus") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(Tj.Palette.text2) + .frame(width: 32, height: 32) + .background( + Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true) + ) + Text("自定义") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Tj.Palette.text2) + Spacer() + } + .padding(.horizontal, 10) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.sand2.opacity(0.5)) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .strokeBorder(Tj.Palette.line.opacity(0.6), + style: StrokeStyle(lineWidth: 1, dash: [4, 3])) + ) + } + .buttonStyle(.plain) } private func monitorTile(_ m: MonitorMetric) -> some View { @@ -265,8 +397,8 @@ struct IndicatorQuickSheet: View { selectedLabPreset = nil } } - .disabled(selectedMonitor != nil) - .opacity(selectedMonitor != nil ? 0.6 : 1) + .disabled(isLongTermMetric) + .opacity(isLongTermMetric ? 0.6 : 1) } } @@ -291,8 +423,8 @@ struct IndicatorQuickSheet: View { .padding(.vertical, 12) .background(fieldBg) .overlay(fieldBorder) - .disabled(selectedMonitor != nil) - .opacity(selectedMonitor != nil ? 0.6 : 1) + .disabled(isLongTermMetric) + .opacity(isLongTermMetric ? 0.6 : 1) } .frame(maxWidth: 130) } @@ -314,8 +446,8 @@ struct IndicatorQuickSheet: View { .padding(.vertical, 12) .background(fieldBg) .overlay(fieldBorder) - .disabled(selectedMonitor != nil) - .opacity(selectedMonitor != nil ? 0.6 : 1) + .disabled(isLongTermMetric) + .opacity(isLongTermMetric ? 0.6 : 1) } } @@ -376,6 +508,193 @@ struct IndicatorQuickSheet: View { } } + // MARK: - 周期提醒 + + private var reminderSection: some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + sectionLabel("周期提醒") + Spacer() + Toggle("", isOn: $reminderEnabled) + .labelsHidden() + .tint(Tj.Palette.ink) + .onChange(of: reminderEnabled) { _, on in + if on { Task { await requestNotifAuthIfNeeded() } } + } + } + + if reminderEnabled { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("时间") + .font(.system(size: 13)) + .foregroundStyle(Tj.Palette.text2) + Spacer() + DatePicker("", selection: $reminderTime, + displayedComponents: .hourAndMinute) + .datePickerStyle(.compact) + .labelsHidden() + } + + VStack(alignment: .leading, spacing: 6) { + HStack { + Text("频率") + .font(.system(size: 13)) + .foregroundStyle(Tj.Palette.text2) + Spacer() + Text(reminderFrequencyLabel) + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + weekdayPickerRow + HStack(spacing: 8) { + quickFreqChip("每天") { + reminderWeekdays = Set(1...7) + } + quickFreqChip("工作日") { + reminderWeekdays = Set([2, 3, 4, 5, 6]) + } + quickFreqChip("周末") { + reminderWeekdays = Set([1, 7]) + } + } + } + + if notifAuthBlocked { + Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.brick) + } else { + Text("本机提醒 · 不发任何数据") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + } + .padding(12) + .background(fieldBg) + .overlay(fieldBorder) + } + } + } + + private var reminderFrequencyLabel: String { + if reminderWeekdays.count == 7 { return "每天" } + if reminderWeekdays.isEmpty { return "未选" } + let names = ["日", "一", "二", "三", "四", "五", "六"] + let sorted = reminderWeekdays.sorted() + return "每周 " + sorted.map { names[$0 - 1] }.joined() + } + + private var weekdayPickerRow: some View { + let names = ["一", "二", "三", "四", "五", "六", "日"] + let weekdayValues = [2, 3, 4, 5, 6, 7, 1] // 周一到周日(Apple Calendar 编号) + return HStack(spacing: 6) { + ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in + Button { + if reminderWeekdays.contains(w) { + reminderWeekdays.remove(w) + } else { + reminderWeekdays.insert(w) + } + } label: { + Text(names[idx]) + .font(.system(size: 13, + weight: reminderWeekdays.contains(w) ? .semibold : .regular)) + .foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text) + .frame(maxWidth: .infinity, minHeight: 32) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(reminderWeekdays.contains(w) ? Tj.Palette.ink : Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Tj.Palette.line, + lineWidth: reminderWeekdays.contains(w) ? 0 : 1) + ) + } + .buttonStyle(.plain) + } + } + } + + private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text2) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(Capsule().fill(Tj.Palette.sand2)) + } + .buttonStyle(.plain) + } + + private func hydrateReminder() { + guard let key = longTermKey else { return } + if reminderHydratedFor == key { return } + reminderHydratedFor = key + if let existing = allReminders.first(where: { $0.metricId == key }) { + reminderEnabled = existing.enabled + reminderTime = Calendar.current.date( + bySettingHour: existing.hour, minute: existing.minute, second: 0, of: .now + ) ?? Self.defaultReminderTime + reminderWeekdays = Set(existing.weekdays) + } else { + reminderEnabled = false + reminderTime = Self.defaultReminderTime + reminderWeekdays = Set(1...7) + } + } + + private func requestNotifAuthIfNeeded() async { + let state = await ReminderService.requestAuthorization() + notifAuthBlocked = (state == .denied) + if notifAuthBlocked { + reminderEnabled = false + } + } + + /// submit() 调用,处理提醒:enabled → upsert SwiftData + 调度通知;disabled → 删旧 reminder + 取消通知。 + private func persistReminderIfNeeded() async { + guard let key = longTermKey, let displayName = longTermDisplayName else { return } + let existing = allReminders.first(where: { $0.metricId == key }) + let cal = Calendar.current + let hour = cal.component(.hour, from: reminderTime) + let minute = cal.component(.minute, from: reminderTime) + + if reminderEnabled && !reminderWeekdays.isEmpty { + let reminder: MetricReminder + if let existing { + existing.enabled = true + existing.hour = hour + existing.minute = minute + existing.weekdays = reminderWeekdays.sorted() + existing.displayName = displayName + existing.updatedAt = .now + reminder = existing + } else { + let new = MetricReminder( + metricId: key, + displayName: displayName, + hour: hour, + minute: minute, + weekdays: reminderWeekdays.sorted(), + enabled: true + ) + ctx.insert(new) + reminder = new + } + try? ctx.save() + await ReminderService.sync(reminder) + } else if let existing { + // 关闭:保留 SwiftData 行,只改 enabled = false,取消通知 + existing.enabled = false + existing.updatedAt = .now + try? ctx.save() + ReminderService.cancel(metricId: key) + } + } + private var footer: some View { HStack(spacing: 12) { Button("取消") { dismiss() } @@ -476,6 +795,7 @@ struct IndicatorQuickSheet: View { } selectedMonitor = m selectedLabPreset = nil + selectedCustom = nil if m == .bloodPressure { // 血压走 bp 字段,不动 name/value/unit @@ -500,21 +820,53 @@ struct IndicatorQuickSheet: View { private func applyLab(_ p: IndicatorPreset) { selectedLabPreset = p selectedMonitor = nil + selectedCustom = nil systolic = ""; diastolic = "" name = p.name if unit.trimmingCharacters(in: .whitespaces).isEmpty { unit = p.unit } if range.trimmingCharacters(in: .whitespaces).isEmpty { range = p.range } } + private func applyCustom(_ cm: CustomMonitorMetric) { + if selectedCustom?.seriesKey == cm.seriesKey { + selectedCustom = nil + clearAllFields() + return + } + selectedCustom = cm + selectedMonitor = nil + selectedLabPreset = nil + fillFromCustom(cm) + } + + private func fillFromCustom(_ cm: CustomMonitorMetric) { + name = cm.name + value = "" + unit = cm.unit + range = cm.rangeText + systolic = ""; diastolic = "" + } + + private func clearAllFields() { + name = ""; value = ""; unit = ""; range = "" + systolic = ""; diastolic = "" + } + // MARK: - auto status private var computedSingleStatus: (label: String, color: Color)? { - guard let m = selectedMonitor, m != .bloodPressure, - let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil } - let f = m.fields[0] - let r = m.effectiveRange(for: f, profile: profile) - let s = MonitorMetric.status(value: v, in: r) - return (s.label, s.color) + guard let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil } + if let m = selectedMonitor, m != .bloodPressure { + let f = m.fields[0] + let r = m.effectiveRange(for: f, profile: profile) + let s = MonitorMetric.status(value: v, in: r) + return (s.label, s.color) + } + if let cm = selectedCustom { + let s = MonitorMetric.status(value: v, in: cm.referenceRange) + return (s.label, s.color) + } + return nil } private enum BPSide { case systolic, diastolic } @@ -538,10 +890,16 @@ struct IndicatorQuickSheet: View { saveBP() } else if let m = selectedMonitor { saveSingleMonitor(m) + } else if let cm = selectedCustom { + saveCustom(cm) } else { saveFreeform() } - dismiss() + + Task { + await persistReminderIfNeeded() + await MainActor.run { dismiss() } + } } private func saveBP() { @@ -603,6 +961,24 @@ struct IndicatorQuickSheet: View { try? ctx.save() } + private func saveCustom(_ cm: CustomMonitorMetric) { + let v = Double(value.trimmingCharacters(in: .whitespaces)) ?? 0 + let status = MonitorMetric.status(value: v, in: cm.referenceRange) + let indicator = Indicator( + name: cm.name, + value: value.trimmingCharacters(in: .whitespaces), + unit: cm.unit, + range: cm.rangeText, + status: status, + note: note.isEmpty ? nil : note, + capturedAt: capturedAt, + pinned: true, + seriesKey: cm.seriesKey + ) + ctx.insert(indicator) + try? ctx.save() + } + private func saveFreeform() { let indicator = Indicator( name: name.trimmingCharacters(in: .whitespaces), @@ -638,7 +1014,16 @@ private extension IndicatorStatus { } } +/// `.sheet(item:)` 要求 Identifiable;包一层避免 CustomMonitorMetric? 不能直接当 binding 用。 +struct CustomMetricEditTarget: Identifiable { + let metric: CustomMonitorMetric? + var id: String { metric?.seriesKey ?? "_new_" } +} + #Preview { IndicatorQuickSheet() - .modelContainer(for: [Indicator.self, UserProfile.self], inMemory: true) + .modelContainer(for: [ + Indicator.self, UserProfile.self, + MetricReminder.self, CustomMonitorMetric.self + ], inMemory: true) } diff --git a/康康/Features/Me/CustomMetricsListView.swift b/康康/Features/Me/CustomMetricsListView.swift new file mode 100644 index 0000000..7363102 --- /dev/null +++ b/康康/Features/Me/CustomMetricsListView.swift @@ -0,0 +1,153 @@ +import SwiftUI +import SwiftData + +/// 「我的 · 自定义指标」管理页。 +/// 从 MeView 进入;集中查看 / 新建 / 编辑 / 删除自定义长期监测指标。 +struct CustomMetricsListView: View { + @Query(sort: \CustomMonitorMetric.createdAt, order: .reverse) + private var metrics: [CustomMonitorMetric] + + @Query private var indicators: [Indicator] + + @State private var editingTarget: CustomMetricEditTarget? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + hintBanner + if metrics.isEmpty { + emptyState + } else { + ForEach(metrics) { m in + Button { + editingTarget = CustomMetricEditTarget(metric: m) + } label: { + row(m) + } + .buttonStyle(.plain) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 32) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + .navigationTitle("自定义指标") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + editingTarget = CustomMetricEditTarget(metric: nil) + } label: { + Image(systemName: "plus") + .font(.system(size: 16, weight: .semibold)) + } + } + } + .sheet(item: $editingTarget) { target in + CustomMetricEditor(existing: target.metric) { _ in } + } + } + + // MARK: - subviews + + private var hintBanner: some View { + HStack(spacing: 10) { + Image(systemName: "info.circle.fill") + .foregroundStyle(Tj.Palette.text3) + Text("自定义指标会出现在「+ 指标记录 → 长期监测」的 grid 里,可设提醒、进趋势") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text2) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + } + .padding(12) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) + .fill(Tj.Palette.sand2.opacity(0.5)) + ) + } + + private var emptyState: some View { + VStack(spacing: 14) { + Spacer(minLength: 40) + TjPlaceholder(label: "还没有自定义指标") + .frame(width: 220, height: 130) + Text("右上角 + 新建一个") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + } + .frame(maxWidth: .infinity) + } + + private func row(_ m: CustomMonitorMetric) -> some View { + let count = usageCount(for: m) + return HStack(spacing: 12) { + ZStack { + Circle().fill(Tj.Palette.leafSoft) + Image(systemName: m.icon) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(Tj.Palette.ink) + } + .frame(width: 40, height: 40) + + VStack(alignment: .leading, spacing: 3) { + Text(m.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .lineLimit(1) + HStack(spacing: 6) { + if !m.unit.isEmpty { + Text(m.unit) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + if !m.rangeText.isEmpty { + Text("·") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + Text(m.rangeText) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + } + } + + Spacer(minLength: 8) + + VStack(alignment: .trailing, spacing: 2) { + Text(count == 0 ? "未使用" : "用 \(count) 次") + .font(.system(size: 11, weight: count > 0 ? .semibold : .regular)) + .foregroundStyle(count > 0 ? Tj.Palette.ink : Tj.Palette.text3) + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) + } + + private func usageCount(for m: CustomMonitorMetric) -> Int { + indicators.filter { $0.seriesKey == m.seriesKey }.count + } +} + +#Preview { + NavigationStack { + CustomMetricsListView() + } + .modelContainer(for: [ + CustomMonitorMetric.self, Indicator.self, + UserProfile.self, MetricReminder.self, + ], inMemory: true) +} diff --git a/康康/Features/Me/MeView.swift b/康康/Features/Me/MeView.swift index d4acaa3..a7479b5 100644 --- a/康康/Features/Me/MeView.swift +++ b/康康/Features/Me/MeView.swift @@ -4,14 +4,19 @@ import SwiftData struct MeView: View { @Environment(\.modelContext) private var ctx @Query private var profiles: [UserProfile] + @Query private var reminders: [MetricReminder] + @Query private var customMetrics: [CustomMonitorMetric] private var profile: UserProfile? { profiles.first } + private var enabledReminderCount: Int { reminders.filter(\.enabled).count } var body: some View { NavigationStack { ScrollView { VStack(spacing: 12) { profileCard + remindersCard + customMetricsCard settingsCard(title: "模型管理", detail: "未配置", icon: "cpu") @@ -77,6 +82,85 @@ struct MeView: View { .buttonStyle(.plain) } + private var remindersCard: some View { + NavigationLink { + RemindersListView() + } label: { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(enabledReminderCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2) + Image(systemName: "bell.fill") + .font(.system(size: 18)) + .foregroundStyle(enabledReminderCount > 0 ? Tj.Palette.ink : Tj.Palette.text2) + } + .frame(width: 44, height: 44) + + VStack(alignment: .leading, spacing: 2) { + Text("记录提醒") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text(reminderLine) + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + .lineLimit(1) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(14) + .tjCard() + } + .buttonStyle(.plain) + } + + private var reminderLine: String { + if reminders.isEmpty { return "尚未设置" } + if enabledReminderCount == 0 { return "全部已关闭(\(reminders.count) 条)" } + return "\(enabledReminderCount) 项启用" + } + + private var customMetricsCard: some View { + NavigationLink { + CustomMetricsListView() + } label: { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(customMetrics.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft) + Image(systemName: "slider.horizontal.3") + .font(.system(size: 18)) + .foregroundStyle(customMetrics.isEmpty ? Tj.Palette.text2 : Tj.Palette.ink) + } + .frame(width: 44, height: 44) + + VStack(alignment: .leading, spacing: 2) { + Text("自定义指标") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text(customMetricsLine) + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + .lineLimit(1) + } + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(Tj.Palette.text3) + } + .padding(14) + .tjCard() + } + .buttonStyle(.plain) + } + + private var customMetricsLine: String { + if customMetrics.isEmpty { return "添加你自己的长期监测项" } + return "\(customMetrics.count) 项" + } + private func settingsCard(title: String, detail: String, icon: String) -> some View { HStack(spacing: 12) { ZStack { @@ -114,6 +198,7 @@ struct MeView: View { MeView() .modelContainer(for: [ UserProfile.self, Indicator.self, Report.self, DiaryEntry.self, - Asset.self, ChatTurn.self, Symptom.self, + Asset.self, ChatTurn.self, Symptom.self, MetricReminder.self, + CustomMonitorMetric.self, ], inMemory: true) } diff --git a/康康/Features/Me/RemindersListView.swift b/康康/Features/Me/RemindersListView.swift new file mode 100644 index 0000000..195585b --- /dev/null +++ b/康康/Features/Me/RemindersListView.swift @@ -0,0 +1,221 @@ +import SwiftUI +import SwiftData + +struct RemindersListView: View { + @Environment(\.modelContext) private var ctx + @Query(sort: \MetricReminder.updatedAt, order: .reverse) + private var reminders: [MetricReminder] + + @State private var editingId: String? + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + header + if reminders.isEmpty { + emptyState + } else { + ForEach(reminders) { r in + ReminderRow( + reminder: r, + isEditing: editingId == r.metricId, + onTapEdit: { toggleEdit(r.metricId) }, + onChange: { Task { await sync(r) } }, + onDelete: { delete(r) } + ) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + .padding(.bottom, 32) + } + .background(Tj.Palette.sand.ignoresSafeArea()) + .navigationTitle("记录提醒") + .navigationBarTitleDisplayMode(.inline) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text("\(enabledCount) / \(reminders.count) 项启用") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + Text("提醒在录入「指标记录 · 长期监测」时开启") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var emptyState: some View { + VStack(spacing: 12) { + Spacer(minLength: 40) + TjPlaceholder(label: "还没有记录提醒\n去「+ 指标记录」录入时打开") + .frame(width: 240, height: 140) + Spacer() + } + .frame(maxWidth: .infinity) + } + + private var enabledCount: Int { reminders.filter(\.enabled).count } + + private func toggleEdit(_ id: String) { + editingId = (editingId == id) ? nil : id + } + + private func sync(_ r: MetricReminder) async { + r.updatedAt = .now + try? ctx.save() + await ReminderService.sync(r) + } + + private func delete(_ r: MetricReminder) { + ReminderService.cancel(metricId: r.metricId) + ctx.delete(r) + try? ctx.save() + } +} + +private struct ReminderRow: View { + @Bindable var reminder: MetricReminder + let isEditing: Bool + let onTapEdit: () -> Void + let onChange: () -> Void + let onDelete: () -> Void + + @State private var pickedTime: Date = .now + @State private var hydrated = false + + var body: some View { + VStack(spacing: 12) { + headerRow + if isEditing { + editingPanel + } + } + .padding(14) + .background( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .fill(Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous) + .strokeBorder(Tj.Palette.lineSoft, lineWidth: 1) + ) + } + + private var headerRow: some View { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(reminder.enabled ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2) + Image(systemName: "bell.fill") + .font(.system(size: 16)) + .foregroundStyle(reminder.enabled ? Tj.Palette.ink : Tj.Palette.text3) + } + .frame(width: 36, height: 36) + + VStack(alignment: .leading, spacing: 2) { + Text(reminder.displayName) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text("\(reminder.timeLabel) · \(reminder.frequencyLabel)") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + + Spacer() + + Toggle("", isOn: $reminder.enabled) + .labelsHidden() + .tint(Tj.Palette.ink) + .onChange(of: reminder.enabled) { _, _ in onChange() } + + Button { + onTapEdit() + } label: { + Image(systemName: isEditing ? "chevron.up" : "chevron.down") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.text3) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + } + } + + private var editingPanel: some View { + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("时间").font(.system(size: 13)).foregroundStyle(Tj.Palette.text2) + Spacer() + DatePicker("", selection: $pickedTime, displayedComponents: .hourAndMinute) + .datePickerStyle(.compact) + .labelsHidden() + .onChange(of: pickedTime) { _, new in + let cal = Calendar.current + reminder.hour = cal.component(.hour, from: new) + reminder.minute = cal.component(.minute, from: new) + onChange() + } + } + weekdayRow + HStack { + Spacer() + Button(role: .destructive) { + onDelete() + } label: { + Label("删除提醒", systemImage: "trash") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.brick) + } + .buttonStyle(.plain) + } + } + .onAppear { + if !hydrated { + pickedTime = Calendar.current.date( + bySettingHour: reminder.hour, minute: reminder.minute, second: 0, of: .now + ) ?? .now + hydrated = true + } + } + } + + private var weekdayRow: some View { + let names = ["一", "二", "三", "四", "五", "六", "日"] + let weekdayValues = [2, 3, 4, 5, 6, 7, 1] + return HStack(spacing: 6) { + ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in + Button { + var s = Set(reminder.weekdays) + if s.contains(w) { s.remove(w) } else { s.insert(w) } + reminder.weekdays = s.sorted() + onChange() + } label: { + Text(names[idx]) + .font(.system(size: 13, + weight: reminder.weekdays.contains(w) ? .semibold : .regular)) + .foregroundStyle(reminder.weekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text) + .frame(maxWidth: .infinity, minHeight: 30) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(reminder.weekdays.contains(w) ? Tj.Palette.ink : Tj.Palette.paper) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(Tj.Palette.line, + lineWidth: reminder.weekdays.contains(w) ? 0 : 1) + ) + } + .buttonStyle(.plain) + } + } + } +} + +#Preview { + NavigationStack { + RemindersListView() + } + .modelContainer(for: [MetricReminder.self], inMemory: true) +} diff --git a/康康/Features/Trends/SeriesBucket.swift b/康康/Features/Trends/SeriesBucket.swift index 9c3d8c0..d0b0e78 100644 --- a/康康/Features/Trends/SeriesBucket.swift +++ b/康康/Features/Trends/SeriesBucket.swift @@ -36,6 +36,7 @@ extension SeriesBucket { /// `minPoints` 以下的系列不返回,默认 2(单点不画线)。 static func build(from indicators: [Indicator], profile: UserProfile? = nil, + customMetrics: [CustomMonitorMetric] = [], minPoints: Int = 2) -> [SeriesBucket] { var buckets: [String: [Indicator]] = [:] for i in indicators { @@ -55,9 +56,15 @@ extension SeriesBucket { } for k in bpKeys { buckets.removeValue(forKey: k) } + let customByKey: [String: CustomMonitorMetric] = Dictionary( + uniqueKeysWithValues: customMetrics.map { ($0.seriesKey, $0) } + ) + for (key, items) in buckets { guard items.count >= minPoints else { continue } - if let bucket = buildSingle(key: key, items: items, profile: profile) { + if let bucket = buildSingle(key: key, items: items, + profile: profile, + custom: customByKey[key]) { results.append(bucket) } } @@ -67,15 +74,24 @@ extension SeriesBucket { private static func buildSingle(key: String, items: [Indicator], - profile: UserProfile?) -> SeriesBucket? { + profile: UserProfile?, + custom: CustomMonitorMetric? = nil) -> SeriesBucket? { let sorted = items.sorted { $0.capturedAt < $1.capturedAt } guard let latest = sorted.last else { return nil } + // 优先 custom,其次 builtin metric,最后 fallback 到 Indicator 自身 let metric = monitorMetric(for: key) let field = metric?.fields.first { $0.seriesKey == key } - let title = metric?.displayName ?? sorted.first?.name ?? key - let unit = field?.unit ?? sorted.first?.unit ?? "" - let range = field.flatMap { metric?.effectiveRange(for: $0, profile: profile) } + let title = custom?.name + ?? metric?.displayName + ?? sorted.first?.name + ?? key + let unit = custom?.unit.nonEmptyOr(nil) + ?? field?.unit + ?? sorted.first?.unit + ?? "" + let range = custom?.referenceRange + ?? field.flatMap { metric?.effectiveRange(for: $0, profile: profile) } let line = SeriesLine( id: key, @@ -151,3 +167,10 @@ extension SeriesBucket { } } } + +private extension String { + /// 空串 → fallback;非空 → 自身。 + func nonEmptyOr(_ fallback: String?) -> String? { + trimmingCharacters(in: .whitespaces).isEmpty ? fallback : self + } +} diff --git a/康康/Features/Trends/SeriesChartCard.swift b/康康/Features/Trends/SeriesChartCard.swift new file mode 100644 index 0000000..1b04525 --- /dev/null +++ b/康康/Features/Trends/SeriesChartCard.swift @@ -0,0 +1,179 @@ +import SwiftUI +import Charts + +struct SeriesChartCard: View { + let bucket: SeriesBucket + + private var allPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)] { + bucket.lines.flatMap { line in line.points.map { (line, $0) } } + } + + private var dateDomain: ClosedRange? { + let dates = allPoints.map(\.point.date) + guard let lo = dates.min(), let hi = dates.max() else { return nil } + if lo == hi { + // 只有一个点的极端情况:扩 1 天显示 + let cal = Calendar.current + let earlier = cal.date(byAdding: .hour, value: -12, to: lo) ?? lo + let later = cal.date(byAdding: .hour, value: 12, to: hi) ?? hi + return earlier...later + } + return lo...hi + } + + private var valueDomain: ClosedRange? { + var lo = Double.greatestFiniteMagnitude + var hi = -Double.greatestFiniteMagnitude + for (_, p) in allPoints { + lo = min(lo, p.value) + hi = max(hi, p.value) + } + for line in bucket.lines { + if let r = line.referenceRange { + lo = min(lo, r.lowerBound) + hi = max(hi, r.upperBound) + } + } + guard lo < hi else { return nil } + let pad = max(1, (hi - lo) * 0.12) + return (lo - pad)...(hi + pad) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + header + chart + .frame(height: 120) + if bucket.lines.count > 1 { + legendLine + } + } + .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 var header: some View { + HStack(alignment: .lastTextBaseline, spacing: 10) { + Text(bucket.title) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + Text("\(allPoints.count) 条 · 近 \(daysSpanLabel)") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + latestValueBadge + } + } + + private var latestValueBadge: some View { + let parts = bucket.lines.compactMap { line -> String? in + guard let p = line.latestPoint else { return nil } + return formatValue(p.value) + } + let joined = parts.joined(separator: " / ") + let anyAbnormal = bucket.lines.contains { line in + (line.latestPoint?.status ?? .normal) != .normal + } + return HStack(spacing: 4) { + Text(joined) + .font(.system(size: 14, weight: .semibold, design: .monospaced)) + .foregroundStyle(anyAbnormal ? Tj.Palette.brick : Tj.Palette.text) + Text(bucket.unit) + .font(.system(size: 10, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + } + + private var chart: some View { + Chart { + // 参考范围带 + ForEach(bucket.lines) { line in + if let r = line.referenceRange, + let dom = dateDomain { + RectangleMark( + xStart: .value("start", dom.lowerBound), + xEnd: .value("end", dom.upperBound), + yStart: .value("lo", r.lowerBound), + yEnd: .value("hi", r.upperBound) + ) + .foregroundStyle(line.color.opacity(0.08)) + } + } + + // 折线 + 点 + ForEach(bucket.lines) { line in + ForEach(line.points) { p in + LineMark( + x: .value("时间", p.date), + y: .value(line.label ?? bucket.title, p.value) + ) + .foregroundStyle(line.color) + .interpolationMethod(.catmullRom) + .lineStyle(StrokeStyle(lineWidth: 2)) + } + .symbol { + Circle() + .fill(line.color) + .frame(width: 6, height: 6) + } + } + } + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 4)) { _ in + AxisGridLine().foregroundStyle(Tj.Palette.lineSoft) + AxisValueLabel(format: .dateTime.month(.abbreviated).day(), + centered: false) + .foregroundStyle(Tj.Palette.text3) + } + } + .chartYAxis { + AxisMarks(position: .leading, values: .automatic(desiredCount: 3)) { _ in + AxisGridLine().foregroundStyle(Tj.Palette.lineSoft) + AxisValueLabel() + .foregroundStyle(Tj.Palette.text3) + .font(.system(size: 10, design: .monospaced)) + } + } + .chartYScale(domain: valueDomain ?? 0...1) + } + + private var legendLine: some View { + HStack(spacing: 12) { + ForEach(bucket.lines) { line in + HStack(spacing: 4) { + Circle() + .fill(line.color) + .frame(width: 8, height: 8) + Text(line.label ?? line.seriesKey) + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text2) + } + } + } + } + + private var daysSpanLabel: String { + guard let dom = dateDomain else { return "—" } + let days = Calendar.current.dateComponents([.day], + from: dom.lowerBound, + to: dom.upperBound).day ?? 0 + if days <= 0 { return "今天" } + if days < 30 { return "\(days) 天" } + if days < 365 { return "\(days / 30) 个月" } + return "\(days / 365) 年" + } + + private func formatValue(_ v: Double) -> String { + v.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", v) + : String(format: "%.1f", v) + } +} diff --git a/康康/Features/Trends/TrendsView.swift b/康康/Features/Trends/TrendsView.swift index 84dc3f0..8dc2707 100644 --- a/康康/Features/Trends/TrendsView.swift +++ b/康康/Features/Trends/TrendsView.swift @@ -25,10 +25,22 @@ struct TrendsView: View { @Query(sort: \Symptom.startedAt, order: .reverse) private var symptoms: [Symptom] + @Query private var profiles: [UserProfile] + + @Query private var customMetrics: [CustomMonitorMetric] + @State private var mode: CalendarMode = .month @State private var anchor: Date = .now @State private var selectedDay: SelectedDay? + private var profile: UserProfile? { profiles.first } + + private var seriesBuckets: [SeriesBucket] { + SeriesBucket.build(from: indicators, + profile: profile, + customMetrics: customMetrics) + } + private let calendar: Calendar = { var c = Calendar(identifier: .gregorian) c.firstWeekday = 2 @@ -54,6 +66,7 @@ struct TrendsView: View { anchorBar calendarBody legend + seriesSection } .padding(.horizontal, 20) .padding(.bottom, 24) @@ -186,6 +199,31 @@ struct TrendsView: View { } } + @ViewBuilder + private var seriesSection: some View { + let buckets = seriesBuckets + if !buckets.isEmpty { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .lastTextBaseline) { + Text("长期监测") + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + Text("\(buckets.count) 项") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + } + .padding(.top, 8) + + VStack(spacing: 12) { + ForEach(buckets) { bucket in + SeriesChartCard(bucket: bucket) + } + } + } + } + } + private var legend: some View { VStack(alignment: .leading, spacing: 8) { Text("图例") diff --git a/康康/Models/Models.swift b/康康/Models/Models.swift index 48976df..dcb609e 100644 --- a/康康/Models/Models.swift +++ b/康康/Models/Models.swift @@ -171,6 +171,98 @@ final class Symptom { } } +/// 用户自定义的长期监测指标。 +/// 与 hardcoded `MonitorMetric` 并列出现在 IndicatorQuickSheet 的 grid 里; +/// `seriesKey` 自动生成成 `"custom."`,以此和 Indicator 双向关联。 +@Model +final class CustomMonitorMetric { + @Attribute(.unique) var seriesKey: String + var name: String + var unit: String + var lowerBound: Double? + var upperBound: Double? + var icon: String + var createdAt: Date + + init(name: String, + unit: String, + lowerBound: Double? = nil, + upperBound: Double? = nil, + icon: String = "circle.fill", + createdAt: Date = .now) { + self.seriesKey = "custom.\(UUID().uuidString)" + self.name = name + self.unit = unit + self.lowerBound = lowerBound + self.upperBound = upperBound + self.icon = icon + self.createdAt = createdAt + } + + var referenceRange: ClosedRange? { + guard let lo = lowerBound, let hi = upperBound, lo <= hi else { return nil } + return lo...hi + } + + var rangeText: String { + guard let r = referenceRange else { return "" } + return "\(Self.format(r.lowerBound)) - \(Self.format(r.upperBound))" + } + + private static func format(_ v: Double) -> String { + v.truncatingRemainder(dividingBy: 1) == 0 + ? String(format: "%.0f", v) + : String(format: "%.1f", v) + } +} + +/// 长期监测指标的周期性记录提醒。 +/// 一个 metric 一条(`metricId` = `MonitorMetric.rawValue`)。 +/// 关闭通过 `enabled=false`(保留时间设置),删除走 `ctx.delete`。 +@Model +final class MetricReminder { + @Attribute(.unique) var metricId: String + var displayName: String + var enabled: Bool + var hour: Int // 0...23 + var minute: Int // 0...59 + var weekdays: [Int] // iOS Calendar 约定:1=日, 2=一, ..., 7=六。全 7 个 = 每天 + var createdAt: Date + var updatedAt: Date + + init(metricId: String, + displayName: String, + hour: Int = 8, + minute: Int = 0, + weekdays: [Int] = [1, 2, 3, 4, 5, 6, 7], + enabled: Bool = true, + createdAt: Date = .now) { + self.metricId = metricId + self.displayName = displayName + self.enabled = enabled + self.hour = max(0, min(23, hour)) + self.minute = max(0, min(59, minute)) + self.weekdays = weekdays + self.createdAt = createdAt + self.updatedAt = createdAt + } + + var isEveryDay: Bool { Set(weekdays) == Set(1...7) } + + var frequencyLabel: String { + if !enabled { return "已关闭" } + if isEveryDay { return "每天" } + if weekdays.isEmpty { return "未选日" } + let names = ["日", "一", "二", "三", "四", "五", "六"] + let sorted = weekdays.sorted() + return "每周 " + sorted.map { names[$0 - 1] }.joined() + } + + var timeLabel: String { + String(format: "%02d:%02d", hour, minute) + } +} + @Model final class ChatTurn { var question: String diff --git a/康康/RootView.swift b/康康/RootView.swift index c30d8d3..21c425e 100644 --- a/康康/RootView.swift +++ b/康康/RootView.swift @@ -77,18 +77,18 @@ struct RootView: View { .fullScreenCover(item: $activeFlow) { flow in switch flow { case .quick: - QuickCaptureFlow(onClose: { activeFlow = nil }) + UnifiedCaptureFlow(onClose: { activeFlow = nil }) case .archive: - ArchiveFlow(onClose: { activeFlow = nil }) + UnifiedCaptureFlow(onClose: { activeFlow = nil }) } } #else .sheet(item: $activeFlow) { flow in switch flow { case .quick: - QuickCaptureFlow(onClose: { activeFlow = nil }) + UnifiedCaptureFlow(onClose: { activeFlow = nil }) case .archive: - ArchiveFlow(onClose: { activeFlow = nil }) + UnifiedCaptureFlow(onClose: { activeFlow = nil }) } } #endif diff --git a/康康/Services/CaptureService.swift b/康康/Services/CaptureService.swift new file mode 100644 index 0000000..7342896 --- /dev/null +++ b/康康/Services/CaptureService.swift @@ -0,0 +1,218 @@ +import Foundation +import UIKit + +/// VL 解析结果(已结构化,可直接喂 SwiftData 模型构造)。 +/// 与 Indicator/Report 字段近似但解耦 —— 这样 prompt schema 调整不污染数据层。 +struct ParsedReport: Sendable { + var title: String + var typeRaw: String + var reportDate: Date + var institution: String + var summary: String + var pageCount: Int + var indicators: [ParsedIndicator] + + struct ParsedIndicator: Sendable { + var name: String + var value: String + var unit: String + var range: String + var status: IndicatorStatus + } + + /// 一项都没识别出来 = 视作失败,UI 走手动录入回退。 + var isEmpty: Bool { indicators.isEmpty } + + /// 占位空结果,失败回退时给 UI。 + static func empty(date: Date = .now) -> ParsedReport { + ParsedReport( + title: "", + typeRaw: ReportType.other.rawValue, + reportDate: date, + institution: "", + summary: "", + pageCount: 1, + indicators: [] + ) + } +} + +/// CaptureService 错误 — UI 决定怎么呈现(回退表单 vs 重试)。 +enum CaptureError: Error, LocalizedError { + case modelNotReady + case writeAssetFailed + case inferenceFailed(String) + case parseFailed(String) + + var errorDescription: String? { + switch self { + case .modelNotReady: return "VL 模型尚未就绪" + case .writeAssetFailed: return "图片保存失败" + case .inferenceFailed(let m): return "识别失败:\(m)" + case .parseFailed(let m): return "结构化失败:\(m)" + } + } +} + +/// `CaptureService` 是 actor 是因为它的方法会等 AIRuntime(也是 actor), +/// 但本身不持任何可变状态 —— 单例 stateless,纯粹是 §3.1 模块边界的"门面"。 +actor CaptureService { + static let shared = CaptureService() + private init() {} + + /// 写图 + VL 推理 + 解析 → ParsedReport。 + /// 任何阶段失败,都抛 CaptureError;UI 接住后切到「手动录入」表单。 + /// - Returns: (ParsedReport, [FileVault.SavedAsset]) 元组, + /// SavedAsset 列表用于后续构造 Asset @Model。 + func analyze(images: [UIImage]) async throws + -> (parsed: ParsedReport, assets: [FileVault.SavedAsset]) { + + // 1. 写图到 Vault(全程加密目录) + let assets: [FileVault.SavedAsset] + do { + assets = try images.map { try FileVault.shared.writeJPEG($0) } + } catch { + throw CaptureError.writeAssetFailed + } + + // 2. VL 推理 + try await AIRuntime.shared.prepareVL() + let urls = assets.map { FileVault.shared.rootURL.appendingPathComponent($0.relativePath) } + let raw: String + do { + raw = try await AIRuntime.shared.analyzeReport( + imageURLs: urls, + prompt: VLPrompts.reportExtraction + ) + } catch { + throw CaptureError.inferenceFailed("\(error)") + } + + // 3. JSON 解析(带容错:可能包含围栏 / 前后文字) + do { + let parsed = try CaptureService.parseReportJSON(raw, pageCount: assets.count) + return (parsed, assets) + } catch let CaptureError.parseFailed(msg) { + throw CaptureError.parseFailed(msg) + } catch { + throw CaptureError.parseFailed("\(error)") + } + } + + // MARK: - JSON parse(static + 纯函数 → 方便单测) + + /// 从 VL 输出里抠出第一段合法 JSON 对象并解析。 + /// 容错: + /// - 去掉 ```json``` markdown 围栏 + /// - 去掉首尾非 JSON 文字 + /// - 缺字段填默认值 + /// 解析不到任何 indicator 也算成功,但 ParsedReport.isEmpty = true, + /// UI 走「手动录入」分支。 + static func parseReportJSON(_ raw: String, pageCount: Int = 1) throws -> ParsedReport { + let jsonString = extractJSONObject(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)") + } + guard let dict = obj as? [String: Any] else { + throw CaptureError.parseFailed("根节点不是对象") + } + + let title = (dict["title"] as? String)?.trimmingCharacters(in: .whitespaces) ?? "" + let typeRaw = parseReportType(dict["type"] as? String) + let reportDate = parseDate(dict["report_date"] as? String) ?? .now + let institution = (dict["institution"] as? String) ?? "" + let summary = (dict["summary"] as? String) ?? "" + let pages = (dict["page_count"] as? Int) ?? pageCount + + let indicatorsRaw = (dict["indicators"] as? [[String: Any]]) ?? [] + let indicators: [ParsedReport.ParsedIndicator] = indicatorsRaw.compactMap { + parseIndicator($0) + } + + return ParsedReport( + title: title.isEmpty ? "拍摄识别" : title, + typeRaw: typeRaw, + reportDate: reportDate, + institution: institution, + summary: summary, + pageCount: max(pages, pageCount), + indicators: indicators + ) + } + + /// 从字符串里抠出第一段平衡的 {...}。处理 markdown 围栏、前后乱码。 + /// 失败返回原字符串(后续 JSONSerialization 报错)。 + static func extractJSONObject(from raw: String) -> String { + var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) + + // 去 markdown 围栏 + if s.hasPrefix("```") { + // 砍掉首行 ```json 或 ``` + if let firstNewline = s.firstIndex(of: "\n") { + s = String(s[s.index(after: firstNewline)...]) + } + // 砍掉末尾 ``` + if let endRange = s.range(of: "```", options: .backwards) { + s = String(s[.. String { + guard let raw = raw?.lowercased() else { return ReportType.other.rawValue } + return ReportType(rawValue: raw)?.rawValue ?? ReportType.other.rawValue + } + + private static func parseDate(_ raw: String?) -> Date? { + guard let s = raw?.trimmingCharacters(in: .whitespaces), !s.isEmpty else { return nil } + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy-MM-dd" + return f.date(from: s) + } + + private static func parseIndicator(_ d: [String: Any]) -> ParsedReport.ParsedIndicator? { + guard let name = (d["name"] as? String)?.trimmingCharacters(in: .whitespaces), + !name.isEmpty else { return nil } + let value: String + if let v = d["value"] as? String { value = v } + else if let v = d["value"] as? NSNumber { value = v.stringValue } + else { value = "" } + let unit = (d["unit"] as? String) ?? "" + let range = (d["range"] as? String) ?? "" + let statusRaw = (d["status"] as? String)?.lowercased() ?? "normal" + let status = IndicatorStatus(rawValue: statusRaw) ?? .normal + return .init(name: name, value: value, unit: unit, range: range, status: status) + } +} diff --git a/康康/Services/ReminderService.swift b/康康/Services/ReminderService.swift new file mode 100644 index 0000000..e45264c --- /dev/null +++ b/康康/Services/ReminderService.swift @@ -0,0 +1,94 @@ +import Foundation +import UserNotifications + +/// 周期性指标提醒的本地通知调度。 +/// 同一 `metricId` 在 iOS 通知中心展开成 N 条 weekly-repeats 通知,id 形如 +/// `kangkang.reminder..w`,方便按 weekday 单独 cancel。 +/// +/// 数据存 SwiftData `MetricReminder`;本服务只负责系统通知中心的同步, +/// 不写 SwiftData。两边写入的协调由调用方负责。 +enum ReminderService { + + static let idPrefix = "kangkang.reminder." + + enum AuthState: String { + case granted, denied, notDetermined, provisional + } + + // MARK: - authorization + + static func currentAuthState() async -> AuthState { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .authorized: return .granted + case .denied: return .denied + case .provisional: return .provisional + case .ephemeral: return .granted + case .notDetermined: return .notDetermined + @unknown default: return .notDetermined + } + } + + /// 申请通知权限。已 granted/denied 时直接返回当前状态。 + @discardableResult + static func requestAuthorization() async -> AuthState { + let center = UNUserNotificationCenter.current() + let settings = await center.notificationSettings() + if settings.authorizationStatus != .notDetermined { + return await currentAuthState() + } + do { + let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge]) + return granted ? .granted : .denied + } catch { + return .denied + } + } + + // MARK: - upsert / cancel + + /// 取消该 metric 在通知中心所有 pending 通知,再按当前 enabled/时间/weekdays 重排。 + /// 调用方在 `MetricReminder` save 之后调用。 + static func sync(_ reminder: MetricReminder) async { + cancel(metricId: reminder.metricId) + guard reminder.enabled, !reminder.weekdays.isEmpty else { return } + + let center = UNUserNotificationCenter.current() + let content = UNMutableNotificationContent() + content.title = "该测\(reminder.displayName)了" + content.body = "在「+ 新建 → 指标记录 → \(reminder.displayName)」记录一次" + content.sound = .default + content.threadIdentifier = "kangkang.reminder.\(reminder.metricId)" + + for weekday in reminder.weekdays { + var comps = DateComponents() + comps.hour = reminder.hour + comps.minute = reminder.minute + comps.weekday = weekday + let trigger = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true) + let id = identifier(metricId: reminder.metricId, weekday: weekday) + let request = UNNotificationRequest(identifier: id, + content: content, + trigger: trigger) + try? await center.add(request) + } + } + + /// 取消某个 metric 的所有 pending 通知(7 个 weekday 一并取消,不漏)。 + static func cancel(metricId: String) { + let center = UNUserNotificationCenter.current() + let ids = (1...7).map { identifier(metricId: metricId, weekday: $0) } + center.removePendingNotificationRequests(withIdentifiers: ids) + } + + /// 全清。Me Tab 一键关闭所有提醒时用。 + static func cancelAll() { + UNUserNotificationCenter.current().removeAllPendingNotificationRequests() + } + + // MARK: - helpers + + private static func identifier(metricId: String, weekday: Int) -> String { + "\(idPrefix)\(metricId).w\(weekday)" + } +} diff --git a/康康Tests/CaptureServiceJSONTests.swift b/康康Tests/CaptureServiceJSONTests.swift new file mode 100644 index 0000000..c750a60 --- /dev/null +++ b/康康Tests/CaptureServiceJSONTests.swift @@ -0,0 +1,112 @@ +import Testing +import Foundation +@testable import 康康 + +struct CaptureServiceJSONTests { + + @Test func parsesCleanJSON() throws { + let raw = """ + {"title":"春检","type":"checkup","report_date":"2026-04-12","institution":"协和","page_count":2,"summary":"血脂偏高","indicators":[{"name":"LDL-C","value":"3.84","unit":"mmol/L","range":"< 3.40","status":"high"}]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.title == "春检") + #expect(parsed.typeRaw == ReportType.checkup.rawValue) + #expect(parsed.institution == "协和") + #expect(parsed.pageCount == 2) + #expect(parsed.indicators.count == 1) + #expect(parsed.indicators.first?.status == .high) + } + + @Test func stripsMarkdownCodeFence() throws { + let raw = """ + ```json + {"title":"x","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[]} + ``` + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.title == "x") + #expect(parsed.typeRaw == ReportType.lab.rawValue) + #expect(parsed.indicators.isEmpty) + } + + @Test func extractsObjectAfterLeadingText() throws { + let raw = """ + 好的,识别结果如下: + {"title":"y","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[]} + 以上。 + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.title == "y") + } + + @Test func handlesNestedBraces() throws { + let raw = """ + {"title":"y","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"含嵌套{x}对象","indicators":[]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.summary == "含嵌套{x}对象") + } + + @Test func handlesEscapedQuotesInStrings() throws { + let raw = #"{"title":"y \"内嵌\" 引号","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[]}"# + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.title == #"y "内嵌" 引号"#) + } + + @Test func fillsDefaultsForMissingFields() throws { + // 缺 title / type / report_date / institution / summary / page_count + let raw = """ + {"indicators":[{"name":"X","value":"1","unit":"","range":"","status":"normal"}]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.title == "拍摄识别") // 默认值 + #expect(parsed.typeRaw == ReportType.other.rawValue) + #expect(parsed.indicators.count == 1) + } + + @Test func skipsIndicatorsWithEmptyName() throws { + let raw = """ + {"title":"t","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[ + {"name":"","value":"1","unit":"","range":"","status":"normal"}, + {"name":" ","value":"1","unit":"","range":"","status":"normal"}, + {"name":"OK","value":"1","unit":"","range":"","status":"normal"} + ]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.indicators.count == 1) + #expect(parsed.indicators.first?.name == "OK") + } + + @Test func malformedJSONThrows() { + let raw = "完全不是 JSON" + #expect(throws: CaptureError.self) { + _ = try CaptureService.parseReportJSON(raw) + } + } + + @Test func valueAsNumberStillParses() throws { + let raw = """ + {"title":"t","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[{"name":"X","value":3.84,"unit":"","range":"","status":"high"}]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.indicators.first?.value == "3.84") + } + + @Test func unknownStatusFallsBackToNormal() throws { + let raw = """ + {"title":"t","type":"lab","report_date":"2026-05-01","institution":"","page_count":1,"summary":"","indicators":[{"name":"X","value":"1","unit":"","range":"","status":"abnormal"}]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + #expect(parsed.indicators.first?.status == .normal) + } + + @Test func badReportDateFallsBackToNow() throws { + let raw = """ + {"title":"t","type":"lab","report_date":"昨天","institution":"","page_count":1,"summary":"","indicators":[]} + """ + let parsed = try CaptureService.parseReportJSON(raw) + let now = Date() + let diff = abs(parsed.reportDate.timeIntervalSince(now)) + #expect(diff < 5) // 5 秒内算 .now + } +} diff --git a/康康Tests/CustomMonitorMetricTests.swift b/康康Tests/CustomMonitorMetricTests.swift new file mode 100644 index 0000000..3c0f7db --- /dev/null +++ b/康康Tests/CustomMonitorMetricTests.swift @@ -0,0 +1,145 @@ +import Testing +import SwiftData +import Foundation +@testable import 康康 + +struct CustomMonitorMetricTests { + + private func makeContainer() throws -> ModelContainer { + let schema = Schema([ + CustomMonitorMetric.self, + Indicator.self, + ]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + @Test func seriesKeyAutoPrefixedWithCustom() { + let m = CustomMonitorMetric(name: "腰围", unit: "cm") + #expect(m.seriesKey.hasPrefix("custom.")) + #expect(m.seriesKey.count > "custom.".count) + } + + @Test func seriesKeyUniquePerInstance() { + let a = CustomMonitorMetric(name: "腰围", unit: "cm") + let b = CustomMonitorMetric(name: "腰围", unit: "cm") + #expect(a.seriesKey != b.seriesKey) + } + + @Test func referenceRangeNilWhenBoundsAbsentOrInverted() { + let none = CustomMonitorMetric(name: "x", unit: "") + #expect(none.referenceRange == nil) + + let inverted = CustomMonitorMetric(name: "x", unit: "", lowerBound: 100, upperBound: 50) + #expect(inverted.referenceRange == nil) + + let valid = CustomMonitorMetric(name: "x", unit: "", lowerBound: 60, upperBound: 100) + #expect(valid.referenceRange == 60...100) + } + + @Test func rangeTextFormattingDropsTrailingZero() { + let intRange = CustomMonitorMetric(name: "x", unit: "cm", + lowerBound: 70, upperBound: 90) + #expect(intRange.rangeText == "70 - 90") + + let decimalRange = CustomMonitorMetric(name: "y", unit: "kg", + lowerBound: 60.5, upperBound: 65.5) + #expect(decimalRange.rangeText == "60.5 - 65.5") + } + + @Test func roundtripsThroughSwiftData() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + let m = CustomMonitorMetric(name: "腰围", unit: "cm", + lowerBound: 70, upperBound: 90, + icon: "flame.fill") + ctx.insert(m) + try ctx.save() + + let fetched = try #require(try ctx.fetch(FetchDescriptor()).first) + #expect(fetched.name == "腰围") + #expect(fetched.unit == "cm") + #expect(fetched.lowerBound == 70) + #expect(fetched.upperBound == 90) + #expect(fetched.icon == "flame.fill") + #expect(fetched.seriesKey.hasPrefix("custom.")) + } + + @Test func seriesBucketResolvesCustomTitleAndRange() { + let custom = CustomMonitorMetric(name: "腰围", unit: "cm", + lowerBound: 70, upperBound: 90) + let key = custom.seriesKey + let now = Date() + let day = { (offset: Int) -> Date in + Calendar.current.date(byAdding: .day, value: offset, to: now)! + } + let items = [ + Indicator(name: "腰围", value: "80", unit: "cm", range: "70-90", + status: .normal, capturedAt: day(-2), seriesKey: key), + Indicator(name: "腰围", value: "82", unit: "cm", range: "70-90", + status: .normal, capturedAt: day(-1), seriesKey: key), + ] + let buckets = SeriesBucket.build(from: items, customMetrics: [custom]) + + #expect(buckets.count == 1) + let b = try! #require(buckets.first) + #expect(b.title == "腰围") + #expect(b.unit == "cm") + #expect(b.lines.first?.referenceRange == 70...90) + } + + @Test func nameConflictEmptyNameYieldsNone() { + let result = detectNameConflict(candidate: " ", customs: []) + #expect(result == .none) + } + + @Test func nameConflictDetectsBuiltinMatch() { + let result = detectNameConflict(candidate: "血压", customs: []) + #expect(result == .builtin("血压")) + } + + @Test func nameConflictBuiltinIgnoresWhitespace() { + let result = detectNameConflict(candidate: " 空腹血糖 ", customs: []) + #expect(result == .builtin("空腹血糖")) + } + + @Test func nameConflictDetectsExistingCustom() { + let existing = CustomMonitorMetric(name: "腰围", unit: "cm") + let result = detectNameConflict(candidate: "腰围", customs: [existing]) + #expect(result == .existingCustom("腰围")) + } + + @Test func nameConflictAllowsRenamingSelf() { + // 编辑自己时,即使没改名也不应该报冲突 + let me = CustomMonitorMetric(name: "腰围", unit: "cm") + let result = detectNameConflict( + candidate: "腰围", + customs: [me], + excludingSeriesKey: me.seriesKey + ) + #expect(result == .none) + } + + @Test func nameConflictUnique() { + let result = detectNameConflict(candidate: "步数", customs: []) + #expect(result == .none) + } + + @Test func seriesBucketFallsBackToIndicatorNameWhenCustomMissing() { + // 用户删了 CustomMonitorMetric 但 Indicator 还在 → title fallback 到 indicator.name + let orphanKey = "custom.deleted-xxxxx" + let now = Date() + let items = [ + Indicator(name: "睡眠时长", value: "7", unit: "h", range: "", + status: .normal, capturedAt: now, seriesKey: orphanKey), + Indicator(name: "睡眠时长", value: "8", unit: "h", range: "", + status: .normal, capturedAt: now.addingTimeInterval(60), + seriesKey: orphanKey), + ] + let buckets = SeriesBucket.build(from: items, customMetrics: []) + let b = try! #require(buckets.first) + #expect(b.title == "睡眠时长") + #expect(b.unit == "h") + #expect(b.lines.first?.referenceRange == nil) + } +} diff --git a/康康Tests/MetricReminderTests.swift b/康康Tests/MetricReminderTests.swift new file mode 100644 index 0000000..67c9b68 --- /dev/null +++ b/康康Tests/MetricReminderTests.swift @@ -0,0 +1,79 @@ +import Testing +import SwiftData +import Foundation +@testable import 康康 + +struct MetricReminderTests { + + private func makeContainer() throws -> ModelContainer { + let schema = Schema([MetricReminder.self]) + let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [config]) + } + + @Test func defaultsToEveryDayAt8AM() { + let r = MetricReminder(metricId: "bloodPressure", displayName: "血压") + #expect(r.hour == 8) + #expect(r.minute == 0) + #expect(r.weekdays == [1, 2, 3, 4, 5, 6, 7]) + #expect(r.enabled == true) + #expect(r.isEveryDay) + #expect(r.frequencyLabel == "每天") + } + + @Test func hourMinuteClampedToValidRange() { + let early = MetricReminder(metricId: "x", displayName: "x", hour: -5, minute: 80) + #expect(early.hour == 0) + #expect(early.minute == 59) + let late = MetricReminder(metricId: "y", displayName: "y", hour: 25, minute: -3) + #expect(late.hour == 23) + #expect(late.minute == 0) + } + + @Test func weekdaysRoundtripThroughSwiftData() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + let r = MetricReminder( + metricId: "bloodPressure", + displayName: "血压", + hour: 7, minute: 30, + weekdays: [2, 4, 6] + ) + ctx.insert(r) + try ctx.save() + + let fetched = try #require(try ctx.fetch(FetchDescriptor()).first) + #expect(fetched.weekdays == [2, 4, 6]) + #expect(fetched.isEveryDay == false) + #expect(fetched.frequencyLabel == "每周 一三五") + #expect(fetched.timeLabel == "07:30") + } + + @Test func disabledFrequencyLabel() { + let r = MetricReminder(metricId: "x", displayName: "x", enabled: false) + #expect(r.frequencyLabel == "已关闭") + } + + @Test func emptyWeekdaysNotEveryDay() { + let r = MetricReminder(metricId: "x", displayName: "x", weekdays: []) + #expect(!r.isEveryDay) + #expect(r.frequencyLabel == "未选日") + } + + @Test func metricIdUniquenessEnforced() throws { + let container = try makeContainer() + let ctx = ModelContext(container) + let r1 = MetricReminder(metricId: "bp", displayName: "血压") + ctx.insert(r1) + try ctx.save() + + let r2 = MetricReminder(metricId: "bp", displayName: "血压重复") + ctx.insert(r2) + try ctx.save() + + // SwiftData @Attribute(.unique) 在冲突时合并/拒绝(具体行为版本依赖); + // 至少 fetch 总数 ≤ 1。 + let all = try ctx.fetch(FetchDescriptor()) + #expect(all.count == 1) + } +} diff --git a/康康Tests/ModelsSchemaTests.swift b/康康Tests/ModelsSchemaTests.swift index 042a552..1f88352 100644 --- a/康康Tests/ModelsSchemaTests.swift +++ b/康康Tests/ModelsSchemaTests.swift @@ -14,6 +14,8 @@ struct ModelsSchemaTests { ChatTurn.self, Symptom.self, UserProfile.self, + MetricReminder.self, + CustomMonitorMetric.self, ]) let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) return try ModelContainer(for: schema, configurations: [config]) diff --git a/康康Tests/SeriesBucketTests.swift b/康康Tests/SeriesBucketTests.swift new file mode 100644 index 0000000..83d38d6 --- /dev/null +++ b/康康Tests/SeriesBucketTests.swift @@ -0,0 +1,107 @@ +import Testing +import Foundation +@testable import 康康 + +struct SeriesBucketTests { + + private func makeIndicator( + name: String = "测试", + value: String, + unit: String = "mmol/L", + range: String = "", + status: IndicatorStatus = .normal, + capturedAt: Date, + seriesKey: String? + ) -> Indicator { + Indicator(name: name, value: value, unit: unit, range: range, + status: status, capturedAt: capturedAt, + seriesKey: seriesKey) + } + + @Test func skipsIndicatorsWithoutSeriesKey() { + let now = Date() + let items = [ + makeIndicator(value: "5.0", capturedAt: now, seriesKey: nil), + makeIndicator(value: "5.2", capturedAt: now, seriesKey: nil), + ] + let buckets = SeriesBucket.build(from: items) + #expect(buckets.isEmpty) + } + + @Test func filtersOutSeriesWithFewerThanMinPoints() { + let now = Date() + let items = [ + makeIndicator(value: "5.0", capturedAt: now, seriesKey: "glucose.fasting"), + ] + let buckets = SeriesBucket.build(from: items, minPoints: 2) + #expect(buckets.isEmpty) + } + + @Test func singleSeriesBucketSortedAscending() { + let day = { (offset: Int) -> Date in + Calendar.current.date(byAdding: .day, value: offset, to: .now)! + } + let items = [ + makeIndicator(value: "5.5", capturedAt: day(-3), seriesKey: "glucose.fasting"), + makeIndicator(value: "5.2", capturedAt: day(-1), seriesKey: "glucose.fasting"), + makeIndicator(value: "5.8", capturedAt: day(-2), seriesKey: "glucose.fasting"), + ] + let buckets = SeriesBucket.build(from: items) + #expect(buckets.count == 1) + let line = try! #require(buckets.first?.lines.first) + // sorted ascending → -3, -2, -1 + let values = line.points.map(\.value) + #expect(values == [5.5, 5.8, 5.2]) + } + + @Test func bloodPressureMergesIntoSingleBucket() { + let now = Date() + let day = { (offset: Int) -> Date in + Calendar.current.date(byAdding: .day, value: offset, to: now)! + } + let items = [ + makeIndicator(value: "125", capturedAt: day(-2), seriesKey: "bp.systolic"), + makeIndicator(value: "82", capturedAt: day(-2), seriesKey: "bp.diastolic"), + makeIndicator(value: "130", capturedAt: day(-1), seriesKey: "bp.systolic"), + makeIndicator(value: "85", capturedAt: day(-1), seriesKey: "bp.diastolic"), + ] + let buckets = SeriesBucket.build(from: items) + let bp = try! #require(buckets.first { $0.id == "bp" }) + #expect(bp.lines.count == 2) + #expect(bp.title == "血压") + #expect(bp.lines.contains { $0.seriesKey == "bp.systolic" }) + #expect(bp.lines.contains { $0.seriesKey == "bp.diastolic" }) + } + + @Test func mixedSeriesProducesMultipleBucketsSortedByRecency() { + let cal = Calendar.current + let day = { (offset: Int) -> Date in + cal.date(byAdding: .day, value: offset, to: .now)! + } + let items = [ + // weight 较旧 + makeIndicator(value: "68", capturedAt: day(-10), seriesKey: "weight"), + makeIndicator(value: "67", capturedAt: day(-7), seriesKey: "weight"), + // glucose 较新 + makeIndicator(value: "5.1", capturedAt: day(-2), seriesKey: "glucose.fasting"), + makeIndicator(value: "5.3", capturedAt: day(-1), seriesKey: "glucose.fasting"), + ] + let buckets = SeriesBucket.build(from: items) + #expect(buckets.count == 2) + // 最新的 glucose 排前面 + #expect(buckets.first?.id == "glucose.fasting") + #expect(buckets.last?.id == "weight") + } + + @Test func nonNumericValueDropped() { + let now = Date() + let items = [ + makeIndicator(value: "高", capturedAt: now, seriesKey: "weight"), + makeIndicator(value: "68", capturedAt: now, seriesKey: "weight"), + makeIndicator(value: "67", capturedAt: now.addingTimeInterval(60), seriesKey: "weight"), + ] + let buckets = SeriesBucket.build(from: items) + let line = try! #require(buckets.first?.lines.first) + #expect(line.points.count == 2) // "高" 被丢 + } +}