Files
kangkang/康康/Features/Indicator/IndicatorQuickSheet.swift
link2026 3dcb792131 feat(profile,monitor): ProfileEditView + MeView 卡片 + IndicatorQuickSheet 改造
- ProfileEditView Form 风格,即改即存,onDisappear 触发 ctx.save
  - basics(出生年 / 性别 / 身高 / 血型)
  - 慢病 chips(8 预设 + 自定义)
  - allergies / familyHistory / medications 通用 list section
  - FlowLayout(Layout 协议自实现)用于 chip 流式换行

- MeView 改造:NavigationStack + ProfileCard 显示 summaryLine,
  3 个 settings 卡片(模型 / Face ID / 关于)stub,DEBUG 块仍在底部

- IndicatorQuickSheet 整合 MonitorMetric:
  - 顶部 LazyVGrid 2 列展示 8 个 MonitorMetric(进趋势)
  - 下方 horizontal scroll 化验项快捷(不进趋势)
  - 选血压切到 2 字段 UI(收缩/舒张),保存写 2 条 Indicator(同 capturedAt)
  - 选单字段 monitor:自动算 status,锁 name/unit/range
  - 选 lab preset:辅助填 name/unit/range,status 手动
  - 自由输入路径不变
  - 身高 monitor 保存时回写 UserProfile.heightCM
  - Profile-aware range hint:'按 67 岁调整' 仅在 effectiveRange 不同于 baseRange 时显示
2026-05-26 07:47:20 +08:00

653 lines
23 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String>, 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<Double>?) -> 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)
}