From 01c0f5759e1dc592899158ff19c61032a9cf3719 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 27 Feb 2026 22:23:45 -0800 Subject: [PATCH] feat(sync): add iCloud sync via NSUbiquitousKeyValueStore with merge strategy --- .../Shared/Cloud/CloudKitSyncManager.swift | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 ios/ChronoMind/Shared/Cloud/CloudKitSyncManager.swift diff --git a/ios/ChronoMind/Shared/Cloud/CloudKitSyncManager.swift b/ios/ChronoMind/Shared/Cloud/CloudKitSyncManager.swift new file mode 100644 index 0000000..15cadcb --- /dev/null +++ b/ios/ChronoMind/Shared/Cloud/CloudKitSyncManager.swift @@ -0,0 +1,168 @@ +// ── 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 { + stopSync() + } + + // 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") +}