Files
Sportstime/SportsTime/Core/Services/CanonicalDataProvider.swift
Trey t 92d808caf5 Add Stadium Progress system and themed loading spinners
Stadium Progress & Achievements:
- Add StadiumVisit and Achievement SwiftData models
- Create Progress tab with interactive map view
- Implement photo-based visit import with GPS/date matching
- Add achievement badges (count-based, regional, journey)
- Create shareable progress cards for social media
- Add canonical data infrastructure (stadium identities, team aliases)
- Implement score resolution from free APIs (MLB, NBA, NHL stats)

UI Improvements:
- Add ThemedSpinner and ThemedSpinnerCompact components
- Replace all ProgressView() with themed spinners throughout app
- Fix sport selection state not persisting when navigating away

Bug Fixes:
- Fix Coast to Coast trips showing only 1 city (validation issue)
- Fix stadium progress showing 0/0 (filtering issue)
- Remove "Stadium Quest" title from progress view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 20:20:03 -06:00

235 lines
8.2 KiB
Swift

//
// CanonicalDataProvider.swift
// SportsTime
//
// DataProvider implementation that reads from SwiftData canonical models.
// This is the primary data source after bootstrap completes.
//
import Foundation
import SwiftData
actor CanonicalDataProvider: DataProvider {
// MARK: - Properties
private let modelContainer: ModelContainer
// Caches for converted domain objects (rebuilt on first access)
private var cachedTeams: [Team]?
private var cachedStadiums: [Stadium]?
private var teamsByCanonicalId: [String: Team] = [:]
private var stadiumsByCanonicalId: [String: Stadium] = [:]
private var teamUUIDByCanonicalId: [String: UUID] = [:]
private var stadiumUUIDByCanonicalId: [String: UUID] = [:]
// MARK: - Initialization
init(modelContainer: ModelContainer) {
self.modelContainer = modelContainer
}
// MARK: - DataProvider Protocol
func fetchTeams(for sport: Sport) async throws -> [Team] {
try await loadCachesIfNeeded()
return cachedTeams?.filter { $0.sport == sport } ?? []
}
func fetchAllTeams() async throws -> [Team] {
try await loadCachesIfNeeded()
return cachedTeams ?? []
}
func fetchStadiums() async throws -> [Stadium] {
try await loadCachesIfNeeded()
return cachedStadiums ?? []
}
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
try await loadCachesIfNeeded()
let context = ModelContext(modelContainer)
// Fetch canonical games within date range
let sportStrings = sports.map { $0.rawValue }
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
sportStrings.contains(game.sport) &&
game.dateTime >= startDate &&
game.dateTime <= endDate &&
game.deprecatedAt == nil
},
sortBy: [SortDescriptor(\.dateTime)]
)
let canonicalGames = try context.fetch(descriptor)
// Convert to domain models
return canonicalGames.compactMap { canonical -> Game? in
guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId],
let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId],
let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
return nil
}
return Game(
id: canonical.uuid,
homeTeamId: homeTeamUUID,
awayTeamId: awayTeamUUID,
stadiumId: stadiumUUID,
dateTime: canonical.dateTime,
sport: canonical.sportEnum ?? .mlb,
season: canonical.season,
isPlayoff: canonical.isPlayoff,
broadcastInfo: canonical.broadcastInfo
)
}
}
func fetchGame(by id: UUID) async throws -> Game? {
try await loadCachesIfNeeded()
let context = ModelContext(modelContainer)
// Search by UUID
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.uuid == id && game.deprecatedAt == nil
}
)
guard let canonical = try context.fetch(descriptor).first else {
return nil
}
guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId],
let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId],
let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
return nil
}
return Game(
id: canonical.uuid,
homeTeamId: homeTeamUUID,
awayTeamId: awayTeamUUID,
stadiumId: stadiumUUID,
dateTime: canonical.dateTime,
sport: canonical.sportEnum ?? .mlb,
season: canonical.season,
isPlayoff: canonical.isPlayoff,
broadcastInfo: canonical.broadcastInfo
)
}
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
try await loadCachesIfNeeded()
let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate)
let teamsById = Dictionary(uniqueKeysWithValues: (cachedTeams ?? []).map { ($0.id, $0) })
let stadiumsById = Dictionary(uniqueKeysWithValues: (cachedStadiums ?? []).map { ($0.id, $0) })
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)
}
}
// MARK: - Additional Queries
/// Fetch stadium by canonical ID (useful for visit tracking)
func fetchStadium(byCanonicalId canonicalId: String) async throws -> Stadium? {
try await loadCachesIfNeeded()
return stadiumsByCanonicalId[canonicalId]
}
/// Fetch team by canonical ID
func fetchTeam(byCanonicalId canonicalId: String) async throws -> Team? {
try await loadCachesIfNeeded()
return teamsByCanonicalId[canonicalId]
}
/// Find stadium by name (matches aliases)
func findStadium(byName name: String) async throws -> Stadium? {
let context = ModelContext(modelContainer)
// Precompute lowercased name outside the predicate
let lowercasedName = name.lowercased()
// First try exact alias match
let aliasDescriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { alias in
alias.aliasName == lowercasedName
}
)
if let alias = try context.fetch(aliasDescriptor).first,
let stadiumCanonicalId = Optional(alias.stadiumCanonicalId) {
return try await fetchStadium(byCanonicalId: stadiumCanonicalId)
}
return nil
}
/// Invalidate caches (call after sync completes)
func invalidateCaches() {
cachedTeams = nil
cachedStadiums = nil
teamsByCanonicalId.removeAll()
stadiumsByCanonicalId.removeAll()
teamUUIDByCanonicalId.removeAll()
stadiumUUIDByCanonicalId.removeAll()
}
// MARK: - Private Helpers
private func loadCachesIfNeeded() async throws {
guard cachedTeams == nil else { return }
let context = ModelContext(modelContainer)
// Load stadiums
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.deprecatedAt == nil
}
)
let canonicalStadiums = try context.fetch(stadiumDescriptor)
cachedStadiums = canonicalStadiums.map { canonical in
let stadium = canonical.toDomain()
stadiumsByCanonicalId[canonical.canonicalId] = stadium
stadiumUUIDByCanonicalId[canonical.canonicalId] = stadium.id
return stadium
}
// Load teams
let teamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate<CanonicalTeam> { team in
team.deprecatedAt == nil
}
)
let canonicalTeams = try context.fetch(teamDescriptor)
cachedTeams = canonicalTeams.compactMap { canonical -> Team? in
guard let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
// Generate a placeholder UUID for teams without known stadiums
let placeholderUUID = CanonicalStadium.deterministicUUID(from: canonical.stadiumCanonicalId)
let team = canonical.toDomain(stadiumUUID: placeholderUUID)
teamsByCanonicalId[canonical.canonicalId] = team
teamUUIDByCanonicalId[canonical.canonicalId] = team.id
return team
}
let team = canonical.toDomain(stadiumUUID: stadiumUUID)
teamsByCanonicalId[canonical.canonicalId] = team
teamUUIDByCanonicalId[canonical.canonicalId] = team.id
return team
}
}
}