From b5aea31b1a5d3c860de27d6b037610ccd80e7e07 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 14 Jan 2026 11:02:39 -0600 Subject: [PATCH] refactor: remove legacy trip creation flow, extract shared components - Delete TripCreationView.swift and TripCreationViewModel.swift (unused) - Extract TripOptionsView to standalone file - Extract DateRangePicker and DayCell to standalone file - Extract LocationSearchSheet and CityInputType to standalone file - Fix TripWizardView to pass games dictionary to TripOptionsView - Remove debug print statements from TripDetailView Co-Authored-By: Claude Opus 4.5 --- .../ViewModels/TripCreationViewModel.swift | 646 ---- .../Trip/Views/TripCreationView.swift | 2707 ----------------- .../Features/Trip/Views/TripDetailView.swift | 4 +- .../Features/Trip/Views/TripOptionsView.swift | 600 ++++ .../Views/Wizard/Steps/DateRangePicker.swift | 328 ++ .../Wizard/Steps/LocationSearchSheet.swift | 141 + .../Trip/Views/Wizard/TripWizardView.swift | 15 +- 7 files changed, 1086 insertions(+), 3355 deletions(-) delete mode 100644 SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift delete mode 100644 SportsTime/Features/Trip/Views/TripCreationView.swift create mode 100644 SportsTime/Features/Trip/Views/TripOptionsView.swift create mode 100644 SportsTime/Features/Trip/Views/Wizard/Steps/DateRangePicker.swift create mode 100644 SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift diff --git a/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift b/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift deleted file mode 100644 index 25b440d..0000000 --- a/SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift +++ /dev/null @@ -1,646 +0,0 @@ -// -// TripCreationViewModel.swift -// SportsTime -// - -import Foundation -import SwiftUI -import Observation -import CoreLocation - -@Observable -final class TripCreationViewModel { - - // MARK: - State - - enum ViewState: Equatable { - case editing - case planning - case selectingOption([ItineraryOption]) // Multiple options to choose from - case completed(Trip) - case error(String) - - static func == (lhs: ViewState, rhs: ViewState) -> Bool { - switch (lhs, rhs) { - case (.editing, .editing): return true - case (.planning, .planning): return true - case (.selectingOption(let o1), .selectingOption(let o2)): return o1.count == o2.count - case (.completed(let t1), .completed(let t2)): return t1.id == t2.id - case (.error(let e1), .error(let e2)): return e1 == e2 - default: return false - } - } - } - - var viewState: ViewState = .editing - - // MARK: - Planning Mode - - var planningMode: PlanningMode = .dateRange { - didSet { - // Reset state when mode changes to ensure clean UI transitions - if oldValue != planningMode { - viewState = .editing - // Clear mode-specific selections - switch planningMode { - case .dateRange, .gameFirst: - // Clear locations for date-based modes - startLocationText = "" - endLocationText = "" - startLocation = nil - endLocation = nil - case .locations: - // Keep locations (user needs to enter them) - break - case .followTeam: - // Clear locations and must-see games for follow team mode - startLocationText = "" - endLocationText = "" - startLocation = nil - endLocation = nil - mustSeeGameIds.removeAll() - } - } - } - } - - // MARK: - Form Fields - - // Locations (used in .locations mode) - var startLocationText: String = "" - var endLocationText: String = "" - var startLocation: LocationInput? - var endLocation: LocationInput? - - // Sports - var selectedSports: Set = [.mlb] { - didSet { - // Clear cached games when sports selection changes - if selectedSports != oldValue { - availableGames = [] - games = [] - } - } - } - - // Dates - var startDate: Date = Date() { - didSet { - // Clear cached games when start date changes - // BUT: In gameFirst mode, games are the source of truth for dates, - // so don't clear them (fixes "date range required" error) - if !Calendar.current.isDate(startDate, inSameDayAs: oldValue) && planningMode != .gameFirst { - availableGames = [] - games = [] - } - } - } - var endDate: Date = Date().addingTimeInterval(86400 * 7) { - didSet { - // Clear cached games when end date changes - // BUT: In gameFirst mode, games are the source of truth for dates, - // so don't clear them (fixes "date range required" error) - if !Calendar.current.isDate(endDate, inSameDayAs: oldValue) && planningMode != .gameFirst { - availableGames = [] - games = [] - } - } - } - - // Games - var mustSeeGameIds: Set = [] - var availableGames: [RichGame] = [] - var isLoadingGames: Bool = false - - // Travel - var travelMode: TravelMode = .drive - var routePreference: RoutePreference = .balanced - - // Constraints - var useStopCount: Bool = true - var numberOfStops: Int = 5 - var leisureLevel: LeisureLevel = .moderate - - // Optional - var mustStopLocations: [LocationInput] = [] - var preferredCities: [String] = [] - var needsEVCharging: Bool = false - var lodgingType: LodgingType = .hotel - var numberOfDrivers: Int = 1 - var maxDrivingHoursPerDriver: Double = 8 - - // Travel Preferences - var allowRepeatCities: Bool = true - var selectedRegions: Set = [.east, .central, .west] - - // Follow Team Mode - var followTeamId: String? - var useHomeLocation: Bool = true - - // Game First Mode - Trip duration for sliding windows - var gameFirstTripDuration: Int = 7 - - // MARK: - Dependencies - - private let planningEngine = TripPlanningEngine() - private let locationService = LocationService.shared - private let dataProvider = AppDataProvider.shared - - // MARK: - Cached Data - - private var teams: [String: Team] = [:] - private var stadiums: [String: Stadium] = [:] - private var games: [Game] = [] - private(set) var currentPreferences: TripPreferences? - - // MARK: - Computed Properties - - var isFormValid: Bool { - switch planningMode { - case .dateRange: - // Need: sports + valid date range - return !selectedSports.isEmpty && endDate > startDate - - case .gameFirst: - // Need: at least one selected game + sports - return !mustSeeGameIds.isEmpty && !selectedSports.isEmpty - - case .locations: - // Need: start + end locations + sports - return !startLocationText.isEmpty && - !endLocationText.isEmpty && - !selectedSports.isEmpty - - case .followTeam: - // Need: team selected + valid date range - guard followTeamId != nil else { return false } - guard endDate > startDate else { return false } - // If using home location, need a valid start location - if useHomeLocation && startLocationText.isEmpty { return false } - return true - } - } - - var formValidationMessage: String? { - switch planningMode { - case .dateRange: - if selectedSports.isEmpty { return "Select at least one sport" } - if endDate <= startDate { return "End date must be after start date" } - case .gameFirst: - if mustSeeGameIds.isEmpty { return "Select at least one game" } - if selectedSports.isEmpty { return "Select at least one sport" } - case .locations: - if startLocationText.isEmpty { return "Enter a starting location" } - if endLocationText.isEmpty { return "Enter an ending location" } - if selectedSports.isEmpty { return "Select at least one sport" } - - case .followTeam: - if followTeamId == nil { return "Select a team to follow" } - if endDate <= startDate { return "End date must be after start date" } - if useHomeLocation && startLocationText.isEmpty { return "Enter your home location" } - } - return nil - } - - var tripDurationDays: Int { - let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0 - return max(1, days) - } - - var selectedGamesCount: Int { - mustSeeGameIds.count - } - - var selectedGames: [RichGame] { - availableGames.filter { mustSeeGameIds.contains($0.game.id) } - } - - /// Computed date range for game-first mode based on selected games - var gameFirstDateRange: (start: Date, end: Date)? { - guard !selectedGames.isEmpty else { return nil } - let gameDates = selectedGames.map { $0.game.dateTime } - guard let earliest = gameDates.min(), - let latest = gameDates.max() else { return nil } - return (earliest, latest) - } - - /// Teams grouped by sport for Follow Team picker - var teamsBySport: [Sport: [Team]] { - var grouped: [Sport: [Team]] = [:] - for team in dataProvider.teams { - grouped[team.sport, default: []].append(team) - } - // Sort teams alphabetically within each sport - for sport in grouped.keys { - grouped[sport]?.sort { $0.name < $1.name } - } - return grouped - } - - /// The currently followed team (for display) - var followedTeam: Team? { - guard let teamId = followTeamId else { return nil } - return dataProvider.teams.first { $0.id == teamId } - } - - // MARK: - Actions - - func loadScheduleData() async { - do { - // Ensure initial data is loaded - if dataProvider.teams.isEmpty { - await dataProvider.loadInitialData() - } - - // Use cached teams and stadiums from data provider - for team in dataProvider.teams { - teams[team.id] = team - } - for stadium in dataProvider.stadiums { - stadiums[stadium.id] = stadium - } - - // Filter games within date range - games = try await dataProvider.filterGames( - sports: selectedSports, - startDate: startDate, - endDate: endDate - ) - - // Build rich games for display - availableGames = games.compactMap { game -> RichGame? in - guard let homeTeam = teams[game.homeTeamId], - let awayTeam = teams[game.awayTeamId], - let stadium = stadiums[game.stadiumId] else { - return nil - } - return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) - } - } catch { - viewState = .error("Failed to load schedule data: \(error.localizedDescription)") - } - } - - func resolveLocations() async { - do { - // Only resolve if we don't already have a location with coordinates - // (LocationSearchSheet already provides full LocationInput with coordinates) - if !startLocationText.isEmpty && startLocation?.coordinate == nil { - print("🔍 resolveLocations: Resolving startLocation from text '\(startLocationText)'") - startLocation = try await locationService.resolveLocation( - LocationInput(name: startLocationText, address: startLocationText) - ) - } else if startLocation?.coordinate != nil { - print("🔍 resolveLocations: startLocation already has coordinates, skipping resolve") - } - - if !endLocationText.isEmpty && endLocation?.coordinate == nil { - print("🔍 resolveLocations: Resolving endLocation from text '\(endLocationText)'") - endLocation = try await locationService.resolveLocation( - LocationInput(name: endLocationText, address: endLocationText) - ) - } else if endLocation?.coordinate != nil { - print("🔍 resolveLocations: endLocation already has coordinates, skipping resolve") - } - } catch { - viewState = .error("Failed to resolve locations: \(error.localizedDescription)") - } - } - - func planTrip() async { - guard isFormValid else { return } - - viewState = .planning - - // Mode-specific setup - var effectiveStartDate = startDate - var effectiveEndDate = endDate - var resolvedStartLocation: LocationInput? - var resolvedEndLocation: LocationInput? - - switch planningMode { - case .dateRange: - // Use provided date range, no location needed - // Games will be found within the date range across all regions - effectiveStartDate = startDate - effectiveEndDate = endDate - - case .gameFirst: - // Calculate date range from selected games + buffer - if let dateRange = gameFirstDateRange { - effectiveStartDate = dateRange.start - effectiveEndDate = dateRange.end - } - // Derive start/end locations from first/last game stadiums - if let firstGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).first, - let lastGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).last { - resolvedStartLocation = LocationInput( - name: firstGame.stadium.city, - coordinate: firstGame.stadium.coordinate, - address: "\(firstGame.stadium.city), \(firstGame.stadium.state)" - ) - resolvedEndLocation = LocationInput( - name: lastGame.stadium.city, - coordinate: lastGame.stadium.coordinate, - address: "\(lastGame.stadium.city), \(lastGame.stadium.state)" - ) - } - - case .locations: - // Resolve provided locations - print("🔍 ViewModel.planTrip: .locations mode") - print(" - startLocationText: '\(startLocationText)'") - print(" - endLocationText: '\(endLocationText)'") - print(" - startLocation BEFORE resolve: \(startLocation?.name ?? "nil")") - print(" - endLocation BEFORE resolve: \(endLocation?.name ?? "nil")") - - await resolveLocations() - resolvedStartLocation = startLocation - resolvedEndLocation = endLocation - - print(" - startLocation AFTER resolve: \(startLocation?.name ?? "nil")") - print(" - endLocation AFTER resolve: \(endLocation?.name ?? "nil")") - print(" - resolvedStartLocation: \(resolvedStartLocation?.name ?? "nil")") - print(" - resolvedEndLocation: \(resolvedEndLocation?.name ?? "nil")") - - guard resolvedStartLocation != nil, resolvedEndLocation != nil else { - viewState = .error("Could not resolve start or end location") - return - } - - case .followTeam: - // Use provided date range - effectiveStartDate = startDate - effectiveEndDate = endDate - - // If using home location, resolve it - if useHomeLocation && !startLocationText.isEmpty { - await resolveLocations() - resolvedStartLocation = startLocation - resolvedEndLocation = startLocation // Round trip - same start/end - } - // Otherwise, planner will use first/last game locations (fly-in/fly-out) - } - - // Ensure we have games data - if games.isEmpty { - await loadScheduleData() - } - - // Build preferences - let preferences = TripPreferences( - planningMode: planningMode, - startLocation: resolvedStartLocation, - endLocation: resolvedEndLocation, - sports: selectedSports, - mustSeeGameIds: mustSeeGameIds, - travelMode: travelMode, - startDate: effectiveStartDate, - endDate: effectiveEndDate, - numberOfStops: useStopCount ? numberOfStops : nil, - tripDuration: useStopCount ? nil : tripDurationDays, - leisureLevel: leisureLevel, - mustStopLocations: mustStopLocations, - preferredCities: preferredCities, - routePreference: routePreference, - needsEVCharging: needsEVCharging, - lodgingType: lodgingType, - numberOfDrivers: numberOfDrivers, - maxDrivingHoursPerDriver: maxDrivingHoursPerDriver, - allowRepeatCities: allowRepeatCities, - selectedRegions: selectedRegions, - followTeamId: followTeamId, - useHomeLocation: useHomeLocation, - gameFirstTripDuration: gameFirstTripDuration - ) - - // Build planning request - let request = PlanningRequest( - preferences: preferences, - availableGames: games, - teams: teams, - stadiums: stadiums - ) - - // Plan the trip - let result = planningEngine.planItineraries(request: request) - - switch result { - case .success(var options): - guard !options.isEmpty else { - viewState = .error("No valid itinerary found") - return - } - - // Enrich with EV chargers if requested and feature is enabled - if FeatureFlags.enableEVCharging && needsEVCharging { - options = await ItineraryBuilder.enrichWithEVChargers(options) - } - - // Store preferences for later conversion - currentPreferences = preferences - - if options.count == 1 { - // Only one option - go directly to detail - let trip = convertToTrip(option: options[0], preferences: preferences) - viewState = .completed(trip) - } else { - // Multiple options - show selection view - viewState = .selectingOption(options) - } - - case .failure(let failure): - viewState = .error(failureMessage(for: failure)) - } - } - - func toggleMustSeeGame(_ gameId: String) { - if mustSeeGameIds.contains(gameId) { - mustSeeGameIds.remove(gameId) - } else { - mustSeeGameIds.insert(gameId) - } - } - - func deselectAllGames() { - mustSeeGameIds.removeAll() - } - - func switchPlanningMode(_ mode: PlanningMode) { - // Just set the mode - didSet observer handles state reset - planningMode = mode - } - - /// Load games for browsing in game-first mode - func loadGamesForBrowsing() async { - isLoadingGames = true - do { - // Ensure initial data is loaded - if dataProvider.teams.isEmpty { - await dataProvider.loadInitialData() - } - - // Use cached teams and stadiums from data provider - for team in dataProvider.teams { - teams[team.id] = team - } - for stadium in dataProvider.stadiums { - stadiums[stadium.id] = stadium - } - - // Fetch all games for browsing (no date filter) - games = try await dataProvider.allGames(for: selectedSports) - - // Build rich games for display - availableGames = games.compactMap { game -> RichGame? in - guard let homeTeam = teams[game.homeTeamId], - let awayTeam = teams[game.awayTeamId], - let stadium = stadiums[game.stadiumId] else { - return nil - } - return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) - }.sorted { $0.game.dateTime < $1.game.dateTime } - - } catch { - viewState = .error("Failed to load games: \(error.localizedDescription)") - } - isLoadingGames = false - } - - func addMustStopLocation(_ location: LocationInput) { - guard !mustStopLocations.contains(where: { $0.name == location.name }) else { return } - mustStopLocations.append(location) - } - - func removeMustStopLocation(_ location: LocationInput) { - mustStopLocations.removeAll { $0.name == location.name } - } - - func addPreferredCity(_ city: String) { - guard !city.isEmpty, !preferredCities.contains(city) else { return } - preferredCities.append(city) - } - - func removePreferredCity(_ city: String) { - preferredCities.removeAll { $0 == city } - } - - func reset() { - viewState = .editing - planningMode = .dateRange - startLocationText = "" - endLocationText = "" - startLocation = nil - endLocation = nil - selectedSports = [.mlb] - startDate = Date() - endDate = Date().addingTimeInterval(86400 * 7) - mustSeeGameIds = [] - numberOfStops = 5 - leisureLevel = .moderate - mustStopLocations = [] - preferredCities = [] - availableGames = [] - isLoadingGames = false - currentPreferences = nil - allowRepeatCities = true - selectedRegions = [.east, .central, .west] - } - - /// Toggles region selection. Any combination is allowed. - func toggleRegion(_ region: Region) { - if selectedRegions.contains(region) { - selectedRegions.remove(region) - } else { - selectedRegions.insert(region) - } - } - - /// Select a specific itinerary option and navigate to its detail - func selectOption(_ option: ItineraryOption) { - guard let preferences = currentPreferences else { - viewState = .error("Unable to load trip preferences") - return - } - let trip = convertToTrip(option: option, preferences: preferences) - viewState = .completed(trip) - } - - /// Convert an itinerary option to a Trip (public for use by TripOptionsView) - func convertOptionToTrip(_ option: ItineraryOption) -> Trip { - let preferences = currentPreferences ?? TripPreferences( - planningMode: planningMode, - startLocation: nil, - endLocation: nil, - sports: selectedSports, - mustSeeGameIds: mustSeeGameIds, - travelMode: travelMode, - startDate: startDate, - endDate: endDate, - numberOfStops: useStopCount ? numberOfStops : nil, - tripDuration: useStopCount ? nil : tripDurationDays, - leisureLevel: leisureLevel, - mustStopLocations: mustStopLocations, - preferredCities: preferredCities, - routePreference: routePreference, - needsEVCharging: needsEVCharging, - lodgingType: lodgingType, - numberOfDrivers: numberOfDrivers, - maxDrivingHoursPerDriver: maxDrivingHoursPerDriver, - allowRepeatCities: allowRepeatCities, - selectedRegions: selectedRegions, - followTeamId: followTeamId, - useHomeLocation: useHomeLocation, - gameFirstTripDuration: gameFirstTripDuration - ) - return convertToTrip(option: option, preferences: preferences) - } - - /// Go back to option selection from trip detail - func backToOptions() { - if case .completed = viewState { - // We'd need to store options to go back - for now, restart planning - viewState = .editing - } - } - - // MARK: - Conversion Helpers - - private func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip { - // Convert ItineraryStops to TripStops - let tripStops = option.stops.enumerated().map { index, stop in - TripStop( - stopNumber: index + 1, - city: stop.city, - state: stop.state, - coordinate: stop.coordinate, - arrivalDate: stop.arrivalDate, - departureDate: stop.departureDate, - games: stop.games, - isRestDay: stop.games.isEmpty - ) - } - - return Trip( - name: generateTripName(from: tripStops), - preferences: preferences, - stops: tripStops, - travelSegments: option.travelSegments, - totalGames: option.totalGames, - totalDistanceMeters: option.totalDistanceMiles * 1609.34, - totalDrivingSeconds: option.totalDrivingHours * 3600 - ) - } - - private func generateTripName(from stops: [TripStop]) -> String { - let cities = stops.compactMap { $0.city }.prefix(3) - if cities.count <= 1 { - return cities.first ?? "Road Trip" - } - return cities.joined(separator: " → ") - } - - private func failureMessage(for failure: PlanningFailure) -> String { - failure.message - } -} diff --git a/SportsTime/Features/Trip/Views/TripCreationView.swift b/SportsTime/Features/Trip/Views/TripCreationView.swift deleted file mode 100644 index 0281180..0000000 --- a/SportsTime/Features/Trip/Views/TripCreationView.swift +++ /dev/null @@ -1,2707 +0,0 @@ -// -// 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 - case homeLocation - case startLocation - case endLocation - } - - 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 + Trip Duration - sportsSection - gameBrowserSection - tripDurationSection - - case .locations: - // Locations + Sports + optional games - locationSection - sportsSection - datesSection - gamesSection - - case .followTeam: - // Team picker + Dates + Home location toggle - teamPickerSection - datesSection - homeLocationSection - } - - // Common sections - travelSection - 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( - selectedSports: viewModel.selectedSports, - selectedIds: $viewModel.mustSeeGameIds - ) - } - .sheet(isPresented: $showCityInput) { - LocationSearchSheet(inputType: cityInputType) { location in - switch cityInputType { - case .mustStop: - viewModel.addMustStopLocation(location) - case .preferred: - viewModel.addPreferredCity(location.name) - case .homeLocation, .startLocation: - viewModel.startLocationText = location.name - viewModel.startLocation = location - case .endLocation: - viewModel.endLocationText = location.name - viewModel.endLocation = location - } - } - } - .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(.largeTitle) - .foregroundStyle(Theme.warmOrange) - - Text("Plan Your Adventure") - .font(.title2) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - Text("Select your games, set your route, and hit the road") - .font(.subheadline) - .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?") { - LazyVGrid( - columns: [ - GridItem(.flexible(), spacing: Theme.Spacing.sm), - GridItem(.flexible(), spacing: Theme.Spacing.sm) - ], - spacing: Theme.Spacing.sm - ) { - ForEach(PlanningMode.allCases) { mode in - PlanningModeCard( - mode: mode, - isSelected: viewModel.planningMode == mode, - colorScheme: colorScheme - ) { - withAnimation(.easeInOut(duration: 0.2)) { - viewModel.planningMode = mode - } - } - } - } - } - } - - private var locationSection: some View { - ThemedSection(title: "Locations") { - // Start Location - opens search sheet - locationButton( - label: "Start Location", - icon: "location.circle.fill", - location: viewModel.startLocation, - placeholder: "Where are you starting from?" - ) { - cityInputType = .startLocation - showCityInput = true - } - - // End Location - opens search sheet - locationButton( - label: "End Location", - icon: "mappin.circle.fill", - location: viewModel.endLocation, - placeholder: "Where do you want to end up?" - ) { - cityInputType = .endLocation - showCityInput = true - } - } - } - - private func locationButton( - label: String, - icon: String, - location: LocationInput?, - placeholder: String, - action: @escaping () -> Void - ) -> some View { - VStack(alignment: .leading, spacing: Theme.Spacing.xs) { - Text(label) - .font(.subheadline) - .fontWeight(.medium) - .foregroundStyle(Theme.warmOrange) - - Button(action: action) { - HStack(spacing: Theme.Spacing.md) { - Image(systemName: icon) - .foregroundStyle(Theme.warmOrange) - .frame(width: 24) - - if let location = location { - Text(location.name) - .foregroundStyle(Theme.textPrimary(colorScheme)) - } else { - Text(placeholder) - .foregroundStyle(Theme.textMuted(colorScheme)) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - } - .padding(Theme.Spacing.md) - .background(Theme.cardBackgroundElevated(colorScheme)) - .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) - } - .buttonStyle(.plain) - } - } - - 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(.subheadline) - - VStack(alignment: .leading, spacing: 2) { - Text(result.name) - .font(.subheadline) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - if !result.address.isEmpty { - Text(result.address) - .font(.caption) - .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) { - LoadingSpinner(size: .small) - Text("Loading games...") - .font(.body) - .foregroundStyle(Theme.textSecondary(colorScheme)) - } - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, Theme.Spacing.md) - .task(id: viewModel.selectedSports) { - // Always load 90-day browsing window for gameFirst mode - 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(.body) - .foregroundStyle(Theme.textPrimary(colorScheme)) - Text("\(viewModel.availableGames.count) games available") - .font(.subheadline) - .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(.body) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - Spacer() - - Button { - viewModel.deselectAllGames() - } label: { - Text("Deselect All") - .font(.subheadline) - .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(.subheadline) - .foregroundStyle(Theme.textPrimary(colorScheme)) - Spacer() - Text(game.game.formattedDate) - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - } - } - - if viewModel.selectedGames.count > 3 { - Text("+ \(viewModel.selectedGames.count - 3) more") - .font(.subheadline) - .foregroundStyle(Theme.textSecondary(colorScheme)) - } - } - .padding(Theme.Spacing.md) - .background(Theme.cardBackgroundElevated(colorScheme)) - .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) - } - } - } - - private var tripDurationSection: some View { - ThemedSection(title: "Trip Duration") { - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { - HStack { - Image(systemName: "calendar.badge.clock") - .foregroundStyle(Theme.warmOrange) - Text("Days") - .font(.body) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - Spacer() - - Stepper( - value: $viewModel.gameFirstTripDuration, - in: 2...21, - step: 1 - ) { - Text("\(viewModel.gameFirstTripDuration) days") - .font(.body.monospacedDigit()) - .foregroundStyle(Theme.textPrimary(colorScheme)) - } - } - .padding(Theme.Spacing.md) - .background(Theme.cardBackgroundElevated(colorScheme)) - .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) - - Text("We'll find all possible \(viewModel.gameFirstTripDuration)-day trips that include your selected games") - .font(.caption) - .foregroundStyle(Theme.textSecondary(colorScheme)) - } - } - } - - private var sportsSection: some View { - ThemedSection(title: "Sports") { - SportSelectorGrid( - selectedSports: viewModel.selectedSports - ) { sport in - if viewModel.selectedSports.contains(sport) { - viewModel.selectedSports.remove(sport) - } else { - viewModel.selectedSports.insert(sport) - } - } - .padding(.vertical, Theme.Spacing.xs) - } - } - - private var datesSection: some View { - ThemedSection(title: "Dates") { - DateRangePicker( - startDate: $viewModel.startDate, - endDate: $viewModel.endDate - ) - } - } - - // MARK: - Follow Team Mode - - @State private var showTeamPicker = false - - private var teamPickerSection: some View { - ThemedSection(title: "Select Team") { - Button { - showTeamPicker = true - } label: { - HStack(spacing: Theme.Spacing.md) { - ZStack { - Circle() - .fill(Theme.warmOrange.opacity(0.15)) - .frame(width: 40, height: 40) - Image(systemName: "person.3.fill") - .foregroundStyle(Theme.warmOrange) - } - - VStack(alignment: .leading, spacing: 2) { - if let team = viewModel.followedTeam { - Text(team.name) - .font(.body) - .foregroundStyle(Theme.textPrimary(colorScheme)) - Text(team.sport.displayName) - .font(.caption) - .foregroundStyle(Theme.textSecondary(colorScheme)) - } else { - Text("Choose a team") - .font(.body) - .foregroundStyle(Theme.textPrimary(colorScheme)) - Text("Pick the team to follow") - .font(.caption) - .foregroundStyle(Theme.textMuted(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) - } - .sheet(isPresented: $showTeamPicker) { - TeamPickerSheet( - selectedTeamId: $viewModel.followTeamId, - teamsBySport: viewModel.teamsBySport - ) - } - } - - private var homeLocationSection: some View { - ThemedSection(title: "Trip Start/End") { - VStack(spacing: Theme.Spacing.md) { - ThemedToggle( - label: "Start and end from home", - isOn: $viewModel.useHomeLocation, - icon: "house.fill" - ) - - if viewModel.useHomeLocation { - // Show button to open location search sheet (same as must-stop) - Button { - cityInputType = .homeLocation - showCityInput = true - } label: { - HStack(spacing: Theme.Spacing.md) { - ZStack { - Circle() - .fill(Theme.warmOrange.opacity(0.15)) - .frame(width: 40, height: 40) - Image(systemName: "house.fill") - .foregroundStyle(Theme.warmOrange) - } - - VStack(alignment: .leading, spacing: 2) { - if let location = viewModel.startLocation { - Text(location.name) - .font(.body) - .foregroundStyle(Theme.textPrimary(colorScheme)) - if let address = location.address, !address.isEmpty { - Text(address) - .font(.caption) - .foregroundStyle(Theme.textSecondary(colorScheme)) - } - } else { - Text("Choose home location") - .font(.body) - .foregroundStyle(Theme.textPrimary(colorScheme)) - Text("Tap to search cities") - .font(.caption) - .foregroundStyle(Theme.textMuted(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) - } else { - Text("Trip will start at first game and end at last game (fly-in/fly-out)") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - .padding(.leading, 32) - } - } - } - } - - 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(.body) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - Spacer() - - Text("\(viewModel.selectedGamesCount) selected") - .font(.subheadline) - .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) { - // Region selector - VStack(alignment: .leading, spacing: Theme.Spacing.xs) { - Text("Regions") - .font(.subheadline) - .foregroundStyle(Theme.textSecondary(colorScheme)) - - RegionMapSelector( - selectedRegions: $viewModel.selectedRegions, - onToggle: { region in - viewModel.toggleRegion(region) - } - ) - - if viewModel.selectedRegions.isEmpty { - Text("Select at least one region") - .font(.caption) - .foregroundStyle(Theme.warmOrange) - .padding(.top, Theme.Spacing.xxs) - } else { - Text("Games will be found in selected regions") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - .padding(.top, Theme.Spacing.xxs) - } - } - - // Route preference - VStack(alignment: .leading, spacing: Theme.Spacing.xs) { - Text("Route Preference") - .font(.subheadline) - .foregroundStyle(Theme.textSecondary(colorScheme)) - - Picker("Route Preference", selection: $viewModel.routePreference) { - ForEach(RoutePreference.allCases) { pref in - Text(pref.displayName).tag(pref) - } - } - .pickerStyle(.segmented) - } - - // Allow repeat cities - ThemedToggle( - label: "Allow Repeat Cities", - isOn: $viewModel.allowRepeatCities, - icon: "arrow.triangle.2.circlepath" - ) - - if !viewModel.allowRepeatCities { - Text("Each city will only be visited on one day") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - .padding(.leading, 32) - } - } - .animation(.easeInOut(duration: 0.2), value: viewModel.selectedRegions) - } - } - - 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(.body) - .foregroundStyle(Theme.textPrimary(colorScheme)) - Spacer() - Text("\(viewModel.mustStopLocations.count)") - .font(.subheadline) - .foregroundStyle(Theme.textSecondary(colorScheme)) - } - - ForEach(viewModel.mustStopLocations, id: \.name) { location in - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(location.name) - .font(.subheadline) - .foregroundStyle(Theme.textPrimary(colorScheme)) - if let address = location.address, !address.isEmpty { - Text(address) - .font(.caption) - .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(.subheadline) - .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 { - LoadingSheet(label: "Planning trip") - } - - 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(.subheadline) - .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() -> [String: 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 (Calendar view: Sport → Team → Date with lazy loading) - -struct GamePickerSheet: View { - let selectedSports: Set - @Binding var selectedIds: Set - @Environment(\.dismiss) private var dismiss - @Environment(\.colorScheme) private var colorScheme - - @State private var expandedSports: Set = [] - @State private var expandedTeams: Set = [] - @State private var gamesCache: [String: [RichGame]] = [:] // teamId -> games - @State private var loadingTeams: Set = [] - @State private var selectedGamesCache: [String: RichGame] = [:] // gameId -> game (for count display) - - private let dataProvider = AppDataProvider.shared - - // Get teams for a sport (from in-memory cache, no fetching needed) - private func teamsForSport(_ sport: Sport) -> [Team] { - dataProvider.teams(for: sport).sorted { $0.name < $1.name } - } - - private var sortedSports: [Sport] { - Sport.supported.filter { selectedSports.contains($0) } - } - - private var selectedGamesCount: Int { - selectedIds.count - } - - private func selectedCountForSport(_ sport: Sport) -> Int { - let teamIds = Set(teamsForSport(sport).map { $0.id }) - return selectedGamesCache.values.filter { game in - teamIds.contains(game.homeTeam.id) || teamIds.contains(game.awayTeam.id) - }.count - } - - private func selectedCountForTeam(_ teamId: String) -> Int { - guard let games = gamesCache[teamId] else { return 0 } - return games.filter { selectedIds.contains($0.id) }.count - } - - var body: some View { - NavigationStack { - ScrollView { - LazyVStack(spacing: 0) { - // Selected games summary (always visible to prevent layout jump) - HStack { - Image(systemName: selectedIds.isEmpty ? "circle" : "checkmark.circle.fill") - .foregroundStyle(selectedIds.isEmpty ? Theme.textMuted(colorScheme) : .green) - Text("\(selectedGamesCount) game(s) selected") - .font(.subheadline) - .foregroundStyle(selectedIds.isEmpty ? Theme.textMuted(colorScheme) : Theme.textPrimary(colorScheme)) - Spacer() - } - .padding(Theme.Spacing.md) - .background(Theme.cardBackground(colorScheme)) - - // Sport sections - ForEach(sortedSports) { sport in - LazySportSection( - sport: sport, - teams: teamsForSport(sport), - selectedIds: $selectedIds, - expandedSports: $expandedSports, - expandedTeams: $expandedTeams, - gamesCache: $gamesCache, - loadingTeams: $loadingTeams, - selectedGamesCache: $selectedGamesCache, - selectedCount: selectedCountForSport(sport) - ) - } - } - } - .themedBackground() - .navigationTitle("Select Games") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - if !selectedIds.isEmpty { - Button("Reset") { - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - selectedIds.removeAll() - selectedGamesCache.removeAll() - } - } - .foregroundStyle(.red) - } - } - ToolbarItem(placement: .confirmationAction) { - Button("Done") { - dismiss() - } - .fontWeight(.semibold) - } - } - } - } -} - -// MARK: - Lazy Sport Section (loads teams from memory, games loaded on-demand per team) - -struct LazySportSection: View { - let sport: Sport - let teams: [Team] - @Binding var selectedIds: Set - @Binding var expandedSports: Set - @Binding var expandedTeams: Set - @Binding var gamesCache: [String: [RichGame]] - @Binding var loadingTeams: Set - @Binding var selectedGamesCache: [String: RichGame] - let selectedCount: Int - - @Environment(\.colorScheme) private var colorScheme - - private var isExpanded: Bool { - expandedSports.contains(sport) - } - - var body: some View { - VStack(spacing: 0) { - // Sport header - Button { - withAnimation(.easeInOut(duration: 0.2)) { - if isExpanded { - expandedSports.remove(sport) - } else { - expandedSports.insert(sport) - } - } - } label: { - HStack(spacing: Theme.Spacing.sm) { - Image(systemName: sport.iconName) - .font(.title3) - .foregroundStyle(sport.themeColor) - .frame(width: 32) - - Text(sport.rawValue) - .font(.headline) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - Text("\(teams.count) teams") - .font(.subheadline) - .foregroundStyle(Theme.textMuted(colorScheme)) - - Spacer() - - if selectedCount > 0 { - Text("\(selectedCount)") - .font(.caption) - .foregroundStyle(.white) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(sport.themeColor) - .clipShape(Capsule()) - } - - Image(systemName: "chevron.right") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(Theme.textMuted(colorScheme)) - .rotationEffect(.degrees(isExpanded ? 90 : 0)) - } - .padding(Theme.Spacing.md) - .background(Theme.cardBackground(colorScheme)) - } - .buttonStyle(.plain) - - // Teams list (when expanded) - if isExpanded { - VStack(spacing: 0) { - ForEach(teams) { team in - LazyTeamSection( - team: team, - sport: sport, - selectedIds: $selectedIds, - expandedTeams: $expandedTeams, - gamesCache: $gamesCache, - loadingTeams: $loadingTeams, - selectedGamesCache: $selectedGamesCache - ) - } - } - .padding(.leading, Theme.Spacing.lg) - } - - Divider() - .overlay(Theme.surfaceGlow(colorScheme)) - } - } -} - -// MARK: - Lazy Team Section (loads games on-demand when expanded) - -struct LazyTeamSection: View { - let team: Team - let sport: Sport - @Binding var selectedIds: Set - @Binding var expandedTeams: Set - @Binding var gamesCache: [String: [RichGame]] - @Binding var loadingTeams: Set - @Binding var selectedGamesCache: [String: RichGame] - - @Environment(\.colorScheme) private var colorScheme - - private let dataProvider = AppDataProvider.shared - - private var isExpanded: Bool { - expandedTeams.contains(team.id) - } - - private var isLoading: Bool { - loadingTeams.contains(team.id) - } - - private var games: [RichGame] { - gamesCache[team.id] ?? [] - } - - private var selectedCount: Int { - games.filter { selectedIds.contains($0.id) }.count - } - - private var gamesCount: Int? { - gamesCache[team.id]?.count - } - - // Group games by date - private var gamesByDate: [(date: String, games: [RichGame])] { - let sortedGames = games.sorted { $0.game.dateTime < $1.game.dateTime } - let grouped = Dictionary(grouping: sortedGames) { game in - game.game.formattedDate - } - return grouped.sorted { $0.value.first!.game.dateTime < $1.value.first!.game.dateTime } - .map { (date: $0.key, games: $0.value) } - } - - var body: some View { - VStack(spacing: 0) { - // Team header - Button { - withAnimation(.easeInOut(duration: 0.2)) { - if isExpanded { - expandedTeams.remove(team.id) - } else { - expandedTeams.insert(team.id) - // Load games if not cached - if gamesCache[team.id] == nil && !loadingTeams.contains(team.id) { - loadGames() - } - } - } - } label: { - HStack(spacing: Theme.Spacing.sm) { - // Team color - if let colorHex = team.primaryColor { - Circle() - .fill(Color(hex: colorHex)) - .frame(width: 10, height: 10) - } - - Text("\(team.city) \(team.name)") - .font(.subheadline) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - if let count = gamesCount { - Text("\(count)") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - } - - Spacer() - - if isLoading { - LoadingSpinner(size: .small) - } else if selectedCount > 0 { - Text("\(selectedCount)") - .font(.caption2) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(Theme.warmOrange) - .clipShape(Capsule()) - } - - Image(systemName: "chevron.right") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - .rotationEffect(.degrees(isExpanded ? 90 : 0)) - } - .padding(.vertical, Theme.Spacing.sm) - .padding(.horizontal, Theme.Spacing.md) - .background(Theme.cardBackgroundElevated(colorScheme)) - } - .buttonStyle(.plain) - - // Games grouped by date (when expanded) - if isExpanded { - if isLoading { - HStack { - LoadingSpinner(size: .small) - Text("Loading games...") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - } - .padding(Theme.Spacing.md) - } else if games.isEmpty { - Text("No games scheduled") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - .padding(Theme.Spacing.md) - } else { - VStack(spacing: 0) { - ForEach(gamesByDate, id: \.date) { dateGroup in - VStack(alignment: .leading, spacing: 0) { - // Date header - Text(dateGroup.date) - .font(.caption) - .foregroundStyle(Theme.warmOrange) - .padding(.horizontal, Theme.Spacing.md) - .padding(.top, Theme.Spacing.sm) - .padding(.bottom, Theme.Spacing.xs) - - // Games on this date - ForEach(dateGroup.games) { game in - GameCalendarRow( - game: game, - isSelected: selectedIds.contains(game.id), - onTap: { - // Disable implicit animation to prevent weird morphing effect - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - if selectedIds.contains(game.id) { - selectedIds.remove(game.id) - selectedGamesCache.removeValue(forKey: game.id) - } else { - selectedIds.insert(game.id) - selectedGamesCache[game.id] = game - } - } - } - ) - } - } - } - } - .padding(.leading, Theme.Spacing.md) - .background(Theme.cardBackgroundElevated(colorScheme).opacity(0.5)) - } - } - } - } - - private func loadGames() { - loadingTeams.insert(team.id) - Task { - do { - let loadedGames = try await dataProvider.gamesForTeam(teamId: team.id) - await MainActor.run { - gamesCache[team.id] = loadedGames - loadingTeams.remove(team.id) - } - } catch { - await MainActor.run { - gamesCache[team.id] = [] - loadingTeams.remove(team.id) - } - } - } - } -} - -// MARK: - Game Calendar Row - -struct GameCalendarRow: View { - let game: RichGame - let isSelected: Bool - let onTap: () -> Void - - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - Button(action: onTap) { - HStack(spacing: Theme.Spacing.sm) { - // Selection indicator - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.title3) - .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme)) - .animation(nil, value: isSelected) - - VStack(alignment: .leading, spacing: 2) { - Text("vs \(game.awayTeam.name)") - .font(.subheadline) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - HStack(spacing: Theme.Spacing.xs) { - Label(game.localGameTimeShort, systemImage: "clock") - .font(.caption) - .foregroundStyle(Theme.textSecondary(colorScheme)) - - Text("•") - .foregroundStyle(Theme.textMuted(colorScheme)) - - Label(game.stadium.name, systemImage: "building.2") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - .lineLimit(1) - } - - HStack(spacing: Theme.Spacing.xs) { - Label(game.stadium.fullAddress, systemImage: "mappin.circle.fill") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - - if let broadcast = game.game.broadcastInfo, !broadcast.isEmpty { - Text("•") - .foregroundStyle(Theme.textMuted(colorScheme)) - - Label(broadcast, systemImage: "tv") - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - } - } - } - - Spacer() - } - .padding(.vertical, Theme.Spacing.sm) - .padding(.horizontal, Theme.Spacing.md) - .background(isSelected ? Theme.warmOrange.opacity(0.1) : Color.clear) - .animation(nil, value: isSelected) - } - .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 { - LoadingSpinner(size: .small) - } 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 mostCities = "Most Cities" - 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 .mostCities: return "mappin.and.ellipse" - case .mostGames, .leastGames: return "sportscourt" - case .mostMiles, .leastMiles: return "road.lanes" - case .bestEfficiency: return "gauge.with.dots.needle.33percent" - } - } -} - -enum TripPaceFilter: String, CaseIterable, Identifiable { - case all = "All" - case packed = "Packed" - case moderate = "Moderate" - case relaxed = "Relaxed" - - var id: String { rawValue } - - var icon: String { - switch self { - case .all: return "rectangle.stack" - case .packed: return "flame" - case .moderate: return "equal.circle" - case .relaxed: return "leaf" - } - } -} - -enum CitiesFilter: Int, CaseIterable, Identifiable { - case noLimit = 100 - case fifteen = 15 - case ten = 10 - case five = 5 - case four = 4 - case three = 3 - case two = 2 - - var id: Int { rawValue } - - var displayName: String { - switch self { - case .noLimit: return "No Limit" - case .fifteen: return "15" - case .ten: return "10" - case .five: return "5" - case .four: return "4" - case .three: return "3" - case .two: return "2" - } - } -} - -// MARK: - Trip Options Grouper - -enum TripOptionsGrouper { - typealias GroupedOptions = (header: String, options: [ItineraryOption]) - - static func groupByCityCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] { - let grouped = Dictionary(grouping: options) { option in - Set(option.stops.map { $0.city }).count - } - let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key } - return sorted.map { count, opts in - ("\(count) \(count == 1 ? "city" : "cities")", opts) - } - } - - static func groupByGameCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] { - let grouped = Dictionary(grouping: options) { $0.totalGames } - let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key } - return sorted.map { count, opts in - ("\(count) \(count == 1 ? "game" : "games")", opts) - } - } - - static func groupByMileageRange(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] { - let ranges: [(min: Int, max: Int, label: String)] = [ - (0, 500, "0-500 mi"), - (500, 1000, "500-1000 mi"), - (1000, 1500, "1000-1500 mi"), - (1500, 2000, "1500-2000 mi"), - (2000, Int.max, "2000+ mi") - ] - - var groupedDict: [String: [ItineraryOption]] = [:] - for option in options { - let miles = Int(option.totalDistanceMiles) - for range in ranges { - if miles >= range.min && miles < range.max { - groupedDict[range.label, default: []].append(option) - break - } - } - } - - // Sort by range order - let rangeOrder = ascending ? ranges : ranges.reversed() - return rangeOrder.compactMap { range in - guard let opts = groupedDict[range.label], !opts.isEmpty else { return nil } - return (range.label, opts) - } - } -} - -struct TripOptionsView: View { - let options: [ItineraryOption] - let games: [String: RichGame] - let preferences: TripPreferences? - let convertToTrip: (ItineraryOption) -> Trip - - @State private var selectedTrip: Trip? - @State private var showTripDetail = false - @State private var sortOption: TripSortOption = .recommended - @State private var citiesFilter: CitiesFilter = .noLimit - @State private var paceFilter: TripPaceFilter = .all - @Environment(\.colorScheme) private var colorScheme - - // MARK: - Computed Properties - - private func uniqueCityCount(for option: ItineraryOption) -> Int { - Set(option.stops.map { $0.city }).count - } - - private var filteredAndSortedOptions: [ItineraryOption] { - // Apply filters first - let filtered = options.filter { option in - let cityCount = uniqueCityCount(for: option) - - // City filter - guard cityCount <= citiesFilter.rawValue else { return false } - - // Pace filter based on games per day ratio - switch paceFilter { - case .all: - return true - case .packed: - // High game density: > 0.8 games per day - return gamesPerDay(for: option) >= 0.8 - case .moderate: - // Medium density: 0.4-0.8 games per day - let gpd = gamesPerDay(for: option) - return gpd >= 0.4 && gpd < 0.8 - case .relaxed: - // Low density: < 0.4 games per day - return gamesPerDay(for: option) < 0.4 - } - } - - // Then apply sorting - switch sortOption { - case .recommended: - return filtered - case .mostCities: - return filtered.sorted { $0.stops.count > $1.stops.count } - case .mostGames: - return filtered.sorted { $0.totalGames > $1.totalGames } - case .leastGames: - return filtered.sorted { $0.totalGames < $1.totalGames } - case .mostMiles: - return filtered.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles } - case .leastMiles: - return filtered.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles } - case .bestEfficiency: - return filtered.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 - } - } - } - - private func gamesPerDay(for option: ItineraryOption) -> Double { - guard let first = option.stops.first, - let last = option.stops.last else { return 0 } - let days = max(1, Calendar.current.dateComponents([.day], from: first.arrivalDate, to: last.departureDate).day ?? 1) - return Double(option.totalGames) / Double(days) - } - - private var groupedOptions: [TripOptionsGrouper.GroupedOptions] { - switch sortOption { - case .recommended, .bestEfficiency: - // Flat list, no grouping - return [("", filteredAndSortedOptions)] - - case .mostCities: - return TripOptionsGrouper.groupByCityCount(filteredAndSortedOptions, ascending: false) - - case .mostGames: - return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: false) - - case .leastGames: - return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: true) - - case .mostMiles: - return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: false) - - case .leastMiles: - return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: true) - } - } - - var body: some View { - ScrollView { - LazyVStack(spacing: 16) { - // Hero header - VStack(spacing: 8) { - Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill") - .font(.largeTitle) - .foregroundStyle(Theme.warmOrange) - - Text("\(filteredAndSortedOptions.count) of \(options.count) Routes") - .font(.title2) - .foregroundStyle(Theme.textPrimary(colorScheme)) - } - .padding(.top, Theme.Spacing.lg) - - // Filters section - filtersSection - .padding(.horizontal, Theme.Spacing.md) - - // Options list (grouped when applicable) - if filteredAndSortedOptions.isEmpty { - emptyFilterState - .padding(.top, Theme.Spacing.xl) - } else { - ForEach(Array(groupedOptions.enumerated()), id: \.offset) { _, group in - VStack(alignment: .leading, spacing: Theme.Spacing.sm) { - // Section header (only if non-empty) - if !group.header.isEmpty { - HStack { - Text(group.header) - .font(.headline) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - Spacer() - - Text("\(group.options.count)") - .font(.subheadline) - .foregroundStyle(Theme.textMuted(colorScheme)) - } - .padding(.horizontal, Theme.Spacing.md) - .padding(.top, Theme.Spacing.md) - } - - // Options in this group - ForEach(group.options) { 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(.subheadline) - Text(sortOption.rawValue) - .font(.subheadline) - Image(systemName: "chevron.down") - .font(.caption) - } - .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: - Filters Section - - private var filtersSection: some View { - VStack(spacing: Theme.Spacing.md) { - // Sort and Pace row - HStack(spacing: Theme.Spacing.sm) { - sortPicker - Spacer() - pacePicker - } - - // Cities picker - citiesPicker - } - .padding(Theme.Spacing.md) - .background(Theme.cardBackground(colorScheme)) - .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) - } - - private var pacePicker: some View { - Menu { - ForEach(TripPaceFilter.allCases) { pace in - Button { - paceFilter = pace - } label: { - Label(pace.rawValue, systemImage: pace.icon) - } - } - } label: { - HStack(spacing: 6) { - Image(systemName: paceFilter.icon) - .font(.caption) - .contentTransition(.identity) - Text(paceFilter.rawValue) - .font(.subheadline) - .contentTransition(.identity) - Image(systemName: "chevron.down") - .font(.caption2) - } - .foregroundStyle(paceFilter == .all ? Theme.textPrimary(colorScheme) : Theme.warmOrange) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(paceFilter == .all ? Theme.cardBackground(colorScheme) : Theme.warmOrange.opacity(0.15)) - .clipShape(Capsule()) - .overlay( - Capsule() - .strokeBorder(paceFilter == .all ? Theme.textMuted(colorScheme).opacity(0.2) : Theme.warmOrange.opacity(0.3), lineWidth: 1) - ) - } - } - - private var citiesPicker: some View { - VStack(alignment: .leading, spacing: Theme.Spacing.xs) { - Label("Max Cities", systemImage: "mappin.circle") - .font(.subheadline) - .foregroundStyle(Theme.textSecondary(colorScheme)) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(CitiesFilter.allCases) { filter in - Button { - withAnimation(.easeInOut(duration: 0.2)) { - citiesFilter = filter - } - } label: { - Text(filter.displayName) - .font(.system(size: 13, weight: citiesFilter == filter ? .semibold : .medium)) - .foregroundStyle(citiesFilter == filter ? .white : Theme.textPrimary(colorScheme)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background(citiesFilter == filter ? Theme.warmOrange : Theme.cardBackground(colorScheme)) - .clipShape(Capsule()) - .overlay( - Capsule() - .strokeBorder(citiesFilter == filter ? Color.clear : Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1) - ) - } - .buttonStyle(.plain) - } - } - } - } - } - - private var emptyFilterState: some View { - VStack(spacing: Theme.Spacing.md) { - Image(systemName: "line.3.horizontal.decrease.circle") - .font(.system(size: 48)) - .foregroundStyle(Theme.textMuted(colorScheme)) - - Text("No routes match your filters") - .font(.body) - .foregroundStyle(Theme.textSecondary(colorScheme)) - - Button { - withAnimation { - citiesFilter = .noLimit - paceFilter = .all - } - } label: { - Text("Reset Filters") - .font(.subheadline) - .foregroundStyle(Theme.warmOrange) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, Theme.Spacing.xxl) - } -} - -// MARK: - Trip Option Card - -struct TripOptionCard: View { - let option: ItineraryOption - let games: [String: 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(.subheadline) - .foregroundStyle(Theme.textPrimary(colorScheme)) - - VStack(spacing: 0) { - Text("|") - .font(.caption2) - Image(systemName: "chevron.down") - .font(.caption2) - } - .foregroundStyle(Theme.warmOrange) - - Text(uniqueCities.last ?? "") - .font(.subheadline) - .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(.caption) - .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(.caption2) - Text("\(item.sport.rawValue.uppercased()) \(item.count)") - .font(.caption2) - } - .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) { - LoadingSpinner(size: .small) - Text("Generating...") - .font(.caption2) - .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(.title2) - .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(.subheadline) - .foregroundStyle(Theme.textSecondary(colorScheme)) - - HStack(spacing: Theme.Spacing.sm) { - Image(systemName: icon) - .foregroundStyle(Theme.warmOrange) - .frame(width: 24) - - TextField(placeholder, text: $text) - .font(.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(.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(.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(.body) - .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(.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 - } - .onAppear { - // Initialize displayed month to show the start date's month - displayedMonth = calendar.startOfDay(for: startDate) - // If dates are already selected (endDate > startDate), show complete state - if endDate > startDate { - selectionState = .complete - } - } - } - - private var selectedRangeSummary: some View { - HStack(spacing: Theme.Spacing.md) { - // Start date - VStack(alignment: .leading, spacing: 4) { - Text("START") - .font(.caption2) - .foregroundStyle(Theme.textMuted(colorScheme)) - Text(startDate.formatted(.dateTime.month(.abbreviated).day().year())) - .font(.body) - .foregroundStyle(Theme.warmOrange) - } - .frame(maxWidth: .infinity, alignment: .leading) - - // Arrow - Image(systemName: "arrow.right") - .font(.subheadline) - .foregroundStyle(Theme.textMuted(colorScheme)) - - // End date - VStack(alignment: .trailing, spacing: 4) { - Text("END") - .font(.caption2) - .foregroundStyle(Theme.textMuted(colorScheme)) - Text(endDate.formatted(.dateTime.month(.abbreviated).day().year())) - .font(.body) - .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(.body) - .foregroundStyle(Theme.warmOrange) - .frame(width: 36, height: 36) - .background(Theme.warmOrange.opacity(0.15)) - .clipShape(Circle()) - } - - Spacer() - - Text(monthYearString) - .font(.headline) - .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(.body) - .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(.caption) - .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(.subheadline) - .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) - } -} - -// MARK: - Team Picker Sheet - -struct TeamPickerSheet: View { - @Binding var selectedTeamId: String? - let teamsBySport: [Sport: [Team]] - - @Environment(\.dismiss) private var dismiss - @Environment(\.colorScheme) private var colorScheme - - @State private var searchText = "" - - private var sortedSports: [Sport] { - Sport.allCases.filter { teamsBySport[$0] != nil && !teamsBySport[$0]!.isEmpty } - } - - private var filteredTeamsBySport: [Sport: [Team]] { - guard !searchText.isEmpty else { return teamsBySport } - - var filtered: [Sport: [Team]] = [:] - for (sport, teams) in teamsBySport { - let matchingTeams = teams.filter { - $0.name.localizedCaseInsensitiveContains(searchText) || - $0.city.localizedCaseInsensitiveContains(searchText) || - $0.abbreviation.localizedCaseInsensitiveContains(searchText) - } - if !matchingTeams.isEmpty { - filtered[sport] = matchingTeams - } - } - return filtered - } - - var body: some View { - NavigationStack { - List { - ForEach(sortedSports, id: \.self) { sport in - if let teams = filteredTeamsBySport[sport], !teams.isEmpty { - Section(sport.displayName) { - ForEach(teams) { team in - TeamRow( - team: team, - isSelected: selectedTeamId == team.id, - colorScheme: colorScheme - ) { - selectedTeamId = team.id - dismiss() - } - } - } - } - } - } - .searchable(text: $searchText, prompt: "Search teams") - .navigationTitle("Select Team") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - } - } - } - } -} - -// MARK: - Planning Mode Card - -struct PlanningModeCard: View { - let mode: PlanningMode - let isSelected: Bool - let colorScheme: ColorScheme - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - VStack(spacing: Theme.Spacing.sm) { - // Icon - ZStack { - Circle() - .fill(isSelected ? Theme.warmOrange : Theme.warmOrange.opacity(0.15)) - .frame(width: 44, height: 44) - - Image(systemName: mode.iconName) - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(isSelected ? .white : Theme.warmOrange) - } - - // Title - Text(mode.displayName) - .font(.subheadline.weight(.medium)) - .foregroundStyle(Theme.textPrimary(colorScheme)) - .lineLimit(1) - } - .frame(maxWidth: .infinity) - .padding(.vertical, Theme.Spacing.md) - .padding(.horizontal, Theme.Spacing.sm) - .background( - RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) - .fill(Theme.cardBackgroundElevated(colorScheme)) - ) - .overlay( - RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) - .strokeBorder( - isSelected ? Theme.warmOrange : Color.clear, - lineWidth: 2 - ) - ) - } - .buttonStyle(.plain) - .accessibilityLabel("\(mode.displayName): \(mode.description)") - .accessibilityAddTraits(isSelected ? .isSelected : []) - } -} - -struct TeamRow: View { - let team: Team - let isSelected: Bool - let colorScheme: ColorScheme - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - HStack(spacing: Theme.Spacing.md) { - // Team color indicator - if let colorHex = team.primaryColor { - Circle() - .fill(Color(hex: colorHex)) - .frame(width: 12, height: 12) - } else { - Circle() - .fill(Theme.warmOrange) - .frame(width: 12, height: 12) - } - - VStack(alignment: .leading, spacing: 2) { - Text(team.name) - .font(.body) - .foregroundStyle(Theme.textPrimary(colorScheme)) - Text(team.city) - .font(.caption) - .foregroundStyle(Theme.textSecondary(colorScheme)) - } - - Spacer() - - if isSelected { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(Theme.warmOrange) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } -} - -#Preview { - TripCreationView(viewModel: TripCreationViewModel()) -} diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 186037f..a3fabdc 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -384,7 +384,9 @@ struct TripDetailView: View { let dayStart = calendar.startOfDay(for: date) let allGameIds = trip.stops.flatMap { $0.games } - return allGameIds.compactMap { games[$0] }.filter { richGame in + let foundGames = allGameIds.compactMap { games[$0] } + + return foundGames.filter { richGame in calendar.startOfDay(for: richGame.game.dateTime) == dayStart }.sorted { $0.game.dateTime < $1.game.dateTime } } diff --git a/SportsTime/Features/Trip/Views/TripOptionsView.swift b/SportsTime/Features/Trip/Views/TripOptionsView.swift new file mode 100644 index 0000000..ff2ce30 --- /dev/null +++ b/SportsTime/Features/Trip/Views/TripOptionsView.swift @@ -0,0 +1,600 @@ +// +// TripOptionsView.swift +// SportsTime +// +// Displays trip options for user selection after planning completes. +// + +import SwiftUI + +// MARK: - Sort Options + +enum TripSortOption: String, CaseIterable, Identifiable { + case recommended = "Recommended" + case mostCities = "Most Cities" + 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 .mostCities: return "mappin.and.ellipse" + case .mostGames, .leastGames: return "sportscourt" + case .mostMiles, .leastMiles: return "road.lanes" + case .bestEfficiency: return "gauge.with.dots.needle.33percent" + } + } +} + +// MARK: - Pace Filter + +enum TripPaceFilter: String, CaseIterable, Identifiable { + case all = "All" + case packed = "Packed" + case moderate = "Moderate" + case relaxed = "Relaxed" + + var id: String { rawValue } + + var icon: String { + switch self { + case .all: return "rectangle.stack" + case .packed: return "flame" + case .moderate: return "equal.circle" + case .relaxed: return "leaf" + } + } +} + +// MARK: - Cities Filter + +enum CitiesFilter: Int, CaseIterable, Identifiable { + case noLimit = 100 + case fifteen = 15 + case ten = 10 + case five = 5 + case four = 4 + case three = 3 + case two = 2 + + var id: Int { rawValue } + + var displayName: String { + switch self { + case .noLimit: return "No Limit" + case .fifteen: return "15" + case .ten: return "10" + case .five: return "5" + case .four: return "4" + case .three: return "3" + case .two: return "2" + } + } +} + +// MARK: - Trip Options Grouper + +enum TripOptionsGrouper { + typealias GroupedOptions = (header: String, options: [ItineraryOption]) + + static func groupByCityCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] { + let grouped = Dictionary(grouping: options) { option in + Set(option.stops.map { $0.city }).count + } + let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key } + return sorted.map { count, opts in + ("\(count) \(count == 1 ? "city" : "cities")", opts) + } + } + + static func groupByGameCount(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] { + let grouped = Dictionary(grouping: options) { $0.totalGames } + let sorted = ascending ? grouped.sorted { $0.key < $1.key } : grouped.sorted { $0.key > $1.key } + return sorted.map { count, opts in + ("\(count) \(count == 1 ? "game" : "games")", opts) + } + } + + static func groupByMileageRange(_ options: [ItineraryOption], ascending: Bool) -> [GroupedOptions] { + let ranges: [(min: Int, max: Int, label: String)] = [ + (0, 500, "0-500 mi"), + (500, 1000, "500-1000 mi"), + (1000, 1500, "1000-1500 mi"), + (1500, 2000, "1500-2000 mi"), + (2000, Int.max, "2000+ mi") + ] + + var groupedDict: [String: [ItineraryOption]] = [:] + for option in options { + let miles = Int(option.totalDistanceMiles) + for range in ranges { + if miles >= range.min && miles < range.max { + groupedDict[range.label, default: []].append(option) + break + } + } + } + + // Sort by range order + let rangeOrder = ascending ? ranges : ranges.reversed() + return rangeOrder.compactMap { range in + guard let opts = groupedDict[range.label], !opts.isEmpty else { return nil } + return (range.label, opts) + } + } +} + +// MARK: - Trip Options View + +struct TripOptionsView: View { + let options: [ItineraryOption] + let games: [String: RichGame] + let preferences: TripPreferences? + let convertToTrip: (ItineraryOption) -> Trip + + @State private var selectedTrip: Trip? + @State private var showTripDetail = false + @State private var sortOption: TripSortOption = .recommended + @State private var citiesFilter: CitiesFilter = .noLimit + @State private var paceFilter: TripPaceFilter = .all + @Environment(\.colorScheme) private var colorScheme + + // MARK: - Computed Properties + + private func uniqueCityCount(for option: ItineraryOption) -> Int { + Set(option.stops.map { $0.city }).count + } + + private var filteredAndSortedOptions: [ItineraryOption] { + // Apply filters first + let filtered = options.filter { option in + let cityCount = uniqueCityCount(for: option) + + // City filter + guard cityCount <= citiesFilter.rawValue else { return false } + + // Pace filter based on games per day ratio + switch paceFilter { + case .all: + return true + case .packed: + // High game density: > 0.8 games per day + return gamesPerDay(for: option) >= 0.8 + case .moderate: + // Medium density: 0.4-0.8 games per day + let gpd = gamesPerDay(for: option) + return gpd >= 0.4 && gpd < 0.8 + case .relaxed: + // Low density: < 0.4 games per day + return gamesPerDay(for: option) < 0.4 + } + } + + // Then apply sorting + switch sortOption { + case .recommended: + return filtered + case .mostCities: + return filtered.sorted { $0.stops.count > $1.stops.count } + case .mostGames: + return filtered.sorted { $0.totalGames > $1.totalGames } + case .leastGames: + return filtered.sorted { $0.totalGames < $1.totalGames } + case .mostMiles: + return filtered.sorted { $0.totalDistanceMiles > $1.totalDistanceMiles } + case .leastMiles: + return filtered.sorted { $0.totalDistanceMiles < $1.totalDistanceMiles } + case .bestEfficiency: + return filtered.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 + } + } + } + + private func gamesPerDay(for option: ItineraryOption) -> Double { + guard let first = option.stops.first, + let last = option.stops.last else { return 0 } + let days = max(1, Calendar.current.dateComponents([.day], from: first.arrivalDate, to: last.departureDate).day ?? 1) + return Double(option.totalGames) / Double(days) + } + + private var groupedOptions: [TripOptionsGrouper.GroupedOptions] { + switch sortOption { + case .recommended, .bestEfficiency: + // Flat list, no grouping + return [("", filteredAndSortedOptions)] + + case .mostCities: + return TripOptionsGrouper.groupByCityCount(filteredAndSortedOptions, ascending: false) + + case .mostGames: + return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: false) + + case .leastGames: + return TripOptionsGrouper.groupByGameCount(filteredAndSortedOptions, ascending: true) + + case .mostMiles: + return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: false) + + case .leastMiles: + return TripOptionsGrouper.groupByMileageRange(filteredAndSortedOptions, ascending: true) + } + } + + var body: some View { + ScrollView { + LazyVStack(spacing: 16) { + // Hero header + VStack(spacing: 8) { + Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill") + .font(.largeTitle) + .foregroundStyle(Theme.warmOrange) + + Text("\(filteredAndSortedOptions.count) of \(options.count) Routes") + .font(.title2) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + .padding(.top, Theme.Spacing.lg) + + // Filters section + filtersSection + .padding(.horizontal, Theme.Spacing.md) + + // Options list (grouped when applicable) + if filteredAndSortedOptions.isEmpty { + emptyFilterState + .padding(.top, Theme.Spacing.xl) + } else { + ForEach(Array(groupedOptions.enumerated()), id: \.offset) { _, group in + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Section header (only if non-empty) + if !group.header.isEmpty { + HStack { + Text(group.header) + .font(.headline) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Spacer() + + Text("\(group.options.count)") + .font(.subheadline) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(.horizontal, Theme.Spacing.md) + .padding(.top, Theme.Spacing.md) + } + + // Options in this group + ForEach(group.options) { 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(.subheadline) + Text(sortOption.rawValue) + .font(.subheadline) + Image(systemName: "chevron.down") + .font(.caption) + } + .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: - Filters Section + + private var filtersSection: some View { + VStack(spacing: Theme.Spacing.md) { + // Sort and Pace row + HStack(spacing: Theme.Spacing.sm) { + sortPicker + Spacer() + pacePicker + } + + // Cities picker + citiesPicker + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + + private var pacePicker: some View { + Menu { + ForEach(TripPaceFilter.allCases) { pace in + Button { + paceFilter = pace + } label: { + Label(pace.rawValue, systemImage: pace.icon) + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: paceFilter.icon) + .font(.caption) + .contentTransition(.identity) + Text(paceFilter.rawValue) + .font(.subheadline) + .contentTransition(.identity) + Image(systemName: "chevron.down") + .font(.caption2) + } + .foregroundStyle(paceFilter == .all ? Theme.textPrimary(colorScheme) : Theme.warmOrange) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(paceFilter == .all ? Theme.cardBackground(colorScheme) : Theme.warmOrange.opacity(0.15)) + .clipShape(Capsule()) + .overlay( + Capsule() + .strokeBorder(paceFilter == .all ? Theme.textMuted(colorScheme).opacity(0.2) : Theme.warmOrange.opacity(0.3), lineWidth: 1) + ) + } + } + + private var citiesPicker: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Label("Max Cities", systemImage: "mappin.circle") + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(CitiesFilter.allCases) { filter in + Button { + withAnimation(.easeInOut(duration: 0.2)) { + citiesFilter = filter + } + } label: { + Text(filter.displayName) + .font(.system(size: 13, weight: citiesFilter == filter ? .semibold : .medium)) + .foregroundStyle(citiesFilter == filter ? .white : Theme.textPrimary(colorScheme)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(citiesFilter == filter ? Theme.warmOrange : Theme.cardBackground(colorScheme)) + .clipShape(Capsule()) + .overlay( + Capsule() + .strokeBorder(citiesFilter == filter ? Color.clear : Theme.textMuted(colorScheme).opacity(0.2), lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + } + } + } + } + + private var emptyFilterState: some View { + VStack(spacing: Theme.Spacing.md) { + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.system(size: 48)) + .foregroundStyle(Theme.textMuted(colorScheme)) + + Text("No routes match your filters") + .font(.body) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + Button { + withAnimation { + citiesFilter = .noLimit + paceFilter = .all + } + } label: { + Text("Reset Filters") + .font(.subheadline) + .foregroundStyle(Theme.warmOrange) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, Theme.Spacing.xxl) + } +} + +// MARK: - Trip Option Card + +struct TripOptionCard: View { + let option: ItineraryOption + let games: [String: 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(.subheadline) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + VStack(spacing: 0) { + Text("|") + .font(.caption2) + Image(systemName: "chevron.down") + .font(.caption2) + } + .foregroundStyle(Theme.warmOrange) + + Text(uniqueCities.last ?? "") + .font(.subheadline) + .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(.caption) + .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(.caption2) + Text("\(item.sport.rawValue.uppercased()) \(item.count)") + .font(.caption2) + } + .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) { + LoadingSpinner(size: .small) + Text("Generating...") + .font(.caption2) + .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 } + } +} diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/DateRangePicker.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/DateRangePicker.swift new file mode 100644 index 0000000..7ae52c2 --- /dev/null +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/DateRangePicker.swift @@ -0,0 +1,328 @@ +// +// DateRangePicker.swift +// SportsTime +// +// Extracted from TripCreationView - reusable date range picker component. +// + +import SwiftUI + +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 + } + .onAppear { + // Initialize displayed month to show the start date's month + displayedMonth = calendar.startOfDay(for: startDate) + // If dates are already selected (endDate > startDate), show complete state + if endDate > startDate { + selectionState = .complete + } + } + } + + private var selectedRangeSummary: some View { + HStack(spacing: Theme.Spacing.md) { + // Start date + VStack(alignment: .leading, spacing: 4) { + Text("START") + .font(.caption2) + .foregroundStyle(Theme.textMuted(colorScheme)) + Text(startDate.formatted(.dateTime.month(.abbreviated).day().year())) + .font(.body) + .foregroundStyle(Theme.warmOrange) + } + .frame(maxWidth: .infinity, alignment: .leading) + + // Arrow + Image(systemName: "arrow.right") + .font(.subheadline) + .foregroundStyle(Theme.textMuted(colorScheme)) + + // End date + VStack(alignment: .trailing, spacing: 4) { + Text("END") + .font(.caption2) + .foregroundStyle(Theme.textMuted(colorScheme)) + Text(endDate.formatted(.dateTime.month(.abbreviated).day().year())) + .font(.body) + .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(.body) + .foregroundStyle(Theme.warmOrange) + .frame(width: 36, height: 36) + .background(Theme.warmOrange.opacity(0.15)) + .clipShape(Circle()) + } + + Spacer() + + Text(monthYearString) + .font(.headline) + .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(.body) + .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(.caption) + .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(.subheadline) + .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) + } +} + +// MARK: - Preview + +#Preview { + DateRangePicker( + startDate: .constant(Date()), + endDate: .constant(Date().addingTimeInterval(86400 * 7)) + ) + .padding() +} diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift new file mode 100644 index 0000000..dc83435 --- /dev/null +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/LocationSearchSheet.swift @@ -0,0 +1,141 @@ +// +// LocationSearchSheet.swift +// SportsTime +// +// Extracted from TripCreationView - location search sheet for adding cities/places. +// + +import SwiftUI + +// MARK: - City Input Type + +enum CityInputType { + case mustStop + case preferred + case homeLocation + case startLocation + case endLocation +} + +// MARK: - Location Search Sheet + +struct LocationSearchSheet: View { + let inputType: 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 { + LoadingSpinner(size: .small) + } 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: - Preview + +#Preview { + LocationSearchSheet(inputType: .mustStop) { _ in } +} diff --git a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift index a558e47..4df7a9a 100644 --- a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift +++ b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift @@ -13,6 +13,7 @@ struct TripWizardView: View { @State private var viewModel = TripWizardViewModel() @State private var showTripOptions = false @State private var tripOptions: [ItineraryOption] = [] + @State private var gamesForDisplay: [String: RichGame] = [:] @State private var planningError: String? @State private var showError = false @@ -91,7 +92,7 @@ struct TripWizardView: View { .navigationDestination(isPresented: $showTripOptions) { TripOptionsView( options: tripOptions, - games: [:], + games: gamesForDisplay, preferences: buildPreferences(), convertToTrip: { option in convertOptionToTrip(option) @@ -126,6 +127,17 @@ struct TripWizardView: View { let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) }) let stadiumsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.stadiums.map { ($0.id, $0) }) + // Build RichGame dictionary for display + var richGamesDict: [String: RichGame] = [:] + for game in games { + if let homeTeam = teamsById[game.homeTeamId], + let awayTeam = teamsById[game.awayTeamId], + let stadium = stadiumsById[game.stadiumId] { + let richGame = RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) + richGamesDict[game.id] = richGame + } + } + // Build planning request let request = PlanningRequest( preferences: preferences, @@ -144,6 +156,7 @@ struct TripWizardView: View { showError = true } else { tripOptions = options + gamesForDisplay = richGamesDict showTripOptions = true } case .failure(let failure):