import SwiftUI import SwiftData /// 化验项 free-form 预设(用户当时手动加的辅助列表)。 /// 跟 MonitorMetric 不同——这些是「一次性化验单数值」,不进 Trends,无 seriesKey。 struct IndicatorPreset: Identifiable, Hashable { let name: String let unit: String let range: String var id: String { name } } private let labPresets: [IndicatorPreset] = [ .init(name: "LDL-C", unit: "mmol/L", range: "< 3.40"), .init(name: "HDL-C", unit: "mmol/L", range: "> 1.04"), .init(name: "总胆固醇", unit: "mmol/L", range: "< 5.18"), .init(name: "甘油三酯", unit: "mmol/L", range: "< 1.70"), .init(name: "ALT", unit: "U/L", range: "9 - 50"), .init(name: "尿酸", unit: "μmol/L", range: "208 - 428"), .init(name: "血红蛋白", unit: "g/L", range: "130 - 175"), ] /// 指标录入 sheet,支持 3 种路径: /// 1. **长期监测预设**(MonitorMetric)— 选预设后字段自动填,带 seriesKey, /// 保存进 Trends。血压拆 2 条 Indicator。 /// 2. **化验项快捷**(labPresets)— 跟 a 类似但只填 name/unit/range 辅助, /// 无 seriesKey,不进 Trends。 /// 3. **自由输入** — name/value/unit/range 全自己填,status 手动选。 struct IndicatorQuickSheet: View { @Environment(\.modelContext) private var ctx @Environment(\.dismiss) private var dismiss @Query private var profiles: [UserProfile] // 状态 @State private var selectedMonitor: MonitorMetric? @State private var selectedLabPreset: IndicatorPreset? // 单字段(monitor 单字段、lab、自由) @State private var name: String = "" @State private var value: String = "" @State private var unit: String = "" @State private var range: String = "" @State private var manualStatus: IndicatorStatus = .normal @State private var capturedAt: Date = .now @State private var note: String = "" // 血压双字段 @State private var systolic: String = "" @State private var diastolic: String = "" // 周期性提醒(仅长期监测可用) @Query private var allReminders: [MetricReminder] @State private var reminderEnabled: Bool = false @State private var reminderTime: Date = Self.defaultReminderTime @State private var reminderWeekdays: Set = Set(1...7) @State private var reminderHydratedFor: String? = nil @State private var notifAuthBlocked: Bool = false // 自定义指标 @Query(sort: \CustomMonitorMetric.createdAt, order: .reverse) private var customMetrics: [CustomMonitorMetric] @State private var selectedCustom: CustomMonitorMetric? @State private var editingCustom: CustomMetricEditTarget? // 隐藏管理 sheet 触发态 @State private var showHiddenSheet: Bool = false private static var defaultReminderTime: Date { Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: .now) ?? .now } private var profile: UserProfile? { profiles.first } private var isBP: Bool { selectedMonitor == .bloodPressure } private var isLongTermMetric: Bool { selectedMonitor != nil || selectedCustom != nil } private var isCustomMonitor: Bool { selectedCustom != nil } /// 当前长期监测的稳定 key,用于 reminder 关联和 .task(id:) hydrate 触发。 /// 血压用 metric.rawValue;custom 用 seriesKey;其他单字段 monitor 用 rawValue;非长期 nil。 private var longTermKey: String? { if let m = selectedMonitor { return m.rawValue } if let cm = selectedCustom { return cm.seriesKey } return nil } private var longTermDisplayName: String? { selectedMonitor?.displayName ?? selectedCustom?.name } private var canSubmit: Bool { if isBP { return !systolic.trimmingCharacters(in: .whitespaces).isEmpty && !diastolic.trimmingCharacters(in: .whitespaces).isEmpty } return !name.trimmingCharacters(in: .whitespaces).isEmpty && !value.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { VStack(spacing: 0) { handle header ScrollView(showsIndicators: false) { VStack(alignment: .leading, spacing: 20) { monitorGridSection labPresetSection Divider().padding(.vertical, 4) if isBP { bpFieldSection } else { nameSection valueRow rangeSection if isLongTermMetric { autoStatusHint } else { statusSection } } timeSection noteSection if isLongTermMetric { reminderSection } } .padding(.horizontal, 20) .padding(.bottom, 20) } footer } .task(id: longTermKey) { hydrateReminder() } .background( Tj.Palette.sand .clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous)) .ignoresSafeArea(edges: .bottom) ) .presentationDetents([.large]) .presentationDragIndicator(.hidden) .presentationBackground(Tj.Palette.sand) .presentationCornerRadius(Tj.Radius.xl) } // MARK: - Sections private var handle: some View { Capsule() .fill(Tj.Palette.line) .frame(width: 40, height: 4) .padding(.top, 10) .padding(.bottom, 14) } private var header: some View { HStack { Text("记录指标") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Spacer() Text("本地处理 · 永不上传") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) } .padding(.horizontal, 20) .padding(.bottom, 16) } private var monitorGridSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { sectionLabel(String(appLoc: "长期监测(进趋势)")) Spacer() if !hiddenSet.isEmpty { hiddenCountChip } } let columns = [GridItem(.flexible()), GridItem(.flexible())] LazyVGrid(columns: columns, spacing: 8) { ForEach(visibleMonitorMetrics) { m in monitorTile(m) } ForEach(customMetrics) { cm in customTile(cm) } addCustomTile } } .sheet(isPresented: $showHiddenSheet) { HiddenMonitorRestoreSheet( hiddenMetrics: hiddenMonitorMetrics, onRestore: { unhideMonitor($0) } ) } .sheet(item: $editingCustom) { target in CustomMetricEditor(existing: target.metric) { saved in // 新建后自动选中,删除后清空选择 if let saved { selectedCustom = saved selectedMonitor = nil selectedLabPreset = nil fillFromCustom(saved) } else if selectedCustom?.seriesKey == target.metric?.seriesKey { selectedCustom = nil clearAllFields() } } } } private func customTile(_ cm: CustomMonitorMetric) -> some View { let selected = selectedCustom?.seriesKey == cm.seriesKey return Button { applyCustom(cm) } label: { HStack(spacing: 10) { Image(systemName: cm.icon) .font(.system(size: 18, weight: .medium)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink) .frame(width: 32, height: 32) .background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.leafSoft)) VStack(alignment: .leading, spacing: 1) { Text(cm.name) .font(.system(size: 14, weight: selected ? .semibold : .medium)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text) .lineLimit(1) Text("自定义") .font(.system(size: 9, design: .monospaced)) .foregroundStyle(selected ? Tj.Palette.paper.opacity(0.7) : Tj.Palette.text3) } Spacer() } .padding(.horizontal, 10) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(selected ? Tj.Palette.ink : Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1) ) } .buttonStyle(.plain) .contextMenu { // 单一入口:进编辑器既能改也能删(编辑器内含删除按钮)。 // 旧实现两项 action 完全相同,第二项却标红 trash「编辑/删除」,看似直接删除实则打开编辑器,误导。 Button { editingCustom = CustomMetricEditTarget(metric: cm) } label: { Label("编辑 / 删除", systemImage: "pencil") } } } private var addCustomTile: some View { Button { editingCustom = CustomMetricEditTarget(metric: nil) } label: { HStack(spacing: 10) { Image(systemName: "plus") .font(.system(size: 18, weight: .semibold)) .foregroundStyle(Tj.Palette.text2) .frame(width: 32, height: 32) .background( Circle().strokeBorder(Tj.Palette.line, lineWidth: 1, antialiased: true) ) Text("自定义") .font(.system(size: 14, weight: .medium)) .foregroundStyle(Tj.Palette.text2) Spacer() } .padding(.horizontal, 10) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.sand2.opacity(0.5)) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.line.opacity(0.6), style: StrokeStyle(lineWidth: 1, dash: [4, 3])) ) } .buttonStyle(.plain) } private func monitorTile(_ m: MonitorMetric) -> some View { let selected = selectedMonitor == m return Button { applyMonitor(m) } label: { HStack(spacing: 10) { Image(systemName: m.icon) .font(.system(size: 18, weight: .medium)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.ink) .frame(width: 32, height: 32) .background(Circle().fill(selected ? Tj.Palette.ink : Tj.Palette.amber.opacity(0.25))) Text(m.displayName) .font(.system(size: 14, weight: selected ? .semibold : .medium)) .foregroundStyle(selected ? Tj.Palette.paper : Tj.Palette.text) Spacer() } .padding(.horizontal, 10) .padding(.vertical, 10) .background( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(selected ? Tj.Palette.ink : Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1) ) } .buttonStyle(.plain) .contextMenu { Button(role: .destructive) { hideMonitor(m) } label: { Label("隐藏", systemImage: "eye.slash") } } } private var labPresetSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel(String(appLoc: "化验项快捷(不进趋势)")) ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { ForEach(labPresets) { p in chip(p.name, selected: selectedLabPreset == p) { applyLab(p) } } } } } } private var bpFieldSection: some View { VStack(alignment: .leading, spacing: 12) { HStack { sectionLabel(String(appLoc: "收缩 / 舒张")) Spacer() bpRangeHint } HStack(spacing: 12) { bpField(label: String(appLoc: "收缩压"), value: $systolic, placeholder: "120") Text("/").font(.system(size: 22, weight: .light)).foregroundStyle(Tj.Palette.text3) bpField(label: String(appLoc: "舒张压"), value: $diastolic, placeholder: "80") Text("mmHg").foregroundStyle(Tj.Palette.text3) } bpStatusChips } } private func bpField(label: String, value: Binding, placeholder: String) -> some View { VStack(alignment: .leading, spacing: 4) { Text(label).font(.system(size: 11)).foregroundStyle(Tj.Palette.text3) TextField(placeholder, text: value) .keyboardType(.decimalPad) .font(.system(size: 20, weight: .semibold, design: .monospaced)) .multilineTextAlignment(.center) .padding(.vertical, 10) .frame(width: 90) .background(fieldBg) .overlay(fieldBorder) } } private var bpRangeHint: some View { let sysRange = MonitorMetric.bloodPressure.effectiveRange( for: MonitorMetric.bloodPressure.fields[0], profile: profile) let diasRange = MonitorMetric.bloodPressure.effectiveRange( for: MonitorMetric.bloodPressure.fields[1], profile: profile) let personalized = MonitorMetric.bloodPressure.isRangePersonalized( for: MonitorMetric.bloodPressure.fields[0], profile: profile) let rangeText = "\(formatRange(sysRange)) / \(formatRange(diasRange))" return HStack(spacing: 4) { Text(rangeText) .font(.system(size: 11, design: .monospaced)) .foregroundStyle(Tj.Palette.text3) if personalized, let age = profile?.age { Text("· 按\(age)岁调整") .font(.system(size: 10)) .foregroundStyle(Tj.Palette.amber) } } } private var bpStatusChips: some View { HStack(spacing: 8) { if let s = computedBPStatus(.systolic) { statusBadge(String(appLoc: "收缩 ") + s.label, color: s.color) } if let s = computedBPStatus(.diastolic) { statusBadge(String(appLoc: "舒张 ") + s.label, color: s.color) } Spacer() } } private var nameSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel(String(appLoc: "指标名")) TextField("例如:血红蛋白", text: $name) .textInputAutocapitalization(.never) .padding(.horizontal, 14) .padding(.vertical, 12) .background(fieldBg) .overlay(fieldBorder) .onChange(of: name) { _, _ in if let p = selectedLabPreset, p.name != name { selectedLabPreset = nil } } .disabled(isLongTermMetric) .opacity(isLongTermMetric ? 0.6 : 1) } } private var valueRow: some View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 8) { sectionLabel(String(appLoc: "数值")) TextField(monitorFieldPlaceholder, text: $value) .keyboardType(.decimalPad) .font(.system(size: 18, weight: .semibold, design: .monospaced)) .padding(.horizontal, 14) .padding(.vertical, 12) .background(fieldBg) .overlay(fieldBorder) } VStack(alignment: .leading, spacing: 8) { sectionLabel(String(appLoc: "单位")) TextField("mmol/L", text: $unit) .textInputAutocapitalization(.never) .autocorrectionDisabled() .padding(.horizontal, 14) .padding(.vertical, 12) .background(fieldBg) .overlay(fieldBorder) .disabled(isLongTermMetric) .opacity(isLongTermMetric ? 0.6 : 1) } .frame(maxWidth: 130) } } private var rangeSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { sectionLabel(String(appLoc: "参考范围")) Spacer() if let m = selectedMonitor, m != .bloodPressure { monitorRangeHint(m) } } TextField("例如:< 3.40 或 3.9 - 6.1", text: $range) .textInputAutocapitalization(.never) .autocorrectionDisabled() .padding(.horizontal, 14) .padding(.vertical, 12) .background(fieldBg) .overlay(fieldBorder) .disabled(isLongTermMetric) .opacity(isLongTermMetric ? 0.6 : 1) } } private func monitorRangeHint(_ m: MonitorMetric) -> some View { let personalized = m.isRangePersonalized(for: m.fields[0], profile: profile) return HStack(spacing: 4) { if personalized, let age = profile?.age { Text("按\(age)岁调整") .font(.system(size: 10)) .foregroundStyle(Tj.Palette.amber) } } } private var statusSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel(String(appLoc: "状态")) HStack(spacing: 8) { statusChip(.normal, label: String(appLoc: "正常"), color: Tj.Palette.leaf) statusChip(.high, label: String(appLoc: "偏高 ↑"), color: Tj.Palette.brick) statusChip(.low, label: String(appLoc: "偏低 ↓"), color: Tj.Palette.amber) } } } private var autoStatusHint: some View { let auto = computedSingleStatus return HStack(spacing: 8) { sectionLabel(String(appLoc: "状态(按数值自动判)")) if let s = auto { statusBadge(s.label, color: s.color) } else { Text("待输入") .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) } } } private var timeSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel(String(appLoc: "测量时间")) DatePicker("", selection: $capturedAt, in: ...Date.now) .datePickerStyle(.compact) .labelsHidden() } } private var noteSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel(String(appLoc: "备注(可选)")) TextField("例如:空腹采血", text: $note, axis: .vertical) .lineLimit(1...3) .padding(.horizontal, 14) .padding(.vertical, 12) .background(fieldBg) .overlay(fieldBorder) } } // MARK: - 周期提醒 private var reminderSection: some View { VStack(alignment: .leading, spacing: 10) { HStack { sectionLabel(String(appLoc: "周期提醒")) Spacer() Toggle("", isOn: $reminderEnabled) .labelsHidden() .tint(Tj.Palette.ink) .onChange(of: reminderEnabled) { _, on in if on { Task { await requestNotifAuthIfNeeded() } } } } if reminderEnabled { VStack(alignment: .leading, spacing: 12) { HStack { Text("时间") .font(.system(size: 13)) .foregroundStyle(Tj.Palette.text2) Spacer() DatePicker("", selection: $reminderTime, displayedComponents: .hourAndMinute) .datePickerStyle(.compact) .labelsHidden() } VStack(alignment: .leading, spacing: 6) { HStack { Text("频率") .font(.system(size: 13)) .foregroundStyle(Tj.Palette.text2) Spacer() Text(reminderFrequencyLabel) .font(.system(size: 12)) .foregroundStyle(Tj.Palette.text3) } weekdayPickerRow HStack(spacing: 8) { quickFreqChip(String(appLoc: "每天")) { reminderWeekdays = Set(1...7) } quickFreqChip(String(appLoc: "工作日")) { reminderWeekdays = Set([2, 3, 4, 5, 6]) } quickFreqChip(String(appLoc: "周末")) { reminderWeekdays = Set([1, 7]) } } } if notifAuthBlocked { Text("⚠️ 通知权限已关闭,去「设置 → 康康 → 通知」打开") .font(.system(size: 11)) .foregroundStyle(Tj.Palette.brick) } else { Text("本机提醒 · 不发任何数据") .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text3) } } .padding(12) .background(fieldBg) .overlay(fieldBorder) } } } private var reminderFrequencyLabel: String { if reminderWeekdays.count == 7 { return String(appLoc: "每天") } if reminderWeekdays.isEmpty { return String(appLoc: "未选") } let names = [ String(appLoc: "日"), String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"), ] let sorted = reminderWeekdays.sorted() return String(appLoc: "每周 ") + sorted.map { names[$0 - 1] }.joined() } private var weekdayPickerRow: some View { let names = [ String(appLoc: "一"), String(appLoc: "二"), String(appLoc: "三"), String(appLoc: "四"), String(appLoc: "五"), String(appLoc: "六"), String(appLoc: "日"), ] let weekdayValues = [2, 3, 4, 5, 6, 7, 1] // 周一到周日(Apple Calendar 编号) return HStack(spacing: 6) { ForEach(Array(weekdayValues.enumerated()), id: \.offset) { idx, w in Button { if reminderWeekdays.contains(w) { reminderWeekdays.remove(w) } else { reminderWeekdays.insert(w) } } label: { Text(names[idx]) .font(.system(size: 13, weight: reminderWeekdays.contains(w) ? .semibold : .regular)) .foregroundStyle(reminderWeekdays.contains(w) ? Tj.Palette.paper : Tj.Palette.text) .frame(maxWidth: .infinity, minHeight: 32) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(reminderWeekdays.contains(w) ? Tj.Palette.ink : Tj.Palette.paper) ) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: reminderWeekdays.contains(w) ? 0 : 1) ) } .buttonStyle(.plain) } } } private func quickFreqChip(_ label: String, action: @escaping () -> Void) -> some View { Button(action: action) { Text(label) .font(.system(size: 11)) .foregroundStyle(Tj.Palette.text2) .padding(.horizontal, 10) .padding(.vertical, 4) .background(Capsule().fill(Tj.Palette.sand2)) } .buttonStyle(.plain) } private func hydrateReminder() { guard let key = longTermKey else { return } if reminderHydratedFor == key { return } reminderHydratedFor = key if let existing = allReminders.first(where: { $0.metricId == key }) { reminderEnabled = existing.enabled reminderTime = Calendar.current.date( bySettingHour: existing.hour, minute: existing.minute, second: 0, of: .now ) ?? Self.defaultReminderTime reminderWeekdays = Set(existing.weekdays) } else { reminderEnabled = false reminderTime = Self.defaultReminderTime reminderWeekdays = Set(1...7) } } private func requestNotifAuthIfNeeded() async { let state = await ReminderService.requestAuthorization() notifAuthBlocked = (state == .denied) if notifAuthBlocked { reminderEnabled = false } } /// submit() 调用,处理提醒:enabled → upsert SwiftData + 调度通知;disabled → 删旧 reminder + 取消通知。 private func persistReminderIfNeeded() async { guard let key = longTermKey, let displayName = longTermDisplayName else { return } let existing = allReminders.first(where: { $0.metricId == key }) let cal = Calendar.current let hour = cal.component(.hour, from: reminderTime) let minute = cal.component(.minute, from: reminderTime) if reminderEnabled && !reminderWeekdays.isEmpty { let reminder: MetricReminder if let existing { existing.enabled = true existing.hour = hour existing.minute = minute existing.weekdays = reminderWeekdays.sorted() existing.displayName = displayName existing.updatedAt = .now reminder = existing } else { let new = MetricReminder( metricId: key, displayName: displayName, hour: hour, minute: minute, weekdays: reminderWeekdays.sorted(), enabled: true ) ctx.insert(new) reminder = new } try? ctx.save() await ReminderService.sync(reminder) } else if let existing { // 关闭:保留 SwiftData 行,只改 enabled = false,取消通知 existing.enabled = false existing.updatedAt = .now try? ctx.save() ReminderService.cancel(metricId: key) } } private var footer: some View { HStack(spacing: 12) { Button("取消") { dismiss() } .buttonStyle(TjGhostButton(height: 44, fontSize: 15, horizontalPadding: 18)) Button("保存到记录") { submit() } .buttonStyle(TjPrimaryButton(height: 44, fontSize: 15, horizontalPadding: 18)) .disabled(!canSubmit) .opacity(canSubmit ? 1 : 0.4) } .padding(.horizontal, 20) .padding(.vertical, 14) .background( Tj.Palette.sand .overlay(alignment: .top) { Rectangle().fill(Tj.Palette.lineSoft).frame(height: 1) } ) } // MARK: - helpers private var fieldBg: some View { RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .fill(Tj.Palette.paper) } private var fieldBorder: some View { RoundedRectangle(cornerRadius: Tj.Radius.sm, style: .continuous) .strokeBorder(Tj.Palette.line, lineWidth: 1) } private func sectionLabel(_ text: String) -> some View { Text(text) .font(.system(size: 12, weight: .semibold)) .tracking(0.3) .foregroundStyle(Tj.Palette.text2) } 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 statusChip(_ value: IndicatorStatus, label: String, color: Color) -> some View { let selected = manualStatus == value return Button { manualStatus = value } label: { Text(label) .font(.system(size: 13, weight: selected ? .semibold : .regular)) .foregroundStyle(selected ? Tj.Palette.paper : color) .padding(.horizontal, 14) .padding(.vertical, 8) .frame(maxWidth: .infinity) .background(Capsule().fill(selected ? color : Tj.Palette.paper)) .overlay(Capsule().strokeBorder(color.opacity(selected ? 0 : 0.5), lineWidth: 1)) } .buttonStyle(.plain) } private func statusBadge(_ label: String, color: Color) -> some View { Text(label) .font(.system(size: 11, weight: .semibold)) .foregroundStyle(color) .padding(.horizontal, 10) .padding(.vertical, 4) .background(Capsule().fill(color.opacity(0.18))) } private var monitorFieldPlaceholder: String { selectedMonitor?.fields.first?.placeholder ?? "3.84" } private func formatRange(_ r: ClosedRange?) -> String { guard let r = r else { return "—" } let fmt = { (v: Double) in v.truncatingRemainder(dividingBy: 1) == 0 ? String(format: "%.0f", v) : String(format: "%.1f", v) } return "\(fmt(r.lowerBound))–\(fmt(r.upperBound))" } // MARK: - hidden preset 管理 private var hiddenSet: Set { Set(profile?.hiddenPresetMetrics ?? []) } private var visibleMonitorMetrics: [MonitorMetric] { MonitorMetric.allCases.filter { !hiddenSet.contains($0.rawValue) } } private var hiddenMonitorMetrics: [MonitorMetric] { MonitorMetric.allCases.filter { hiddenSet.contains($0.rawValue) } } private var hiddenCountChip: some View { Button { showHiddenSheet = true } label: { HStack(spacing: 3) { Text("已隐藏 \(hiddenSet.count)") .font(.system(size: 11, weight: .medium)) Image(systemName: "chevron.right") .font(.system(size: 9, weight: .semibold)) } .foregroundStyle(Tj.Palette.text2) .padding(.horizontal, 10) .padding(.vertical, 4) .background(Capsule().fill(Tj.Palette.sand2)) } .buttonStyle(.plain) } private func hideMonitor(_ m: MonitorMetric) { let profile = UserProfileStore.loadOrCreate(in: ctx) guard !profile.hiddenPresetMetrics.contains(m.rawValue) else { return } profile.hiddenPresetMetrics.append(m.rawValue) profile.updatedAt = .now try? ctx.save() if selectedMonitor == m { clearMonitor() } } private func unhideMonitor(_ m: MonitorMetric) { guard let profile = profile else { return } profile.hiddenPresetMetrics.removeAll { $0 == m.rawValue } profile.updatedAt = .now try? ctx.save() if profile.hiddenPresetMetrics.isEmpty { showHiddenSheet = false } } // MARK: - apply preset private func applyMonitor(_ m: MonitorMetric) { if selectedMonitor == m { // 取消选择 clearMonitor() return } selectedMonitor = m selectedLabPreset = nil selectedCustom = nil if m == .bloodPressure { // 血压走 bp 字段,不动 name/value/unit name = m.displayName unit = "mmHg" } else { let f = m.fields[0] name = m.displayName value = "" unit = f.unit let r = m.effectiveRange(for: f, profile: profile) range = f.rangeText(r) } } private func clearMonitor() { selectedMonitor = nil name = ""; value = ""; unit = ""; range = "" systolic = ""; diastolic = "" } private func applyLab(_ p: IndicatorPreset) { selectedLabPreset = p selectedMonitor = nil selectedCustom = nil systolic = ""; diastolic = "" name = p.name if unit.trimmingCharacters(in: .whitespaces).isEmpty { unit = p.unit } if range.trimmingCharacters(in: .whitespaces).isEmpty { range = p.range } } private func applyCustom(_ cm: CustomMonitorMetric) { if selectedCustom?.seriesKey == cm.seriesKey { selectedCustom = nil clearAllFields() return } selectedCustom = cm selectedMonitor = nil selectedLabPreset = nil fillFromCustom(cm) } private func fillFromCustom(_ cm: CustomMonitorMetric) { name = cm.name value = "" unit = cm.unit range = cm.rangeText systolic = ""; diastolic = "" } private func clearAllFields() { name = ""; value = ""; unit = ""; range = "" systolic = ""; diastolic = "" } // MARK: - auto status private var computedSingleStatus: (label: String, color: Color)? { guard let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil } if let m = selectedMonitor, m != .bloodPressure { let f = m.fields[0] let r = m.effectiveRange(for: f, profile: profile) let s = MonitorMetric.status(value: v, in: r) return (s.label, s.color) } if let cm = selectedCustom { let s = MonitorMetric.status(value: v, in: cm.referenceRange) return (s.label, s.color) } return nil } private enum BPSide { case systolic, diastolic } private func computedBPStatus(_ side: BPSide) -> (label: String, color: Color)? { let text = side == .systolic ? systolic : diastolic guard let v = Double(text.trimmingCharacters(in: .whitespaces)) else { return nil } let m = MonitorMetric.bloodPressure let f = m.fields[side == .systolic ? 0 : 1] let r = m.effectiveRange(for: f, profile: profile) let s = MonitorMetric.status(value: v, in: r) return (s.label, s.color) } // MARK: - submit private func submit() { guard canSubmit else { return } if isBP { saveBP() } else if let m = selectedMonitor { saveSingleMonitor(m) } else if let cm = selectedCustom { saveCustom(cm) } else { saveFreeform() } Task { await persistReminderIfNeeded() await MainActor.run { dismiss() } } } private func saveBP() { let m = MonitorMetric.bloodPressure let sys = Double(systolic.trimmingCharacters(in: .whitespaces)) ?? 0 let dia = Double(diastolic.trimmingCharacters(in: .whitespaces)) ?? 0 let sysField = m.fields[0] let diaField = m.fields[1] let sysRange = m.effectiveRange(for: sysField, profile: profile) let diaRange = m.effectiveRange(for: diaField, profile: profile) let sysStatus = MonitorMetric.status(value: sys, in: sysRange) let diaStatus = MonitorMetric.status(value: dia, in: diaRange) let timestamp = capturedAt let systolicI = Indicator( name: sysField.label, value: systolic, unit: sysField.unit, range: sysField.rangeText(sysRange), status: sysStatus, note: note.isEmpty ? nil : note, capturedAt: timestamp, pinned: true, seriesKey: sysField.seriesKey ) let diastolicI = Indicator( name: diaField.label, value: diastolic, unit: diaField.unit, range: diaField.rangeText(diaRange), status: diaStatus, note: nil, capturedAt: timestamp, pinned: true, seriesKey: diaField.seriesKey ) ctx.insert(systolicI) ctx.insert(diastolicI) try? ctx.save() } private func saveSingleMonitor(_ m: MonitorMetric) { let f = m.fields[0] let v = Double(value.trimmingCharacters(in: .whitespaces)) ?? 0 let r = m.effectiveRange(for: f, profile: profile) let status = MonitorMetric.status(value: v, in: r) let indicator = Indicator( name: m.displayName, value: value.trimmingCharacters(in: .whitespaces), unit: f.unit, range: f.rangeText(r), status: status, note: note.isEmpty ? nil : note, capturedAt: capturedAt, pinned: true, seriesKey: f.seriesKey ) ctx.insert(indicator) try? ctx.save() } private func saveCustom(_ cm: CustomMonitorMetric) { let v = Double(value.trimmingCharacters(in: .whitespaces)) ?? 0 let status = MonitorMetric.status(value: v, in: cm.referenceRange) let indicator = Indicator( name: cm.name, value: value.trimmingCharacters(in: .whitespaces), unit: cm.unit, range: cm.rangeText, status: status, note: note.isEmpty ? nil : note, capturedAt: capturedAt, pinned: true, seriesKey: cm.seriesKey ) ctx.insert(indicator) try? ctx.save() } private func saveFreeform() { let indicator = Indicator( name: name.trimmingCharacters(in: .whitespaces), value: value.trimmingCharacters(in: .whitespaces), unit: unit.trimmingCharacters(in: .whitespaces), range: range.trimmingCharacters(in: .whitespaces), status: manualStatus, note: note.trimmingCharacters(in: .whitespaces).isEmpty ? nil : note, capturedAt: capturedAt ) ctx.insert(indicator) try? ctx.save() } } // MARK: - Status display helpers private extension IndicatorStatus { var label: String { switch self { case .normal: return String(appLoc: "正常") case .high: return String(appLoc: "偏高 ↑") case .low: return String(appLoc: "偏低 ↓") } } var color: Color { switch self { case .normal: return Tj.Palette.leaf case .high: return Tj.Palette.brick case .low: return Tj.Palette.amber } } } /// `.sheet(item:)` 要求 Identifiable;包一层避免 CustomMonitorMetric? 不能直接当 binding 用。 struct CustomMetricEditTarget: Identifiable { let metric: CustomMonitorMetric? var id: String { metric?.seriesKey ?? "_new_" } } /// 已隐藏的长期监测预设恢复列表。点"显示"把对应 metric 从 hiddenPresetMetrics 移除。 private struct HiddenMonitorRestoreSheet: View { let hiddenMetrics: [MonitorMetric] let onRestore: (MonitorMetric) -> Void @Environment(\.dismiss) private var dismiss var body: some View { VStack(spacing: 0) { Capsule() .fill(Tj.Palette.line) .frame(width: 40, height: 4) .padding(.top, 10) .padding(.bottom, 14) HStack { Text("已隐藏的长期监测") .font(.tjH2()) .foregroundStyle(Tj.Palette.text) Spacer() Button("完成") { dismiss() } .font(.system(size: 14)) .foregroundStyle(Tj.Palette.ink) } .padding(.horizontal, 20) .padding(.bottom, 12) ScrollView { VStack(spacing: 8) { ForEach(hiddenMetrics) { m in row(m) } } .padding(.horizontal, 20) .padding(.bottom, 24) } } .background(Tj.Palette.sand) .presentationDetents([.medium]) .presentationBackground(Tj.Palette.sand) .presentationCornerRadius(Tj.Radius.xl) } private func row(_ m: MonitorMetric) -> some View { HStack(spacing: 12) { Image(systemName: m.icon) .font(.system(size: 16, weight: .medium)) .foregroundStyle(Tj.Palette.ink) .frame(width: 32, height: 32) .background(Circle().fill(Tj.Palette.amber.opacity(0.25))) Text(m.displayName) .font(.system(size: 15, weight: .medium)) .foregroundStyle(Tj.Palette.text) Spacer() Button("显示") { onRestore(m) } .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Tj.Palette.paper) .padding(.horizontal, 14) .padding(.vertical, 6) .background(Capsule().fill(Tj.Palette.ink)) } .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) ) } } #Preview { IndicatorQuickSheet() .modelContainer(for: [ Indicator.self, UserProfile.self, MetricReminder.self, CustomMonitorMetric.self ], inMemory: true) }