feat(profile,monitor): ProfileEditView + MeView 卡片 + IndicatorQuickSheet 改造
- ProfileEditView Form 风格,即改即存,onDisappear 触发 ctx.save - basics(出生年 / 性别 / 身高 / 血型) - 慢病 chips(8 预设 + 自定义) - allergies / familyHistory / medications 通用 list section - FlowLayout(Layout 协议自实现)用于 chip 流式换行 - MeView 改造:NavigationStack + ProfileCard 显示 summaryLine, 3 个 settings 卡片(模型 / Face ID / 关于)stub,DEBUG 块仍在底部 - IndicatorQuickSheet 整合 MonitorMetric: - 顶部 LazyVGrid 2 列展示 8 个 MonitorMetric(进趋势) - 下方 horizontal scroll 化验项快捷(不进趋势) - 选血压切到 2 字段 UI(收缩/舒张),保存写 2 条 Indicator(同 capturedAt) - 选单字段 monitor:自动算 status,锁 name/unit/range - 选 lab preset:辅助填 name/unit/range,status 手动 - 自由输入路径不变 - 身高 monitor 保存时回写 UserProfile.heightCM - Profile-aware range hint:'按 67 岁调整' 仅在 effectiveRange 不同于 baseRange 时显示
This commit is contained in:
276
康康/Features/Profile/ProfileEditView.swift
Normal file
276
康康/Features/Profile/ProfileEditView.swift
Normal file
@@ -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<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)
|
||||
}
|
||||
Reference in New Issue
Block a user