Files
Sportstime/SportsTime/Features/Progress/Views/ProgressMapView.swift
Trey t 81095a8170 fix: resolve 4 UI/planning bugs from issue tracker
- Lock all maps to North America (no pan/zoom) in ProgressMapView and TripDetailView
- Sort saved trips by most cities (stops count)
- Filter cross-country trips to top 2 by stops on home screen
- Use LocationSearchSheet for Follow Team home location (consistent with must-stop)
- Initialize DateRangePicker to show selected dates on appear

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 18:46:40 -06:00

195 lines
5.9 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?
// Fixed region for continental US - map is locked to this view
private let usRegion = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795),
span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 60)
)
var body: some View {
// Use initialPosition with empty interactionModes to disable pan/zoom
// while keeping annotations tappable
Map(initialPosition: .region(usRegion), interactionModes: []) {
ForEach(stadiums) { stadium in
Annotation(
stadium.name,
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()
}