// // PlaceSearchSheet.swift // SportsTime // // Focused place search sheet for custom itinerary items // import SwiftUI import MapKit /// A sheet for searching and selecting a place via MapKit struct PlaceSearchSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme /// Category for search hints (optional) let category: ItemCategory? /// Callback when a location is selected let onSelect: (MKMapItem) -> Void @State private var searchQuery = "" @State private var searchResults: [MKMapItem] = [] @State private var isSearching = false @State private var searchError: String? @FocusState private var isSearchFocused: Bool var body: some View { NavigationStack { VStack(spacing: 0) { searchBar .padding(Theme.Spacing.md) if isSearching { loadingView } else if let error = searchError { errorView(error) } else if searchResults.isEmpty && !searchQuery.isEmpty { emptyResultsView } else { resultsList } Spacer() } .navigationTitle("Add Location") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } } .onAppear { isSearchFocused = true } } // MARK: - Search Bar private var searchBar: some View { HStack(spacing: Theme.Spacing.sm) { Image(systemName: "magnifyingglass") .foregroundStyle(Theme.textMuted(colorScheme)) .accessibilityHidden(true) TextField(searchPlaceholder, text: $searchQuery) .textFieldStyle(.plain) .focused($isSearchFocused) .onSubmit { performSearch() } .accessibilityLabel("Search for places") .accessibilityHint(searchPlaceholder) if !searchQuery.isEmpty { Button { searchQuery = "" searchResults = [] } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(Theme.textMuted(colorScheme)) } .minimumHitTarget() .accessibilityLabel("Clear search") } } .padding(Theme.Spacing.sm) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } private var searchPlaceholder: String { guard let category else { return "Search for a place..." } switch category { case .restaurant: return "Search restaurants..." case .attraction: return "Search attractions..." case .fuel: return "Search gas stations..." case .hotel: return "Search hotels..." case .other: return "Search for a place..." } } // MARK: - Results List private var resultsList: some View { ScrollView { LazyVStack(spacing: Theme.Spacing.xs) { ForEach(searchResults, id: \.self) { place in PlaceRow(place: place, colorScheme: colorScheme) { onSelect(place) dismiss() } } } .padding(.horizontal, Theme.Spacing.md) } } // MARK: - Loading View private var loadingView: some View { VStack(spacing: Theme.Spacing.md) { Spacer() ProgressView() .scaleEffect(1.2) Text("Searching...") .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) Spacer() } .frame(maxWidth: .infinity) } // MARK: - Empty Results View private var emptyResultsView: some View { VStack(spacing: Theme.Spacing.md) { Spacer() Image(systemName: "mappin.slash") .font(.largeTitle) .foregroundStyle(Theme.textMuted(colorScheme)) .accessibilityHidden(true) Text("No places found") .font(.headline) .foregroundStyle(Theme.textPrimary(colorScheme)) Text("Try a different search term") .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) Button("Add without location") { dismiss() } .buttonStyle(.bordered) .tint(Theme.warmOrange) .padding(.top, Theme.Spacing.sm) .accessibilityHint("Returns to the add item form without a location") Spacer() } .frame(maxWidth: .infinity) .padding(Theme.Spacing.lg) } // MARK: - Error View private func errorView(_ error: String) -> some View { VStack(spacing: Theme.Spacing.md) { Spacer() Image(systemName: "exclamationmark.triangle") .font(.largeTitle) .foregroundStyle(.orange) .accessibilityHidden(true) Text("Search unavailable") .font(.headline) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(error) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) .multilineTextAlignment(.center) HStack(spacing: Theme.Spacing.md) { Button("Add without location") { dismiss() } .buttonStyle(.bordered) Button("Retry") { performSearch() } .buttonStyle(.borderedProminent) .tint(Theme.warmOrange) } .padding(.top, Theme.Spacing.sm) Spacer() } .frame(maxWidth: .infinity) .padding(Theme.Spacing.lg) } // MARK: - Search private func performSearch() { let trimmedQuery = searchQuery.trimmingCharacters(in: .whitespaces) guard !trimmedQuery.isEmpty else { return } isSearching = true searchError = nil let request = MKLocalSearch.Request() request.naturalLanguageQuery = trimmedQuery let search = MKLocalSearch(request: request) search.start { response, error in isSearching = false if let error { searchError = error.localizedDescription return } if let response { searchResults = response.mapItems } } } } // MARK: - Place Row private struct PlaceRow: View { let place: MKMapItem let colorScheme: ColorScheme let onTap: () -> Void var body: some View { Button(action: onTap) { HStack(spacing: Theme.Spacing.sm) { Image(systemName: "mappin.circle.fill") .font(.title2) .foregroundStyle(Theme.warmOrange) VStack(alignment: .leading, spacing: Theme.Spacing.xxs) { Text(place.name ?? "Unknown Place") .font(.body) .fontWeight(.medium) .foregroundStyle(Theme.textPrimary(colorScheme)) if let address = formattedAddress { Text(address) .font(.caption) .foregroundStyle(Theme.textSecondary(colorScheme)) .lineLimit(1) } } Spacer() Image(systemName: "chevron.right") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) } .padding(Theme.Spacing.sm) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small)) } .buttonStyle(.plain) .accessibilityLabel(place.name ?? "Unknown Place") .accessibilityHint(formattedAddress ?? "Tap to select this location") } private var formattedAddress: String? { let placemark = place.placemark var components: [String] = [] if let thoroughfare = placemark.thoroughfare { components.append(thoroughfare) } if let locality = placemark.locality { components.append(locality) } if let administrativeArea = placemark.administrativeArea { components.append(administrativeArea) } return components.isEmpty ? nil : components.joined(separator: ", ") } } #Preview { PlaceSearchSheet(category: .restaurant) { place in print("Selected: \(place.name ?? "unknown")") } }