From 37b47b2076cffa7941ece5bd388882258f4a23f0 Mon Sep 17 00:00:00 2001 From: link2026 Date: Tue, 26 May 2026 07:53:16 +0800 Subject: [PATCH] =?UTF-8?q?docs(claude):=20sync=20=C2=A75/=C2=A77/=C2=A710?= =?UTF-8?q?=20with=20Monitor+Profile;=20fix=20SeriesBucket=20SwiftData=20i?= =?UTF-8?q?mport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - §5 schema 重写为 7 @Model 完整列表(含 UserProfile + Indicator.seriesKey) - §7 IA 改成 5 槽 TabBar(2 内容 + 中间 + + 2 设置),记录入口 5 个 kind - §10.6 红线例外清单加 Monitor + Profile(Symptom 也补上) - SeriesBucket.swift 缺 import SwiftData(persistentModelID 报错) 全套测试 50 case pass / 0 fail / 0 warning。 --- CLAUDE.md | 67 ++- 康康/Features/Record/RecordSheet.swift | 40 +- 康康/Features/Timeline/DateSection.swift | 7 +- 康康/Features/Trends/CalendarMarkers.swift | 108 +++++ 康康/Features/Trends/CalendarMonthGrid.swift | 191 +++++++++ 康康/Features/Trends/CalendarYearGrid.swift | 114 ++++++ 康康/Features/Trends/DayDetailSheet.swift | 406 +++++++++++++++++++ 康康/Features/Trends/SeriesBucket.swift | 153 +++++++ 康康/Features/Trends/TrendsView.swift | 250 +++++++++++- 康康/RootView.swift | 13 +- 10 files changed, 1275 insertions(+), 74 deletions(-) create mode 100644 康康/Features/Trends/CalendarMarkers.swift create mode 100644 康康/Features/Trends/CalendarMonthGrid.swift create mode 100644 康康/Features/Trends/CalendarYearGrid.swift create mode 100644 康康/Features/Trends/DayDetailSheet.swift create mode 100644 康康/Features/Trends/SeriesBucket.swift diff --git a/CLAUDE.md b/CLAUDE.md index d6cbd4a..ffd2b73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,37 +92,27 @@ VL prompt 必须: ## 5. 数据模型(SwiftData) -现有 3 个 `@Model`,要新增 2 个: +**当前 schema(2026-05-26)**:7 个 @Model。 ```swift -// 已有(在 Models/Models.swift) -@Model class Indicator { name, value, unit, range, statusRaw, note, capturedAt } -@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt } -@Model class DiaryEntry { content, createdAt } - -// 待加字段 -// Indicator + report: Report? 反向关系 -// Indicator + asset: Asset? 关联原图 -// Indicator + pinned: Bool C2 "关联到趋势" 后置 true,Trends 默认展示 pinned 指标 -// Report + indicators: [Indicator] @Relationship cascade -// Report + assets: [Asset] @Relationship cascade -// DiaryEntry + tags: [String] VL/LLM 抽取的标签 - -// 待加 @Model -@Model class Asset { - var relativePath: String // 相对 Vault/ 的路径 - var mimeType: String - var bytes: Int - var createdAt: Date +@Model class Indicator { + name, value, unit, range, statusRaw, note, capturedAt, + report: Report?, asset: Asset?, + pinned: Bool, // 长期监测自动 true,Trends 默认展示 + seriesKey: String? // "bp.systolic" / "glucose.fasting" / ... 长期指标分组 key } +@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt, + indicators: [Indicator] cascade, + assets: [Asset] cascade } +@Model class DiaryEntry { content, createdAt, tags: [String] } +@Model class Symptom { name, startedAt, endedAt?, note?, severity 1-5, tags, createdAt } +@Model class Asset { relativePath, mimeType, bytes, createdAt } +@Model class ChatTurn { question, answer, referencedIndicatorIDs, referencedReportIDs, createdAt, decodeRate } -@Model class ChatTurn { - var question: String - var answer: String - var referencedIndicatorIDs: [String] - var referencedReportIDs: [String] - var createdAt: Date - var decodeRate: Double // 该轮问答推理速度,Me 页性能展示 +@Model class UserProfile { // 全 App 单例(UserProfileStore.loadOrCreate) + birthYear?, biologicalSexRaw, heightCM?, bloodTypeRaw, + allergies, chronicConditions, familyHistory, currentMedications, + updatedAt } ``` @@ -149,18 +139,21 @@ VL prompt 必须: ## 7. 信息架构 ``` -TabBar: [首页] [+ 记录] [趋势] [我的] - │ │ │ │ - │ │ │ └─ 模型管理 / Face ID / 关于 - │ │ └─ 折线图 + AI 一句话解读 - │ └─ Modal: 选择 拍一张 / 写日记 / 问问看 - └─ 问候 + 今日摘要 + 时间线 + 影像档案入口 +TabBar: [主页] [记录] [+ 新建] [趋势] [我的] + │ │ │ │ │ + │ │ │ │ └─ 个人资料 / 模型管理 / Face ID / 关于 + │ │ │ └─ 折线图 + AI 一句话解读 + │ │ └─ Sheet: 拍一张 / 指标记录 / 报告归档 / 写日记 / 症状 + │ └─ ArchiveListView(时间线 + 分类 chip + 年/月分组) + └─ 问候 + 今日摘要 + 进行中症状 + 最近时间线 ``` -- **3 Tab 不变**,中间 + 号是 Sheet +- TabBar **5 槽**:左 2 个内容 Tab + 中间 + 号 + 右 2 个 Tab +- "+ 新建" 是 sheet 不是 Tab - AI 问答以 Modal Sheet 形式出现,**不占 Tab** -- "问问看"入口除了在 RecordSheet 里,首页摘要卡片下方也有一个常驻入口 -- 历史时间线在首页下半部分,不单独开 Tab +- 「指标记录」sheet 顶部 LazyVGrid 是 8 个 MonitorMetric 长期监测预设(进趋势), + 下方 horizontal scroll 是化验项快捷预设(不进趋势),不选预设走自由输入 +- 「我的 · 个人资料」是 NavigationLink push 的 Form 编辑页 ### 7.1 档案库 C1 / C2 导航(看的一半) @@ -256,7 +249,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算 3. **UI 不直接调 AIRuntime**——必须经过 Service 4. **AIRuntime 必须 actor 化**——禁止 class + lock 5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏 -6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**例外**:报告对比(16.1)已加回,见 §7.2 +6. **新功能必须问"清单里有吗"**——清单外的功能(用药提醒、多 profile、暗黑模式、iCloud 同步……)默认不做,要做必须先讨论。**已加回的例外**:报告对比(16.1,§7.2)、症状追踪(Symptom @Model)、长期监测指标(MonitorMetric / IndicatorQuickSheet,W2)、个人资料(UserProfile,W2) 7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来 8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架 diff --git a/康康/Features/Record/RecordSheet.swift b/康康/Features/Record/RecordSheet.swift index f48013d..7c24bc9 100644 --- a/康康/Features/Record/RecordSheet.swift +++ b/康康/Features/Record/RecordSheet.swift @@ -1,39 +1,43 @@ import SwiftUI enum RecordKind: String, Identifiable, CaseIterable { - case quick, archive, diary, symptom + case quick, indicator, archive, diary, symptom var id: String { rawValue } var title: String { switch self { - case .quick: return "异常项快拍" - case .archive: return "关键报告归档" - case .diary: return "文字日记" - case .symptom: return "症状开始" + case .quick: return "异常项快拍" + case .indicator: return "指标记录" + case .archive: return "关键报告归档" + case .diary: return "文字日记" + case .symptom: return "症状开始" } } var subtitle: String { switch self { - case .quick: return "只记录单个或几项异常指标" - case .archive: return "完整保存整份报告(可多页)" - case .diary: return "记录心情、用药、其他" - case .symptom: return "开始一个持续症状,结束时再点结束" + case .quick: return "拍一张化验单,VL 自动识别" + case .indicator: return "手动填一项指标(免拍照)" + case .archive: return "完整保存整份报告(可多页)" + case .diary: return "记录心情、用药、其他" + case .symptom: return "开始一个持续症状,结束时再点结束" } } var icon: String { switch self { - case .quick: return "camera.fill" - case .archive: return "doc.fill" - case .diary: return "pencil" - case .symptom: return "waveform.path.ecg" + case .quick: return "camera.fill" + case .indicator: return "number.square.fill" + case .archive: return "doc.fill" + case .diary: return "pencil" + case .symptom: return "waveform.path.ecg" } } var accent: Color { switch self { - case .quick: return Tj.Palette.brick - case .archive: return Tj.Palette.ink - case .diary: return Tj.Palette.leaf - case .symptom: return Tj.Palette.amber + case .quick: return Tj.Palette.brick + case .indicator: return Tj.Palette.brick + case .archive: return Tj.Palette.ink + case .diary: return Tj.Palette.leaf + case .symptom: return Tj.Palette.amber } } } @@ -102,7 +106,7 @@ struct RecordSheet: View { .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) .ignoresSafeArea(edges: .bottom) ) - .presentationDetents([.fraction(0.68)]) + .presentationDetents([.fraction(0.8)]) .presentationDragIndicator(.hidden) .presentationBackground(Tj.Palette.sand) .presentationCornerRadius(Tj.Radius.xl) diff --git a/康康/Features/Timeline/DateSection.swift b/康康/Features/Timeline/DateSection.swift index 7552b9a..33a4514 100644 --- a/康康/Features/Timeline/DateSection.swift +++ b/康康/Features/Timeline/DateSection.swift @@ -32,8 +32,11 @@ enum TimelineGrouping { static func section(for date: Date, now: Date = .now, calendar: Calendar = .current) -> DateSection { - if calendar.isDateInToday(date) { return .today } - if calendar.isDateInYesterday(date) { return .yesterday } + if calendar.isDate(date, inSameDayAs: now) { return .today } + if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), + calendar.isDate(date, inSameDayAs: yesterday) { + return .yesterday + } if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) { return .thisWeek } diff --git a/康康/Features/Trends/CalendarMarkers.swift b/康康/Features/Trends/CalendarMarkers.swift new file mode 100644 index 0000000..6ae71d3 --- /dev/null +++ b/康康/Features/Trends/CalendarMarkers.swift @@ -0,0 +1,108 @@ +import SwiftUI +import SwiftData +import Foundation + +struct SymptomRange: Identifiable, Hashable { + let id: String + let name: String + let startDay: Date + let endDay: Date + let severity: Int + let isOngoing: Bool + + var color: Color { + switch severity { + case 1, 2: return Tj.Palette.leaf + case 3: return Tj.Palette.amber + default: return Tj.Palette.brick + } + } + + func contains(_ day: Date, calendar: Calendar = .current) -> Bool { + let d = calendar.startOfDay(for: day) + return d >= startDay && d <= endDay + } + + func position(_ day: Date, calendar: Calendar = .current) -> Position { + let d = calendar.startOfDay(for: day) + let isStart = d == startDay + let isEnd = d == endDay + if isStart && isEnd { return .single } + if isStart { return .start } + if isEnd { return .end } + return .middle + } + + enum Position { case single, start, middle, end } +} + +struct DayMarks: Hashable { + var abnormalCount: Int = 0 + var normalCount: Int = 0 + var reportCount: Int = 0 + var diaryCount: Int = 0 + + var hasAnyEvent: Bool { + abnormalCount + normalCount + reportCount + diaryCount > 0 + } +} + +struct CalendarData { + let dayMarks: [Date: DayMarks] + let symptomRanges: [SymptomRange] + + func marks(for day: Date, calendar: Calendar = .current) -> DayMarks { + dayMarks[calendar.startOfDay(for: day)] ?? DayMarks() + } + + func ranges(touching day: Date, calendar: Calendar = .current) -> [SymptomRange] { + symptomRanges.filter { $0.contains(day, calendar: calendar) } + } + + static func build(indicators: [Indicator], + reports: [Report], + diaries: [DiaryEntry], + symptoms: [Symptom], + now: Date = .now, + calendar: Calendar = .current) -> CalendarData { + var buckets: [Date: DayMarks] = [:] + + for i in indicators { + let day = calendar.startOfDay(for: i.capturedAt) + var m = buckets[day] ?? DayMarks() + if i.status == .normal { + m.normalCount += 1 + } else { + m.abnormalCount += 1 + } + buckets[day] = m + } + + for r in reports { + let day = calendar.startOfDay(for: r.reportDate) + var m = buckets[day] ?? DayMarks() + m.reportCount += 1 + buckets[day] = m + } + + for d in diaries { + let day = calendar.startOfDay(for: d.createdAt) + var m = buckets[day] ?? DayMarks() + m.diaryCount += 1 + buckets[day] = m + } + + let ranges: [SymptomRange] = symptoms.map { s in + SymptomRange( + id: "\(s.persistentModelID)", + name: s.name, + startDay: calendar.startOfDay(for: s.startedAt), + endDay: calendar.startOfDay(for: s.endedAt ?? now), + severity: s.severity, + isOngoing: s.isOngoing + ) + } + + return CalendarData(dayMarks: buckets, symptomRanges: ranges) + } +} diff --git a/康康/Features/Trends/CalendarMonthGrid.swift b/康康/Features/Trends/CalendarMonthGrid.swift new file mode 100644 index 0000000..71b9680 --- /dev/null +++ b/康康/Features/Trends/CalendarMonthGrid.swift @@ -0,0 +1,191 @@ +import SwiftUI + +struct CalendarMonthGrid: View { + let monthAnchor: Date + let data: CalendarData + let onTapDay: (Date) -> Void + + private let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.firstWeekday = 2 // 周一开始 + c.locale = Locale(identifier: "zh_CN") + return c + }() + + private let weekdayLabels = ["一", "二", "三", "四", "五", "六", "日"] + private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) + + private var days: [DayCell] { + guard let monthInterval = calendar.dateInterval(of: .month, for: monthAnchor) else { + return [] + } + let firstOfMonth = monthInterval.start + let weekdayIndex = (calendar.component(.weekday, from: firstOfMonth) - calendar.firstWeekday + 7) % 7 + let daysInMonth = calendar.range(of: .day, in: .month, for: firstOfMonth)?.count ?? 30 + + var cells: [DayCell] = [] + // leading padding (上月尾) + for offset in (0.. 2 { + Text("+\(ranges.count - 2)") + .font(.system(size: 7, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + } + } + + // 异常 / 普通点 + if marks.hasAnyEvent { + HStack(spacing: 2) { + if marks.abnormalCount > 0 { + Circle().fill(Tj.Palette.brick).frame(width: 4, height: 4) + } + if marks.reportCount > 0 { + Circle().fill(Tj.Palette.ink2).frame(width: 4, height: 4) + } + if marks.normalCount > 0 && marks.abnormalCount == 0 { + Circle().fill(Tj.Palette.leaf).frame(width: 4, height: 4) + } + if marks.diaryCount > 0 { + Circle().fill(Tj.Palette.text3.opacity(0.7)).frame(width: 4, height: 4) + } + } + } + + Spacer(minLength: 0) + } + } + .frame(height: 56) + .contentShape(Rectangle()) + } + + private var textColor: Color { + if !cell.inCurrentMonth { return Tj.Palette.text3.opacity(0.5) } + if isToday { return Tj.Palette.ink } + return Tj.Palette.text + } + + private func symptomBar(_ range: SymptomRange) -> some View { + let pos = range.position(cell.date, calendar: calendar) + let leadingRadius: CGFloat = (pos == .start || pos == .single) ? 3 : 0 + let trailingRadius: CGFloat = (pos == .end || pos == .single) ? 3 : 0 + return GeometryReader { geo in + UnevenRoundedRectangle( + topLeadingRadius: leadingRadius, + bottomLeadingRadius: leadingRadius, + bottomTrailingRadius: trailingRadius, + topTrailingRadius: trailingRadius, + style: .continuous + ) + .fill(range.color) + .frame( + width: barWidth(for: pos, in: geo.size.width), + height: 4 + ) + .frame(maxWidth: .infinity, + alignment: barAlignment(for: pos)) + } + .frame(height: 4) + } + + private func barWidth(for pos: SymptomRange.Position, in cellWidth: CGFloat) -> CGFloat { + switch pos { + case .single: return cellWidth - 8 + case .start, .end: return cellWidth - 2 + case .middle: return cellWidth + 4 // 越界让相邻天视觉连接 + } + } + + private func barAlignment(for pos: SymptomRange.Position) -> Alignment { + switch pos { + case .start: return .leading + case .end: return .trailing + case .single: return .center + case .middle: return .center + } + } +} diff --git a/康康/Features/Trends/CalendarYearGrid.swift b/康康/Features/Trends/CalendarYearGrid.swift new file mode 100644 index 0000000..a64ec6c --- /dev/null +++ b/康康/Features/Trends/CalendarYearGrid.swift @@ -0,0 +1,114 @@ +import SwiftUI + +struct CalendarYearGrid: View { + let year: Int + let data: CalendarData + let onTapMonth: (Date) -> Void + + private let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.firstWeekday = 2 + c.locale = Locale(identifier: "zh_CN") + return c + }() + + private var monthAnchors: [Date] { + (1...12).compactMap { m in + var comps = DateComponents() + comps.year = year; comps.month = m; comps.day = 1 + return calendar.date(from: comps) + } + } + + private let columns = Array(repeating: GridItem(.flexible(), spacing: 14), count: 3) + + var body: some View { + LazyVGrid(columns: columns, spacing: 18) { + ForEach(monthAnchors, id: \.self) { anchor in + Button { + onTapMonth(anchor) + } label: { + MiniMonth(anchor: anchor, data: data, calendar: calendar) + } + .buttonStyle(.plain) + } + } + } +} + +private struct MiniMonth: View { + let anchor: Date + let data: CalendarData + let calendar: Calendar + + private var monthLabel: String { + let f = DateFormatter() + f.locale = Locale(identifier: "zh_CN") + f.dateFormat = "M 月" + return f.string(from: anchor) + } + + private var days: [Date] { + guard let interval = calendar.dateInterval(of: .month, for: anchor) else { return [] } + let count = calendar.dateComponents([.day], from: interval.start, to: interval.end).day ?? 30 + return (0.. some View { + let marks = data.marks(for: date, calendar: calendar) + let ranges = data.ranges(touching: date, calendar: calendar) + let color: Color = { + if marks.abnormalCount > 0 { return Tj.Palette.brick } + if let topSeverity = ranges.map(\.severity).max() { + switch topSeverity { + case 1, 2: return Tj.Palette.leaf + case 3: return Tj.Palette.amber + default: return Tj.Palette.brick + } + } + if marks.hasAnyEvent { return Tj.Palette.text3.opacity(0.6) } + return Tj.Palette.lineSoft + }() + let isToday = calendar.isDateInToday(date) + return RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(color) + .frame(height: 8) + .overlay( + RoundedRectangle(cornerRadius: 2, style: .continuous) + .strokeBorder(Tj.Palette.ink, lineWidth: isToday ? 1 : 0) + ) + } +} diff --git a/康康/Features/Trends/DayDetailSheet.swift b/康康/Features/Trends/DayDetailSheet.swift new file mode 100644 index 0000000..8b403dd --- /dev/null +++ b/康康/Features/Trends/DayDetailSheet.swift @@ -0,0 +1,406 @@ +import SwiftUI +import SwiftData + +struct SelectedDay: Identifiable, Hashable { + let date: Date + var id: TimeInterval { date.timeIntervalSince1970 } +} + +struct DayDetailSheet: View { + @Environment(\.modelContext) private var ctx + @Environment(\.dismiss) private var dismiss + + let date: Date + let indicators: [Indicator] + let reports: [Report] + let diaries: [DiaryEntry] + let symptoms: [Symptom] + + @State private var endingSymptom: Symptom? + + private let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.locale = Locale(identifier: "zh_CN") + return c + }() + + // MARK: - 当日数据筛选 + + private var dayIndicators: [Indicator] { + indicators.filter { calendar.isDate($0.capturedAt, inSameDayAs: date) } + } + + private var dayReports: [Report] { + reports.filter { calendar.isDate($0.reportDate, inSameDayAs: date) } + } + + private var dayDiaries: [DiaryEntry] { + diaries.filter { calendar.isDate($0.createdAt, inSameDayAs: date) } + } + + private var daySymptoms: [(symptom: Symptom, state: SymptomDayState)] { + symptoms.compactMap { s in + let start = calendar.startOfDay(for: s.startedAt) + let end = calendar.startOfDay(for: s.endedAt ?? .now) + let target = calendar.startOfDay(for: date) + guard target >= start && target <= end else { return nil } + let state: SymptomDayState + if start == end && s.isOngoing { state = .startedToday } + else if target == start { state = .startedToday } + else if !s.isOngoing && target == end { state = .endedToday } + else { state = .ongoing } + return (s, state) + } + } + + private var totalCount: Int { + dayIndicators.count + dayReports.count + dayDiaries.count + daySymptoms.count + } + + // MARK: - body + + 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, 12) + + if totalCount == 0 { + emptyState + } else { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 18) { + if !daySymptoms.isEmpty { + section("症状", count: daySymptoms.count) { + VStack(spacing: 8) { + ForEach(daySymptoms, id: \.symptom.id) { item in + symptomRow(item.symptom, state: item.state) + } + } + } + } + + if !dayIndicators.isEmpty { + section("指标", count: dayIndicators.count) { + VStack(spacing: 8) { + ForEach(dayIndicators) { i in + indicatorRow(i) + } + } + } + } + + if !dayReports.isEmpty { + section("报告", count: dayReports.count) { + VStack(spacing: 8) { + ForEach(dayReports) { r in + reportRow(r) + } + } + } + } + + if !dayDiaries.isEmpty { + section("日记", count: dayDiaries.count) { + VStack(spacing: 8) { + ForEach(dayDiaries) { d in + diaryRow(d) + } + } + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 24) + } + } + } + .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) + .sheet(item: $endingSymptom) { sym in + SymptomEndSheet(symptom: sym) + } + } + + // MARK: - header + + private var header: some View { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 4) { + Text(dateLine) + .font(.system(size: 12, weight: .semibold)) + .tracking(0.5) + .foregroundStyle(Tj.Palette.text3) + Text(dayLabel) + .font(.tjTitle(28)) + .foregroundStyle(Tj.Palette.text) + } + Spacer() + if totalCount > 0 { + Text("\(totalCount) 条") + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + } + } + } + + private var dateLine: String { + let f = DateFormatter() + f.locale = Locale(identifier: "zh_CN") + f.dateFormat = "yyyy 年" + return f.string(from: date) + " · " + weekdayLabel + } + + private var dayLabel: String { + let f = DateFormatter() + f.locale = Locale(identifier: "zh_CN") + f.dateFormat = "M 月 d 日" + return f.string(from: date) + } + + private var weekdayLabel: String { + let f = DateFormatter() + f.locale = Locale(identifier: "zh_CN") + f.dateFormat = "EEEE" + return f.string(from: date) + } + + // MARK: - section + + private func section(_ title: String, + count: Int, + @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .tracking(0.3) + .foregroundStyle(Tj.Palette.text2) + Text("\(count)") + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + } + content() + } + } + + // MARK: - rows + + private func symptomRow(_ s: Symptom, state: SymptomDayState) -> some View { + HStack(spacing: 12) { + Capsule() + .fill(severityColor(s.severity)) + .frame(width: 4, height: 36) + + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + Text(s.name) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + stateBadge(state, isOngoing: s.isOngoing) + } + Text("\(state.subtitle) · 持续 \(formatDuration(s.duration))") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer(minLength: 6) + + if s.isOngoing { + Button { + endingSymptom = s + } label: { + Text("结束") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(Tj.Palette.sand2)) + } + .buttonStyle(.plain) + } + } + .padding(12) + .tjCard(bordered: true) + } + + private func stateBadge(_ state: SymptomDayState, isOngoing: Bool) -> some View { + Text(state.badge) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(state.badgeFg) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule().fill(state.badgeBg)) + } + + private func indicatorRow(_ i: Indicator) -> some View { + HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(indicatorAccent(i).opacity(0.12)) + Image(systemName: "drop.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(indicatorAccent(i)) + } + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(i.name) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Tj.Palette.text) + .lineLimit(1) + if !i.range.isEmpty { + Text("参考 \(i.range)") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + } + Spacer(minLength: 6) + + Text("\(i.value) \(i.unit)\(arrow(i))") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(i.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick) + .lineLimit(1) + .fixedSize() + } + .padding(12) + .tjCard(bordered: true) + } + + private func reportRow(_ r: Report) -> some View { + let abnormal = r.indicators.filter { $0.status != .normal }.count + return HStack(spacing: 12) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Tj.Palette.ink2.opacity(0.12)) + Image(systemName: "doc.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Tj.Palette.ink2) + } + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(r.title) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Tj.Palette.text) + .lineLimit(1) + Text("\(r.type.label) · 共 \(r.pageCount) 页") + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text3) + } + Spacer(minLength: 6) + if abnormal > 0 { + Text("\(abnormal) 项偏高") + .font(.system(size: 11, weight: .semibold, design: .monospaced)) + .foregroundStyle(Tj.Palette.brick) + } + } + .padding(12) + .tjCard(bordered: true) + } + + private func diaryRow(_ d: DiaryEntry) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(d.createdAt.formatted(date: .omitted, time: .shortened)) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + } + Text(d.content) + .font(.tjSerifBody(14)) + .foregroundStyle(Tj.Palette.text) + .lineSpacing(4) + .multilineTextAlignment(.leading) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .tjCard(bordered: true) + } + + // MARK: - empty + + private var emptyState: some View { + VStack(spacing: 12) { + Spacer(minLength: 16) + TjPlaceholder(label: "这一天还没有记录") + .frame(width: 220, height: 120) + Text("点底部 + 号可以补一条") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + Spacer() + } + .frame(maxWidth: .infinity) + } + + // MARK: - utils + + private func severityColor(_ value: Int) -> Color { + switch value { + case 1, 2: return Tj.Palette.leaf + case 3: return Tj.Palette.amber + default: return Tj.Palette.brick + } + } + + private func indicatorAccent(_ i: Indicator) -> Color { + i.status == .normal ? Tj.Palette.leaf : Tj.Palette.brick + } + + private func arrow(_ i: Indicator) -> String { + switch i.status { + case .high: return " ↑" + case .low: return " ↓" + case .normal: return "" + } + } +} + +enum SymptomDayState { + case startedToday, ongoing, endedToday + + var subtitle: String { + switch self { + case .startedToday: return "今天开始" + case .ongoing: return "进行中" + case .endedToday: return "今天结束" + } + } + + var badge: String { + switch self { + case .startedToday: return "开始" + case .ongoing: return "持续" + case .endedToday: return "结束" + } + } + + var badgeBg: Color { + switch self { + case .startedToday: return Tj.Palette.brickSoft + case .ongoing: return Tj.Palette.sand2 + case .endedToday: return Tj.Palette.leafSoft + } + } + + var badgeFg: Color { + switch self { + case .startedToday: return Tj.Palette.brick + case .ongoing: return Tj.Palette.text2 + case .endedToday: return Tj.Palette.leaf + } + } +} diff --git a/康康/Features/Trends/SeriesBucket.swift b/康康/Features/Trends/SeriesBucket.swift new file mode 100644 index 0000000..9c3d8c0 --- /dev/null +++ b/康康/Features/Trends/SeriesBucket.swift @@ -0,0 +1,153 @@ +import SwiftUI +import SwiftData +import Foundation + +/// 长期监测系列在 Trends 折线图里的展示桶。 +/// 单系列(血糖/体重/...)= 1 个 SeriesLine;血压特殊 = 收缩 + 舒张 2 条线同卡。 +struct SeriesBucket: Identifiable { + let id: String + let title: String + let unit: String + let lines: [SeriesLine] + let latestDate: Date + + struct SeriesLine: Identifiable { + let id: String + let seriesKey: String + let label: String? + let color: Color + let points: [Point] + let referenceRange: ClosedRange? + + var latestPoint: Point? { points.last } + } + + struct Point: Identifiable, Hashable { + let id: String + let date: Date + let value: Double + let status: IndicatorStatus + } +} + +extension SeriesBucket { + /// 把全表 Indicator(无 seriesKey 的会被跳过)折成 SeriesBucket 列表。 + /// 同 seriesKey 内按 capturedAt 升序;BP 两个 key 合并成一个 bucket; + /// `minPoints` 以下的系列不返回,默认 2(单点不画线)。 + static func build(from indicators: [Indicator], + profile: UserProfile? = nil, + minPoints: Int = 2) -> [SeriesBucket] { + var buckets: [String: [Indicator]] = [:] + for i in indicators { + guard let key = i.seriesKey, !key.isEmpty else { continue } + buckets[key, default: []].append(i) + } + + // 合并血压 + let bpKeys: Set = ["bp.systolic", "bp.diastolic"] + let bpIndicators = bpKeys.flatMap { buckets[$0] ?? [] } + let bpHasEnoughPoints = bpIndicators.filter { $0.seriesKey == "bp.systolic" }.count >= minPoints + + var results: [SeriesBucket] = [] + + if bpHasEnoughPoints { + results.append(buildBP(from: buckets, profile: profile)) + } + for k in bpKeys { buckets.removeValue(forKey: k) } + + for (key, items) in buckets { + guard items.count >= minPoints else { continue } + if let bucket = buildSingle(key: key, items: items, profile: profile) { + results.append(bucket) + } + } + + return results.sorted { $0.latestDate > $1.latestDate } + } + + private static func buildSingle(key: String, + items: [Indicator], + profile: UserProfile?) -> SeriesBucket? { + let sorted = items.sorted { $0.capturedAt < $1.capturedAt } + guard let latest = sorted.last else { return nil } + + 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 line = SeriesLine( + id: key, + seriesKey: key, + label: nil, + color: Tj.Palette.ink, + points: sorted.compactMap { point(from: $0) }, + referenceRange: range + ) + + return SeriesBucket( + id: key, + title: title, + unit: unit, + lines: [line], + latestDate: latest.capturedAt + ) + } + + private static func buildBP(from buckets: [String: [Indicator]], + profile: UserProfile?) -> SeriesBucket { + let m = MonitorMetric.bloodPressure + let sysField = m.fields[0] + let diaField = m.fields[1] + + let sysItems = (buckets["bp.systolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt } + let diaItems = (buckets["bp.diastolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt } + + let sysLine = SeriesLine( + id: "bp.systolic", + seriesKey: "bp.systolic", + label: "收缩", + color: Tj.Palette.brick, + points: sysItems.compactMap { point(from: $0) }, + referenceRange: m.effectiveRange(for: sysField, profile: profile) + ) + let diaLine = SeriesLine( + id: "bp.diastolic", + seriesKey: "bp.diastolic", + label: "舒张", + color: Tj.Palette.leaf, + points: diaItems.compactMap { point(from: $0) }, + referenceRange: m.effectiveRange(for: diaField, profile: profile) + ) + + let latest = max( + sysItems.last?.capturedAt ?? .distantPast, + diaItems.last?.capturedAt ?? .distantPast + ) + + return SeriesBucket( + id: "bp", + title: "血压", + unit: "mmHg", + lines: [sysLine, diaLine], + latestDate: latest + ) + } + + private static func point(from i: Indicator) -> Point? { + guard let v = Double(i.value.trimmingCharacters(in: .whitespaces)) else { return nil } + return Point( + id: "\(i.persistentModelID)", + date: i.capturedAt, + value: v, + status: i.status + ) + } + + private static func monitorMetric(for seriesKey: String) -> MonitorMetric? { + MonitorMetric.allCases.first { m in + m.fields.contains { $0.seriesKey == seriesKey } + } + } +} diff --git a/康康/Features/Trends/TrendsView.swift b/康康/Features/Trends/TrendsView.swift index 3819ee7..84dc3f0 100644 --- a/康康/Features/Trends/TrendsView.swift +++ b/康康/Features/Trends/TrendsView.swift @@ -1,19 +1,243 @@ import SwiftUI +import SwiftData -struct TrendsView: View { - var body: some View { - VStack(spacing: 12) { - Spacer() - TjPlaceholder(label: "trends · 折线图 + 影像档案入口\n(尚未实现)") - .frame(width: 280, height: 180) - Text("趋势") - .font(.tjH2()) - .foregroundStyle(Tj.Palette.text2) - Spacer() +enum CalendarMode: String, CaseIterable, Identifiable { + case month, year + var id: String { rawValue } + var label: String { + switch self { + case .month: return "月" + case .year: return "年" } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Tj.Palette.sand.ignoresSafeArea()) } } -#Preview { TrendsView() } +struct TrendsView: View { + @Query(sort: \Indicator.capturedAt, order: .reverse) + private var indicators: [Indicator] + + @Query(sort: \Report.reportDate, order: .reverse) + private var reports: [Report] + + @Query(sort: \DiaryEntry.createdAt, order: .reverse) + private var diaries: [DiaryEntry] + + @Query(sort: \Symptom.startedAt, order: .reverse) + private var symptoms: [Symptom] + + @State private var mode: CalendarMode = .month + @State private var anchor: Date = .now + @State private var selectedDay: SelectedDay? + + private let calendar: Calendar = { + var c = Calendar(identifier: .gregorian) + c.firstWeekday = 2 + c.locale = Locale(identifier: "zh_CN") + return c + }() + + @MainActor + private var data: CalendarData { + CalendarData.build( + indicators: indicators, + reports: reports, + diaries: diaries, + symptoms: symptoms + ) + } + + var body: some View { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 18) { + header.padding(.top, 4) + modeSwitch + anchorBar + calendarBody + legend + } + .padding(.horizontal, 20) + .padding(.bottom, 24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Tj.Palette.sand.ignoresSafeArea()) + .sheet(item: $selectedDay) { sel in + DayDetailSheet( + date: sel.date, + indicators: indicators, + reports: reports, + diaries: diaries, + symptoms: symptoms + ) + } + } + + private var header: some View { + HStack(alignment: .lastTextBaseline) { + Text("趋势") + .font(.tjTitle(26)) + .foregroundStyle(Tj.Palette.text) + Spacer() + Button { + anchor = .now + } label: { + Text("回到今天") + .font(.system(size: 12)) + .foregroundStyle(Tj.Palette.text3) + } + .buttonStyle(.plain) + } + } + + private var modeSwitch: some View { + HStack(spacing: 0) { + ForEach(CalendarMode.allCases) { m in + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + mode = m + } + } label: { + Text(m.label) + .font(.system(size: 13, weight: mode == m ? .semibold : .regular)) + .foregroundStyle(mode == m ? Tj.Palette.paper : Tj.Palette.text) + .frame(maxWidth: .infinity) + .padding(.vertical, 9) + .background( + Capsule().fill(mode == m ? Tj.Palette.ink : Color.clear) + ) + } + .buttonStyle(.plain) + } + } + .padding(3) + .background(Capsule().fill(Tj.Palette.paper)) + .overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1)) + .frame(maxWidth: 220) + } + + private var anchorBar: some View { + HStack { + Button { shiftAnchor(-1) } label: { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 36, height: 36) + .background(Circle().fill(Tj.Palette.paper)) + .overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1)) + } + .buttonStyle(.plain) + + Spacer() + + Text(anchorTitle) + .font(.tjH2()) + .foregroundStyle(Tj.Palette.text) + .contentTransition(.numericText()) + .animation(.snappy, value: anchor) + + Spacer() + + Button { shiftAnchor(1) } label: { + Image(systemName: "chevron.right") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Tj.Palette.text) + .frame(width: 36, height: 36) + .background(Circle().fill(Tj.Palette.paper)) + .overlay(Circle().strokeBorder(Tj.Palette.line, lineWidth: 1)) + } + .buttonStyle(.plain) + .disabled(isAnchorAtFuture) + .opacity(isAnchorAtFuture ? 0.4 : 1) + } + } + + private var anchorTitle: String { + let f = DateFormatter() + f.locale = Locale(identifier: "zh_CN") + f.dateFormat = mode == .month ? "yyyy 年 M 月" : "yyyy 年" + return f.string(from: anchor) + } + + @ViewBuilder + private var calendarBody: some View { + switch mode { + case .month: + CalendarMonthGrid(monthAnchor: anchor, data: data) { day in + selectedDay = SelectedDay(date: day) + } + .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) + ) + case .year: + CalendarYearGrid( + year: calendar.component(.year, from: anchor), + data: data + ) { tappedMonth in + anchor = tappedMonth + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + mode = .month + } + } + } + } + + private var legend: some View { + VStack(alignment: .leading, spacing: 8) { + Text("图例") + .font(.system(size: 11, weight: .semibold)) + .tracking(0.5) + .foregroundStyle(Tj.Palette.text3) + HStack(spacing: 14) { + legendItem(color: Tj.Palette.brick, label: "指标异常") + legendItem(color: Tj.Palette.amber, label: "症状持续中") + legendItem(color: Tj.Palette.ink2, label: "报告归档") + legendItem(color: Tj.Palette.leaf, label: "正常") + } + } + .padding(.top, 4) + } + + private func legendItem(color: Color, label: String) -> some View { + HStack(spacing: 5) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(color) + .frame(width: 14, height: 6) + Text(label) + .font(.system(size: 11)) + .foregroundStyle(Tj.Palette.text2) + } + } + + private var isAnchorAtFuture: Bool { + switch mode { + case .month: + return calendar.isDate(anchor, equalTo: .now, toGranularity: .month) || + anchor > .now + case .year: + let nowYear = calendar.component(.year, from: .now) + let anchorYear = calendar.component(.year, from: anchor) + return anchorYear >= nowYear + } + } + + private func shiftAnchor(_ delta: Int) { + let component: Calendar.Component = (mode == .month) ? .month : .year + if let next = calendar.date(byAdding: component, value: delta, to: anchor) { + withAnimation(.snappy) { + anchor = next + } + } + } +} + +#Preview { + TrendsView() + .modelContainer(for: [ + Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self + ], inMemory: true) +} diff --git a/康康/RootView.swift b/康康/RootView.swift index 0d5f5e4..c30d8d3 100644 --- a/康康/RootView.swift +++ b/康康/RootView.swift @@ -31,6 +31,7 @@ struct RootView: View { @State private var activeFlow: ActiveFlow? @State private var showSymptomStart = false @State private var showDiary = false + @State private var showIndicator = false var body: some View { VStack(spacing: 0) { @@ -54,10 +55,11 @@ struct RootView: View { showRecordSheet = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { switch kind { - case .quick: activeFlow = .quick - case .archive: activeFlow = .archive - case .symptom: showSymptomStart = true - case .diary: showDiary = true + case .quick: activeFlow = .quick + case .archive: activeFlow = .archive + case .symptom: showSymptomStart = true + case .diary: showDiary = true + case .indicator: showIndicator = true } } } @@ -68,6 +70,9 @@ struct RootView: View { .sheet(isPresented: $showDiary) { DiaryQuickSheet() } + .sheet(isPresented: $showIndicator) { + IndicatorQuickSheet() + } #if os(iOS) .fullScreenCover(item: $activeFlow) { flow in switch flow {