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:
Trey t
2026-02-19 16:04:53 -06:00
parent 999b5a1190
commit c976ae5cb3
6 changed files with 337 additions and 87 deletions

View File

@@ -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
}
}
}