1.0.5: Eating out slots, rule violations panel, Crashlytics, search bar fix
- 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>
This commit is contained in:
+46
-25
@@ -1,22 +1,20 @@
|
||||
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 }
|
||||
private var isTestFlightBuild: Bool {
|
||||
guard let receiptURL = Bundle.main.appStoreReceiptURL else { return false }
|
||||
return receiptURL.lastPathComponent == "sandboxReceipt"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if !isReady {
|
||||
// Splash screen
|
||||
ZStack {
|
||||
Color.mealMoodBackground.ignoresSafeArea()
|
||||
AppIconPlaceholder(size: 120)
|
||||
@@ -30,13 +28,39 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
.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()
|
||||
await syncPremiumStatus(isColdLaunch: true)
|
||||
hasCompletedColdLaunch = true
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
withAnimation(.easeInOut) {
|
||||
@@ -48,7 +72,11 @@ struct ContentView: View {
|
||||
Task {
|
||||
switch phase {
|
||||
case .active:
|
||||
await syncPremiumStatus()
|
||||
// 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)
|
||||
@@ -60,35 +88,28 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func initializeAppIfNeeded() {
|
||||
// Create default settings if none exist
|
||||
if allSettings.isEmpty {
|
||||
DefaultDataService.createDefaultSettings(context: context)
|
||||
}
|
||||
|
||||
// Create default tags if none exist
|
||||
let tagDescriptor = FetchDescriptor<Tag>()
|
||||
if (try? context.fetch(tagDescriptor))?.isEmpty ?? true {
|
||||
DefaultDataService.createDefaultTags(context: context)
|
||||
}
|
||||
}
|
||||
|
||||
private func syncPremiumStatus() async {
|
||||
let descriptor = FetchDescriptor<AppSettings>()
|
||||
guard let settings = try? context.fetch(descriptor).first else { return }
|
||||
|
||||
if isTestFlightBuild {
|
||||
if settings.isPremium != true {
|
||||
settings.isPremium = true
|
||||
try? context.save()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let premium = await StoreManager.hasActiveSubscription()
|
||||
if settings.isPremium != premium {
|
||||
settings.isPremium = premium
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user