Files
Sportstime/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift
Trey t a4e9327b18 fix: restrict By Route wizard to stadium cities and filter sports by selected cities
LocationSearchSheet now shows only stadium cities (with sport badges) when
selecting start/end locations, preventing users from picking cities with no
stadiums. TripWizardViewModel filters available sports to the union of sports
at the selected cities, and clears invalid selections when locations change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:47:46 -06:00

276 lines
9.5 KiB
Swift

//
// LocationSearchSheet.swift
// SportsTime
//
// Extracted from TripCreationView - location search sheet for adding cities/places.
//
import SwiftUI
import CoreLocation
// MARK: - City Input Type
enum CityInputType {
case mustStop
case preferred
case homeLocation
case startLocation
case endLocation
}
// MARK: - Location Search Sheet
struct LocationSearchSheet: View {
let inputType: CityInputType
let onAdd: (LocationInput) -> Void
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@State private var searchText = ""
@State private var searchResults: [LocationSearchResult] = []
@State private var isSearching = false
@State private var searchTask: Task<Void, Never>?
private let locationService = LocationService.shared
/// Whether this search should be restricted to stadium cities
private var isStadiumCityMode: Bool {
inputType == .startLocation || inputType == .endLocation
}
private var navigationTitle: String {
switch inputType {
case .mustStop: return "Add Must-Stop"
case .preferred: return "Add Preferred Location"
case .homeLocation: return "Set Home Location"
case .startLocation: return "Set Start Location"
case .endLocation: return "Set End Location"
}
}
/// Unique cities derived from stadiums, with coordinate and sports
private var stadiumCities: [StadiumCity] {
let stadiums = AppDataProvider.shared.stadiums
let grouped = Dictionary(grouping: stadiums) { "\($0.city), \($0.state)" }
return grouped.map { key, stadiums in
StadiumCity(
name: stadiums.first?.city ?? "",
state: stadiums.first?.state ?? "",
coordinate: stadiums.first?.coordinate,
sports: Set(stadiums.map { $0.sport })
)
}.sorted { $0.name < $1.name }
}
/// Stadium cities filtered by search text
private var filteredStadiumCities: [StadiumCity] {
guard !searchText.isEmpty else { return stadiumCities }
let query = searchText.lowercased()
return stadiumCities.filter {
$0.name.lowercased().contains(query) ||
$0.state.lowercased().contains(query) ||
$0.displayName.lowercased().contains(query)
}
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Search field
HStack {
Image(systemName: "magnifyingglass")
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
TextField(
isStadiumCityMode ? "Search stadium cities..." : "Search cities, addresses, places...",
text: $searchText
)
.textFieldStyle(.plain)
.autocorrectionDisabled()
if isSearching {
LoadingSpinner(size: .small)
} else if !searchText.isEmpty {
Button {
searchText = ""
searchResults = []
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme))
}
.minimumHitTarget()
.accessibilityLabel("Clear search")
}
}
.padding()
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding()
if isStadiumCityMode {
stadiumCityResultsList
} else {
locationSearchResultsList
}
Spacer()
}
.themedBackground()
.navigationTitle(navigationTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
.presentationDetents([.large])
.onChange(of: searchText) { _, newValue in
guard !isStadiumCityMode else { return } // Stadium cities filter locally, no debounce needed
// Debounce search
searchTask?.cancel()
searchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
await performSearch(query: newValue)
}
}
}
// MARK: - Stadium City Results
@ViewBuilder
private var stadiumCityResultsList: some View {
let cities = filteredStadiumCities
if cities.isEmpty && !searchText.isEmpty {
ContentUnavailableView(
"No Stadium Cities",
systemImage: "sportscourt",
description: Text("No stadium cities match your search")
)
} else {
List(cities) { city in
Button {
onAdd(LocationInput(
name: city.displayName,
coordinate: city.coordinate,
address: nil
))
dismiss()
} label: {
HStack {
Image(systemName: "building.2.fill")
.foregroundStyle(Theme.warmOrange)
.font(.title2)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(city.displayName)
.foregroundStyle(Theme.textPrimary(colorScheme))
HStack(spacing: 4) {
ForEach(city.sortedSports, id: \.self) { sport in
Text(sport.rawValue)
.font(.caption2)
.fontWeight(.medium)
.foregroundStyle(sport.themeColor)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(sport.themeColor.opacity(0.15))
.clipShape(Capsule())
}
}
}
Spacer()
Image(systemName: "plus.circle")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
}
}
.buttonStyle(.plain)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
// MARK: - Location Search Results (Apple Maps)
@ViewBuilder
private var locationSearchResultsList: some View {
if searchResults.isEmpty && !searchText.isEmpty && !isSearching {
ContentUnavailableView(
"No Results",
systemImage: "mappin.slash",
description: Text("Try a different search term")
)
} else {
List(searchResults) { result in
Button {
onAdd(result.toLocationInput())
dismiss()
} label: {
HStack {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(.red)
.font(.title2)
.accessibilityHidden(true)
VStack(alignment: .leading) {
Text(result.name)
.foregroundStyle(Theme.textPrimary(colorScheme))
if !result.address.isEmpty {
Text(result.address)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
Spacer()
Image(systemName: "plus.circle")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
}
}
.buttonStyle(.plain)
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
}
}
private func performSearch(query: String) async {
guard !query.isEmpty else {
searchResults = []
return
}
isSearching = true
do {
searchResults = try await locationService.searchLocations(query)
} catch {
searchResults = []
}
isSearching = false
}
}
// MARK: - Stadium City Model
private struct StadiumCity: Identifiable {
let name: String
let state: String
let coordinate: CLLocationCoordinate2D?
let sports: Set<Sport>
var id: String { "\(name), \(state)" }
var displayName: String { "\(name), \(state)" }
var sortedSports: [Sport] {
sports.sorted { $0.rawValue < $1.rawValue }
}
}
// MARK: - Preview
#Preview {
LocationSearchSheet(inputType: .mustStop) { _ in }
}