Files
Sportstime/SportsTime/Core/Services/DataProvider.swift
Trey t f180e5bfed feat(sync): add CloudKit sync for dynamic sports
- 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>
2026-01-13 18:27:56 -06:00

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"
}
}
}