import SwiftUI import SwiftData enum CalendarMode: String, CaseIterable, Identifiable { case month, year var id: String { rawValue } var label: String { switch self { case .month: return String(appLoc: "月") case .year: return String(appLoc: "年") } } } 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] @Query private var profiles: [UserProfile] @Query private var customMetrics: [CustomMonitorMetric] @State private var mode: CalendarMode = .month @State private var anchor: Date = .now /// 选中的当天 — 默认选今天,日历下方 inline 显示该日详情 @State private var selectedDate: Date = .now private var profile: UserProfile? { profiles.first } private var seriesBuckets: [SeriesBucket] { SeriesBucket.build(from: indicators, profile: profile, customMetrics: customMetrics) } private let calendar: Calendar = { var c = Calendar(identifier: .gregorian) c.firstWeekday = 2 c.locale = Locale.current 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 if mode == .month { dayDetailInline } seriesSection } .padding(.horizontal, 20) .padding(.bottom, 24) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Tj.Palette.sand.ignoresSafeArea()) } /// 日历下方 inline 显示选中天的详情(symptoms / indicators / reports / diaries) private var dayDetailInline: some View { VStack(alignment: .leading, spacing: 0) { DayDetailContent( date: selectedDate, indicators: indicators, reports: reports, diaries: diaries, symptoms: symptoms, showHeader: true ) .padding(14) } .frame(maxWidth: .infinity, alignment: .leading) .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) ) .animation(.snappy(duration: 0.2), value: selectedDate) } private var header: some View { HStack(alignment: .lastTextBaseline) { Text("趋势") .font(.tjTitle(26)) .foregroundStyle(Tj.Palette.text) Spacer() Button { withAnimation(.snappy(duration: 0.2)) { anchor = .now selectedDate = .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 style: Date.FormatStyle = mode == .month ? .dateTime.year().month() : .dateTime.year() return anchor.formatted(style) } @ViewBuilder private var calendarBody: some View { switch mode { case .month: CalendarMonthGrid(monthAnchor: anchor, data: data, selectedDate: selectedDate) { day in withAnimation(.snappy(duration: 0.2)) { selectedDate = 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 } } } } @ViewBuilder private var seriesSection: some View { let buckets = seriesBuckets if !buckets.isEmpty { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .lastTextBaseline) { Text("长期监测") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Text("\(buckets.count) 项") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) Spacer() } .padding(.top, 8) VStack(spacing: 12) { ForEach(buckets) { bucket in SeriesChartCard(bucket: bucket) } } } } } 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: String(appLoc: "指标异常")) legendItem(color: Tj.Palette.amber, label: String(appLoc: "症状持续中")) legendItem(color: Tj.Palette.ink2, label: String(appLoc: "报告归档")) legendItem(color: Tj.Palette.leaf, label: String(appLoc: "正常")) } } .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 // 翻月时把 selection 跟着走:同月内停在今天(如果是当前月)或 1 号 if mode == .month { if calendar.isDate(next, equalTo: .now, toGranularity: .month) { selectedDate = .now } else if let first = calendar.dateInterval(of: .month, for: next)?.start { selectedDate = first } } } } } } #Preview { TrendsView() .modelContainer(for: [ Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self ], inMemory: true) }