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:
186
SportsTime/Features/Progress/Views/ProgressMapView.swift
Normal file
186
SportsTime/Features/Progress/Views/ProgressMapView.swift
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user