4ffa15b06a
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
121 lines
4.5 KiB
Swift
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)
|
|
}
|
|
}
|