// // TripCreationView.swift // SportsTime // import SwiftUI struct TripCreationView: View { @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme @Bindable var viewModel: TripCreationViewModel let initialSport: Sport? init(viewModel: TripCreationViewModel, initialSport: Sport? = nil) { self.viewModel = viewModel self.initialSport = initialSport } @State private var showGamePicker = false @State private var showCityInput = false @State private var cityInputType: CityInputType = .mustStop @State private var showLocationBanner = true @State private var showTripDetail = false @State private var showTripOptions = false @State private var completedTrip: Trip? @State private var tripOptions: [ItineraryOption] = [] // Location search state @State private var startLocationSuggestions: [LocationSearchResult] = [] @State private var endLocationSuggestions: [LocationSearchResult] = [] @State private var startSearchTask: Task? @State private var endSearchTask: Task? @State private var isSearchingStart = false @State private var isSearchingEnd = false private let locationService = LocationService.shared enum CityInputType { case mustStop case preferred } var body: some View { NavigationStack { ScrollView { VStack(spacing: Theme.Spacing.lg) { // Hero header heroHeader // Planning Mode Selector planningModeSection // Location Permission Banner (only for locations mode) if viewModel.planningMode == .locations && showLocationBanner { LocationPermissionBanner(isPresented: $showLocationBanner) } // Mode-specific sections switch viewModel.planningMode { case .dateRange: // Sports + Dates sportsSection datesSection case .gameFirst: // Sports + Game Picker sportsSection gameBrowserSection tripBufferSection case .locations: // Locations + Sports + optional games locationSection sportsSection datesSection gamesSection } // Common sections travelSection constraintsSection optionalSection // Validation message if let message = viewModel.formValidationMessage { validationBanner(message: message) } // Plan button planButton } .padding(Theme.Spacing.md) } .themedBackground() .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } .overlay { if case .planning = viewModel.viewState { planningOverlay } } .sheet(isPresented: $showGamePicker) { GamePickerSheet( games: viewModel.availableGames, selectedIds: $viewModel.mustSeeGameIds ) } .sheet(isPresented: $showCityInput) { LocationSearchSheet(inputType: cityInputType) { location in switch cityInputType { case .mustStop: viewModel.addMustStopLocation(location) case .preferred: viewModel.addPreferredCity(location.name) } } } .alert("Error", isPresented: Binding( get: { viewModel.viewState.isError }, set: { if !$0 { viewModel.viewState = .editing } } )) { Button("OK") { viewModel.viewState = .editing } } message: { if case .error(let message) = viewModel.viewState { Text(message) } } .navigationDestination(isPresented: $showTripOptions) { TripOptionsView( options: tripOptions, games: buildGamesDictionary(), preferences: viewModel.currentPreferences, convertToTrip: { option in viewModel.convertOptionToTrip(option) } ) } .navigationDestination(isPresented: $showTripDetail) { if let trip = completedTrip { TripDetailView(trip: trip, games: buildGamesDictionary()) } } .onChange(of: viewModel.viewState) { _, newState in switch newState { case .selectingOption(let options): tripOptions = options showTripOptions = true case .completed(let trip): completedTrip = trip showTripDetail = true default: break } } .onChange(of: showTripOptions) { _, isShowing in if !isShowing { // User navigated back from options to editing viewModel.viewState = .editing tripOptions = [] } } .onChange(of: showTripDetail) { _, isShowing in if !isShowing { // User navigated back from single-option detail to editing completedTrip = nil viewModel.viewState = .editing } } .task { await viewModel.loadScheduleData() } .onAppear { if let sport = initialSport { viewModel.selectedSports = [sport] } } } } // MARK: - Hero Header private var heroHeader: some View { VStack(spacing: Theme.Spacing.sm) { Image(systemName: "map.fill") .font(.system(size: 40)) .foregroundStyle(Theme.warmOrange) Text("Plan Your Adventure") .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) .foregroundStyle(Theme.textPrimary(colorScheme)) Text("Select your games, set your route, and hit the road") .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textSecondary(colorScheme)) .multilineTextAlignment(.center) } .padding(.vertical, Theme.Spacing.md) } // MARK: - Sections private var planningModeSection: some View { ThemedSection(title: "How do you want to plan?") { Picker("Planning Mode", selection: $viewModel.planningMode) { ForEach(PlanningMode.allCases) { mode in Text(mode.displayName).tag(mode) } } .pickerStyle(.segmented) Text(viewModel.planningMode.description) .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textSecondary(colorScheme)) .padding(.top, Theme.Spacing.xs) } } private var locationSection: some View { ThemedSection(title: "Locations") { // Start Location with suggestions VStack(alignment: .leading, spacing: 0) { ThemedTextField( label: "Start Location", placeholder: "Where are you starting from?", text: $viewModel.startLocationText, icon: "location.circle.fill" ) .onChange(of: viewModel.startLocationText) { _, newValue in searchLocation(query: newValue, isStart: true) } // Suggestions for start location if !startLocationSuggestions.isEmpty { locationSuggestionsList( suggestions: startLocationSuggestions, isLoading: isSearchingStart ) { result in viewModel.startLocationText = result.name viewModel.startLocation = result.toLocationInput() startLocationSuggestions = [] } } else if isSearchingStart { HStack { ThemedSpinnerCompact(size: 14) Text("Searching...") .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textMuted(colorScheme)) } .padding(.top, Theme.Spacing.xs) } } // End Location with suggestions VStack(alignment: .leading, spacing: 0) { ThemedTextField( label: "End Location", placeholder: "Where do you want to end up?", text: $viewModel.endLocationText, icon: "mappin.circle.fill" ) .onChange(of: viewModel.endLocationText) { _, newValue in searchLocation(query: newValue, isStart: false) } // Suggestions for end location if !endLocationSuggestions.isEmpty { locationSuggestionsList( suggestions: endLocationSuggestions, isLoading: isSearchingEnd ) { result in viewModel.endLocationText = result.name viewModel.endLocation = result.toLocationInput() endLocationSuggestions = [] } } else if isSearchingEnd { HStack { ThemedSpinnerCompact(size: 14) Text("Searching...") .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textMuted(colorScheme)) } .padding(.top, Theme.Spacing.xs) } } } } private func searchLocation(query: String, isStart: Bool) { // Cancel previous search if isStart { startSearchTask?.cancel() } else { endSearchTask?.cancel() } guard query.count >= 2 else { if isStart { startLocationSuggestions = [] isSearchingStart = false } else { endLocationSuggestions = [] isSearchingEnd = false } return } let task = Task { // Debounce try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } if isStart { isSearchingStart = true } else { isSearchingEnd = true } do { let results = try await locationService.searchLocations(query) guard !Task.isCancelled else { return } if isStart { startLocationSuggestions = Array(results.prefix(5)) isSearchingStart = false } else { endLocationSuggestions = Array(results.prefix(5)) isSearchingEnd = false } } catch { if isStart { startLocationSuggestions = [] isSearchingStart = false } else { endLocationSuggestions = [] isSearchingEnd = false } } } if isStart { startSearchTask = task } else { endSearchTask = task } } @ViewBuilder private func locationSuggestionsList( suggestions: [LocationSearchResult], isLoading: Bool, onSelect: @escaping (LocationSearchResult) -> Void ) -> some View { VStack(alignment: .leading, spacing: 0) { ForEach(suggestions) { result in Button { onSelect(result) } label: { HStack(spacing: Theme.Spacing.sm) { Image(systemName: "mappin.circle.fill") .foregroundStyle(Theme.warmOrange) .font(.system(size: 14)) VStack(alignment: .leading, spacing: 2) { Text(result.name) .font(.system(size: Theme.FontSize.caption, weight: .medium)) .foregroundStyle(Theme.textPrimary(colorScheme)) if !result.address.isEmpty { Text(result.address) .font(.system(size: Theme.FontSize.micro)) .foregroundStyle(Theme.textMuted(colorScheme)) } } Spacer() } .padding(.vertical, Theme.Spacing.sm) .padding(.horizontal, Theme.Spacing.xs) } .buttonStyle(.plain) if result.id != suggestions.last?.id { Divider() .overlay(Theme.surfaceGlow(colorScheme)) } } } .padding(.top, Theme.Spacing.xs) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small)) } private var gameBrowserSection: some View { ThemedSection(title: "Select Games") { if viewModel.isLoadingGames || viewModel.availableGames.isEmpty { HStack(spacing: Theme.Spacing.sm) { ThemedSpinnerCompact(size: 20) Text("Loading games...") .font(.system(size: Theme.FontSize.body)) .foregroundStyle(Theme.textSecondary(colorScheme)) } .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, Theme.Spacing.md) .task(id: viewModel.selectedSports) { // Re-run when sports selection changes if viewModel.availableGames.isEmpty { await viewModel.loadGamesForBrowsing() } } } else { Button { showGamePicker = true } label: { HStack(spacing: Theme.Spacing.md) { ZStack { Circle() .fill(Theme.warmOrange.opacity(0.15)) .frame(width: 44, height: 44) Image(systemName: "sportscourt.fill") .foregroundStyle(Theme.warmOrange) } VStack(alignment: .leading, spacing: 4) { Text("Browse Teams & Games") .font(.system(size: Theme.FontSize.body, weight: .semibold)) .foregroundStyle(Theme.textPrimary(colorScheme)) Text("\(viewModel.availableGames.count) games available") .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textSecondary(colorScheme)) } Spacer() Image(systemName: "chevron.right") .foregroundStyle(Theme.textMuted(colorScheme)) } .padding(Theme.Spacing.md) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } .buttonStyle(.plain) } // Show selected games summary if !viewModel.mustSeeGameIds.isEmpty { VStack(alignment: .leading, spacing: Theme.Spacing.sm) { HStack { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) Text("\(viewModel.mustSeeGameIds.count) game(s) selected") .font(.system(size: Theme.FontSize.body, weight: .medium)) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Button { viewModel.deselectAllGames() } label: { Text("Deselect All") .font(.system(size: Theme.FontSize.caption, weight: .medium)) .foregroundStyle(.red) } } // Show selected games preview ForEach(viewModel.selectedGames.prefix(3)) { game in HStack(spacing: Theme.Spacing.sm) { SportColorBar(sport: game.game.sport) Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)") .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Text(game.game.formattedDate) .font(.system(size: Theme.FontSize.micro)) .foregroundStyle(Theme.textMuted(colorScheme)) } } if viewModel.selectedGames.count > 3 { Text("+ \(viewModel.selectedGames.count - 3) more") .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textSecondary(colorScheme)) } } .padding(Theme.Spacing.md) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } } } private var tripBufferSection: some View { ThemedSection(title: "Trip Duration") { ThemedStepper( label: "Buffer Days", value: viewModel.tripBufferDays, range: 0...7, onIncrement: { viewModel.tripBufferDays += 1 }, onDecrement: { viewModel.tripBufferDays -= 1 } ) if let dateRange = viewModel.gameFirstDateRange { HStack { Text("Trip window:") .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textSecondary(colorScheme)) Spacer() Text("\(dateRange.start.formatted(date: .abbreviated, time: .omitted)) - \(dateRange.end.formatted(date: .abbreviated, time: .omitted))") .font(.system(size: Theme.FontSize.caption, weight: .medium)) .foregroundStyle(Theme.warmOrange) } } Text("Days before first game and after last game for travel/rest") .font(.system(size: Theme.FontSize.micro)) .foregroundStyle(Theme.textMuted(colorScheme)) } } private var sportsSection: some View { ThemedSection(title: "Sports") { HStack(spacing: Theme.Spacing.sm) { ForEach(Sport.supported) { sport in SportSelectionChip( sport: sport, isSelected: viewModel.selectedSports.contains(sport), onTap: { if viewModel.selectedSports.contains(sport) { viewModel.selectedSports.remove(sport) } else { viewModel.selectedSports.insert(sport) } } ) } } } } private var datesSection: some View { ThemedSection(title: "Dates") { DateRangePicker( startDate: $viewModel.startDate, endDate: $viewModel.endDate ) } } private var gamesSection: some View { ThemedSection(title: "Must-See Games") { Button { showGamePicker = true } label: { HStack(spacing: Theme.Spacing.md) { ZStack { Circle() .fill(Theme.warmOrange.opacity(0.15)) .frame(width: 40, height: 40) Image(systemName: "star.fill") .foregroundStyle(Theme.warmOrange) } Text("Select Games") .font(.system(size: Theme.FontSize.body, weight: .medium)) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Text("\(viewModel.selectedGamesCount) selected") .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textSecondary(colorScheme)) Image(systemName: "chevron.right") .foregroundStyle(Theme.textMuted(colorScheme)) } .padding(Theme.Spacing.md) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } .buttonStyle(.plain) } } private var travelSection: some View { ThemedSection(title: "Travel") { VStack(spacing: Theme.Spacing.md) { // Route preference VStack(alignment: .leading, spacing: Theme.Spacing.xs) { Text("Route Preference") .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textSecondary(colorScheme)) Picker("Route Preference", selection: $viewModel.routePreference) { ForEach(RoutePreference.allCases) { pref in Text(pref.displayName).tag(pref) } } .pickerStyle(.segmented) } } } } private var constraintsSection: some View { ThemedSection(title: "Trip Style") { VStack(spacing: Theme.Spacing.md) { ThemedToggle( label: "Limit Cities", isOn: $viewModel.useStopCount, icon: "mappin.and.ellipse" ) if viewModel.useStopCount { VStack(alignment: .leading, spacing: Theme.Spacing.xs) { ThemedStepper( label: "Number of Cities", value: viewModel.numberOfStops, range: 1...20, onIncrement: { viewModel.numberOfStops += 1 }, onDecrement: { viewModel.numberOfStops -= 1 } ) Text("How many different cities to visit on your trip. More cities = more variety, but more driving between them.") .font(.system(size: Theme.FontSize.micro)) .foregroundStyle(Theme.textMuted(colorScheme)) } } VStack(alignment: .leading, spacing: Theme.Spacing.xs) { Text("Trip Pace") .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textSecondary(colorScheme)) Picker("Pace", selection: $viewModel.leisureLevel) { ForEach(LeisureLevel.allCases) { level in Text(level.displayName).tag(level) } } .pickerStyle(.segmented) Text(viewModel.leisureLevel.description) .font(.system(size: Theme.FontSize.micro)) .foregroundStyle(Theme.textMuted(colorScheme)) .padding(.top, Theme.Spacing.xxs) } } } } private var optionalSection: some View { ThemedSection(title: "More Options") { VStack(spacing: Theme.Spacing.md) { // Must-Stop Locations VStack(alignment: .leading, spacing: Theme.Spacing.sm) { HStack { Image(systemName: "mappin.circle") .foregroundStyle(Theme.warmOrange) Text("Must-Stop Locations") .font(.system(size: Theme.FontSize.body, weight: .medium)) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Text("\(viewModel.mustStopLocations.count)") .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textSecondary(colorScheme)) } ForEach(viewModel.mustStopLocations, id: \.name) { location in HStack { VStack(alignment: .leading, spacing: 2) { Text(location.name) .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textPrimary(colorScheme)) if let address = location.address, !address.isEmpty { Text(address) .font(.system(size: Theme.FontSize.micro)) .foregroundStyle(Theme.textMuted(colorScheme)) } } Spacer() Button { viewModel.removeMustStopLocation(location) } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(Theme.textMuted(colorScheme)) } } .padding(Theme.Spacing.sm) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small)) } Button { cityInputType = .mustStop showCityInput = true } label: { HStack { Image(systemName: "plus.circle.fill") Text("Add Location") } .font(.system(size: Theme.FontSize.caption, weight: .medium)) .foregroundStyle(Theme.warmOrange) } } Divider() .overlay(Theme.surfaceGlow(colorScheme)) // EV Charging (feature flagged) if FeatureFlags.enableEVCharging { ThemedToggle( label: "EV Charging Needed", isOn: $viewModel.needsEVCharging, icon: "bolt.car" ) } // Drivers ThemedStepper( label: "Number of Drivers", value: viewModel.numberOfDrivers, range: 1...4, onIncrement: { viewModel.numberOfDrivers += 1 }, onDecrement: { viewModel.numberOfDrivers -= 1 } ) } } } private var planningOverlay: some View { ZStack { Color.black.opacity(0.5) .ignoresSafeArea() PlanningProgressView() .padding(40) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24)) } } private var planButton: some View { Button { Task { await viewModel.planTrip() } } label: { HStack(spacing: Theme.Spacing.sm) { Image(systemName: "map.fill") Text("Plan My Trip") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding(Theme.Spacing.md) .background(viewModel.isFormValid ? Theme.warmOrange : Theme.textMuted(colorScheme)) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } .disabled(!viewModel.isFormValid) .padding(.top, Theme.Spacing.md) .glowEffect(color: viewModel.isFormValid ? Theme.warmOrange : .clear, radius: 12) } private func validationBanner(message: String) -> some View { HStack(spacing: Theme.Spacing.sm) { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) Text(message) .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textPrimary(colorScheme)) } .padding(Theme.Spacing.md) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.orange.opacity(0.15)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } // MARK: - Helpers private func binding(for sport: Sport) -> Binding { Binding( get: { viewModel.selectedSports.contains(sport) }, set: { isSelected in if isSelected { viewModel.selectedSports.insert(sport) } else { viewModel.selectedSports.remove(sport) } } ) } private func buildGamesDictionary() -> [UUID: RichGame] { viewModel.availableGames.reduce(into: [:]) { $0[$1.id] = $1 } } } // MARK: - View State Extensions extension TripCreationViewModel.ViewState { var isError: Bool { if case .error = self { return true } return false } var isCompleted: Bool { if case .completed = self { return true } return false } } // MARK: - Game Picker Sheet (Team-based selection) struct GamePickerSheet: View { let games: [RichGame] @Binding var selectedIds: Set @Environment(\.dismiss) private var dismiss // Group games by team (both home and away) private var teamsList: [TeamWithGames] { var teamsDict: [UUID: TeamWithGames] = [:] for game in games { // Add to home team if var teamData = teamsDict[game.homeTeam.id] { teamData.games.append(game) teamsDict[game.homeTeam.id] = teamData } else { teamsDict[game.homeTeam.id] = TeamWithGames( team: game.homeTeam, sport: game.game.sport, games: [game] ) } // Add to away team if var teamData = teamsDict[game.awayTeam.id] { teamData.games.append(game) teamsDict[game.awayTeam.id] = teamData } else { teamsDict[game.awayTeam.id] = TeamWithGames( team: game.awayTeam, sport: game.game.sport, games: [game] ) } } return teamsDict.values .sorted { $0.team.name < $1.team.name } } private var teamsBySport: [(sport: Sport, teams: [TeamWithGames])] { let grouped = Dictionary(grouping: teamsList) { $0.sport } return Sport.supported .filter { grouped[$0] != nil } .map { sport in (sport, grouped[sport]!.sorted { $0.team.name < $1.team.name }) } } private var selectedGamesCount: Int { selectedIds.count } var body: some View { NavigationStack { List { // Selected games summary if !selectedIds.isEmpty { Section { HStack { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) Text("\(selectedGamesCount) game(s) selected") .fontWeight(.medium) Spacer() } } } // Teams by sport ForEach(teamsBySport, id: \.sport.id) { sportGroup in Section(sportGroup.sport.rawValue) { ForEach(sportGroup.teams) { teamData in NavigationLink { TeamGamesView( teamData: teamData, selectedIds: $selectedIds ) } label: { TeamRow(teamData: teamData, selectedIds: selectedIds) } } } } } .navigationTitle("Select Teams") .toolbar { ToolbarItem(placement: .cancellationAction) { if !selectedIds.isEmpty { Button("Reset") { selectedIds.removeAll() } .foregroundStyle(.red) } } ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } } } } // MARK: - Team With Games Model struct TeamWithGames: Identifiable { let team: Team let sport: Sport var games: [RichGame] var id: UUID { team.id } var sortedGames: [RichGame] { games.sorted { $0.game.dateTime < $1.game.dateTime } } } // MARK: - Team Row struct TeamRow: View { let teamData: TeamWithGames let selectedIds: Set private var selectedCount: Int { teamData.games.filter { selectedIds.contains($0.id) }.count } var body: some View { HStack(spacing: 12) { // Team color indicator if let colorHex = teamData.team.primaryColor { Circle() .fill(Color(hex: colorHex)) .frame(width: 12, height: 12) } VStack(alignment: .leading, spacing: 2) { Text("\(teamData.team.city) \(teamData.team.name)") .font(.subheadline) .fontWeight(.medium) Text("\(teamData.games.count) game(s) available") .font(.caption) .foregroundStyle(.secondary) } Spacer() if selectedCount > 0 { Text("\(selectedCount)") .font(.caption) .fontWeight(.bold) .foregroundStyle(.white) .padding(.horizontal, 8) .padding(.vertical, 4) .background(.blue) .clipShape(Capsule()) } } } } // MARK: - Team Games View struct TeamGamesView: View { let teamData: TeamWithGames @Binding var selectedIds: Set var body: some View { List { ForEach(teamData.sortedGames) { game in GamePickerRow(game: game, isSelected: selectedIds.contains(game.id)) { if selectedIds.contains(game.id) { selectedIds.remove(game.id) } else { selectedIds.insert(game.id) } } } } .navigationTitle("\(teamData.team.city) \(teamData.team.name)") .navigationBarTitleDisplayMode(.inline) } } struct GamePickerRow: View { let game: RichGame let isSelected: Bool let onTap: () -> Void @Environment(\.colorScheme) private var colorScheme var body: some View { Button(action: onTap) { HStack(spacing: 12) { // Sport color bar SportColorBar(sport: game.game.sport) VStack(alignment: .leading, spacing: 4) { Text(game.matchupDescription) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(game.venueDescription) .font(.caption) .foregroundStyle(Theme.textSecondary(colorScheme)) Text("\(game.game.formattedDate) • \(game.game.gameTime)") .font(.caption2) .foregroundStyle(Theme.textMuted(colorScheme)) } Spacer() Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme)) .font(.title2) } } .buttonStyle(.plain) } } struct GameSelectRow: View { let game: RichGame let isSelected: Bool let onTap: () -> Void var body: some View { Button(action: onTap) { HStack(spacing: 12) { // Sport icon Image(systemName: game.game.sport.iconName) .font(.title3) .foregroundStyle(isSelected ? .blue : .secondary) .frame(width: 24) VStack(alignment: .leading, spacing: 2) { Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)") .font(.subheadline) .fontWeight(.medium) Text("\(game.game.formattedDate) • \(game.game.gameTime)") .font(.caption) .foregroundStyle(.secondary) Text(game.stadium.city) .font(.caption2) .foregroundStyle(.tertiary) } Spacer() Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") .foregroundStyle(isSelected ? .blue : .gray.opacity(0.5)) .font(.title3) } .contentShape(Rectangle()) } .buttonStyle(.plain) } } // MARK: - Location Search Sheet struct LocationSearchSheet: View { let inputType: TripCreationView.CityInputType let onAdd: (LocationInput) -> Void @Environment(\.dismiss) private var dismiss @State private var searchText = "" @State private var searchResults: [LocationSearchResult] = [] @State private var isSearching = false @State private var searchTask: Task? private let locationService = LocationService.shared var body: some View { NavigationStack { VStack(spacing: 0) { // Search field HStack { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) TextField("Search cities, addresses, places...", text: $searchText) .textFieldStyle(.plain) .autocorrectionDisabled() if isSearching { ThemedSpinnerCompact(size: 16) } else if !searchText.isEmpty { Button { searchText = "" searchResults = [] } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } } } .padding() .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: 10)) .padding() // Results list if searchResults.isEmpty && !searchText.isEmpty && !isSearching { ContentUnavailableView( "No Results", systemImage: "mappin.slash", description: Text("Try a different search term") ) } else { List(searchResults) { result in Button { onAdd(result.toLocationInput()) dismiss() } label: { HStack { Image(systemName: "mappin.circle.fill") .foregroundStyle(.red) .font(.title2) VStack(alignment: .leading) { Text(result.name) .foregroundStyle(.primary) if !result.address.isEmpty { Text(result.address) .font(.caption) .foregroundStyle(.secondary) } } Spacer() Image(systemName: "plus.circle") .foregroundStyle(.blue) } } .buttonStyle(.plain) } .listStyle(.plain) } Spacer() } .navigationTitle(inputType == .mustStop ? "Add Must-Stop" : "Add Location") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } } .presentationDetents([.large]) .onChange(of: searchText) { _, newValue in // Debounce search searchTask?.cancel() searchTask = Task { try? await Task.sleep(for: .milliseconds(300)) guard !Task.isCancelled else { return } await performSearch(query: newValue) } } } private func performSearch(query: String) async { guard !query.isEmpty else { searchResults = [] return } isSearching = true do { searchResults = try await locationService.searchLocations(query) } catch { searchResults = [] } isSearching = false } } // MARK: - Trip Options View // MARK: - Sort Options enum TripSortOption: String, CaseIterable, Identifiable { case recommended = "Recommended" case mostGames = "Most Games" case leastGames = "Least Games" case mostMiles = "Most Miles" case leastMiles = "Least Miles" case bestEfficiency = "Best Efficiency" var id: String { rawValue } var icon: String { switch self { case .recommended: return "star.fill" case .mostGames, .leastGames: return "sportscourt" case .mostMiles, .leastMiles: return "road.lanes" case .bestEfficiency: return "gauge.with.dots.needle.33percent" } } } struct TripOptionsView: View { let options: [ItineraryOption] let games: [UUID: RichGame] let preferences: TripPreferences? let convertToTrip: (ItineraryOption) -> Trip @State private var selectedTrip: Trip? @State private var showTripDetail = false @State private var sortOption: TripSortOption = .recommended @Environment(\.colorScheme) private var colorScheme private var sortedOptions: [ItineraryOption] { switch sortOption { case .recommended: return options case .mostGames: return options.sorted { $0.totalGames > $1.totalGames } case .leastGames: return options.sorted { $0.totalGames < $1.totalGames } case .mostMiles: return options.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles } case .leastMiles: return options.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles } case .bestEfficiency: // Games per driving hour (higher is better) return options.sorted { let effA = $0.totalDrivingHours > 0 ? Double($0.totalGames) / $0.totalDrivingHours : 0 let effB = $1.totalDrivingHours > 0 ? Double($1.totalGames) / $1.totalDrivingHours : 0 return effA > effB } } } var body: some View { ScrollView { LazyVStack(spacing: 20) { // Hero header VStack(spacing: 12) { Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill") .font(.system(size: 44)) .foregroundStyle(Theme.warmOrange) Text("\(options.count) Routes Found") .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) .foregroundStyle(Theme.textPrimary(colorScheme)) Text("Each route offers a unique adventure") .font(.system(size: Theme.FontSize.body)) .foregroundStyle(Theme.textSecondary(colorScheme)) } .padding(.top, Theme.Spacing.xl) .padding(.bottom, Theme.Spacing.sm) // Sort picker sortPicker .padding(.horizontal, Theme.Spacing.md) .padding(.bottom, Theme.Spacing.sm) // Options list ForEach(sortedOptions) { option in TripOptionCard( option: option, games: games, onSelect: { selectedTrip = convertToTrip(option) showTripDetail = true } ) .padding(.horizontal, Theme.Spacing.md) } } .padding(.bottom, Theme.Spacing.xxl) } .themedBackground() .navigationDestination(isPresented: $showTripDetail) { if let trip = selectedTrip { TripDetailView(trip: trip, games: games) } } .onChange(of: showTripDetail) { _, isShowing in if !isShowing { selectedTrip = nil } } } private var sortPicker: some View { Menu { ForEach(TripSortOption.allCases) { option in Button { withAnimation(.easeInOut(duration: 0.2)) { sortOption = option } } label: { Label(option.rawValue, systemImage: option.icon) } } } label: { HStack(spacing: 8) { Image(systemName: sortOption.icon) .font(.system(size: 14)) Text(sortOption.rawValue) .font(.system(size: 14, weight: .medium)) Image(systemName: "chevron.down") .font(.system(size: 12)) } .foregroundStyle(Theme.textPrimary(colorScheme)) .padding(.horizontal, 16) .padding(.vertical, 10) .background(Theme.cardBackground(colorScheme)) .clipShape(Capsule()) .overlay( Capsule() .strokeBorder(Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1) ) } } } // MARK: - Trip Option Card struct TripOptionCard: View { let option: ItineraryOption let games: [UUID: RichGame] let onSelect: () -> Void @Environment(\.colorScheme) private var colorScheme @State private var aiDescription: String? @State private var isLoadingDescription = false private var uniqueCities: [String] { option.stops.map { $0.city }.removingDuplicates() } private var totalGames: Int { option.stops.flatMap { $0.games }.count } private var uniqueSports: [Sport] { let gameIds = option.stops.flatMap { $0.games } let sports = gameIds.compactMap { games[$0]?.game.sport } return Array(Set(sports)).sorted { $0.rawValue < $1.rawValue } } private var gamesPerSport: [(sport: Sport, count: Int)] { let gameIds = option.stops.flatMap { $0.games } var countsBySport: [Sport: Int] = [:] for gameId in gameIds { if let sport = games[gameId]?.game.sport { countsBySport[sport, default: 0] += 1 } } return countsBySport.sorted { $0.key.rawValue < $1.key.rawValue } .map { (sport: $0.key, count: $0.value) } } var body: some View { Button(action: onSelect) { HStack(spacing: Theme.Spacing.md) { // Route info VStack(alignment: .leading, spacing: 6) { // Vertical route display VStack(alignment: .leading, spacing: 0) { Text(uniqueCities.first ?? "") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Theme.textPrimary(colorScheme)) VStack(spacing: 0) { Text("|") .font(.system(size: 10)) Image(systemName: "chevron.down") .font(.system(size: 8, weight: .bold)) } .foregroundStyle(Theme.warmOrange) Text(uniqueCities.last ?? "") .font(.system(size: 15, weight: .semibold)) .foregroundStyle(Theme.textPrimary(colorScheme)) } // Top stats row: cities and miles HStack(spacing: 12) { Label("\(uniqueCities.count) cities", systemImage: "mappin") if option.totalDistanceMiles > 0 { Label("\(Int(option.totalDistanceMiles)) mi", systemImage: "car") } } .font(.system(size: 12)) .foregroundStyle(Theme.textSecondary(colorScheme)) // Bottom row: sports with game counts HStack(spacing: 6) { ForEach(gamesPerSport, id: \.sport) { item in HStack(spacing: 3) { Image(systemName: item.sport.iconName) .font(.system(size: 9)) Text("\(item.sport.rawValue.uppercased()) \(item.count)") .font(.system(size: 9, weight: .semibold)) } .padding(.horizontal, 6) .padding(.vertical, 3) .background(item.sport.themeColor.opacity(0.15)) .foregroundStyle(item.sport.themeColor) .clipShape(Capsule()) } } // AI-generated description (after stats) if let description = aiDescription { Text(description) .font(.system(size: 13, weight: .regular)) .foregroundStyle(Theme.textMuted(colorScheme)) .fixedSize(horizontal: false, vertical: true) .transition(.opacity) } else if isLoadingDescription { HStack(spacing: 4) { ThemedSpinnerCompact(size: 12) Text("Generating...") .font(.system(size: 11)) .foregroundStyle(Theme.textMuted(colorScheme)) } } } Spacer() // Right: Chevron Image(systemName: "chevron.right") .font(.system(size: 14, weight: .semibold)) .foregroundStyle(Theme.textMuted(colorScheme)) } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) .overlay { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) } } .buttonStyle(.plain) .task(id: option.id) { // Reset state when option changes aiDescription = nil isLoadingDescription = false await generateDescription() } } private func generateDescription() async { guard RouteDescriptionGenerator.shared.isAvailable else { return } isLoadingDescription = true // Build input from THIS specific option let input = RouteDescriptionInput(from: option, games: games) if let description = await RouteDescriptionGenerator.shared.generateDescription(for: input) { withAnimation(.easeInOut(duration: 0.3)) { aiDescription = description } } isLoadingDescription = false } } // MARK: - Array Extension for Removing Duplicates extension Array where Element: Hashable { func removingDuplicates() -> [Element] { var seen = Set() return filter { seen.insert($0).inserted } } } // MARK: - Themed Form Components struct ThemedSection: View { let title: String @ViewBuilder let content: () -> Content @Environment(\.colorScheme) private var colorScheme var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.sm) { Text(title) .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) .foregroundStyle(Theme.textPrimary(colorScheme)) VStack(alignment: .leading, spacing: Theme.Spacing.md) { content() } .padding(Theme.Spacing.lg) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) .overlay { RoundedRectangle(cornerRadius: Theme.CornerRadius.large) .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) } } } } struct ThemedTextField: View { let label: String let placeholder: String @Binding var text: String var icon: String = "mappin" @Environment(\.colorScheme) private var colorScheme var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.xs) { Text(label) .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textSecondary(colorScheme)) HStack(spacing: Theme.Spacing.sm) { Image(systemName: icon) .foregroundStyle(Theme.warmOrange) .frame(width: 24) TextField(placeholder, text: $text) .font(.system(size: Theme.FontSize.body)) .foregroundStyle(Theme.textPrimary(colorScheme)) } .padding(Theme.Spacing.md) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } } } struct ThemedToggle: View { let label: String @Binding var isOn: Bool var icon: String = "checkmark.circle" @Environment(\.colorScheme) private var colorScheme var body: some View { HStack(spacing: Theme.Spacing.sm) { Image(systemName: icon) .foregroundStyle(isOn ? Theme.warmOrange : Theme.textMuted(colorScheme)) .frame(width: 24) Text(label) .font(.system(size: Theme.FontSize.body)) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Toggle("", isOn: $isOn) .labelsHidden() .tint(Theme.warmOrange) } } } struct ThemedStepper: View { let label: String let value: Int let range: ClosedRange let onIncrement: () -> Void let onDecrement: () -> Void @Environment(\.colorScheme) private var colorScheme var body: some View { HStack { Text(label) .font(.system(size: Theme.FontSize.body)) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() HStack(spacing: Theme.Spacing.sm) { Button { if value > range.lowerBound { onDecrement() } } label: { Image(systemName: "minus.circle.fill") .font(.title2) .foregroundStyle(value > range.lowerBound ? Theme.warmOrange : Theme.textMuted(colorScheme)) } .disabled(value <= range.lowerBound) Text("\(value)") .font(.system(size: Theme.FontSize.body, weight: .bold)) .foregroundStyle(Theme.textPrimary(colorScheme)) .frame(minWidth: 30) Button { if value < range.upperBound { onIncrement() } } label: { Image(systemName: "plus.circle.fill") .font(.title2) .foregroundStyle(value < range.upperBound ? Theme.warmOrange : Theme.textMuted(colorScheme)) } .disabled(value >= range.upperBound) } } } } struct ThemedDatePicker: View { let label: String @Binding var selection: Date @Environment(\.colorScheme) private var colorScheme var body: some View { HStack { HStack(spacing: Theme.Spacing.sm) { Image(systemName: "calendar") .foregroundStyle(Theme.warmOrange) Text(label) .font(.system(size: Theme.FontSize.body)) .foregroundStyle(Theme.textPrimary(colorScheme)) } Spacer() DatePicker("", selection: $selection, displayedComponents: .date) .labelsHidden() .tint(Theme.warmOrange) } } } // MARK: - Date Range Picker struct DateRangePicker: View { @Binding var startDate: Date @Binding var endDate: Date @Environment(\.colorScheme) private var colorScheme @State private var displayedMonth: Date = Date() @State private var selectionState: SelectionState = .none enum SelectionState { case none case startSelected case complete } private let calendar = Calendar.current private let daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"] private var monthYearString: String { let formatter = DateFormatter() formatter.dateFormat = "MMMM yyyy" return formatter.string(from: displayedMonth) } private var daysInMonth: [Date?] { guard let monthInterval = calendar.dateInterval(of: .month, for: displayedMonth), let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) else { return [] } var days: [Date?] = [] let startOfMonth = monthInterval.start let endOfMonth = calendar.date(byAdding: .day, value: -1, to: monthInterval.end)! // Get the first day of the week containing the first day of the month var currentDate = monthFirstWeek.start // Add days until we've covered the month while currentDate <= endOfMonth || days.count % 7 != 0 { if currentDate >= startOfMonth && currentDate <= endOfMonth { days.append(currentDate) } else if currentDate < startOfMonth { days.append(nil) // Placeholder for days before month starts } else if days.count % 7 != 0 { days.append(nil) // Placeholder to complete the last week } else { break } currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)! } return days } private var tripDuration: Int { let components = calendar.dateComponents([.day], from: startDate, to: endDate) return (components.day ?? 0) + 1 } var body: some View { VStack(spacing: Theme.Spacing.md) { // Selected range summary selectedRangeSummary // Month navigation monthNavigation // Days of week header daysOfWeekHeader // Calendar grid calendarGrid // Trip duration tripDurationBadge } } private var selectedRangeSummary: some View { HStack(spacing: Theme.Spacing.md) { // Start date VStack(alignment: .leading, spacing: 4) { Text("START") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(Theme.textMuted(colorScheme)) Text(startDate.formatted(.dateTime.month(.abbreviated).day().year())) .font(.system(size: Theme.FontSize.body, weight: .semibold)) .foregroundStyle(Theme.warmOrange) } .frame(maxWidth: .infinity, alignment: .leading) // Arrow Image(systemName: "arrow.right") .font(.system(size: 14, weight: .medium)) .foregroundStyle(Theme.textMuted(colorScheme)) // End date VStack(alignment: .trailing, spacing: 4) { Text("END") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(Theme.textMuted(colorScheme)) Text(endDate.formatted(.dateTime.month(.abbreviated).day().year())) .font(.system(size: Theme.FontSize.body, weight: .semibold)) .foregroundStyle(Theme.warmOrange) } .frame(maxWidth: .infinity, alignment: .trailing) } .padding(Theme.Spacing.md) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } private var monthNavigation: some View { HStack { Button { withAnimation(.easeInOut(duration: 0.2)) { displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth } } label: { Image(systemName: "chevron.left") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Theme.warmOrange) .frame(width: 36, height: 36) .background(Theme.warmOrange.opacity(0.15)) .clipShape(Circle()) } Spacer() Text(monthYearString) .font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded)) .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Button { withAnimation(.easeInOut(duration: 0.2)) { displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth } } label: { Image(systemName: "chevron.right") .font(.system(size: 16, weight: .semibold)) .foregroundStyle(Theme.warmOrange) .frame(width: 36, height: 36) .background(Theme.warmOrange.opacity(0.15)) .clipShape(Circle()) } } } private var daysOfWeekHeader: some View { HStack(spacing: 0) { ForEach(Array(daysOfWeek.enumerated()), id: \.offset) { _, day in Text(day) .font(.system(size: 12, weight: .semibold)) .foregroundStyle(Theme.textMuted(colorScheme)) .frame(maxWidth: .infinity) } } } private var calendarGrid: some View { let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) return LazyVGrid(columns: columns, spacing: 4) { ForEach(Array(daysInMonth.enumerated()), id: \.offset) { _, date in if let date = date { DayCell( date: date, isStart: calendar.isDate(date, inSameDayAs: startDate), isEnd: calendar.isDate(date, inSameDayAs: endDate), isInRange: isDateInRange(date), isToday: calendar.isDateInToday(date), onTap: { handleDateTap(date) } ) } else { Color.clear .frame(height: 40) } } } } private var tripDurationBadge: some View { HStack(spacing: Theme.Spacing.xs) { Image(systemName: "calendar.badge.clock") .foregroundStyle(Theme.warmOrange) Text("\(tripDuration) day\(tripDuration == 1 ? "" : "s")") .font(.system(size: Theme.FontSize.caption, weight: .medium)) .foregroundStyle(Theme.textSecondary(colorScheme)) } .frame(maxWidth: .infinity, alignment: .center) .padding(.top, Theme.Spacing.xs) } private func isDateInRange(_ date: Date) -> Bool { let start = calendar.startOfDay(for: startDate) let end = calendar.startOfDay(for: endDate) let current = calendar.startOfDay(for: date) return current > start && current < end } private func handleDateTap(_ date: Date) { let today = calendar.startOfDay(for: Date()) let tappedDate = calendar.startOfDay(for: date) // Don't allow selecting dates in the past if tappedDate < today { return } switch selectionState { case .none, .complete: // First tap: set start date, reset end to same day startDate = date endDate = date selectionState = .startSelected case .startSelected: // Second tap: set end date (if after start) if date >= startDate { endDate = date } else { // If tapped date is before start, make it the new start endDate = startDate startDate = date } selectionState = .complete } } } // MARK: - Day Cell struct DayCell: View { let date: Date let isStart: Bool let isEnd: Bool let isInRange: Bool let isToday: Bool let onTap: () -> Void @Environment(\.colorScheme) private var colorScheme private let calendar = Calendar.current private var dayNumber: String { "\(calendar.component(.day, from: date))" } private var isPast: Bool { calendar.startOfDay(for: date) < calendar.startOfDay(for: Date()) } var body: some View { Button(action: onTap) { ZStack { // Range highlight background (stretches edge to edge) if isInRange || isStart || isEnd { HStack(spacing: 0) { Rectangle() .fill(Theme.warmOrange.opacity(0.15)) .frame(maxWidth: .infinity) .opacity(isStart && !isEnd ? 0 : 1) .offset(x: isStart ? 20 : 0) Rectangle() .fill(Theme.warmOrange.opacity(0.15)) .frame(maxWidth: .infinity) .opacity(isEnd && !isStart ? 0 : 1) .offset(x: isEnd ? -20 : 0) } .opacity(isStart && isEnd ? 0 : 1) // Hide when start == end } // Day circle ZStack { if isStart || isEnd { Circle() .fill(Theme.warmOrange) } else if isToday { Circle() .stroke(Theme.warmOrange, lineWidth: 2) } Text(dayNumber) .font(.system(size: 14, weight: (isStart || isEnd) ? .bold : .medium)) .foregroundStyle( isPast ? Theme.textMuted(colorScheme).opacity(0.5) : (isStart || isEnd) ? .white : isToday ? Theme.warmOrange : Theme.textPrimary(colorScheme) ) } .frame(width: 36, height: 36) } } .buttonStyle(.plain) .disabled(isPast) .frame(height: 40) } } struct SportSelectionChip: View { let sport: Sport let isSelected: Bool let onTap: () -> Void @Environment(\.colorScheme) private var colorScheme var body: some View { Button(action: onTap) { VStack(spacing: Theme.Spacing.xs) { ZStack { Circle() .fill(isSelected ? sport.themeColor : sport.themeColor.opacity(0.15)) .frame(width: 44, height: 44) Image(systemName: sport.iconName) .font(.title3) .foregroundStyle(isSelected ? .white : sport.themeColor) } Text(sport.rawValue) .font(.system(size: Theme.FontSize.micro, weight: .medium)) .foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme)) } .frame(maxWidth: .infinity) } .buttonStyle(.plain) } } #Preview { TripCreationView(viewModel: TripCreationViewModel()) }