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:
@@ -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 = "<group>"; };
|
||||
9100804DB1E61EA882CC54DA /* FloatingTabState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FloatingTabState.swift; sourceTree = "<group>"; };
|
||||
93D045FE3DEB1D22D908A29F /* NotificationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationManager.swift; sourceTree = "<group>"; };
|
||||
CC00DD11EE22FF33AA44BB55 /* CloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudSyncManager.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
ADF6CCD95A587E26E30F5712 /* CalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarView.swift; sourceTree = "<group>"; };
|
||||
B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; };
|
||||
B38135942FCAF0BD0037DC41 /* KisaniCal.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = KisaniCal.entitlements; sourceTree = "<group>"; };
|
||||
B38135952FCAF9CE0037DC41 /* HealthKitManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthKitManager.swift; sourceTree = "<group>"; };
|
||||
BC5E179A9189B0A8C3F856F6 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
@@ -60,8 +64,6 @@
|
||||
F3A4B5C6D7E8F9A0B1C2D3E4 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = "<group>"; };
|
||||
F7A8B9C0D1E2F3A4B5C6D7E8 /* AuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthView.swift; sourceTree = "<group>"; };
|
||||
F8A9B0C1D2E3F4A5B6C7D8E9 /* ProfileSetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSetupView.swift; sourceTree = "<group>"; };
|
||||
A0B1C2D3E4F5A6B7C8D9E0F1 /* TutorialManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialManager.swift; sourceTree = "<group>"; };
|
||||
B1C2D3E4F5A6B7C8D9E0F1A2 /* TutorialView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TutorialView.swift; sourceTree = "<group>"; };
|
||||
/* 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;
|
||||
|
||||
@@ -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