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>
115 lines
4.3 KiB
Swift
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")
|
|
}
|