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:
link2026
2026-05-30 10:28:24 +08:00
parent 910ca99f21
commit d2c77d5c51
84 changed files with 15643 additions and 699 deletions

View File

@@ -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