Files
Sportstime/SportsTime/Features/Progress/ViewModels/ProgressViewModel.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

205 lines
6.0 KiB
Swift

//
// 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
)
}
}