Files
Sportstime/SportsTime/Core/Services/DataProvider.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files:

- Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in
  DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test
  bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files
- Silent failure elimination: all 34 try? sites replaced with do/try/catch +
  logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService,
  CanonicalModels, CKModels, SportsTimeApp, and more)
- Performance: cached DateFormatters (7 files), O(1) team lookups via
  AppDataProvider, achievement definition dictionary, AnimatedBackground
  consolidated from 19 Tasks to 1, task cancellation in SharePreviewView
- Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard,
  @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix
- Planning engine: game end time in travel feasibility, state-aware city
  normalization, exact city matching, DrivingConstraints parameter propagation
- IAP: unknown subscription states → expired, unverified transaction logging,
  entitlements updated before paywall dismiss, restore visible to all users
- Security: API key to Info.plist lookup, filename sanitization in PDF export,
  honest User-Agent, removed stale "Feels" analytics super properties
- Navigation: consolidated competing navigationDestination, boolean → value-based
- Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat
- Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote
  fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel
  MKDirections, Sendable-safe POI struct

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

355 lines
12 KiB
Swift

//
// DataProvider.swift
// SportsTime
//
// Unified data provider that reads from SwiftData for offline support.
// Data is bootstrapped from bundled JSON and can be synced via CloudKit.
//
import Foundation
import SwiftData
import Combine
/// Environment-aware data provider that reads from SwiftData (offline-first)
@MainActor
final class AppDataProvider: ObservableObject {
static let shared = AppDataProvider()
@Published private(set) var teams: [Team] = []
@Published private(set) var stadiums: [Stadium] = []
@Published private(set) var dynamicSports: [DynamicSport] = []
@Published private(set) var isLoading = false
@Published private(set) var error: Error?
@Published private(set) var errorMessage: String?
// Lookup dictionaries - keyed by canonical ID (String)
private var teamsById: [String: Team] = [:]
private var stadiumsById: [String: Stadium] = [:]
private var dynamicSportsById: [String: DynamicSport] = [:]
private var modelContext: ModelContext?
private init() {}
// MARK: - Configuration
/// Set the model context for SwiftData access
func configure(with context: ModelContext) {
self.modelContext = context
}
// MARK: - Data Loading
/// Load all data from SwiftData into memory for fast access
func loadInitialData() async {
guard let context = modelContext else {
errorMessage = "Model context not configured"
return
}
isLoading = true
error = nil
errorMessage = nil
do {
// Fetch canonical stadiums from SwiftData
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
let canonicalStadiums = try context.fetch(stadiumDescriptor)
// Convert to domain models and build lookups
var loadedStadiums: [Stadium] = []
var stadiumLookup: [String: Stadium] = [:]
for canonical in canonicalStadiums {
let stadium = canonical.toDomain()
loadedStadiums.append(stadium)
stadiumLookup[stadium.id] = stadium
}
// Fetch canonical teams from SwiftData
let teamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.deprecatedAt == nil }
)
let canonicalTeams = try context.fetch(teamDescriptor)
// Convert to domain models
var loadedTeams: [Team] = []
var teamLookup: [String: Team] = [:]
for canonical in canonicalTeams {
let team = canonical.toDomain()
loadedTeams.append(team)
teamLookup[team.id] = team
}
// Fetch canonical sports from SwiftData
let sportDescriptor = FetchDescriptor<CanonicalSport>(
predicate: #Predicate { $0.isActive == true }
)
let canonicalSports = try context.fetch(sportDescriptor)
// Convert to domain models
var loadedSports: [DynamicSport] = []
var sportLookup: [String: DynamicSport] = [:]
for canonical in canonicalSports {
let sport = canonical.toDomain()
loadedSports.append(sport)
sportLookup[sport.id] = sport
}
self.teams = loadedTeams
self.stadiums = loadedStadiums
self.dynamicSports = loadedSports
self.teamsById = teamLookup
self.stadiumsById = stadiumLookup
self.dynamicSportsById = sportLookup
} catch {
self.error = error
self.errorMessage = error.localizedDescription
}
isLoading = false
}
func clearError() {
error = nil
errorMessage = nil
}
func retry() async {
await loadInitialData()
}
// MARK: - Data Access
func team(for id: String) -> Team? {
teamsById[id]
}
func stadium(for id: String) -> Stadium? {
stadiumsById[id]
}
func teams(for sport: Sport) -> [Team] {
teams.filter { $0.sport == sport }
}
func dynamicSport(for id: String) -> DynamicSport? {
dynamicSportsById[id]
}
/// All sports: built-in Sport enum cases + CloudKit-defined DynamicSports
var allSports: [any AnySport] {
let builtIn: [any AnySport] = Sport.allCases
let dynamic: [any AnySport] = dynamicSports
return builtIn + dynamic
}
// MARK: - Game Fetching
/// Filter games from SwiftData within date range
func filterGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
guard let context = modelContext else {
throw DataProviderError.contextNotConfigured
}
let sportStrings = Set(sports.map(\.rawValue))
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.deprecatedAt == nil &&
game.dateTime >= startDate &&
game.dateTime <= endDate
}
)
let canonicalGames = try context.fetch(descriptor)
// Filter by sport and convert to domain models
return canonicalGames.compactMap { canonical -> Game? in
guard sportStrings.contains(canonical.sport) else { return nil }
return canonical.toDomain()
}
}
/// Get all games for specified sports (no date filtering)
func allGames(for sports: Set<Sport>) async throws -> [Game] {
guard let context = modelContext else {
throw DataProviderError.contextNotConfigured
}
let sportStrings = Set(sports.map(\.rawValue))
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.deprecatedAt == nil
},
sortBy: [SortDescriptor(\.dateTime)]
)
let canonicalGames = try context.fetch(descriptor)
return canonicalGames.compactMap { canonical -> Game? in
guard sportStrings.contains(canonical.sport) else { return nil }
return canonical.toDomain()
}
}
/// Fetch a single game by canonical ID
func fetchGame(by id: String) async throws -> Game? {
guard let context = modelContext else {
throw DataProviderError.contextNotConfigured
}
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { $0.canonicalId == id }
)
guard let canonical = try context.fetch(descriptor).first else {
return nil
}
return canonical.toDomain()
}
/// Filter games with full team and stadium data within date range
func filterRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
let games = try await filterGames(sports: sports, startDate: startDate, endDate: endDate)
#if DEBUG
print("🎮 [DATA] filterRichGames: \(games.count) games from SwiftData for \(sports.map(\.rawValue).joined(separator: ", "))")
#endif
var richGames: [RichGame] = []
var droppedGames: [(game: Game, reason: String)] = []
var stadiumFallbacksApplied = 0
for game in games {
let homeTeam = teamsById[game.homeTeamId]
let awayTeam = teamsById[game.awayTeamId]
let resolvedStadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam)
if resolvedStadium?.id != game.stadiumId {
stadiumFallbacksApplied += 1
}
if homeTeam == nil || awayTeam == nil || resolvedStadium == nil {
var reasons: [String] = []
if homeTeam == nil { reasons.append("homeTeam(\(game.homeTeamId))") }
if awayTeam == nil { reasons.append("awayTeam(\(game.awayTeamId))") }
if resolvedStadium == nil { reasons.append("stadium(\(game.stadiumId))") }
droppedGames.append((game, "missing: \(reasons.joined(separator: ", "))"))
continue
}
if let homeTeam, let awayTeam, let resolvedStadium {
richGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: resolvedStadium))
}
}
#if DEBUG
if !droppedGames.isEmpty {
print("⚠️ [DATA] Dropped \(droppedGames.count) games due to missing lookups:")
for (game, reason) in droppedGames.prefix(10) {
print("⚠️ [DATA] \(game.sport.rawValue) game \(game.id): \(reason)")
}
if droppedGames.count > 10 {
print("⚠️ [DATA] ... and \(droppedGames.count - 10) more")
}
}
if stadiumFallbacksApplied > 0 {
print("⚠️ [DATA] Applied stadium fallback for \(stadiumFallbacksApplied) games")
}
print("🎮 [DATA] Returning \(richGames.count) rich games")
#endif
return richGames
}
/// Get all games with full team and stadium data (no date filtering)
func allRichGames(for sports: Set<Sport>) async throws -> [RichGame] {
let games = try await allGames(for: sports)
return games.compactMap { game in
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
}
func richGame(from game: Game) -> RichGame? {
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
/// Get all games for a specific team (home or away) - for lazy loading in game picker
func gamesForTeam(teamId: String) async throws -> [RichGame] {
guard let context = modelContext else {
throw DataProviderError.contextNotConfigured
}
// Use two separate predicates to avoid the type-check timeout from combining
// captured vars with OR in a single #Predicate expression.
let homeDescriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { $0.deprecatedAt == nil && $0.homeTeamCanonicalId == teamId },
sortBy: [SortDescriptor(\.dateTime)]
)
let awayDescriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { $0.deprecatedAt == nil && $0.awayTeamCanonicalId == teamId },
sortBy: [SortDescriptor(\.dateTime)]
)
let homeGames = try context.fetch(homeDescriptor)
let awayGames = try context.fetch(awayDescriptor)
// Merge and deduplicate
var seenIds = Set<String>()
var teamGames: [RichGame] = []
for canonical in homeGames + awayGames {
guard seenIds.insert(canonical.canonicalId).inserted else { continue }
let game = canonical.toDomain()
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = resolveStadium(for: game, homeTeam: homeTeam, awayTeam: awayTeam) else {
continue
}
teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium))
}
return teamGames.sorted { $0.game.dateTime < $1.game.dateTime }
}
// Resolve stadium defensively: direct game reference first, then team home-venue fallbacks.
private func resolveStadium(for game: Game, homeTeam: Team?, awayTeam: Team?) -> Stadium? {
if let stadium = stadiumsById[game.stadiumId] {
return stadium
}
if let homeTeam, let fallback = stadiumsById[homeTeam.stadiumId] {
return fallback
}
if let awayTeam, let fallback = stadiumsById[awayTeam.stadiumId] {
return fallback
}
return nil
}
}
// MARK: - Errors
enum DataProviderError: Error, LocalizedError {
case contextNotConfigured
var errorDescription: String? {
switch self {
case .contextNotConfigured:
return "Data provider not configured with model context"
}
}
}