Files
Sportstime/SportsTime/SportsTimeApp.swift
Trey t 87079b434d fix(schedule): use start of day for date range queries
- Fix startDate to use Calendar.startOfDay instead of Date() to include
  games earlier in the current day
- Add SyncLogger for file-based sync logging viewable in Settings
- Add "View Sync Logs" button in Settings debug section
- Add diagnostics and NBA game logging to ScheduleViewModel
- Add dropped game logging to DataProvider.filterRichGames
- Use SyncLogger in SportsTimeApp and CloudKitService for sync operations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:25:44 -06:00

311 lines
11 KiB
Swift

//
// 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() {
// 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
@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:
// 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
}
}
.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()
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. App is now usable
print("🚀 [BOOT] Step 7: Bootstrap complete - app ready")
isBootstrapping = false
// 8. Schedule background tasks for future syncs
BackgroundSyncManager.shared.scheduleAllTasks()
// 9. Background: Try to refresh from CloudKit (non-blocking)
print("🚀 [BOOT] Step 9: Starting background CloudKit sync...")
Task.detached(priority: .background) {
await self.performBackgroundSync(context: context)
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...")
// Reset stale syncInProgress flag (in case app was killed mid-sync)
let syncState = SyncState.current(in: context)
if syncState.syncInProgress {
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()
}
}