import SwiftUI import SwiftData import Charts /// 趋势详情:大图表 + 时间范围筛选 + 统计摘要 + 数据点列表(点击跳当日详情)。 struct TrendDetailView: View { let bucket: SeriesBucket @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] @State private var range: TrendRange = .all @State private var openDay: SelectedDay? private let calendar = Calendar.current // MARK: 时间范围裁剪 /// 锚点 = 最新一条记录的时间(数据稀疏时,"近3月"从最新记录倒推更有用)。 private var anchorDate: Date { bucket.lines.flatMap(\.points).map(\.date).max() ?? .now } private var fullSpanDays: Int { let dates = bucket.lines.flatMap(\.points).map(\.date) guard let lo = dates.min(), let hi = dates.max() else { return 0 } return calendar.dateComponents([.day], from: lo, to: hi).day ?? 0 } private var availableRanges: [TrendRange] { TrendRange.allCases.filter { r in guard let d = r.days else { return true } // .all 总显示 return d < fullSpanDays } } private func filtered(_ line: SeriesBucket.SeriesLine) -> [SeriesBucket.Point] { guard let days = range.days, let cutoff = calendar.date(byAdding: .day, value: -days, to: anchorDate) else { return line.points } return line.points.filter { $0.date >= cutoff } } private var filteredLines: [SeriesBucket.SeriesLine] { bucket.lines.map { line in SeriesBucket.SeriesLine( id: line.id, seriesKey: line.seriesKey, label: line.label, color: line.color, points: filtered(line), referenceRange: line.referenceRange ) } } var body: some View { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 18) { if availableRanges.count > 1 { rangePicker } chartCard statsCard aiPlaceholder pointsList } .padding(.horizontal, 20) .padding(.vertical, 16) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Tj.Palette.sand.ignoresSafeArea()) .navigationTitle(bucket.title) .navigationBarTitleDisplayMode(.inline) .sheet(item: $openDay) { day in DayDetailSheet( date: day.date, indicators: indicators, reports: reports, diaries: diaries, symptoms: symptoms ) } } // MARK: 时间范围切换 private var rangePicker: some View { HStack(spacing: 0) { ForEach(availableRanges) { r in Button { withAnimation(.snappy(duration: 0.2)) { range = r } } label: { Text(r.label) .font(.system(size: 12, weight: range == r ? .semibold : .regular)) .foregroundStyle(range == r ? Tj.Palette.paper : Tj.Palette.text) .frame(maxWidth: .infinity) .padding(.vertical, 7) .background(Capsule().fill(range == r ? Tj.Palette.ink : Color.clear)) } .buttonStyle(.plain) } } .padding(3) .background(Capsule().fill(Tj.Palette.paper)) .overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: 1)) } // MARK: 大图表 private var allFilteredPoints: [(line: SeriesBucket.SeriesLine, point: SeriesBucket.Point)] { filteredLines.flatMap { line in line.points.map { (line, $0) } } } private var dateDomain: ClosedRange? { let dates = allFilteredPoints.map(\.point.date) guard let lo = dates.min(), let hi = dates.max() else { return nil } if lo == hi { let earlier = calendar.date(byAdding: .hour, value: -12, to: lo) ?? lo let later = calendar.date(byAdding: .hour, value: 12, to: hi) ?? hi return earlier...later } return lo...hi } private var valueDomain: ClosedRange? { var lo = Double.greatestFiniteMagnitude var hi = -Double.greatestFiniteMagnitude for (_, p) in allFilteredPoints { lo = min(lo, p.value); hi = max(hi, p.value) } for line in filteredLines { if let r = line.referenceRange { lo = min(lo, r.lowerBound); hi = max(hi, r.upperBound) } } guard lo <= hi else { return nil } let span = hi - lo let pad = span > 0 ? max(1, span * 0.12) : max(1, abs(lo) * 0.1) return (lo - pad)...(hi + pad) } private var chartCard: some View { VStack(alignment: .leading, spacing: 12) { chart.frame(height: 220) if filteredLines.count > 1 { legendLine } } .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) ) } private var chart: some View { Chart { ForEach(filteredLines) { line in if let r = line.referenceRange, let dom = dateDomain { RectangleMark( xStart: .value("start", dom.lowerBound), xEnd: .value("end", dom.upperBound), yStart: .value("lo", r.lowerBound), yEnd: .value("hi", r.upperBound) ) .foregroundStyle(line.color.opacity(0.08)) } } ForEach(filteredLines) { line in ForEach(line.points) { p in LineMark( x: .value("时间", p.date), y: .value(line.label ?? bucket.title, p.value), series: .value("series", line.id) ) .foregroundStyle(line.color) .interpolationMethod(.catmullRom) .lineStyle(StrokeStyle(lineWidth: 2)) PointMark( x: .value("时间", p.date), y: .value(line.label ?? bucket.title, p.value) ) .foregroundStyle(p.status == .normal ? line.color : Tj.Palette.brick) .symbolSize(p.status == .normal ? 26 : 44) } } } .chartXAxis { AxisMarks(values: .automatic(desiredCount: 4)) { _ in AxisGridLine().foregroundStyle(Tj.Palette.lineSoft) AxisValueLabel(format: .dateTime.month(.abbreviated).day()) .foregroundStyle(Tj.Palette.text3) } } .chartYAxis { AxisMarks(position: .leading, values: .automatic(desiredCount: 4)) { _ in AxisGridLine().foregroundStyle(Tj.Palette.lineSoft) AxisValueLabel() .foregroundStyle(Tj.Palette.text3) .font(.system(size: 10, design: .monospaced)) } } .chartYScale(domain: valueDomain ?? 0...1) } private var legendLine: some View { HStack(spacing: 14) { ForEach(filteredLines) { line in HStack(spacing: 5) { Circle().fill(line.color).frame(width: 8, height: 8) Text(line.label ?? line.seriesKey) .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text2) } } } } // MARK: 统计摘要 private var statsCard: some View { VStack(alignment: .leading, spacing: 14) { ForEach(filteredLines) { line in lineStats(line) if line.id != filteredLines.last?.id { Divider().overlay(Tj.Palette.lineSoft) } } } .padding(16) .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) ) } @ViewBuilder private func lineStats(_ line: SeriesBucket.SeriesLine) -> some View { let pts = line.points let values = pts.map(\.value) let latest = pts.last let prev = pts.count >= 2 ? pts[pts.count - 2] : nil let minV = values.min() ?? 0 let maxV = values.max() ?? 0 let avg = values.isEmpty ? 0 : values.reduce(0, +) / Double(values.count) VStack(alignment: .leading, spacing: 10) { if filteredLines.count > 1, let label = line.label { Text(label) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) } HStack(alignment: .firstTextBaseline, spacing: 6) { Text(latest.map { fmt($0.value) } ?? "—") .font(.system(size: 28, weight: .bold, design: .monospaced)) .foregroundStyle((latest?.status ?? .normal) == .normal ? Tj.Palette.text : Tj.Palette.brick) Text(bucket.unit) .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) Spacer() if let delta = deltaText(latest: latest, prev: prev) { Text(delta.text) .font(.system(size: 13, weight: .semibold, design: .monospaced)) .foregroundStyle(delta.color) } } HStack(spacing: 0) { statCell(String(appLoc: "最低"), fmt(minV)) statCell(String(appLoc: "最高"), fmt(maxV)) statCell(String(appLoc: "平均"), fmt(avg)) statCell(String(appLoc: "记录"), "\(pts.count)") } } } private func statCell(_ label: String, _ value: String) -> some View { VStack(spacing: 3) { Text(value) .font(.system(size: 14, weight: .semibold, design: .monospaced)) .foregroundStyle(Tj.Palette.text) Text(label) .font(.system(size: 10)) .foregroundStyle(Tj.Palette.text3) } .frame(maxWidth: .infinity) } /// 对比上次:Δ 绝对值 + 百分比 + 升降箭头;跨参考范围边界标红。 private func deltaText(latest: SeriesBucket.Point?, prev: SeriesBucket.Point?) -> (text: String, color: Color)? { guard let latest, let prev else { return nil } let d = latest.value - prev.value let arrow = d > 0 ? "↑" : (d < 0 ? "↓" : "→") let pct = prev.value != 0 ? abs(d / prev.value) * 100 : 0 let abnormalShift = (prev.status == .normal) != (latest.status == .normal) let color: Color = abnormalShift ? Tj.Palette.brick : (d == 0 ? Tj.Palette.text3 : Tj.Palette.text2) let pctStr = pct > 0 ? String(format: " (%.0f%%)", pct) : "" return ("\(arrow) \(fmt(abs(d)))\(pctStr)", color) } // MARK: AI 解读占位 private var aiPlaceholder: some View { HStack(spacing: 8) { Image(systemName: "sparkles") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) Text("AI 趋势解读即将上线") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) Spacer() } .padding(.horizontal, 14) .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.sand2.opacity(0.6)) ) } // MARK: 数据点列表 /// 跨线按天合并:每天一行,展示该天各线的值。倒序。 private var pointRows: [PointRow] { var byDay: [Date: [String: SeriesBucket.Point]] = [:] for line in filteredLines { for p in line.points { let day = calendar.startOfDay(for: p.date) byDay[day, default: [:]][line.id] = p } } return byDay.keys.sorted(by: >).map { day in PointRow(day: day, byLine: byDay[day] ?? [:]) } } private struct PointRow: Identifiable { let day: Date let byLine: [String: SeriesBucket.Point] var id: TimeInterval { day.timeIntervalSince1970 } } private var pointsList: some View { VStack(alignment: .leading, spacing: 10) { Text("全部记录") .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) VStack(spacing: 8) { ForEach(pointRows) { row in Button { openDay = SelectedDay(date: row.day) } label: { pointRowView(row) } .buttonStyle(.plain) } } } } private func pointRowView(_ row: PointRow) -> some View { HStack(spacing: 12) { Text(row.day.formatted(.dateTime.year().month(.abbreviated).day())) .font(.system(size: 13)) .foregroundStyle(Tj.Palette.text2) Spacer(minLength: 8) HStack(spacing: 10) { ForEach(filteredLines) { line in if let p = row.byLine[line.id] { HStack(spacing: 3) { if filteredLines.count > 1 { Circle().fill(line.color).frame(width: 6, height: 6) } Text(fmt(p.value) + arrow(p.status)) .font(.system(size: 13, weight: .semibold, design: .monospaced)) .foregroundStyle(p.status == .normal ? Tj.Palette.text : Tj.Palette.brick) } } } } Image(systemName: "chevron.right") .font(.system(size: 11, weight: .medium)) .foregroundStyle(Tj.Palette.text3) } .padding(12) .frame(maxWidth: .infinity) .tjCard(bordered: true) } private func arrow(_ status: IndicatorStatus) -> String { switch status { case .high: return " ↑" case .low: return " ↓" case .normal: return "" } } private func fmt(_ v: Double) -> String { v.truncatingRemainder(dividingBy: 1) == 0 ? String(format: "%.0f", v) : String(format: "%.1f", v) } } enum TrendRange: String, CaseIterable, Identifiable { case all, year, sixMonths, threeMonths var id: String { rawValue } var label: String { switch self { case .all: return String(appLoc: "全部") case .year: return String(appLoc: "近1年") case .sixMonths: return String(appLoc: "近6月") case .threeMonths: return String(appLoc: "近3月") } } /// nil = 不裁剪。 var days: Int? { switch self { case .all: return nil case .year: return 365 case .sixMonths: return 182 case .threeMonths: return 91 } } }