bd8090c845
- El primer push se descartaba: updateApplicationContext se llamaba antes de que WCSession terminara de activarse (activacion asincrona). Ahora la sesion se activa al arrancar la app (ContentView), el payload pendiente se envia al completarse la activacion, y ademas el reloj PIDE los datos al abrirse (sendMessage despierta al iPhone en segundo plano y responde con el snapshot del app group) - Rediseno: Hoy con cabecera del dia en coral y tarjetas degradadas por comida (estilo Weather); Semana con circulo de dia a la izquierda y hoy relleno en coral (estilo Calendar); complicacion con iconos tintados por comida y widgetAccentable - Verificado con capturas en simulador de Watch sembrando el app group Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
124 lines
4.7 KiB
Swift
124 lines
4.7 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() }
|
|
// 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)
|
|
}
|
|
}
|