441 lines
17 KiB
Swift
441 lines
17 KiB
Swift
//
|
||
// 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()
|
||
}
|
||
}
|