// // LocationSearchSheet.swift // SportsTime // // Extracted from TripCreationView - location search sheet for adding cities/places. // import SwiftUI import CoreLocation // MARK: - City Input Type enum CityInputType { case mustStop case preferred case homeLocation case startLocation case endLocation } // MARK: - Location Search Sheet struct LocationSearchSheet: View { let inputType: CityInputType let onAdd: (LocationInput) -> Void @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @State private var searchText = "" @State private var searchResults: [LocationSearchResult] = [] @State private var isSearching = false @State private var searchTask: Task? 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" case .preferred: return "Add Preferred Location" case .homeLocation: return "Set Home Location" case .startLocation: return "Set Start Location" case .endLocation: return "Set End Location" } } /// 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) { // Search field HStack { Image(systemName: "magnifyingglass") .foregroundStyle(Theme.textMuted(colorScheme)) .accessibilityHidden(true) TextField( isStadiumCityMode ? "Search stadium cities..." : "Search cities, addresses, places...", text: $searchText ) .textFieldStyle(.plain) .autocorrectionDisabled() if isSearching { LoadingSpinner(size: .small) } else if !searchText.isEmpty { Button { searchText = "" searchResults = [] } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(Theme.textMuted(colorScheme)) } .minimumHitTarget() .accessibilityLabel("Clear search") } } .padding() .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: 10)) .padding() if isStadiumCityMode { stadiumCityResultsList } else { locationSearchResultsList } Spacer() } .themedBackground() .navigationTitle(navigationTitle) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } } .presentationDetents([.large]) .onChange(of: searchText) { _, newValue in guard !isStadiumCityMode else { return } // Stadium cities filter locally, no debounce needed // Debounce search searchTask?.cancel() searchTask = Task { try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } await performSearch(query: newValue) } } } // 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 = [] return } isSearching = true do { searchResults = try await locationService.searchLocations(query) } catch { searchResults = [] } isSearching = false } } // 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 { LocationSearchSheet(inputType: .mustStop) { _ in } }