// // ThemeEngine.swift // Zyquo Atlas // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The customization engine (Phase 4.2 headline). Publishes the active theme, // layout, and any user custom themes; every change applies live (SwiftUI // re-renders because views observe this object). Persists per profile as JSON // under the Atlas data root, imports/exports themes as small JSON files, and // can follow the system light/dark appearance. // import SwiftUI import Combine @MainActor final class ThemeEngine: ObservableObject { /// The active resolved theme (built-in or custom). @Published private(set) var theme: AtlasTheme /// Layout/typography preferences (top/left tabs, density, zoom…). @Published var layout: LayoutSettings /// User-created themes (editable, exportable). @Published private(set) var customThemes: [AtlasTheme] /// When true, the engine swaps between `lightThemeID`/`darkThemeID` to match /// the system appearance. @Published var matchSystemAppearance: Bool @Published var selectedThemeID: String @Published var lightThemeID: String @Published var darkThemeID: String private let storeURL: URL private var saveTask: Task? init(profile: Profile = .defaultProfile) { let dir = PersistenceService.shared.rootDirectory storeURL = dir.appendingPathComponent("theme-\(profile.id.uuidString).json") // Defaults. var restored = Persisted() if let data = try? Data(contentsOf: storeURL), let decoded = try? JSONDecoder().decode(Persisted.self, from: data) { restored = decoded } layout = restored.layout customThemes = restored.customThemes matchSystemAppearance = restored.matchSystemAppearance selectedThemeID = restored.selectedThemeID lightThemeID = restored.lightThemeID darkThemeID = restored.darkThemeID let all = BuiltInThemes.all + restored.customThemes theme = all.first { $0.id == restored.selectedThemeID } ?? BuiltInThemes.atlasLight resolveActive() } // MARK: - Catalog var gallery: [AtlasTheme] { BuiltInThemes.all + customThemes } func theme(id: String) -> AtlasTheme? { gallery.first { $0.id == id } } // MARK: - Selection func select(_ id: String) { selectedThemeID = id if let t = theme(id: id) { if t.appearance == .dark { darkThemeID = id } else { lightThemeID = id } } matchSystemAppearance = false resolveActive() persist() } /// Called when the system appearance changes (from the view layer). func systemAppearanceChanged(toDark: Bool) { guard matchSystemAppearance else { return } selectedThemeID = toDark ? darkThemeID : lightThemeID resolveActive() } func setMatchSystem(_ on: Bool, systemIsDark: Bool) { matchSystemAppearance = on if on { selectedThemeID = systemIsDark ? darkThemeID : lightThemeID } resolveActive() persist() } // MARK: - Layout func updateLayout(_ mutate: (inout LayoutSettings) -> Void) { mutate(&layout) persist() } // MARK: - Custom themes /// Creates an editable copy of a theme and selects it. @discardableResult func duplicateForEditing(_ base: AtlasTheme, name: String) -> AtlasTheme { var copy = base copy.id = "custom-\(UUID().uuidString.prefix(8))" copy.name = name customThemes.append(copy) select(copy.id) return copy } /// Updates an existing custom theme in place (live editor). func updateCustom(_ theme: AtlasTheme) { guard let idx = customThemes.firstIndex(where: { $0.id == theme.id }) else { return } customThemes[idx] = theme if selectedThemeID == theme.id { self.theme = theme } persist() } func deleteCustom(_ id: String) { customThemes.removeAll { $0.id == id } if selectedThemeID == id { select(BuiltInThemes.defaultLightID) } persist() } // MARK: - Import / export func exportTheme(_ theme: AtlasTheme, to url: URL) throws { let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] try encoder.encode(theme).write(to: url, options: .atomic) } @discardableResult func importTheme(from url: URL) throws -> AtlasTheme { var t = try JSONDecoder().decode(AtlasTheme.self, from: Data(contentsOf: url)) // Give imported themes a fresh custom id to avoid collisions. if BuiltInThemes.theme(id: t.id) != nil || customThemes.contains(where: { $0.id == t.id }) { t.id = "custom-\(UUID().uuidString.prefix(8))" } customThemes.append(t) select(t.id) return t } // MARK: - Internals private func resolveActive() { theme = theme(id: selectedThemeID) ?? BuiltInThemes.atlasLight } private func persist() { let snapshot = Persisted( layout: layout, customThemes: customThemes, matchSystemAppearance: matchSystemAppearance, selectedThemeID: selectedThemeID, lightThemeID: lightThemeID, darkThemeID: darkThemeID) let url = storeURL saveTask?.cancel() saveTask = Task.detached(priority: .utility) { let encoder = JSONEncoder() encoder.outputFormatting = [.sortedKeys] if let data = try? encoder.encode(snapshot) { try? data.write(to: url, options: .atomic) } } } private struct Persisted: Codable { var layout = LayoutSettings() var customThemes: [AtlasTheme] = [] var matchSystemAppearance = true var selectedThemeID = BuiltInThemes.defaultLightID var lightThemeID = BuiltInThemes.defaultLightID var darkThemeID = BuiltInThemes.defaultDarkID } }