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:
Trey t
2026-01-08 20:20:03 -06:00
parent 2281440bf8
commit 92d808caf5
55 changed files with 14348 additions and 61 deletions

View File

@@ -0,0 +1,186 @@
//
// ProgressMapView.swift
// SportsTime
//
// Interactive map showing stadium visit progress with custom annotations.
//
import SwiftUI
import MapKit
// MARK: - Progress Map View
struct ProgressMapView: View {
let stadiums: [Stadium]
let visitStatus: [UUID: StadiumVisitStatus]
@Binding var selectedStadium: Stadium?
@State private var mapRegion = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795), // US center
span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50)
)
var body: some View {
Map(coordinateRegion: $mapRegion, annotationItems: stadiums) { stadium in
MapAnnotation(coordinate: CLLocationCoordinate2D(
latitude: stadium.latitude,
longitude: stadium.longitude
)) {
StadiumMapPin(
stadium: stadium,
isVisited: isVisited(stadium),
isSelected: selectedStadium?.id == stadium.id,
onTap: {
withAnimation(.spring(response: 0.3)) {
if selectedStadium?.id == stadium.id {
selectedStadium = nil
} else {
selectedStadium = stadium
}
}
}
)
}
}
.mapStyle(.standard(elevation: .realistic))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
private func isVisited(_ stadium: Stadium) -> Bool {
if case .visited = visitStatus[stadium.id] {
return true
}
return false
}
}
// MARK: - Stadium Map Pin
struct StadiumMapPin: View {
let stadium: Stadium
let isVisited: Bool
let isSelected: Bool
let onTap: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: onTap) {
VStack(spacing: 2) {
ZStack {
// Pin background
Circle()
.fill(pinColor)
.frame(width: pinSize, height: pinSize)
.shadow(color: .black.opacity(0.2), radius: 2, y: 1)
// Icon
Image(systemName: isVisited ? "checkmark" : "sportscourt")
.font(.system(size: iconSize, weight: .bold))
.foregroundStyle(.white)
}
// Pin pointer
Triangle()
.fill(pinColor)
.frame(width: 10, height: 6)
.offset(y: -2)
// Stadium name (when selected)
if isSelected {
Text(stadium.name)
.font(.caption2)
.fontWeight(.semibold)
.foregroundStyle(colorScheme == .dark ? .white : .primary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background {
Capsule()
.fill(colorScheme == .dark ? Color(.systemGray5) : .white)
.shadow(color: .black.opacity(0.15), radius: 4, y: 2)
}
.fixedSize()
.transition(.scale.combined(with: .opacity))
}
}
}
.buttonStyle(.plain)
.animation(.spring(response: 0.3), value: isSelected)
}
private var pinColor: Color {
if isVisited {
return .green
} else {
return .orange
}
}
private var pinSize: CGFloat {
isSelected ? 36 : 28
}
private var iconSize: CGFloat {
isSelected ? 16 : 12
}
}
// MARK: - Triangle Shape
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
path.closeSubpath()
return path
}
}
// MARK: - Map Region Extension
extension ProgressMapView {
/// Calculate region to fit all stadiums
static func region(for stadiums: [Stadium]) -> MKCoordinateRegion {
guard !stadiums.isEmpty else {
// Default to US center
return MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795),
span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50)
)
}
let latitudes = stadiums.map { $0.latitude }
let longitudes = stadiums.map { $0.longitude }
let minLat = latitudes.min()!
let maxLat = latitudes.max()!
let minLon = longitudes.min()!
let maxLon = longitudes.max()!
let center = CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
)
let span = MKCoordinateSpan(
latitudeDelta: (maxLat - minLat) * 1.3 + 2, // Add padding
longitudeDelta: (maxLon - minLon) * 1.3 + 2
)
return MKCoordinateRegion(center: center, span: span)
}
}
// MARK: - Preview
#Preview {
ProgressMapView(
stadiums: [],
visitStatus: [:],
selectedStadium: .constant(nil)
)
.frame(height: 300)
.padding()
}