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 = "" private var profile: UserProfile? { profiles.first } private var isBP: Bool { selectedMonitor == .bloodPressure } 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 selectedMonitor == nil { // 自由输入或 lab preset 时 status 手动;monitor 单字段自动 statusSection } else { autoStatusHint } } timeSection noteSection } .padding(.horizontal, 20) .padding(.bottom, 20) } footer } .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) { sectionLabel("长期监测(进趋势)") let columns = [GridItem(.flexible()), GridItem(.flexible())] LazyVGrid(columns: columns, spacing: 8) { ForEach(MonitorMetric.allCases) { m in monitorTile(m) } } } } 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) } private var labPresetSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel("化验项快捷(不进趋势)") 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("收缩 / 舒张") Spacer() bpRangeHint } HStack(spacing: 12) { bpField(label: "收缩压", value: $systolic, placeholder: "120") Text("/").font(.system(size: 22, weight: .light)).foregroundStyle(Tj.Palette.text3) bpField(label: "舒张压", 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("收缩 " + s.label, color: s.color) } if let s = computedBPStatus(.diastolic) { statusBadge("舒张 " + s.label, color: s.color) } Spacer() } } private var nameSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel("指标名") 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(selectedMonitor != nil) .opacity(selectedMonitor != nil ? 0.6 : 1) } } private var valueRow: some View { HStack(alignment: .top, spacing: 12) { VStack(alignment: .leading, spacing: 8) { sectionLabel("数值") 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("单位") TextField("mmol/L", text: $unit) .textInputAutocapitalization(.never) .autocorrectionDisabled() .padding(.horizontal, 14) .padding(.vertical, 12) .background(fieldBg) .overlay(fieldBorder) .disabled(selectedMonitor != nil) .opacity(selectedMonitor != nil ? 0.6 : 1) } .frame(maxWidth: 130) } } private var rangeSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { sectionLabel("参考范围") 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(selectedMonitor != nil) .opacity(selectedMonitor != nil ? 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("状态") HStack(spacing: 8) { statusChip(.normal, label: "正常", color: Tj.Palette.leaf) statusChip(.high, label: "偏高 ↑", color: Tj.Palette.brick) statusChip(.low, label: "偏低 ↓", color: Tj.Palette.amber) } } } private var autoStatusHint: some View { let auto = computedSingleStatus return HStack(spacing: 8) { sectionLabel("状态(按数值自动判)") 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("测量时间") DatePicker("", selection: $capturedAt, in: ...Date.now) .datePickerStyle(.compact) .labelsHidden() } } private var noteSection: some View { VStack(alignment: .leading, spacing: 8) { sectionLabel("备注(可选)") TextField("例如:空腹采血", text: $note, axis: .vertical) .lineLimit(1...3) .padding(.horizontal, 14) .padding(.vertical, 12) .background(fieldBg) .overlay(fieldBorder) } } 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: - apply preset private func applyMonitor(_ m: MonitorMetric) { if selectedMonitor == m { // 取消选择 clearMonitor() return } selectedMonitor = m selectedLabPreset = 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 systolic = ""; diastolic = "" name = p.name if unit.trimmingCharacters(in: .whitespaces).isEmpty { unit = p.unit } if range.trimmingCharacters(in: .whitespaces).isEmpty { range = p.range } } // MARK: - auto status private var computedSingleStatus: (label: String, color: Color)? { guard let m = selectedMonitor, m != .bloodPressure, let v = Double(value.trimmingCharacters(in: .whitespaces)) else { return nil } 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) } 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 { saveFreeform() } 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() // 身高顺手回写 Profile if m == .height, let cm = Int(value.trimmingCharacters(in: .whitespaces)), let p = profile { p.heightCM = cm p.updatedAt = .now 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 "正常" case .high: return "偏高 ↑" case .low: return "偏低 ↓" } } var color: Color { switch self { case .normal: return Tj.Palette.leaf case .high: return Tj.Palette.brick case .low: return Tj.Palette.amber } } } #Preview { IndicatorQuickSheet() .modelContainer(for: [Indicator.self, UserProfile.self], inMemory: true) }