Compare commits
3 Commits
713a65f64b
...
e0392f2c22
| Author | SHA1 | Date | |
|---|---|---|---|
| e0392f2c22 | |||
| 3c0637ed7a | |||
| 94e23c946b |
@ -573,7 +573,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = "$(PRODUCT_NAME)";
|
PRODUCT_NAME = "$(PRODUCT_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
@ -610,7 +610,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.2;
|
MARKETING_VERSION = 1.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(APP_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = "$(PRODUCT_NAME)";
|
PRODUCT_NAME = "$(PRODUCT_NAME)";
|
||||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -11,7 +11,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
|
||||||
@ -19,12 +20,14 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
@ObservationIgnored private let isRunningTests: Bool
|
@ObservationIgnored private let isRunningTests: Bool
|
||||||
@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 let cloudKitSyncManager: SwiftDataCloudKitSyncManager
|
||||||
|
|
||||||
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 +66,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
|
||||||
@ -71,6 +75,10 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
self.isRunningTests = isRunningTests
|
self.isRunningTests = isRunningTests
|
||||||
self.dayFormatter = DateFormatter()
|
self.dayFormatter = DateFormatter()
|
||||||
self.displayFormatter = DateFormatter()
|
self.displayFormatter = DateFormatter()
|
||||||
|
self.cloudKitSyncManager = SwiftDataCloudKitSyncManager(
|
||||||
|
isEnabled: !isRunningTests,
|
||||||
|
logIdentifier: "AndromidaCloudKitSync"
|
||||||
|
)
|
||||||
dayFormatter.calendar = calendar
|
dayFormatter.calendar = calendar
|
||||||
dayFormatter.dateFormat = "yyyy-MM-dd"
|
dayFormatter.dateFormat = "yyyy-MM-dd"
|
||||||
displayFormatter.calendar = calendar
|
displayFormatter.calendar = calendar
|
||||||
@ -88,29 +96,25 @@ final class RitualStore: RitualStoreProviding {
|
|||||||
nowProvider()
|
nowProvider()
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
|
||||||
if let observer = remoteChangeObserver {
|
|
||||||
NotificationCenter.default.removeObserver(observer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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() {
|
||||||
remoteChangeObserver = NotificationCenter.default.addObserver(
|
cloudKitSyncManager.startObserving { [weak self] in
|
||||||
forName: .NSPersistentStoreRemoteChange,
|
self?.handleRemoteStoreChange()
|
||||||
object: nil,
|
|
||||||
queue: .main
|
|
||||||
) { [weak self] _ in
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleRemoteStoreChange() {
|
||||||
|
let eventCount = cloudKitSyncManager.remoteChangeEventCount
|
||||||
|
lastRemoteChangeDate = cloudKitSyncManager.lastRemoteChangeDate
|
||||||
|
// 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)
|
||||||
|
Design.debugLog("Received remote store change #\(eventCount); 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 +999,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
|
||||||
|
|||||||
19
PRD.md
19
PRD.md
@ -266,6 +266,23 @@ Implementation note: Onboarding flows and root-level shell transitions should av
|
|||||||
| TR-DATA-04 | Use App Group shared container for widget data access |
|
| TR-DATA-04 | Use App Group shared container for widget data access |
|
||||||
| TR-DATA-05 | Run a startup integrity migration to normalize arc date ranges, in-progress arc state, and persisted sort indexes |
|
| TR-DATA-05 | Run a startup integrity migration to normalize arc date ranges, in-progress arc state, and persisted sort indexes |
|
||||||
| TR-DATA-06 | Enable iCloud runtime compatibility by shipping `com.apple.developer.ubiquity-kvstore-identifier` and `remote-notification` background mode when CloudKit/KVS sync is enabled |
|
| TR-DATA-06 | Enable iCloud runtime compatibility by shipping `com.apple.developer.ubiquity-kvstore-identifier` and `remote-notification` background mode when CloudKit/KVS sync is enabled |
|
||||||
|
| TR-DATA-07 | For SwiftData CloudKit sync, ship Push Notifications entitlement (`aps-environment`) with per-configuration environment values |
|
||||||
|
| TR-DATA-08 | Observe `.NSPersistentStoreRemoteChange` and reload SwiftData-backed state when remote merges arrive |
|
||||||
|
| TR-DATA-09 | Maintain a foreground sync safety-net refresh, but do not rely on app relaunch for cross-device update visibility |
|
||||||
|
| TR-DATA-10 | Ensure UI invalidation on each remote merge so batched updates appear without manual pull-to-refresh |
|
||||||
|
| TR-DATA-11 | Emit sync diagnostics (remote event count and timestamp) to support field debugging on physical devices |
|
||||||
|
| TR-DATA-12 | Validate every release with a two-device real-time sync test matrix (single toggle, rapid batch toggles, background/foreground recovery) |
|
||||||
|
|
||||||
|
#### 5.3.1 SwiftData to CloudKit Reuse Standard
|
||||||
|
|
||||||
|
Use `SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md` as the canonical implementation and QA checklist for any app that uses SwiftData with CloudKit.
|
||||||
|
|
||||||
|
Standard coverage includes:
|
||||||
|
|
||||||
|
- capabilities/entitlements baseline (CloudKit, Push Notifications, Remote Notifications background mode)
|
||||||
|
- runtime remote-change handling and stale-context mitigation
|
||||||
|
- deterministic UI refresh behavior for remote merges and rapid batched edits
|
||||||
|
- repeatable two-device verification and CloudKit Console validation flow
|
||||||
|
|
||||||
### 5.4 Third-Party Dependencies
|
### 5.4 Third-Party Dependencies
|
||||||
|
|
||||||
@ -579,6 +596,7 @@ Andromida/
|
|||||||
| `Andromida/Shared/Theme/RitualsTheme.swift` | Bedrock theme configuration |
|
| `Andromida/Shared/Theme/RitualsTheme.swift` | Bedrock theme configuration |
|
||||||
| `Andromida/Shared/BrandingConfig.swift` | Branding constants |
|
| `Andromida/Shared/BrandingConfig.swift` | Branding constants |
|
||||||
| `Andromida/Shared/Configuration/Base.xcconfig` | Build configuration source of truth |
|
| `Andromida/Shared/Configuration/Base.xcconfig` | Build configuration source of truth |
|
||||||
|
| `SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md` | Reusable SwiftData to CloudKit sync standard and test checklist |
|
||||||
| `Andromida/Resources/LaunchScreen.storyboard` | Native launch screen |
|
| `Andromida/Resources/LaunchScreen.storyboard` | Native launch screen |
|
||||||
| `Andromida/App/State/RitualStore.swift` | Primary data store |
|
| `Andromida/App/State/RitualStore.swift` | Primary data store |
|
||||||
| `Andromida/App/State/SettingsStore.swift` | Settings with cloud sync |
|
| `Andromida/App/State/SettingsStore.swift` | Settings with cloud sync |
|
||||||
@ -609,3 +627,4 @@ Andromida/
|
|||||||
| 1.0 | February 2026 | Initial PRD based on implemented features |
|
| 1.0 | February 2026 | Initial PRD based on implemented features |
|
||||||
| 1.1 | February 2026 | Fixed time-of-day refresh bug in Today view and Widget; added debug time simulation |
|
| 1.1 | February 2026 | Fixed time-of-day refresh bug in Today view and Widget; added debug time simulation |
|
||||||
| 1.2 | February 2026 | Added deterministic UI-test launch harness and expanded critical UI flow coverage |
|
| 1.2 | February 2026 | Added deterministic UI-test launch harness and expanded critical UI flow coverage |
|
||||||
|
| 1.3 | February 2026 | Added reusable SwiftData to CloudKit sync requirements, runtime expectations, and two-device verification standard |
|
||||||
|
|||||||
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`).
|
||||||
|
|||||||
76
SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md
Normal file
76
SWIFTDATA_CLOUDKIT_SYNC_REQUIREMENTS.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# SwiftData to CloudKit Sync Requirements (Reusable)
|
||||||
|
|
||||||
|
Use this checklist for any iOS app that uses SwiftData with CloudKit and requires near-real-time multi-device sync.
|
||||||
|
|
||||||
|
## 1) Capabilities and Entitlements
|
||||||
|
|
||||||
|
- Enable `iCloud` with `CloudKit` for the app target.
|
||||||
|
- Enable `Push Notifications` for the app target.
|
||||||
|
- Enable `Background Modes > Remote notifications`.
|
||||||
|
- Add App Group if app + widget share local SQLite.
|
||||||
|
|
||||||
|
Required entitlement keys:
|
||||||
|
- `com.apple.developer.icloud-container-identifiers`
|
||||||
|
- `com.apple.developer.icloud-services` including `CloudKit`
|
||||||
|
- `com.apple.developer.ubiquity-kvstore-identifier` (if KVS is used)
|
||||||
|
- `aps-environment` (must resolve per config)
|
||||||
|
- `com.apple.security.application-groups` (if widget/app group storage is used)
|
||||||
|
|
||||||
|
## 2) Build Configuration
|
||||||
|
|
||||||
|
Use xcconfig variables so environments are explicit and portable:
|
||||||
|
|
||||||
|
- Debug: `APS_ENVIRONMENT = development`
|
||||||
|
- Release: `APS_ENVIRONMENT = production`
|
||||||
|
|
||||||
|
Entitlements should reference variables, not hard-coded values, where possible.
|
||||||
|
|
||||||
|
## 3) Model and Schema Constraints (SwiftData + CloudKit)
|
||||||
|
|
||||||
|
- Avoid `@Attribute(.unique)` in CloudKit-mirrored models.
|
||||||
|
- Ensure all stored properties have defaults or are optional.
|
||||||
|
- Keep relationships optional for CloudKit compatibility.
|
||||||
|
- Use additive schema evolution only (add fields/models; do not remove/rename/change types in place).
|
||||||
|
|
||||||
|
## 4) Runtime Sync Behavior
|
||||||
|
|
||||||
|
- Observe `.NSPersistentStoreRemoteChange` to detect remote merges.
|
||||||
|
- On remote change, refetch from SwiftData and invalidate derived caches.
|
||||||
|
- For long-lived stores, recreate `ModelContext` on remote change before refetch when stale objects are observed.
|
||||||
|
- Keep a foreground fallback refresh as a safety net, but do not rely on force-quit/relaunch behavior.
|
||||||
|
- Emit structured logs for remote sync events (event count + timestamp) for debugging.
|
||||||
|
|
||||||
|
## 5) UI Freshness Requirements
|
||||||
|
|
||||||
|
- UI must re-render on each remote merge, even for batched updates.
|
||||||
|
- Keep an observable refresh version/counter and increment on each successful reload.
|
||||||
|
- Ensure list/detail views do not rely on stale assumptions when models are updated remotely.
|
||||||
|
- Make high-frequency interaction rows fully tappable to reduce missed user actions.
|
||||||
|
|
||||||
|
## 6) Verification Matrix
|
||||||
|
|
||||||
|
Test all cases on two physical devices with the same Apple ID and same app flavor:
|
||||||
|
|
||||||
|
1. Single toggle on Device A appears on Device B while both apps are open.
|
||||||
|
2. Rapid batch toggles on Device A all appear on Device B without manual pull-to-refresh.
|
||||||
|
3. Device B in background receives updates after foregrounding (without force quit).
|
||||||
|
4. Airplane mode recovery syncs correctly after reconnection.
|
||||||
|
5. Simultaneous edits resolve predictably (CloudKit last-writer-wins).
|
||||||
|
|
||||||
|
## 7) Observability and Console Checks
|
||||||
|
|
||||||
|
- Device logs: filter by sync logger category (for example `CloudKitSync`).
|
||||||
|
- CloudKit Console: validate record updates in the app container private database.
|
||||||
|
- If pushes are delivered but UI is stale, investigate context freshness and view invalidation, not transport.
|
||||||
|
|
||||||
|
## 8) Reuse Checklist for New Apps
|
||||||
|
|
||||||
|
Before shipping any new SwiftData+CloudKit app:
|
||||||
|
|
||||||
|
- [ ] Capabilities: iCloud/CloudKit + Push + Remote Notifications are enabled
|
||||||
|
- [ ] Entitlements include `aps-environment` and correct container IDs
|
||||||
|
- [ ] xcconfig defines `APS_ENVIRONMENT` per configuration
|
||||||
|
- [ ] Remote change observer reloads data and invalidates caches
|
||||||
|
- [ ] UI has deterministic invalidation on remote reload
|
||||||
|
- [ ] Two-device batch-update test passes without manual refresh
|
||||||
|
- [ ] CloudKit Console verification documented in README/PRD
|
||||||
188
SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md
Normal file
188
SWIFTDATA_CLOUDKIT_SYNC_TEMPLATE.swift.md
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
# SwiftData to CloudKit Sync Template (Copy/Paste)
|
||||||
|
|
||||||
|
Use this as a starter for new apps that need near-real-time SwiftData sync across Apple devices.
|
||||||
|
|
||||||
|
## 1) Build Config (xcconfig)
|
||||||
|
|
||||||
|
```xcconfig
|
||||||
|
// Debug.xcconfig
|
||||||
|
#include "Base.xcconfig"
|
||||||
|
APS_ENVIRONMENT = development
|
||||||
|
|
||||||
|
// Release.xcconfig
|
||||||
|
#include "Base.xcconfig"
|
||||||
|
APS_ENVIRONMENT = production
|
||||||
|
```
|
||||||
|
|
||||||
|
Entitlements should include:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<key>aps-environment</key>
|
||||||
|
<string>$(APS_ENVIRONMENT)</string>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) App Container Setup
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import SwiftData
|
||||||
|
|
||||||
|
enum AppIdentifiers {
|
||||||
|
static let appGroupIdentifier = "group.com.example.app"
|
||||||
|
static let cloudKitContainerIdentifier = "iCloud.com.example.app"
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeModelContainer(isRunningTests: Bool) throws -> ModelContainer {
|
||||||
|
let schema = Schema([
|
||||||
|
Ritual.self,
|
||||||
|
RitualArc.self,
|
||||||
|
ArcHabit.self
|
||||||
|
])
|
||||||
|
|
||||||
|
let storeURL = FileManager.default
|
||||||
|
.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
|
||||||
|
.appendingPathComponent("App.sqlite")
|
||||||
|
?? URL.documentsDirectory.appendingPathComponent("App.sqlite")
|
||||||
|
|
||||||
|
let config = ModelConfiguration(
|
||||||
|
schema: schema,
|
||||||
|
url: storeURL,
|
||||||
|
cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier)
|
||||||
|
)
|
||||||
|
|
||||||
|
return try ModelContainer(for: schema, configurations: [config])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3) Store Pattern (Remote Change + Context Refresh)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import SwiftData
|
||||||
|
import CoreData
|
||||||
|
import os
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class RitualStore {
|
||||||
|
@ObservationIgnored private let modelContainer: ModelContainer
|
||||||
|
@ObservationIgnored private var modelContext: ModelContext
|
||||||
|
@ObservationIgnored private var remoteChangeObserver: NSObjectProtocol?
|
||||||
|
@ObservationIgnored private let syncLogger = Logger(
|
||||||
|
subsystem: Bundle.main.bundleIdentifier ?? "App",
|
||||||
|
category: "CloudKitSync"
|
||||||
|
)
|
||||||
|
|
||||||
|
private(set) var rituals: [Ritual] = []
|
||||||
|
private(set) var dataRefreshVersion: Int = 0
|
||||||
|
private(set) var lastRemoteChangeDate: Date?
|
||||||
|
private(set) var remoteChangeEventCount: Int = 0
|
||||||
|
|
||||||
|
init(modelContext: ModelContext) {
|
||||||
|
self.modelContainer = modelContext.container
|
||||||
|
self.modelContext = modelContext
|
||||||
|
reloadRituals()
|
||||||
|
observeRemoteChanges()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
if let observer = remoteChangeObserver {
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func observeRemoteChanges() {
|
||||||
|
syncLogger.info("Starting CloudKit remote change observation")
|
||||||
|
remoteChangeObserver = NotificationCenter.default.addObserver(
|
||||||
|
forName: .NSPersistentStoreRemoteChange,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.handleRemoteStoreChange()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleRemoteStoreChange() {
|
||||||
|
remoteChangeEventCount += 1
|
||||||
|
lastRemoteChangeDate = Date()
|
||||||
|
|
||||||
|
// Important when long-lived contexts become stale after remote merges.
|
||||||
|
modelContext = ModelContext(modelContainer)
|
||||||
|
|
||||||
|
syncLogger.info(
|
||||||
|
"Received remote store change #\(self.remoteChangeEventCount, privacy: .public); reloading"
|
||||||
|
)
|
||||||
|
reloadRituals()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh() {
|
||||||
|
reloadRituals()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func reloadRituals() {
|
||||||
|
do {
|
||||||
|
rituals = try modelContext.fetch(FetchDescriptor<Ritual>())
|
||||||
|
dataRefreshVersion &+= 1
|
||||||
|
} catch {
|
||||||
|
syncLogger.error("Reload failed: \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4) Root View Pattern (Safety-Net Refresh)
|
||||||
|
|
||||||
|
```swift
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct RootView: View {
|
||||||
|
@Bindable var store: RitualStore
|
||||||
|
@Environment(\.scenePhase) private var scenePhase
|
||||||
|
@State private var fallbackRefreshTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// Ensure body observes remote reload increments.
|
||||||
|
let _ = store.dataRefreshVersion
|
||||||
|
|
||||||
|
ContentView(store: store)
|
||||||
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
|
if newPhase == .active {
|
||||||
|
scheduleCloudKitFallbackRefresh()
|
||||||
|
} else if newPhase == .background {
|
||||||
|
fallbackRefreshTask?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleCloudKitFallbackRefresh() {
|
||||||
|
let activationDate = Date()
|
||||||
|
fallbackRefreshTask?.cancel()
|
||||||
|
fallbackRefreshTask = Task { @MainActor in
|
||||||
|
try? await Task.sleep(for: .seconds(8))
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
let gotRemoteSinceActive = store.lastRemoteChangeDate.map { $0 >= activationDate } ?? false
|
||||||
|
guard !gotRemoteSinceActive else { return }
|
||||||
|
store.refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5) Verification Script (Manual)
|
||||||
|
|
||||||
|
Run this release gate on two real devices:
|
||||||
|
|
||||||
|
1. Single habit toggle on Device A appears on Device B with both open.
|
||||||
|
2. Rapid 4-6 toggles on Device A all appear on Device B without pull-to-refresh.
|
||||||
|
3. Background Device B, then foreground, data remains consistent.
|
||||||
|
4. Device logs show `CloudKitSync` remote-change events.
|
||||||
|
5. CloudKit Console private DB shows matching record updates.
|
||||||
|
|
||||||
|
## 6) Common Failure Modes
|
||||||
|
|
||||||
|
- Push works but UI stale: context/view invalidation issue.
|
||||||
|
- Only updates after relaunch: missing remote observer or stale long-lived context.
|
||||||
|
- Works in one build flavor only: `aps-environment` mismatch or signing/provisioning drift.
|
||||||
|
- Partial batch updates shown: render invalidation not happening for every remote merge.
|
||||||
Loading…
Reference in New Issue
Block a user