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>
274 lines
8.6 KiB
Swift
274 lines
8.6 KiB
Swift
//
|
|
// StadiumIdentityService.swift
|
|
// SportsTime
|
|
//
|
|
// Service for resolving stadium identities across renames and aliases.
|
|
// Wraps CanonicalStadium lookups from SwiftData.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftData
|
|
|
|
// MARK: - Stadium Identity Service
|
|
|
|
/// Resolves stadium identities to canonical IDs, handling renames and aliases.
|
|
/// Example: "SBC Park", "AT&T Park", and "Oracle Park" all resolve to the same canonical ID.
|
|
actor StadiumIdentityService {
|
|
|
|
// MARK: - Singleton
|
|
|
|
static let shared = StadiumIdentityService()
|
|
|
|
// MARK: - Properties
|
|
|
|
private var modelContainer: ModelContainer?
|
|
|
|
// Cache for performance
|
|
private var uuidToCanonicalId: [UUID: String] = [:]
|
|
private var canonicalIdToUUID: [String: UUID] = [:]
|
|
private var nameToCanonicalId: [String: String] = [:]
|
|
|
|
// MARK: - Initialization
|
|
|
|
private init() {}
|
|
|
|
// MARK: - Configuration
|
|
|
|
/// Configure the service with a model container
|
|
func configure(with container: ModelContainer) {
|
|
self.modelContainer = container
|
|
invalidateCache()
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Get the canonical ID for a stadium UUID
|
|
/// Returns the same canonicalId for stadiums that are the same physical location
|
|
func canonicalId(for stadiumUUID: UUID) async throws -> String? {
|
|
// Check cache first
|
|
if let cached = uuidToCanonicalId[stadiumUUID] {
|
|
return cached
|
|
}
|
|
|
|
guard let container = modelContainer else {
|
|
return nil
|
|
}
|
|
|
|
let context = ModelContext(container)
|
|
|
|
let descriptor = FetchDescriptor<CanonicalStadium>(
|
|
predicate: #Predicate<CanonicalStadium> { stadium in
|
|
stadium.uuid == stadiumUUID
|
|
}
|
|
)
|
|
|
|
guard let stadium = try context.fetch(descriptor).first else {
|
|
return nil
|
|
}
|
|
|
|
// Cache the result
|
|
uuidToCanonicalId[stadiumUUID] = stadium.canonicalId
|
|
canonicalIdToUUID[stadium.canonicalId] = stadium.uuid
|
|
|
|
return stadium.canonicalId
|
|
}
|
|
|
|
/// Get the canonical ID for a stadium name (searches aliases too)
|
|
func canonicalId(forName name: String) async throws -> String? {
|
|
let lowercasedName = name.lowercased()
|
|
|
|
// Check cache first
|
|
if let cached = nameToCanonicalId[lowercasedName] {
|
|
return cached
|
|
}
|
|
|
|
guard let container = modelContainer else {
|
|
return nil
|
|
}
|
|
|
|
let context = ModelContext(container)
|
|
|
|
// First check stadium aliases
|
|
let aliasDescriptor = FetchDescriptor<StadiumAlias>(
|
|
predicate: #Predicate<StadiumAlias> { alias in
|
|
alias.aliasName == lowercasedName
|
|
}
|
|
)
|
|
|
|
if let alias = try context.fetch(aliasDescriptor).first {
|
|
nameToCanonicalId[lowercasedName] = alias.stadiumCanonicalId
|
|
return alias.stadiumCanonicalId
|
|
}
|
|
|
|
// Fall back to direct stadium name match
|
|
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>()
|
|
let stadiums = try context.fetch(stadiumDescriptor)
|
|
|
|
// Case-insensitive match on stadium name
|
|
if let stadium = stadiums.first(where: { $0.name.lowercased() == lowercasedName }) {
|
|
nameToCanonicalId[lowercasedName] = stadium.canonicalId
|
|
return stadium.canonicalId
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Check if two stadium UUIDs represent the same physical stadium
|
|
func isSameStadium(_ id1: UUID, _ id2: UUID) async throws -> Bool {
|
|
guard let canonicalId1 = try await canonicalId(for: id1),
|
|
let canonicalId2 = try await canonicalId(for: id2) else {
|
|
// If we can't resolve, fall back to direct comparison
|
|
return id1 == id2
|
|
}
|
|
return canonicalId1 == canonicalId2
|
|
}
|
|
|
|
/// Get the current UUID for a canonical stadium ID
|
|
func currentUUID(forCanonicalId canonicalId: String) async throws -> UUID? {
|
|
// Check cache first
|
|
if let cached = canonicalIdToUUID[canonicalId] {
|
|
return cached
|
|
}
|
|
|
|
guard let container = modelContainer else {
|
|
return nil
|
|
}
|
|
|
|
let context = ModelContext(container)
|
|
|
|
let descriptor = FetchDescriptor<CanonicalStadium>(
|
|
predicate: #Predicate<CanonicalStadium> { stadium in
|
|
stadium.canonicalId == canonicalId && stadium.deprecatedAt == nil
|
|
}
|
|
)
|
|
|
|
guard let stadium = try context.fetch(descriptor).first else {
|
|
return nil
|
|
}
|
|
|
|
// Cache the result
|
|
canonicalIdToUUID[canonicalId] = stadium.uuid
|
|
uuidToCanonicalId[stadium.uuid] = stadium.canonicalId
|
|
|
|
return stadium.uuid
|
|
}
|
|
|
|
/// Get the current name for a canonical stadium ID
|
|
func currentName(forCanonicalId canonicalId: String) async throws -> String? {
|
|
guard let container = modelContainer else {
|
|
return nil
|
|
}
|
|
|
|
let context = ModelContext(container)
|
|
|
|
let descriptor = FetchDescriptor<CanonicalStadium>(
|
|
predicate: #Predicate<CanonicalStadium> { stadium in
|
|
stadium.canonicalId == canonicalId && stadium.deprecatedAt == nil
|
|
}
|
|
)
|
|
|
|
guard let stadium = try context.fetch(descriptor).first else {
|
|
return nil
|
|
}
|
|
|
|
return stadium.name
|
|
}
|
|
|
|
/// Get all historical names for a stadium
|
|
func allNames(forCanonicalId canonicalId: String) async throws -> [String] {
|
|
guard let container = modelContainer else {
|
|
return []
|
|
}
|
|
|
|
let context = ModelContext(container)
|
|
|
|
// Get aliases
|
|
let aliasDescriptor = FetchDescriptor<StadiumAlias>(
|
|
predicate: #Predicate<StadiumAlias> { alias in
|
|
alias.stadiumCanonicalId == canonicalId
|
|
}
|
|
)
|
|
|
|
let aliases = try context.fetch(aliasDescriptor)
|
|
var names = aliases.map { $0.aliasName }
|
|
|
|
// Add current name
|
|
if let currentName = try await currentName(forCanonicalId: canonicalId) {
|
|
if !names.contains(currentName.lowercased()) {
|
|
names.append(currentName)
|
|
}
|
|
}
|
|
|
|
return names
|
|
}
|
|
|
|
/// Find stadium by approximate location (for photo import)
|
|
func findStadium(near latitude: Double, longitude: Double, radiusMeters: Double = 5000) async throws -> CanonicalStadium? {
|
|
guard let container = modelContainer else {
|
|
return nil
|
|
}
|
|
|
|
let context = ModelContext(container)
|
|
|
|
// Fetch all active stadiums and filter by distance
|
|
let descriptor = FetchDescriptor<CanonicalStadium>(
|
|
predicate: #Predicate<CanonicalStadium> { stadium in
|
|
stadium.deprecatedAt == nil
|
|
}
|
|
)
|
|
|
|
let stadiums = try context.fetch(descriptor)
|
|
|
|
// Calculate approximate degree ranges for the radius
|
|
// At equator: 1 degree ≈ 111km, so radiusMeters / 111000 gives degrees
|
|
let degreeDelta = radiusMeters / 111000.0
|
|
|
|
let nearbyStadiums = stadiums.filter { stadium in
|
|
abs(stadium.latitude - latitude) <= degreeDelta &&
|
|
abs(stadium.longitude - longitude) <= degreeDelta * 1.5 // Account for longitude compression at higher latitudes
|
|
}
|
|
|
|
// If multiple stadiums nearby, find the closest
|
|
guard !nearbyStadiums.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
if nearbyStadiums.count == 1 {
|
|
return nearbyStadiums.first
|
|
}
|
|
|
|
// Calculate actual distances for the nearby stadiums
|
|
return nearbyStadiums.min { s1, s2 in
|
|
let d1 = haversineDistance(lat1: latitude, lon1: longitude, lat2: s1.latitude, lon2: s1.longitude)
|
|
let d2 = haversineDistance(lat1: latitude, lon1: longitude, lat2: s2.latitude, lon2: s2.longitude)
|
|
return d1 < d2
|
|
}
|
|
}
|
|
|
|
// MARK: - Cache Management
|
|
|
|
/// Invalidate all caches (call after sync)
|
|
func invalidateCache() {
|
|
uuidToCanonicalId.removeAll()
|
|
canonicalIdToUUID.removeAll()
|
|
nameToCanonicalId.removeAll()
|
|
}
|
|
|
|
// MARK: - Private Helpers
|
|
|
|
/// Calculate distance between two coordinates using Haversine formula
|
|
nonisolated private func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
|
|
let R = 6371000.0 // Earth's radius in meters
|
|
let phi1 = lat1 * .pi / 180
|
|
let phi2 = lat2 * .pi / 180
|
|
let deltaPhi = (lat2 - lat1) * .pi / 180
|
|
let deltaLambda = (lon2 - lon1) * .pi / 180
|
|
|
|
let a = sin(deltaPhi / 2) * sin(deltaPhi / 2) +
|
|
cos(phi1) * cos(phi2) * sin(deltaLambda / 2) * sin(deltaLambda / 2)
|
|
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
|
|
|
return R * c
|
|
}
|
|
}
|