Files
Sportstime/SportsTime/Features/Trip/Views/RegionMapSelector.swift
Trey t d63d311cab feat: add WCAG AA accessibility app-wide, fix CloudKit container config, remove debug logs
- Add VoiceOver labels, hints, and element grouping across all 60+ views
- Add Reduce Motion support (Theme.Animation.prefersReducedMotion) to all animations
- Replace fixed font sizes with semantic Dynamic Type styles
- Hide decorative elements from VoiceOver with .accessibilityHidden(true)
- Add .minimumHitTarget() modifier ensuring 44pt touch targets
- Add AccessibilityAnnouncer utility for VoiceOver announcements
- Improve color contrast values in Theme.swift for WCAG AA compliance
- Extract CloudKitContainerConfig for explicit container identity
- Remove PostHog debug console log from AnalyticsManager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:27:23 -06:00

443 lines
19 KiB
Swift

//
// RegionMapSelector.swift
// SportsTime
//
// 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 tappable overlay zones: West, Central, East.
///
/// Selection rules:
/// - Can select: East, Central, West, East+Central, Central+West
/// - Cannot select: East+West (must have Central between them)
struct RegionMapSelector: View {
@Binding var selectedRegions: Set<Region>
let onToggle: (Region) -> Void
@Environment(\.colorScheme) private var colorScheme
@Environment(\.isDemoMode) private var isDemoMode
@State private var hasAppliedDemoSelection = false
// 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 region overlays
ZStack {
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))
// 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)
}
}
}
// Invisible button overlays for UI testing accessibility
HStack(spacing: 0) {
Button { onToggle(.west) } label: { Color.clear }
.accessibilityIdentifier("wizard.regions.west")
.accessibilityLabel("West region")
.accessibilityValue(selectedRegions.contains(.west) ? "Selected" : "Not selected")
.accessibilityHint(
selectedRegions.contains(.west)
? "Double-tap to deselect this region"
: "Double-tap to select this region"
)
.accessibilityAddTraits(selectedRegions.contains(.west) ? .isSelected : [])
.frame(maxWidth: .infinity)
Button { onToggle(.central) } label: { Color.clear }
.accessibilityIdentifier("wizard.regions.central")
.accessibilityLabel("Central region")
.accessibilityValue(selectedRegions.contains(.central) ? "Selected" : "Not selected")
.accessibilityHint(
selectedRegions.contains(.central)
? "Double-tap to deselect this region"
: "Double-tap to select this region"
)
.accessibilityAddTraits(selectedRegions.contains(.central) ? .isSelected : [])
.frame(maxWidth: .infinity)
Button { onToggle(.east) } label: { Color.clear }
.accessibilityIdentifier("wizard.regions.east")
.accessibilityLabel("East region")
.accessibilityValue(selectedRegions.contains(.east) ? "Selected" : "Not selected")
.accessibilityHint(
selectedRegions.contains(.east)
? "Double-tap to deselect this region"
: "Double-tap to select this region"
)
.accessibilityAddTraits(selectedRegions.contains(.east) ? .isSelected : [])
.frame(maxWidth: .infinity)
}
}
.frame(height: 160)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay(
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.textMuted(colorScheme).opacity(0.3), lineWidth: 1)
)
// Region legend with colors and example states
regionLegend
// Selection footer
selectionFooter
}
.onAppear {
if isDemoMode && !hasAppliedDemoSelection && selectedRegions.isEmpty {
hasAppliedDemoSelection = true
DispatchQueue.main.asyncAfter(deadline: .now() + DemoConfig.selectionDelay) {
onToggle(DemoConfig.demoRegion)
}
}
}
}
// MARK: - Coordinate to Region
/// 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: - Styling
private func fillColor(for region: Region) -> Color {
let isSelected = selectedRegions.contains(region)
if isSelected {
return regionColor(region).opacity(0.6)
}
return Color.gray.opacity(0.15)
}
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 strokeWidth(for region: Region) -> CGFloat {
selectedRegions.contains(region) ? 3 : 1
}
private func regionColor(_ region: Region) -> Color {
switch region {
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
}
}
// MARK: - Legend
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)
)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 1) {
Text(region.shortName)
.font(.caption2)
.foregroundStyle(isSelected ? regionColor(region) : Theme.textPrimary(colorScheme))
Text(states)
.font(.caption2)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var selectionFooter: some View {
HStack(spacing: Theme.Spacing.md) {
if selectedRegions.isEmpty {
Text("Tap map to select regions")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
} else {
Spacer()
Button {
selectedRegions.removeAll()
} label: {
Text("Clear")
.font(.caption)
.foregroundStyle(Theme.warmOrange)
}
}
}
}
}
// 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]
var body: some View {
VStack(spacing: 20) {
RegionMapSelector(selectedRegions: $selected) { region in
if selected.contains(region) {
selected.remove(region)
} else {
// Adjacency rule: can't select both East and West
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: ", "))")
}
.padding()
}
}
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)
}