From b67de2454af837cfe088f209332e275e5dfe9637 Mon Sep 17 00:00:00 2001 From: Matt Bruce Date: Mon, 16 Feb 2026 16:30:19 -0600 Subject: [PATCH] Signed-off-by: Matt Bruce --- Andromida/App/State/RitualStore.swift | 75 ++++++++--------------- SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md | 6 +- SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md | 33 +++++----- 3 files changed, 45 insertions(+), 69 deletions(-) diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift index fd46514..14c06fc 100644 --- a/Andromida/App/State/RitualStore.swift +++ b/Andromida/App/State/RitualStore.swift @@ -85,7 +85,9 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { runDataIntegrityMigrationIfNeeded() loadRitualsIfNeeded() if !isRunningTests { - observeRemoteChanges() + startObservingCloudKitRemoteChanges { + WidgetCenter.shared.reloadAllTimelines() + } } } @@ -93,27 +95,6 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { nowProvider() } - /// Observes CloudKit remote change notifications to auto-refresh UI when iCloud data syncs. - private func observeRemoteChanges() { - cloudKitSyncManager.startObserving { [weak self] in - self?.handleRemoteStoreChange() - } - } - - private func handleRemoteStoreChange() { - let result = cloudKitSyncManager.processObservedRemoteChange( - modelContext: &modelContext, - modelContainer: modelContainer - ) - Design.debugLog( - "RitualStore.CloudKitSync: received remote store change #\(result.eventCount); " + - "rebuiltContext=\(result.didRebuildModelContext); reloading rituals" - ) - reloadData() - // Also refresh widgets when data arrives from other devices - WidgetCenter.shared.reloadAllTimelines() - } - var activeRitual: Ritual? { currentRituals.first } @@ -198,7 +179,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { SoundManager.shared.playSystemSound(SystemSound.success) } } - saveContext() + saveAndReload() } func ritualDayIndex(for ritual: Ritual) -> Int { @@ -322,7 +303,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { var arcs = ritual.arcs ?? [] arcs.append(newArc) ritual.arcs = arcs - saveContext() + saveAndReload() } /// Starts a new arc for a past ritual (one without an active arc). @@ -340,7 +321,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { if let currentArc = ritual.currentArc { currentArc.isActive = false - saveContext() + saveAndReload() } } @@ -709,7 +690,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { durationDays: defaultDuration, habits: habits ) - saveContext() + saveAndReload() } private func createRitualWithInitialArc( @@ -783,7 +764,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { durationDays: durationDays, habits: habits ) - saveContext() + saveAndReload() } /// Creates a ritual from a preset template @@ -823,7 +804,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { durationDays: preset.durationDays, habits: habits ) - saveContext() + saveAndReload() return ritual } @@ -852,13 +833,13 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { currentArc.endDate = newEndDate } - saveContext() + saveAndReload() } /// Permanently deletes a ritual and all its history func deleteRitual(_ ritual: Ritual) { modelContext.delete(ritual) - saveContext() + saveAndReload() } /// Adds a habit to the current arc of a ritual @@ -870,7 +851,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { var updatedHabits = habits updatedHabits.append(habit) arc.habits = updatedHabits - saveContext() + saveAndReload() } /// Removes a habit from the current arc of a ritual @@ -880,7 +861,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { habits.removeAll { $0.id == habit.id } arc.habits = habits modelContext.delete(habit) - saveContext() + saveAndReload() } private func loadRitualsIfNeeded() { @@ -1003,20 +984,18 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { } } - private func saveContext() { - do { - try modelContext.save() - reloadData() - // Widget timeline reloads can destabilize test hosts; skip in tests. - if !isRunningTests { - WidgetCenter.shared.reloadAllTimelines() - } - // Trigger a UI refresh for observation-based views - analyticsNeedsRefresh = true - insightCardsNeedRefresh = true - } catch { - lastErrorMessage = error.localizedDescription + func didSaveAndReloadData() { + // Widget timeline reloads can destabilize test hosts; skip in tests. + if !isRunningTests { + WidgetCenter.shared.reloadAllTimelines() } + // Trigger a UI refresh for observation-based views + analyticsNeedsRefresh = true + insightCardsNeedRefresh = true + } + + func handleSaveAndReloadError(_ error: Error) { + lastErrorMessage = error.localizedDescription } private func updateDerivedData() { @@ -1539,7 +1518,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate } - saveContext() + saveAndReload() } /// Clears all completion data (for testing). @@ -1551,7 +1530,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { } } } - saveContext() + saveAndReload() } /// Simulates arc completion by setting the first active arc's end date to yesterday. @@ -1572,7 +1551,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore { let arcDuration = arc.durationDays arc.startDate = calendar.date(byAdding: .day, value: -arcDuration, to: yesterday) ?? yesterday - saveContext() + saveAndReload() // Trigger the completion check - this will set ritualNeedingRenewal checkForCompletedArcs() diff --git a/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md b/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md index 10be9d1..14450d3 100644 --- a/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md +++ b/SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md @@ -42,9 +42,10 @@ Entitlements should reference variables, not hard-coded values, where possible. - Prefer Bedrock `SwiftDataCloudKitSyncManager` as the reusable remote observer/lifecycle component. - Prefer Bedrock `SwiftDataStore` + `SwiftDataCloudKitStore` to avoid app-specific pulse boilerplate. - Observe `.NSPersistentStoreRemoteChange` to detect remote merges. -- On remote change, call Bedrock `processObservedRemoteChange(modelContext:modelContainer:)` before refetch. +- Use Bedrock `startObservingCloudKitRemoteChanges(...)` for default remote-change handling. - Rebuild long-lived contexts only when safe (`hasChanges == false`) to avoid dropping unsaved local edits. - Implement protocol reload hook (`reloadData`) to run your store-specific fetch step. +- Prefer Bedrock `saveAndReload()` + protocol hooks (`didSaveAndReloadData`, `handleSaveAndReloadError`) instead of local save wrappers. - Keep iOS-on-Mac pulsing loop in the root scene lifecycle (`active` only, cancel on `background`). - Keep a foreground fallback refresh as a safety net; gate it with Bedrock `hasReceivedRemoteChange(since:)`. - Emit structured logs for remote sync events (event count + timestamp) for debugging. @@ -80,8 +81,9 @@ Before shipping any new SwiftData+CloudKit app: - [ ] Capabilities: iCloud/CloudKit + Push + Remote Notifications are enabled - [ ] Entitlements include `aps-environment` and correct container IDs - [ ] xcconfig defines `APS_ENVIRONMENT` per configuration -- [ ] Remote change observer uses Bedrock `processObservedRemoteChange(...)` + reloads data +- [ ] Remote change observer uses Bedrock `startObservingCloudKitRemoteChanges(...)` - [ ] Store conforms to Bedrock `SwiftDataCloudKitStore` +- [ ] Save flows use Bedrock `saveAndReload()` and protocol hooks (no local wrapper duplication) - [ ] Foreground fallback is gated by Bedrock `hasReceivedRemoteChange(since:)` - [ ] UI has deterministic invalidation on remote reload - [ ] Two-device batch-update test passes without manual refresh diff --git a/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md b/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md index 5bffafd..d09b561 100644 --- a/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md +++ b/SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md @@ -83,25 +83,7 @@ final class AppDataStore: SwiftDataCloudKitStore { self.modelContainer = modelContext.container self.modelContext = modelContext reloadEntities() - observeRemoteChanges() - } - - private func observeRemoteChanges() { - cloudKitSyncManager.startObserving { [weak self] in - self?.handleRemoteStoreChange() - } - } - - private func handleRemoteStoreChange() { - let result = cloudKitSyncManager.processObservedRemoteChange( - modelContext: &modelContext, - modelContainer: modelContainer - ) - Design.debugLog( - "Received remote store change #\(result.eventCount); " + - "rebuiltContext=\(result.didRebuildModelContext); reloading" - ) - reloadEntities() + startObservingCloudKitRemoteChanges() } func refresh() { @@ -112,6 +94,19 @@ final class AppDataStore: SwiftDataCloudKitStore { reloadEntities() } + func updateEntity(_ entity: PrimaryEntity) { + // mutate entity fields... + saveAndReload() + } + + func didSaveAndReloadData() { + // optional post-save side effects + } + + func handleSaveAndReloadError(_ error: Error) { + Design.debugLog("AppDataStore: save failed: \(error.localizedDescription)") + } + private func reloadEntities() { do { entities = try modelContext.fetch(FetchDescriptor())