From 284a10d9e1407bdc7617e91446334519cdbb7cf3 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 10 Jan 2026 18:18:10 -0600 Subject: [PATCH] feat(ui): add interactive MapKit region picker Replace abstract colored rectangles with actual North America map using MapKit. Regions now follow state borders with tappable overlays for West/Central/East selection. Co-Authored-By: Claude Opus 4.5 --- .../Trip/Views/RegionMapSelector.swift | 401 +++++++++++++----- 1 file changed, 290 insertions(+), 111 deletions(-) diff --git a/SportsTime/Features/Trip/Views/RegionMapSelector.swift b/SportsTime/Features/Trip/Views/RegionMapSelector.swift index 2f359b9..1fd9c65 100644 --- a/SportsTime/Features/Trip/Views/RegionMapSelector.swift +++ b/SportsTime/Features/Trip/Views/RegionMapSelector.swift @@ -2,13 +2,14 @@ // RegionMapSelector.swift // SportsTime // -// Interactive map for selecting travel regions. +// Interactive map for selecting travel regions using MapKit. // +import MapKit import SwiftUI /// A map-based selector for choosing geographic regions. -/// Shows North America with three selectable zones: West, Central, East. +/// Shows North America with three tappable overlay zones: West, Central, East. /// /// Selection rules: /// - Can select: East, Central, West, East+Central, Central+West @@ -19,155 +20,148 @@ struct RegionMapSelector: View { @Environment(\.colorScheme) private var colorScheme + // Camera position centered on continental US + @State private var cameraPosition: MapCameraPosition = .camera( + MapCamera( + centerCoordinate: CLLocationCoordinate2D(latitude: 39.5, longitude: -98.0), + distance: 8_000_000, + heading: 0, + pitch: 0 + ) + ) + var body: some View { VStack(spacing: Theme.Spacing.sm) { - // Map with regions - GeometryReader { geometry in - ZStack { - // Background map outline - mapBackground + // Map with region overlays + MapReader { proxy in + Map(position: $cameraPosition, interactionModes: []) { + // West region polygon + MapPolygon(coordinates: RegionCoordinates.west) + .foregroundStyle(fillColor(for: .west)) + .stroke(strokeColor(for: .west), lineWidth: strokeWidth(for: .west)) - // Selectable regions - HStack(spacing: 0) { - regionButton(.west) - regionButton(.central) - regionButton(.east) + // Central region polygon + MapPolygon(coordinates: RegionCoordinates.central) + .foregroundStyle(fillColor(for: .central)) + .stroke(strokeColor(for: .central), lineWidth: strokeWidth(for: .central)) + + // East region polygon + MapPolygon(coordinates: RegionCoordinates.east) + .foregroundStyle(fillColor(for: .east)) + .stroke(strokeColor(for: .east), lineWidth: strokeWidth(for: .east)) + } + .mapStyle(.standard(elevation: .flat, pointsOfInterest: .excludingAll)) + .onTapGesture { location in + if let coordinate = proxy.convert(location, from: .local) { + let tappedRegion = regionForCoordinate(coordinate) + onToggle(tappedRegion) } } } - .frame(height: 140) + .frame(height: 160) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) - .stroke(Theme.textMuted(colorScheme).opacity(0.5), lineWidth: 1) + .stroke(Theme.textMuted(colorScheme).opacity(0.3), lineWidth: 1) ) - // Legend - selectionLegend + // Region legend with colors and example states + regionLegend + + // Selection footer + selectionFooter } } - // MARK: - Map Background + // MARK: - Coordinate to Region - private var mapBackground: some View { - ZStack { - // Simple gradient background representing land - LinearGradient( - colors: [ - Color.green.opacity(0.15), - Color.green.opacity(0.1), - Color.green.opacity(0.15) - ], - startPoint: .leading, - endPoint: .trailing - ) - - // Subtle grid lines for visual separation - HStack(spacing: 0) { - Color.clear - .frame(maxWidth: .infinity) - Rectangle() - .fill(Theme.textMuted(colorScheme).opacity(0.3)) - .frame(width: 1) - Color.clear - .frame(maxWidth: .infinity) - Rectangle() - .fill(Theme.textMuted(colorScheme).opacity(0.3)) - .frame(width: 1) - Color.clear - .frame(maxWidth: .infinity) - } + /// Determines which region a coordinate falls into based on state groupings. + /// West: States west of ~-102° (WA, OR, CA, NV, ID, UT, AZ, MT, WY, CO, NM) + /// Central: States between ~-102° and ~-89° (ND, SD, NE, KS, OK, TX, MN, IA, MO, AR, LA, WI, IL) + /// East: States east of ~-89° + private func regionForCoordinate(_ coordinate: CLLocationCoordinate2D) -> Region { + let longitude = coordinate.longitude + if longitude < -102 { + return .west + } else if longitude > -89 { + return .east + } else { + return .central } } - // MARK: - Region Button + // MARK: - Styling - private func regionButton(_ region: Region) -> some View { + private func fillColor(for region: Region) -> Color { let isSelected = selectedRegions.contains(region) - let isDisabled = isRegionDisabled(region) - - return Button { - onToggle(region) - } label: { - VStack(spacing: Theme.Spacing.xs) { - // Region icon - Image(systemName: iconForRegion(region)) - .font(.system(size: 24)) - .foregroundStyle(isSelected ? .white : Theme.textSecondary(colorScheme)) - - // Region name - Text(region.shortName) - .font(.system(size: Theme.FontSize.caption, weight: .semibold)) - .foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme)) - - // Cities hint - Text(citiesForRegion(region)) - .font(.system(size: Theme.FontSize.micro)) - .foregroundStyle(isSelected ? .white.opacity(0.8) : Theme.textMuted(colorScheme)) - .lineLimit(2) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background( - isSelected - ? regionColor(region) - : Color.clear - ) - .opacity(isDisabled ? 0.4 : 1.0) + if isSelected { + return regionColor(region).opacity(0.6) } - .buttonStyle(.plain) - .disabled(isDisabled) + return Color.gray.opacity(0.15) } - // MARK: - Helpers - - private func iconForRegion(_ region: Region) -> String { - switch region { - case .west: return "sun.max.fill" - case .central: return "building.2.fill" - case .east: return "building.columns.fill" - case .crossCountry: return "arrow.left.arrow.right" + private func strokeColor(for region: Region) -> Color { + let isSelected = selectedRegions.contains(region) + if isSelected { + return regionColor(region) } + return Color.white.opacity(0.4) } - private func citiesForRegion(_ region: Region) -> String { - switch region { - case .west: return "LA, SF, Seattle" - case .central: return "Chicago, Houston, Denver" - case .east: return "NYC, Boston, Miami" - case .crossCountry: return "" - } + private func strokeWidth(for region: Region) -> CGFloat { + selectedRegions.contains(region) ? 3 : 1 } private func regionColor(_ region: Region) -> Color { switch region { - case .west: return .orange - case .central: return .blue - case .east: return .green + case .west: return Color(hex: "f97316") // Orange + case .central: return Color(hex: "3b82f6") // Blue + case .east: return Color(hex: "22c55e") // Green case .crossCountry: return .purple } } - /// East and West cannot both be selected (not adjacent) - private func isRegionDisabled(_ region: Region) -> Bool { - // If trying to show East+West as disabled, we handle that in toggle logic instead - // This is for visual indication only - return false - } - // MARK: - Legend - private var selectionLegend: some View { + private var regionLegend: some View { + HStack(spacing: Theme.Spacing.md) { + legendItem(.west, states: "CA, WA, OR, AZ") + legendItem(.central, states: "TX, IL, CO, MN") + legendItem(.east, states: "NY, FL, MA, GA") + } + } + + private func legendItem(_ region: Region, states: String) -> some View { + let isSelected = selectedRegions.contains(region) + return HStack(spacing: 6) { + Circle() + .fill(regionColor(region)) + .frame(width: 10, height: 10) + .overlay( + Circle() + .stroke(Color.white.opacity(0.5), lineWidth: isSelected ? 2 : 0) + ) + + VStack(alignment: .leading, spacing: 1) { + Text(region.shortName) + .font(.system(size: 11, weight: isSelected ? .bold : .medium)) + .foregroundStyle(isSelected ? regionColor(region) : Theme.textPrimary(colorScheme)) + + Text(states) + .font(.system(size: 9)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var selectionFooter: some View { HStack(spacing: Theme.Spacing.md) { if selectedRegions.isEmpty { - Text("Tap regions to select") + Text("Tap map to select regions") .font(.system(size: Theme.FontSize.micro)) .foregroundStyle(Theme.textMuted(colorScheme)) } else { - Text("Selected: \(selectedRegions.map { $0.shortName }.sorted().joined(separator: " + "))") - .font(.system(size: Theme.FontSize.caption, weight: .medium)) - .foregroundStyle(Theme.textPrimary(colorScheme)) - Spacer() Button { @@ -182,6 +176,159 @@ struct RegionMapSelector: View { } } +// MARK: - Region Coordinates + +/// Geographic coordinates tracing actual state borders. +/// West: WA, OR, CA, NV, ID, UT, AZ, MT, WY, CO, NM +/// Central: ND, SD, NE, KS, OK, TX, MN, IA, MO, AR, LA, WI, IL +/// East: All remaining states +private enum RegionCoordinates { + + // MARK: - West Region + // Outer boundary of: WA, OR, CA, NV, ID, UT, AZ, MT, WY, CO, NM + static let west: [CLLocationCoordinate2D] = [ + // Start: Canada/WA border, Pacific + CLLocationCoordinate2D(latitude: 49.0, longitude: -123.3), + // WA coast south + CLLocationCoordinate2D(latitude: 46.3, longitude: -124.0), + // OR coast + CLLocationCoordinate2D(latitude: 42.0, longitude: -124.2), + // CA coast + CLLocationCoordinate2D(latitude: 39.0, longitude: -123.7), + CLLocationCoordinate2D(latitude: 34.5, longitude: -120.5), + CLLocationCoordinate2D(latitude: 32.5, longitude: -117.1), + // CA/Mexico border + CLLocationCoordinate2D(latitude: 32.7, longitude: -114.7), + // AZ/Mexico border + CLLocationCoordinate2D(latitude: 31.3, longitude: -111.1), + // NM/Mexico border to TX border + CLLocationCoordinate2D(latitude: 31.8, longitude: -108.2), + CLLocationCoordinate2D(latitude: 32.0, longitude: -106.6), + // NM/TX border north + CLLocationCoordinate2D(latitude: 32.0, longitude: -103.0), + // NM/OK/TX corner, then CO/OK border + CLLocationCoordinate2D(latitude: 36.5, longitude: -103.0), + // CO/KS border + CLLocationCoordinate2D(latitude: 37.0, longitude: -102.0), + CLLocationCoordinate2D(latitude: 40.0, longitude: -102.0), + // CO/NE border to WY + CLLocationCoordinate2D(latitude: 41.0, longitude: -102.0), + // WY/NE/SD corner + CLLocationCoordinate2D(latitude: 43.0, longitude: -104.0), + // WY/SD border + CLLocationCoordinate2D(latitude: 45.0, longitude: -104.0), + // MT/ND border + CLLocationCoordinate2D(latitude: 49.0, longitude: -104.0), + // Canada border west + CLLocationCoordinate2D(latitude: 49.0, longitude: -117.0), + ] + + // MARK: - Central Region + // Outer boundary of: ND, SD, NE, KS, OK, TX, MN, IA, MO, AR, LA, WI, IL + static let central: [CLLocationCoordinate2D] = [ + // Start: Canada/ND border at MT + CLLocationCoordinate2D(latitude: 49.0, longitude: -104.0), + // MT/ND border south to SD + CLLocationCoordinate2D(latitude: 45.9, longitude: -104.0), + // WY/SD border + CLLocationCoordinate2D(latitude: 45.0, longitude: -104.0), + CLLocationCoordinate2D(latitude: 43.0, longitude: -104.0), + // NE/WY border + CLLocationCoordinate2D(latitude: 41.0, longitude: -104.0), + // CO/NE border + CLLocationCoordinate2D(latitude: 41.0, longitude: -102.0), + // KS/CO border + CLLocationCoordinate2D(latitude: 40.0, longitude: -102.0), + CLLocationCoordinate2D(latitude: 37.0, longitude: -102.0), + // OK/CO/NM corner + CLLocationCoordinate2D(latitude: 36.5, longitude: -103.0), + // TX/NM border + CLLocationCoordinate2D(latitude: 32.0, longitude: -103.0), + CLLocationCoordinate2D(latitude: 32.0, longitude: -106.6), + // TX/Mexico border (Rio Grande) + CLLocationCoordinate2D(latitude: 31.8, longitude: -106.4), + CLLocationCoordinate2D(latitude: 29.5, longitude: -103.4), + CLLocationCoordinate2D(latitude: 26.0, longitude: -97.4), + // TX Gulf coast + CLLocationCoordinate2D(latitude: 26.1, longitude: -97.2), + CLLocationCoordinate2D(latitude: 29.4, longitude: -94.7), + // LA Gulf coast + CLLocationCoordinate2D(latitude: 29.5, longitude: -93.8), + CLLocationCoordinate2D(latitude: 29.1, longitude: -89.0), + // LA/MS border at Gulf + CLLocationCoordinate2D(latitude: 30.2, longitude: -89.1), + // MS/LA/AR corner, up to TN + CLLocationCoordinate2D(latitude: 33.0, longitude: -91.0), + // AR/TN border + CLLocationCoordinate2D(latitude: 35.0, longitude: -90.3), + CLLocationCoordinate2D(latitude: 36.5, longitude: -89.5), + // IL eastern border (up to WI) + CLLocationCoordinate2D(latitude: 37.0, longitude: -89.1), + CLLocationCoordinate2D(latitude: 39.0, longitude: -87.5), + CLLocationCoordinate2D(latitude: 41.7, longitude: -87.5), + CLLocationCoordinate2D(latitude: 42.5, longitude: -87.5), + // WI/MI border at Lake Michigan, north to Lake Superior + CLLocationCoordinate2D(latitude: 45.0, longitude: -87.0), + CLLocationCoordinate2D(latitude: 46.8, longitude: -87.5), + CLLocationCoordinate2D(latitude: 46.5, longitude: -85.0), + // Lake Superior north shore + CLLocationCoordinate2D(latitude: 48.0, longitude: -89.5), + // MN/Canada + CLLocationCoordinate2D(latitude: 49.0, longitude: -89.5), + // Canada border west to ND + CLLocationCoordinate2D(latitude: 49.0, longitude: -97.2), + ] + + // MARK: - East Region + // Outer boundary of: MI, IN, OH, KY, TN, MS, AL, GA, FL, SC, NC, VA, WV, MD, DE, PA, NJ, NY, CT, RI, MA, VT, NH, ME + static let east: [CLLocationCoordinate2D] = [ + // Start at western border (IL/IN, TN/AR, MS/LA line) going south + CLLocationCoordinate2D(latitude: 42.5, longitude: -87.5), // WI/IL/IN corner (Lake Michigan) + CLLocationCoordinate2D(latitude: 41.7, longitude: -87.5), // IL/IN border + CLLocationCoordinate2D(latitude: 39.0, longitude: -87.5), // IN/IL border + CLLocationCoordinate2D(latitude: 37.0, longitude: -89.1), // KY/IL/MO corner + CLLocationCoordinate2D(latitude: 36.5, longitude: -89.5), // TN/AR/MO corner + CLLocationCoordinate2D(latitude: 35.0, longitude: -90.3), // TN/AR/MS corner + CLLocationCoordinate2D(latitude: 33.0, longitude: -91.0), // MS/LA/AR corner + CLLocationCoordinate2D(latitude: 31.0, longitude: -89.7), // MS/LA border south + CLLocationCoordinate2D(latitude: 30.2, longitude: -89.1), // MS Gulf coast + // AL/FL Gulf coast + CLLocationCoordinate2D(latitude: 30.2, longitude: -87.5), // AL coast + CLLocationCoordinate2D(latitude: 30.0, longitude: -85.0), // FL panhandle + // FL peninsula + CLLocationCoordinate2D(latitude: 29.0, longitude: -83.0), // FL Gulf + CLLocationCoordinate2D(latitude: 26.0, longitude: -82.0), // SW FL + CLLocationCoordinate2D(latitude: 25.0, longitude: -80.5), // Miami + // Atlantic coast north + CLLocationCoordinate2D(latitude: 27.0, longitude: -80.0), // FL Atlantic + CLLocationCoordinate2D(latitude: 30.5, longitude: -81.2), // Jacksonville + CLLocationCoordinate2D(latitude: 32.0, longitude: -80.8), // Savannah + CLLocationCoordinate2D(latitude: 34.0, longitude: -78.0), // NC coast + CLLocationCoordinate2D(latitude: 36.5, longitude: -76.0), // VA coast + CLLocationCoordinate2D(latitude: 38.5, longitude: -75.0), // DE coast + CLLocationCoordinate2D(latitude: 40.0, longitude: -74.0), // NJ coast + CLLocationCoordinate2D(latitude: 41.0, longitude: -72.0), // CT coast + CLLocationCoordinate2D(latitude: 42.0, longitude: -70.5), // MA coast + CLLocationCoordinate2D(latitude: 43.5, longitude: -70.0), // ME south + CLLocationCoordinate2D(latitude: 45.0, longitude: -67.0), // ME coast + CLLocationCoordinate2D(latitude: 47.2, longitude: -68.5), // ME north + // Canada border west to Great Lakes + CLLocationCoordinate2D(latitude: 47.0, longitude: -69.5), // ME/Canada + CLLocationCoordinate2D(latitude: 45.0, longitude: -71.5), // VT/Canada + CLLocationCoordinate2D(latitude: 45.0, longitude: -75.0), // NY/Canada + CLLocationCoordinate2D(latitude: 43.5, longitude: -79.0), // Lake Ontario + CLLocationCoordinate2D(latitude: 42.5, longitude: -79.0), // Lake Erie west + CLLocationCoordinate2D(latitude: 41.5, longitude: -83.0), // OH/MI + CLLocationCoordinate2D(latitude: 43.0, longitude: -82.5), // MI east + CLLocationCoordinate2D(latitude: 46.0, longitude: -84.0), // Upper MI + CLLocationCoordinate2D(latitude: 46.5, longitude: -85.0), // Lake Superior + CLLocationCoordinate2D(latitude: 46.8, longitude: -87.5), // Upper MI west + CLLocationCoordinate2D(latitude: 45.0, longitude: -87.0), // WI/MI (Green Bay) + ] +} + +// MARK: - Preview + #Preview { struct PreviewWrapper: View { @State private var selected: Set = [.central] @@ -192,7 +339,7 @@ struct RegionMapSelector: View { if selected.contains(region) { selected.remove(region) } else { - // Adjacency rule + // Adjacency rule: can't select both East and West if region == .east { selected.remove(.west) } else if region == .west { @@ -211,3 +358,35 @@ struct RegionMapSelector: View { return PreviewWrapper() } + +#Preview("Dark Mode") { + struct PreviewWrapper: View { + @State private var selected: Set = [.west, .central] + + var body: some View { + VStack(spacing: 20) { + RegionMapSelector(selectedRegions: $selected) { region in + if selected.contains(region) { + selected.remove(region) + } else { + if region == .east { + selected.remove(.west) + } else if region == .west { + selected.remove(.east) + } + selected.insert(region) + } + } + .padding() + + Text("Selected: \(selected.map { $0.shortName }.joined(separator: ", "))") + .foregroundStyle(.white) + } + .padding() + .background(Color(hex: "1a1a2e")) + } + } + + return PreviewWrapper() + .preferredColorScheme(.dark) +}