// // QuickAddItemSheet.swift // SportsTime // // Polished sheet for adding/editing custom itinerary items // import SwiftUI import MapKit /// Streamlined sheet for adding custom items to a trip itinerary struct QuickAddItemSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme let tripId: UUID let day: Int 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 } private var canSave: Bool { !title.trimmingCharacters(in: .whitespaces).isEmpty } private let placeholderText = "e.g., Lunch at the stadium" var body: some View { NavigationStack { ScrollView { VStack(spacing: Theme.Spacing.lg) { combinedInputCard if regionCoordinate != nil && !isEditing { nearbyPOISection } if isEditing, onDelete != nil { deleteButton } } .padding(.horizontal, Theme.Spacing.lg) .padding(.top, Theme.Spacing.md) .padding(.bottom, Theme.Spacing.xxl) } .scrollDismissesKeyboard(.interactively) .background(Theme.backgroundGradient(colorScheme)) .navigationTitle(isEditing ? "Edit Item" : "Add to Day \(day)") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } .foregroundStyle(Theme.textSecondary(colorScheme)) } ToolbarItem(placement: .confirmationAction) { Button(isEditing ? "Save" : "Add") { saveItem() } .fontWeight(.semibold) .foregroundStyle(canSave ? Theme.warmOrange : Theme.textMuted(colorScheme)) .disabled(!canSave) .accessibilityLabel(saveButtonAccessibilityLabel) .accessibilityIdentifier("quickAdd.saveButton") } } .sheet(isPresented: $showLocationSearch) { PlaceSearchSheet(regionCoordinate: regionCoordinate) { place in Theme.Animation.withMotion(Theme.Animation.spring) { selectedPlace = place } // Use place name as title if empty if title.trimmingCharacters(in: .whitespaces).isEmpty, let placeName = place.name { title = placeName } } } .sheet(item: $selectedPOI) { poi in POIDetailSheet(poi: poi, day: day) { selectedPoi in addPOIToDay(selectedPoi) } } .onAppear { loadExistingItem() // Focus text field after a brief delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { isTitleFocused = true } } .task { await loadNearbyPOIs() } } } // MARK: - Combined Input Card 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) { TextField(placeholderText, text: $title, axis: .vertical) .textFieldStyle(.plain) .font(.body) .lineLimit(1...3) .focused($isTitleFocused) .padding(Theme.Spacing.md) .background(inputBackground) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .strokeBorder( isTitleFocused ? Theme.warmOrange : Theme.surfaceGlow(colorScheme), lineWidth: isTitleFocused ? 2 : 1 ) ) .accessibilityLabel("Item description") .accessibilityHint(placeholderText) .accessibilityIdentifier("quickAdd.titleField") // Character hint if !title.isEmpty { HStack { Spacer() Text("\(title.count) characters") .font(.caption2) .foregroundStyle(Theme.textMuted(colorScheme)) } } } // Divider between sections Divider() .padding(.vertical, Theme.Spacing.xs) // Location section sectionHeader(title: "Location", icon: "mappin.and.ellipse", optional: true) if let place = selectedPlace { selectedLocationRow(place) } else { addLocationButton } } .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 } label: { HStack(spacing: Theme.Spacing.sm) { ZStack { Circle() .fill(Theme.warmOrange.opacity(0.15)) .frame(width: 40, height: 40) Image(systemName: "plus") .font(.body.weight(.semibold)) .foregroundStyle(Theme.warmOrange) } Text("Add a location") .font(.body) .fontWeight(.medium) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(Theme.textMuted(colorScheme)) .accessibilityHidden(true) } .padding(Theme.Spacing.md) .background(inputBackground) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .strokeBorder(Theme.surfaceGlow(colorScheme), lineWidth: 1) ) } .buttonStyle(.plain) .pressableStyle() .accessibilityLabel("Add location") .accessibilityHint("Search for nearby places to add to this item") } private func selectedLocationRow(_ place: MKMapItem) -> some View { HStack(spacing: Theme.Spacing.sm) { // Location icon with accent ZStack { Circle() .fill(Theme.warmOrange.opacity(0.15)) .frame(width: 44, height: 44) Image(systemName: "mappin.circle.fill") .font(.title2) .foregroundStyle(Theme.warmOrange) } VStack(alignment: .leading, spacing: Theme.Spacing.xxs) { Text(place.name ?? "Selected Location") .font(.body) .fontWeight(.medium) .foregroundStyle(Theme.textPrimary(colorScheme)) if let address = formatAddress(for: place) { Text(address) .font(.caption) .foregroundStyle(Theme.textSecondary(colorScheme)) .lineLimit(1) } } Spacer() // Remove button Button { Theme.Animation.withMotion(Theme.Animation.spring) { selectedPlace = nil } } label: { Image(systemName: "xmark.circle.fill") .font(.title3) .foregroundStyle(Theme.textMuted(colorScheme)) } .minimumHitTarget() .accessibilityLabel("Remove \(place.name ?? "location")") .accessibilityHint("Double-tap to remove this location from the item") } .padding(Theme.Spacing.md) .background(Theme.warmOrange.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .overlay( RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .strokeBorder(Theme.warmOrange.opacity(0.3), lineWidth: 1) ) } // 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() Text("Finding nearby places\u{2026}") .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, Theme.Spacing.lg) } 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(displayedPOIs.enumerated()), id: \.element.id) { index, poi in Button { selectedPOI = poi } label: { POIRow(poi: poi, colorScheme: colorScheme) } .buttonStyle(.plain) if index < displayedPOIs.count - 1 { Divider() .padding(.leading, 52) } } } } } .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 { HStack(spacing: Theme.Spacing.xs) { Image(systemName: icon) .font(.caption) .foregroundStyle(Theme.warmOrange) .accessibilityHidden(true) Text(title) .font(.subheadline) .fontWeight(.semibold) .foregroundStyle(Theme.textPrimary(colorScheme)) if optional { Text("optional") .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) ) } } } // 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 { let trimmedTitle = title.trimmingCharacters(in: .whitespaces) if trimmedTitle.isEmpty { return isEditing ? "Save, button disabled" : "Add, button disabled" } return isEditing ? "Save \(trimmedTitle)" : "Add \(trimmedTitle) to Day \(day)" } private func loadExistingItem() { guard let existing = existingItem, case .custom(let info) = existing.kind else { return } title = info.title // Restore location if present if let lat = info.latitude, let lon = info.longitude { let placemark = MKPlacemark( coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lon) ) let mapItem = MKMapItem(placemark: placemark) mapItem.name = info.title selectedPlace = mapItem } } private func saveItem() { let trimmedTitle = title.trimmingCharacters(in: .whitespaces) guard !trimmedTitle.isEmpty else { return } let customInfo: CustomInfo let item: ItineraryItem if let place = selectedPlace { // Item with location let coordinate = place.placemark.coordinate customInfo = CustomInfo( title: trimmedTitle, icon: "\u{1F4CC}", time: nil, latitude: coordinate.latitude, longitude: coordinate.longitude, address: formatAddress(for: place) ) } else { // Item without location customInfo = CustomInfo( title: trimmedTitle, icon: "\u{1F4CC}", time: nil ) } if let existing = existingItem { // Update existing item item = ItineraryItem( id: existing.id, tripId: existing.tripId, day: existing.day, sortOrder: existing.sortOrder, kind: .custom(customInfo), modifiedAt: Date() ) } else { // Create new item item = ItineraryItem( tripId: tripId, day: day, sortOrder: 0.0, kind: .custom(customInfo) ) } onSave(item) dismiss() } private func formatAddress(for place: MKMapItem) -> 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: ", ") } // 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 } 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 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, 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: - 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 { 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 .bar: return .indigo case .coffee: return .brown case .hotel: return .blue case .parking: return .green case .attraction: return .yellow case .entertainment: return .purple } } } // MARK: - Card Style Modifier private extension View { func cardStyle() -> some View { modifier(AddItemCardStyle()) } } private struct AddItemCardStyle: ViewModifier { @Environment(\.colorScheme) private var colorScheme func body(content: Content) -> some View { content .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) } } // MARK: - Pressable Button Style private extension View { func pressableStyle() -> some View { buttonStyle(PressableStyle()) } } private struct PressableStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.97 : 1.0) .animation( Theme.Animation.prefersReducedMotion ? nil : .spring(response: 0.3, dampingFraction: 0.7), value: configuration.isPressed ) } } // MARK: - Previews #Preview("New Item - Light") { QuickAddItemSheet( tripId: UUID(), day: 1, existingItem: nil ) { item in print("Saved: \(item)") } .preferredColorScheme(.light) } #Preview("New Item - Dark") { QuickAddItemSheet( tripId: UUID(), day: 3, existingItem: nil ) { item in print("Saved: \(item)") } .preferredColorScheme(.dark) } #Preview("Edit Item") { let existing = ItineraryItem( tripId: UUID(), day: 2, sortOrder: 1.0, kind: .custom(CustomInfo( title: "Dinner at Joe's", icon: "\u{1F4CC}", time: nil, latitude: 42.3601, longitude: -71.0589, address: "123 Main St, Boston, MA" )) ) return QuickAddItemSheet( tripId: existing.tripId, day: existing.day, existingItem: existing ) { item in print("Updated: \(item)") } }