// // 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 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 // Derived state private var isEditing: Bool { existingItem != nil } private var canSave: Bool { !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" } } var body: some View { NavigationStack { ScrollView { VStack(spacing: Theme.Spacing.lg) { // Category picker card categoryCard // Description card descriptionCard // Location card locationCard } .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) } } .sheet(isPresented: $showLocationSearch) { PlaceSearchSheet(category: selectedCategory) { place in withAnimation(Theme.Animation.spring) { selectedPlace = place } // Use place name as title if empty if title.trimmingCharacters(in: .whitespaces).isEmpty, let placeName = place.name { title = placeName } } } .onAppear { loadExistingItem() // Focus text field after a brief delay DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { isTitleFocused = true } } } } // MARK: - Category 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 { VStack(alignment: .leading, spacing: Theme.Spacing.md) { 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) // Character hint if !title.isEmpty { HStack { Spacer() Text("\(title.count) characters") .font(.caption2) .foregroundStyle(Theme.textMuted(colorScheme)) } } } } .cardStyle() } private var inputBackground: Color { colorScheme == .dark ? Color.white.opacity(0.05) : Color.black.opacity(0.03) } // MARK: - Location Card private var locationCard: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { sectionHeader(title: "Location", icon: "mappin.and.ellipse", optional: true) if let place = selectedPlace { selectedLocationRow(place) } else { addLocationButton } } .cardStyle() } 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) } 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)) } Spacer() Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(Theme.textMuted(colorScheme)) } .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 { withAnimation(Theme.Animation.spring) { selectedPlace = nil } } label: { Image(systemName: "xmark.circle.fill") .font(.title3) .foregroundStyle(Theme.textMuted(colorScheme)) } .accessibilityLabel("Remove location") } .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) ) .accessibilityElement(children: .combine) .accessibilityLabel("\(place.name ?? "Location"), \(formatAddress(for: place) ?? "")") .accessibilityHint("Double-tap the remove button to clear this location") } // 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) Text(title) .font(.subheadline) .fontWeight(.semibold) .foregroundStyle(Theme.textPrimary(colorScheme)) if optional { Text("optional") .font(.caption2) .foregroundStyle(Theme.textMuted(colorScheme)) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Theme.surfaceGlow(colorScheme)) .clipShape(Capsule()) } } } // 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 // 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 { 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: selectedCategory.icon, time: nil, latitude: coordinate.latitude, longitude: coordinate.longitude, address: formatAddress(for: place) ) } else { // Item without location customInfo = CustomInfo( title: trimmedTitle, icon: selectedCategory.icon, 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: - 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(.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: ItemCategory.restaurant.icon, 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)") } }