身高/体重对成人变化慢,作为 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。
306 lines
10 KiB
Swift
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)
|
|
}
|