From a4e9327b1819845eba69af61d02b1c1a19e4579f Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 20 Feb 2026 22:47:46 -0600 Subject: [PATCH] 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 --- .../Trip/ViewModels/TripWizardViewModel.swift | 42 +++- .../Wizard/Steps/LocationSearchSheet.swift | 194 ++++++++++++++---- .../Trip/Views/Wizard/TripWizardView.swift | 6 + 3 files changed, 202 insertions(+), 40 deletions(-) diff --git a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift index d1f81a8..5029bad 100644 --- a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift +++ b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift @@ -161,8 +161,48 @@ final class TripWizardViewModel { // MARK: - Sport Availability + /// Sports available at the selected start and/or end cities (union of both) + var sportsAtSelectedCities: Set { + let stadiums = AppDataProvider.shared.stadiums + var sports: Set = [] + + 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 { - 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 { diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift index 36d34bd..9e4b27f 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift @@ -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 + + var id: String { "\(name), \(state)" } + var displayName: String { "\(name), \(state)" } + + var sortedSports: [Sport] { + sports.sorted { $0.rawValue < $1.rawValue } + } +} + // MARK: - Preview #Preview { diff --git a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift index fa797bf..cd5214a 100644 --- a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift +++ b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift @@ -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 {