// // 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 /// Optional coordinate to bias search results toward (e.g., the trip stop for this day) var regionCoordinate: CLLocationCoordinate2D? /// 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? @State private var debounceTask: Task? @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 if searchResults.isEmpty { initialStateView } else { resultsList } } .navigationTitle("Add Location") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } } .onAppear { isSearchFocused = true } .onDisappear { debounceTask?.cancel() } } // 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 { debounceTask?.cancel() performSearch() } .onChange(of: searchQuery) { debounceTask?.cancel() let query = searchQuery guard !query.trimmingCharacters(in: .whitespaces).isEmpty else { searchResults = [] return } debounceTask = Task { try? await Task.sleep(for: .milliseconds(500)) guard !Task.isCancelled else { return } performSearch() } } .accessibilityLabel("Search for places") .accessibilityHint(searchPlaceholder) if !searchQuery.isEmpty { Button { searchQuery = "" searchResults = [] searchError = nil debounceTask?.cancel() } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(Theme.textMuted(colorScheme)) } .minimumHitTarget() .accessibilityLabel("Clear search") } // Explicit search button if !searchQuery.trimmingCharacters(in: .whitespaces).isEmpty { Button { debounceTask?.cancel() performSearch() } label: { Text("Search") .font(.subheadline) .fontWeight(.medium) .foregroundStyle(.white) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Theme.warmOrange) .clipShape(Capsule()) } .accessibilityLabel("Search for \(searchQuery)") } } .padding(Theme.Spacing.sm) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } private let searchPlaceholder = "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: - Initial State View private var initialStateView: some View { ScrollView { VStack(spacing: Theme.Spacing.xl) { // Illustration VStack(spacing: Theme.Spacing.sm) { ZStack { Circle() .fill(Theme.warmOrange.opacity(0.1)) .frame(width: 80, height: 80) Image(systemName: "map.fill") .font(.system(size: 32)) .foregroundStyle(Theme.warmOrange) } .padding(.top, Theme.Spacing.xxl) Text("Find a Place") .font(.title3) .fontWeight(.bold) .foregroundStyle(Theme.textPrimary(colorScheme)) Text("Search for restaurants, attractions, hotels, and more") .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) .multilineTextAlignment(.center) .padding(.horizontal, Theme.Spacing.lg) } // Suggestion chips VStack(alignment: .leading, spacing: Theme.Spacing.sm) { Text("Try searching for") .font(.caption) .fontWeight(.semibold) .foregroundStyle(Theme.textMuted(colorScheme)) .textCase(.uppercase) .padding(.horizontal, Theme.Spacing.xs) FlowLayout(spacing: Theme.Spacing.xs) { ForEach(searchSuggestions, id: \.label) { suggestion in Button { searchQuery = suggestion.query debounceTask?.cancel() performSearch() } label: { Label(suggestion.label, systemImage: suggestion.icon) .font(.subheadline) .fontWeight(.medium) .foregroundStyle(Theme.textPrimary(colorScheme)) .padding(.horizontal, Theme.Spacing.sm) .padding(.vertical, Theme.Spacing.xs) .background(Theme.cardBackground(colorScheme)) .clipShape(Capsule()) .overlay( Capsule() .strokeBorder(Theme.surfaceGlow(colorScheme), lineWidth: 1) ) } .buttonStyle(.plain) } } } .padding(.horizontal, Theme.Spacing.md) } } } private struct SearchSuggestion { let label: String let query: String let icon: String } private var searchSuggestions: [SearchSuggestion] { [ SearchSuggestion(label: "Restaurants", query: "restaurants", icon: "fork.knife"), SearchSuggestion(label: "Hotels", query: "hotels", icon: "bed.double.fill"), SearchSuggestion(label: "Coffee", query: "coffee shops", icon: "cup.and.saucer.fill"), SearchSuggestion(label: "Parking", query: "parking", icon: "car.fill"), SearchSuggestion(label: "Gas Stations", query: "gas stations", icon: "fuelpump.fill"), SearchSuggestion(label: "Attractions", query: "tourist attractions", icon: "star.fill"), SearchSuggestion(label: "Bars", query: "bars", icon: "wineglass.fill"), SearchSuggestion(label: "Parks", query: "parks", icon: "leaf.fill"), ] } // 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 // Bias results toward the trip stop's city if available if let coordinate = regionCoordinate { request.region = MKCoordinateRegion( center: coordinate, latitudinalMeters: 50_000, longitudinalMeters: 50_000 ) } 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: - Flow Layout private struct FlowLayout: Layout { var spacing: CGFloat = 8 func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { let result = arrangeSubviews(proposal: proposal, subviews: subviews) return result.size } func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { let result = arrangeSubviews(proposal: proposal, subviews: subviews) for (index, position) in result.positions.enumerated() { subviews[index].place( at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified ) } } private func arrangeSubviews(proposal: ProposedViewSize, subviews: Subviews) -> (positions: [CGPoint], size: CGSize) { let maxWidth = proposal.width ?? .infinity var positions: [CGPoint] = [] var currentX: CGFloat = 0 var currentY: CGFloat = 0 var lineHeight: CGFloat = 0 for subview in subviews { let size = subview.sizeThatFits(.unspecified) if currentX + size.width > maxWidth && currentX > 0 { currentX = 0 currentY += lineHeight + spacing lineHeight = 0 } positions.append(CGPoint(x: currentX, y: currentY)) lineHeight = max(lineHeight, size.height) currentX += size.width + spacing } return (positions, CGSize(width: maxWidth, height: currentY + lineHeight)) } } // 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 { place in #if DEBUG print("Selected: \(place.name ?? "unknown")") #endif } }