主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施:Localizable.xcstrings(String Catalog,sourceLanguage=zh-Hans)
+ pbxproj developmentRegion/knownRegions 注册 en/ja/ko
- 全部硬编码 Locale("zh_CN") → Locale.current;中文 dateFormat → Date.FormatStyle(跟随系统)
- UI 中文字面量统一为 String(appLoc:)(显式绑定所选语言 bundle+locale,即时切换)
Text 字面量走环境 \.locale + Bundle 重定向
- 549 个 catalog key 全部 en/ja/ko 翻译完成(0 未翻译)
- App 内语言切换:我的 → 语言(LanguageManager + 即时生效,无需重启)
- 双用预设(症状/监测指标/慢病)本地化:static→computed 避免缓存
注:本提交为 WIP,一并打包了并行进行的功能模块
(HealthExport 健康导出、Security/Face ID 锁、DiaryAssist 日记 AI 辅助)
及 App 图标、CLAUDE.md、docs/scripts。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
390 lines
14 KiB
Swift
390 lines
14 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) }
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 实际表单。
|
|
///
|
|
/// 性能要点(为什么拆成一堆小 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<Int> {
|
|
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<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)
|
|
}
|