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>
187 lines
5.5 KiB
Swift
187 lines
5.5 KiB
Swift
//
|
|
// 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()
|
|
}
|