import SwiftUI import SwiftData enum CalendarMode: String, CaseIterable, Identifiable { case month, year var id: String { rawValue } var label: String { switch self { case .month: return "月" case .year: return "年" } } } 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 @State private var selectedDay: SelectedDay? 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(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 seriesSection } .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 } } } } @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: "指标异常") 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) }