import SwiftUI import SwiftData /// 趋势 Tab。日历已迁至主页;此页专注「时间序列」: /// 任何出现 ≥2 次的指标都能成趋势,分「长期监测」(seriesKey)与「化验指标」(按名归并)两段。 struct TrendsView: View { @Query(sort: \Indicator.capturedAt, order: .reverse) private var indicators: [Indicator] @Query private var profiles: [UserProfile] @Query private var customMetrics: [CustomMonitorMetric] private var profile: UserProfile? { profiles.first } /// 顶部搜索:点放大镜展开搜索框,按指标名(bucket.title)实时过滤两段列表。 @State private var searching = false @State private var query = "" private var seriesBuckets: [SeriesBucket] { SeriesBucket.build(from: indicators, profile: profile, customMetrics: customMetrics) } private var monitorBuckets: [SeriesBucket] { seriesBuckets.filter { $0.kind == .monitor } } private var labBuckets: [SeriesBucket] { seriesBuckets.filter { $0.kind == .lab } } private func filtered(_ buckets: [SeriesBucket]) -> [SeriesBucket] { let q = query.trimmingCharacters(in: .whitespaces) guard !q.isEmpty else { return buckets } return buckets.filter { $0.title.localizedCaseInsensitiveContains(q) } } private var filteredMonitor: [SeriesBucket] { filtered(monitorBuckets) } private var filteredLab: [SeriesBucket] { filtered(labBuckets) } var body: some View { NavigationStack { ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 18) { header.padding(.top, 4) if seriesBuckets.isEmpty { emptyState } else if filteredMonitor.isEmpty && filteredLab.isEmpty { noMatchState } else { if !filteredMonitor.isEmpty { section(title: String(appLoc: "长期监测"), buckets: filteredMonitor) } if !filteredLab.isEmpty { section(title: String(appLoc: "化验指标趋势"), buckets: filteredLab) } } } .padding(.horizontal, 20) .padding(.bottom, 24) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Tj.Palette.sand.ignoresSafeArea()) .navigationBarHidden(true) } } private var header: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .lastTextBaseline) { Text("趋势") .font(.tjTitle(26)) .foregroundStyle(Tj.Palette.text) Spacer() searchToggle } if searching { searchField } } } private var searchToggle: some View { Button { withAnimation(.easeInOut(duration: 0.18)) { searching.toggle() if !searching { query = "" } } } label: { Image(systemName: searching ? "xmark" : "magnifyingglass") .font(.tjScaled( 15, weight: .semibold)) .foregroundStyle(Tj.Palette.text) .frame(width: 36, height: 36) .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) ) } private var noMatchState: some View { VStack(spacing: 12) { TjPlaceholder(label: String(appLoc: "没有匹配「\(query)」的指标")) .frame(height: 120) .frame(maxWidth: 260) } .frame(maxWidth: .infinity) .padding(.top, 60) } 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(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) Spacer() } VStack(spacing: 12) { ForEach(buckets) { bucket in NavigationLink { TrendDetailView(bucket: bucket) } label: { TrendRow(bucket: bucket) } .buttonStyle(.plain) } } } } private var emptyState: some View { VStack(spacing: 12) { TjPlaceholder(label: String(appLoc: "还没有可成趋势的指标")) .frame(height: 120) .frame(maxWidth: 260) Text("同一指标记录满 2 次后,会在这里出现时间序列") .font(.tjScaled( 12)) .foregroundStyle(Tj.Palette.text3) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.top, 60) } } #Preview { TrendsView() .modelContainer(for: [ Indicator.self, Report.self, DiaryEntry.self, Symptom.self, Asset.self ], inMemory: true) }