Files
kangkang/康康/Features/Profile/ProfileEditView.swift
link2026 39edc25dc1 refactor(profile,monitor): move height/weight from MonitorMetric to UserProfile
身高/体重对成人变化慢,作为 Profile 静态字段比每次录入 Indicator 更合适。

- MonitorMetric:6 case(从 8 减),删 .height / .weight
- UserProfile:加 weightKG: Double?(支持小数),加 bmi computed
- summaryLine 加体重段:'175cm · 68.5kg'(整数省小数)
- ProfileEditView basics 加 weight 行 + footer 显示 BMI + 分类(偏瘦/正常/超重/肥胖)
- IndicatorQuickSheet:删 .height 回写 Profile 的特殊逻辑
- UserProfileTests:+5 个(weight 字段、summaryLine 含 weight、BMI 计算)

兼容性:老 Indicator 里的 seriesKey 'weight' / 'height' 数据保留(SwiftData String?
不变),只是新录入路径走 Profile 不走 Indicator;Trends 仍能用 String seriesKey
查询历史(如果将来要展示老数据)。

测试:60 case pass / 0 fail / 0 warning。
2026-05-26 07:58:47 +08:00

306 lines
10 KiB
Swift

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<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)
}