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>
349 lines
11 KiB
Swift
349 lines
11 KiB
Swift
//
|
|
// StadiumProximityMatcher.swift
|
|
// SportsTime
|
|
//
|
|
// Service for matching GPS coordinates to nearby stadiums.
|
|
//
|
|
|
|
import Foundation
|
|
import CoreLocation
|
|
|
|
// MARK: - Match Confidence
|
|
|
|
enum MatchConfidence: Sendable {
|
|
case high // < 500m from stadium center
|
|
case medium // 500m - 2km
|
|
case low // 2km - 5km
|
|
case none // > 5km or no coordinates
|
|
|
|
nonisolated var description: String {
|
|
switch self {
|
|
case .high: return "High (within 500m)"
|
|
case .medium: return "Medium (500m - 2km)"
|
|
case .low: return "Low (2km - 5km)"
|
|
case .none: return "No match"
|
|
}
|
|
}
|
|
|
|
/// Should auto-select this match without user confirmation?
|
|
nonisolated var shouldAutoSelect: Bool {
|
|
switch self {
|
|
case .high: return true
|
|
default: return false
|
|
}
|
|
}
|
|
}
|
|
|
|
// Explicit nonisolated Equatable and Comparable conformance
|
|
extension MatchConfidence: Equatable {
|
|
nonisolated static func == (lhs: MatchConfidence, rhs: MatchConfidence) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.high, .high), (.medium, .medium), (.low, .low), (.none, .none):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
extension MatchConfidence: Comparable {
|
|
nonisolated static func < (lhs: MatchConfidence, rhs: MatchConfidence) -> Bool {
|
|
let order: [MatchConfidence] = [.none, .low, .medium, .high]
|
|
guard let lhsIndex = order.firstIndex(of: lhs),
|
|
let rhsIndex = order.firstIndex(of: rhs) else {
|
|
return false
|
|
}
|
|
return lhsIndex < rhsIndex
|
|
}
|
|
}
|
|
|
|
// MARK: - Stadium Match
|
|
|
|
struct StadiumMatch: Identifiable, Sendable {
|
|
let id: UUID
|
|
let stadium: Stadium
|
|
let distance: CLLocationDistance
|
|
let confidence: MatchConfidence
|
|
|
|
init(stadium: Stadium, distance: CLLocationDistance) {
|
|
self.id = stadium.id
|
|
self.stadium = stadium
|
|
self.distance = distance
|
|
self.confidence = Self.calculateConfidence(for: distance)
|
|
}
|
|
|
|
var formattedDistance: String {
|
|
if distance < 1000 {
|
|
return String(format: "%.0fm away", distance)
|
|
} else {
|
|
return String(format: "%.1f km away", distance / 1000)
|
|
}
|
|
}
|
|
|
|
private static func calculateConfidence(for distance: CLLocationDistance) -> MatchConfidence {
|
|
switch distance {
|
|
case 0..<500:
|
|
return .high
|
|
case 500..<2000:
|
|
return .medium
|
|
case 2000..<5000:
|
|
return .low
|
|
default:
|
|
return .none
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Temporal Confidence
|
|
|
|
enum TemporalConfidence: Sendable {
|
|
case exactDay // Same local date as game
|
|
case adjacentDay // ±1 day (tailgating, next morning)
|
|
case outOfRange // >1 day difference
|
|
|
|
nonisolated var description: String {
|
|
switch self {
|
|
case .exactDay: return "Same day"
|
|
case .adjacentDay: return "Adjacent day (±1)"
|
|
case .outOfRange: return "Out of range"
|
|
}
|
|
}
|
|
}
|
|
|
|
extension TemporalConfidence: Equatable {
|
|
nonisolated static func == (lhs: TemporalConfidence, rhs: TemporalConfidence) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.exactDay, .exactDay), (.adjacentDay, .adjacentDay), (.outOfRange, .outOfRange):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
extension TemporalConfidence: Comparable {
|
|
nonisolated static func < (lhs: TemporalConfidence, rhs: TemporalConfidence) -> Bool {
|
|
let order: [TemporalConfidence] = [.outOfRange, .adjacentDay, .exactDay]
|
|
guard let lhsIndex = order.firstIndex(of: lhs),
|
|
let rhsIndex = order.firstIndex(of: rhs) else {
|
|
return false
|
|
}
|
|
return lhsIndex < rhsIndex
|
|
}
|
|
}
|
|
|
|
// MARK: - Combined Confidence
|
|
|
|
enum CombinedConfidence: Sendable {
|
|
case autoSelect // High spatial + exactDay → auto-select
|
|
case userConfirm // Medium spatial OR adjacentDay → user confirms
|
|
case manualOnly // Low spatial OR outOfRange → manual entry
|
|
|
|
nonisolated var description: String {
|
|
switch self {
|
|
case .autoSelect: return "Auto-select"
|
|
case .userConfirm: return "Needs confirmation"
|
|
case .manualOnly: return "Manual entry required"
|
|
}
|
|
}
|
|
|
|
nonisolated static func combine(spatial: MatchConfidence, temporal: TemporalConfidence) -> CombinedConfidence {
|
|
// Low spatial or out of range → manual only
|
|
switch spatial {
|
|
case .low, .none:
|
|
return .manualOnly
|
|
default:
|
|
break
|
|
}
|
|
|
|
switch temporal {
|
|
case .outOfRange:
|
|
return .manualOnly
|
|
default:
|
|
break
|
|
}
|
|
|
|
// High spatial + exact day → auto-select
|
|
switch (spatial, temporal) {
|
|
case (.high, .exactDay):
|
|
return .autoSelect
|
|
default:
|
|
break
|
|
}
|
|
|
|
// Everything else needs user confirmation
|
|
return .userConfirm
|
|
}
|
|
}
|
|
|
|
extension CombinedConfidence: Equatable {
|
|
nonisolated static func == (lhs: CombinedConfidence, rhs: CombinedConfidence) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.autoSelect, .autoSelect), (.userConfirm, .userConfirm), (.manualOnly, .manualOnly):
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
extension CombinedConfidence: Comparable {
|
|
nonisolated static func < (lhs: CombinedConfidence, rhs: CombinedConfidence) -> Bool {
|
|
let order: [CombinedConfidence] = [.manualOnly, .userConfirm, .autoSelect]
|
|
guard let lhsIndex = order.firstIndex(of: lhs),
|
|
let rhsIndex = order.firstIndex(of: rhs) else {
|
|
return false
|
|
}
|
|
return lhsIndex < rhsIndex
|
|
}
|
|
}
|
|
|
|
// MARK: - Photo Match Confidence
|
|
|
|
struct PhotoMatchConfidence: Sendable {
|
|
let spatial: MatchConfidence
|
|
let temporal: TemporalConfidence
|
|
let combined: CombinedConfidence
|
|
|
|
nonisolated init(spatial: MatchConfidence, temporal: TemporalConfidence) {
|
|
self.spatial = spatial
|
|
self.temporal = temporal
|
|
self.combined = CombinedConfidence.combine(spatial: spatial, temporal: temporal)
|
|
}
|
|
}
|
|
|
|
// MARK: - Stadium Proximity Matcher
|
|
|
|
@MainActor
|
|
final class StadiumProximityMatcher {
|
|
static let shared = StadiumProximityMatcher()
|
|
|
|
// Configuration constants
|
|
static let highConfidenceRadius: CLLocationDistance = 500 // 500m
|
|
static let mediumConfidenceRadius: CLLocationDistance = 2000 // 2km
|
|
static let searchRadius: CLLocationDistance = 5000 // 5km default
|
|
static let dateToleranceDays: Int = 1 // ±1 day for timezone/tailgating
|
|
|
|
private let dataProvider = AppDataProvider.shared
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Stadium Matching
|
|
|
|
/// Find stadiums within radius of coordinates
|
|
func findNearbyStadiums(
|
|
coordinates: CLLocationCoordinate2D,
|
|
radius: CLLocationDistance = StadiumProximityMatcher.searchRadius,
|
|
sport: Sport? = nil
|
|
) -> [StadiumMatch] {
|
|
let photoLocation = CLLocation(latitude: coordinates.latitude, longitude: coordinates.longitude)
|
|
|
|
var stadiums = dataProvider.stadiums
|
|
|
|
// Filter by sport if specified
|
|
if let sport = sport {
|
|
let sportTeams = dataProvider.teams.filter { $0.sport == sport }
|
|
let stadiumIds = Set(sportTeams.map { $0.stadiumId })
|
|
stadiums = stadiums.filter { stadiumIds.contains($0.id) }
|
|
}
|
|
|
|
// Calculate distances and filter by radius
|
|
var matches: [StadiumMatch] = []
|
|
|
|
for stadium in stadiums {
|
|
let stadiumLocation = CLLocation(latitude: stadium.latitude, longitude: stadium.longitude)
|
|
let distance = photoLocation.distance(from: stadiumLocation)
|
|
|
|
if distance <= radius {
|
|
matches.append(StadiumMatch(stadium: stadium, distance: distance))
|
|
}
|
|
}
|
|
|
|
// Sort by distance (closest first)
|
|
return matches.sorted { $0.distance < $1.distance }
|
|
}
|
|
|
|
/// Find best matching stadium (single result)
|
|
func findBestMatch(
|
|
coordinates: CLLocationCoordinate2D,
|
|
sport: Sport? = nil
|
|
) -> StadiumMatch? {
|
|
let matches = findNearbyStadiums(coordinates: coordinates, sport: sport)
|
|
return matches.first
|
|
}
|
|
|
|
/// Check if coordinates are near any stadium
|
|
func isNearStadium(
|
|
coordinates: CLLocationCoordinate2D,
|
|
radius: CLLocationDistance = StadiumProximityMatcher.searchRadius
|
|
) -> Bool {
|
|
let matches = findNearbyStadiums(coordinates: coordinates, radius: radius)
|
|
return !matches.isEmpty
|
|
}
|
|
|
|
// MARK: - Temporal Matching
|
|
|
|
/// Calculate temporal confidence between photo date and game date
|
|
nonisolated func calculateTemporalConfidence(photoDate: Date, gameDate: Date) -> TemporalConfidence {
|
|
let calendar = Calendar.current
|
|
|
|
// Normalize to day boundaries
|
|
let photoDay = calendar.startOfDay(for: photoDate)
|
|
let gameDay = calendar.startOfDay(for: gameDate)
|
|
|
|
let daysDifference = abs(calendar.dateComponents([.day], from: photoDay, to: gameDay).day ?? Int.max)
|
|
|
|
switch daysDifference {
|
|
case 0:
|
|
return .exactDay
|
|
case 1:
|
|
return .adjacentDay
|
|
default:
|
|
return .outOfRange
|
|
}
|
|
}
|
|
|
|
/// Calculate combined confidence for a photo-stadium-game match
|
|
nonisolated func calculateMatchConfidence(
|
|
stadiumMatch: StadiumMatch,
|
|
photoDate: Date?,
|
|
gameDate: Date?
|
|
) -> PhotoMatchConfidence {
|
|
let spatial = stadiumMatch.confidence
|
|
|
|
let temporal: TemporalConfidence
|
|
if let photoDate = photoDate, let gameDate = gameDate {
|
|
temporal = calculateTemporalConfidence(photoDate: photoDate, gameDate: gameDate)
|
|
} else {
|
|
// Missing date information
|
|
temporal = .outOfRange
|
|
}
|
|
|
|
return PhotoMatchConfidence(spatial: spatial, temporal: temporal)
|
|
}
|
|
}
|
|
|
|
// MARK: - Batch Processing
|
|
|
|
extension StadiumProximityMatcher {
|
|
/// Find matches for multiple photos
|
|
func findMatchesForPhotos(
|
|
_ metadata: [PhotoMetadata],
|
|
sport: Sport? = nil
|
|
) -> [(metadata: PhotoMetadata, matches: [StadiumMatch])] {
|
|
var results: [(metadata: PhotoMetadata, matches: [StadiumMatch])] = []
|
|
|
|
for photo in metadata {
|
|
if let coordinates = photo.coordinates {
|
|
let matches = findNearbyStadiums(coordinates: coordinates, sport: sport)
|
|
results.append((metadata: photo, matches: matches))
|
|
} else {
|
|
// No coordinates - empty matches
|
|
results.append((metadata: photo, matches: []))
|
|
}
|
|
}
|
|
|
|
return results
|
|
}
|
|
}
|