feat(AI): 优化AIRuntime任务取消机制并增强安全保护 - 在AI推理流中添加Task.checkCancellation()检查,使消费者取消时能快速退出 - 为异步流添加onTermination回调以取消内部Task,与LLMSession一致 - 实现SwiftData store的completeUnlessOpen文件保护,提升数据安全性 - 在store备份过程中同样应用加密保护 feat(home): 优化主页交互体验并统一详情查看功能 - 在主页"最近记录"中点击任意条目可打开只读详情sheet - 将时间线详情解析逻辑统一收敛到TimelineDetail.resolve方法 - 修复血压条目的精确反查逻辑,避免时间窗匹配错误 feat(archive): 新增提醒任务汇总卡并完善档案库功能 - 在档案库页面新增提醒任务汇总卡,显示总数和启用状态 - 添加按更新时间倒序合并的提醒标题预览功能 - 实现RemindersListView导航路由,统一管理提醒任务 - 优化导出列表显示,优先使用中文标签展示 feat(me): 优化个人中心界面并改进语言设置体验 - 将个人中心标题改为内容文字渲染,解决导航栏背景问题 - 为语言选择器添加个性化图标,使用本族语代表字区分 - 修复语言设置视图的图标显示逻辑 feat(timeline): 新增记录详情页删除功能并优化图表显示 - 在时间线详情页添加永久删除按钮和确认弹窗 - 实现完整的删除逻辑,包括SwiftData硬删和Vault原图unlink - 修复系列图表的数值范围计算,处理同值数据的对称留白 - 优化血压图表合并逻辑,只保留有数据点的线条 refactor(calendar): 修复DST切换导致的月份天数计算错误 - 使用calendar.range(of:.day,in:.month)替代日期间隔计算 - 避免在夏令时切换月份出现天数偏差问题 fix(ui): 修复多个UI组件的交互响应区域问题 - 为纯描边按钮和胶囊添加contentShape以扩大点击区域 - 修复提醒行展开按钮尺寸,保证不同提醒类型的垂直对齐 ```
153 lines
5.9 KiB
Swift
153 lines
5.9 KiB
Swift
import SwiftUI
|
|
import ObjectiveC
|
|
|
|
/// App 支持的界面语言。`system` = 跟随系统;其余对应 .lproj / String Catalog 语言。
|
|
enum AppLanguage: String, CaseIterable, Identifiable {
|
|
case system
|
|
case zhHans = "zh-Hans"
|
|
case en
|
|
case ja
|
|
case ko
|
|
|
|
var id: String { rawValue }
|
|
|
|
/// 选择器里展示的名字。各语言用其**本族语**显示(行业惯例,不本地化),仅「跟随系统」随 App 语言。
|
|
var displayName: String {
|
|
switch self {
|
|
case .system: return String(appLoc: "跟随系统")
|
|
case .zhHans: return "简体中文"
|
|
case .en: return "English"
|
|
case .ja: return "日本語"
|
|
case .ko: return "한국어"
|
|
}
|
|
}
|
|
|
|
/// nil = 跟随系统;否则为 .lproj / Locale 标识。
|
|
var localeIdentifier: String? {
|
|
self == .system ? nil : rawValue
|
|
}
|
|
|
|
/// 语言选择器图标。各语言用本族语代表字区分(中 / A / あ / 가),
|
|
/// 「跟随系统」非具体语言,用地球符号。代表字与 `displayName` 一样不本地化。
|
|
enum PickerIcon: Equatable {
|
|
case symbol(String) // SF Symbol 名
|
|
case glyph(String) // 本族语代表字
|
|
}
|
|
|
|
var pickerIcon: PickerIcon {
|
|
switch self {
|
|
case .system: return .symbol("globe")
|
|
case .zhHans: return .glyph("中")
|
|
case .en: return .glyph("A")
|
|
case .ja: return .glyph("あ")
|
|
case .ko: return .glyph("가")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 全 App 单例。负责:持久化选择、维护当前语言的 lproj bundle 与 locale。
|
|
/// - `Text("…")` 走根视图注入的环境 `\.locale`(+ Bundle 重定向)即时切换;
|
|
/// - `String(appLoc:)` 显式绑定本管理器的 bundle/locale,不受 `.current` 限制,同样即时切换。
|
|
/// 切换后由根视图 `.id(current)` 触发整树重建,无需重启。
|
|
@Observable
|
|
final class LanguageManager {
|
|
static let shared = LanguageManager()
|
|
|
|
private let storageKey = "appLanguage"
|
|
|
|
private(set) var current: AppLanguage
|
|
/// 当前语言对应的 .lproj bundle(system 或缺失时为 .main)。缓存,切换时更新。
|
|
private(set) var lprojBundle: Bundle = .main
|
|
/// 当前解析后的 locale(system 时为 .autoupdatingCurrent)。
|
|
private(set) var resolvedLocale: Locale = .autoupdatingCurrent
|
|
|
|
/// 供 SwiftUI 环境使用(日期/数字格式化 + Text 语言)。
|
|
var locale: Locale { resolvedLocale }
|
|
|
|
private init() {
|
|
let saved = UserDefaults.standard.string(forKey: storageKey)
|
|
current = AppLanguage(rawValue: saved ?? "") ?? .system
|
|
apply()
|
|
}
|
|
|
|
func set(_ language: AppLanguage) {
|
|
guard language != current else { return }
|
|
current = language
|
|
UserDefaults.standard.set(language.rawValue, forKey: storageKey)
|
|
// 同步 AppleLanguages:保证下次冷启动解析正确,并与系统「设置 → App → 语言」一致。
|
|
if let id = language.localeIdentifier {
|
|
UserDefaults.standard.set([id], forKey: "AppleLanguages")
|
|
} else {
|
|
UserDefaults.standard.removeObject(forKey: "AppleLanguages")
|
|
}
|
|
apply()
|
|
}
|
|
|
|
private func apply() {
|
|
if let id = current.localeIdentifier {
|
|
resolvedLocale = Locale(identifier: id)
|
|
if let path = Bundle.main.path(forResource: id, ofType: "lproj"),
|
|
let b = Bundle(path: path) {
|
|
lprojBundle = b
|
|
} else {
|
|
lprojBundle = .main
|
|
}
|
|
} else {
|
|
resolvedLocale = .autoupdatingCurrent
|
|
lprojBundle = .main
|
|
}
|
|
Bundle.redirectMain(to: current.localeIdentifier)
|
|
// 同步 nonisolated 快照,供 String(appLoc:) 在非 MainActor 上下文读取。
|
|
appLocBundle = lprojBundle
|
|
appLocLocale = resolvedLocale
|
|
}
|
|
}
|
|
|
|
/// nonisolated 快照:`String(appLoc:)` 可能在非 MainActor 上下文被调用
|
|
/// (LocalizedError.errorDescription、nonisolated 枚举 label、static 解析器…)。
|
|
/// 只由 `LanguageManager.apply()`(MainActor)写入,切换语言时刷新;读为快照,无竞态影响。
|
|
nonisolated(unsafe) private var appLocBundle: Bundle = .main
|
|
nonisolated(unsafe) private var appLocLocale: Locale = .autoupdatingCurrent
|
|
|
|
extension String {
|
|
/// 尊重「我的 · 语言」选择的本地化(可即时切换)。
|
|
/// 等价 `String(localized:)`,但显式绑定当前所选语言的 bundle + locale,
|
|
/// 因此不受 `Locale.current`(系统/启动时语言)限制。
|
|
nonisolated init(appLoc key: String.LocalizationValue) {
|
|
self = String(localized: key, bundle: appLocBundle, locale: appLocLocale)
|
|
}
|
|
}
|
|
|
|
// MARK: - Bundle 运行时重定向(供 Text / NSLocalizedString 双保险)
|
|
|
|
/// 关联对象 key(需稳定地址,文件级全局即可)。
|
|
private var redirectBundleKey: UInt8 = 0
|
|
|
|
/// 把 `Bundle.main` 的字符串查表重定向到指定语言的 .lproj。
|
|
private final class LocalizedMainBundle: Bundle, @unchecked Sendable {
|
|
override func localizedString(forKey key: String, value: String?, table tableName: String?) -> String {
|
|
if let target = objc_getAssociatedObject(self, &redirectBundleKey) as? Bundle {
|
|
return target.localizedString(forKey: key, value: value, table: tableName)
|
|
}
|
|
return super.localizedString(forKey: key, value: value, table: tableName)
|
|
}
|
|
}
|
|
|
|
extension Bundle {
|
|
/// language == nil → 跟随系统(走默认解析)。
|
|
static func redirectMain(to language: String?) {
|
|
if !(Bundle.main is LocalizedMainBundle) {
|
|
object_setClass(Bundle.main, LocalizedMainBundle.self)
|
|
}
|
|
let target: Bundle?
|
|
if let language,
|
|
let path = Bundle.main.path(forResource: language, ofType: "lproj"),
|
|
let bundle = Bundle(path: path) {
|
|
target = bundle
|
|
} else {
|
|
target = nil
|
|
}
|
|
objc_setAssociatedObject(Bundle.main, &redirectBundleKey, target, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|
}
|
|
}
|