diff --git a/SportsTime/Features/Trip/Views/AddItem/CategoryPicker.swift b/SportsTime/Features/Trip/Views/AddItem/CategoryPicker.swift new file mode 100644 index 0000000..87c0055 --- /dev/null +++ b/SportsTime/Features/Trip/Views/AddItem/CategoryPicker.swift @@ -0,0 +1,180 @@ +// +// CategoryPicker.swift +// SportsTime +// +// Polished category picker for custom itinerary items +// + +import SwiftUI + +/// Category picker with labeled pills in a grid layout +struct CategoryPicker: View { + @Binding var selectedCategory: ItemCategory + @Environment(\.colorScheme) private var colorScheme + + /// Dynamic columns that adapt to accessibility text sizes + private var columns: [GridItem] { + [ + GridItem(.flexible(), spacing: Theme.Spacing.sm), + GridItem(.flexible(), spacing: Theme.Spacing.sm), + GridItem(.flexible(), spacing: Theme.Spacing.sm) + ] + } + + var body: some View { + LazyVGrid(columns: columns, spacing: Theme.Spacing.sm) { + ForEach(ItemCategory.allCases, id: \.self) { category in + CategoryPill( + category: category, + isSelected: selectedCategory == category, + colorScheme: colorScheme + ) { + withAnimation(Theme.Animation.spring) { + selectedCategory = category + } + } + } + } + } +} + +/// Individual category pill with emoji icon and label +private struct CategoryPill: View { + let category: ItemCategory + let isSelected: Bool + let colorScheme: ColorScheme + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(spacing: Theme.Spacing.xs) { + // Emoji with background circle + ZStack { + Circle() + .fill(emojiBackground) + .frame(width: 44, height: 44) + + Text(category.icon) + .font(.system(size: 24)) + } + .shadow( + color: isSelected ? Theme.warmOrange.opacity(0.3) : .clear, + radius: 6, + y: 2 + ) + + Text(category.label) + .font(.caption) + .fontWeight(isSelected ? .semibold : .medium) + .foregroundStyle(labelColor) + } + .frame(maxWidth: .infinity) + .padding(.vertical, Theme.Spacing.md) + .padding(.horizontal, Theme.Spacing.xs) + .background(pillBackground) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .overlay( + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .strokeBorder(borderColor, lineWidth: isSelected ? 2 : 1) + ) + .shadow( + color: isSelected ? Theme.warmOrange.opacity(0.15) : .clear, + radius: 8, + y: 4 + ) + } + .buttonStyle(CategoryPillButtonStyle()) + .accessibilityLabel(category.label) + .accessibilityHint("Category for \(category.label.lowercased()) items") + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + } + + private var emojiBackground: Color { + if isSelected { + return Theme.warmOrange.opacity(0.2) + } else { + return colorScheme == .dark + ? Color.white.opacity(0.08) + : Color.black.opacity(0.04) + } + } + + private var pillBackground: Color { + if isSelected { + return Theme.warmOrange.opacity(0.08) + } else { + return colorScheme == .dark + ? Color.white.opacity(0.03) + : Color.black.opacity(0.02) + } + } + + private var borderColor: Color { + if isSelected { + return Theme.warmOrange + } else { + return Theme.surfaceGlow(colorScheme) + } + } + + private var labelColor: Color { + if isSelected { + return Theme.warmOrange + } else { + return Theme.textSecondary(colorScheme) + } + } +} + +/// Custom button style with scale effect on press +private struct CategoryPillButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .scaleEffect(configuration.isPressed ? 0.95 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: configuration.isPressed) + } +} + +// MARK: - Previews + +#Preview("Light Mode") { + struct PreviewWrapper: View { + @State private var selected: ItemCategory = .restaurant + + var body: some View { + VStack(spacing: 20) { + CategoryPicker(selectedCategory: $selected) + .padding() + + Text("Selected: \(selected.label)") + .foregroundStyle(.secondary) + } + .padding() + .background(Color(.systemGroupedBackground)) + } + } + + return PreviewWrapper() + .preferredColorScheme(.light) +} + +#Preview("Dark Mode") { + struct PreviewWrapper: View { + @State private var selected: ItemCategory = .attraction + + var body: some View { + VStack(spacing: 20) { + CategoryPicker(selectedCategory: $selected) + .padding() + + Text("Selected: \(selected.label)") + .foregroundStyle(.secondary) + } + .padding() + .background(Color(.systemGroupedBackground)) + } + } + + return PreviewWrapper() + .preferredColorScheme(.dark) +} diff --git a/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift b/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift new file mode 100644 index 0000000..c09a82c --- /dev/null +++ b/SportsTime/Features/Trip/Views/AddItem/PlaceSearchSheet.swift @@ -0,0 +1,306 @@ +// +// PlaceSearchSheet.swift +// SportsTime +// +// Focused place search sheet for custom itinerary items +// + +import SwiftUI +import MapKit + +/// A sheet for searching and selecting a place via MapKit +struct PlaceSearchSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + /// Category for search hints (optional) + let category: ItemCategory? + + /// Callback when a location is selected + let onSelect: (MKMapItem) -> Void + + @State private var searchQuery = "" + @State private var searchResults: [MKMapItem] = [] + @State private var isSearching = false + @State private var searchError: String? + @FocusState private var isSearchFocused: Bool + + var body: some View { + NavigationStack { + VStack(spacing: 0) { + searchBar + .padding(Theme.Spacing.md) + + if isSearching { + loadingView + } else if let error = searchError { + errorView(error) + } else if searchResults.isEmpty && !searchQuery.isEmpty { + emptyResultsView + } else { + resultsList + } + + Spacer() + } + .navigationTitle("Add Location") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + } + .onAppear { + isSearchFocused = true + } + } + + // MARK: - Search Bar + + private var searchBar: some View { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: "magnifyingglass") + .foregroundStyle(Theme.textMuted(colorScheme)) + + TextField(searchPlaceholder, text: $searchQuery) + .textFieldStyle(.plain) + .focused($isSearchFocused) + .onSubmit { + performSearch() + } + .accessibilityLabel("Search for places") + .accessibilityHint(searchPlaceholder) + + if !searchQuery.isEmpty { + Button { + searchQuery = "" + searchResults = [] + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .accessibilityLabel("Clear search") + } + } + .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..." + } + } + + // MARK: - Results List + + private var resultsList: some View { + ScrollView { + LazyVStack(spacing: Theme.Spacing.xs) { + ForEach(searchResults, id: \.self) { place in + PlaceRow(place: place, colorScheme: colorScheme) { + onSelect(place) + dismiss() + } + } + } + .padding(.horizontal, Theme.Spacing.md) + } + } + + // MARK: - Loading View + + private var loadingView: some View { + VStack(spacing: Theme.Spacing.md) { + Spacer() + ProgressView() + .scaleEffect(1.2) + Text("Searching...") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + Spacer() + } + .frame(maxWidth: .infinity) + } + + // MARK: - Empty Results View + + private var emptyResultsView: some View { + VStack(spacing: Theme.Spacing.md) { + Spacer() + + Image(systemName: "mappin.slash") + .font(.largeTitle) + .foregroundStyle(Theme.textMuted(colorScheme)) + + Text("No places found") + .font(.headline) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text("Try a different search term") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + Button("Add without location") { + dismiss() + } + .buttonStyle(.bordered) + .tint(Theme.warmOrange) + .padding(.top, Theme.Spacing.sm) + .accessibilityHint("Returns to the add item form without a location") + + Spacer() + } + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.lg) + } + + // MARK: - Error View + + private func errorView(_ error: String) -> some View { + VStack(spacing: Theme.Spacing.md) { + Spacer() + + Image(systemName: "exclamationmark.triangle") + .font(.largeTitle) + .foregroundStyle(.orange) + + Text("Search unavailable") + .font(.headline) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(error) + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .multilineTextAlignment(.center) + + HStack(spacing: Theme.Spacing.md) { + Button("Add without location") { + dismiss() + } + .buttonStyle(.bordered) + + Button("Retry") { + performSearch() + } + .buttonStyle(.borderedProminent) + .tint(Theme.warmOrange) + } + .padding(.top, Theme.Spacing.sm) + + Spacer() + } + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.lg) + } + + // MARK: - Search + + private func performSearch() { + let trimmedQuery = searchQuery.trimmingCharacters(in: .whitespaces) + guard !trimmedQuery.isEmpty else { return } + + isSearching = true + searchError = nil + + let request = MKLocalSearch.Request() + request.naturalLanguageQuery = trimmedQuery + + let search = MKLocalSearch(request: request) + search.start { response, error in + isSearching = false + + if let error { + searchError = error.localizedDescription + return + } + + if let response { + searchResults = response.mapItems + } + } + } +} + +// MARK: - Place Row + +private struct PlaceRow: View { + let place: MKMapItem + let colorScheme: ColorScheme + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: "mappin.circle.fill") + .font(.title2) + .foregroundStyle(Theme.warmOrange) + + VStack(alignment: .leading, spacing: Theme.Spacing.xxs) { + Text(place.name ?? "Unknown Place") + .font(.body) + .fontWeight(.medium) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + if let address = formattedAddress { + Text(address) + .font(.caption) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .lineLimit(1) + } + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(Theme.Spacing.sm) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small)) + } + .buttonStyle(.plain) + .accessibilityLabel(place.name ?? "Unknown Place") + .accessibilityHint(formattedAddress ?? "Tap to select this location") + } + + private var formattedAddress: 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: ", ") + } +} + +#Preview { + PlaceSearchSheet(category: .restaurant) { 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 new file mode 100644 index 0000000..a72de79 --- /dev/null +++ b/SportsTime/Features/Trip/Views/AddItem/QuickAddItemSheet.swift @@ -0,0 +1,493 @@ +// +// 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)") + } +} diff --git a/SportsTime/Features/Trip/Views/AddItemSheet.swift b/SportsTime/Features/Trip/Views/AddItemSheet.swift index deb5873..e294dec 100644 --- a/SportsTime/Features/Trip/Views/AddItemSheet.swift +++ b/SportsTime/Features/Trip/Views/AddItemSheet.swift @@ -37,6 +37,9 @@ enum ItemCategory: String, CaseIterable { } } +/// Legacy sheet for adding/editing custom itinerary items. +/// - Note: Use `QuickAddItemSheet` instead for new code. +@available(*, deprecated, message: "Use QuickAddItemSheet instead") struct AddItemSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift index 688519f..54ac873 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift @@ -1059,17 +1059,16 @@ struct DaySectionHeaderView: View { Spacer() - // Add button (right-aligned) + // Add button (right-aligned, bordered capsule for discoverability) Button(action: onAddTapped) { - HStack(spacing: Theme.Spacing.xs) { - Image(systemName: "plus.circle.fill") - .foregroundStyle(Theme.warmOrange.opacity(0.6)) - Text("Add") - .font(.subheadline) - .foregroundStyle(Theme.textMuted(colorScheme)) - } + Label("Add", systemImage: "plus") + .font(.subheadline.weight(.medium)) } - .buttonStyle(.plain) + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .tint(Theme.warmOrange) + .accessibilityLabel("Add item to Day \(dayNumber)") + .accessibilityHint("Add restaurants, activities, or notes to this day") } .padding(.horizontal, Theme.Spacing.lg) .padding(.top, Theme.Spacing.lg) // More space above for section separation diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 0d1e82a..3e29dd5 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -1934,7 +1934,7 @@ private struct SheetModifiers: ViewModifier { PaywallView() } .sheet(item: $addItemAnchor) { anchor in - AddItemSheet( + QuickAddItemSheet( tripId: tripId, day: anchor.day, existingItem: nil @@ -1943,7 +1943,7 @@ private struct SheetModifiers: ViewModifier { } } .sheet(item: $editingItem) { item in - AddItemSheet( + QuickAddItemSheet( tripId: tripId, day: item.day, existingItem: item