Files
Sportstime/SportsTime/SportsTimeApp.swift
Trey t 5511e07538 fix: 13 audit fixes — memory, concurrency, performance, accessibility
Critical:
- ProgressViewModel: use single stored ModelContext instead of creating
  new ones per operation (deleteVisit silently no-op'd)
- ProgressViewModel: convert expensive computed properties to stored
  with explicit recompute after mutations (3x recomputation per render)

Memory:
- AnimatedSportsIcon: replace recursive GCD asyncAfter with Task loop,
  cancelled in onDisappear (19 unkillable timer chains)
- ItineraryItemService: remove [weak self] from actor Task (semantically
  wrong, silently drops flushPendingUpdates)
- VisitPhotoService: remove [weak self] from @MainActor Task closures

Concurrency:
- StoreManager: replace nested MainActor.run{Task{}} with direct await
  in listenForTransactions (fire-and-forget race)
- VisitPhotoService: move JPEG encoding/file writing off MainActor via
  nonisolated static helper + Task.detached
- SportsIconImageGenerator: replace GCD dispatch with Task.detached for
  structured concurrency compliance

Performance:
- Game/RichGame: cache DateFormatters as static lets instead of
  allocating per-call (hundreds of allocations in schedule view)
- TripDetailView: wrap ~10 routeWaypoints print() in #if DEBUG, remove
  2 let _ = print() from TripMapView.body (fires every render)

Accessibility:
- GameRow: add combined VoiceOver label (was reading abbreviations
  letter-by-letter)
- Sport badges: add accessibilityLabel to prevent SF symbol name readout
- SportsTimeApp: post UIAccessibility.screenChanged after bootstrap
  completes so VoiceOver users know app is ready

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 22:09:06 -06:00

405 lines
16 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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
/// Task that listens for StoreKit transaction updates
private var transactionListener: Task<Void, Never>?
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 {
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)
.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)
}
}
.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 {
print("🚀 [BOOT] Starting app bootstrap...")
isBootstrapping = true
bootstrapError = nil
let context = modelContainer.mainContext
let bootstrapService = BootstrapService()
do {
// 0. UI Test Mode: reset user data if requested
if ProcessInfo.shouldResetState {
print("🚀 [BOOT] Step 0: Resetting user data for UI tests...")
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)
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()
if let loadError = AppDataProvider.shared.error {
throw loadError
}
print("🚀 [BOOT] Loaded \(AppDataProvider.shared.teams.count) teams")
print("🚀 [BOOT] Loaded \(AppDataProvider.shared.stadiums.count) stadiums")
// 5. Load store products and entitlements
if ProcessInfo.isUITesting {
print("🚀 [BOOT] Step 5: UI Test Mode — forcing Pro, skipping StoreKit")
#if DEBUG
StoreManager.shared.debugProOverride = true
#endif
} else {
print("🚀 [BOOT] Step 5: Loading store products...")
await StoreManager.shared.loadProducts()
await StoreManager.shared.updateEntitlements()
}
// 6. Start network monitoring and wire up sync callback
if !ProcessInfo.isUITesting {
print("🚀 [BOOT] Step 6: Starting network monitoring...")
NetworkMonitor.shared.onSyncNeeded = {
await BackgroundSyncManager.shared.triggerSyncFromNetworkRestoration()
}
NetworkMonitor.shared.startMonitoring()
} else {
print("🚀 [BOOT] Step 6: UI Test Mode — skipping network monitoring")
}
// 7. Configure analytics
if !ProcessInfo.isUITesting {
print("🚀 [BOOT] Step 7: Configuring analytics...")
AnalyticsManager.shared.configure()
} else {
print("🚀 [BOOT] Step 7: UI Test Mode — skipping analytics")
}
// 8. App is now usable
print("🚀 [BOOT] Step 8: Bootstrap complete - app ready")
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)
print("🚀 [BOOT] Step 10: Starting background CloudKit sync...")
Task(priority: .background) {
await self.performBackgroundSync(context: self.modelContainer.mainContext)
await MainActor.run {
self.hasCompletedInitialSync = true
}
}
} else {
print("🚀 [BOOT] Steps 9-10: UI Test Mode — skipping CloudKit sync")
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...")
AccessibilityAnnouncer.announce("Sync started.")
// 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")
}
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: - 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()
}
}