// // 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()) }