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:
alexandrev-tibco
2026-04-27 09:48:20 +02:00
parent 6c7e12b41f
commit 110807d239
111 changed files with 3116 additions and 154 deletions
+46 -25
View File
@@ -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)
}
}