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

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