docs(claude): sync §5/§7/§10 with Monitor+Profile; fix SeriesBucket SwiftData import

- §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。
This commit is contained in:
link2026
2026-05-26 07:53:16 +08:00
parent e2fb631b96
commit 37b47b2076
10 changed files with 1275 additions and 74 deletions

View File

@@ -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 浏览(读),不要合并复用主框架

View File

@@ -1,12 +1,13 @@
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 .indicator: return "指标记录"
case .archive: return "关键报告归档"
case .diary: return "文字日记"
case .symptom: return "症状开始"
@@ -14,15 +15,17 @@ enum RecordKind: String, Identifiable, CaseIterable {
}
var subtitle: String {
switch self {
case .quick: return "只记录单个或几项异常指标"
case .quick: return "拍一张化验单,VL 自动识别"
case .indicator: return "手动填一项指标(免拍照)"
case .archive: return "完整保存整份报告(可多页)"
case .diary: return "记录心情、用药、其他"
case .symptom: return "开始一个持续症状结束时再点结束"
case .symptom: return "开始一个持续症状,结束时再点结束"
}
}
var icon: String {
switch self {
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"
@@ -31,6 +34,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
var accent: Color {
switch self {
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)

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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..<weekdayIndex).reversed() {
if let d = calendar.date(byAdding: .day, value: -(offset + 1), to: firstOfMonth) {
cells.append(DayCell(date: d, inCurrentMonth: false))
}
}
// current month days
for i in 0..<daysInMonth {
if let d = calendar.date(byAdding: .day, value: i, to: firstOfMonth) {
cells.append(DayCell(date: d, inCurrentMonth: true))
}
}
// trailing padding () 6 = 42
while cells.count < 42 {
if let last = cells.last,
let next = calendar.date(byAdding: .day, value: 1, to: last.date) {
cells.append(DayCell(date: next, inCurrentMonth: false))
} else { break }
}
return cells
}
var body: some View {
VStack(spacing: 8) {
HStack(spacing: 4) {
ForEach(weekdayLabels, id: \.self) { w in
Text(w)
.font(.system(size: 11, weight: .medium))
.foregroundStyle(Tj.Palette.text3)
.frame(maxWidth: .infinity)
}
}
LazyVGrid(columns: columns, spacing: 4) {
ForEach(days) { cell in
DayCellView(
cell: cell,
ranges: data.ranges(touching: cell.date, calendar: calendar),
marks: data.marks(for: cell.date, calendar: calendar),
isToday: calendar.isDateInToday(cell.date),
calendar: calendar
)
.onTapGesture { onTapDay(cell.date) }
}
}
}
}
}
struct DayCell: Identifiable, Hashable {
let date: Date
let inCurrentMonth: Bool
var id: String { "\(date.timeIntervalSince1970)" }
}
private struct DayCellView: View {
let cell: DayCell
let ranges: [SymptomRange]
let marks: DayMarks
let isToday: Bool
let calendar: Calendar
private var dayNumber: Int {
calendar.component(.day, from: cell.date)
}
var body: some View {
ZStack(alignment: .top) {
// :
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(isToday ? Tj.Palette.sand2 : Color.clear)
VStack(spacing: 2) {
Text("\(dayNumber)")
.font(.system(size: 13,
weight: isToday ? .bold : .regular,
design: .default))
.foregroundStyle(textColor)
.padding(.top, 4)
//
if !ranges.isEmpty {
VStack(spacing: 1) {
ForEach(Array(ranges.prefix(2).enumerated()), id: \.element.id) { _, range in
symptomBar(range)
}
if ranges.count > 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
}
}
}

View File

@@ -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..<count).compactMap { calendar.date(byAdding: .day, value: $0, to: interval.start) }
}
private var leadingPadding: Int {
guard let first = days.first else { return 0 }
return (calendar.component(.weekday, from: first) - calendar.firstWeekday + 7) % 7
}
private let microColumns = Array(repeating: GridItem(.flexible(), spacing: 2), count: 7)
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(monthLabel)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
LazyVGrid(columns: microColumns, spacing: 2) {
ForEach(0..<leadingPadding, id: \.self) { _ in
Color.clear.frame(height: 8)
}
ForEach(days, id: \.self) { d in
dot(for: d)
}
}
}
.padding(8)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
}
private func dot(for date: Date) -> 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)
)
}
}

View File

@@ -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<Content: View>(_ 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
}
}
}

View File

@@ -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<Double>?
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<String> = ["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 }
}
}
}

View File

@@ -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)
}

View File

@@ -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) {
@@ -58,6 +59,7 @@ struct RootView: View {
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 {