Files
KisaniCal/KisaniCal/Managers/CloudSyncManager.swift
kutesir 3a35783331 workout: fix iCloud sync stomping the Watch-logged streak, remove Undo (KC-67)
Root cause: CloudSyncManager.kvStoreChanged blindly overwrote any
externally-changed iCloud key, including workoutDates - a lifetime
tally that every local write path (HealthKit sync, manual finish)
carefully dedups/unions. A stale iCloud snapshot racing a fresh
Watch-sync write could silently replace the correct array with an
older, smaller one. Now unions instead of overwriting for that key
specifically.

Also: removed the Undo action/row from DotProgressCard entirely (Watch/
Health is the source of truth once a day is logged), and restored the
confirmation dialog on Finish Workout so every state-changing action
confirms first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 22:57:13 +00:00

115 lines
4.3 KiB
Swift

import Foundation
// Mirrors UserDefaults to iCloud Key-Value Store.
// On fresh install, restores from iCloud so settings/data survive reinstalls and new builds.
// Silently no-ops when iCloud is unavailable.
final class CloudSyncManager {
static let shared = CloudSyncManager()
private let kv = NSUbiquitousKeyValueStore.default
private let maxBytes = 900_000 // stay under 1 MB total limit
// Settings keys that are always small enough to sync unconditionally
private let settingsKeys: [String] = [
"appearanceMode", "showMatrixTab", "showWorkoutTab",
"bodyWeightKg", "weightUnit",
"kisani.tutorial.done.v1", "kisani.tutorial.today.v1", "kisani.tutorial.workout.v1",
"kisani.cal.hiddenIDs", "kisani.cal.localEnabled", "kisani.cal.dnd",
"workoutReminderEnabled", "workoutCheckInEnabled",
"workoutHour", "workoutMinute", "checkInHour", "checkInMinute",
]
private init() {
NotificationCenter.default.addObserver(
self,
selector: #selector(kvStoreChanged(_:)),
name: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: kv
)
kv.synchronize()
}
// MARK: - Write
/// Push a Data blob to iCloud (tasks, workout programs, etc.)
func push(data: Data, forKey key: String) {
guard data.count < maxBytes else { return }
kv.set(data, forKey: key)
kv.synchronize()
}
/// Push any plist-compatible value to iCloud (strings, numbers, arrays, dicts)
func push(value: Any, forKey key: String) {
kv.set(value, forKey: key)
kv.synchronize()
}
// MARK: - Read / Restore
/// Copy iCloud value into UserDefaults ONLY when UserDefaults has no value yet.
/// This safely restores data after a fresh install or new build without overwriting live data.
func restoreIfMissing(key: String) {
guard UserDefaults.kisani.object(forKey: key) == nil,
let icloudValue = kv.object(forKey: key)
else { return }
UserDefaults.kisani.set(icloudValue, forKey: key)
}
// MARK: - Bulk helpers
/// Backup all small settings keys from UserDefaults iCloud
func backupSettings() {
for key in settingsKeys {
if let val = UserDefaults.kisani.object(forKey: key) {
kv.set(val, forKey: key)
}
}
kv.synchronize()
}
/// Restore all settings keys from iCloud UserDefaults where missing
func restoreSettings() {
for key in settingsKeys {
restoreIfMissing(key: key)
}
}
// MARK: - External change handler
/// When another device (or a reinstall) writes to iCloud KV store,
/// pull the updated values into UserDefaults and notify the app.
@objc private func kvStoreChanged(_ notification: Notification) {
guard let info = notification.userInfo,
let changedKeys = info[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String]
else { return }
for key in changedKeys {
guard let val = kv.object(forKey: key) else { continue }
if key.hasPrefix("kisani.workout.completedDates."), let incoming = val as? [String] {
// Workout dates are an append-only lifetime tally, and the Watch/
// HealthKit sync writes here independently of this device a
// blind overwrite can race a fresh local write with a stale
// iCloud snapshot and silently drop already-logged days. Union
// instead, so an external change can only ever add days, never
// erase ones this device already knows about.
let existing = Set(UserDefaults.kisani.stringArray(forKey: key) ?? [])
UserDefaults.kisani.set(Array(existing.union(incoming)), forKey: key)
} else {
UserDefaults.kisani.set(val, forKey: key)
}
}
DispatchQueue.main.async {
NotificationCenter.default.post(
name: .kisaniCloudDataRefreshed,
object: nil,
userInfo: ["keys": changedKeys]
)
}
}
}
extension Notification.Name {
static let kisaniCloudDataRefreshed = Notification.Name("kisani.cloud.refreshed")
}