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`).