// // 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 isLoading = false @Published private(set) var error: Error? @Published private(set) var errorMessage: String? private var teamsById: [UUID: Team] = [:] private var stadiumsById: [UUID: Stadium] = [:] private var stadiumsByCanonicalId: [String: Stadium] = [:] private var teamsByCanonicalId: [String: Team] = [:] // Canonical ID lookups for game conversion private var canonicalTeamUUIDs: [String: UUID] = [:] private var canonicalStadiumUUIDs: [String: UUID] = [:] 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( predicate: #Predicate { $0.deprecatedAt == nil } ) let canonicalStadiums = try context.fetch(stadiumDescriptor) // Convert to domain models and build lookups var loadedStadiums: [Stadium] = [] for canonical in canonicalStadiums { let stadium = canonical.toDomain() loadedStadiums.append(stadium) stadiumsByCanonicalId[canonical.canonicalId] = stadium canonicalStadiumUUIDs[canonical.canonicalId] = stadium.id } // Fetch canonical teams from SwiftData let teamDescriptor = FetchDescriptor( predicate: #Predicate { $0.deprecatedAt == nil } ) let canonicalTeams = try context.fetch(teamDescriptor) // Convert to domain models var loadedTeams: [Team] = [] for canonical in canonicalTeams { // Get stadium UUID for this team let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID() let team = canonical.toDomain(stadiumUUID: stadiumUUID) loadedTeams.append(team) teamsByCanonicalId[canonical.canonicalId] = team canonicalTeamUUIDs[canonical.canonicalId] = team.id } self.teams = loadedTeams self.stadiums = loadedStadiums // Build lookup dictionaries (use reduce to handle potential duplicates gracefully) self.teamsById = loadedTeams.reduce(into: [:]) { dict, team in if dict[team.id] != nil { print("⚠️ Duplicate team UUID: \(team.id) - \(team.name)") } dict[team.id] = team } self.stadiumsById = loadedStadiums.reduce(into: [:]) { dict, stadium in if dict[stadium.id] != nil { print("⚠️ Duplicate stadium UUID: \(stadium.id) - \(stadium.name)") } dict[stadium.id] = stadium } } 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: UUID) -> Team? { teamsById[id] } func stadium(for id: UUID) -> Stadium? { stadiumsById[id] } func teams(for sport: Sport) -> [Team] { teams.filter { $0.sport == sport } } // MARK: - Game Fetching /// Fetch games from SwiftData within date range func fetchGames(sports: Set, startDate: Date, endDate: Date) async throws -> [Game] { guard let context = modelContext else { throw DataProviderError.contextNotConfigured } let sportStrings = sports.map { $0.rawValue } let descriptor = FetchDescriptor( predicate: #Predicate { 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 } let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID() let awayTeamUUID = canonicalTeamUUIDs[canonical.awayTeamCanonicalId] ?? UUID() let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID() return canonical.toDomain( homeTeamUUID: homeTeamUUID, awayTeamUUID: awayTeamUUID, stadiumUUID: stadiumUUID ) } } /// Fetch a single game by ID func fetchGame(by id: UUID) async throws -> Game? { guard let context = modelContext else { throw DataProviderError.contextNotConfigured } let idString = id.uuidString let descriptor = FetchDescriptor( predicate: #Predicate { $0.canonicalId == idString } ) guard let canonical = try context.fetch(descriptor).first else { return nil } let homeTeamUUID = canonicalTeamUUIDs[canonical.homeTeamCanonicalId] ?? UUID() let awayTeamUUID = canonicalTeamUUIDs[canonical.awayTeamCanonicalId] ?? UUID() let stadiumUUID = canonicalStadiumUUIDs[canonical.stadiumCanonicalId] ?? UUID() return canonical.toDomain( homeTeamUUID: homeTeamUUID, awayTeamUUID: awayTeamUUID, stadiumUUID: stadiumUUID ) } /// Fetch games with full team and stadium data func fetchRichGames(sports: Set, startDate: Date, endDate: Date) async throws -> [RichGame] { let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate) return games.compactMap { game in guard let homeTeam = teamsById[game.homeTeamId], let awayTeam = teamsById[game.awayTeamId], let stadium = stadiumsById[game.stadiumId] 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 = stadiumsById[game.stadiumId] else { return nil } return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) } } // MARK: - Errors enum DataProviderError: Error, LocalizedError { case contextNotConfigured var errorDescription: String? { switch self { case .contextNotConfigured: return "Data provider not configured with model context" } } }