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

@@ -3,16 +3,31 @@ import SwiftUI
struct CalendarMonthGrid: View {
let monthAnchor: Date
let data: CalendarData
let selectedDate: Date?
let onTapDay: (Date) -> Void
init(monthAnchor: Date,
data: CalendarData,
selectedDate: Date? = nil,
onTapDay: @escaping (Date) -> Void) {
self.monthAnchor = monthAnchor
self.data = data
self.selectedDate = selectedDate
self.onTapDay = onTapDay
}
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2 //
c.locale = Locale(identifier: "zh_CN")
c.locale = Locale.current
return c
}()
private let weekdayLabels = ["", "", "", "", "", "", ""]
private let weekdayLabels = [
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: ""), String(appLoc: ""), String(appLoc: ""),
String(appLoc: "")
]
private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7)
private var days: [DayCell] {
@@ -64,6 +79,9 @@ struct CalendarMonthGrid: View {
ranges: data.ranges(touching: cell.date, calendar: calendar),
marks: data.marks(for: cell.date, calendar: calendar),
isToday: calendar.isDateInToday(cell.date),
isSelected: selectedDate.map {
calendar.isDate(cell.date, inSameDayAs: $0)
} ?? false,
calendar: calendar
)
.onTapGesture { onTapDay(cell.date) }
@@ -84,6 +102,7 @@ private struct DayCellView: View {
let ranges: [SymptomRange]
let marks: DayMarks
let isToday: Bool
let isSelected: Bool
let calendar: Calendar
private var dayNumber: Int {
@@ -92,14 +111,20 @@ private struct DayCellView: View {
var body: some View {
ZStack(alignment: .top) {
// :
// :selected > today
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(isToday ? Tj.Palette.sand2 : Color.clear)
.fill(backgroundFill)
//
if isSelected {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.strokeBorder(Tj.Palette.brick, lineWidth: 1.5)
}
VStack(spacing: 2) {
Text("\(dayNumber)")
.font(.system(size: 13,
weight: isToday ? .bold : .regular,
weight: (isToday || isSelected) ? .bold : .regular,
design: .default))
.foregroundStyle(textColor)
.padding(.top, 4)
@@ -145,10 +170,17 @@ private struct DayCellView: View {
private var textColor: Color {
if !cell.inCurrentMonth { return Tj.Palette.text3.opacity(0.5) }
if isSelected { return Tj.Palette.brick }
if isToday { return Tj.Palette.ink }
return Tj.Palette.text
}
private var backgroundFill: Color {
if isSelected { return Tj.Palette.brickSoft.opacity(0.5) }
if isToday { return Tj.Palette.sand2 }
return .clear
}
private func symptomBar(_ range: SymptomRange) -> some View {
let pos = range.position(cell.date, calendar: calendar)
let leadingRadius: CGFloat = (pos == .start || pos == .single) ? 3 : 0

View File

@@ -8,7 +8,7 @@ struct CalendarYearGrid: View {
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2
c.locale = Locale(identifier: "zh_CN")
c.locale = Locale.current
return c
}()
@@ -42,10 +42,7 @@ private struct MiniMonth: View {
let calendar: Calendar
private var monthLabel: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "M 月"
return f.string(from: anchor)
anchor.formatted(.dateTime.month())
}
private var days: [Date] {

View File

@@ -6,38 +6,37 @@ struct SelectedDay: Identifiable, Hashable {
var id: TimeInterval { date.timeIntervalSince1970 }
}
struct DayDetailSheet: View {
@Environment(\.modelContext) private var ctx
@Environment(\.dismiss) private var dismiss
// MARK: - DayDetailContent( inline sheet)
/// sheet , TrendsView inline 使, sheet
struct DayDetailContent: View {
let date: Date
let indicators: [Indicator]
let reports: [Report]
let diaries: [DiaryEntry]
let symptoms: [Symptom]
/// header(inline header,sheet DayDetailSheet )
var showHeader: Bool = true
@State private var endingSymptom: Symptom?
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.locale = Locale(identifier: "zh_CN")
c.locale = Locale.current
return c
}()
// MARK: -
// MARK:
private var dayIndicators: [Indicator] {
indicators.filter { calendar.isDate($0.capturedAt, inSameDayAs: date) }
}
private var dayReports: [Report] {
reports.filter { calendar.isDate($0.reportDate, inSameDayAs: date) }
}
private var dayDiaries: [DiaryEntry] {
diaries.filter { calendar.isDate($0.createdAt, inSameDayAs: date) }
}
private var daySymptoms: [(symptom: Symptom, state: SymptomDayState)] {
symptoms.compactMap { s in
let start = calendar.startOfDay(for: s.startedAt)
@@ -52,90 +51,54 @@ struct DayDetailSheet: View {
return (s, state)
}
}
private var totalCount: Int {
dayIndicators.count + dayReports.count + dayDiaries.count + daySymptoms.count
}
// MARK: - body
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
header
.padding(.horizontal, 20)
.padding(.bottom, 12)
VStack(alignment: .leading, spacing: 14) {
if showHeader { header }
if totalCount == 0 {
emptyState
} else {
ScrollView(showsIndicators: false) {
VStack(alignment: .leading, spacing: 18) {
if !daySymptoms.isEmpty {
section("症状", count: daySymptoms.count) {
VStack(spacing: 8) {
ForEach(daySymptoms, id: \.symptom.id) { item in
symptomRow(item.symptom, state: item.state)
}
}
}
}
if !dayIndicators.isEmpty {
section("指标", count: dayIndicators.count) {
VStack(spacing: 8) {
ForEach(dayIndicators) { i in
indicatorRow(i)
}
}
}
}
if !dayReports.isEmpty {
section("报告", count: dayReports.count) {
VStack(spacing: 8) {
ForEach(dayReports) { r in
reportRow(r)
}
}
}
}
if !dayDiaries.isEmpty {
section("日记", count: dayDiaries.count) {
VStack(spacing: 8) {
ForEach(dayDiaries) { d in
diaryRow(d)
}
}
if !daySymptoms.isEmpty {
section(String(appLoc: "症状"), count: daySymptoms.count) {
VStack(spacing: 8) {
ForEach(daySymptoms, id: \.symptom.id) { item in
symptomRow(item.symptom, state: item.state)
}
}
}
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
if !dayIndicators.isEmpty {
section(String(appLoc: "指标"), count: dayIndicators.count) {
VStack(spacing: 8) {
ForEach(dayIndicators) { i in indicatorRow(i) }
}
}
}
if !dayReports.isEmpty {
section(String(appLoc: "报告"), count: dayReports.count) {
VStack(spacing: 8) {
ForEach(dayReports) { r in reportRow(r) }
}
}
}
if !dayDiaries.isEmpty {
section(String(appLoc: "日记"), count: dayDiaries.count) {
VStack(spacing: 8) {
ForEach(dayDiaries) { d in diaryRow(d) }
}
}
}
}
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
.sheet(item: $endingSymptom) { sym in
SymptomEndSheet(symptom: sym)
}
}
// MARK: - header
// MARK: header
private var header: some View {
HStack(alignment: .firstTextBaseline) {
@@ -145,7 +108,7 @@ struct DayDetailSheet: View {
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
Text(dayLabel)
.font(.tjTitle(28))
.font(.tjTitle(22))
.foregroundStyle(Tj.Palette.text)
}
Spacer()
@@ -158,27 +121,18 @@ struct DayDetailSheet: View {
}
private var dateLine: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "yyyy 年"
return f.string(from: date) + " · " + weekdayLabel
date.formatted(.dateTime.year()) + " · " + weekdayLabel
}
private var dayLabel: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "M 月 d 日"
return f.string(from: date)
date.formatted(.dateTime.month().day())
}
private var weekdayLabel: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = "EEEE"
return f.string(from: date)
date.formatted(.dateTime.weekday(.wide))
}
// MARK: - section
// MARK: section helper
private func section<Content: View>(_ title: String,
count: Int,
@@ -198,27 +152,30 @@ struct DayDetailSheet: View {
}
}
// MARK: - rows
// MARK: rows
private func symptomRow(_ s: Symptom, state: SymptomDayState) -> some View {
HStack(spacing: 12) {
Capsule()
.fill(severityColor(s.severity))
.frame(width: 4, height: 36)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(s.name)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Tj.Palette.text)
stateBadge(state, isOngoing: s.isOngoing)
Text(state.badge)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(state.badgeFg)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(state.badgeBg))
}
Text("\(state.subtitle) · 持续 \(formatDuration(s.duration))")
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
}
Spacer(minLength: 6)
if s.isOngoing {
Button {
endingSymptom = s
@@ -237,15 +194,6 @@ struct DayDetailSheet: View {
.tjCard(bordered: true)
}
private func stateBadge(_ state: SymptomDayState, isOngoing: Bool) -> some View {
Text(state.badge)
.font(.system(size: 10, weight: .semibold))
.foregroundStyle(state.badgeFg)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Capsule().fill(state.badgeBg))
}
private func indicatorRow(_ i: Indicator) -> some View {
HStack(spacing: 12) {
ZStack {
@@ -256,7 +204,6 @@ struct DayDetailSheet: View {
.foregroundStyle(indicatorAccent(i))
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(i.name)
.font(.system(size: 14, weight: .medium))
@@ -269,7 +216,6 @@ struct DayDetailSheet: View {
}
}
Spacer(minLength: 6)
Text("\(i.value) \(i.unit)\(arrow(i))")
.font(.system(size: 13, weight: .semibold, design: .monospaced))
.foregroundStyle(i.status == .normal ? Tj.Palette.text2 : Tj.Palette.brick)
@@ -291,7 +237,6 @@ struct DayDetailSheet: View {
.foregroundStyle(Tj.Palette.ink2)
}
.frame(width: 32, height: 32)
VStack(alignment: .leading, spacing: 2) {
Text(r.title)
.font(.system(size: 14, weight: .medium))
@@ -331,23 +276,19 @@ struct DayDetailSheet: View {
.tjCard(bordered: true)
}
// MARK: - empty
private var emptyState: some View {
VStack(spacing: 12) {
Spacer(minLength: 16)
TjPlaceholder(label: "这一天还没有记录")
.frame(width: 220, height: 120)
VStack(spacing: 8) {
TjPlaceholder(label: String(appLoc: "这一天还没有记录"))
.frame(height: 90)
.frame(maxWidth: 240)
Text("点底部 + 号可以补一条")
.font(.system(size: 12))
.font(.system(size: 11))
.foregroundStyle(Tj.Palette.text3)
Spacer()
}
.padding(.vertical, 12)
.frame(maxWidth: .infinity)
}
// MARK: - utils
private func severityColor(_ value: Int) -> Color {
switch value {
case 1, 2: return Tj.Palette.leaf
@@ -369,22 +310,65 @@ struct DayDetailSheet: View {
}
}
// MARK: - Sheet wrapper(; TrendsView inline,)
struct DayDetailSheet: View {
let date: Date
let indicators: [Indicator]
let reports: [Report]
let diaries: [DiaryEntry]
let symptoms: [Symptom]
var body: some View {
VStack(spacing: 0) {
Capsule()
.fill(Tj.Palette.line)
.frame(width: 40, height: 4)
.padding(.top, 10)
.padding(.bottom, 14)
ScrollView(showsIndicators: false) {
DayDetailContent(
date: date,
indicators: indicators,
reports: reports,
diaries: diaries,
symptoms: symptoms,
showHeader: true
)
.padding(.horizontal, 20)
.padding(.bottom, 24)
}
}
.background(
Tj.Palette.sand
.clipShape(RoundedRectangle(cornerRadius: Tj.Radius.xl, style: .continuous))
.ignoresSafeArea(edges: .bottom)
)
.presentationDetents([.medium, .large])
.presentationDragIndicator(.hidden)
.presentationBackground(Tj.Palette.sand)
.presentationCornerRadius(Tj.Radius.xl)
}
}
// MARK: - SymptomDayState
enum SymptomDayState {
case startedToday, ongoing, endedToday
var subtitle: String {
switch self {
case .startedToday: return "今天开始"
case .ongoing: return "进行中"
case .endedToday: return "今天结束"
case .startedToday: return String(appLoc: "今天开始")
case .ongoing: return String(appLoc: "进行中")
case .endedToday: return String(appLoc: "今天结束")
}
}
var badge: String {
switch self {
case .startedToday: return "开始"
case .ongoing: return "持续"
case .endedToday: return "结束"
case .startedToday: return String(appLoc: "开始")
case .ongoing: return String(appLoc: "持续")
case .endedToday: return String(appLoc: "结束")
}
}

View File

@@ -123,7 +123,7 @@ extension SeriesBucket {
let sysLine = SeriesLine(
id: "bp.systolic",
seriesKey: "bp.systolic",
label: "收缩",
label: String(appLoc: "收缩"),
color: Tj.Palette.brick,
points: sysItems.compactMap { point(from: $0) },
referenceRange: m.effectiveRange(for: sysField, profile: profile)
@@ -131,7 +131,7 @@ extension SeriesBucket {
let diaLine = SeriesLine(
id: "bp.diastolic",
seriesKey: "bp.diastolic",
label: "舒张",
label: String(appLoc: "舒张"),
color: Tj.Palette.leaf,
points: diaItems.compactMap { point(from: $0) },
referenceRange: m.effectiveRange(for: diaField, profile: profile)
@@ -144,7 +144,7 @@ extension SeriesBucket {
return SeriesBucket(
id: "bp",
title: "血压",
title: String(appLoc: "血压"),
unit: "mmHg",
lines: [sysLine, diaLine],
latestDate: latest

View File

@@ -165,10 +165,10 @@ struct SeriesChartCard: View {
let days = Calendar.current.dateComponents([.day],
from: dom.lowerBound,
to: dom.upperBound).day ?? 0
if days <= 0 { return "今天" }
if days < 30 { return "\(days)" }
if days < 365 { return "\(days / 30) 个月" }
return "\(days / 365)"
if days <= 0 { return String(appLoc: "今天") }
if days < 30 { return String(appLoc: "\(days)") }
if days < 365 { return String(appLoc: "\(days / 30) 个月") }
return String(appLoc: "\(days / 365)")
}
private func formatValue(_ v: Double) -> String {

View File

@@ -6,8 +6,8 @@ enum CalendarMode: String, CaseIterable, Identifiable {
var id: String { rawValue }
var label: String {
switch self {
case .month: return ""
case .year: return ""
case .month: return String(appLoc: "")
case .year: return String(appLoc: "")
}
}
}
@@ -31,7 +31,8 @@ struct TrendsView: View {
@State private var mode: CalendarMode = .month
@State private var anchor: Date = .now
@State private var selectedDay: SelectedDay?
/// , inline
@State private var selectedDate: Date = .now
private var profile: UserProfile? { profiles.first }
@@ -44,7 +45,7 @@ struct TrendsView: View {
private let calendar: Calendar = {
var c = Calendar(identifier: .gregorian)
c.firstWeekday = 2
c.locale = Locale(identifier: "zh_CN")
c.locale = Locale.current
return c
}()
@@ -66,6 +67,9 @@ struct TrendsView: View {
anchorBar
calendarBody
legend
if mode == .month {
dayDetailInline
}
seriesSection
}
.padding(.horizontal, 20)
@@ -73,15 +77,31 @@ struct TrendsView: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(Tj.Palette.sand.ignoresSafeArea())
.sheet(item: $selectedDay) { sel in
DayDetailSheet(
date: sel.date,
}
/// inline (symptoms / indicators / reports / diaries)
private var dayDetailInline: some View {
VStack(alignment: .leading, spacing: 0) {
DayDetailContent(
date: selectedDate,
indicators: indicators,
reports: reports,
diaries: diaries,
symptoms: symptoms
symptoms: symptoms,
showHeader: true
)
.padding(14)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.fill(Tj.Palette.paper)
)
.overlay(
RoundedRectangle(cornerRadius: Tj.Radius.md, style: .continuous)
.strokeBorder(Tj.Palette.lineSoft, lineWidth: 1)
)
.animation(.snappy(duration: 0.2), value: selectedDate)
}
private var header: some View {
@@ -91,7 +111,10 @@ struct TrendsView: View {
.foregroundStyle(Tj.Palette.text)
Spacer()
Button {
anchor = .now
withAnimation(.snappy(duration: 0.2)) {
anchor = .now
selectedDate = .now
}
} label: {
Text("回到今天")
.font(.system(size: 12))
@@ -164,18 +187,20 @@ struct TrendsView: View {
}
private var anchorTitle: String {
let f = DateFormatter()
f.locale = Locale(identifier: "zh_CN")
f.dateFormat = mode == .month ? "yyyy 年 M 月" : "yyyy 年"
return f.string(from: anchor)
let style: Date.FormatStyle = mode == .month
? .dateTime.year().month()
: .dateTime.year()
return anchor.formatted(style)
}
@ViewBuilder
private var calendarBody: some View {
switch mode {
case .month:
CalendarMonthGrid(monthAnchor: anchor, data: data) { day in
selectedDay = SelectedDay(date: day)
CalendarMonthGrid(monthAnchor: anchor, data: data, selectedDate: selectedDate) { day in
withAnimation(.snappy(duration: 0.2)) {
selectedDate = day
}
}
.padding(14)
.background(
@@ -231,10 +256,10 @@ struct TrendsView: View {
.tracking(0.5)
.foregroundStyle(Tj.Palette.text3)
HStack(spacing: 14) {
legendItem(color: Tj.Palette.brick, label: "指标异常")
legendItem(color: Tj.Palette.amber, label: "症状持续中")
legendItem(color: Tj.Palette.ink2, label: "报告归档")
legendItem(color: Tj.Palette.leaf, label: "正常")
legendItem(color: Tj.Palette.brick, label: String(appLoc: "指标异常"))
legendItem(color: Tj.Palette.amber, label: String(appLoc: "症状持续中"))
legendItem(color: Tj.Palette.ink2, label: String(appLoc: "报告归档"))
legendItem(color: Tj.Palette.leaf, label: String(appLoc: "正常"))
}
}
.padding(.top, 4)
@@ -268,6 +293,14 @@ struct TrendsView: View {
if let next = calendar.date(byAdding: component, value: delta, to: anchor) {
withAnimation(.snappy) {
anchor = next
// selection :() 1
if mode == .month {
if calendar.isDate(next, equalTo: .now, toGranularity: .month) {
selectedDate = .now
} else if let first = calendar.dateInterval(of: .month, for: next)?.start {
selectedDate = first
}
}
}
}
}