// // SportsTimeApp.swift // SportsTime // // Created by Trey Tartt on 1/6/26. // import SwiftUI import SwiftData import BackgroundTasks import CloudKit import os private let appLogger = Logger(subsystem: "com.88oakapps.SportsTime", category: "SportsTimeApp") @main struct SportsTimeApp: App { /// App delegate for handling push notifications @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate init() { // UI Test Mode: disable animations and force classic style for deterministic tests if ProcessInfo.isUITesting || ProcessInfo.shouldDisableAnimations { UIView.setAnimationsEnabled(false) } if ProcessInfo.isUITesting { // Force classic (non-animated) home variant for consistent identifiers DesignStyleManager.shared.setStyle(.classic) } // Configure sync manager immediately so push/background triggers can sync. BackgroundSyncManager.shared.configure(with: sharedModelContainer) // Register background tasks BEFORE app finishes launching // This must happen synchronously in init or applicationDidFinishLaunching BackgroundSyncManager.shared.registerTasks() // Start listening for transactions immediately if !ProcessInfo.isUITesting { StoreManager.shared.startListeningForTransactions() } } var sharedModelContainer: ModelContainer = { let schema = Schema([ // User data models SavedTrip.self, TripVote.self, LocalItineraryItem.self, UserPreferences.self, CachedSchedule.self, // Stadium progress models StadiumVisit.self, VisitPhotoMetadata.self, Achievement.self, CachedGameScore.self, // Poll models LocalTripPoll.self, LocalPollVote.self, // Canonical data models SyncState.self, CanonicalStadium.self, StadiumAlias.self, CanonicalTeam.self, TeamAlias.self, LeagueStructureModel.self, CanonicalGame.self, CanonicalSport.self, ]) let modelConfiguration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: false, cloudKitDatabase: .none // Local only; CloudKit used separately for schedules ) do { return try ModelContainer(for: schema, configurations: [modelConfiguration]) } catch { assertionFailure("Could not create persistent ModelContainer: \(error)") // Log the error so it's visible in release builds (assertionFailure is stripped) os_log(.error, "Could not create persistent ModelContainer: %@. Falling back to in-memory store.", error.localizedDescription) do { let fallbackConfiguration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none ) return try ModelContainer(for: schema, configurations: [fallbackConfiguration]) } catch { preconditionFailure("Could not create fallback ModelContainer: \(error)") } } }() var body: some Scene { WindowGroup { BootstrappedContentView(modelContainer: sharedModelContainer) .environment(\.isDemoMode, ProcessInfo.isDemoMode) } .modelContainer(sharedModelContainer) } } // MARK: - Bootstrapped Content View /// Wraps the main content with bootstrap logic. /// Shows a loading indicator until bootstrap completes, then shows HomeView. struct BootstrappedContentView: View { let modelContainer: ModelContainer @Environment(\.scenePhase) private var scenePhase @State private var isBootstrapping = true @State private var bootstrapError: Error? @State private var hasCompletedInitialSync = false @State private var showOnboardingPaywall = false @State private var deepLinkHandler = DeepLinkHandler.shared @State private var appearanceManager = AppearanceManager.shared @State private var showDeepLinkError = false private var shouldShowOnboardingPaywall: Bool { guard !ProcessInfo.isUITesting else { return false } return !UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro } var body: some View { Group { if isBootstrapping { BootstrapLoadingView() } else if let error = bootstrapError { BootstrapErrorView(error: error) { Task { await performBootstrap() } } } else { HomeView() .sheet(isPresented: $showOnboardingPaywall) { OnboardingPaywallView(isPresented: $showOnboardingPaywall) .interactiveDismissDisabled() } .sheet(item: $deepLinkHandler.pendingPollShareCode) { code in NavigationStack { PollDetailView(shareCode: code.value) } } .alert("Error", isPresented: $showDeepLinkError) { Button("OK") { deepLinkHandler.clearPending() } } message: { Text(deepLinkHandler.error?.localizedDescription ?? "") } .onChange(of: deepLinkHandler.error != nil) { _, hasError in showDeepLinkError = hasError } .onAppear { if shouldShowOnboardingPaywall { showOnboardingPaywall = true } } } } .task { await performBootstrap() } .onOpenURL { url in deepLinkHandler.handleURL(url) } .onChange(of: scenePhase) { _, newPhase in guard !ProcessInfo.isUITesting else { return } switch newPhase { case .active: // Refresh super properties (subscription status may have changed) AnalyticsManager.shared.updateSuperProperties() // Track subscription state with rich properties for funnel analysis StoreManager.shared.trackSubscriptionAnalytics(source: "app_foreground") // Sync when app comes to foreground (but not on initial launch) if hasCompletedInitialSync { Task { await performBackgroundSync(context: modelContainer.mainContext) } } case .background: // Flush pending analytics events AnalyticsManager.shared.flush() // Schedule background tasks when app goes to background BackgroundSyncManager.shared.scheduleAllTasks() default: break } } .preferredColorScheme(appearanceManager.currentMode.colorScheme) } @MainActor private func performBootstrap() async { #if DEBUG print("🚀 [BOOT] Starting app bootstrap...") #endif isBootstrapping = true bootstrapError = nil let context = modelContainer.mainContext let bootstrapService = BootstrapService() do { // 0. UI Test Mode: reset user data if requested if ProcessInfo.shouldResetState { #if DEBUG print("🚀 [BOOT] Step 0: Resetting user data for UI tests...") #endif try context.delete(model: SavedTrip.self) try context.delete(model: StadiumVisit.self) try context.delete(model: Achievement.self) try context.delete(model: LocalTripPoll.self) try context.delete(model: LocalPollVote.self) try context.delete(model: TripVote.self) try context.save() } // 1. Bootstrap from bundled JSON if first launch (no data exists) #if DEBUG print("🚀 [BOOT] Step 1: Checking if bootstrap needed...") #endif try await bootstrapService.bootstrapIfNeeded(context: context) // 2. Configure DataProvider with SwiftData context #if DEBUG print("🚀 [BOOT] Step 2: Configuring DataProvider...") #endif AppDataProvider.shared.configure(with: context) // 3. Configure BackgroundSyncManager with model container #if DEBUG print("🚀 [BOOT] Step 3: Configuring BackgroundSyncManager...") #endif BackgroundSyncManager.shared.configure(with: modelContainer) // 4. Load data from SwiftData into memory #if DEBUG print("🚀 [BOOT] Step 4: Loading initial data from SwiftData...") #endif await AppDataProvider.shared.loadInitialData() if let loadError = AppDataProvider.shared.error { throw loadError } #if DEBUG print("🚀 [BOOT] Loaded \(AppDataProvider.shared.teams.count) teams") print("🚀 [BOOT] Loaded \(AppDataProvider.shared.stadiums.count) stadiums") #endif // 5. Load store products and entitlements if ProcessInfo.isUITesting { #if DEBUG print("🚀 [BOOT] Step 5: UI Test Mode — forcing Pro, skipping StoreKit") StoreManager.shared.debugProOverride = true #endif } else { #if DEBUG print("🚀 [BOOT] Step 5: Loading store products...") #endif await StoreManager.shared.loadProducts() await StoreManager.shared.updateEntitlements() } // 6. Start network monitoring and wire up sync callback if !ProcessInfo.isUITesting { #if DEBUG print("🚀 [BOOT] Step 6: Starting network monitoring...") #endif NetworkMonitor.shared.onSyncNeeded = { await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration() } NetworkMonitor.shared.startMonitoring() } else { #if DEBUG print("🚀 [BOOT] Step 6: UI Test Mode — skipping network monitoring") #endif } // 7. Configure analytics if !ProcessInfo.isUITesting { #if DEBUG print("🚀 [BOOT] Step 7: Configuring analytics...") #endif AnalyticsManager.shared.configure() } else { #if DEBUG print("🚀 [BOOT] Step 7: UI Test Mode — skipping analytics") #endif } // 8. App is now usable #if DEBUG print("🚀 [BOOT] Step 8: Bootstrap complete - app ready") #endif isBootstrapping = false UIAccessibility.post(notification: .screenChanged, argument: nil) // 9-10: Background sync (skip in UI test mode) if !ProcessInfo.isUITesting { // 9. Schedule background tasks for future syncs BackgroundSyncManager.shared.scheduleAllTasks() // 9b. Ensure CloudKit subscriptions exist for push-driven sync. Task(priority: .utility) { await BackgroundSyncManager.shared.ensureCanonicalSubscriptions() } // 10. Background: Try to refresh from CloudKit (non-blocking) #if DEBUG print("🚀 [BOOT] Step 10: Starting background CloudKit sync...") #endif Task(priority: .background) { await self.performBackgroundSync(context: self.modelContainer.mainContext) await MainActor.run { self.hasCompletedInitialSync = true } } } else { #if DEBUG print("🚀 [BOOT] Steps 9-10: UI Test Mode — skipping CloudKit sync") #endif hasCompletedInitialSync = true } } catch { #if DEBUG print("❌ [BOOT] Bootstrap failed: \(error.localizedDescription)") #endif bootstrapError = error isBootstrapping = false } } @MainActor private func performBackgroundSync(context: ModelContext) async { let log = SyncLogger.shared log.log("🔄 [SYNC] Starting background sync...") // Log diagnostic info for debugging CloudKit container issues let bundleId = Bundle.main.bundleIdentifier ?? "unknown" let container = CloudKitContainerConfig.makeContainer() let containerId = container.containerIdentifier ?? "unknown" log.log("🔧 [DIAG] Bundle ID: \(bundleId)") log.log("🔧 [DIAG] CloudKit container: \(containerId)") log.log("🔧 [DIAG] Configured container: \(CloudKitContainerConfig.identifier)") if let accountStatus = try? await container.accountStatus() { log.log("🔧 [DIAG] iCloud account status: \(accountStatus.rawValue) (0=couldNotDetermine, 1=available, 2=restricted, 3=noAccount)") } else { log.log("🔧 [DIAG] iCloud account status: failed to check") } // Only reset stale syncInProgress flags; do not clobber an actively running sync. let syncState = SyncState.current(in: context) if syncState.syncInProgress { let staleSyncTimeout: TimeInterval = 15 * 60 if let lastAttempt = syncState.lastSyncAttempt, Date().timeIntervalSince(lastAttempt) < staleSyncTimeout { log.log("â„šī¸ [SYNC] Sync already in progress; skipping duplicate trigger") return } log.log("âš ī¸ [SYNC] Resetting stale syncInProgress flag") syncState.syncInProgress = false do { try context.save() } catch { log.log("âš ī¸ [SYNC] Failed to save stale sync flag reset: \(error.localizedDescription)") } } let syncService = CanonicalSyncService() do { let result = try await syncService.syncAll(context: context) log.log("🔄 [SYNC] Sync completed in \(String(format: "%.2f", result.duration))s") log.log("🔄 [SYNC] Stadiums: \(result.stadiumsUpdated)") log.log("🔄 [SYNC] Teams: \(result.teamsUpdated)") log.log("🔄 [SYNC] Games: \(result.gamesUpdated)") log.log("🔄 [SYNC] League Structures: \(result.leagueStructuresUpdated)") log.log("🔄 [SYNC] Team Aliases: \(result.teamAliasesUpdated)") log.log("🔄 [SYNC] Stadium Aliases: \(result.stadiumAliasesUpdated)") log.log("🔄 [SYNC] Sports: \(result.sportsUpdated)") log.log("🔄 [SYNC] Skipped (incompatible): \(result.skippedIncompatible)") log.log("🔄 [SYNC] Skipped (older): \(result.skippedOlder)") log.log("🔄 [SYNC] Total updated: \(result.totalUpdated)") // If any data was updated, reload the DataProvider if !result.isEmpty { log.log("🔄 [SYNC] Reloading DataProvider...") await AppDataProvider.shared.loadInitialData() log.log("🔄 [SYNC] DataProvider reloaded. Teams: \(AppDataProvider.shared.teams.count), Stadiums: \(AppDataProvider.shared.stadiums.count)") } else { log.log("🔄 [SYNC] No updates - skipping DataProvider reload") } if result.totalUpdated > 0 { AccessibilityAnnouncer.announce("Sync complete. Updated \(result.totalUpdated) records.") } } catch CanonicalSyncService.SyncError.cloudKitUnavailable { log.log("❌ [SYNC] CloudKit unavailable - using local data only") AccessibilityAnnouncer.announce("Cloud sync unavailable. Using local data.") } catch { log.log("❌ [SYNC] Error: \(error.localizedDescription)") AccessibilityAnnouncer.announce("Sync failed. \(error.localizedDescription)") } } } // MARK: - Bootstrap Loading View struct BootstrapLoadingView: View { var body: some View { VStack(spacing: 20) { LoadingSpinner(size: .large) Text("Setting up SportsTime...") .font(.headline) .foregroundStyle(.secondary) } .accessibilityElement(children: .combine) } } // MARK: - Bootstrap Error View struct BootstrapErrorView: View { let error: Error let onRetry: () -> Void var body: some View { VStack(spacing: 20) { Image(systemName: "exclamationmark.triangle.fill") .font(.system(size: 50)) .foregroundStyle(.orange) Text("Setup Failed") .font(.title2) .fontWeight(.semibold) Text(error.localizedDescription) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) Button("Try Again") { onRetry() } .buttonStyle(.borderedProminent) } .padding() } }