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:
324
SportsTime/Core/Services/GameMatcher.swift
Normal file
324
SportsTime/Core/Services/GameMatcher.swift
Normal file
@@ -0,0 +1,324 @@
|
||||
//
|
||||
// GameMatcher.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Deterministic game matching from photo metadata.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
// MARK: - No Match Reason
|
||||
|
||||
enum NoMatchReason: Sendable {
|
||||
case noStadiumNearby
|
||||
case noGamesOnDate
|
||||
case metadataMissing(MetadataMissingReason)
|
||||
|
||||
enum MetadataMissingReason: Sendable {
|
||||
case noLocation
|
||||
case noDate
|
||||
case noBoth
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .noStadiumNearby:
|
||||
return "No stadium found nearby"
|
||||
case .noGamesOnDate:
|
||||
return "No games found on this date"
|
||||
case .metadataMissing(let reason):
|
||||
switch reason {
|
||||
case .noLocation:
|
||||
return "Photo has no location data"
|
||||
case .noDate:
|
||||
return "Photo has no date information"
|
||||
case .noBoth:
|
||||
return "Photo has no location or date data"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Game Match Result
|
||||
|
||||
struct GameMatchCandidate: Identifiable, Sendable {
|
||||
let id: UUID
|
||||
let game: Game
|
||||
let stadium: Stadium
|
||||
let homeTeam: Team
|
||||
let awayTeam: Team
|
||||
let confidence: PhotoMatchConfidence
|
||||
|
||||
init(game: Game, stadium: Stadium, homeTeam: Team, awayTeam: Team, confidence: PhotoMatchConfidence) {
|
||||
self.id = game.id
|
||||
self.game = game
|
||||
self.stadium = stadium
|
||||
self.homeTeam = homeTeam
|
||||
self.awayTeam = awayTeam
|
||||
self.confidence = confidence
|
||||
}
|
||||
|
||||
var matchupDescription: String {
|
||||
"\(awayTeam.abbreviation) @ \(homeTeam.abbreviation)"
|
||||
}
|
||||
|
||||
var fullMatchupDescription: String {
|
||||
"\(awayTeam.fullName) at \(homeTeam.fullName)"
|
||||
}
|
||||
|
||||
var gameDateTime: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: game.dateTime)
|
||||
}
|
||||
}
|
||||
|
||||
enum GameMatchResult: Sendable {
|
||||
case singleMatch(GameMatchCandidate) // Auto-select
|
||||
case multipleMatches([GameMatchCandidate]) // User selects (doubleheader, nearby stadiums)
|
||||
case noMatches(NoMatchReason) // Manual entry required
|
||||
|
||||
var hasMatch: Bool {
|
||||
switch self {
|
||||
case .singleMatch, .multipleMatches:
|
||||
return true
|
||||
case .noMatches:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photo Import Result
|
||||
|
||||
struct PhotoImportCandidate: Identifiable, Sendable {
|
||||
let id: UUID
|
||||
let metadata: PhotoMetadata
|
||||
let matchResult: GameMatchResult
|
||||
let stadiumMatches: [StadiumMatch]
|
||||
|
||||
init(metadata: PhotoMetadata, matchResult: GameMatchResult, stadiumMatches: [StadiumMatch]) {
|
||||
self.id = UUID()
|
||||
self.metadata = metadata
|
||||
self.matchResult = matchResult
|
||||
self.stadiumMatches = stadiumMatches
|
||||
}
|
||||
|
||||
/// Best stadium match if available
|
||||
var bestStadiumMatch: StadiumMatch? {
|
||||
stadiumMatches.first
|
||||
}
|
||||
|
||||
/// Whether this can be auto-processed without user input
|
||||
var canAutoProcess: Bool {
|
||||
if case .singleMatch(let candidate) = matchResult {
|
||||
return candidate.confidence.combined == .autoSelect
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Game Matcher
|
||||
|
||||
@MainActor
|
||||
final class GameMatcher {
|
||||
static let shared = GameMatcher()
|
||||
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
private let proximityMatcher = StadiumProximityMatcher.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Primary Matching
|
||||
|
||||
/// Match photo metadata to a game
|
||||
/// Uses deterministic rules - never guesses
|
||||
func matchGame(
|
||||
metadata: PhotoMetadata,
|
||||
sport: Sport? = nil
|
||||
) async -> GameMatchResult {
|
||||
// 1. Check for required metadata
|
||||
guard metadata.hasValidLocation else {
|
||||
let reason: NoMatchReason.MetadataMissingReason = metadata.hasValidDate ? .noLocation : .noBoth
|
||||
return .noMatches(.metadataMissing(reason))
|
||||
}
|
||||
|
||||
guard metadata.hasValidDate, let photoDate = metadata.captureDate else {
|
||||
return .noMatches(.metadataMissing(.noDate))
|
||||
}
|
||||
|
||||
guard let coordinates = metadata.coordinates else {
|
||||
return .noMatches(.metadataMissing(.noLocation))
|
||||
}
|
||||
|
||||
// 2. Find nearby stadiums
|
||||
let stadiumMatches = proximityMatcher.findNearbyStadiums(
|
||||
coordinates: coordinates,
|
||||
sport: sport
|
||||
)
|
||||
|
||||
guard !stadiumMatches.isEmpty else {
|
||||
return .noMatches(.noStadiumNearby)
|
||||
}
|
||||
|
||||
// 3. Find games at those stadiums on/around that date
|
||||
var candidates: [GameMatchCandidate] = []
|
||||
|
||||
for stadiumMatch in stadiumMatches {
|
||||
let games = await findGames(
|
||||
at: stadiumMatch.stadium,
|
||||
around: photoDate,
|
||||
sport: sport
|
||||
)
|
||||
|
||||
for game in games {
|
||||
// Look up teams
|
||||
guard let homeTeam = dataProvider.teams.first(where: { $0.id == game.homeTeamId }),
|
||||
let awayTeam = dataProvider.teams.first(where: { $0.id == game.awayTeamId }) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate confidence
|
||||
let confidence = proximityMatcher.calculateMatchConfidence(
|
||||
stadiumMatch: stadiumMatch,
|
||||
photoDate: photoDate,
|
||||
gameDate: game.dateTime
|
||||
)
|
||||
|
||||
// Only include if temporal confidence is acceptable
|
||||
if confidence.temporal != .outOfRange {
|
||||
candidates.append(GameMatchCandidate(
|
||||
game: game,
|
||||
stadium: stadiumMatch.stadium,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
confidence: confidence
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Return based on matches found
|
||||
if candidates.isEmpty {
|
||||
return .noMatches(.noGamesOnDate)
|
||||
} else if candidates.count == 1 {
|
||||
return .singleMatch(candidates[0])
|
||||
} else {
|
||||
// Sort by confidence (best first)
|
||||
let sorted = candidates.sorted { c1, c2 in
|
||||
c1.confidence.combined > c2.confidence.combined
|
||||
}
|
||||
return .multipleMatches(sorted)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Full Import Processing
|
||||
|
||||
/// Process a photo for import, returning full match context
|
||||
func processPhotoForImport(
|
||||
metadata: PhotoMetadata,
|
||||
sport: Sport? = nil
|
||||
) async -> PhotoImportCandidate {
|
||||
// Get stadium matches regardless of game matching
|
||||
var stadiumMatches: [StadiumMatch] = []
|
||||
if let coordinates = metadata.coordinates {
|
||||
stadiumMatches = proximityMatcher.findNearbyStadiums(
|
||||
coordinates: coordinates,
|
||||
sport: sport
|
||||
)
|
||||
}
|
||||
|
||||
let matchResult = await matchGame(metadata: metadata, sport: sport)
|
||||
|
||||
return PhotoImportCandidate(
|
||||
metadata: metadata,
|
||||
matchResult: matchResult,
|
||||
stadiumMatches: stadiumMatches
|
||||
)
|
||||
}
|
||||
|
||||
/// Process multiple photos for import
|
||||
func processPhotosForImport(
|
||||
_ metadataList: [PhotoMetadata],
|
||||
sport: Sport? = nil
|
||||
) async -> [PhotoImportCandidate] {
|
||||
var results: [PhotoImportCandidate] = []
|
||||
|
||||
for metadata in metadataList {
|
||||
let candidate = await processPhotoForImport(metadata: metadata, sport: sport)
|
||||
results.append(candidate)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Find games at a stadium around a given date (±1 day for timezone/tailgating)
|
||||
private func findGames(
|
||||
at stadium: Stadium,
|
||||
around date: Date,
|
||||
sport: Sport?
|
||||
) async -> [Game] {
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Search window: ±1 day
|
||||
guard let startDate = calendar.date(byAdding: .day, value: -1, to: date),
|
||||
let endDate = calendar.date(byAdding: .day, value: 2, to: date) else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Determine which sports to query
|
||||
let sports: Set<Sport> = sport != nil ? [sport!] : Set(Sport.allCases)
|
||||
|
||||
do {
|
||||
let allGames = try await dataProvider.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
|
||||
|
||||
// Filter by stadium
|
||||
let games = allGames.filter { $0.stadiumId == stadium.id }
|
||||
|
||||
return games
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Batch Processing Helpers
|
||||
|
||||
extension GameMatcher {
|
||||
/// Separate photos into categories for UI
|
||||
struct CategorizedImports: Sendable {
|
||||
let autoProcessable: [PhotoImportCandidate]
|
||||
let needsConfirmation: [PhotoImportCandidate]
|
||||
let needsManualEntry: [PhotoImportCandidate]
|
||||
}
|
||||
|
||||
nonisolated func categorizeImports(_ candidates: [PhotoImportCandidate]) -> CategorizedImports {
|
||||
var auto: [PhotoImportCandidate] = []
|
||||
var confirm: [PhotoImportCandidate] = []
|
||||
var manual: [PhotoImportCandidate] = []
|
||||
|
||||
for candidate in candidates {
|
||||
switch candidate.matchResult {
|
||||
case .singleMatch(let match):
|
||||
if match.confidence.combined == .autoSelect {
|
||||
auto.append(candidate)
|
||||
} else {
|
||||
confirm.append(candidate)
|
||||
}
|
||||
case .multipleMatches:
|
||||
confirm.append(candidate)
|
||||
case .noMatches:
|
||||
manual.append(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return CategorizedImports(
|
||||
autoProcessable: auto,
|
||||
needsConfirmation: confirm,
|
||||
needsManualEntry: manual
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user