From d2b530b06f514e55fad44777d7ad0891e397079e Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 12 Jan 2026 20:49:28 -0600 Subject: [PATCH] feat(wizard): add TripWizardView container with progressive reveal - Auto-scroll to newly revealed sections - Integration with TripPlanningEngine - Navigation to TripOptionsView on success - Error handling with alerts Co-Authored-By: Claude Opus 4.5 --- .../Trip/Views/Wizard/TripWizardView.swift | 232 ++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift diff --git a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift new file mode 100644 index 0000000..40edcf4 --- /dev/null +++ b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift @@ -0,0 +1,232 @@ +// +// TripWizardView.swift +// SportsTime +// +// Progressive-reveal 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 planningError: String? + @State private var showError = false + + var body: some View { + NavigationStack { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: Theme.Spacing.lg) { + // Step 1: Planning Mode (always visible) + PlanningModeStep(selection: $viewModel.planningMode) + .id("planningMode") + + // Step 2: Sports (after mode selected) + if viewModel.isSportsStepVisible { + SportsStep( + selectedSports: $viewModel.selectedSports, + sportAvailability: viewModel.sportAvailability, + isLoading: viewModel.isLoadingSportAvailability, + canSelectSport: viewModel.canSelectSport + ) + .id("sports") + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + // Step 3: Dates (after sport selected) + if viewModel.isDatesStepVisible { + DatesStep( + startDate: $viewModel.startDate, + endDate: $viewModel.endDate, + hasSetDates: $viewModel.hasSetDates, + onDatesChanged: { + Task { + await viewModel.fetchSportAvailability() + } + } + ) + .id("dates") + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + // Step 4: Regions (after dates set) + if viewModel.isRegionsStepVisible { + RegionsStep(selectedRegions: $viewModel.selectedRegions) + .id("regions") + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + // Step 5: Route Preference (after regions selected) + if viewModel.isRoutePreferenceStepVisible { + RoutePreferenceStep( + routePreference: $viewModel.routePreference, + hasSetRoutePreference: $viewModel.hasSetRoutePreference + ) + .id("routePreference") + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + // Step 6: Repeat Cities (after route preference) + if viewModel.isRepeatCitiesStepVisible { + RepeatCitiesStep( + allowRepeatCities: $viewModel.allowRepeatCities, + hasSetRepeatCities: $viewModel.hasSetRepeatCities + ) + .id("repeatCities") + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + // Step 7: Must Stops (after repeat cities) + if viewModel.isMustStopsStepVisible { + MustStopsStep(mustStopLocations: $viewModel.mustStopLocations) + .id("mustStops") + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + + // Step 8: Review (after must stops visible) + if viewModel.isReviewStepVisible { + 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, + onPlan: { Task { await planTrip() } } + ) + .id("review") + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .padding(Theme.Spacing.md) + .animation(.easeInOut(duration: 0.3), value: viewModel.revealState) + } + .onChange(of: viewModel.revealState) { _, _ in + // Auto-scroll to newly revealed section + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + withAnimation { + scrollToLatestStep(proxy: proxy) + } + } + } + } + .themedBackground() + .navigationTitle("Plan a Trip") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .navigationDestination(isPresented: $showTripOptions) { + TripOptionsView( + options: tripOptions, + games: [:], + preferences: buildPreferences(), + convertToTrip: { _ in nil } + ) + } + .alert("Planning Error", isPresented: $showError) { + Button("OK") { showError = false } + } message: { + Text(planningError ?? "An unknown error occurred") + } + } + } + + // MARK: - Auto-scroll + + private func scrollToLatestStep(proxy: ScrollViewProxy) { + if viewModel.isReviewStepVisible { + proxy.scrollTo("review", anchor: .top) + } else if viewModel.isMustStopsStepVisible { + proxy.scrollTo("mustStops", anchor: .top) + } else if viewModel.isRepeatCitiesStepVisible { + proxy.scrollTo("repeatCities", anchor: .top) + } else if viewModel.isRoutePreferenceStepVisible { + proxy.scrollTo("routePreference", anchor: .top) + } else if viewModel.isRegionsStepVisible { + proxy.scrollTo("regions", anchor: .top) + } else if viewModel.isDatesStepVisible { + proxy.scrollTo("dates", anchor: .top) + } else if viewModel.isSportsStepVisible { + proxy.scrollTo("sports", anchor: .top) + } + } + + // MARK: - Planning + + private func planTrip() async { + viewModel.isPlanning = true + defer { viewModel.isPlanning = false } + + do { + let preferences = buildPreferences() + + // Fetch games for selected sports and date range + let games = try await AppDataProvider.shared.filterGames( + sports: preferences.sports, + startDate: preferences.startDate, + endDate: preferences.endDate + ) + + // Build planning request + let request = PlanningRequest( + preferences: preferences, + availableGames: games, + teams: AppDataProvider.shared.teams, + stadiums: AppDataProvider.shared.stadiums + ) + + // Run planning engine + let result = await TripPlanningEngine.shared.plan(request: request) + + await MainActor.run { + 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 + } else { + tripOptions = options + showTripOptions = true + } + case .failure(let failure): + planningError = failure.message + showError = true + } + } + } catch { + await MainActor.run { + planningError = error.localizedDescription + showError = true + } + } + } + + private func buildPreferences() -> TripPreferences { + TripPreferences( + planningMode: viewModel.planningMode ?? .dateRange, + sports: viewModel.selectedSports, + startDate: viewModel.startDate, + endDate: viewModel.endDate, + mustStopLocations: viewModel.mustStopLocations, + routePreference: viewModel.routePreference, + allowRepeatCities: viewModel.allowRepeatCities, + selectedRegions: viewModel.selectedRegions + ) + } +} + +// MARK: - Preview + +#Preview { + TripWizardView() +}