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:
67
CLAUDE.md
67
CLAUDE.md
@@ -92,37 +92,27 @@ VL prompt 必须:
|
|||||||
|
|
||||||
## 5. 数据模型(SwiftData)
|
## 5. 数据模型(SwiftData)
|
||||||
|
|
||||||
现有 3 个 `@Model`,要新增 2 个:
|
**当前 schema(2026-05-26)**:7 个 @Model。
|
||||||
|
|
||||||
```swift
|
```swift
|
||||||
// 已有(在 Models/Models.swift)
|
@Model class Indicator {
|
||||||
@Model class Indicator { name, value, unit, range, statusRaw, note, capturedAt }
|
name, value, unit, range, statusRaw, note, capturedAt,
|
||||||
@Model class Report { title, typeRaw, reportDate, institution, note, summary, pageCount, createdAt }
|
report: Report?, asset: Asset?,
|
||||||
@Model class DiaryEntry { content, createdAt }
|
pinned: Bool, // 长期监测自动 true,Trends 默认展示
|
||||||
|
seriesKey: String? // "bp.systolic" / "glucose.fasting" / ... 长期指标分组 key
|
||||||
// 待加字段
|
|
||||||
// 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 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 {
|
@Model class UserProfile { // 全 App 单例(UserProfileStore.loadOrCreate)
|
||||||
var question: String
|
birthYear?, biologicalSexRaw, heightCM?, bloodTypeRaw,
|
||||||
var answer: String
|
allergies, chronicConditions, familyHistory, currentMedications,
|
||||||
var referencedIndicatorIDs: [String]
|
updatedAt
|
||||||
var referencedReportIDs: [String]
|
|
||||||
var createdAt: Date
|
|
||||||
var decodeRate: Double // 该轮问答推理速度,Me 页性能展示
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -149,18 +139,21 @@ VL prompt 必须:
|
|||||||
## 7. 信息架构
|
## 7. 信息架构
|
||||||
|
|
||||||
```
|
```
|
||||||
TabBar: [首页] [+ 记录] [趋势] [我的]
|
TabBar: [主页] [记录] [+ 新建] [趋势] [我的]
|
||||||
│ │ │ │
|
│ │ │ │ │
|
||||||
│ │ │ └─ 模型管理 / Face ID / 关于
|
│ │ │ │ └─ 个人资料 / 模型管理 / Face ID / 关于
|
||||||
│ │ └─ 折线图 + AI 一句话解读
|
│ │ │ └─ 折线图 + AI 一句话解读
|
||||||
│ └─ Modal: 选择 拍一张 / 写日记 / 问问看
|
│ │ └─ Sheet: 拍一张 / 指标记录 / 报告归档 / 写日记 / 症状
|
||||||
└─ 问候 + 今日摘要 + 时间线 + 影像档案入口
|
│ └─ ArchiveListView(时间线 + 分类 chip + 年/月分组)
|
||||||
|
└─ 问候 + 今日摘要 + 进行中症状 + 最近时间线
|
||||||
```
|
```
|
||||||
|
|
||||||
- **3 Tab 不变**,中间 + 号是 Sheet
|
- TabBar **5 槽**:左 2 个内容 Tab + 中间 + 号 + 右 2 个 Tab
|
||||||
|
- "+ 新建" 是 sheet 不是 Tab
|
||||||
- AI 问答以 Modal Sheet 形式出现,**不占 Tab**
|
- AI 问答以 Modal Sheet 形式出现,**不占 Tab**
|
||||||
- "问问看"入口除了在 RecordSheet 里,首页摘要卡片下方也有一个常驻入口
|
- 「指标记录」sheet 顶部 LazyVGrid 是 8 个 MonitorMetric 长期监测预设(进趋势),
|
||||||
- 历史时间线在首页下半部分,不单独开 Tab
|
下方 horizontal scroll 是化验项快捷预设(不进趋势),不选预设走自由输入
|
||||||
|
- 「我的 · 个人资料」是 NavigationLink push 的 Form 编辑页
|
||||||
|
|
||||||
### 7.1 档案库 C1 / C2 导航(看的一半)
|
### 7.1 档案库 C1 / C2 导航(看的一半)
|
||||||
|
|
||||||
@@ -256,7 +249,7 @@ C2 解读 Tab 底部显示一段 diff 文本,**由 `ReportCompareService` 计算
|
|||||||
3. **UI 不直接调 AIRuntime**——必须经过 Service
|
3. **UI 不直接调 AIRuntime**——必须经过 Service
|
||||||
4. **AIRuntime 必须 actor 化**——禁止 class + lock
|
4. **AIRuntime 必须 actor 化**——禁止 class + lock
|
||||||
5. **VL/LLM prompt 必须有 few-shot + 失败回退**——不能让用户卡在 AI 错误屏
|
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 骨架**——增量加东西,不要推倒重来
|
7. **不要在 6 周里重构现有 Tab/RecordSheet 骨架**——增量加东西,不要推倒重来
|
||||||
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
|
8. **报告详情(C2)与归档元信息编辑(B3)是两个 View**——B3 是 draft 编辑(写),C2 是 detail 浏览(读),不要合并复用主框架
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
enum RecordKind: String, Identifiable, CaseIterable {
|
enum RecordKind: String, Identifiable, CaseIterable {
|
||||||
case quick, archive, diary, symptom
|
case quick, indicator, archive, diary, symptom
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .quick: return "异常项快拍"
|
case .quick: return "异常项快拍"
|
||||||
|
case .indicator: return "指标记录"
|
||||||
case .archive: return "关键报告归档"
|
case .archive: return "关键报告归档"
|
||||||
case .diary: return "文字日记"
|
case .diary: return "文字日记"
|
||||||
case .symptom: return "症状开始"
|
case .symptom: return "症状开始"
|
||||||
@@ -14,15 +15,17 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
|||||||
}
|
}
|
||||||
var subtitle: String {
|
var subtitle: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .quick: return "只记录单个或几项异常指标"
|
case .quick: return "拍一张化验单,VL 自动识别"
|
||||||
|
case .indicator: return "手动填一项指标(免拍照)"
|
||||||
case .archive: return "完整保存整份报告(可多页)"
|
case .archive: return "完整保存整份报告(可多页)"
|
||||||
case .diary: return "记录心情、用药、其他"
|
case .diary: return "记录心情、用药、其他"
|
||||||
case .symptom: return "开始一个持续症状,结束时再点结束"
|
case .symptom: return "开始一个持续症状,结束时再点结束"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var icon: String {
|
var icon: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .quick: return "camera.fill"
|
case .quick: return "camera.fill"
|
||||||
|
case .indicator: return "number.square.fill"
|
||||||
case .archive: return "doc.fill"
|
case .archive: return "doc.fill"
|
||||||
case .diary: return "pencil"
|
case .diary: return "pencil"
|
||||||
case .symptom: return "waveform.path.ecg"
|
case .symptom: return "waveform.path.ecg"
|
||||||
@@ -31,6 +34,7 @@ enum RecordKind: String, Identifiable, CaseIterable {
|
|||||||
var accent: Color {
|
var accent: Color {
|
||||||
switch self {
|
switch self {
|
||||||
case .quick: return Tj.Palette.brick
|
case .quick: return Tj.Palette.brick
|
||||||
|
case .indicator: return Tj.Palette.brick
|
||||||
case .archive: return Tj.Palette.ink
|
case .archive: return Tj.Palette.ink
|
||||||
case .diary: return Tj.Palette.leaf
|
case .diary: return Tj.Palette.leaf
|
||||||
case .symptom: return Tj.Palette.amber
|
case .symptom: return Tj.Palette.amber
|
||||||
@@ -102,7 +106,7 @@ struct RecordSheet: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
|
||||||
.ignoresSafeArea(edges: .bottom)
|
.ignoresSafeArea(edges: .bottom)
|
||||||
)
|
)
|
||||||
.presentationDetents([.fraction(0.68)])
|
.presentationDetents([.fraction(0.8)])
|
||||||
.presentationDragIndicator(.hidden)
|
.presentationDragIndicator(.hidden)
|
||||||
.presentationBackground(Tj.Palette.sand)
|
.presentationBackground(Tj.Palette.sand)
|
||||||
.presentationCornerRadius(Tj.Radius.xl)
|
.presentationCornerRadius(Tj.Radius.xl)
|
||||||
|
|||||||
@@ -32,8 +32,11 @@ enum TimelineGrouping {
|
|||||||
static func section(for date: Date,
|
static func section(for date: Date,
|
||||||
now: Date = .now,
|
now: Date = .now,
|
||||||
calendar: Calendar = .current) -> DateSection {
|
calendar: Calendar = .current) -> DateSection {
|
||||||
if calendar.isDateInToday(date) { return .today }
|
if calendar.isDate(date, inSameDayAs: now) { return .today }
|
||||||
if calendar.isDateInYesterday(date) { return .yesterday }
|
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) {
|
if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) {
|
||||||
return .thisWeek
|
return .thisWeek
|
||||||
}
|
}
|
||||||
|
|||||||
108
康康/Features/Trends/CalendarMarkers.swift
Normal file
108
康康/Features/Trends/CalendarMarkers.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
191
康康/Features/Trends/CalendarMonthGrid.swift
Normal file
191
康康/Features/Trends/CalendarMonthGrid.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
康康/Features/Trends/CalendarYearGrid.swift
Normal file
114
康康/Features/Trends/CalendarYearGrid.swift
Normal 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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
406
康康/Features/Trends/DayDetailSheet.swift
Normal file
406
康康/Features/Trends/DayDetailSheet.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
153
康康/Features/Trends/SeriesBucket.swift
Normal file
153
康康/Features/Trends/SeriesBucket.swift
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,243 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
struct TrendsView: View {
|
enum CalendarMode: String, CaseIterable, Identifiable {
|
||||||
var body: some View {
|
case month, year
|
||||||
VStack(spacing: 12) {
|
var id: String { rawValue }
|
||||||
Spacer()
|
var label: String {
|
||||||
TjPlaceholder(label: "trends · 折线图 + 影像档案入口\n(尚未实现)")
|
switch self {
|
||||||
.frame(width: 280, height: 180)
|
case .month: return "月"
|
||||||
Text("趋势")
|
case .year: return "年"
|
||||||
.font(.tjH2())
|
|
||||||
.foregroundStyle(Tj.Palette.text2)
|
|
||||||
Spacer()
|
|
||||||
}
|
}
|
||||||
.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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ struct RootView: View {
|
|||||||
@State private var activeFlow: ActiveFlow?
|
@State private var activeFlow: ActiveFlow?
|
||||||
@State private var showSymptomStart = false
|
@State private var showSymptomStart = false
|
||||||
@State private var showDiary = false
|
@State private var showDiary = false
|
||||||
|
@State private var showIndicator = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -58,6 +59,7 @@ struct RootView: View {
|
|||||||
case .archive: activeFlow = .archive
|
case .archive: activeFlow = .archive
|
||||||
case .symptom: showSymptomStart = true
|
case .symptom: showSymptomStart = true
|
||||||
case .diary: showDiary = true
|
case .diary: showDiary = true
|
||||||
|
case .indicator: showIndicator = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +70,9 @@ struct RootView: View {
|
|||||||
.sheet(isPresented: $showDiary) {
|
.sheet(isPresented: $showDiary) {
|
||||||
DiaryQuickSheet()
|
DiaryQuickSheet()
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showIndicator) {
|
||||||
|
IndicatorQuickSheet()
|
||||||
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.fullScreenCover(item: $activeFlow) { flow in
|
.fullScreenCover(item: $activeFlow) { flow in
|
||||||
switch flow {
|
switch flow {
|
||||||
|
|||||||
Reference in New Issue
Block a user