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) } } } } /// 实际表单。 /// /// 性能要点(为什么拆成一堆小 Row 子视图): /// SwiftData `@Model` 走 Observation,谁读了某个属性谁才会因它变化而失效。 /// 早期这页把所有字段塞进一个 `body`,任何一次按键(包括「添加过敏/用药」输入框, /// 它们的 `@State` 当时也挂在父视图上)都会重算整个 `body`,顺带把年份选择器里 /// 126 个 `Text(year)` 全部重建一遍 → 输入卡顿。 /// /// 现在的写法: /// - `ProfileEditForm.body` 不读任何 `profile.*`、不持有随打字变化的 `@State`, /// 所以编辑过程中它整体不再重算,只是组合一批子视图。 /// - 每个 Row / Section 子视图只读自己那一个字段,Observation 把失效范围收到单行。 /// - 各「添加条目」输入框的 `@State` 下沉进各自的 Section 子视图,敲字只重算那一节。 /// - 年份用「点开展开 .wheel 滚轮」,折叠时不构建 126 项,展开时由原生 /// UIPickerView 虚拟化承载,秒开。 private struct ProfileEditForm: View { @Environment(\.modelContext) private var ctx @Bindable var profile: UserProfile var body: some View { Form { Section { BirthYearRow(profile: profile) SexRow(profile: profile) HeightRow(profile: profile) WeightRow(profile: profile) BloodTypeRow(profile: profile) } header: { Text("基本") } footer: { BMIFooter(profile: profile) } ChronicSection(profile: profile) StringListSection(title: String(appLoc: "过敏史"), placeholder: String(appLoc: "如:青霉素"), items: $profile.allergies) StringListSection(title: String(appLoc: "家族史"), placeholder: String(appLoc: "如:母亲 高血压"), items: $profile.familyHistory) StringListSection(title: String(appLoc: "当前用药"), placeholder: String(appLoc: "如:缬沙坦 80mg qd"), items: $profile.currentMedications) } .navigationTitle("个人资料") .navigationBarTitleDisplayMode(.inline) .scrollContentBackground(.hidden) .background(Tj.Palette.sand.ignoresSafeArea()) .onDisappear { profile.updatedAt = .now try? ctx.save() } } } // MARK: - 基本:逐行子视图(各自只读一个字段,失效互不牵连) /// 出生年份:点击行展开 `.wheel` 滚轮,折叠时只是一行文字 —— 不构建 126 项列表。 private struct BirthYearRow: View { @Bindable var profile: UserProfile @State private var expanded = false private var currentYear: Int { Calendar.current.component(.year, from: .now) } /// 年份倒序数组。本行仅在 birthYear / expanded 变化时重算,与其他字段编辑解耦; /// 且 `years` 只在滚轮展开(body 实际读它)时才被遍历构建。 private var years: [Int] { Array((1900...currentYear).reversed()) } private var selectedLabel: String { if let y = profile.birthYear { let age = currentYear - y return age >= 0 ? "\(y)(\(age)\(String(appLoc: "岁")))" : String(y) } return String(appLoc: "未设置") } private var yearBinding: Binding { Binding( get: { profile.birthYear ?? 0 }, set: { profile.birthYear = $0 == 0 ? nil : $0 } ) } var body: some View { Button { withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() } } label: { HStack { Text("出生年份").foregroundStyle(Tj.Palette.text) Spacer() Text(selectedLabel) .foregroundStyle(profile.birthYear == nil ? Tj.Palette.text3 : Tj.Palette.text2) Image(systemName: "chevron.right") .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Tj.Palette.text3) .rotationEffect(.degrees(expanded ? 90 : 0)) } .contentShape(Rectangle()) } .buttonStyle(.plain) if expanded { Picker("出生年份", selection: yearBinding) { Text("未设置").tag(0) ForEach(years, id: \.self) { year in Text(String(year)).tag(year) } } .pickerStyle(.wheel) .frame(maxHeight: 140) } } } private struct SexRow: View { @Bindable var profile: UserProfile var body: 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) } } /// 身高:数值输入逻辑不变,只把整行变成可点聚焦区 —— 原先只有右侧 80pt 的输入框 /// 本体能点中,标签与中间空白点了不聚焦,所以显得「不灵敏」。 private struct HeightRow: View { @Bindable var profile: UserProfile @FocusState private var focused: Bool var body: some View { HStack { Text("身高") Spacer() TextField("cm", value: $profile.heightCM, format: .number) .keyboardType(.numberPad) .multilineTextAlignment(.trailing) .frame(width: 80) .focused($focused) Text("cm").foregroundStyle(Tj.Palette.text3) } .contentShape(Rectangle()) .onTapGesture { focused = true } } } private struct WeightRow: View { @Bindable var profile: UserProfile @FocusState private var focused: Bool var body: some View { HStack { Text("体重") Spacer() TextField("kg", value: $profile.weightKG, format: .number.precision(.fractionLength(0...1))) .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) .frame(width: 80) .focused($focused) Text("kg").foregroundStyle(Tj.Palette.text3) } .contentShape(Rectangle()) .onTapGesture { focused = true } } } private struct BloodTypeRow: View { @Bindable var profile: UserProfile var body: some View { Picker("血型", selection: $profile.bloodTypeRaw) { Text("不知道").tag("") Text("A 型").tag("A") Text("B 型").tag("B") Text("AB 型").tag("AB") Text("O 型").tag("O") } } } /// BMI 页脚:只读 heightCM + weightKG,只有这两项变化时才重算。 private struct BMIFooter: View { @Bindable var profile: UserProfile var body: some View { if let bmi = profile.bmi { Text("BMI: \(String(format: "%.1f", bmi)) \(label(bmi))") .font(.system(size: 11)) } } private func label(_ bmi: Double) -> String { switch bmi { case ..<18.5: return String(appLoc: "(偏瘦)") case ..<24: return String(appLoc: "(正常)") case ..<28: return String(appLoc: "(超重)") default: return String(appLoc: "(肥胖)") } } } // MARK: - 慢病 private struct ChronicSection: View { @Bindable var profile: UserProfile @State private var newCustomCondition = "" /// 计算属性形式:每次按当前语言解析,语言切换即时更新(不可用 static/let 缓存)。 private var presets: [String] { [String(appLoc: "高血压"), String(appLoc: "糖尿病"), String(appLoc: "冠心病"), String(appLoc: "高血脂"), String(appLoc: "甲状腺疾病"), String(appLoc: "哮喘"), String(appLoc: "慢性肾病"), String(appLoc: "抑郁/焦虑")] } var body: some View { Section { FlexibleChipGrid { ForEach(presets, id: \.self) { name in chip(label: name, selected: profile.chronicConditions.contains(name)) { toggle(name) } } ForEach(profile.chronicConditions.filter { !presets.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 解读)") } } private func toggle(_ name: String) { if profile.chronicConditions.contains(name) { profile.chronicConditions.removeAll { $0 == name } } else { profile.chronicConditions.append(name) } } 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) } } // MARK: - 过敏 / 家族史 / 用药(每节自带 @State,敲字只重算本节) private struct StringListSection: View { let title: String let placeholder: String @Binding var items: [String] @State private var newInput = "" var body: some View { Section(title) { ForEach(items, id: \.self) { item in HStack { Text(item) Spacer() Button(role: .destructive) { items.removeAll { $0 == item } } label: { Image(systemName: "minus.circle") .foregroundStyle(Tj.Palette.brick) } .buttonStyle(.borderless) } } HStack { TextField(placeholder, text: $newInput) Button("加") { let trimmed = newInput.trimmingCharacters(in: .whitespaces) guard !trimmed.isEmpty, !items.contains(trimmed) else { return } items.append(trimmed) newInput = "" } .disabled(newInput.trimmingCharacters(in: .whitespaces).isEmpty) } } } } // MARK: - 流式 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) }