// // SportsTimeApp.swift // SportsTime // // Created by Trey Tartt on 1/6/26. // import SwiftUI import SwiftData import BackgroundTasks @main struct SportsTimeApp: App { /// Task that listens for StoreKit transaction updates private var transactionListener: Task? init() { // Register background tasks BEFORE app finishes launching // This must happen synchronously in init or applicationDidFinishLaunching BackgroundSyncManager.shared.registerTasks() // Start listening for transactions immediately transactionListener = StoreManager.shared.listenForTransactions() } var sharedModelContainer: ModelContainer = { let schema = Schema([ // User data models SavedTrip.self, TripVote.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 { fatalError("Could not create ModelContainer: \(error)") } }() var body: some Scene { WindowGroup { BootstrappedContentView(modelContainer: sharedModelContainer) } .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 private var shouldShowOnboardingPaywall: Bool { !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) } } .alert("Error", isPresented: .constant(deepLinkHandler.error != nil)) { Button("OK") { deepLinkHandler.clearPending() } } message: { Text(deepLinkHandler.error?.localizedDescription ?? "") } .onAppear { if shouldShowOnboardingPaywall { showOnboardingPaywall = true } } } } .task { await performBootstrap() } .onOpenURL { url in deepLinkHandler.handleURL(url) } .onChange(of: scenePhase) { _, newPhase in switch newPhase { case .active: // Sync when app comes to foreground (but not on initial launch) if hasCompletedInitialSync { Task { await performBackgroundSync(context: modelContainer.mainContext) } } case .background: // Schedule background tasks when app goes to background BackgroundSyncManager.shared.scheduleAllTasks() default: break } } } @MainActor private func performBootstrap() async { isBootstrapping = true bootstrapError = nil let context = modelContainer.mainContext let bootstrapService = BootstrapService() do { // 1. Bootstrap from bundled JSON if first launch (no data exists) try await bootstrapService.bootstrapIfNeeded(context: context) // 2. Configure DataProvider with SwiftData context AppDataProvider.shared.configure(with: context) // 3. Configure BackgroundSyncManager with model container BackgroundSyncManager.shared.configure(with: modelContainer) // 4. Load data from SwiftData into memory await AppDataProvider.shared.loadInitialData() // 5. Load store products and entitlements await StoreManager.shared.loadProducts() await StoreManager.shared.updateEntitlements() // 6. Start network monitoring and wire up sync callback NetworkMonitor.shared.onSyncNeeded = { await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration() } NetworkMonitor.shared.startMonitoring() // 7. App is now usable isBootstrapping = false // 8. Schedule background tasks for future syncs BackgroundSyncManager.shared.scheduleAllTasks() // 9. Background: Try to refresh from CloudKit (non-blocking) Task.detached(priority: .background) { await self.performBackgroundSync(context: context) await MainActor.run { self.hasCompletedInitialSync = true } } } catch { bootstrapError = error isBootstrapping = false } } @MainActor private func performBackgroundSync(context: ModelContext) async { let syncService = CanonicalSyncService() do { let result = try await syncService.syncAll(context: context) // If any data was updated, reload the DataProvider if !result.isEmpty { await AppDataProvider.shared.loadInitialData() print("CloudKit sync completed: \(result.totalUpdated) items updated") } } catch CanonicalSyncService.SyncError.cloudKitUnavailable { // Offline or CloudKit not available - silently continue with local data print("CloudKit unavailable, using local data") } catch { // Other sync errors - log but don't interrupt user print("Background sync error: \(error.localizedDescription)") } } } // MARK: - String Identifiable for Sheet extension String: @retroactive Identifiable { public var id: String { self } } // 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) } } } // 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() } }