Files
Sportstime/SportsTime/SportsTimeApp.swift
2026-02-10 18:15:36 -06:00

340 lines
13 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// SportsTimeApp.swift
// SportsTime
//
// Created by Trey Tartt on 1/6/26.
//
import SwiftUI
import SwiftData
import BackgroundTasks
@main
struct SportsTimeApp: App {
/// App delegate for handling push notifications
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
/// Task that listens for StoreKit transaction updates
private var transactionListener: Task<Void, Never>?
init() {
// 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
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)
.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
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:
// 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 {
print("🚀 [BOOT] Starting app bootstrap...")
isBootstrapping = true
bootstrapError = nil
let context = modelContainer.mainContext
let bootstrapService = BootstrapService()
do {
// 1. Bootstrap from bundled JSON if first launch (no data exists)
print("🚀 [BOOT] Step 1: Checking if bootstrap needed...")
try await bootstrapService.bootstrapIfNeeded(context: context)
// 2. Configure DataProvider with SwiftData context
print("🚀 [BOOT] Step 2: Configuring DataProvider...")
AppDataProvider.shared.configure(with: context)
// 3. Configure BackgroundSyncManager with model container
print("🚀 [BOOT] Step 3: Configuring BackgroundSyncManager...")
BackgroundSyncManager.shared.configure(with: modelContainer)
// 4. Load data from SwiftData into memory
print("🚀 [BOOT] Step 4: Loading initial data from SwiftData...")
await AppDataProvider.shared.loadInitialData()
if let loadError = AppDataProvider.shared.error {
throw loadError
}
print("🚀 [BOOT] Loaded \(AppDataProvider.shared.teams.count) teams")
print("🚀 [BOOT] Loaded \(AppDataProvider.shared.stadiums.count) stadiums")
// 5. Load store products and entitlements
print("🚀 [BOOT] Step 5: Loading store products...")
await StoreManager.shared.loadProducts()
await StoreManager.shared.updateEntitlements()
// 6. Start network monitoring and wire up sync callback
print("🚀 [BOOT] Step 6: Starting network monitoring...")
NetworkMonitor.shared.onSyncNeeded = {
await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration()
}
NetworkMonitor.shared.startMonitoring()
// 7. Configure analytics
print("🚀 [BOOT] Step 7: Configuring analytics...")
AnalyticsManager.shared.configure()
// 8. App is now usable
print("🚀 [BOOT] Step 8: Bootstrap complete - app ready")
isBootstrapping = false
// 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)
print("🚀 [BOOT] Step 10: Starting background CloudKit sync...")
Task(priority: .background) {
await self.performBackgroundSync(context: self.modelContainer.mainContext)
await MainActor.run {
self.hasCompletedInitialSync = true
}
}
} catch {
print("❌ [BOOT] Bootstrap failed: \(error.localizedDescription)")
bootstrapError = error
isBootstrapping = false
}
}
@MainActor
private func performBackgroundSync(context: ModelContext) async {
let log = SyncLogger.shared
log.log("🔄 [SYNC] Starting background sync...")
// 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
try? context.save()
}
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")
}
} catch CanonicalSyncService.SyncError.cloudKitUnavailable {
log.log("❌ [SYNC] CloudKit unavailable - using local data only")
} catch {
log.log("❌ [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()
}
}