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 weightRow bloodTypePicker } header: { Text("基本") } footer: { if let bmi = profile.bmi { Text("BMI: \(String(format: "%.1f", bmi)) \(bmiLabel(bmi))") .font(.system(size: 11)) } } } private func bmiLabel(_ bmi: Double) -> String { switch bmi { case ..<18.5: return "(偏瘦)" case ..<24: return "(正常)" case ..<28: return "(超重)" default: return "(肥胖)" } } 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 weightRow: some View { HStack { Text("体重") Spacer() TextField("kg", value: $profile.weightKG, format: .number.precision(.fractionLength(0...1))) .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) .frame(width: 80) Text("kg").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) }