Files
Sportstime/SportsTime/SportsTimeApp.swift
2026-01-13 22:09:26 -06:00

270 lines
8.7 KiB
Swift

//
// 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<Void, Never>?
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()
}
}