- Add CKSport model to parse CloudKit Sport records - Add fetchSportsForSync() to CloudKitService for delta fetching - Add syncSports() and mergeSport() to CanonicalSyncService - Update DataProvider with dynamicSports support and allSports computed property - Update MockAppDataProvider with matching dynamic sports support - Add comprehensive documentation for adding new sports The app can now sync sport definitions from CloudKit, enabling new sports to be added without app updates. Sports are fetched, merged into SwiftData, and exposed via AppDataProvider.allSports alongside built-in Sport enum cases. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
221 lines
6.8 KiB
Swift
221 lines
6.8 KiB
Swift
//
|
|
// SportsTimeApp.swift
|
|
// SportsTime
|
|
//
|
|
// Created by Trey Tartt on 1/6/26.
|
|
//
|
|
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
@main
|
|
struct SportsTimeApp: App {
|
|
/// Task that listens for StoreKit transaction updates
|
|
private var transactionListener: Task<Void, Never>?
|
|
|
|
init() {
|
|
// 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,
|
|
// 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
|
|
|
|
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()
|
|
}
|
|
.onAppear {
|
|
if shouldShowOnboardingPaywall {
|
|
showOnboardingPaywall = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.task {
|
|
await performBootstrap()
|
|
}
|
|
.onChange(of: scenePhase) { _, newPhase in
|
|
// Sync when app comes to foreground (but not on initial launch)
|
|
if newPhase == .active && hasCompletedInitialSync {
|
|
Task {
|
|
await performBackgroundSync(context: modelContainer.mainContext)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@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. Load data from SwiftData into memory
|
|
await AppDataProvider.shared.loadInitialData()
|
|
|
|
// 4. Load store products and entitlements
|
|
await StoreManager.shared.loadProducts()
|
|
await StoreManager.shared.updateEntitlements()
|
|
|
|
// 5. App is now usable
|
|
isBootstrapping = false
|
|
|
|
// 6. 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: - 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()
|
|
}
|
|
}
|