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:
204
SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift
Normal file
204
SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
//
|
||||
// ProgressViewModel.swift
|
||||
// SportsTime
|
||||
//
|
||||
// ViewModel for stadium progress tracking and visualization.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProgressViewModel {
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var selectedSport: Sport = .mlb
|
||||
var isLoading = false
|
||||
var error: Error?
|
||||
var errorMessage: String?
|
||||
|
||||
// MARK: - Data
|
||||
|
||||
private(set) var visits: [StadiumVisit] = []
|
||||
private(set) var stadiums: [Stadium] = []
|
||||
private(set) var teams: [Team] = []
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private var modelContainer: ModelContainer?
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Overall progress for the selected sport
|
||||
var leagueProgress: LeagueProgress {
|
||||
// Filter stadiums by sport directly (same as sportStadiums)
|
||||
let sportStadiums = stadiums.filter { $0.sport == selectedSport }
|
||||
|
||||
let visitedStadiumIds = Set(
|
||||
visits
|
||||
.filter { $0.sportEnum == selectedSport }
|
||||
.compactMap { visit -> UUID? in
|
||||
// Match visit's canonical stadium ID to a stadium
|
||||
stadiums.first { stadium in
|
||||
stadium.id == visit.stadiumUUID
|
||||
}?.id
|
||||
}
|
||||
)
|
||||
|
||||
let visited = sportStadiums.filter { visitedStadiumIds.contains($0.id) }
|
||||
let remaining = sportStadiums.filter { !visitedStadiumIds.contains($0.id) }
|
||||
|
||||
return LeagueProgress(
|
||||
sport: selectedSport,
|
||||
totalStadiums: sportStadiums.count,
|
||||
visitedStadiums: visited.count,
|
||||
stadiumsVisited: visited,
|
||||
stadiumsRemaining: remaining
|
||||
)
|
||||
}
|
||||
|
||||
/// Stadium visit status indexed by stadium ID
|
||||
var stadiumVisitStatus: [UUID: StadiumVisitStatus] {
|
||||
var statusMap: [UUID: StadiumVisitStatus] = [:]
|
||||
|
||||
// Group visits by stadium
|
||||
let visitsByStadium = Dictionary(grouping: visits.filter { $0.sportEnum == selectedSport }) { $0.stadiumUUID }
|
||||
|
||||
for stadium in stadiums {
|
||||
if let stadiumVisits = visitsByStadium[stadium.id], !stadiumVisits.isEmpty {
|
||||
let summaries = stadiumVisits.map { visit in
|
||||
VisitSummary(
|
||||
id: visit.id,
|
||||
stadium: stadium,
|
||||
visitDate: visit.visitDate,
|
||||
visitType: visit.visitType,
|
||||
sport: selectedSport,
|
||||
matchup: visit.matchupDescription,
|
||||
score: visit.finalScore,
|
||||
photoCount: visit.photoMetadata?.count ?? 0,
|
||||
notes: visit.notes
|
||||
)
|
||||
}
|
||||
statusMap[stadium.id] = .visited(visits: summaries)
|
||||
} else {
|
||||
statusMap[stadium.id] = .notVisited
|
||||
}
|
||||
}
|
||||
|
||||
return statusMap
|
||||
}
|
||||
|
||||
/// Stadiums for the selected sport
|
||||
var sportStadiums: [Stadium] {
|
||||
stadiums.filter { $0.sport == selectedSport }
|
||||
}
|
||||
|
||||
/// Visited stadiums for the selected sport
|
||||
var visitedStadiums: [Stadium] {
|
||||
leagueProgress.stadiumsVisited
|
||||
}
|
||||
|
||||
/// Unvisited stadiums for the selected sport
|
||||
var unvisitedStadiums: [Stadium] {
|
||||
leagueProgress.stadiumsRemaining
|
||||
}
|
||||
|
||||
/// Recent visits sorted by date
|
||||
var recentVisits: [VisitSummary] {
|
||||
visits
|
||||
.sorted { $0.visitDate > $1.visitDate }
|
||||
.prefix(10)
|
||||
.compactMap { visit -> VisitSummary? in
|
||||
guard let stadium = stadiums.first(where: { $0.id == visit.stadiumUUID }),
|
||||
let sport = visit.sportEnum else {
|
||||
return nil
|
||||
}
|
||||
return VisitSummary(
|
||||
id: visit.id,
|
||||
stadium: stadium,
|
||||
visitDate: visit.visitDate,
|
||||
visitType: visit.visitType,
|
||||
sport: sport,
|
||||
matchup: visit.matchupDescription,
|
||||
score: visit.finalScore,
|
||||
photoCount: visit.photoMetadata?.count ?? 0,
|
||||
notes: visit.notes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
func configure(with container: ModelContainer) {
|
||||
self.modelContainer = container
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func loadData() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
// Load stadiums and teams from data provider
|
||||
if dataProvider.stadiums.isEmpty {
|
||||
await dataProvider.loadInitialData()
|
||||
}
|
||||
stadiums = dataProvider.stadiums
|
||||
teams = dataProvider.teams
|
||||
|
||||
// Load visits from SwiftData
|
||||
if let container = modelContainer {
|
||||
let context = ModelContext(container)
|
||||
let descriptor = FetchDescriptor<StadiumVisit>(
|
||||
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
|
||||
)
|
||||
visits = try context.fetch(descriptor)
|
||||
}
|
||||
} catch {
|
||||
self.error = error
|
||||
self.errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func selectSport(_ sport: Sport) {
|
||||
selectedSport = sport
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
error = nil
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
// MARK: - Visit Management
|
||||
|
||||
func deleteVisit(_ visit: StadiumVisit) async throws {
|
||||
guard let container = modelContainer else { return }
|
||||
|
||||
let context = ModelContext(container)
|
||||
context.delete(visit)
|
||||
try context.save()
|
||||
|
||||
// Reload data
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// MARK: - Progress Card Generation
|
||||
|
||||
func progressCardData(includeUsername: Bool = false) -> ProgressCardData {
|
||||
ProgressCardData(
|
||||
sport: selectedSport,
|
||||
progress: leagueProgress,
|
||||
username: nil,
|
||||
includeMap: true,
|
||||
showDetailedStats: false
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user