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:
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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user