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:
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user