- 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>
297 lines
9.9 KiB
Swift
297 lines
9.9 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 = sports.map { $0.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 = sports.map { $0.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)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
/// 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 = 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)
|
|
}
|
|
|
|
/// 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
|
|
}
|
|
|
|
// Fetch all non-deprecated games (predicate with captured vars causes type-check timeout)
|
|
let descriptor = FetchDescriptor<CanonicalGame>(
|
|
predicate: #Predicate<CanonicalGame> { $0.deprecatedAt == nil },
|
|
sortBy: [SortDescriptor(\.dateTime)]
|
|
)
|
|
|
|
let allCanonical: [CanonicalGame] = try context.fetch(descriptor)
|
|
|
|
// Filter by team in Swift (fast for ~5K games)
|
|
var teamGames: [RichGame] = []
|
|
for canonical in allCanonical {
|
|
guard canonical.homeTeamCanonicalId == teamId || canonical.awayTeamCanonicalId == teamId else {
|
|
continue
|
|
}
|
|
let game = canonical.toDomain()
|
|
guard let homeTeam = teamsById[game.homeTeamId],
|
|
let awayTeam = teamsById[game.awayTeamId],
|
|
let stadium = stadiumsById[game.stadiumId] else {
|
|
continue
|
|
}
|
|
teamGames.append(RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium))
|
|
}
|
|
return teamGames
|
|
}
|
|
}
|
|
|
|
// MARK: - Errors
|
|
|
|
enum DataProviderError: Error, LocalizedError {
|
|
case contextNotConfigured
|
|
|
|
var errorDescription: String? {
|
|
switch self {
|
|
case .contextNotConfigured:
|
|
return "Data provider not configured with model context"
|
|
}
|
|
}
|
|
}
|