import SwiftUI import SwiftData struct ArchiveListView: 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(sort: \HealthExport.createdAt, order: .reverse) private var exports: [HealthExport] @Query(sort: \CustomReminder.updatedAt, order: .reverse) private var customReminders: [CustomReminder] @Query(sort: \MetricReminder.updatedAt, order: .reverse) private var metricReminders: [MetricReminder] /// 记录页内的 push 目的地。用单个 `navigationDestination(item:)` 驱动—— /// 多个 `navigationDestination(isPresented:)` 并存时 SwiftUI 行为未定义(会误触发)。 private enum Route: Hashable { case exports, reminders } @State private var filter: TimelineKind? = nil @State private var endingSymptom: Symptom? @State private var selectedEntry: TimelineEntry? @State private var showExportSheet = false @State private var route: Route? @MainActor private var allEntries: [TimelineEntry] { let mapped = TimelineEntry.from(indicators: indicators) + reports.map(TimelineEntry.from(report:)) + diaries.map(TimelineEntry.from(diary:)) + symptoms.map(TimelineEntry.from(symptom:)) let filtered = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped return filtered.sorted { $0.date > $1.date } } private var grouped: [(section: DateSection, items: [TimelineEntry])] { TimelineGrouping.group(allEntries) } private var totalCount: Int { allEntries.count } var body: some View { NavigationStack { content .navigationDestination(item: $route) { route in switch route { case .exports: HealthExportListView() case .reminders: RemindersListView() } } } } private var content: some View { VStack(alignment: .leading, spacing: 0) { header .padding(.horizontal, 20) .padding(.top, 8) .padding(.bottom, 14) if reminderTotal > 0 { reminderBoard .padding(.horizontal, 20) .padding(.bottom, 14) } filterChips .padding(.bottom, 14) if allEntries.isEmpty { emptyState } else { ScrollView(showsIndicators: false) { LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) { ForEach(grouped, id: \.section) { group in Section { VStack(spacing: 10) { ForEach(group.items) { entry in rowView(for: entry) } } .padding(.horizontal, 20) } header: { sectionHeader(group.section, count: group.items.count) } } } .padding(.bottom, 24) } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Tj.Palette.sand.ignoresSafeArea()) .sheet(item: $endingSymptom) { sym in SymptomEndSheet(symptom: sym) } .sheet(item: $selectedEntry) { entry in if let d = detail(for: entry) { TimelineEntryDetailView(detail: d) } } .fullScreenCover(isPresented: $showExportSheet) { HealthExportSheet() } } @ViewBuilder private func rowView(for entry: TimelineEntry) -> some View { if entry.kind == .symptom, entry.isOngoing, let sym = symptoms.first(where: { "symptom-\($0.persistentModelID)" == entry.id }) { // 进行中症状:点 → 标记结束 sheet(沿用原交互) Button { endingSymptom = sym } label: { TimelineRow(entry: entry) } .buttonStyle(.plain) } else { // 其余条目(报告/指标/日记/已结束症状):点 → 只读详情 Button { if detail(for: entry) != nil { selectedEntry = entry } } label: { TimelineRow(entry: entry) } .buttonStyle(.plain) } } /// 把时间线条目反查回源记录。逻辑统一收敛到 `TimelineDetail.resolve`(主页/档案库共用)。 private func detail(for entry: TimelineEntry) -> TimelineDetail? { TimelineDetail.resolve(for: entry, indicators: indicators, reports: reports, diaries: diaries, symptoms: symptoms) } private var header: some View { HStack(alignment: .lastTextBaseline) { Text("记录") .font(.tjTitle(26)) .foregroundStyle(Tj.Palette.text) Text(totalCount == 0 ? "" : String(appLoc: "\(totalCount) 条")) .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) Spacer() Menu { Button { showExportSheet = true } label: { Label("生成新导出", systemImage: "doc.text.below.ecg") } if !exports.isEmpty { Button { route = .exports } label: { Label("我的导出 · \(exports.count) 份", systemImage: "clock.arrow.circlepath") } } } label: { HStack(spacing: 6) { Image(systemName: "doc.text.below.ecg") .font(.system(size: 12, weight: .semibold)) Text("导出身体档案") .font(.system(size: 13, weight: .semibold)) Image(systemName: "chevron.down") .font(.system(size: 9, weight: .semibold)) } .foregroundStyle(Tj.Palette.paper) .padding(.horizontal, 12) .padding(.vertical, 7) .background(Capsule().fill(Tj.Palette.ink)) } } } // MARK: - 提醒任务汇总卡 /// 两类提醒(自由 + 指标记录)合计,含已关闭。 private var reminderTotal: Int { customReminders.count + metricReminders.count } private var reminderEnabledCount: Int { customReminders.filter(\.enabled).count + metricReminders.filter(\.enabled).count } /// 按 updatedAt 倒序合并,取前 3 条标题做预览(标题是用户数据,不本地化)。 private var reminderTitlePreview: [String] { let merged: [(title: String, at: Date)] = customReminders.map { ($0.title, $0.updatedAt) } + metricReminders.map { ($0.displayName, $0.updatedAt) } return merged.sorted { $0.at > $1.at }.prefix(3).map(\.title) } private var reminderCountLabel: String { reminderEnabledCount == reminderTotal ? String(appLoc: "\(reminderTotal) 个提醒任务") : String(appLoc: "\(reminderTotal) 个提醒任务 · \(reminderEnabledCount) 个开启中") } private var reminderTitleLine: String { let joined = reminderTitlePreview.joined(separator: " · ") return reminderTotal > reminderTitlePreview.count ? joined + " …" : joined } /// 点击进提醒中心(RemindersListView)统一管理;卡片本身只展示。 private var reminderBoard: some View { Button { route = .reminders } label: { HStack(spacing: 12) { ZStack { Circle().fill(reminderEnabledCount > 0 ? Tj.Palette.amber.opacity(0.25) : Tj.Palette.sand2) Image(systemName: "bell.fill") .font(.system(size: 16)) .foregroundStyle(reminderEnabledCount > 0 ? Tj.Palette.ink : Tj.Palette.text3) } .frame(width: 36, height: 36) VStack(alignment: .leading, spacing: 2) { Text(reminderCountLabel) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) if !reminderTitlePreview.isEmpty { Text(reminderTitleLine) .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) .lineLimit(1) } } Spacer(minLength: 0) Image(systemName: "chevron.right") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) } .padding(14) .contentShape(Rectangle()) .tjCard() } .buttonStyle(.plain) } private var filterChips: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { chip(label: String(appLoc: "全部"), selected: filter == nil) { filter = nil } ForEach(TimelineKind.allCases) { kind in chip(label: kind.label, selected: filter == kind) { filter = filter == kind ? nil : kind } } } .padding(.horizontal, 20) } } private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) .font(.system(size: 13, weight: selected ? .semibold : .regular)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text) .padding(.horizontal, 14) .padding(.vertical, 8) .background( Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper) ) .overlay( Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1) ) } .buttonStyle(.plain) } private func sectionHeader(_ section: DateSection, count: Int) -> some View { HStack { Text(section.label) .font(.system(size: 12, weight: .semibold)) .tracking(0.5) .foregroundStyle(Tj.Palette.text2) Rectangle() .fill(Tj.Palette.lineSoft) .frame(height: 1) Text("\(count)") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } .padding(.horizontal, 20) .padding(.vertical, 8) .background(Tj.Palette.sand) } private var emptyState: some View { VStack(spacing: 14) { Spacer() TjPlaceholder(label: String(appLoc: "还没有任何记录\n点底部 + 号开始")) .frame(width: 240, height: 140) Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录")) .font(.system(size: 13)) .foregroundStyle(Tj.Palette.text3) Spacer() } .frame(maxWidth: .infinity) } } #Preview { ArchiveListView() .modelContainer(for: [ Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self, HealthExport.self, ChatTurn.self, UserProfile.self, MetricReminder.self, CustomMonitorMetric.self ], inMemory: true) }