import Observation import SwiftUI /// T-iOS-34 · 主题设置(跟随系统 / 深色 / 浅色)。 /// /// 历史:`RootView` 曾**硬锁** `.preferredColorScheme(.dark)`,理由写在注释里 /// ——「琥珀金强调色是为暖深色背景设计的,浅底上金字对比不足」。那个理由对 /// **当时的 token 取值**成立,但结论下错了:正确做法不是禁掉浅色,而是给每个 /// 语义色一个能看清的浅色值(见 `Tokens.swift` 的 light 分支与 /// `AppThemeTests` 的 WCAG 断言)。本类型就是解锁后的那个选择。 /// /// 默认值 = `.dark`,与桌面 web 的 `DEFAULT_SETTINGS.theme = 'dark'` /// (`public/settings.ts:33`)以及此前的硬锁行为一致 —— 老用户升级后观感零变化。 enum AppTheme: String, CaseIterable, Equatable, Sendable { /// 跟随系统外观(iOS 设置里的浅色/深色/自动)。 case system /// 强制深色(历史默认)。 case dark /// 强制浅色。 case light /// 注入给 SwiftUI 的 `preferredColorScheme` 值。`nil` = 不表态,交给系统 /// ——这正是「跟随系统」的实现方式,不能用当前系统值去"快照",否则用户在 /// 系统里切换外观时 App 不跟。 var colorScheme: ColorScheme? { switch self { case .system: return nil case .dark: return .dark case .light: return .light } } /// 选择器行文案(中文,与全 App 一致)。 var label: String { switch self { case .system: return "跟随系统" case .dark: return "深色" case .light: return "浅色" } } /// 选择器行图标。三档互不相同 —— 颜色之外还有形状(DS 的一贯纪律: /// 语义绝不只靠颜色传达)。 var symbolName: String { switch self { case .system: return "circle.lefthalf.filled" case .dark: return "moon.fill" case .light: return "sun.max.fill" } } /// 本设置在给定系统外观下的**有效**配色。终端画布等需要"现在到底是深还是浅" /// 的地方用它(`.system` 时答案来自环境,不是常量)。 func resolvedScheme(system: ColorScheme) -> ColorScheme { colorScheme ?? system } } // MARK: - 持久化编解码(纯函数) /// 存/读的唯一编解码点。读侧**永不抛错**:磁盘上的值是可能被改坏/被旧版本写坏的 /// 外部输入,任何不在白名单里的串都退回 `fallback`(而不是崩、也不是悄悄落到 /// 「跟随系统」——那会让用户的显式选择变成另一档)。 enum AppThemeCodec { /// 未设置 / 脏值时的落点。等于历史硬锁的深色 ⇒ 零回归。 static let fallback = AppTheme.dark static func decode(_ raw: String?) -> AppTheme { raw.flatMap(AppTheme.init(rawValue:)) ?? fallback } static func encode(_ theme: AppTheme) -> String { theme.rawValue } } // MARK: - 存储接缝 /// `UserDefaults` 的可测接缝(同 `SecItemShim` 的房规:live 实现零逻辑, /// 逻辑全在可单测的一侧)。**只存主题标识**——这里绝不放任何密级材料 /// (令牌/证书只进 Keychain,§1.1)。 protocol ThemeDefaults { func themeRaw(forKey key: String) -> String? func setThemeRaw(_ value: String, forKey key: String) } /// 直通 `UserDefaults.standard`。无存储属性 ⇒ 无并发状态。 struct LiveThemeDefaults: ThemeDefaults { func themeRaw(forKey key: String) -> String? { UserDefaults.standard.string(forKey: key) } func setThemeRaw(_ value: String, forKey key: String) { UserDefaults.standard.set(value, forKey: key) } } // MARK: - ThemeStore /// 主题的单一真值。`RootView` 持有它、注入环境;设置页读写它。 /// /// 写策略:`select` 对**同值**是 no-op(不产生多余写);读只在 init 发生一次, /// 之后内存里的 `theme` 就是真值(`@Observable` 驱动 UI)。 @MainActor @Observable final class ThemeStore { /// `UserDefaults` 键。带 App 反查前缀,避免与任何库的键撞车。 static let storageKey = "webterm.appearance.theme" private(set) var theme: AppTheme @ObservationIgnored private let defaults: any ThemeDefaults init(defaults: any ThemeDefaults = LiveThemeDefaults()) { self.defaults = defaults self.theme = AppThemeCodec.decode(defaults.themeRaw(forKey: Self.storageKey)) } /// 选一档。同值直接返回 —— 既省一次写,也避免 `@Observable` 无谓触发重绘。 func select(_ next: AppTheme) { guard next != theme else { return } theme = next defaults.setThemeRaw(AppThemeCodec.encode(next), forKey: Self.storageKey) } }