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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-10 18:18:10 -06:00
parent 1a835d369a
commit 284a10d9e1

View File

@@ -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<Region> = [.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<Region> = [.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)
}