Files
FamilyMealPlanner/MealMood/ContentView.swift
T
alexandrev-tibco 4ffa15b06a 2.0: CloudKit private-database sync replaces KV snapshot sync
Foundation for 2.1 family sharing (CKShare needs these models + container).
SwiftData cannot share across Apple IDs today, so 2.0 ships true multi-device
sync for the same account instead:

- Models made CloudKit-compatible: dropped @Attribute(.unique) on all 5,
  inline defaults on every attribute, WeekPlan.slots stored as optional
  relationship (name preserved → lightweight migration) with non-optional
  slotList facade; ~73 call sites renamed.
- Container: cloudKitDatabase .automatic, falling back to the local-only
  store when CloudKit is unavailable; failures recorded to Crashlytics.
- Entitlements: iCloud CloudKit service (container already existed);
  remote-notification background mode for push-driven sync.
- Legacy KV snapshot sync (ICloudSyncService) stays inert when CloudKit is
  active — kept only for 1.x devices.
- DeduplicationService collapses cross-device duplicates deterministically on
  launch (settings singleton, default tags with tagId remapping, same-week
  plans).
- Note: AppSettings.isPremium now syncs across same-Apple-ID devices; StoreKit
  (PremiumSyncService + Transaction.updates) remains the source of truth and
  reconciles on every launch/foreground.

All 21 unit tests pass, including ICloudSyncPremiumIsolationTests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
2026-07-12 18:13:45 +02:00

121 lines
4.5 KiB
Swift

import SwiftUI
import SwiftData
import StoreKit
struct ContentView: View {
@Environment(\.modelContext) private var context
@Environment(\.scenePhase) private var scenePhase
@Query private var allSettings: [AppSettings]
@State private var isReady = false
@State private var hasCompletedColdLaunch = false
private let premiumSyncService = PremiumSyncService()
private var settings: AppSettings? { allSettings.first }
var body: some View {
Group {
if !isReady {
ZStack {
Color.mealMoodBackground.ignoresSafeArea()
AppIconPlaceholder(size: 120)
}
} else if let settings = settings, settings.onboardingCompleted {
HomeView()
} else {
OnboardingView {
// Onboarding completed
}
}
}
.environment(\.locale, Locale(identifier: settings?.languageEnum.localeIdentifier ?? Locale.current.identifier))
// MARK: Transaction.updates listener (StoreKit 2 recommended pattern)
// Fires for new purchases, renewals and revocations while the app is running.
// This is the most reliable way to catch a purchase even if the app was
// interrupted during the payment flow.
.task {
for await result in Transaction.updates {
guard case .verified(let transaction) = result else { continue }
guard let s = settings else {
await transaction.finish()
continue
}
let isActive = transaction.revocationDate == nil &&
(transaction.expirationDate.map { $0 > Date() } ?? true)
if isActive && !s.isPremium {
s.isPremium = true
try? context.save()
} else if transaction.revocationDate != nil && s.isPremium {
// Explicitly revoked subscription downgrade
s.isPremium = false
try? context.save()
}
await transaction.finish()
}
}
.onAppear {
initializeAppIfNeeded()
Task {
if settings?.iCloudSyncEnabledResolved ?? true {
await ICloudSyncService.shared.pullRemoteIfNeeded(context: context)
}
await syncPremiumStatus(isColdLaunch: true)
hasCompletedColdLaunch = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
withAnimation(.easeInOut) {
isReady = true
}
}
}
.onChange(of: scenePhase) { _, phase in
Task {
switch phase {
case .active:
// Only run foreground sync after cold launch is complete.
// The first activation is covered by .onAppear above.
if hasCompletedColdLaunch {
await syncPremiumStatus(isColdLaunch: false)
}
case .inactive, .background:
if settings?.iCloudSyncEnabledResolved ?? true {
await ICloudSyncService.shared.pushLocalSnapshot(context: context)
}
@unknown default:
break
}
}
}
}
// MARK: - Private
private func initializeAppIfNeeded() {
// CloudKit mirroring can duplicate singletons/default data created on
// other devices collapse them before anything reads the store.
if CloudSyncRuntime.isCloudKitActive {
DeduplicationService.run(context: context)
}
if allSettings.isEmpty {
DefaultDataService.createDefaultSettings(context: context)
}
let tagDescriptor = FetchDescriptor<Tag>()
if (try? context.fetch(tagDescriptor))?.isEmpty ?? true {
DefaultDataService.createDefaultTags(context: context)
}
}
private func syncPremiumStatus(isColdLaunch: Bool) async {
guard let settings else { return }
let newPremium = await premiumSyncService.sync(
currentIsPremium: settings.isPremium,
isColdLaunch: isColdLaunch
)
if settings.isPremium != newPremium {
settings.isPremium = newPremium
try? context.save()
}
CrashlyticsService.setUserProperties(isPremium: settings.isPremium)
}
}