Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
This commit is contained in:
parent
713a65f64b
commit
94e23c946b
@ -12,6 +12,8 @@
|
|||||||
</array>
|
</array>
|
||||||
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
|
||||||
<string>$(DEVELOPMENT_TEAM).$(APP_BUNDLE_IDENTIFIER)</string>
|
<string>$(DEVELOPMENT_TEAM).$(APP_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>$(APS_ENVIRONMENT)</string>
|
||||||
<key>com.apple.security.application-groups</key>
|
<key>com.apple.security.application-groups</key>
|
||||||
<array>
|
<array>
|
||||||
<string>$(APP_GROUP_IDENTIFIER)</string>
|
<string>$(APP_GROUP_IDENTIFIER)</string>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import SwiftData
|
|||||||
import CoreData
|
import CoreData
|
||||||
import Bedrock
|
import Bedrock
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
|
import os
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
@ -11,7 +12,8 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
private static let dataIntegrityMigrationVersion = 1
|
private static let dataIntegrityMigrationVersion = 1
|
||||||
private static let dataIntegrityMigrationVersionKey = "ritualDataIntegrityMigrationVersion"
|
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 seedService: RitualSeedProviding
|
||||||
@ObservationIgnored private let settingsStore: any RitualFeedbackSettingsProviding
|
@ObservationIgnored private let settingsStore: any RitualFeedbackSettingsProviding
|
||||||
@ObservationIgnored private let calendar: Calendar
|
@ObservationIgnored private let calendar: Calendar
|
||||||
@ -20,11 +22,18 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
@ObservationIgnored private let dayFormatter: DateFormatter
|
@ObservationIgnored private let dayFormatter: DateFormatter
|
||||||
@ObservationIgnored private let displayFormatter: DateFormatter
|
@ObservationIgnored private let displayFormatter: DateFormatter
|
||||||
@ObservationIgnored private var remoteChangeObserver: NSObjectProtocol?
|
@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 rituals: [Ritual] = []
|
||||||
private(set) var currentRituals: [Ritual] = []
|
private(set) var currentRituals: [Ritual] = []
|
||||||
private(set) var pastRituals: [Ritual] = []
|
private(set) var pastRituals: [Ritual] = []
|
||||||
private(set) var lastErrorMessage: String?
|
private(set) var lastErrorMessage: String?
|
||||||
|
private(set) var lastRemoteChangeDate: Date?
|
||||||
|
private(set) var dataRefreshVersion: Int = 0
|
||||||
private var analyticsNeedsRefresh = true
|
private var analyticsNeedsRefresh = true
|
||||||
private var cachedDatesWithActivity: Set<Date> = []
|
private var cachedDatesWithActivity: Set<Date> = []
|
||||||
private var cachedPerfectDayIDs: Set<String> = []
|
private var cachedPerfectDayIDs: Set<String> = []
|
||||||
@ -63,6 +72,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
calendar: Calendar = .current,
|
calendar: Calendar = .current,
|
||||||
now: @escaping () -> Date = Date.init
|
now: @escaping () -> Date = Date.init
|
||||||
) {
|
) {
|
||||||
|
self.modelContainer = modelContext.container
|
||||||
self.modelContext = modelContext
|
self.modelContext = modelContext
|
||||||
self.seedService = seedService
|
self.seedService = seedService
|
||||||
self.settingsStore = settingsStore
|
self.settingsStore = settingsStore
|
||||||
@ -96,6 +106,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
|
|
||||||
/// Observes CloudKit remote change notifications to auto-refresh UI when iCloud data syncs.
|
/// Observes CloudKit remote change notifications to auto-refresh UI when iCloud data syncs.
|
||||||
private func observeRemoteChanges() {
|
private func observeRemoteChanges() {
|
||||||
|
syncLogger.info("Starting CloudKit remote change observation")
|
||||||
remoteChangeObserver = NotificationCenter.default.addObserver(
|
remoteChangeObserver = NotificationCenter.default.addObserver(
|
||||||
forName: .NSPersistentStoreRemoteChange,
|
forName: .NSPersistentStoreRemoteChange,
|
||||||
object: nil,
|
object: nil,
|
||||||
@ -104,13 +115,25 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
// Hop to the main actor and capture a strong reference safely there
|
// Hop to the main actor and capture a strong reference safely there
|
||||||
Task { @MainActor [weak self] in
|
Task { @MainActor [weak self] in
|
||||||
guard let strongSelf = self else { return }
|
guard let strongSelf = self else { return }
|
||||||
strongSelf.reloadRituals()
|
strongSelf.handleRemoteStoreChange()
|
||||||
// Also refresh widgets when data arrives from other devices
|
|
||||||
WidgetCenter.shared.reloadAllTimelines()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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? {
|
var activeRitual: Ritual? {
|
||||||
currentRituals.first
|
currentRituals.first
|
||||||
}
|
}
|
||||||
@ -995,6 +1018,7 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
updateDerivedData()
|
updateDerivedData()
|
||||||
invalidateAnalyticsCache()
|
invalidateAnalyticsCache()
|
||||||
scheduleReminderUpdate()
|
scheduleReminderUpdate()
|
||||||
|
dataRefreshVersion &+= 1
|
||||||
} catch {
|
} catch {
|
||||||
lastErrorMessage = error.localizedDescription
|
lastErrorMessage = error.localizedDescription
|
||||||
}
|
}
|
||||||
|
|||||||
@ -245,6 +245,8 @@ struct HistoryDayDetailSheet: View {
|
|||||||
.foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary)
|
.foregroundStyle(completion.isCompleted ? AppStatus.success : AppTextColors.tertiary)
|
||||||
}
|
}
|
||||||
.padding(.vertical, Design.Spacing.small)
|
.padding(.vertical, Design.Spacing.small)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
|
||||||
if isToday {
|
if isToday {
|
||||||
return AnyView(
|
return AnyView(
|
||||||
|
|||||||
@ -216,6 +216,7 @@ private struct OnboardingHabitRowView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, Design.Spacing.medium)
|
.padding(.horizontal, Design.Spacing.medium)
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(
|
.background(
|
||||||
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
RoundedRectangle(cornerRadius: Design.CornerRadius.medium)
|
||||||
.fill(isHighlighted ? AppAccent.primary.opacity(0.1) : AppSurface.tertiary)
|
.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)
|
.scaleEffect(pulseAnimation && isHighlighted && !reduceMotion ? 1.02 : 1.0)
|
||||||
.opacity(pulseAnimation && isHighlighted && !reduceMotion ? 0.5 : 1.0)
|
.opacity(pulseAnimation && isHighlighted && !reduceMotion ? 0.5 : 1.0)
|
||||||
)
|
)
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ struct RootView: View {
|
|||||||
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
||||||
@State private var selectedTab: RootTab
|
@State private var selectedTab: RootTab
|
||||||
@State private var analyticsPrewarmTask: Task<Void, Never>?
|
@State private var analyticsPrewarmTask: Task<Void, Never>?
|
||||||
|
@State private var cloudKitFallbackRefreshTask: Task<Void, Never>?
|
||||||
@State private var isForegroundRefreshing = false
|
@State private var isForegroundRefreshing = false
|
||||||
@State private var isResumingFromBackground = false
|
@State private var isResumingFromBackground = false
|
||||||
private let foregroundRefreshMinimumSeconds: TimeInterval = 0.15
|
private let foregroundRefreshMinimumSeconds: TimeInterval = 0.15
|
||||||
@ -47,6 +48,7 @@ struct RootView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
let _ = store.dataRefreshVersion
|
||||||
ZStack {
|
ZStack {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
Tab(String(localized: "Today"), systemImage: "sun.max.fill", value: RootTab.today) {
|
Tab(String(localized: "Today"), systemImage: "sun.max.fill", value: RootTab.today) {
|
||||||
@ -131,17 +133,11 @@ struct RootView: View {
|
|||||||
showOverlay: useDebugOverlay,
|
showOverlay: useDebugOverlay,
|
||||||
minimumSeconds: useDebugOverlay ? debugForegroundRefreshMinimumSeconds : foregroundRefreshMinimumSeconds
|
minimumSeconds: useDebugOverlay ? debugForegroundRefreshMinimumSeconds : foregroundRefreshMinimumSeconds
|
||||||
)
|
)
|
||||||
// Delayed refreshes to catch CloudKit sync that completes after app activation.
|
scheduleCloudKitFallbackRefresh()
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if newPhase == .background {
|
} else if newPhase == .background {
|
||||||
// Prepare for next resume
|
// Prepare for next resume
|
||||||
isResumingFromBackground = true
|
isResumingFromBackground = true
|
||||||
|
cloudKitFallbackRefreshTask?.cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: selectedTab) { _, _ in
|
.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 {
|
#Preview {
|
||||||
|
|||||||
@ -42,8 +42,10 @@ struct TodayHabitRowView: View {
|
|||||||
}
|
}
|
||||||
.padding(.horizontal, horizontalPadding)
|
.padding(.horizontal, horizontalPadding)
|
||||||
.padding(.vertical, Design.Spacing.medium)
|
.padding(.vertical, Design.Spacing.medium)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(AppSurface.card)
|
.background(AppSurface.card)
|
||||||
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
|
||||||
|
.contentShape(Rectangle())
|
||||||
}
|
}
|
||||||
.accessibilityLabel(Text(title))
|
.accessibilityLabel(Text(title))
|
||||||
.accessibilityValue(isCompleted ? String(localized: "Completed") : String(localized: "Not completed"))
|
.accessibilityValue(isCompleted ? String(localized: "Completed") : String(localized: "Not completed"))
|
||||||
|
|||||||
@ -4,3 +4,4 @@
|
|||||||
// Debug-specific settings
|
// Debug-specific settings
|
||||||
SWIFT_OPTIMIZATION_LEVEL = -Onone
|
SWIFT_OPTIMIZATION_LEVEL = -Onone
|
||||||
ENABLE_TESTABILITY = YES
|
ENABLE_TESTABILITY = YES
|
||||||
|
APS_ENVIRONMENT = development
|
||||||
|
|||||||
@ -4,3 +4,4 @@
|
|||||||
// Release-specific settings
|
// Release-specific settings
|
||||||
SWIFT_OPTIMIZATION_LEVEL = -O
|
SWIFT_OPTIMIZATION_LEVEL = -O
|
||||||
SWIFT_COMPILATION_MODE = wholemodule
|
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.
|
- 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.
|
- 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.
|
- 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