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>
235 lines
8.2 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|