主体:多语言支持(简体中文源 + 英/日/韩)
- 基础设施: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>
177 lines
6.0 KiB
Swift
177 lines
6.0 KiB
Swift
import SwiftUI
|
|
import SwiftData
|
|
import Foundation
|
|
|
|
/// 长期监测系列在 Trends 折线图里的展示桶。
|
|
/// 单系列(血糖/体重/...)= 1 个 SeriesLine;血压特殊 = 收缩 + 舒张 2 条线同卡。
|
|
struct SeriesBucket: Identifiable {
|
|
let id: String
|
|
let title: String
|
|
let unit: String
|
|
let lines: [SeriesLine]
|
|
let latestDate: Date
|
|
|
|
struct SeriesLine: Identifiable {
|
|
let id: String
|
|
let seriesKey: String
|
|
let label: String?
|
|
let color: Color
|
|
let points: [Point]
|
|
let referenceRange: ClosedRange<Double>?
|
|
|
|
var latestPoint: Point? { points.last }
|
|
}
|
|
|
|
struct Point: Identifiable, Hashable {
|
|
let id: String
|
|
let date: Date
|
|
let value: Double
|
|
let status: IndicatorStatus
|
|
}
|
|
}
|
|
|
|
extension SeriesBucket {
|
|
/// 把全表 Indicator(无 seriesKey 的会被跳过)折成 SeriesBucket 列表。
|
|
/// 同 seriesKey 内按 capturedAt 升序;BP 两个 key 合并成一个 bucket;
|
|
/// `minPoints` 以下的系列不返回,默认 2(单点不画线)。
|
|
static func build(from indicators: [Indicator],
|
|
profile: UserProfile? = nil,
|
|
customMetrics: [CustomMonitorMetric] = [],
|
|
minPoints: Int = 2) -> [SeriesBucket] {
|
|
var buckets: [String: [Indicator]] = [:]
|
|
for i in indicators {
|
|
guard let key = i.seriesKey, !key.isEmpty else { continue }
|
|
buckets[key, default: []].append(i)
|
|
}
|
|
|
|
// 合并血压
|
|
let bpKeys: Set<String> = ["bp.systolic", "bp.diastolic"]
|
|
let bpIndicators = bpKeys.flatMap { buckets[$0] ?? [] }
|
|
let bpHasEnoughPoints = bpIndicators.filter { $0.seriesKey == "bp.systolic" }.count >= minPoints
|
|
|
|
var results: [SeriesBucket] = []
|
|
|
|
if bpHasEnoughPoints {
|
|
results.append(buildBP(from: buckets, profile: profile))
|
|
}
|
|
for k in bpKeys { buckets.removeValue(forKey: k) }
|
|
|
|
let customByKey: [String: CustomMonitorMetric] = Dictionary(
|
|
uniqueKeysWithValues: customMetrics.map { ($0.seriesKey, $0) }
|
|
)
|
|
|
|
for (key, items) in buckets {
|
|
guard items.count >= minPoints else { continue }
|
|
if let bucket = buildSingle(key: key, items: items,
|
|
profile: profile,
|
|
custom: customByKey[key]) {
|
|
results.append(bucket)
|
|
}
|
|
}
|
|
|
|
return results.sorted { $0.latestDate > $1.latestDate }
|
|
}
|
|
|
|
private static func buildSingle(key: String,
|
|
items: [Indicator],
|
|
profile: UserProfile?,
|
|
custom: CustomMonitorMetric? = nil) -> SeriesBucket? {
|
|
let sorted = items.sorted { $0.capturedAt < $1.capturedAt }
|
|
guard let latest = sorted.last else { return nil }
|
|
|
|
// 优先 custom,其次 builtin metric,最后 fallback 到 Indicator 自身
|
|
let metric = monitorMetric(for: key)
|
|
let field = metric?.fields.first { $0.seriesKey == key }
|
|
let title = custom?.name
|
|
?? metric?.displayName
|
|
?? sorted.first?.name
|
|
?? key
|
|
let unit = custom?.unit.nonEmptyOr(nil)
|
|
?? field?.unit
|
|
?? sorted.first?.unit
|
|
?? ""
|
|
let range = custom?.referenceRange
|
|
?? field.flatMap { metric?.effectiveRange(for: $0, profile: profile) }
|
|
|
|
let line = SeriesLine(
|
|
id: key,
|
|
seriesKey: key,
|
|
label: nil,
|
|
color: Tj.Palette.ink,
|
|
points: sorted.compactMap { point(from: $0) },
|
|
referenceRange: range
|
|
)
|
|
|
|
return SeriesBucket(
|
|
id: key,
|
|
title: title,
|
|
unit: unit,
|
|
lines: [line],
|
|
latestDate: latest.capturedAt
|
|
)
|
|
}
|
|
|
|
private static func buildBP(from buckets: [String: [Indicator]],
|
|
profile: UserProfile?) -> SeriesBucket {
|
|
let m = MonitorMetric.bloodPressure
|
|
let sysField = m.fields[0]
|
|
let diaField = m.fields[1]
|
|
|
|
let sysItems = (buckets["bp.systolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt }
|
|
let diaItems = (buckets["bp.diastolic"] ?? []).sorted { $0.capturedAt < $1.capturedAt }
|
|
|
|
let sysLine = SeriesLine(
|
|
id: "bp.systolic",
|
|
seriesKey: "bp.systolic",
|
|
label: String(appLoc: "收缩"),
|
|
color: Tj.Palette.brick,
|
|
points: sysItems.compactMap { point(from: $0) },
|
|
referenceRange: m.effectiveRange(for: sysField, profile: profile)
|
|
)
|
|
let diaLine = SeriesLine(
|
|
id: "bp.diastolic",
|
|
seriesKey: "bp.diastolic",
|
|
label: String(appLoc: "舒张"),
|
|
color: Tj.Palette.leaf,
|
|
points: diaItems.compactMap { point(from: $0) },
|
|
referenceRange: m.effectiveRange(for: diaField, profile: profile)
|
|
)
|
|
|
|
let latest = max(
|
|
sysItems.last?.capturedAt ?? .distantPast,
|
|
diaItems.last?.capturedAt ?? .distantPast
|
|
)
|
|
|
|
return SeriesBucket(
|
|
id: "bp",
|
|
title: String(appLoc: "血压"),
|
|
unit: "mmHg",
|
|
lines: [sysLine, diaLine],
|
|
latestDate: latest
|
|
)
|
|
}
|
|
|
|
private static func point(from i: Indicator) -> Point? {
|
|
guard let v = Double(i.value.trimmingCharacters(in: .whitespaces)) else { return nil }
|
|
return Point(
|
|
id: "\(i.persistentModelID)",
|
|
date: i.capturedAt,
|
|
value: v,
|
|
status: i.status
|
|
)
|
|
}
|
|
|
|
private static func monitorMetric(for seriesKey: String) -> MonitorMetric? {
|
|
MonitorMetric.allCases.first { m in
|
|
m.fields.contains { $0.seriesKey == seriesKey }
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension String {
|
|
/// 空串 → fallback;非空 → 自身。
|
|
func nonEmptyOr(_ fallback: String?) -> String? {
|
|
trimmingCharacters(in: .whitespaces).isEmpty ? fallback : self
|
|
}
|
|
}
|