Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-16 16:30:19 -06:00
parent e9de34355b
commit b67de2454a
3 changed files with 45 additions and 69 deletions

View File

@ -85,7 +85,9 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
runDataIntegrityMigrationIfNeeded() runDataIntegrityMigrationIfNeeded()
loadRitualsIfNeeded() loadRitualsIfNeeded()
if !isRunningTests { if !isRunningTests {
observeRemoteChanges() startObservingCloudKitRemoteChanges {
WidgetCenter.shared.reloadAllTimelines()
}
} }
} }
@ -93,27 +95,6 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
nowProvider() 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? { var activeRitual: Ritual? {
currentRituals.first currentRituals.first
} }
@ -198,7 +179,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
SoundManager.shared.playSystemSound(SystemSound.success) SoundManager.shared.playSystemSound(SystemSound.success)
} }
} }
saveContext() saveAndReload()
} }
func ritualDayIndex(for ritual: Ritual) -> Int { func ritualDayIndex(for ritual: Ritual) -> Int {
@ -322,7 +303,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
var arcs = ritual.arcs ?? [] var arcs = ritual.arcs ?? []
arcs.append(newArc) arcs.append(newArc)
ritual.arcs = arcs ritual.arcs = arcs
saveContext() saveAndReload()
} }
/// Starts a new arc for a past ritual (one without an active arc). /// 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 { if let currentArc = ritual.currentArc {
currentArc.isActive = false currentArc.isActive = false
saveContext() saveAndReload()
} }
} }
@ -709,7 +690,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
durationDays: defaultDuration, durationDays: defaultDuration,
habits: habits habits: habits
) )
saveContext() saveAndReload()
} }
private func createRitualWithInitialArc( private func createRitualWithInitialArc(
@ -783,7 +764,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
durationDays: durationDays, durationDays: durationDays,
habits: habits habits: habits
) )
saveContext() saveAndReload()
} }
/// Creates a ritual from a preset template /// Creates a ritual from a preset template
@ -823,7 +804,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
durationDays: preset.durationDays, durationDays: preset.durationDays,
habits: habits habits: habits
) )
saveContext() saveAndReload()
return ritual return ritual
} }
@ -852,13 +833,13 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
currentArc.endDate = newEndDate currentArc.endDate = newEndDate
} }
saveContext() saveAndReload()
} }
/// Permanently deletes a ritual and all its history /// Permanently deletes a ritual and all its history
func deleteRitual(_ ritual: Ritual) { func deleteRitual(_ ritual: Ritual) {
modelContext.delete(ritual) modelContext.delete(ritual)
saveContext() saveAndReload()
} }
/// Adds a habit to the current arc of a ritual /// Adds a habit to the current arc of a ritual
@ -870,7 +851,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
var updatedHabits = habits var updatedHabits = habits
updatedHabits.append(habit) updatedHabits.append(habit)
arc.habits = updatedHabits arc.habits = updatedHabits
saveContext() saveAndReload()
} }
/// Removes a habit from the current arc of a ritual /// 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 } habits.removeAll { $0.id == habit.id }
arc.habits = habits arc.habits = habits
modelContext.delete(habit) modelContext.delete(habit)
saveContext() saveAndReload()
} }
private func loadRitualsIfNeeded() { private func loadRitualsIfNeeded() {
@ -1003,10 +984,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
} }
} }
private func saveContext() { func didSaveAndReloadData() {
do {
try modelContext.save()
reloadData()
// Widget timeline reloads can destabilize test hosts; skip in tests. // Widget timeline reloads can destabilize test hosts; skip in tests.
if !isRunningTests { if !isRunningTests {
WidgetCenter.shared.reloadAllTimelines() WidgetCenter.shared.reloadAllTimelines()
@ -1014,9 +992,10 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
// Trigger a UI refresh for observation-based views // Trigger a UI refresh for observation-based views
analyticsNeedsRefresh = true analyticsNeedsRefresh = true
insightCardsNeedRefresh = true insightCardsNeedRefresh = true
} catch {
lastErrorMessage = error.localizedDescription
} }
func handleSaveAndReloadError(_ error: Error) {
lastErrorMessage = error.localizedDescription
} }
private func updateDerivedData() { private func updateDerivedData() {
@ -1539,7 +1518,7 @@ final class RitualStore: RitualStoreProviding, SwiftDataCloudKitStore {
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate) ?? currentDate
} }
saveContext() saveAndReload()
} }
/// Clears all completion data (for testing). /// 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. /// 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 let arcDuration = arc.durationDays
arc.startDate = calendar.date(byAdding: .day, value: -arcDuration, to: yesterday) ?? yesterday arc.startDate = calendar.date(byAdding: .day, value: -arcDuration, to: yesterday) ?? yesterday
saveContext() saveAndReload()
// Trigger the completion check - this will set ritualNeedingRenewal // Trigger the completion check - this will set ritualNeedingRenewal
checkForCompletedArcs() checkForCompletedArcs()

View File

@ -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 `SwiftDataCloudKitSyncManager` as the reusable remote observer/lifecycle component.
- Prefer Bedrock `SwiftDataStore` + `SwiftDataCloudKitStore` to avoid app-specific pulse boilerplate. - Prefer Bedrock `SwiftDataStore` + `SwiftDataCloudKitStore` to avoid app-specific pulse boilerplate.
- Observe `.NSPersistentStoreRemoteChange` to detect remote merges. - 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. - 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. - 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 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:)`. - 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. - 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 - [ ] Capabilities: iCloud/CloudKit + Push + Remote Notifications are enabled
- [ ] Entitlements include `aps-environment` and correct container IDs - [ ] Entitlements include `aps-environment` and correct container IDs
- [ ] xcconfig defines `APS_ENVIRONMENT` per configuration - [ ] 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` - [ ] 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:)` - [ ] Foreground fallback is gated by Bedrock `hasReceivedRemoteChange(since:)`
- [ ] UI has deterministic invalidation on remote reload - [ ] UI has deterministic invalidation on remote reload
- [ ] Two-device batch-update test passes without manual refresh - [ ] Two-device batch-update test passes without manual refresh

View File

@ -83,25 +83,7 @@ final class AppDataStore: SwiftDataCloudKitStore {
self.modelContainer = modelContext.container self.modelContainer = modelContext.container
self.modelContext = modelContext self.modelContext = modelContext
reloadEntities() reloadEntities()
observeRemoteChanges() startObservingCloudKitRemoteChanges()
}
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()
} }
func refresh() { func refresh() {
@ -112,6 +94,19 @@ final class AppDataStore: SwiftDataCloudKitStore {
reloadEntities() 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() { private func reloadEntities() {
do { do {
entities = try modelContext.fetch(FetchDescriptor<PrimaryEntity>()) entities = try modelContext.fetch(FetchDescriptor<PrimaryEntity>())