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] @Query(sort: \Medication.updatedAt, order: .reverse) private var medications: [Medication] /// 记录页内的 push 目的地。用单个 `navigationDestination(item:)` 驱动—— /// 多个 `navigationDestination(isPresented:)` 并存时 SwiftUI 行为未定义(会误触发)。 private enum Route: Hashable { case exports, reminders, medicationLibrary } @State private var filter: TimelineKind? = nil @State private var endingSymptom: Symptom? @State private var selectedEntry: TimelineEntry? @State private var selectedGroup: IndicatorGroup? @State private var route: Route? /// 顶部搜索:点放大镜展开搜索框,按条目标题(指标/报告/症状/日记名)实时过滤,与分类 chip 叠加。 @State private var searching = false @State private var query = "" @MainActor private var allEntries: [TimelineEntry] { let mapped = TimelineEntry.aggregatedIndicators(indicators) + reports.map(TimelineEntry.from(report:)) + diaries.map(TimelineEntry.from(diary:)) + symptoms.map(TimelineEntry.from(symptom:)) let byKind = filter.map { kind in mapped.filter { $0.kind == kind } } ?? mapped let q = query.trimmingCharacters(in: .whitespaces) let byQuery = q.isEmpty ? byKind : byKind.filter { $0.title.localizedCaseInsensitiveContains(q) } return byQuery.sorted { $0.date > $1.date } } var body: some View { NavigationStack { content .navigationDestination(item: $route) { route in switch route { case .exports: HealthExportListView() case .reminders: RemindersListView() case .medicationLibrary: MedicationLibraryView() } } } } private var content: some View { // 聚合(含血压配对 O(m²))+ 分类/搜索过滤在一次 body 内只算一次。原先 .isEmpty、分组、 // 计数各调一遍 allEntries,等于全表聚合三次;搜索时每次按键都翻三倍,这里收敛成一次。 let entries = allEntries let groups = TimelineGrouping.group(entries) return VStack(alignment: .leading, spacing: 0) { header(total: entries.count) .padding(.horizontal, 20) .padding(.top, 8) .padding(.bottom, 14) if reminderTotal > 0 { reminderBoard .padding(.horizontal, 20) .padding(.bottom, 10) } // 药品库入口:始终显示——它是「管理常用药」的浏览/管理目的地,空库时也要能找到来添加。 medicationBoard .padding(.horizontal, 20) .padding(.bottom, 14) filterChips .padding(.bottom, searching ? 10 : 14) if searching { searchField .padding(.horizontal, 20) .padding(.bottom, 14) } if entries.isEmpty { emptyState } else { ScrollView(showsIndicators: false) { LazyVStack(alignment: .leading, spacing: 18, pinnedViews: [.sectionHeaders]) { ForEach(groups, 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) } } .sheet(item: $selectedGroup) { group in IndicatorSeriesDetailView(group: group) } } @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 { guard let d = detail(for: entry) else { return } switch d { case .indicator(let i): selectedGroup = IndicatorGroup.of(i) case .bloodPressure(let sys, _): selectedGroup = IndicatorGroup.of(sys) default: 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 func header(total: Int) -> some View { HStack(alignment: .lastTextBaseline) { Text("记录") .font(.tjTitle(26)) .foregroundStyle(Tj.Palette.text) Text(total == 0 ? "" : String(appLoc: "\(total) 条")) .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer() if !exports.isEmpty { Button { route = .exports } label: { HStack(spacing: 6) { Image(systemName: "clock.arrow.circlepath") .font(.tjScaled( 12, weight: .semibold)) Text("导出历史") .font(.tjScaled( 13, weight: .semibold)) } .foregroundStyle(Tj.Palette.paper) .padding(.horizontal, 12) .padding(.vertical, 7) .background(Capsule().fill(Tj.Palette.ink)) } .buttonStyle(.plain) } searchToggle } } private var searchToggle: some View { Button { withAnimation(.easeInOut(duration: 0.18)) { searching.toggle() if !searching { query = "" } } } label: { Image(systemName: searching ? "xmark" : "magnifyingglass") .font(.tjScaled( 14, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .frame(width: 32, height: 32) .background(Circle().fill(Tj.Palette.sand2)) } .buttonStyle(.plain) .accessibilityLabel(searching ? String(appLoc: "关闭搜索") : String(appLoc: "搜索记录")) } private var searchField: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") .font(.tjScaled( 13)) .foregroundStyle(Tj.Palette.text3) TextField(String(appLoc: "搜索指标 / 报告 / 症状名"), text: $query) .textInputAutocapitalization(.never) .autocorrectionDisabled() .foregroundStyle(Tj.Palette.text) .tint(Tj.Palette.ink) if !query.isEmpty { Button { query = "" } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(Tj.Palette.text3) } .buttonStyle(.plain) } } .padding(.horizontal, 12) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: 1) ) } // 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(.tjScaled( 16)) .foregroundStyle(reminderEnabledCount > 0 ? Tj.Palette.ink : Tj.Palette.text3) } .frame(width: 36, height: 36) VStack(alignment: .leading, spacing: 2) { Text(reminderCountLabel) .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) if !reminderTitlePreview.isEmpty { Text(reminderTitleLine) .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) .lineLimit(1) } } Spacer(minLength: 0) Image(systemName: "chevron.right") .font(.tjScaled( 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) } .padding(14) .contentShape(Rectangle()) .tjCard() } .buttonStyle(.plain) } // MARK: - 药品库入口卡 /// 主标题:空库「药品库」,有药「药品库 · N 种常用药」。 private var medicationCountLabel: String { medications.isEmpty ? String(appLoc: "药品库") : String(appLoc: "药品库 · \(medications.count) 种常用药") } /// 副标题:空库给引导文案;有药取前 3 个药名预览(药名是用户数据,不本地化)。 private var medicationPreviewLine: String { if medications.isEmpty { return String(appLoc: "拍药盒或手动添加常用药") } let names = medications.prefix(3).map(\.name).joined(separator: " · ") return medications.count > 3 ? names + " …" : names } /// 点击进药品库(MedicationLibraryView,push 形态)统一管理;卡片本身只展示。 private var medicationBoard: some View { Button { route = .medicationLibrary } label: { HStack(spacing: 12) { ZStack { Circle().fill(medications.isEmpty ? Tj.Palette.sand2 : Tj.Palette.leafSoft) Image(systemName: "pills.fill") .font(.tjScaled( 16)) .foregroundStyle(medications.isEmpty ? Tj.Palette.text3 : Tj.Palette.ink) } .frame(width: 36, height: 36) VStack(alignment: .leading, spacing: 2) { Text(medicationCountLabel) .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .lineLimit(1) Text(medicationPreviewLine) .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) .lineLimit(1) } Spacer(minLength: 0) Image(systemName: "chevron.right") .font(.tjScaled( 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(.tjScaled( 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(.tjScaled( 12, weight: .semibold)) .tracking(0.5) .foregroundStyle(Tj.Palette.text2) Rectangle() .fill(Tj.Palette.lineSoft) .frame(height: 1) Text("\(count)") .font(.tjScaled( 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) } .padding(.horizontal, 20) .padding(.vertical, 8) .background(Tj.Palette.sand) } private var emptyState: some View { let q = query.trimmingCharacters(in: .whitespaces) let isSearchMiss = !q.isEmpty return VStack(spacing: 14) { Spacer() TjPlaceholder(label: isSearchMiss ? String(appLoc: "没有匹配「\(q)」的记录") : String(appLoc: "还没有任何记录\n点底部 + 号开始")) .frame(width: 240, height: 140) if !isSearchMiss { Text(filter == nil ? String(appLoc: "记录会按时间归类显示") : String(appLoc: "这个类别下没有记录")) .font(.tjScaled( 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) }