diff --git a/Andromida/Andromida.entitlements b/Andromida/Andromida.entitlements
index 30b5b07..c6f44c7 100644
--- a/Andromida/Andromida.entitlements
+++ b/Andromida/Andromida.entitlements
@@ -12,6 +12,8 @@
com.apple.developer.ubiquity-kvstore-identifier
$(DEVELOPMENT_TEAM).$(APP_BUNDLE_IDENTIFIER)
+ aps-environment
+ $(APS_ENVIRONMENT)
com.apple.security.application-groups
$(APP_GROUP_IDENTIFIER)
diff --git a/Andromida/App/State/RitualStore.swift b/Andromida/App/State/RitualStore.swift
index 907f4da..b9ad64b 100644
--- a/Andromida/App/State/RitualStore.swift
+++ b/Andromida/App/State/RitualStore.swift
@@ -4,6 +4,7 @@ import SwiftData
import CoreData
import Bedrock
import WidgetKit
+import os
@MainActor
@Observable
@@ -11,7 +12,8 @@ final class RitualStore: RitualStoreProviding {
private static let dataIntegrityMigrationVersion = 1
private static let dataIntegrityMigrationVersionKey = "ritualDataIntegrityMigrationVersion"
- @ObservationIgnored private let modelContext: ModelContext
+ @ObservationIgnored private let modelContainer: ModelContainer
+ @ObservationIgnored private var modelContext: ModelContext
@ObservationIgnored private let seedService: RitualSeedProviding
@ObservationIgnored private let settingsStore: any RitualFeedbackSettingsProviding
@ObservationIgnored private let calendar: Calendar
@@ -20,11 +22,18 @@ final class RitualStore: RitualStoreProviding {
@ObservationIgnored private let dayFormatter: DateFormatter
@ObservationIgnored private let displayFormatter: DateFormatter
@ObservationIgnored private var remoteChangeObserver: NSObjectProtocol?
+ @ObservationIgnored private let syncLogger = Logger(
+ subsystem: Bundle.main.bundleIdentifier ?? "Andromida",
+ category: "CloudKitSync"
+ )
+ @ObservationIgnored private var remoteChangeEventCount: Int = 0
private(set) var rituals: [Ritual] = []
private(set) var currentRituals: [Ritual] = []
private(set) var pastRituals: [Ritual] = []
private(set) var lastErrorMessage: String?
+ private(set) var lastRemoteChangeDate: Date?
+ private(set) var dataRefreshVersion: Int = 0
private var analyticsNeedsRefresh = true
private var cachedDatesWithActivity: Set = []
private var cachedPerfectDayIDs: Set = []
@@ -63,6 +72,7 @@ final class RitualStore: RitualStoreProviding {
calendar: Calendar = .current,
now: @escaping () -> Date = Date.init
) {
+ self.modelContainer = modelContext.container
self.modelContext = modelContext
self.seedService = seedService
self.settingsStore = settingsStore
@@ -96,6 +106,7 @@ final class RitualStore: RitualStoreProviding {
/// Observes CloudKit remote change notifications to auto-refresh UI when iCloud data syncs.
private func observeRemoteChanges() {
+ syncLogger.info("Starting CloudKit remote change observation")
remoteChangeObserver = NotificationCenter.default.addObserver(
forName: .NSPersistentStoreRemoteChange,
object: nil,
@@ -104,13 +115,25 @@ final class RitualStore: RitualStoreProviding {
// Hop to the main actor and capture a strong reference safely there
Task { @MainActor [weak self] in
guard let strongSelf = self else { return }
- strongSelf.reloadRituals()
- // Also refresh widgets when data arrives from other devices
- WidgetCenter.shared.reloadAllTimelines()
+ strongSelf.handleRemoteStoreChange()
}
}
}
+ private func handleRemoteStoreChange() {
+ remoteChangeEventCount += 1
+ lastRemoteChangeDate = now()
+ // SwiftData may keep stale registered objects in long-lived contexts.
+ // Recreate the context on remote store changes so fetches observe latest merged values.
+ modelContext = ModelContext(modelContainer)
+ syncLogger.info(
+ "Received remote store change #\(self.remoteChangeEventCount, privacy: .public); reloading rituals"
+ )
+ reloadRituals()
+ // Also refresh widgets when data arrives from other devices
+ WidgetCenter.shared.reloadAllTimelines()
+ }
+
var activeRitual: Ritual? {
currentRituals.first
}
@@ -995,6 +1018,7 @@ final class RitualStore: RitualStoreProviding {
updateDerivedData()
invalidateAnalyticsCache()
scheduleReminderUpdate()
+ dataRefreshVersion &+= 1
} catch {
lastErrorMessage = error.localizedDescription
}
diff --git a/Andromida/App/Views/History/HistoryDayDetailSheet.swift b/Andromida/App/Views/History/HistoryDayDetailSheet.swift
index 36cfad6..88ea263 100644
--- a/Andromida/App/Views/History/HistoryDayDetailSheet.swift
+++ b/Andromida/App/Views/History/HistoryDayDetailSheet.swift
@@ -245,6 +245,8 @@ struct HistoryDayDetailSheet: View {
.foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary)
}
.padding(.vertical, Design.Spacing.small)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
if isToday {
return AnyView(
diff --git a/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift b/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift
index 77d74af..a09f756 100644
--- a/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift
+++ b/Andromida/App/Views/Onboarding/FirstCheckInStepView.swift
@@ -216,6 +216,7 @@ private struct OnboardingHabitRowView: View {
}
.padding(.horizontal, Design.Spacing.medium)
.padding(.vertical, Design.Spacing.medium)
+ .frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
.fill(isHighlighted ? AppAccent.primary.opacity(0.1) : AppSurface.tertiary)
@@ -229,6 +230,7 @@ private struct OnboardingHabitRowView: View {
.scaleEffect(pulseAnimation && isHighlighted && !reduceMotion ? 1.02 : 1.0)
.opacity(pulseAnimation && isHighlighted && !reduceMotion ? 0.5 : 1.0)
)
+ .contentShape(Rectangle())
}
.buttonStyle(.plain)
.onAppear {
diff --git a/Andromida/App/Views/RootView.swift b/Andromida/App/Views/RootView.swift
index 670ce8e..82a5415 100644
--- a/Andromida/App/Views/RootView.swift
+++ b/Andromida/App/Views/RootView.swift
@@ -9,6 +9,7 @@ struct RootView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var selectedTab: RootTab
@State private var analyticsPrewarmTask: Task?
+ @State private var cloudKitFallbackRefreshTask: Task?
@State private var isForegroundRefreshing = false
@State private var isResumingFromBackground = false
private let foregroundRefreshMinimumSeconds: TimeInterval = 0.15
@@ -47,6 +48,7 @@ struct RootView: View {
}
var body: some View {
+ let _ = store.dataRefreshVersion
ZStack {
TabView(selection: $selectedTab) {
Tab(String(localized: "Today"), systemImage: "sun.max.fill", value: RootTab.today) {
@@ -131,17 +133,11 @@ struct RootView: View {
showOverlay: useDebugOverlay,
minimumSeconds: useDebugOverlay ? debugForegroundRefreshMinimumSeconds : foregroundRefreshMinimumSeconds
)
- // Delayed refreshes to catch CloudKit sync that completes after app activation.
- // CloudKit fetch can take 5–10+ seconds; NSPersistentStoreRemoteChange is unreliable.
- for delay in [5.0, 10.0] {
- Task { @MainActor in
- try? await Task.sleep(for: .seconds(delay))
- store.refresh()
- }
- }
+ scheduleCloudKitFallbackRefresh()
} else if newPhase == .background {
// Prepare for next resume
isResumingFromBackground = true
+ cloudKitFallbackRefreshTask?.cancel()
}
}
.onChange(of: selectedTab) { _, _ in
@@ -207,6 +203,19 @@ struct RootView: View {
}
}
}
+
+ /// Schedules a one-shot fallback refresh only if no remote change arrived after activation.
+ private func scheduleCloudKitFallbackRefresh() {
+ let activationDate = Date()
+ cloudKitFallbackRefreshTask?.cancel()
+ cloudKitFallbackRefreshTask = Task { @MainActor in
+ try? await Task.sleep(for: .seconds(8))
+ guard !Task.isCancelled else { return }
+ let receivedRemoteChangeSinceActivation = store.lastRemoteChangeDate.map { $0 >= activationDate } ?? false
+ guard !receivedRemoteChangeSinceActivation else { return }
+ store.refresh()
+ }
+ }
}
#Preview {
diff --git a/Andromida/App/Views/Today/Components/TodayHabitRowView.swift b/Andromida/App/Views/Today/Components/TodayHabitRowView.swift
index 528d1ce..82dd016 100644
--- a/Andromida/App/Views/Today/Components/TodayHabitRowView.swift
+++ b/Andromida/App/Views/Today/Components/TodayHabitRowView.swift
@@ -42,8 +42,10 @@ struct TodayHabitRowView: View {
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, Design.Spacing.medium)
+ .frame(maxWidth: .infinity, alignment: .leading)
.background(AppSurface.card)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
+ .contentShape(Rectangle())
}
.accessibilityLabel(Text(title))
.accessibilityValue(isCompleted ? String(localized: "Completed") : String(localized: "Not completed"))
diff --git a/Andromida/Shared/Configuration/Debug.xcconfig b/Andromida/Shared/Configuration/Debug.xcconfig
index 7b0c18a..d351a95 100644
--- a/Andromida/Shared/Configuration/Debug.xcconfig
+++ b/Andromida/Shared/Configuration/Debug.xcconfig
@@ -4,3 +4,4 @@
// Debug-specific settings
SWIFT_OPTIMIZATION_LEVEL = -Onone
ENABLE_TESTABILITY = YES
+APS_ENVIRONMENT = development
diff --git a/Andromida/Shared/Configuration/Release.xcconfig b/Andromida/Shared/Configuration/Release.xcconfig
index a0e857f..fecb7a4 100644
--- a/Andromida/Shared/Configuration/Release.xcconfig
+++ b/Andromida/Shared/Configuration/Release.xcconfig
@@ -4,3 +4,4 @@
// Release-specific settings
SWIFT_OPTIMIZATION_LEVEL = -O
SWIFT_COMPILATION_MODE = wholemodule
+APS_ENVIRONMENT = production
diff --git a/README.md b/README.md
index 12d591d..d464c9a 100644
--- a/README.md
+++ b/README.md
@@ -199,3 +199,18 @@ String catalogs are used for English (en), Spanish (es-MX), and French (fr-CA):
- Fresh installs start with no rituals; users create their own from scratch or presets.
- A startup data-integrity migration normalizes arc date ranges, in-progress arc state, and sort indexes.
- Date-sensitive analytics in `RitualStore` are driven by an injectable time source for deterministic tests.
+
+## CloudKit Sync Verification
+
+Use this checklist when validating SwiftData sync between iPhone and iPad:
+
+1. Confirm both devices are signed in to the same Apple ID and running the same app flavor (Debug vs TestFlight/App Store).
+2. On Device A, change ritual data (for example, toggle a habit completion).
+3. Keep Device B open; verify the update appears without force-quitting.
+4. If needed, background Device B and return to foreground once to trigger safety-net refresh.
+5. In Xcode Console, filter for `CloudKitSync` to verify remote change logs from `RitualStore`.
+6. In CloudKit Console, inspect container `iCloud.com.mbrucedogs.Andromida` in the **private database** and confirm recent record updates.
+
+Important sync scope:
+- Ritual/arcs/habits data syncs via SwiftData + CloudKit private database.
+- Settings sync is separate and uses `NSUbiquitousKeyValueStore` (Bedrock `CloudSyncManager`).