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 TrendInsightCard(bucket: bucket) 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(.tjScaled( 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)) } } // 单条线时,线下垫一层渐变面积,增加体量、柔化观感 //(多条线如血压不加,避免两片面积互相盖住)。 if filteredLines.count == 1, let line = filteredLines.first { ForEach(line.points) { p in // 显式把基线钉在值域下界:用单值 AreaMark 会以隐式基线(0/域外)兜底, // 渐变被拉到可视区外几乎不淡出,看着像一块实色底色一路铺到图表底。 AreaMark( x: .value("时间", p.date), yStart: .value("基线", (valueDomain ?? 0...1).lowerBound), yEnd: .value(line.label ?? bucket.title, p.value) ) .foregroundStyle(LinearGradient( colors: [line.color.opacity(0.16), line.color.opacity(0)], startPoint: .top, endPoint: .bottom)) .interpolationMethod(.monotone) } } 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) // monotone:平滑但不在尖峰处过冲鼓包,更贴合真实读数。 .interpolationMethod(.monotone) // 圆角端点 + 连接,去掉折线的生硬尖角。 .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)) 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(.tjScaled( 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(.tjScaled( 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(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) } HStack(alignment: .firstTextBaseline, spacing: 6) { Text(latest.map { fmt($0.value) } ?? "—") .font(.tjScaled( 28, weight: .bold, design: .monospaced)) .foregroundStyle((latest?.status ?? .normal) == .normal ? Tj.Palette.text : Tj.Palette.brick) Text(bucket.unit) .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer() if let delta = deltaText(latest: latest, prev: prev) { Text(delta.text) .font(.tjScaled( 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(.tjScaled( 14, weight: .semibold, design: .monospaced)) .foregroundStyle(Tj.Palette.text) Text(label) .font(.tjScaled( 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: 数据点列表 /// 跨线按天合并:每天一行,展示该天各线的值。倒序。 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(.tjScaled( 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(.tjScaled( 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(.tjScaled( 13, weight: .semibold, design: .monospaced)) .foregroundStyle(p.status == .normal ? Tj.Palette.text : Tj.Palette.brick) } } } } Image(systemName: "chevron.right") .font(.tjScaled( 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) } } // MARK: - AI 趋势解读卡 /// 进入页面先查指纹缓存:命中秒显;未命中本地现算(经 TrendInsightService,§3.1)。 private struct TrendInsightCard: View { let bucket: SeriesBucket @State private var text: String? @State private var running = false @State private var failedMessage: String? var body: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 6) { Image(systemName: "sparkles") .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.ink) Text("AI 解读") .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) Spacer() // 手动重新解读:任何时候都能点(解读中除外)。解决偶发不自动触发, // 也允许已生成后再跑一次拿最新解读。 if !running { Button { Task { await load(force: true) } } label: { HStack(spacing: 4) { Image(systemName: "arrow.clockwise") .font(.tjScaled( 11, weight: .semibold)) Text(text == nil ? String(appLoc: "解读") : String(appLoc: "重新解读")) .font(.tjScaled( 12, weight: .semibold)) } .foregroundStyle(Tj.Palette.ink) .padding(.horizontal, 10) .padding(.vertical, 5) .background(Capsule().fill(Tj.Palette.sand2)) .contentShape(Capsule()) } .buttonStyle(.plain) } } if let text { Text(text) .font(.tjScaled( 13)) .lineSpacing(3) .foregroundStyle(Tj.Palette.text) .fixedSize(horizontal: false, vertical: true) AIDisclaimerFooter() } else if running { Text("本地 AI 解读中…") .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) AIFlowBar() } else if let failedMessage { Text(failedMessage) .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } else { // 空闲态(偶发没自动触发):给明确引导,点右上即可生成。 Text("点右上「解读」生成本地趋势解读") .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) } } .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) ) .task(id: bucket.id) { await load(force: false) } } @MainActor private func load(force: Bool) async { if !force, let cached = TrendInsightService.shared.cachedText(for: bucket) { text = cached return } running = true failedMessage = nil do { text = try await TrendInsightService.shared.generate(for: bucket) } catch { failedMessage = String(appLoc: "AI 解读暂不可用(模型未就绪或繁忙)") } running = false } } 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 } } }