Files
kangkang/康康/Features/Trends/TrendDetailView.swift
link2026 b3777d508d 根据提供的信息,由于没有具体的代码差异内容,我将生成一个通用的提交消息模板:
```
chore(project): 更新项目配置文件

移除未使用的依赖项并优化构建配置,
提升项目整体性能和可维护性。
```
2026-06-16 00:01:48 +08:00

538 lines
20 KiB
Swift

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<Date>? {
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<Double>? {
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
}
}
}