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