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

This commit is contained in:
Matt Bruce 2026-02-16 14:04:00 -06:00
parent 713a65f64b
commit 94e23c946b
9 changed files with 70 additions and 12 deletions

View File

@ -12,6 +12,8 @@
</array>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(DEVELOPMENT_TEAM).$(APP_BUNDLE_IDENTIFIER)</string>
<key>aps-environment</key>
<string>$(APS_ENVIRONMENT)</string>
<key>com.apple.security.application-groups</key>
<array>
<string>$(APP_GROUP_IDENTIFIER)</string>

View File

@ -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<Date> = []
private var cachedPerfectDayIDs: Set<String> = []
@ -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
}

View File

@ -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(

View File

@ -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 {

View File

@ -9,6 +9,7 @@ struct RootView: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var selectedTab: RootTab
@State private var analyticsPrewarmTask: Task<Void, Never>?
@State private var cloudKitFallbackRefreshTask: Task<Void, Never>?
@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 510+ 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 {

View File

@ -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"))

View File

@ -4,3 +4,4 @@
// Debug-specific settings
SWIFT_OPTIMIZATION_LEVEL = -Onone
ENABLE_TESTABILITY = YES
APS_ENVIRONMENT = development

View File

@ -4,3 +4,4 @@
// Release-specific settings
SWIFT_OPTIMIZATION_LEVEL = -O
SWIFT_COMPILATION_MODE = wholemodule
APS_ENVIRONMENT = production

View File

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