缺少代码差异信息,无法生成具体的commit message。
请提供 "code differences" 的具体内容,以便我能够根据代码变更情况生成符合 Angular 规范的中文 commit message。
This commit is contained in:
291
康康/Features/Calendar/CalendarOverviewView.swift
Normal file
291
康康/Features/Calendar/CalendarOverviewView.swift
Normal file
@@ -0,0 +1,291 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
enum CalendarMode: String, CaseIterable, Identifiable {
|
||||
case month, year
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .month: return String(appLoc: "月")
|
||||
case .year: return String(appLoc: "年")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 健康日历总览页。从主页 HomeCalendarCard 进入。
|
||||
/// 月/年切换 + 上下导航 + 图例 + 月视图下方当日详情。日历组件复用 CalendarMonthGrid / CalendarYearGrid。
|
||||
struct CalendarOverviewView: View {
|
||||
/// 进入时定位到的日期(从主页某天点入);nil → 今天。
|
||||
var initialDate: Date = .now
|
||||
/// fullScreenCover 形态下的关闭回调。
|
||||
var onClose: (() -> Void)?
|
||||
|
||||
@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 mode: CalendarMode = .month
|
||||
@State private var anchor: Date = .now
|
||||
@State private var selectedDate: Date = .now
|
||||
|
||||
private let calendar: Calendar = {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.firstWeekday = 2
|
||||
c.locale = Locale.current
|
||||
return c
|
||||
}()
|
||||
|
||||
@MainActor
|
||||
private var data: CalendarData {
|
||||
CalendarData.build(
|
||||
indicators: indicators,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
symptoms: symptoms
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
modeSwitch.padding(.top, 4)
|
||||
anchorBar
|
||||
calendarBody
|
||||
legend
|
||||
if mode == .month {
|
||||
dayDetailInline
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationTitle(String(appLoc: "健康日历"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.2)) {
|
||||
anchor = .now
|
||||
selectedDate = .now
|
||||
mode = .month
|
||||
}
|
||||
} label: {
|
||||
Text("回到今天")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if let onClose {
|
||||
Button(action: onClose) {
|
||||
Text("完成")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
anchor = initialDate
|
||||
selectedDate = initialDate
|
||||
}
|
||||
}
|
||||
|
||||
private var dayDetailInline: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
DayDetailContent(
|
||||
date: selectedDate,
|
||||
indicators: indicators,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
symptoms: symptoms,
|
||||
showHeader: true
|
||||
)
|
||||
.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)
|
||||
)
|
||||
.animation(.snappy(duration: 0.2), value: selectedDate)
|
||||
}
|
||||
|
||||
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 style: Date.FormatStyle = mode == .month
|
||||
? .dateTime.year().month()
|
||||
: .dateTime.year()
|
||||
return anchor.formatted(style)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var calendarBody: some View {
|
||||
switch mode {
|
||||
case .month:
|
||||
CalendarMonthGrid(monthAnchor: anchor, data: data, selectedDate: selectedDate) { day in
|
||||
withAnimation(.snappy(duration: 0.2)) {
|
||||
selectedDate = 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: String(appLoc: "指标异常"))
|
||||
legendItem(color: Tj.Palette.amber, label: String(appLoc: "症状持续中"))
|
||||
legendItem(color: Tj.Palette.ink2, label: String(appLoc: "报告归档"))
|
||||
legendItem(color: Tj.Palette.leaf, label: String(appLoc: "正常"))
|
||||
}
|
||||
}
|
||||
.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
|
||||
if mode == .month {
|
||||
if calendar.isDate(next, equalTo: .now, toGranularity: .month) {
|
||||
selectedDate = .now
|
||||
} else if let first = calendar.dateInterval(of: .month, for: next)?.start {
|
||||
selectedDate = first
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CalendarOverviewView(onClose: {})
|
||||
.modelContainer(for: [
|
||||
Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self
|
||||
], inMemory: true)
|
||||
}
|
||||
@@ -299,7 +299,8 @@ struct UnifiedCaptureFlow: View {
|
||||
range: ind.range,
|
||||
status: ind.status,
|
||||
capturedAt: final.reportDate,
|
||||
report: report
|
||||
report: report,
|
||||
source: .report
|
||||
)
|
||||
ctx.insert(i)
|
||||
}
|
||||
|
||||
176
康康/Features/Home/HomeCalendarCard.swift
Normal file
176
康康/Features/Home/HomeCalendarCard.swift
Normal file
@@ -0,0 +1,176 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
/// 主页「健康日历」卡:当前一周横条 + 本月记录摘要。
|
||||
/// 点整卡或某一天 → 打开 CalendarOverviewView 看月/年总览。自包含 @Query(对齐 TodayRemindersCard)。
|
||||
struct HomeCalendarCard: 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]
|
||||
|
||||
/// 打开总览时定位的日期(nil = 不展示)。
|
||||
@State private var openDay: SelectedDay?
|
||||
|
||||
private let calendar: Calendar = {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.firstWeekday = 2
|
||||
c.locale = Locale.current
|
||||
return c
|
||||
}()
|
||||
|
||||
@MainActor
|
||||
private var data: CalendarData {
|
||||
CalendarData.build(
|
||||
indicators: indicators,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
symptoms: symptoms
|
||||
)
|
||||
}
|
||||
|
||||
/// 本周一 → 本周日。
|
||||
private var weekDays: [Date] {
|
||||
let today = calendar.startOfDay(for: .now)
|
||||
let weekdayIndex = (calendar.component(.weekday, from: today) - calendar.firstWeekday + 7) % 7
|
||||
guard let monday = calendar.date(byAdding: .day, value: -weekdayIndex, to: today) else {
|
||||
return []
|
||||
}
|
||||
return (0..<7).compactMap { calendar.date(byAdding: .day, value: $0, to: monday) }
|
||||
}
|
||||
|
||||
/// 本月有记录的天数(指标/报告/日记/症状任一)。
|
||||
private var daysWithRecordsThisMonth: Int {
|
||||
guard let interval = calendar.dateInterval(of: .month, for: .now) else { return 0 }
|
||||
let count = calendar.range(of: .day, in: .month, for: .now)?.count ?? 30
|
||||
var n = 0
|
||||
for i in 0..<count {
|
||||
guard let d = calendar.date(byAdding: .day, value: i, to: interval.start) else { continue }
|
||||
if data.marks(for: d, calendar: calendar).hasAnyEvent ||
|
||||
!data.ranges(touching: d, calendar: calendar).isEmpty {
|
||||
n += 1
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
header
|
||||
weekStrip
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.tjCard(bordered: true)
|
||||
.padding(.bottom, 18)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { openDay = SelectedDay(date: .now) }
|
||||
.fullScreenCover(item: $openDay) { day in
|
||||
CalendarOverviewView(initialDate: day.date, onClose: { openDay = nil })
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text("健康日历")
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
HStack(spacing: 3) {
|
||||
Text(summaryLine)
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryLine: String {
|
||||
let n = daysWithRecordsThisMonth
|
||||
return n > 0 ? String(appLoc: "本月 \(n) 天有记录") : String(appLoc: "本月暂无记录")
|
||||
}
|
||||
|
||||
private var weekStrip: some View {
|
||||
HStack(spacing: 6) {
|
||||
ForEach(weekDays, id: \.self) { day in
|
||||
dayCell(day)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dayCell(_ day: Date) -> some View {
|
||||
let marks = data.marks(for: day, calendar: calendar)
|
||||
let ranges = data.ranges(touching: day, calendar: calendar)
|
||||
let isToday = calendar.isDateInToday(day)
|
||||
let hasSymptom = !ranges.isEmpty
|
||||
|
||||
return Button {
|
||||
openDay = SelectedDay(date: day)
|
||||
} label: {
|
||||
VStack(spacing: 5) {
|
||||
Text(weekdayLabel(day))
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.fill(cellFill(isToday: isToday, hasSymptom: hasSymptom))
|
||||
if isToday {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.strokeBorder(Tj.Palette.ink, lineWidth: 1.2)
|
||||
}
|
||||
Text("\(calendar.component(.day, from: day))")
|
||||
.font(.system(size: 14, weight: isToday ? .bold : .regular))
|
||||
.foregroundStyle(isToday ? Tj.Palette.ink : Tj.Palette.text)
|
||||
}
|
||||
.frame(height: 38)
|
||||
marksDots(marks)
|
||||
.frame(height: 5)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func marksDots(_ marks: DayMarks) -> some View {
|
||||
HStack(spacing: 2) {
|
||||
if marks.abnormalCount > 0 {
|
||||
dot(Tj.Palette.brick)
|
||||
} else if marks.normalCount > 0 {
|
||||
dot(Tj.Palette.leaf)
|
||||
}
|
||||
if marks.reportCount > 0 { dot(Tj.Palette.ink2) }
|
||||
if marks.diaryCount > 0 { dot(Tj.Palette.text3.opacity(0.7)) }
|
||||
}
|
||||
}
|
||||
|
||||
private func dot(_ color: Color) -> some View {
|
||||
Circle().fill(color).frame(width: 4, height: 4)
|
||||
}
|
||||
|
||||
private func cellFill(isToday: Bool, hasSymptom: Bool) -> Color {
|
||||
if hasSymptom { return Tj.Palette.amber.opacity(0.18) }
|
||||
if isToday { return Tj.Palette.sand2 }
|
||||
return Tj.Palette.sand2.opacity(0.5)
|
||||
}
|
||||
|
||||
private func weekdayLabel(_ day: Date) -> String {
|
||||
let labels = [
|
||||
String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"),
|
||||
String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"),
|
||||
String(appLoc: "日")
|
||||
]
|
||||
let idx = (calendar.component(.weekday, from: day) - calendar.firstWeekday + 7) % 7
|
||||
return labels[idx]
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ struct HomeView: View {
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 18)
|
||||
|
||||
HomeCalendarCard()
|
||||
|
||||
TodayRemindersCard()
|
||||
|
||||
OngoingSymptomsCard()
|
||||
|
||||
@@ -191,7 +191,8 @@ struct QuickRegionCaptureFlow: View {
|
||||
unit: item.unit.trimmingCharacters(in: .whitespaces),
|
||||
range: item.range.trimmingCharacters(in: .whitespaces),
|
||||
status: item.status,
|
||||
capturedAt: capturedAt
|
||||
capturedAt: capturedAt,
|
||||
source: .quickCapture
|
||||
)
|
||||
ctx.insert(indicator)
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ struct TimelineEntry: Identifiable, Hashable {
|
||||
if let report = i.report {
|
||||
return String(appLoc: "指标 · \(report.title)")
|
||||
}
|
||||
return String(appLoc: "异常项快拍")
|
||||
return i.source.label
|
||||
}
|
||||
|
||||
private static func indicatorValue(_ i: Indicator) -> String {
|
||||
|
||||
@@ -196,7 +196,7 @@ struct TimelineEntryDetailView: View {
|
||||
divider
|
||||
if !i.range.isEmpty { field(String(appLoc: "参考范围"), i.range) }
|
||||
field(String(appLoc: "记录时间"), Self.dateTimeText(i.capturedAt))
|
||||
field(String(appLoc: "来源"), i.report?.title ?? String(appLoc: "异常项快拍"))
|
||||
field(String(appLoc: "来源"), i.report?.title ?? i.source.label)
|
||||
if let note = i.note, !note.isEmpty { field(String(appLoc: "备注"), note) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,11 @@ import SwiftUI
|
||||
import SwiftData
|
||||
import Foundation
|
||||
|
||||
/// 趋势桶的来源类别。
|
||||
/// - `.monitor`:长期监测预设 / 自定义 / 血压(按 seriesKey 分组)。
|
||||
/// - `.lab`:任意出现 ≥2 次的化验/手动/报告指标(按 name+unit 分组,无 seriesKey)。
|
||||
enum SeriesKind { case monitor, lab }
|
||||
|
||||
/// 长期监测系列在 Trends 折线图里的展示桶。
|
||||
/// 单系列(血糖/体重/...)= 1 个 SeriesLine;血压特殊 = 收缩 + 舒张 2 条线同卡。
|
||||
struct SeriesBucket: Identifiable {
|
||||
@@ -10,6 +15,7 @@ struct SeriesBucket: Identifiable {
|
||||
let unit: String
|
||||
let lines: [SeriesLine]
|
||||
let latestDate: Date
|
||||
let kind: SeriesKind
|
||||
|
||||
struct SeriesLine: Identifiable {
|
||||
let id: String
|
||||
@@ -68,9 +74,79 @@ extension SeriesBucket {
|
||||
}
|
||||
}
|
||||
|
||||
// —— lab 段:任何没有 seriesKey 的指标,按 name+unit 归并;同名出现 ≥minPoints 次即成趋势。
|
||||
var labBuckets: [String: [Indicator]] = [:]
|
||||
for i in indicators {
|
||||
if let key = i.seriesKey, !key.isEmpty { continue } // seriesKey 指标只进 monitor 段
|
||||
let nk = normalizedKey(name: i.name, unit: i.unit)
|
||||
guard !nk.isEmpty else { continue }
|
||||
labBuckets[nk, default: []].append(i)
|
||||
}
|
||||
for (_, items) in labBuckets {
|
||||
guard items.count >= minPoints else { continue }
|
||||
if let bucket = buildLab(items: items) {
|
||||
results.append(bucket)
|
||||
}
|
||||
}
|
||||
|
||||
return results.sorted { $0.latestDate > $1.latestDate }
|
||||
}
|
||||
|
||||
/// name+unit 归一化:trim + 小写 + 折叠内部空白。空名返回空串(调用方跳过)。
|
||||
static func normalizedKey(name: String, unit: String) -> String {
|
||||
func norm(_ s: String) -> String {
|
||||
s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.components(separatedBy: .whitespacesAndNewlines)
|
||||
.filter { !$0.isEmpty }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
let n = norm(name)
|
||||
guard !n.isEmpty else { return "" }
|
||||
return n + "|" + norm(unit)
|
||||
}
|
||||
|
||||
/// 解析参考范围字符串 → ClosedRange。支持 "3.9-6.1" / "3.9~6.1" / "3.9 - 6.1"。
|
||||
/// 单边("<5.2" / ">40" / "≤120")暂返回 nil(图不画带,正常)。
|
||||
static func parseRange(_ raw: String) -> ClosedRange<Double>? {
|
||||
let s = raw.replacingOccurrences(of: "~", with: "~")
|
||||
.replacingOccurrences(of: "~", with: "-")
|
||||
guard let regex = try? NSRegularExpression(
|
||||
pattern: #"(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)"#
|
||||
) else { return nil }
|
||||
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
||||
guard let m = regex.firstMatch(in: s, range: range),
|
||||
let r1 = Range(m.range(at: 1), in: s),
|
||||
let r2 = Range(m.range(at: 2), in: s),
|
||||
let lo = Double(s[r1]), let hi = Double(s[r2]),
|
||||
lo <= hi else { return nil }
|
||||
return lo...hi
|
||||
}
|
||||
|
||||
private static func buildLab(items: [Indicator]) -> SeriesBucket? {
|
||||
let sorted = items.sorted { $0.capturedAt < $1.capturedAt }
|
||||
guard let latest = sorted.last else { return nil }
|
||||
let points = sorted.compactMap { point(from: $0) }
|
||||
guard points.count >= 2 else { return nil } // 值无法解析为数字的会被丢弃,可能不足 2 点
|
||||
|
||||
let line = SeriesLine(
|
||||
id: "lab:\(latest.name)",
|
||||
seriesKey: "lab:\(latest.name)",
|
||||
label: nil,
|
||||
color: Tj.Palette.ink,
|
||||
points: points,
|
||||
referenceRange: parseRange(latest.range)
|
||||
)
|
||||
return SeriesBucket(
|
||||
id: "lab:\(normalizedKey(name: latest.name, unit: latest.unit))",
|
||||
title: latest.name,
|
||||
unit: latest.unit,
|
||||
lines: [line],
|
||||
latestDate: latest.capturedAt,
|
||||
kind: .lab
|
||||
)
|
||||
}
|
||||
|
||||
private static func buildSingle(key: String,
|
||||
items: [Indicator],
|
||||
profile: UserProfile?,
|
||||
@@ -106,7 +182,8 @@ extension SeriesBucket {
|
||||
title: title,
|
||||
unit: unit,
|
||||
lines: [line],
|
||||
latestDate: latest.capturedAt
|
||||
latestDate: latest.capturedAt,
|
||||
kind: .monitor
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,7 +225,8 @@ extension SeriesBucket {
|
||||
title: String(appLoc: "血压"),
|
||||
unit: "mmHg",
|
||||
lines: lines,
|
||||
latestDate: latest
|
||||
latestDate: latest,
|
||||
kind: .monitor
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
448
康康/Features/Trends/TrendDetailView.swift
Normal file
448
康康/Features/Trends/TrendDetailView.swift
Normal file
@@ -0,0 +1,448 @@
|
||||
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<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(.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
|
||||
}
|
||||
}
|
||||
}
|
||||
113
康康/Features/Trends/TrendRow.swift
Normal file
113
康康/Features/Trends/TrendRow.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
/// 趋势列表的紧凑行:名称 + 条数/跨度 + mini sparkline + 最新值。
|
||||
struct TrendRow: View {
|
||||
let bucket: SeriesBucket
|
||||
|
||||
private var allPoints: [SeriesBucket.Point] {
|
||||
bucket.lines.flatMap(\.points)
|
||||
}
|
||||
|
||||
private var pointCount: Int { allPoints.count }
|
||||
|
||||
private var anyLatestAbnormal: Bool {
|
||||
bucket.lines.contains { ($0.latestPoint?.status ?? .normal) != .normal }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(bucket.title)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Text(subtitle)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
sparkline
|
||||
.frame(width: 76, height: 34)
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(latestValue)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundStyle(anyLatestAbnormal ? Tj.Palette.brick : Tj.Palette.text)
|
||||
.lineLimit(1)
|
||||
Text(bucket.unit)
|
||||
.font(.system(size: 9, design: .monospaced))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.fixedSize()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.padding(14)
|
||||
.frame(maxWidth: .infinity)
|
||||
.tjCard(bordered: true)
|
||||
}
|
||||
|
||||
private var sparkline: some View {
|
||||
Chart {
|
||||
ForEach(bucket.lines) { line in
|
||||
ForEach(line.points) { p in
|
||||
LineMark(
|
||||
x: .value("t", p.date),
|
||||
y: .value(line.label ?? bucket.title, p.value),
|
||||
series: .value("s", line.id)
|
||||
)
|
||||
.foregroundStyle(line.color)
|
||||
.interpolationMethod(.catmullRom)
|
||||
.lineStyle(StrokeStyle(lineWidth: 1.6))
|
||||
}
|
||||
}
|
||||
// 最新点高亮
|
||||
ForEach(bucket.lines) { line in
|
||||
if let p = line.latestPoint {
|
||||
PointMark(
|
||||
x: .value("t", p.date),
|
||||
y: .value("v", p.value)
|
||||
)
|
||||
.foregroundStyle(p.status == .normal ? line.color : Tj.Palette.brick)
|
||||
.symbolSize(28)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartXAxis(.hidden)
|
||||
.chartYAxis(.hidden)
|
||||
.chartLegend(.hidden)
|
||||
}
|
||||
|
||||
private var subtitle: String {
|
||||
"\(pointCount) 条 · 近 \(spanLabel)"
|
||||
}
|
||||
|
||||
private var spanLabel: String {
|
||||
let dates = allPoints.map(\.date)
|
||||
guard let lo = dates.min(), let hi = dates.max() else { return "—" }
|
||||
let days = Calendar.current.dateComponents([.day], from: lo, to: hi).day ?? 0
|
||||
if days <= 0 { return String(appLoc: "今天") }
|
||||
if days < 30 { return String(appLoc: "\(days) 天") }
|
||||
if days < 365 { return String(appLoc: "\(days / 30) 个月") }
|
||||
return String(appLoc: "\(days / 365) 年")
|
||||
}
|
||||
|
||||
private var latestValue: String {
|
||||
let parts = bucket.lines.compactMap { line -> String? in
|
||||
guard let p = line.latestPoint else { return nil }
|
||||
return formatValue(p.value)
|
||||
}
|
||||
return parts.joined(separator: "/")
|
||||
}
|
||||
|
||||
private func formatValue(_ v: Double) -> String {
|
||||
v.truncatingRemainder(dividingBy: 1) == 0
|
||||
? String(format: "%.0f", v)
|
||||
: String(format: "%.1f", v)
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,15 @@
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
enum CalendarMode: String, CaseIterable, Identifiable {
|
||||
case month, year
|
||||
var id: String { rawValue }
|
||||
var label: String {
|
||||
switch self {
|
||||
case .month: return String(appLoc: "月")
|
||||
case .year: return String(appLoc: "年")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 趋势 Tab。日历已迁至主页;此页专注「时间序列」:
|
||||
/// 任何出现 ≥2 次的指标都能成趋势,分「长期监测」(seriesKey)与「化验指标」(按名归并)两段。
|
||||
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
|
||||
/// 选中的当天 — 默认选今天,日历下方 inline 显示该日详情
|
||||
@State private var selectedDate: Date = .now
|
||||
|
||||
private var profile: UserProfile? { profiles.first }
|
||||
|
||||
private var seriesBuckets: [SeriesBucket] {
|
||||
@@ -42,267 +18,81 @@ struct TrendsView: View {
|
||||
customMetrics: customMetrics)
|
||||
}
|
||||
|
||||
private let calendar: Calendar = {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.firstWeekday = 2
|
||||
c.locale = Locale.current
|
||||
return c
|
||||
}()
|
||||
|
||||
@MainActor
|
||||
private var data: CalendarData {
|
||||
CalendarData.build(
|
||||
indicators: indicators,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
symptoms: symptoms
|
||||
)
|
||||
private var monitorBuckets: [SeriesBucket] {
|
||||
seriesBuckets.filter { $0.kind == .monitor }
|
||||
}
|
||||
private var labBuckets: [SeriesBucket] {
|
||||
seriesBuckets.filter { $0.kind == .lab }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
header.padding(.top, 4)
|
||||
modeSwitch
|
||||
anchorBar
|
||||
calendarBody
|
||||
legend
|
||||
if mode == .month {
|
||||
dayDetailInline
|
||||
NavigationStack {
|
||||
ScrollView(showsIndicators: false) {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
header.padding(.top, 4)
|
||||
if seriesBuckets.isEmpty {
|
||||
emptyState
|
||||
} else {
|
||||
if !monitorBuckets.isEmpty {
|
||||
section(title: String(appLoc: "长期监测"), buckets: monitorBuckets)
|
||||
}
|
||||
if !labBuckets.isEmpty {
|
||||
section(title: String(appLoc: "化验指标趋势"), buckets: labBuckets)
|
||||
}
|
||||
}
|
||||
}
|
||||
seriesSection
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 24)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.bottom, 24)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
.navigationBarHidden(true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||
}
|
||||
|
||||
/// 日历下方 inline 显示选中天的详情(symptoms / indicators / reports / diaries)
|
||||
private var dayDetailInline: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
DayDetailContent(
|
||||
date: selectedDate,
|
||||
indicators: indicators,
|
||||
reports: reports,
|
||||
diaries: diaries,
|
||||
symptoms: symptoms,
|
||||
showHeader: true
|
||||
)
|
||||
.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)
|
||||
)
|
||||
.animation(.snappy(duration: 0.2), value: selectedDate)
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text("趋势")
|
||||
.font(.tjTitle(26))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Spacer()
|
||||
Button {
|
||||
withAnimation(.snappy(duration: 0.2)) {
|
||||
anchor = .now
|
||||
selectedDate = .now
|
||||
}
|
||||
} label: {
|
||||
Text("回到今天")
|
||||
Text("趋势")
|
||||
.font(.tjTitle(26))
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
}
|
||||
|
||||
private func section(title: String, buckets: [SeriesBucket]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(alignment: .lastTextBaseline) {
|
||||
Text(title)
|
||||
.font(.tjH2())
|
||||
.foregroundStyle(Tj.Palette.text)
|
||||
Text("\(buckets.count) 项")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
Spacer()
|
||||
}
|
||||
.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
|
||||
VStack(spacing: 12) {
|
||||
ForEach(buckets) { bucket in
|
||||
NavigationLink {
|
||||
TrendDetailView(bucket: bucket)
|
||||
} label: {
|
||||
TrendRow(bucket: bucket)
|
||||
}
|
||||
} 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 style: Date.FormatStyle = mode == .month
|
||||
? .dateTime.year().month()
|
||||
: .dateTime.year()
|
||||
return anchor.formatted(style)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var calendarBody: some View {
|
||||
switch mode {
|
||||
case .month:
|
||||
CalendarMonthGrid(monthAnchor: anchor, data: data, selectedDate: selectedDate) { day in
|
||||
withAnimation(.snappy(duration: 0.2)) {
|
||||
selectedDate = 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
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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)
|
||||
private var emptyState: some View {
|
||||
VStack(spacing: 12) {
|
||||
TjPlaceholder(label: String(appLoc: "还没有可成趋势的指标"))
|
||||
.frame(height: 120)
|
||||
.frame(maxWidth: 260)
|
||||
Text("同一指标记录满 2 次后,会在这里出现时间序列")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(Tj.Palette.text3)
|
||||
HStack(spacing: 14) {
|
||||
legendItem(color: Tj.Palette.brick, label: String(appLoc: "指标异常"))
|
||||
legendItem(color: Tj.Palette.amber, label: String(appLoc: "症状持续中"))
|
||||
legendItem(color: Tj.Palette.ink2, label: String(appLoc: "报告归档"))
|
||||
legendItem(color: Tj.Palette.leaf, label: String(appLoc: "正常"))
|
||||
}
|
||||
}
|
||||
.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
|
||||
// 翻月时把 selection 跟着走:同月内停在今天(如果是当前月)或 1 号
|
||||
if mode == .month {
|
||||
if calendar.isDate(next, equalTo: .now, toGranularity: .month) {
|
||||
selectedDate = .now
|
||||
} else if let first = calendar.dateInterval(of: .month, for: next)?.start {
|
||||
selectedDate = first
|
||||
}
|
||||
}
|
||||
}
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.top, 60)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user