feat: 添加自定义提醒功能并优化项目配置 - 添加 CustomReminder 模型支持自由文案周期性提醒功能 - 实现自定义提醒的 UI 界面,包括新建、编辑和列表展示 - 集成本地通知服务支持自定义提醒的时间触发 - 更新项目配置文件添加应用显示名称和加密声明 - 修正 iOS 部署目标版本从 26.0 到 17.0 - 修复 FileDownloader 中的线程安全问题 - 优化 ModelManifest 和 Localization 的并发安全性 - 扩展本地化字符串支持多语言提醒相关文本 - 调整项目支持平台范围仅保留 iphoneos 和 iphonesimulator ```
136 lines
5.3 KiB
Swift
136 lines
5.3 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
|
|
}
|
|
}
|
|
|
|
/// 全 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)
|
|
}
|
|
}
|