Files
kangkang/康康/Features/Trends/TrendsView.swift
link2026 1b01923c8e feat(capture): 统一报告捕获流程并集成视觉语言模型识别
- 替换 QuickCaptureFlow 和 ArchiveFlow 为 UnifiedCaptureFlow 统一流程
- 新增 VLSession 封装 Qwen2.5-VL 模型进行图像文本推理
- 实现 AIRuntime 中 VL 模型的准备和分析功能
- 添加 VLPrompts 定义体检化验单识别的 JSON 输出模板
- 创建 CaptureReviewForm 提供 VL 解析结果的可编辑表单界面
- 集成 VisionKit 文档扫描器支持真机多页文档扫描
- 为模拟器实现 PhotosPicker 回退方案选择已有照片
- 在 RootView 中统一使用 UnifiedCaptureFlow 处理快速和归档流程
- 添加 CustomMetricEditor 支持自定义监测指标的创建编辑删除
- 扩展 KangkangApp 模型配置以支持新数据类型
- 实现档案列表中症状结束功能通过时间线行点击触发
2026-05-26 11:18:00 +08:00

282 lines
9.0 KiB
Swift

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)
}