feat(sync): add iCloud sync via NSUbiquitousKeyValueStore with merge strategy
This commit is contained in:
parent
5a9a855ec9
commit
01c0f5759e
168
ios/ChronoMind/Shared/Cloud/CloudKitSyncManager.swift
Normal file
168
ios/ChronoMind/Shared/Cloud/CloudKitSyncManager.swift
Normal 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")
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user