diff --git a/康康/Features/Indicator/IndicatorQuickSheet.swift b/康康/Features/Indicator/IndicatorQuickSheet.swift new file mode 100644 index 0000000..d4e6376 --- /dev/null +++ b/康康/Features/Indicator/IndicatorQuickSheet.swift @@ -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, 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) +} diff --git a/康康/Features/Me/MeView.swift b/康康/Features/Me/MeView.swift index af71dab..d4acaa3 100644 --- a/康康/Features/Me/MeView.swift +++ b/康康/Features/Me/MeView.swift @@ -1,20 +1,119 @@ import SwiftUI +import SwiftData struct MeView: View { - var body: some View { - ScrollView { - VStack(spacing: 16) { - TjPlaceholder(label: "我的 · 模型管理 / Face ID / 关于\n(W6 实现)") - .frame(width: 280, height: 180) + @Environment(\.modelContext) private var ctx + @Query private var profiles: [UserProfile] - #if DEBUG - DebugAIRunner() - #endif + private var profile: UserProfile? { profiles.first } + + 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) +} diff --git a/康康/Features/Profile/ProfileEditView.swift b/康康/Features/Profile/ProfileEditView.swift new file mode 100644 index 0000000..ef3b070 --- /dev/null +++ b/康康/Features/Profile/ProfileEditView.swift @@ -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) -> 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: 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) +}