270 lines
8.7 KiB
Swift
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()
|
|
}
|
|
}
|