// ── CloudKit Sync Manager ───────────────────────────────────── // iCloud sync for timer data using NSUbiquitousKeyValueStore // Lightweight approach: KV store for timer data (< 1MB limit) // Full CloudKit container migration in future if needed import Foundation import Combine @MainActor final class CloudKitSyncManager: ObservableObject { static let shared = CloudKitSyncManager() @Published var isSyncing = false @Published var lastSyncDate: Date? @Published var syncEnabled: Bool { didSet { UserDefaults.standard.set(syncEnabled, forKey: syncEnabledKey) if syncEnabled { startSync() } else { stopSync() } } } private let kvStore = NSUbiquitousKeyValueStore.default private let timersKey = "chronomind-timers-cloud" private let syncEnabledKey = "chronomind-cloud-sync-enabled" private let lastSyncKey = "chronomind-last-sync" private var observer: NSObjectProtocol? private init() { syncEnabled = UserDefaults.standard.bool(forKey: syncEnabledKey) if let date = UserDefaults.standard.object(forKey: lastSyncKey) as? Date { lastSyncDate = date } if syncEnabled { startSync() } } deinit { if let observer = observer { NotificationCenter.default.removeObserver(observer) } } // MARK: - Sync Control func startSync() { guard observer == nil else { return } // Listen for remote changes observer = NotificationCenter.default.addObserver( forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: kvStore, queue: .main ) { [weak self] notification in Task { @MainActor in self?.handleRemoteChange(notification) } } // Force initial sync kvStore.synchronize() } func stopSync() { if let observer = observer { NotificationCenter.default.removeObserver(observer) self.observer = nil } } // MARK: - Push to Cloud func pushTimers(_ timers: [CMTimer]) { guard syncEnabled else { return } isSyncing = true do { let data = try JSONEncoder().encode(timers) kvStore.set(data, forKey: timersKey) kvStore.synchronize() lastSyncDate = Date() UserDefaults.standard.set(lastSyncDate, forKey: lastSyncKey) } catch { // Silently fail — sync is best-effort } isSyncing = false } // MARK: - Pull from Cloud func pullTimers() -> [CMTimer]? { guard syncEnabled else { return nil } guard let data = kvStore.data(forKey: timersKey) else { return nil } do { return try JSONDecoder().decode([CMTimer].self, from: data) } catch { return nil } } // MARK: - Merge Strategy /// Merge remote timers with local timers (last-write-wins per timer ID) func mergeTimers(local: [CMTimer], remote: [CMTimer]) -> [CMTimer] { var merged: [String: CMTimer] = [:] // Add all local timers for timer in local { merged[timer.id] = timer } // Merge remote — newer state wins for remoteTimer in remote { if let localTimer = merged[remoteTimer.id] { // Compare by most recent state change let localDate = latestDate(for: localTimer) let remoteDate = latestDate(for: remoteTimer) if remoteDate > localDate { merged[remoteTimer.id] = remoteTimer } } else { // New timer from remote merged[remoteTimer.id] = remoteTimer } } return Array(merged.values).sorted { $0.targetTime < $1.targetTime } } // MARK: - Private private func handleRemoteChange(_ notification: Notification) { guard let userInfo = notification.userInfo, let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int else { return } switch reason { case NSUbiquitousKeyValueStoreServerChange, NSUbiquitousKeyValueStoreInitialSyncChange: // Remote data changed — post notification for TimerStore to handle NotificationCenter.default.post(name: .chronoMindCloudTimersDidChange, object: nil) case NSUbiquitousKeyValueStoreQuotaViolationChange: // Over 1MB limit — would need CloudKit migration break default: break } lastSyncDate = Date() UserDefaults.standard.set(lastSyncDate, forKey: lastSyncKey) } /// Get the most recent date from a timer's lifecycle private func latestDate(for timer: CMTimer) -> Date { [timer.completedAt, timer.dismissedAt, timer.firedAt, timer.pausedAt, timer.startedAt, timer.createdAt] .compactMap { $0 } .max() ?? timer.createdAt } } // MARK: - Notification Names extension Notification.Name { static let chronoMindCloudTimersDidChange = Notification.Name("chronoMindCloudTimersDidChange") }