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>
This commit is contained in:
234
SportsTime/Core/Services/CanonicalDataProvider.swift
Normal file
234
SportsTime/Core/Services/CanonicalDataProvider.swift
Normal file
@@ -0,0 +1,234 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user