Add POI category filters, delete item button, and fix itinerary persistence
- Expand POI categories from 5 to 7 (restaurant, bar, coffee, hotel, parking, attraction, entertainment) - Add category filter chips with per-category API calls and caching - Add delete button with confirmation dialog to Edit Item sheet - Fix itinerary items not persisting: use LocalItineraryItem (SwiftData) as primary store with CloudKit sync as secondary, register model in schema Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -18,17 +18,21 @@ struct QuickAddItemSheet: View {
|
||||
let existingItem: ItineraryItem?
|
||||
var regionCoordinate: CLLocationCoordinate2D?
|
||||
var onSave: (ItineraryItem) -> Void
|
||||
var onDelete: ((ItineraryItem) -> Void)?
|
||||
|
||||
// Form state
|
||||
@State private var title: String = ""
|
||||
@State private var selectedPlace: MKMapItem?
|
||||
@State private var showLocationSearch = false
|
||||
@State private var showDeleteConfirmation = false
|
||||
@FocusState private var isTitleFocused: Bool
|
||||
|
||||
// POI state
|
||||
@State private var nearbyPOIs: [POISearchService.POI] = []
|
||||
@State private var categoryCache: [POISearchService.POICategory: [POISearchService.POI]] = [:]
|
||||
@State private var isLoadingPOIs = false
|
||||
@State private var selectedPOI: POISearchService.POI?
|
||||
@State private var selectedCategory: POISearchService.POICategory?
|
||||
|
||||
// Derived state
|
||||
private var isEditing: Bool { existingItem != nil }
|
||||
@@ -48,6 +52,10 @@ struct QuickAddItemSheet: View {
|
||||
if regionCoordinate != nil && !isEditing {
|
||||
nearbyPOISection
|
||||
}
|
||||
|
||||
if isEditing, onDelete != nil {
|
||||
deleteButton
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.lg)
|
||||
.padding(.top, Theme.Spacing.md)
|
||||
@@ -258,10 +266,42 @@ struct QuickAddItemSheet: View {
|
||||
|
||||
// MARK: - Nearby POI Section
|
||||
|
||||
private var displayedPOIs: [POISearchService.POI] {
|
||||
guard let category = selectedCategory else { return nearbyPOIs }
|
||||
return categoryCache[category] ?? []
|
||||
}
|
||||
|
||||
private var nearbyPOISection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
sectionHeader(title: "Nearby Places", icon: "mappin.and.ellipse")
|
||||
|
||||
// Category filter chips
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
CategoryChip(
|
||||
label: "All",
|
||||
icon: "map.fill",
|
||||
isSelected: selectedCategory == nil,
|
||||
color: Theme.warmOrange,
|
||||
colorScheme: colorScheme
|
||||
) {
|
||||
selectedCategory = nil
|
||||
}
|
||||
|
||||
ForEach(POISearchService.POICategory.allCases, id: \.self) { category in
|
||||
CategoryChip(
|
||||
label: category.displayName,
|
||||
icon: category.iconName,
|
||||
isSelected: selectedCategory == category,
|
||||
color: categoryColor(for: category),
|
||||
colorScheme: colorScheme
|
||||
) {
|
||||
selectCategory(category)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isLoadingPOIs {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ProgressView()
|
||||
@@ -271,15 +311,15 @@ struct QuickAddItemSheet: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, Theme.Spacing.lg)
|
||||
} else if nearbyPOIs.isEmpty {
|
||||
Text("No nearby places found")
|
||||
} else if displayedPOIs.isEmpty {
|
||||
Text(selectedCategory != nil ? "No \(selectedCategory!.displayName.lowercased())s found nearby" : "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
|
||||
ForEach(Array(displayedPOIs.enumerated()), id: \.element.id) { index, poi in
|
||||
Button {
|
||||
selectedPOI = poi
|
||||
} label: {
|
||||
@@ -287,7 +327,7 @@ struct QuickAddItemSheet: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if index < nearbyPOIs.count - 1 {
|
||||
if index < displayedPOIs.count - 1 {
|
||||
Divider()
|
||||
.padding(.leading, 52)
|
||||
}
|
||||
@@ -298,6 +338,18 @@ struct QuickAddItemSheet: View {
|
||||
.cardStyle()
|
||||
}
|
||||
|
||||
private func categoryColor(for category: POISearchService.POICategory) -> Color {
|
||||
switch category {
|
||||
case .restaurant: return .orange
|
||||
case .bar: return .indigo
|
||||
case .coffee: return .brown
|
||||
case .hotel: return .blue
|
||||
case .parking: return .green
|
||||
case .attraction: return .yellow
|
||||
case .entertainment: return .purple
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section Header
|
||||
|
||||
private func sectionHeader(title: String, icon: String, optional: Bool = false) -> some View {
|
||||
@@ -328,6 +380,40 @@ struct QuickAddItemSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete Button
|
||||
|
||||
private var deleteButton: some View {
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirmation = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
Text("Delete Item")
|
||||
}
|
||||
.font(.body)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.red)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Theme.Spacing.md)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete this item?",
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete", role: .destructive) {
|
||||
if let item = existingItem {
|
||||
onDelete?(item)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
} message: {
|
||||
Text("This will remove the item from your itinerary.")
|
||||
}
|
||||
.accessibilityLabel("Delete item")
|
||||
.accessibilityHint("Removes this item from the itinerary")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private var saveButtonAccessibilityLabel: String {
|
||||
@@ -426,6 +512,20 @@ struct QuickAddItemSheet: View {
|
||||
|
||||
// MARK: - POI Loading
|
||||
|
||||
private func selectCategory(_ category: POISearchService.POICategory) {
|
||||
if selectedCategory == category {
|
||||
selectedCategory = nil
|
||||
return
|
||||
}
|
||||
selectedCategory = category
|
||||
|
||||
// If we already have cached results, no need to fetch
|
||||
if categoryCache[category] != nil { return }
|
||||
|
||||
// Fetch results for this category
|
||||
Task { await loadCategoryPOIs(category) }
|
||||
}
|
||||
|
||||
private func loadNearbyPOIs() async {
|
||||
guard let coordinate = regionCoordinate, !isEditing else { return }
|
||||
|
||||
@@ -444,6 +544,24 @@ struct QuickAddItemSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func loadCategoryPOIs(_ category: POISearchService.POICategory) async {
|
||||
guard let coordinate = regionCoordinate else { return }
|
||||
|
||||
isLoadingPOIs = true
|
||||
defer { isLoadingPOIs = false }
|
||||
|
||||
do {
|
||||
let pois = try await POISearchService().findNearbyPOIs(
|
||||
near: coordinate,
|
||||
categories: [category],
|
||||
limitPerCategory: 10
|
||||
)
|
||||
categoryCache[category] = pois
|
||||
} catch {
|
||||
categoryCache[category] = []
|
||||
}
|
||||
}
|
||||
|
||||
private func addPOIToDay(_ poi: POISearchService.POI) {
|
||||
let customInfo = CustomInfo(
|
||||
title: poi.name,
|
||||
@@ -472,6 +590,38 @@ struct QuickAddItemSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Chip
|
||||
|
||||
private struct CategoryChip: View {
|
||||
let label: String
|
||||
let icon: String
|
||||
let isSelected: Bool
|
||||
let color: Color
|
||||
let colorScheme: ColorScheme
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption2)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme))
|
||||
.background(isSelected ? color : color.opacity(0.12))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(label)
|
||||
.accessibilityValue(isSelected ? "Selected" : "Not selected")
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - POI Row
|
||||
|
||||
private struct POIRow: View {
|
||||
@@ -527,10 +677,12 @@ private struct POIRow: View {
|
||||
private var categoryColor: Color {
|
||||
switch poi.category {
|
||||
case .restaurant: return .orange
|
||||
case .bar: return .indigo
|
||||
case .coffee: return .brown
|
||||
case .hotel: return .blue
|
||||
case .parking: return .green
|
||||
case .attraction: return .yellow
|
||||
case .entertainment: return .purple
|
||||
case .nightlife: return .indigo
|
||||
case .museum: return .teal
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user