- Fix CloudKitSyncManager deinit concurrency violation - Replace deprecated List(selection:) with VStack+ForEach sidebar - Replace removed Animation.none with Animation.linear(duration: 0) - Fix CountdownRing initializer parameter mismatch - Unwrap optional timer.duration in ShareableTimerManager and DataExportManager - Add missing .event case to exhaustive switch - Change CascadeWarning.scheduledTime from let to var - Fix CalendarSyncManager CMTimer init (add elapsedBeforePause, remove non-existent params) - Add missing UserNotifications import in SleepManager - Remove parameterized App Intents phrases (iOS 26 restriction) - Temporarily remove watchOS target dependency for iOS build
171 lines
5.3 KiB
Swift
171 lines
5.3 KiB
Swift
// ── 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")
|
|
}
|