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>
This commit is contained in:
Trey t
2026-02-20 22:47:46 -06:00
parent b062ced000
commit a4e9327b18
3 changed files with 202 additions and 40 deletions

View File

@@ -6,6 +6,7 @@
//
import SwiftUI
import CoreLocation
// MARK: - City Input Type
@@ -32,6 +33,11 @@ struct LocationSearchSheet: View {
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"
@@ -42,6 +48,31 @@ struct LocationSearchSheet: View {
}
}
/// 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) {
@@ -50,9 +81,12 @@ struct LocationSearchSheet: View {
Image(systemName: "magnifyingglass")
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
TextField("Search cities, addresses, places...", text: $searchText)
.textFieldStyle(.plain)
.autocorrectionDisabled()
TextField(
isStadiumCityMode ? "Search stadium cities..." : "Search cities, addresses, places...",
text: $searchText
)
.textFieldStyle(.plain)
.autocorrectionDisabled()
if isSearching {
LoadingSpinner(size: .small)
} else if !searchText.isEmpty {
@@ -72,43 +106,10 @@ struct LocationSearchSheet: View {
.clipShape(RoundedRectangle(cornerRadius: 10))
.padding()
// Results list
if searchResults.isEmpty && !searchText.isEmpty && !isSearching {
ContentUnavailableView(
"No Results",
systemImage: "mappin.slash",
description: Text("Try a different search term")
)
if isStadiumCityMode {
stadiumCityResultsList
} 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)
locationSearchResultsList
}
Spacer()
@@ -126,6 +127,7 @@ struct LocationSearchSheet: View {
}
.presentationDetents([.large])
.onChange(of: searchText) { _, newValue in
guard !isStadiumCityMode else { return } // Stadium cities filter locally, no debounce needed
// Debounce search
searchTask?.cancel()
searchTask = Task {
@@ -136,6 +138,104 @@ struct LocationSearchSheet: View {
}
}
// 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 = []
@@ -152,6 +252,22 @@ struct LocationSearchSheet: View {
}
}
// 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 {

View File

@@ -60,6 +60,12 @@ struct TripWizardView: View {
startLocation: $viewModel.startLocation,
endLocation: $viewModel.endLocation
)
.onChange(of: viewModel.startLocation) { _, _ in
viewModel.validateSportsForLocations()
}
.onChange(of: viewModel.endLocation) { _, _ in
viewModel.validateSportsForLocations()
}
}
if viewModel.showTeamFirstStep {