feat(sync): add iCloud sync via NSUbiquitousKeyValueStore with merge strategy

This commit is contained in:
saravanakumardb1 2026-02-27 22:23:45 -08:00
parent 5a9a855ec9
commit 01c0f5759e

View File

@ -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")
}