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:
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// PhotoImportViewModel.swift
|
||||
// SportsTime
|
||||
//
|
||||
// ViewModel for photo import flow - orchestrates extraction, matching, and import.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import SwiftData
|
||||
import Photos
|
||||
|
||||
@MainActor @Observable
|
||||
final class PhotoImportViewModel {
|
||||
// State
|
||||
var showingPicker = false
|
||||
var isProcessing = false
|
||||
var processedCount = 0
|
||||
var totalCount = 0
|
||||
|
||||
// Results
|
||||
var processedPhotos: [PhotoImportCandidate] = []
|
||||
var confirmedImports: Set<UUID> = []
|
||||
var selectedMatches: [UUID: GameMatchCandidate] = [:]
|
||||
|
||||
// Services
|
||||
private let metadataExtractor = PhotoMetadataExtractor.shared
|
||||
private let gameMatcher = GameMatcher.shared
|
||||
|
||||
// MARK: - Computed
|
||||
|
||||
var categorized: GameMatcher.CategorizedImports {
|
||||
gameMatcher.categorizeImports(processedPhotos)
|
||||
}
|
||||
|
||||
var hasConfirmedImports: Bool {
|
||||
!confirmedImports.isEmpty
|
||||
}
|
||||
|
||||
var confirmedCount: Int {
|
||||
confirmedImports.count
|
||||
}
|
||||
|
||||
// MARK: - Photo Processing
|
||||
|
||||
func processSelectedPhotos(_ items: [PhotosPickerItem]) async {
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
isProcessing = true
|
||||
totalCount = items.count
|
||||
processedCount = 0
|
||||
processedPhotos = []
|
||||
confirmedImports = []
|
||||
selectedMatches = [:]
|
||||
|
||||
// Load PHAssets from PhotosPickerItems
|
||||
var assets: [PHAsset] = []
|
||||
|
||||
for item in items {
|
||||
if let assetId = item.itemIdentifier {
|
||||
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
|
||||
if let asset = fetchResult.firstObject {
|
||||
assets.append(asset)
|
||||
}
|
||||
}
|
||||
processedCount += 1
|
||||
}
|
||||
|
||||
// Extract metadata from all assets
|
||||
let metadataList = await metadataExtractor.extractMetadata(from: assets)
|
||||
|
||||
// Process each photo through game matcher
|
||||
processedCount = 0
|
||||
for metadata in metadataList {
|
||||
let candidate = await gameMatcher.processPhotoForImport(metadata: metadata)
|
||||
processedPhotos.append(candidate)
|
||||
|
||||
// Auto-confirm high-confidence matches
|
||||
if candidate.canAutoProcess {
|
||||
confirmedImports.insert(candidate.id)
|
||||
}
|
||||
|
||||
processedCount += 1
|
||||
}
|
||||
|
||||
isProcessing = false
|
||||
}
|
||||
|
||||
// MARK: - User Actions
|
||||
|
||||
func toggleConfirmation(for candidateId: UUID) {
|
||||
if confirmedImports.contains(candidateId) {
|
||||
confirmedImports.remove(candidateId)
|
||||
} else {
|
||||
confirmedImports.insert(candidateId)
|
||||
}
|
||||
}
|
||||
|
||||
func selectMatch(_ match: GameMatchCandidate, for candidateId: UUID) {
|
||||
selectedMatches[candidateId] = match
|
||||
confirmedImports.insert(candidateId)
|
||||
}
|
||||
|
||||
func confirmAll() {
|
||||
for candidate in processedPhotos {
|
||||
if case .singleMatch = candidate.matchResult {
|
||||
confirmedImports.insert(candidate.id)
|
||||
} else if case .multipleMatches = candidate.matchResult,
|
||||
selectedMatches[candidate.id] != nil {
|
||||
confirmedImports.insert(candidate.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import Creation
|
||||
|
||||
func createVisits(modelContext: ModelContext) async {
|
||||
for candidate in processedPhotos {
|
||||
guard confirmedImports.contains(candidate.id) else { continue }
|
||||
|
||||
// Get the match to use
|
||||
let matchToUse: GameMatchCandidate?
|
||||
|
||||
switch candidate.matchResult {
|
||||
case .singleMatch(let match):
|
||||
matchToUse = match
|
||||
case .multipleMatches:
|
||||
matchToUse = selectedMatches[candidate.id]
|
||||
case .noMatches:
|
||||
matchToUse = nil
|
||||
}
|
||||
|
||||
guard let match = matchToUse else { continue }
|
||||
|
||||
// Create the visit
|
||||
let visit = StadiumVisit(
|
||||
canonicalStadiumId: match.stadium.id.uuidString,
|
||||
stadiumUUID: match.stadium.id,
|
||||
stadiumNameAtVisit: match.stadium.name,
|
||||
visitDate: match.game.dateTime,
|
||||
sport: match.game.sport,
|
||||
visitType: .game,
|
||||
homeTeamName: match.homeTeam.fullName,
|
||||
awayTeamName: match.awayTeam.fullName,
|
||||
finalScore: nil,
|
||||
scoreSource: nil,
|
||||
dataSource: .automatic,
|
||||
seatLocation: nil,
|
||||
notes: nil,
|
||||
photoLatitude: candidate.metadata.coordinates?.latitude,
|
||||
photoLongitude: candidate.metadata.coordinates?.longitude,
|
||||
photoCaptureDate: candidate.metadata.captureDate,
|
||||
source: .photoImport
|
||||
)
|
||||
|
||||
modelContext.insert(visit)
|
||||
}
|
||||
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
// MARK: - Reset
|
||||
|
||||
func reset() {
|
||||
processedPhotos = []
|
||||
confirmedImports = []
|
||||
selectedMatches = [:]
|
||||
isProcessing = false
|
||||
processedCount = 0
|
||||
totalCount = 0
|
||||
}
|
||||
}
|
||||
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