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 时显示
This commit is contained in:
link2026
2026-05-26 07:47:20 +08:00
parent 9a6d21100b
commit 3dcb792131
3 changed files with 1038 additions and 11 deletions

View File

@@ -0,0 +1,652 @@
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)
}