From 06475830367839e72013ce0dfc4a9816142ffec4 Mon Sep 17 00:00:00 2001 From: Robin Kutesa Date: Mon, 1 Jun 2026 20:45:26 +0300 Subject: [PATCH] feat: iCloud KV Store sync for tasks, workout data, and settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- KisaniCal.xcodeproj/project.pbxproj | 26 +++-- .../AppIcon.appiconset/Contents.json | 99 +++++++++++++++-- KisaniCal/ContentView.swift | 3 + KisaniCal/KisaniCal.entitlements | 2 + KisaniCal/Managers/CloudSyncManager.swift | 104 ++++++++++++++++++ KisaniCal/Models/ExerciseModels.swift | 10 ++ KisaniCal/Models/TaskItem.swift | 2 + 7 files changed, 225 insertions(+), 21 deletions(-) create mode 100644 KisaniCal/Managers/CloudSyncManager.swift diff --git a/KisaniCal.xcodeproj/project.pbxproj b/KisaniCal.xcodeproj/project.pbxproj index 3f1675d..f1326d9 100644 --- a/KisaniCal.xcodeproj/project.pbxproj +++ b/KisaniCal.xcodeproj/project.pbxproj @@ -18,20 +18,21 @@ 8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A4491BFA50721082024756 /* SharedComponents.swift */; }; 9070521B1D36A5551976C275 /* CalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADF6CCD95A587E26E30F5712 /* CalendarView.swift */; }; 9E3F966F6B6AF2E4F8109E51 /* WorkoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20DAD771EFCC8B3B1DBD4DD6 /* WorkoutView.swift */; }; + A1B2C3D4E5F6A7B8C9D0E1F2 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */; }; A1DBC4D2F09C02B8CEE6449E /* AddExerciseSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */; }; A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72308FEE0226F45414C04DDD /* SettingsView.swift */; }; A9FF93259AE8FF0ABF69D71A /* DesignTokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD4A35C0E1270E2E15C03F23 /* DesignTokens.swift */; }; + B2C3D4E5F6A7B8C9D0E1F2A3 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */; }; B38135962FCAF9CE0037DC41 /* HealthKitManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B38135952FCAF9CE0037DC41 /* HealthKitManager.swift */; }; BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D44530A77DF12A17E52AAF34 /* MatrixView.swift */; }; C79C33BE2802E81AA00175CC /* TodayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C786EBC7DF879D64EB28165E /* TodayView.swift */; }; CBE7295BF5ADE08FE93AFAAF /* FloatingTabState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */; }; D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001F67ADC72FBAEC03EB7E01 /* KisaniCalApp.swift */; }; + CC11DD22EE33FF44AA55BB66 /* CloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC00DD11EE22FF33AA44BB55 /* CloudSyncManager.swift */; }; EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */; }; F1A2B3C4D5E6F7A8B9C0D1E2 /* AuthManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */; }; F5A6B7C8D9E0F1A2B3C4D5E6 /* AuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */; }; F9B0C1D2E3F4A5B6C7D8E9F0 /* ProfileSetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */; }; - A1B2C3D4E5F6A7B8C9D0E1F2 /* TutorialManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */; }; - B2C3D4E5F6A7B8C9D0E1F2A3 /* TutorialView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -47,8 +48,11 @@ 768D4E314252E2A90A06CCF2 /* AddExerciseSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExerciseSheet.swift; sourceTree = ""; }; 9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = ""; }; 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = ""; }; + CC00DD11EE22FF33AA44BB55 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = ""; }; 9BD1F84B4F45B12CB9B9F1B2 /* KisaniCal.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KisaniCal.app; sourceTree = BUILT_PRODUCTS_DIR; }; + A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = ""; }; ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = ""; }; + B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = ""; }; B38135942FCAF0BD0037DC41 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = ""; }; B38135952FCAF9CE0037DC41 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = ""; }; BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -60,8 +64,6 @@ F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = ""; }; F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = ""; }; - A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = ""; }; - B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXGroup section */ @@ -156,6 +158,7 @@ F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */, B38135952FCAF9CE0037DC41 /* HealthKitManager.swift */, 93D045FE3DEB1D22D908A29F /* NotificationManager.swift */, + CC00DD11EE22FF33AA44BB55 /* CloudSyncManager.swift */, 326B77A7B317A7DB44E13EA5 /* ShortcutHandler.swift */, A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */, ); @@ -247,7 +250,8 @@ D591A72235A53D4038FBC2B4 /* KisaniCalApp.swift in Sources */, BEAFF968632A34C70B11C5AC /* MatrixView.swift in Sources */, B38135962FCAF9CE0037DC41 /* HealthKitManager.swift in Sources */, - EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */, + CC11DD22EE33FF44AA55BB66 /* CloudSyncManager.swift in Sources */, + EF03EC9F974B14D100DD5528 /* NotificationManager.swift in Sources */, 67CE1747E5DB3FDA79D0FDFD /* OnboardingView.swift in Sources */, A3B2D6622A2EE35C8D5A3C9B /* SettingsView.swift in Sources */, 8BE9D3D650B0F06169EC7048 /* SharedComponents.swift in Sources */, @@ -270,10 +274,11 @@ CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; INFOPLIST_KEY_NSCalendarsUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; INFOPLIST_KEY_NSHealthShareUsageDescription = "KisaniCal reads your steps, active calories, resting heart rate, and workout history to display your fitness stats."; @@ -282,13 +287,14 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + NEW_SETTING = ""; PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal; SDKROOT = iphoneos; SWIFT_VERSION = 5.9; @@ -429,10 +435,11 @@ CODE_SIGN_ENTITLEMENTS = KisaniCal/KisaniCal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 3; + CURRENT_PROJECT_VERSION = 8; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = "Kisani Cal"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; INFOPLIST_KEY_NSCalendarsUsageDescription = "Kisani Cal shows your calendar events alongside tasks."; INFOPLIST_KEY_NSHealthShareUsageDescription = "KisaniCal reads your steps, active calories, resting heart rate, and workout history to display your fitness stats."; @@ -441,13 +448,14 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDefault; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait"; IPHONEOS_DEPLOYMENT_TARGET = 16.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; + NEW_SETTING = ""; PRODUCT_BUNDLE_IDENTIFIER = com.kutesir.KisaniCal; SDKROOT = iphoneos; SWIFT_VERSION = 5.9; diff --git a/KisaniCal/Assets.xcassets/AppIcon.appiconset/Contents.json b/KisaniCal/Assets.xcassets/AppIcon.appiconset/Contents.json index 5fbbed4..6f28e07 100644 --- a/KisaniCal/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/KisaniCal/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -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", diff --git a/KisaniCal/ContentView.swift b/KisaniCal/ContentView.swift index 795224a..6d0639d 100644 --- a/KisaniCal/ContentView.swift +++ b/KisaniCal/ContentView.swift @@ -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) } } diff --git a/KisaniCal/KisaniCal.entitlements b/KisaniCal/KisaniCal.entitlements index 6860d2e..e84875b 100644 --- a/KisaniCal/KisaniCal.entitlements +++ b/KisaniCal/KisaniCal.entitlements @@ -8,5 +8,7 @@ Default + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) diff --git a/KisaniCal/Managers/CloudSyncManager.swift b/KisaniCal/Managers/CloudSyncManager.swift new file mode 100644 index 0000000..dc64a82 --- /dev/null +++ b/KisaniCal/Managers/CloudSyncManager.swift @@ -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") +} diff --git a/KisaniCal/Models/ExerciseModels.swift b/KisaniCal/Models/ExerciseModels.swift index 7a4d00c..be3efdd 100644 --- a/KisaniCal/Models/ExerciseModels.swift +++ b/KisaniCal/Models/ExerciseModels.swift @@ -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()) { diff --git a/KisaniCal/Models/TaskItem.swift b/KisaniCal/Models/TaskItem.swift index 11aa980..08a6f9a 100644 --- a/KisaniCal/Models/TaskItem.swift +++ b/KisaniCal/Models/TaskItem.swift @@ -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) } }