import SwiftUI import SwiftData struct SelectedDay: Identifiable, Hashable { let date: Date var id: TimeInterval { date.timeIntervalSince1970 } } // MARK: - DayDetailContent(可 inline 或入 sheet) /// 选中日详情的核心渲染。无 sheet 外壳,可同时被 TrendsView inline 使用,也能被 sheet 包。 struct DayDetailContent: View { let date: Date let indicators: [Indicator] let reports: [Report] let diaries: [DiaryEntry] let symptoms: [Symptom] /// 是否显示日期 header(inline 时通常自带 header,sheet 模式让 DayDetailSheet 自己画) var showHeader: Bool = true @State private var endingSymptom: Symptom? private let calendar: Calendar = { var c = Calendar(identifier: .gregorian) c.locale = Locale.current 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 } var body: some View { VStack(alignment: .leading, spacing: 14) { if showHeader { header } if totalCount == 0 { emptyState } else { if !daySymptoms.isEmpty { section(String(appLoc: "症状"), count: daySymptoms.count) { VStack(spacing: 8) { ForEach(daySymptoms, id: \.symptom.id) { item in symptomRow(item.symptom, state: item.state) } } } } if !dayIndicators.isEmpty { section(String(appLoc: "指标"), count: dayIndicators.count) { VStack(spacing: 8) { ForEach(dayIndicators) { i in indicatorRow(i) } } } } if !dayReports.isEmpty { section(String(appLoc: "报告"), count: dayReports.count) { VStack(spacing: 8) { ForEach(dayReports) { r in reportRow(r) } } } } if !dayDiaries.isEmpty { section(String(appLoc: "日记"), count: dayDiaries.count) { VStack(spacing: 8) { ForEach(dayDiaries) { d in diaryRow(d) } } } } } } .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(22)) .foregroundStyle(Tj.Palette.text) } Spacer() if totalCount > 0 { Text("\(totalCount) 条") .font(.system(size: 12, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } } } private var dateLine: String { date.formatted(.dateTime.year()) + " · " + weekdayLabel } private var dayLabel: String { date.formatted(.dateTime.month().day()) } private var weekdayLabel: String { date.formatted(.dateTime.weekday(.wide)) } // MARK: section helper private func section(_ title: String, count: Int, @ViewBuilder content: () -> Content) -> some View { VStack(alignment: .leading, spacing: 10) { HStack { Text(title) .font(.system(size: 13, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) Text("\(count)") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) Spacer() } content() } } // MARK: rows private func symptomRow(_ s: Symptom, state: SymptomDayState) -> some View { HStack(spacing: 12) { Capsule() .fill(severityColor(s.severity)) .frame(width: 4, height: 36) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 6) { Text(s.name) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) Text(state.badge) .font(.system(size: 10, weight: .semibold)) .foregroundStyle(state.badgeFg) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Capsule().fill(state.badgeBg)) } 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 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) } private var emptyState: some View { VStack(spacing: 8) { TjPlaceholder(label: String(appLoc: "这一天还没有记录")) .frame(height: 90) .frame(maxWidth: 240) Text("点底部 + 号可以补一条") .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text3) } .padding(.vertical, 12) .frame(maxWidth: .infinity) } 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 "" } } } // MARK: - Sheet wrapper(保留;现在 TrendsView 走 inline,但其他入口可能用) struct DayDetailSheet: View { let date: Date let indicators: [Indicator] let reports: [Report] let diaries: [DiaryEntry] let symptoms: [Symptom] var body: some View { VStack(spacing: 0) { Capsule() .fill(Tj.Palette.line) .frame(width: 40, height: 4) .padding(.top, 10) .padding(.bottom, 14) ScrollView(showsIndicators: false) { DayDetailContent( date: date, indicators: indicators, reports: reports, diaries: diaries, symptoms: symptoms, showHeader: true ) .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) } } // MARK: - SymptomDayState enum SymptomDayState { case startedToday, ongoing, endedToday var subtitle: String { switch self { case .startedToday: return String(appLoc: "今天开始") case .ongoing: return String(appLoc: "进行中") case .endedToday: return String(appLoc: "今天结束") } } var badge: String { switch self { case .startedToday: return String(appLoc: "开始") case .ongoing: return String(appLoc: "持续") case .endedToday: return String(appLoc: "结束") } } 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 } } }