Add nearby POIs to Add-to-Day sheet and improve PlaceSearchSheet empty state

- Add mapItem field to POISearchService.POI for Apple Maps integration
- Merge description + location into single combined card in QuickAddItemSheet
- Auto-load nearby POIs when regionCoordinate is available, with detail sheet
- Create POIDetailSheet with map preview, metadata, and one-tap add-to-day
- Add poiAddedToDay/poiDetailViewed analytics events
- Add initial state to PlaceSearchSheet with search suggestions and flow layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-19 10:45:36 -06:00
parent e7420061a5
commit e6c4b8e12b
5 changed files with 583 additions and 95 deletions

View File

@@ -13,8 +13,8 @@ struct PlaceSearchSheet: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
/// Category for search hints (optional)
let category: ItemCategory?
/// 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
@@ -23,6 +23,7 @@ struct PlaceSearchSheet: View {
@State private var searchResults: [MKMapItem] = []
@State private var isSearching = false
@State private var searchError: String?
@State private var debounceTask: Task<Void, Never>?
@FocusState private var isSearchFocused: Bool
var body: some View {
@@ -37,11 +38,11 @@ struct PlaceSearchSheet: View {
errorView(error)
} else if searchResults.isEmpty && !searchQuery.isEmpty {
emptyResultsView
} else if searchResults.isEmpty {
initialStateView
} else {
resultsList
}
Spacer()
}
.navigationTitle("Add Location")
.navigationBarTitleDisplayMode(.inline)
@@ -56,6 +57,9 @@ struct PlaceSearchSheet: View {
.onAppear {
isSearchFocused = true
}
.onDisappear {
debounceTask?.cancel()
}
}
// MARK: - Search Bar
@@ -70,8 +74,22 @@ struct PlaceSearchSheet: View {
.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)
@@ -79,6 +97,8 @@ struct PlaceSearchSheet: View {
Button {
searchQuery = ""
searchResults = []
searchError = nil
debounceTask?.cancel()
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(Theme.textMuted(colorScheme))
@@ -86,29 +106,31 @@ struct PlaceSearchSheet: View {
.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 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..."
}
}
private let searchPlaceholder = "Search for a place..."
// MARK: - Results List
@@ -174,6 +196,93 @@ struct PlaceSearchSheet: View {
.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 {
@@ -226,6 +335,15 @@ struct PlaceSearchSheet: View {
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
@@ -242,6 +360,51 @@ struct PlaceSearchSheet: View {
}
}
// 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 {
@@ -304,7 +467,7 @@ private struct PlaceRow: View {
}
#Preview {
PlaceSearchSheet(category: .restaurant) { place in
PlaceSearchSheet { place in
print("Selected: \(place.name ?? "unknown")")
}
}