当您提供代码差异后,我将按照以下格式生成: ``` <type>(<scope>): <subject> <body> ``` 其中type会根据更改类型选择(feat、fix、docs、style、refactor等),scope表示影响范围,subject简要描述变更内容,body详细说明修改内容。
449 lines
16 KiB
Swift
449 lines
16 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
|
|
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(.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))
|
|
}
|
|
}
|
|
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(.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: AI 解读占位
|
|
|
|
private var aiPlaceholder: some View {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "sparkles")
|
|
.font(.tjScaled( 12))
|
|
.foregroundStyle(Tj.Palette.text3)
|
|
Text("AI 趋势解读即将上线")
|
|
.font(.tjScaled( 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(.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)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|