From e6c4b8e12b4bda057bdd94f1b94124c0a810c755 Mon Sep 17 00:00:00 2001 From: Trey t Date: Thu, 19 Feb 2026 10:45:36 -0600 Subject: [PATCH] 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 --- .../Core/Analytics/AnalyticsEvent.swift | 13 + .../Export/Services/POISearchService.swift | 6 +- .../Trip/Views/AddItem/POIDetailSheet.swift | 180 ++++++++++++ .../Trip/Views/AddItem/PlaceSearchSheet.swift | 207 +++++++++++-- .../Views/AddItem/QuickAddItemSheet.swift | 272 +++++++++++++----- 5 files changed, 583 insertions(+), 95 deletions(-) create mode 100644 SportsTime/Features/Trip/Views/AddItem/POIDetailSheet.swift diff --git a/SportsTime/Core/Analytics/AnalyticsEvent.swift b/SportsTime/Core/Analytics/AnalyticsEvent.swift index 8c5d6fa..617959a 100644 --- a/SportsTime/Core/Analytics/AnalyticsEvent.swift +++ b/SportsTime/Core/Analytics/AnalyticsEvent.swift @@ -75,6 +75,11 @@ enum AnalyticsEvent { case onboardingPaywallViewed case onboardingPaywallDismissed + // MARK: - POI + + case poiAddedToDay(poiName: String, category: String, day: Int) + case poiDetailViewed(poiName: String, category: String) + // MARK: - Errors case errorOccurred(domain: String, message: String, screen: String?) @@ -122,6 +127,8 @@ enum AnalyticsEvent { case .pollShared: return "poll_shared" case .onboardingPaywallViewed: return "onboarding_paywall_viewed" case .onboardingPaywallDismissed: return "onboarding_paywall_dismissed" + case .poiAddedToDay: return "poi_added_to_day" + case .poiDetailViewed: return "poi_detail_viewed" case .errorOccurred: return "error_occurred" } } @@ -249,6 +256,12 @@ enum AnalyticsEvent { case .onboardingPaywallDismissed: return [:] + case .poiAddedToDay(let poiName, let category, let day): + return ["poi_name": poiName, "category": category, "day": day] + + case .poiDetailViewed(let poiName, let category): + return ["poi_name": poiName, "category": category] + case .errorOccurred(let domain, let message, let screen): var props: [String: Any] = ["domain": domain, "message": message] if let screen { props["screen"] = screen } diff --git a/SportsTime/Export/Services/POISearchService.swift b/SportsTime/Export/Services/POISearchService.swift index 2d9fa71..2848eb2 100644 --- a/SportsTime/Export/Services/POISearchService.swift +++ b/SportsTime/Export/Services/POISearchService.swift @@ -13,13 +13,14 @@ actor POISearchService { // MARK: - Types - struct POI: Identifiable, Hashable { + struct POI: Identifiable, Hashable, @unchecked Sendable { let id: UUID let name: String let category: POICategory let coordinate: CLLocationCoordinate2D let distanceMeters: Double let address: String? + let mapItem: MKMapItem? var formattedDistance: String { let miles = distanceMeters * 0.000621371 @@ -218,7 +219,8 @@ actor POISearchService { category: category, coordinate: itemCoordinate, distanceMeters: distance, - address: formatAddress(item) + address: formatAddress(item), + mapItem: item ) } diff --git a/SportsTime/Features/Trip/Views/AddItem/POIDetailSheet.swift b/SportsTime/Features/Trip/Views/AddItem/POIDetailSheet.swift new file mode 100644 index 0000000..44211af --- /dev/null +++ b/SportsTime/Features/Trip/Views/AddItem/POIDetailSheet.swift @@ -0,0 +1,180 @@ +// +// POIDetailSheet.swift +// SportsTime +// +// Detail sheet for a nearby point of interest with map preview and add-to-day action. +// + +import SwiftUI +import MapKit + +struct POIDetailSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + let poi: POISearchService.POI + let day: Int + let onAddToDay: (POISearchService.POI) -> Void + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 0) { + mapPreview + detailContent + } + } + .background(Theme.backgroundGradient(colorScheme)) + .navigationTitle(poi.name) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + dismiss() + } + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + } + .onAppear { + AnalyticsManager.shared.track(.poiDetailViewed( + poiName: poi.name, + category: poi.category.displayName + )) + } + } + .presentationDetents([.medium, .large]) + } + + // MARK: - Map Preview + + private var mapPreview: some View { + Map(initialPosition: .region(MKCoordinateRegion( + center: poi.coordinate, + latitudinalMeters: 800, + longitudinalMeters: 800 + ))) { + Marker(poi.name, coordinate: poi.coordinate) + .tint(Theme.warmOrange) + } + .mapStyle(.standard(pointsOfInterest: .excludingAll)) + .frame(height: 160) + .allowsHitTesting(false) + .accessibilityLabel("Map showing \(poi.name)") + } + + // MARK: - Detail Content + + private var detailContent: some View { + VStack(spacing: Theme.Spacing.lg) { + // Info section + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + // Category badge + HStack(spacing: Theme.Spacing.xs) { + Image(systemName: poi.category.iconName) + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: 24, height: 24) + .background(categoryColor) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + Text(poi.category.displayName) + .font(.subheadline) + .fontWeight(.medium) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + + // Name + Text(poi.name) + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + // Metadata rows + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + if let address = poi.address { + metadataRow(icon: "mappin.circle.fill", text: address) + } + + metadataRow( + icon: "figure.walk", + text: "\(poi.formattedDistance) away", + highlight: true + ) + + if let url = poi.mapItem?.url { + Link(destination: url) { + metadataRow(icon: "globe", text: url.host ?? "Website", isLink: true) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .strokeBorder(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + .shadow(color: Theme.cardShadow(colorScheme), radius: 8, y: 4) + + // Action buttons + VStack(spacing: Theme.Spacing.sm) { + Button { + onAddToDay(poi) + dismiss() + } label: { + Label("Add to Day \(day)", systemImage: "plus.circle.fill") + .font(.body.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, Theme.Spacing.md) + } + .buttonStyle(.borderedProminent) + .tint(Theme.warmOrange) + .accessibilityLabel("Add \(poi.name) to Day \(day)") + + if poi.mapItem != nil { + Button { + poi.mapItem?.openInMaps() + } label: { + Label("Open in Apple Maps", systemImage: "map.fill") + .font(.body.weight(.medium)) + .frame(maxWidth: .infinity) + .padding(.vertical, Theme.Spacing.md) + } + .buttonStyle(.bordered) + .tint(Theme.textSecondary(colorScheme)) + .accessibilityLabel("Open \(poi.name) in Apple Maps") + } + } + } + .padding(Theme.Spacing.lg) + } + + // MARK: - Helpers + + private func metadataRow(icon: String, text: String, highlight: Bool = false, isLink: Bool = false) -> some View { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: icon) + .font(.caption) + .foregroundStyle(highlight ? Theme.warmOrange : Theme.textMuted(colorScheme)) + .frame(width: 20) + .accessibilityHidden(true) + + Text(text) + .font(.subheadline) + .foregroundStyle(isLink ? Theme.warmOrange : Theme.textSecondary(colorScheme)) + .lineLimit(2) + } + } + + private var categoryColor: Color { + switch poi.category { + case .restaurant: return .orange + case .attraction: return .yellow + case .entertainment: return .purple + case .nightlife: return .indigo + case .museum: return .teal + } + } +} diff --git a/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift b/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift index c2860dc..c9f6ba7 100644 --- a/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift +++ b/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift @@ -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? @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")") } } diff --git a/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift b/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift index e9a6f29..0b68b23 100644 --- a/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift +++ b/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift @@ -16,15 +16,20 @@ struct QuickAddItemSheet: View { let tripId: UUID let day: Int let existingItem: ItineraryItem? + var regionCoordinate: CLLocationCoordinate2D? var onSave: (ItineraryItem) -> Void // Form state - @State private var selectedCategory: ItemCategory = .restaurant @State private var title: String = "" @State private var selectedPlace: MKMapItem? @State private var showLocationSearch = false @FocusState private var isTitleFocused: Bool + // POI state + @State private var nearbyPOIs: [POISearchService.POI] = [] + @State private var isLoadingPOIs = false + @State private var selectedPOI: POISearchService.POI? + // Derived state private var isEditing: Bool { existingItem != nil } @@ -32,33 +37,17 @@ struct QuickAddItemSheet: View { !title.trimmingCharacters(in: .whitespaces).isEmpty } - private var placeholderText: String { - switch selectedCategory { - case .restaurant: - return "e.g., Lunch at the stadium" - case .attraction: - return "e.g., Visit the art museum" - case .fuel: - return "e.g., Fill up before leaving" - case .hotel: - return "e.g., Check in at Marriott" - case .other: - return "e.g., Pick up tickets" - } - } + private let placeholderText = "e.g., Lunch at the stadium" var body: some View { NavigationStack { ScrollView { VStack(spacing: Theme.Spacing.lg) { - // Category picker card - categoryCard + combinedInputCard - // Description card - descriptionCard - - // Location card - locationCard + if regionCoordinate != nil && !isEditing { + nearbyPOISection + } } .padding(.horizontal, Theme.Spacing.lg) .padding(.top, Theme.Spacing.md) @@ -86,7 +75,7 @@ struct QuickAddItemSheet: View { } } .sheet(isPresented: $showLocationSearch) { - PlaceSearchSheet(category: selectedCategory) { place in + PlaceSearchSheet(regionCoordinate: regionCoordinate) { place in Theme.Animation.withMotion(Theme.Animation.spring) { selectedPlace = place } @@ -97,6 +86,11 @@ struct QuickAddItemSheet: View { } } } + .sheet(item: $selectedPOI) { poi in + POIDetailSheet(poi: poi, day: day) { selectedPoi in + addPOIToDay(selectedPoi) + } + } .onAppear { loadExistingItem() // Focus text field after a brief delay @@ -104,24 +98,17 @@ struct QuickAddItemSheet: View { isTitleFocused = true } } + .task { + await loadNearbyPOIs() + } } } - // MARK: - Category Card + // MARK: - Combined Input Card - private var categoryCard: some View { - VStack(alignment: .leading, spacing: Theme.Spacing.md) { - sectionHeader(title: "What type?", icon: "square.grid.2x2") - - CategoryPicker(selectedCategory: $selectedCategory) - } - .cardStyle() - } - - // MARK: - Description Card - - private var descriptionCard: some View { + private var combinedInputCard: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { + // Description section sectionHeader(title: "Description", icon: "text.alignleft") VStack(spacing: Theme.Spacing.xs) { @@ -153,20 +140,12 @@ struct QuickAddItemSheet: View { } } } - } - .cardStyle() - } - private var inputBackground: Color { - colorScheme == .dark - ? Color.white.opacity(0.05) - : Color.black.opacity(0.03) - } + // Divider between sections + Divider() + .padding(.vertical, Theme.Spacing.xs) - // MARK: - Location Card - - private var locationCard: some View { - VStack(alignment: .leading, spacing: Theme.Spacing.md) { + // Location section sectionHeader(title: "Location", icon: "mappin.and.ellipse", optional: true) if let place = selectedPlace { @@ -178,6 +157,12 @@ struct QuickAddItemSheet: View { .cardStyle() } + private var inputBackground: Color { + colorScheme == .dark + ? Color.white.opacity(0.05) + : Color.black.opacity(0.03) + } + private var addLocationButton: some View { Button { showLocationSearch = true @@ -193,16 +178,10 @@ struct QuickAddItemSheet: View { .foregroundStyle(Theme.warmOrange) } - VStack(alignment: .leading, spacing: 2) { - Text("Add a location") - .font(.body) - .fontWeight(.medium) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - Text("Search for nearby places") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - } + Text("Add a location") + .font(.body) + .fontWeight(.medium) + .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() @@ -277,6 +256,48 @@ struct QuickAddItemSheet: View { ) } + // MARK: - Nearby POI Section + + private var nearbyPOISection: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + sectionHeader(title: "Nearby Places", icon: "mappin.and.ellipse") + + if isLoadingPOIs { + HStack(spacing: Theme.Spacing.sm) { + ProgressView() + Text("Finding nearby places\u{2026}") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, Theme.Spacing.lg) + } else if nearbyPOIs.isEmpty { + Text("No nearby places found") + .font(.subheadline) + .foregroundStyle(Theme.textMuted(colorScheme)) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, Theme.Spacing.lg) + } else { + VStack(spacing: 0) { + ForEach(Array(nearbyPOIs.enumerated()), id: \.element.id) { index, poi in + Button { + selectedPOI = poi + } label: { + POIRow(poi: poi, colorScheme: colorScheme) + } + .buttonStyle(.plain) + + if index < nearbyPOIs.count - 1 { + Divider() + .padding(.leading, 52) + } + } + } + } + } + .cardStyle() + } + // MARK: - Section Header private func sectionHeader(title: String, icon: String, optional: Bool = false) -> some View { @@ -293,12 +314,16 @@ struct QuickAddItemSheet: View { if optional { Text("optional") - .font(.caption2) - .foregroundStyle(Theme.textMuted(colorScheme)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Theme.surfaceGlow(colorScheme)) + .font(.caption) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Theme.surfaceGlow(colorScheme).opacity(1.5)) .clipShape(Capsule()) + .overlay( + Capsule() + .strokeBorder(Theme.textMuted(colorScheme).opacity(0.3), lineWidth: 0.5) + ) } } } @@ -319,11 +344,6 @@ struct QuickAddItemSheet: View { title = info.title - // Find category by icon - if let category = ItemCategory.allCases.first(where: { $0.icon == info.icon }) { - selectedCategory = category - } - // Restore location if present if let lat = info.latitude, let lon = info.longitude { @@ -348,7 +368,7 @@ struct QuickAddItemSheet: View { let coordinate = place.placemark.coordinate customInfo = CustomInfo( title: trimmedTitle, - icon: selectedCategory.icon, + icon: "\u{1F4CC}", time: nil, latitude: coordinate.latitude, longitude: coordinate.longitude, @@ -358,7 +378,7 @@ struct QuickAddItemSheet: View { // Item without location customInfo = CustomInfo( title: trimmedTitle, - icon: selectedCategory.icon, + icon: "\u{1F4CC}", time: nil ) } @@ -403,6 +423,116 @@ struct QuickAddItemSheet: View { return components.isEmpty ? nil : components.joined(separator: ", ") } + + // MARK: - POI Loading + + private func loadNearbyPOIs() async { + guard let coordinate = regionCoordinate, !isEditing else { return } + + isLoadingPOIs = true + defer { isLoadingPOIs = false } + + do { + let pois = try await POISearchService().findNearbyPOIs( + near: coordinate, + categories: POISearchService.POICategory.allCases, + limitPerCategory: 3 + ) + nearbyPOIs = pois + } catch { + nearbyPOIs = [] + } + } + + private func addPOIToDay(_ poi: POISearchService.POI) { + let customInfo = CustomInfo( + title: poi.name, + icon: "\u{1F4CC}", + time: nil, + latitude: poi.coordinate.latitude, + longitude: poi.coordinate.longitude, + address: poi.address + ) + + let item = ItineraryItem( + tripId: tripId, + day: day, + sortOrder: 0.0, + kind: .custom(customInfo) + ) + + AnalyticsManager.shared.track(.poiAddedToDay( + poiName: poi.name, + category: poi.category.displayName, + day: day + )) + + onSave(item) + dismiss() + } +} + +// MARK: - POI Row + +private struct POIRow: View { + let poi: POISearchService.POI + let colorScheme: ColorScheme + + var body: some View { + HStack(spacing: Theme.Spacing.sm) { + // Category icon + ZStack { + Circle() + .fill(categoryColor.opacity(0.15)) + .frame(width: 40, height: 40) + + Image(systemName: poi.category.iconName) + .font(.body.weight(.semibold)) + .foregroundStyle(categoryColor) + } + + VStack(alignment: .leading, spacing: Theme.Spacing.xxs) { + Text(poi.name) + .font(.body) + .fontWeight(.medium) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .lineLimit(1) + + if let address = poi.address { + Text(address) + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + .lineLimit(1) + } + } + + Spacer() + + Text(poi.formattedDistance) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(Theme.warmOrange) + + Image(systemName: "chevron.right") + .font(.caption2.weight(.semibold)) + .foregroundStyle(Theme.textMuted(colorScheme)) + .accessibilityHidden(true) + } + .padding(.vertical, Theme.Spacing.sm) + .contentShape(Rectangle()) + .accessibilityLabel("\(poi.name), \(poi.category.displayName), \(poi.formattedDistance) away") + .accessibilityHint("Double-tap to view details and add to itinerary") + } + + private var categoryColor: Color { + switch poi.category { + case .restaurant: return .orange + case .attraction: return .yellow + case .entertainment: return .purple + case .nightlife: return .indigo + case .museum: return .teal + } + } } // MARK: - Card Style Modifier @@ -479,7 +609,7 @@ private struct PressableStyle: ButtonStyle { sortOrder: 1.0, kind: .custom(CustomInfo( title: "Dinner at Joe's", - icon: ItemCategory.restaurant.icon, + icon: "\u{1F4CC}", time: nil, latitude: 42.3601, longitude: -71.0589,