- Move 7 Data(contentsOf:) calls off MainActor via Task.detached (BootstrapService) - Batch-fetch N+1 queries in sync merge loops (CanonicalSyncService) - Predicate-based gamesForTeam fetch instead of fetching all games (DataProvider) - Proper Sendable on RouteInfo with nonisolated(unsafe) polyline (LocationService) - [weak self] in BGTaskScheduler register closures (BackgroundSyncManager) - Cache tripDays, routeWaypoints as @State with recompute (TripDetailView) - Remove unused AnyCancellable, add Task lifecycle management (TripDetailView) - Cache sportStadiums, recentVisits as stored properties (ProgressViewModel) - Dynamic Type fonts replacing hardcoded sizes (OnboardingPaywallView) - Accessibility labels/hints on stadium picker, date picker, map, stats, settings toggle, and day cards Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
349 lines
12 KiB
Swift
349 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)
|
|
|
|
print("🎮 [DATA] filterRichGames: \(games.count) games from SwiftData for \(sports.map(\.rawValue).joined(separator: ", "))")
|
|
|
|
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
|
|
}
|
|
|
|
richGames.append(RichGame(game: game, homeTeam: homeTeam!, awayTeam: awayTeam!, stadium: resolvedStadium!))
|
|
}
|
|
|
|
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")
|
|
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"
|
|
}
|
|
}
|
|
}
|