Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
713a65f64b
commit
94e23c946b
@ -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>
|
||||
|
||||
@ -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,12 +115,24 @@ 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()
|
||||
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
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 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 {
|
||||
|
||||
@ -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"))
|
||||
|
||||
@ -4,3 +4,4 @@
|
||||
// Debug-specific settings
|
||||
SWIFT_OPTIMIZATION_LEVEL = -Onone
|
||||
ENABLE_TESTABILITY = YES
|
||||
APS_ENVIRONMENT = development
|
||||
|
||||
@ -4,3 +4,4 @@
|
||||
// Release-specific settings
|
||||
SWIFT_OPTIMIZATION_LEVEL = -O
|
||||
SWIFT_COMPILATION_MODE = wholemodule
|
||||
APS_ENVIRONMENT = production
|
||||
|
||||
15
README.md
15
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`).
|
||||
|
||||
Loading…
Reference in New Issue
Block a user