163fd6026a
CloudKit sincroniza la base privada de un Apple ID: ni llega a Android ni deja que dos cuentas editen el mismo plan (CKShare sigue sin existir en SwiftData). El contenido de un hogar pasa por tanto a Firestore, y un dispositivo que entra en un hogar construye el store local sin CloudKit — dos espejos escribiendo los mismos objetos se pelean, que es justo lo que ya obligó a apagar el sync por iCloud KV. SwiftData sigue siendo el store local y el modo offline; HouseholdSyncService es lo unico que habla con la red. Detecta cambios comparando una huella del contenido de cada documento con la ultima sincronizada (el "shadow"), asi que no hace falta instrumentar con updatedAt las treinta vistas que mutan modelos. Los borrados van como tombstone: un borrado duro volveria desde cualquier miembro que estuviera sin conexion. Semanas y slots usan id derivado del contenido (2026-09-14, 5-dinner) para que dos miembros que abren la misma semana escriban el mismo documento en vez de crear dos, y para que los conflictos se resuelvan por slot y no por semana. Incluye reglas de seguridad (solo miembros; los codigos de invitacion se pueden leer por id pero no listar), pantalla de hogar en Ajustes con Sign in with Apple, invitacion por codigo de 6 caracteres sin vocales ni 0/O/1/I, y la eleccion al unirse entre llevarse los platos propios o adoptar los del hogar. Fuera de esta fase: fotos de platos (necesitan Storage) y el cliente Android. Refs #33 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013su1ttRiMeMYxkZJ1Y3246
129 lines
4.9 KiB
Swift
129 lines
4.9 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.
|
|
// Activate WatchConnectivity as early as possible so the first widget
|
|
// refresh doesn't race the session activation.
|
|
.onAppear { WatchSyncService.shared.activate() }
|
|
// 2.2: household content syncs through Firestore instead of CloudKit.
|
|
.onAppear {
|
|
HouseholdService.shared.start()
|
|
HouseholdSyncService.shared.start(context: context)
|
|
}
|
|
// 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)
|
|
}
|
|
}
|