Files
Sportstime/SportsTime/SportsTimeApp.swift

441 lines
17 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
import CloudKit
@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)")
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
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: .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
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
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")
}
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()
}
}