feat: iCloud KV Store sync for tasks, workout data, and settings
- Add NSUbiquitousKeyValueStore entitlement to KisaniCal.entitlements - New CloudSyncManager: mirrors UserDefaults to iCloud KV Store on every save; restores to UserDefaults on fresh install when local data is missing; observes external changes to pull updates in real-time; guards against 1 MB limit - TaskViewModel: restore from iCloud on init, push to iCloud on every save - WorkoutViewModel: restore programs/schedule/activePid/completedDates from iCloud on init, push all four keys on every save - ContentView: restoreSettings() + backupSettings() on appear and foreground — syncs ~18 small settings keys (appearance, tabs, tutorial flags, calendar prefs, workout notifications) Co-Authored-By: Kutesir <tqwyy79vzn@privaterelay.appleid.com>
This commit is contained in:
@@ -1,17 +1,92 @@
|
||||
{
|
||||
"images" : [
|
||||
{ "filename" : "AppIcon-20@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "20x20" },
|
||||
{ "filename" : "AppIcon-20@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "20x20" },
|
||||
{ "filename" : "AppIcon-29@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "29x29" },
|
||||
{ "filename" : "AppIcon-29@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "29x29" },
|
||||
{ "filename" : "AppIcon-40@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "40x40" },
|
||||
{ "filename" : "AppIcon-40@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "40x40" },
|
||||
{ "filename" : "AppIcon-60@2x.png", "idiom" : "iphone", "scale" : "2x", "size" : "60x60" },
|
||||
{ "filename" : "AppIcon-60@3x.png", "idiom" : "iphone", "scale" : "3x", "size" : "60x60" },
|
||||
{ "filename" : "AppIcon-76@1x.png", "idiom" : "ipad", "scale" : "1x", "size" : "76x76" },
|
||||
{ "filename" : "AppIcon-76@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "76x76" },
|
||||
{ "filename" : "AppIcon-83.5@2x.png", "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" },
|
||||
{ "filename" : "AppIcon-1024.png", "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" }
|
||||
{
|
||||
"filename" : "AppIcon-20@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-20@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-29@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-29@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-40@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-40@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-60@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-60@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-76@1x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-76@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-83.5@2x.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "AppIcon-1024.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
|
||||
@@ -84,11 +84,14 @@ struct ContentView: View {
|
||||
}
|
||||
.onAppear {
|
||||
if !hasOnboarded { showOnboarding = true }
|
||||
CloudSyncManager.shared.restoreSettings()
|
||||
CloudSyncManager.shared.backupSettings()
|
||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
if phase == .active {
|
||||
taskVM.reload()
|
||||
CloudSyncManager.shared.backupSettings()
|
||||
NotificationManager.shared.reschedule(workoutVM: workoutVM, taskVM: taskVM)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,7 @@
|
||||
<array>
|
||||
<string>Default</string>
|
||||
</array>
|
||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
104
KisaniCal/Managers/CloudSyncManager.swift
Normal file
104
KisaniCal/Managers/CloudSyncManager.swift
Normal file
@@ -0,0 +1,104 @@
|
||||
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.standard.object(forKey: key) == nil,
|
||||
let icloudValue = kv.object(forKey: key)
|
||||
else { return }
|
||||
UserDefaults.standard.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.standard.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 {
|
||||
if let val = kv.object(forKey: key) {
|
||||
UserDefaults.standard.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")
|
||||
}
|
||||
@@ -216,6 +216,12 @@ class WorkoutViewModel: ObservableObject {
|
||||
self.kActivePid = "kisani.workout.activePid.\(uid)"
|
||||
self.kWorkoutDates = "kisani.workout.completedDates.\(uid)"
|
||||
|
||||
// Restore all workout keys from iCloud if missing locally (fresh install / new build)
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.programs.\(uid)")
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.schedule.\(uid)")
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.activePid.\(uid)")
|
||||
CloudSyncManager.shared.restoreIfMissing(key: "kisani.workout.completedDates.\(uid)")
|
||||
|
||||
// ── Attempt to restore persisted state ──
|
||||
if let data = UserDefaults.standard.data(forKey: kPrograms),
|
||||
let saved = try? JSONDecoder().decode([WorkoutProgram].self, from: data),
|
||||
@@ -283,11 +289,15 @@ class WorkoutViewModel: ObservableObject {
|
||||
private func save() {
|
||||
if let data = try? JSONEncoder().encode(programs) {
|
||||
UserDefaults.standard.set(data, forKey: kPrograms)
|
||||
CloudSyncManager.shared.push(data: data, forKey: kPrograms)
|
||||
}
|
||||
let schedDict = schedule.reduce(into: [String: String]()) { $0[String($1.key)] = $1.value.uuidString }
|
||||
UserDefaults.standard.set(schedDict, forKey: kSchedule)
|
||||
UserDefaults.standard.set(activeProgramId.uuidString, forKey: kActivePid)
|
||||
UserDefaults.standard.set(workoutDates, forKey: kWorkoutDates)
|
||||
CloudSyncManager.shared.push(value: schedDict, forKey: kSchedule)
|
||||
CloudSyncManager.shared.push(value: activeProgramId.uuidString, forKey: kActivePid)
|
||||
CloudSyncManager.shared.push(value: workoutDates, forKey: kWorkoutDates)
|
||||
}
|
||||
|
||||
func logWorkoutCompleted(on date: Date = Date()) {
|
||||
|
||||
@@ -129,6 +129,7 @@ class TaskViewModel: ObservableObject {
|
||||
init() {
|
||||
let uid = AuthManager.shared.currentUser?.id ?? "shared"
|
||||
self.persistKey = "kisani.tasks.v2.\(uid)"
|
||||
CloudSyncManager.shared.restoreIfMissing(key: persistKey)
|
||||
if let data = UserDefaults.standard.data(forKey: persistKey),
|
||||
let saved = try? JSONDecoder().decode([TaskItem].self, from: data), !saved.isEmpty {
|
||||
tasks = saved
|
||||
@@ -147,6 +148,7 @@ class TaskViewModel: ObservableObject {
|
||||
private func save() {
|
||||
if let data = try? JSONEncoder().encode(tasks) {
|
||||
UserDefaults.standard.set(data, forKey: persistKey)
|
||||
CloudSyncManager.shared.push(data: data, forKey: persistKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user