110807d239
- Eating out: tap any empty slot → "Mark as eating out"; shows teal indicator, skipped by auto-assign, counts as complete, tap again to unmark - Rule violations panel: access via wand long-press context menu when conflicts exist; shows all isRuleOverridden slots with Fix (clear) or Ignore (acknowledge) actions - Firebase Crashlytics integrated: CrashlyticsService + dSYM upload build phase, isPremium property tracked per session - PremiumSyncService: extracted premium state machine, StoreKit Transaction.updates listener, isPremium no longer synced via iCloud to avoid stale state - StoreManager: analytics on purchase/restore, bundle ID fallback for product ID lookup - Search bar contrast bug fixed: TextField now has explicit foreground color for dark mode - WelcomeStepView: redesigned onboarding welcome screen with week preview - Version bump: 1.0.5 build 22 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
116 lines
4.3 KiB
Swift
116 lines
4.3 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() {
|
|
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)
|
|
}
|
|
}
|