Files
Sportstime/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift
Trey t a4e9327b18 fix: restrict By Route wizard to stadium cities and filter sports by selected cities
LocationSearchSheet now shows only stadium cities (with sport badges) when
selecting start/end locations, preventing users from picking cities with no
stadiums. TripWizardViewModel filters available sports to the union of sports
at the selected cities, and clears invalid selections when locations change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 22:47:46 -06:00

430 lines
19 KiB
Swift

//
// TripWizardView.swift
// SportsTime
//
// Wizard for trip creation.
//
import SwiftUI
struct TripWizardView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@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
private let planningEngine = TripPlanningEngine()
/// Selected team name for display in ReviewStep
private var selectedTeamName: String? {
guard let teamId = viewModel.selectedTeamId else { return nil }
return AppDataProvider.shared.teams.first { $0.id == teamId }?.fullName
}
var body: some View {
NavigationStack {
GeometryReader { geometry in
ScrollViewReader { proxy in
ScrollView(.vertical) {
VStack(spacing: Theme.Spacing.lg) {
// Step 1: Planning Mode (always visible)
PlanningModeStep(selection: $viewModel.planningMode)
// All other steps appear together after planning mode selected
if viewModel.areStepsVisible {
Group {
// Mode-specific steps
if viewModel.showGamePickerStep {
GamePickerStep(
selectedSports: $viewModel.gamePickerSports,
selectedTeamIds: $viewModel.gamePickerTeamIds,
selectedGameIds: $viewModel.selectedGameIds,
startDate: $viewModel.startDate,
endDate: $viewModel.endDate
)
}
if viewModel.showTeamPickerStep {
TeamPickerStep(
selectedSport: $viewModel.teamPickerSport,
selectedTeamId: $viewModel.selectedTeamId
)
}
if viewModel.showLocationsStep {
LocationsStep(
startLocation: $viewModel.startLocation,
endLocation: $viewModel.endLocation
)
.onChange(of: viewModel.startLocation) { _, _ in
viewModel.validateSportsForLocations()
}
.onChange(of: viewModel.endLocation) { _, _ in
viewModel.validateSportsForLocations()
}
}
if viewModel.showTeamFirstStep {
TeamFirstWizardStep(
selectedSport: $viewModel.teamFirstSport,
selectedTeamIds: $viewModel.teamFirstSelectedTeamIds
)
}
// Common steps (conditionally shown)
if viewModel.showDatesStep {
DatesStep(
startDate: $viewModel.startDate,
endDate: $viewModel.endDate,
hasSetDates: $viewModel.hasSetDates,
onDatesChanged: {
Task {
await viewModel.fetchSportAvailability()
}
}
)
}
if viewModel.showSportsStep {
SportsStep(
selectedSports: $viewModel.selectedSports,
sportAvailability: viewModel.sportAvailability,
isLoading: viewModel.isLoadingSportAvailability,
canSelectSport: viewModel.canSelectSport
)
}
if viewModel.showRegionsStep {
RegionsStep(selectedRegions: $viewModel.selectedRegions)
}
// Always shown steps
RoutePreferenceStep(
routePreference: $viewModel.routePreference,
hasSetRoutePreference: $viewModel.hasSetRoutePreference
)
RepeatCitiesStep(
allowRepeatCities: $viewModel.allowRepeatCities,
hasSetRepeatCities: $viewModel.hasSetRepeatCities
)
MustStopsStep(mustStopLocations: $viewModel.mustStopLocations)
ReviewStep(
planningMode: viewModel.planningMode ?? .dateRange,
selectedSports: viewModel.selectedSports,
startDate: viewModel.startDate,
endDate: viewModel.endDate,
selectedRegions: viewModel.selectedRegions,
routePreference: viewModel.routePreference,
allowRepeatCities: viewModel.allowRepeatCities,
mustStopLocations: viewModel.mustStopLocations,
isPlanning: viewModel.isPlanning,
canPlanTrip: viewModel.canPlanTrip,
fieldValidation: viewModel.fieldValidation,
onPlan: { Task { await planTrip() } },
selectedGameCount: viewModel.selectedGameIds.count,
selectedTeamName: selectedTeamName,
startLocationName: viewModel.startLocation?.name,
endLocationName: viewModel.endLocation?.name,
teamFirstTeamCount: viewModel.teamFirstSelectedTeamIds.count
)
}
.transition(.opacity)
}
Color.clear
.frame(height: 1)
.id("wizardBottom")
}
.padding(Theme.Spacing.md)
.frame(width: geometry.size.width)
.animation(Theme.Animation.prefersReducedMotion ? .none : .easeInOut(duration: 0.2), value: viewModel.areStepsVisible)
}
#if DEBUG
.onChange(of: viewModel.planningMode) { _, newMode in
if newMode == .gameFirst && UserDefaults.standard.bool(forKey: "marketingVideoMode") {
marketingAutoFill(proxy: proxy)
}
}
#endif
}
}
.themedBackground()
.navigationTitle("Plan a Trip")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.navigationDestination(isPresented: $showTripOptions) {
TripOptionsView(
options: tripOptions,
games: gamesForDisplay,
preferences: buildPreferences(),
convertToTrip: { option in
convertOptionToTrip(option)
}
)
}
.alert("Planning Error", isPresented: $showError) {
Button("OK") { showError = false }
} message: {
Text(planningError ?? "An unknown error occurred")
}
}
}
// MARK: - Planning
private func planTrip() async {
let mode = viewModel.planningMode?.rawValue ?? "unknown"
AnalyticsManager.shared.track(.tripWizardStarted(mode: mode))
viewModel.isPlanning = true
defer { viewModel.isPlanning = false }
do {
var preferences = buildPreferences()
// Build dictionaries from arrays
let teamsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.teams.map { ($0.id, $0) })
let stadiumsById = Dictionary(uniqueKeysWithValues: AppDataProvider.shared.stadiums.map { ($0.id, $0) })
// For gameFirst mode, use the UI-selected date range (set by GamePickerStep)
// The date range is a 7-day span centered on the selected game(s)
var games: [Game]
if viewModel.planningMode == .teamFirst {
// Team-First mode: fetch ALL games for the season
// ScenarioEPlanner will generate sliding windows across the full season
games = try await AppDataProvider.shared.allGames(for: preferences.sports)
print("🔍 TripWizard: Team-First mode - fetched \(games.count) games for \(preferences.sports)")
} else if viewModel.planningMode == .gameFirst && !viewModel.selectedGameIds.isEmpty {
// Fetch all games for the selected sports within the UI date range
// GamePickerStep already set viewModel.startDate/endDate to a 7-day span
let allGames = try await AppDataProvider.shared.allGames(for: preferences.sports)
// Validate that selected must-see games exist
let mustSeeGames = allGames.filter { viewModel.selectedGameIds.contains($0.id) }
if mustSeeGames.isEmpty {
planningError = "Could not find the selected games. Please try again."
showError = true
return
}
// Use the UI-selected date range (already set by GamePickerStep to 7-day span)
// Filter all games within this range - ScenarioBPlanner will use anchor games
// as required stops and add bonus games that fit geographically
let rangeStart = Calendar.current.startOfDay(for: preferences.startDate)
let rangeEnd = Calendar.current.date(bySettingHour: 23, minute: 59, second: 59, of: preferences.endDate) ?? preferences.endDate
games = allGames.filter {
$0.dateTime >= rangeStart && $0.dateTime <= rangeEnd
}
} else {
// Standard mode: fetch games for date range
games = try await AppDataProvider.shared.filterGames(
sports: preferences.sports,
startDate: preferences.startDate,
endDate: preferences.endDate
)
}
// 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,
availableGames: games,
teams: teamsById,
stadiums: stadiumsById
)
// Run planning engine
let result = planningEngine.planItineraries(request: request)
switch result {
case .success(let options):
if options.isEmpty {
planningError = "No valid trip options found for your criteria. Try expanding your date range or regions."
showError = true
AnalyticsManager.shared.track(.tripPlanFailed(mode: mode, error: "no_options_found"))
} else {
tripOptions = options
gamesForDisplay = richGamesDict
showTripOptions = true
if let first = options.first {
AnalyticsManager.shared.track(.tripPlanned(
sportCount: viewModel.selectedSports.count,
stopCount: first.stops.count,
dayCount: first.stops.count,
mode: mode
))
}
}
case .failure(let failure):
planningError = failure.message
showError = true
AnalyticsManager.shared.track(.tripPlanFailed(mode: mode, error: failure.message))
}
} catch {
planningError = error.localizedDescription
showError = true
AnalyticsManager.shared.track(.tripPlanFailed(mode: mode, error: error.localizedDescription))
}
}
private func buildPreferences() -> TripPreferences {
// Determine which sports to use based on mode
let sports: Set<Sport>
if viewModel.planningMode == .gameFirst {
sports = viewModel.gamePickerSports
} else if viewModel.planningMode == .followTeam, let sport = viewModel.teamPickerSport {
sports = [sport]
} else if viewModel.planningMode == .teamFirst, let sport = viewModel.teamFirstSport {
sports = [sport]
} else {
sports = viewModel.selectedSports
}
return TripPreferences(
planningMode: viewModel.planningMode ?? .dateRange,
startLocation: viewModel.startLocation,
endLocation: viewModel.endLocation,
sports: sports,
mustSeeGameIds: viewModel.selectedGameIds,
startDate: viewModel.startDate,
endDate: viewModel.endDate,
mustStopLocations: viewModel.mustStopLocations,
routePreference: viewModel.routePreference,
allowRepeatCities: viewModel.allowRepeatCities,
selectedRegions: viewModel.selectedRegions,
followTeamId: viewModel.selectedTeamId,
selectedTeamIds: viewModel.teamFirstSelectedTeamIds
)
}
private func convertOptionToTrip(_ option: ItineraryOption) -> Trip {
let preferences = buildPreferences()
// 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: "")
}
// MARK: - Marketing Video Auto-Fill
#if DEBUG
private func marketingAutoFill(proxy: ScrollViewProxy) {
// Pre-fetch data off the main thread, then run a clean sequential fill
Task {
let astros = AppDataProvider.shared.teams.first { $0.fullName.contains("Astros") }
let allGames = try? await AppDataProvider.shared.allGames(for: [.mlb])
let astrosGames = (allGames ?? []).filter {
$0.homeTeamId == astros?.id || $0.awayTeamId == astros?.id
}
let pickedGames = Array(astrosGames.shuffled().prefix(3))
let pickedIds = Set(pickedGames.map { $0.id })
// Sequential fill with generous pauses no competing animations
await MainActor.run {
// Step 1: Select MLB
viewModel.gamePickerSports = [.mlb]
}
try? await Task.sleep(for: .seconds(1.5))
await MainActor.run {
// Step 2: Select Astros
if let astros {
viewModel.gamePickerTeamIds = [astros.id]
}
}
try? await Task.sleep(for: .seconds(1.5))
await MainActor.run {
// Step 3: Pick games + set date range
if !pickedIds.isEmpty {
viewModel.selectedGameIds = pickedIds
if let earliest = pickedGames.map({ $0.dateTime }).min(),
let latest = pickedGames.map({ $0.dateTime }).max() {
viewModel.startDate = Calendar.current.date(byAdding: .day, value: -1, to: earliest) ?? earliest
viewModel.endDate = Calendar.current.date(byAdding: .day, value: 1, to: latest) ?? latest
}
}
}
try? await Task.sleep(for: .seconds(1.5))
await MainActor.run {
// Step 4: Balanced route
viewModel.routePreference = .balanced
viewModel.hasSetRoutePreference = true
}
try? await Task.sleep(for: .seconds(1.5))
await MainActor.run {
// Step 5: Allow repeat cities
viewModel.allowRepeatCities = true
viewModel.hasSetRepeatCities = true
}
try? await Task.sleep(for: .seconds(0.5))
// Single smooth scroll to bottom after everything is laid out
await MainActor.run {
withAnimation(.easeInOut(duration: 5.0)) {
proxy.scrollTo("wizardBottom", anchor: .bottom)
}
}
}
}
#endif
}
// MARK: - Preview
#Preview {
TripWizardView()
}