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

@@ -161,8 +161,48 @@ final class TripWizardViewModel {
// MARK: - Sport Availability // MARK: - Sport Availability
/// Sports available at the selected start and/or end cities (union of both)
var sportsAtSelectedCities: Set<Sport> {
let stadiums = AppDataProvider.shared.stadiums
var sports: Set<Sport> = []
if let startCity = startLocation?.name {
let normalized = startCity.split(separator: ",").first?
.trimmingCharacters(in: .whitespaces).lowercased() ?? ""
for stadium in stadiums where stadium.city.lowercased() == normalized {
sports.insert(stadium.sport)
}
}
if let endCity = endLocation?.name {
let normalized = endCity.split(separator: ",").first?
.trimmingCharacters(in: .whitespaces).lowercased() ?? ""
for stadium in stadiums where stadium.city.lowercased() == normalized {
sports.insert(stadium.sport)
}
}
return sports
}
func canSelectSport(_ sport: Sport) -> Bool { func canSelectSport(_ sport: Sport) -> Bool {
sportAvailability[sport] ?? true // Default to available if not checked // Existing date-range availability check
let dateAvailable = sportAvailability[sport] ?? true
// For locations mode, also check if sport has stadiums at selected cities
if planningMode == .locations,
startLocation != nil || endLocation != nil {
let cityAvailable = sportsAtSelectedCities.contains(sport)
return dateAvailable && cityAvailable
}
return dateAvailable
}
/// Removes selected sports that are no longer available at the chosen cities
func validateSportsForLocations() {
guard planningMode == .locations else { return }
let available = sportsAtSelectedCities
guard !available.isEmpty else { return }
selectedSports = selectedSports.filter { available.contains($0) }
} }
func fetchSportAvailability() async { func fetchSportAvailability() async {

View File

@@ -6,6 +6,7 @@
// //
import SwiftUI import SwiftUI
import CoreLocation
// MARK: - City Input Type // MARK: - City Input Type
@@ -32,6 +33,11 @@ struct LocationSearchSheet: View {
private let locationService = LocationService.shared 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 { private var navigationTitle: String {
switch inputType { switch inputType {
case .mustStop: return "Add Must-Stop" 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 { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -50,9 +81,12 @@ struct LocationSearchSheet: View {
Image(systemName: "magnifyingglass") Image(systemName: "magnifyingglass")
.foregroundStyle(Theme.textMuted(colorScheme)) .foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true) .accessibilityHidden(true)
TextField("Search cities, addresses, places...", text: $searchText) TextField(
.textFieldStyle(.plain) isStadiumCityMode ? "Search stadium cities..." : "Search cities, addresses, places...",
.autocorrectionDisabled() text: $searchText
)
.textFieldStyle(.plain)
.autocorrectionDisabled()
if isSearching { if isSearching {
LoadingSpinner(size: .small) LoadingSpinner(size: .small)
} else if !searchText.isEmpty { } else if !searchText.isEmpty {
@@ -72,43 +106,10 @@ struct LocationSearchSheet: View {
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
.padding() .padding()
// Results list if isStadiumCityMode {
if searchResults.isEmpty && !searchText.isEmpty && !isSearching { stadiumCityResultsList
ContentUnavailableView(
"No Results",
systemImage: "mappin.slash",
description: Text("Try a different search term")
)
} else { } else {
List(searchResults) { result in locationSearchResultsList
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)
} }
Spacer() Spacer()
@@ -126,6 +127,7 @@ struct LocationSearchSheet: View {
} }
.presentationDetents([.large]) .presentationDetents([.large])
.onChange(of: searchText) { _, newValue in .onChange(of: searchText) { _, newValue in
guard !isStadiumCityMode else { return } // Stadium cities filter locally, no debounce needed
// Debounce search // Debounce search
searchTask?.cancel() searchTask?.cancel()
searchTask = Task { 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 { private func performSearch(query: String) async {
guard !query.isEmpty else { guard !query.isEmpty else {
searchResults = [] 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 // MARK: - Preview
#Preview { #Preview {

View File

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