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:
652
康康/Features/Indicator/IndicatorQuickSheet.swift
Normal file
652
康康/Features/Indicator/IndicatorQuickSheet.swift
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,20 +1,119 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
struct MeView: View {
|
struct MeView: View {
|
||||||
var body: some View {
|
@Environment(\.modelContext) private var ctx
|
||||||
ScrollView {
|
@Query private var profiles: [UserProfile]
|
||||||
VStack(spacing: 16) {
|
|
||||||
TjPlaceholder(label: "我的 · 模型管理 / Face ID / 关于\n(W6 实现)")
|
|
||||||
.frame(width: 280, height: 180)
|
|
||||||
|
|
||||||
#if DEBUG
|
private var profile: UserProfile? { profiles.first }
|
||||||
DebugAIRunner()
|
|
||||||
#endif
|
var body: some View {
|
||||||
|
NavigationStack {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
profileCard
|
||||||
|
settingsCard(title: "模型管理",
|
||||||
|
detail: "未配置",
|
||||||
|
icon: "cpu")
|
||||||
|
settingsCard(title: "Face ID 启动锁",
|
||||||
|
detail: "关闭",
|
||||||
|
icon: "faceid")
|
||||||
|
settingsCard(title: "关于",
|
||||||
|
detail: "v0.1 · W2",
|
||||||
|
icon: "info.circle")
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
DebugAIRunner()
|
||||||
|
.padding(.top, 8)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.navigationTitle("我的")
|
||||||
|
.navigationBarTitleDisplayMode(.large)
|
||||||
|
.onAppear {
|
||||||
|
if profiles.isEmpty {
|
||||||
|
_ = UserProfileStore.loadOrCreate(in: ctx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 24)
|
|
||||||
}
|
}
|
||||||
.background(Tj.Palette.sand.ignoresSafeArea())
|
}
|
||||||
|
|
||||||
|
// MARK: - Cards
|
||||||
|
|
||||||
|
private var profileCard: some View {
|
||||||
|
NavigationLink {
|
||||||
|
ProfileEditView()
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(Tj.Palette.amber.opacity(0.25))
|
||||||
|
Image(systemName: "person.crop.circle.fill")
|
||||||
|
.font(.system(size: 22))
|
||||||
|
.foregroundStyle(Tj.Palette.ink)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("个人资料")
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Text(profileLine)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func settingsCard(title: String, detail: String, icon: String) -> some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ZStack {
|
||||||
|
Circle().fill(Tj.Palette.sand2)
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 18))
|
||||||
|
.foregroundStyle(Tj.Palette.text2)
|
||||||
|
}
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text)
|
||||||
|
Spacer()
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
.padding(14)
|
||||||
|
.tjCard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private var profileLine: String {
|
||||||
|
guard let p = profile, p.hasAnyBasics else {
|
||||||
|
return "点这里完善你的资料"
|
||||||
|
}
|
||||||
|
return p.summaryLine
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#Preview { MeView() }
|
#Preview {
|
||||||
|
MeView()
|
||||||
|
.modelContainer(for: [
|
||||||
|
UserProfile.self, Indicator.self, Report.self, DiaryEntry.self,
|
||||||
|
Asset.self, ChatTurn.self, Symptom.self,
|
||||||
|
], inMemory: true)
|
||||||
|
}
|
||||||
|
|||||||
276
康康/Features/Profile/ProfileEditView.swift
Normal file
276
康康/Features/Profile/ProfileEditView.swift
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
/// 「我的 · 个人资料」编辑页。Form 风格,即改即存(无显式 Save 按钮)。
|
||||||
|
/// UserProfile 是 SwiftData 单例:进入时通过 UserProfileStore.loadOrCreate 拿到。
|
||||||
|
struct ProfileEditView: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Query private var profiles: [UserProfile]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let p = profiles.first {
|
||||||
|
ProfileEditForm(profile: p)
|
||||||
|
} else {
|
||||||
|
ProgressView()
|
||||||
|
.onAppear { _ = UserProfileStore.loadOrCreate(in: ctx) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 实际表单。`@Bindable` 让 SwiftData @Model 的字段可以 `$profile.xxx` 双向绑定。
|
||||||
|
private struct ProfileEditForm: View {
|
||||||
|
@Environment(\.modelContext) private var ctx
|
||||||
|
@Bindable var profile: UserProfile
|
||||||
|
|
||||||
|
@State private var newAllergy = ""
|
||||||
|
@State private var newFamilyEntry = ""
|
||||||
|
@State private var newMedication = ""
|
||||||
|
@State private var newCustomCondition = ""
|
||||||
|
|
||||||
|
private static let chronicPresets = [
|
||||||
|
"高血压", "糖尿病", "冠心病", "高血脂",
|
||||||
|
"甲状腺疾病", "哮喘", "慢性肾病", "抑郁/焦虑",
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Form {
|
||||||
|
basicsSection
|
||||||
|
chronicSection
|
||||||
|
allergySection
|
||||||
|
familySection
|
||||||
|
medicationSection
|
||||||
|
}
|
||||||
|
.navigationTitle("个人资料")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
|
.background(Tj.Palette.sand.ignoresSafeArea())
|
||||||
|
.onDisappear {
|
||||||
|
profile.updatedAt = .now
|
||||||
|
try? ctx.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 基本
|
||||||
|
|
||||||
|
private var basicsSection: some View {
|
||||||
|
Section("基本") {
|
||||||
|
birthYearPicker
|
||||||
|
sexPicker
|
||||||
|
heightRow
|
||||||
|
bloodTypePicker
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var birthYearPicker: some View {
|
||||||
|
Picker("出生年份", selection: Binding(
|
||||||
|
get: { profile.birthYear ?? 0 },
|
||||||
|
set: { profile.birthYear = $0 == 0 ? nil : $0 }
|
||||||
|
)) {
|
||||||
|
Text("未设置").tag(0)
|
||||||
|
ForEach((1900...currentYear).reversed(), id: \.self) { year in
|
||||||
|
Text(String(year)).tag(year)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sexPicker: some View {
|
||||||
|
Picker("性别", selection: Binding(
|
||||||
|
get: { profile.sex },
|
||||||
|
set: { profile.sex = $0 }
|
||||||
|
)) {
|
||||||
|
ForEach(UserProfile.Sex.allCases, id: \.self) { s in
|
||||||
|
Text(s.label).tag(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var heightRow: some View {
|
||||||
|
HStack {
|
||||||
|
Text("身高")
|
||||||
|
Spacer()
|
||||||
|
TextField("cm", value: $profile.heightCM, format: .number)
|
||||||
|
.keyboardType(.numberPad)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
.frame(width: 80)
|
||||||
|
Text("cm").foregroundStyle(Tj.Palette.text3)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var bloodTypePicker: some View {
|
||||||
|
Picker("血型", selection: $profile.bloodTypeRaw) {
|
||||||
|
Text("不知道").tag("")
|
||||||
|
Text("A 型").tag("A")
|
||||||
|
Text("B 型").tag("B")
|
||||||
|
Text("AB 型").tag("AB")
|
||||||
|
Text("O 型").tag("O")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 慢病
|
||||||
|
|
||||||
|
private var chronicSection: some View {
|
||||||
|
Section {
|
||||||
|
FlexibleChipGrid {
|
||||||
|
ForEach(Self.chronicPresets, id: \.self) { name in
|
||||||
|
chip(label: name,
|
||||||
|
selected: profile.chronicConditions.contains(name)) {
|
||||||
|
toggleCondition(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(profile.chronicConditions.filter { !Self.chronicPresets.contains($0) },
|
||||||
|
id: \.self) { name in
|
||||||
|
chip(label: name, selected: true) {
|
||||||
|
profile.chronicConditions.removeAll { $0 == name }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
TextField("自定义慢病", text: $newCustomCondition)
|
||||||
|
Button("加") {
|
||||||
|
let trimmed = newCustomCondition.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty,
|
||||||
|
!profile.chronicConditions.contains(trimmed) else { return }
|
||||||
|
profile.chronicConditions.append(trimmed)
|
||||||
|
newCustomCondition = ""
|
||||||
|
}
|
||||||
|
.disabled(newCustomCondition.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("慢病(影响参考范围与 AI 解读)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - 过敏 / 家族史 / 用药
|
||||||
|
|
||||||
|
private var allergySection: some View {
|
||||||
|
listSection(title: "过敏史", placeholder: "如:青霉素",
|
||||||
|
items: $profile.allergies, newInput: $newAllergy)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var familySection: some View {
|
||||||
|
listSection(title: "家族史", placeholder: "如:母亲 高血压",
|
||||||
|
items: $profile.familyHistory, newInput: $newFamilyEntry)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var medicationSection: some View {
|
||||||
|
listSection(title: "当前用药", placeholder: "如:缬沙坦 80mg qd",
|
||||||
|
items: $profile.currentMedications, newInput: $newMedication)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func listSection(title: String, placeholder: String,
|
||||||
|
items: Binding<[String]>,
|
||||||
|
newInput: Binding<String>) -> some View {
|
||||||
|
Section(title) {
|
||||||
|
ForEach(items.wrappedValue, id: \.self) { item in
|
||||||
|
HStack {
|
||||||
|
Text(item)
|
||||||
|
Spacer()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
items.wrappedValue.removeAll { $0 == item }
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "minus.circle")
|
||||||
|
.foregroundStyle(Tj.Palette.brick)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
HStack {
|
||||||
|
TextField(placeholder, text: newInput)
|
||||||
|
Button("加") {
|
||||||
|
let trimmed = newInput.wrappedValue.trimmingCharacters(in: .whitespaces)
|
||||||
|
guard !trimmed.isEmpty,
|
||||||
|
!items.wrappedValue.contains(trimmed) else { return }
|
||||||
|
items.wrappedValue.append(trimmed)
|
||||||
|
newInput.wrappedValue = ""
|
||||||
|
}
|
||||||
|
.disabled(newInput.wrappedValue.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - helpers
|
||||||
|
|
||||||
|
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, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Capsule().fill(selected ? Tj.Palette.ink : Tj.Palette.paper))
|
||||||
|
.overlay(Capsule().strokeBorder(Tj.Palette.line, lineWidth: selected ? 0 : 1))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleCondition(_ name: String) {
|
||||||
|
if profile.chronicConditions.contains(name) {
|
||||||
|
profile.chronicConditions.removeAll { $0 == name }
|
||||||
|
} else {
|
||||||
|
profile.chronicConditions.append(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var currentYear: Int {
|
||||||
|
Calendar.current.component(.year, from: .now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 简化版 chip 流式布局——SwiftUI 没有原生 Wrap,用 Layout 协议自实现。
|
||||||
|
struct FlexibleChipGrid<Content: View>: View {
|
||||||
|
@ViewBuilder let content: () -> Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
FlowLayout { content() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct FlowLayout: Layout {
|
||||||
|
var spacing: CGFloat = 6
|
||||||
|
|
||||||
|
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||||
|
let maxWidth = proposal.width ?? .infinity
|
||||||
|
var rows: [CGFloat] = [0]
|
||||||
|
var rowMaxHeight: [CGFloat] = [0]
|
||||||
|
var x: CGFloat = 0
|
||||||
|
for s in subviews {
|
||||||
|
let size = s.sizeThatFits(.unspecified)
|
||||||
|
if x + size.width > maxWidth, x > 0 {
|
||||||
|
rows.append(0); rowMaxHeight.append(0)
|
||||||
|
x = 0
|
||||||
|
}
|
||||||
|
rows[rows.count - 1] = max(rows[rows.count - 1], x + size.width)
|
||||||
|
rowMaxHeight[rowMaxHeight.count - 1] = max(rowMaxHeight.last ?? 0, size.height)
|
||||||
|
x += size.width + spacing
|
||||||
|
}
|
||||||
|
let totalHeight = rowMaxHeight.reduce(0, +) + spacing * CGFloat(max(0, rows.count - 1))
|
||||||
|
let totalWidth = rows.max() ?? 0
|
||||||
|
return CGSize(width: totalWidth, height: totalHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||||
|
var x: CGFloat = bounds.minX
|
||||||
|
var y: CGFloat = bounds.minY
|
||||||
|
var rowHeight: CGFloat = 0
|
||||||
|
for s in subviews {
|
||||||
|
let size = s.sizeThatFits(.unspecified)
|
||||||
|
if x + size.width > bounds.maxX, x > bounds.minX {
|
||||||
|
x = bounds.minX
|
||||||
|
y += rowHeight + spacing
|
||||||
|
rowHeight = 0
|
||||||
|
}
|
||||||
|
s.place(at: CGPoint(x: x, y: y), proposal: ProposedViewSize(size))
|
||||||
|
x += size.width + spacing
|
||||||
|
rowHeight = max(rowHeight, size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#Preview {
|
||||||
|
NavigationStack {
|
||||||
|
ProfileEditView()
|
||||||
|
}
|
||||||
|
.modelContainer(for: [UserProfile.self], inMemory: true)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user