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>
276 lines
9.5 KiB
Swift
276 lines
9.5 KiB
Swift
//
|
|
// 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<Void, Never>?
|
|
|
|
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<Sport>
|
|
|
|
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 }
|
|
}
|