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 {