feat: 国际化(i18n) en/ja/ko + App 内语言切换
主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施: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>
This commit is contained in:
@@ -17,28 +17,47 @@ struct ProfileEditView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 实际表单。`@Bindable` 让 SwiftData @Model 的字段可以 `$profile.xxx` 双向绑定。
|
||||
/// 实际表单。
|
||||
///
|
||||
/// 性能要点(为什么拆成一堆小 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
|
||||
|
||||
@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
|
||||
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)
|
||||
@@ -49,48 +68,75 @@ private struct ProfileEditForm: View {
|
||||
try? ctx.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 基本
|
||||
// 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))
|
||||
}
|
||||
}
|
||||
/// 出生年份:点击行展开 `.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)
|
||||
}
|
||||
|
||||
private func bmiLabel(_ bmi: Double) -> String {
|
||||
switch bmi {
|
||||
case ..<18.5: return "(偏瘦)"
|
||||
case ..<24: return "(正常)"
|
||||
case ..<28: return "(超重)"
|
||||
default: return "(肥胖)"
|
||||
}
|
||||
/// 年份倒序数组。本行仅在 birthYear / expanded 变化时重算,与其他字段编辑解耦;
|
||||
/// 且 `years` 只在滚轮展开(body 实际读它)时才被遍历构建。
|
||||
private var years: [Int] {
|
||||
Array((1900...currentYear).reversed())
|
||||
}
|
||||
|
||||
private var birthYearPicker: some View {
|
||||
Picker("出生年份", selection: Binding(
|
||||
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 }
|
||||
)) {
|
||||
Text("未设置").tag(0)
|
||||
ForEach((1900...currentYear).reversed(), id: \.self) { year in
|
||||
Text(String(year)).tag(year)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private var sexPicker: some View {
|
||||
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 }
|
||||
@@ -101,8 +147,15 @@ private struct ProfileEditForm: View {
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
|
||||
private var heightRow: some View {
|
||||
/// 身高:数值输入逻辑不变,只把整行变成可点聚焦区 —— 原先只有右侧 80pt 的输入框
|
||||
/// 本体能点中,标签与中间空白点了不聚焦,所以显得「不灵敏」。
|
||||
private struct HeightRow: View {
|
||||
@Bindable var profile: UserProfile
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text("身高")
|
||||
Spacer()
|
||||
@@ -110,11 +163,19 @@ private struct ProfileEditForm: View {
|
||||
.keyboardType(.numberPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 80)
|
||||
.focused($focused)
|
||||
Text("cm").foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { focused = true }
|
||||
}
|
||||
}
|
||||
|
||||
private var weightRow: some View {
|
||||
private struct WeightRow: View {
|
||||
@Bindable var profile: UserProfile
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
Text("体重")
|
||||
Spacer()
|
||||
@@ -122,11 +183,18 @@ private struct ProfileEditForm: View {
|
||||
.keyboardType(.decimalPad)
|
||||
.multilineTextAlignment(.trailing)
|
||||
.frame(width: 80)
|
||||
.focused($focused)
|
||||
Text("kg").foregroundStyle(Tj.Palette.text3)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { focused = true }
|
||||
}
|
||||
}
|
||||
|
||||
private var bloodTypePicker: some View {
|
||||
private struct BloodTypeRow: View {
|
||||
@Bindable var profile: UserProfile
|
||||
|
||||
var body: some View {
|
||||
Picker("血型", selection: $profile.bloodTypeRaw) {
|
||||
Text("不知道").tag("")
|
||||
Text("A 型").tag("A")
|
||||
@@ -135,19 +203,51 @@ private struct ProfileEditForm: View {
|
||||
Text("O 型").tag("O")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 慢病
|
||||
/// BMI 页脚:只读 heightCM + weightKG,只有这两项变化时才重算。
|
||||
private struct BMIFooter: View {
|
||||
@Bindable var profile: UserProfile
|
||||
|
||||
private var chronicSection: some View {
|
||||
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(Self.chronicPresets, id: \.self) { name in
|
||||
ForEach(presets, id: \.self) { name in
|
||||
chip(label: name,
|
||||
selected: profile.chronicConditions.contains(name)) {
|
||||
toggleCondition(name)
|
||||
toggle(name)
|
||||
}
|
||||
}
|
||||
ForEach(profile.chronicConditions.filter { !Self.chronicPresets.contains($0) },
|
||||
ForEach(profile.chronicConditions.filter { !presets.contains($0) },
|
||||
id: \.self) { name in
|
||||
chip(label: name, selected: true) {
|
||||
profile.chronicConditions.removeAll { $0 == name }
|
||||
@@ -171,56 +271,14 @@ private struct ProfileEditForm: View {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
private func toggle(_ name: String) {
|
||||
if profile.chronicConditions.contains(name) {
|
||||
profile.chronicConditions.removeAll { $0 == name }
|
||||
} else {
|
||||
profile.chronicConditions.append(name)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - helpers
|
||||
|
||||
private func chip(label: String, selected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Text(label)
|
||||
@@ -233,21 +291,47 @@ private struct ProfileEditForm: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleCondition(_ name: String) {
|
||||
if profile.chronicConditions.contains(name) {
|
||||
profile.chronicConditions.removeAll { $0 == name }
|
||||
} else {
|
||||
profile.chronicConditions.append(name)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var currentYear: Int {
|
||||
Calendar.current.component(.year, from: .now)
|
||||
}
|
||||
}
|
||||
|
||||
/// 简化版 chip 流式布局——SwiftUI 没有原生 Wrap,用 Layout 协议自实现。
|
||||
// MARK: - 流式 chip 布局(SwiftUI 无原生 Wrap,用 Layout 协议自实现)
|
||||
|
||||
struct FlexibleChipGrid<Content: View>: View {
|
||||
@ViewBuilder let content: () -> Content
|
||||
|
||||
|
||||
Reference in New Issue
Block a user