Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
e9de34355b
commit
b67de2454a
@ -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()
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>())
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user