276 lines
9.8 KiB
Swift
276 lines
9.8 KiB
Swift
import SwiftUI
|
||
import SwiftData
|
||
|
||
struct HomeView: View {
|
||
/// 跳记录页;传 filter 时预选对应分类 chip(报告档案卡传 `.report`,统计磁贴按类别预选)。
|
||
var onTapArchive: (TimelineKind?) -> Void = { _ in }
|
||
|
||
@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]
|
||
|
||
/// 点迷你趋势卡 → 打开同类聚合详情(历次翻页 + 趋势,与档案库 C1 同款)。
|
||
@State private var selectedGroup: IndicatorGroup?
|
||
|
||
private var profile: UserProfile? { profiles.first }
|
||
|
||
/// 主页只挑前 3 条最有代表性的趋势:长期监测优先,其次化验指标。数据不足时整段隐藏。
|
||
@MainActor
|
||
private var featuredBuckets: [SeriesBucket] {
|
||
let all = SeriesBucket.build(from: indicators,
|
||
profile: profile,
|
||
customMetrics: customMetrics)
|
||
let monitor = all.filter { $0.kind == .monitor }
|
||
let lab = all.filter { $0.kind == .lab }
|
||
return Array((monitor + lab).prefix(3))
|
||
}
|
||
|
||
private var ongoingSymptomCount: Int { symptoms.filter { $0.endedAt == nil }.count }
|
||
|
||
var body: some View {
|
||
ScrollView(showsIndicators: false) {
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
greeting
|
||
.padding(.top, 4)
|
||
.padding(.bottom, 18)
|
||
|
||
HomeCalendarCard()
|
||
.padding(.bottom, 18)
|
||
|
||
overviewSection
|
||
.padding(.bottom, 18)
|
||
|
||
let buckets = featuredBuckets
|
||
if !buckets.isEmpty {
|
||
trendsSection(buckets)
|
||
.padding(.bottom, 18)
|
||
}
|
||
|
||
TodayRemindersCard()
|
||
|
||
OngoingSymptomsCard()
|
||
.padding(.bottom, 18)
|
||
|
||
archiveSection
|
||
}
|
||
.padding(.horizontal, 20)
|
||
.padding(.bottom, 20)
|
||
}
|
||
.background(Tj.Palette.sand.ignoresSafeArea())
|
||
.sheet(item: $selectedGroup) { group in
|
||
IndicatorSeriesDetailView(group: group)
|
||
}
|
||
}
|
||
|
||
// MARK: - 问候
|
||
|
||
private var greeting: some View {
|
||
let t = TimeOfDay.current
|
||
return HStack(alignment: .center, spacing: 14) {
|
||
// 时段徽章:暖色圆底 + 对应图标(晨/午/夜),随时段自动切换。
|
||
ZStack {
|
||
Circle().fill(Tj.Palette.sand2)
|
||
Image(systemName: t.icon)
|
||
.font(.tjScaled( 22))
|
||
.foregroundStyle(Tj.Palette.amber)
|
||
}
|
||
.frame(width: 52, height: 52)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(todayLine)
|
||
.font(.tjScaled( 11))
|
||
.tracking(1)
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
// 衬线问候,编辑感更强。
|
||
Text(t.word)
|
||
.font(.tjScaled( 28, weight: .semibold, design: .serif))
|
||
.foregroundStyle(Tj.Palette.text)
|
||
Text(t.subtitle)
|
||
.font(.tjScaled( 12))
|
||
.foregroundStyle(Tj.Palette.text2)
|
||
}
|
||
|
||
Spacer(minLength: 8)
|
||
|
||
TjLockChip()
|
||
.padding(.top, 2)
|
||
}
|
||
}
|
||
|
||
private var todayLine: String {
|
||
let now = Date()
|
||
let day = now.formatted(.dateTime.month().day())
|
||
let weekday = now.formatted(.dateTime.weekday(.abbreviated))
|
||
return "\(day) · \(weekday)"
|
||
}
|
||
|
||
/// 一天三段:驱动问候语、副标题、徽章图标,保证三者一致。
|
||
private enum TimeOfDay {
|
||
case morning, afternoon, evening
|
||
|
||
static var current: TimeOfDay {
|
||
switch Calendar.current.component(.hour, from: Date()) {
|
||
case 5..<12: return .morning
|
||
case 12..<18: return .afternoon
|
||
default: return .evening
|
||
}
|
||
}
|
||
|
||
var word: String {
|
||
switch self {
|
||
case .morning: return String(appLoc: "早安")
|
||
case .afternoon: return String(appLoc: "下午好")
|
||
case .evening: return String(appLoc: "晚上好")
|
||
}
|
||
}
|
||
|
||
var subtitle: String {
|
||
switch self {
|
||
case .morning: return String(appLoc: "新的一天,慢慢来")
|
||
case .afternoon: return String(appLoc: "记得起身活动一下")
|
||
case .evening: return String(appLoc: "夜深了,记得早点休息")
|
||
}
|
||
}
|
||
|
||
var icon: String {
|
||
switch self {
|
||
case .morning: return "sun.max.fill"
|
||
case .afternoon: return "sun.haze.fill"
|
||
case .evening: return "moon.stars.fill"
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 数据概览磁贴(2×2,大数字 + 图标,点进对应分类)
|
||
|
||
private var overviewSection: some View {
|
||
LazyVGrid(columns: [GridItem(.flexible(), spacing: 12),
|
||
GridItem(.flexible(), spacing: 12)], spacing: 12) {
|
||
statTile(icon: "doc.fill", value: reports.count,
|
||
label: String(appLoc: "报告"), tint: Tj.Palette.ink) {
|
||
onTapArchive(.report)
|
||
}
|
||
statTile(icon: "drop.fill", value: indicators.count,
|
||
label: String(appLoc: "指标"), tint: Tj.Palette.brick) {
|
||
onTapArchive(.indicator)
|
||
}
|
||
statTile(icon: "pencil", value: diaries.count,
|
||
label: String(appLoc: "日记"), tint: Tj.Palette.leaf) {
|
||
onTapArchive(.diary)
|
||
}
|
||
statTile(icon: "waveform.path.ecg", value: symptoms.count,
|
||
label: ongoingSymptomCount > 0
|
||
? String(appLoc: "症状 · \(ongoingSymptomCount) 进行中")
|
||
: String(appLoc: "症状"),
|
||
tint: Tj.Palette.amber) {
|
||
onTapArchive(.symptom)
|
||
}
|
||
}
|
||
}
|
||
|
||
private func statTile(icon: String, value: Int, label: String,
|
||
tint: Color, action: @escaping () -> Void) -> some View {
|
||
Button(action: action) {
|
||
HStack(spacing: 12) {
|
||
ZStack {
|
||
Circle().fill(tint.opacity(0.15))
|
||
Image(systemName: icon)
|
||
.font(.tjScaled( 16, weight: .semibold))
|
||
.foregroundStyle(tint)
|
||
}
|
||
.frame(width: 40, height: 40)
|
||
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text("\(value)")
|
||
.font(.tjScaled( 22, weight: .bold, design: .rounded))
|
||
.foregroundStyle(Tj.Palette.text)
|
||
Text(label)
|
||
.font(.tjScaled( 11))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
.lineLimit(1)
|
||
.minimumScaleFactor(0.85)
|
||
}
|
||
Spacer(minLength: 0)
|
||
}
|
||
.padding(12)
|
||
.frame(maxWidth: .infinity)
|
||
.tjCard()
|
||
.contentShape(Rectangle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
|
||
// MARK: - 健康趋势(迷你折线图,复用趋势页 TrendRow)
|
||
|
||
private func trendsSection(_ buckets: [SeriesBucket]) -> some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("健康趋势")
|
||
.font(.tjH2())
|
||
.foregroundStyle(Tj.Palette.text)
|
||
|
||
VStack(spacing: 12) {
|
||
ForEach(buckets) { bucket in
|
||
Button {
|
||
selectedGroup = group(for: bucket)
|
||
} label: {
|
||
TrendRow(bucket: bucket)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// SeriesBucket → 聚合详情的 IndicatorGroup(与趋势页分组语义一致)。
|
||
private func group(for bucket: SeriesBucket) -> IndicatorGroup {
|
||
if bucket.id == "bp" { return .bloodPressure }
|
||
if bucket.id.hasPrefix("lab:") { return .lab(key: String(bucket.id.dropFirst(4))) }
|
||
return .series(key: bucket.id)
|
||
}
|
||
|
||
// MARK: - 影像档案入口
|
||
|
||
private var archiveSection: some View {
|
||
VStack(alignment: .leading, spacing: 10) {
|
||
Text("影像档案").font(.tjH2()).foregroundStyle(Tj.Palette.text)
|
||
|
||
Button { onTapArchive(.report) } label: {
|
||
HStack(spacing: 14) {
|
||
TjPlaceholder(label: String(appLoc: "档案 · \(reports.count)"))
|
||
.frame(width: 56, height: 56)
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("我的报告档案")
|
||
.font(.tjScaled( 14, weight: .semibold))
|
||
.foregroundStyle(Tj.Palette.text)
|
||
Text("\(reports.count) 份 · \(indicators.count) 项指标 · 端侧加密")
|
||
.font(.tjScaled( 11))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
Spacer()
|
||
Image(systemName: "chevron.right")
|
||
.font(.tjScaled( 14, weight: .medium))
|
||
.foregroundStyle(Tj.Palette.text3)
|
||
}
|
||
.padding(14)
|
||
.tjCard(bordered: true)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
HomeView()
|
||
}
|