diff --git a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift index 2eddeec..6009ed6 100644 --- a/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift +++ b/SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift @@ -2,7 +2,7 @@ // TripWizardViewModel.swift // SportsTime // -// ViewModel for progressive-reveal trip wizard. +// ViewModel for trip wizard. // import Foundation @@ -16,7 +16,7 @@ final class TripWizardViewModel { var planningMode: PlanningMode? = nil { didSet { if oldValue != nil && oldValue != planningMode { - resetDownstreamFromPlanningMode() + resetAllSelections() } } } @@ -58,49 +58,23 @@ final class TripWizardViewModel { var sportAvailability: [Sport: Bool] = [:] var isLoadingSportAvailability: Bool = false - // MARK: - Reveal State (computed) + // MARK: - Visibility - var isPlanningModeStepVisible: Bool { true } - - var isDatesStepVisible: Bool { + /// All steps visible once planning mode is selected + var areStepsVisible: Bool { planningMode != nil } - var isSportsStepVisible: Bool { - isDatesStepVisible && hasSetDates - } + // MARK: - Validation - var isRegionsStepVisible: Bool { - isSportsStepVisible && !selectedSports.isEmpty - } - - var isRoutePreferenceStepVisible: Bool { - isRegionsStepVisible && !selectedRegions.isEmpty - } - - var isRepeatCitiesStepVisible: Bool { - isRoutePreferenceStepVisible && hasSetRoutePreference - } - - var isMustStopsStepVisible: Bool { - isRepeatCitiesStepVisible && hasSetRepeatCities - } - - var isReviewStepVisible: Bool { - isMustStopsStepVisible - } - - /// Combined state for animation tracking - var revealState: Int { - var state = 0 - if isSportsStepVisible { state += 1 } - if isDatesStepVisible { state += 2 } - if isRegionsStepVisible { state += 4 } - if isRoutePreferenceStepVisible { state += 8 } - if isRepeatCitiesStepVisible { state += 16 } - if isMustStopsStepVisible { state += 32 } - if isReviewStepVisible { state += 64 } - return state + /// All required fields must be set before planning + var canPlanTrip: Bool { + planningMode != nil && + hasSetDates && + !selectedSports.isEmpty && + !selectedRegions.isEmpty && + hasSetRoutePreference && + hasSetRepeatCities } // MARK: - Sport Availability @@ -137,7 +111,7 @@ final class TripWizardViewModel { // MARK: - Reset Logic - private func resetDownstreamFromPlanningMode() { + private func resetAllSelections() { selectedSports = [] hasSetDates = false selectedRegions = [] diff --git a/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift b/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift index b42cb6a..4220152 100644 --- a/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift +++ b/SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift @@ -18,6 +18,7 @@ struct ReviewStep: View { let allowRepeatCities: Bool let mustStopLocations: [LocationInput] let isPlanning: Bool + let canPlanTrip: Bool let onPlan: () -> Void var body: some View { @@ -55,11 +56,11 @@ struct ReviewStep: View { } .frame(maxWidth: .infinity) .padding(Theme.Spacing.md) - .background(Theme.warmOrange) + .background(canPlanTrip ? Theme.warmOrange : Theme.textMuted(colorScheme)) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) } - .disabled(isPlanning) + .disabled(!canPlanTrip || isPlanning) } .padding(Theme.Spacing.lg) .background(Theme.cardBackground(colorScheme)) @@ -113,6 +114,7 @@ private struct ReviewRow: View { allowRepeatCities: false, mustStopLocations: [], isPlanning: false, + canPlanTrip: true, onPlan: {} ) .padding() diff --git a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift index b305c00..a558e47 100644 --- a/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift +++ b/SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift @@ -2,7 +2,7 @@ // TripWizardView.swift // SportsTime // -// Progressive-reveal wizard for trip creation. +// Wizard for trip creation. // import SwiftUI @@ -20,15 +20,14 @@ struct TripWizardView: View { 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") + ScrollView { + VStack(spacing: Theme.Spacing.lg) { + // Step 1: Planning Mode (always visible) + PlanningModeStep(selection: $viewModel.planningMode) - // Step 2: Dates (after mode selected) - if viewModel.isDatesStepVisible { + // All other steps appear together after planning mode selected + if viewModel.areStepsVisible { + Group { DatesStep( startDate: $viewModel.startDate, endDate: $viewModel.endDate, @@ -39,58 +38,28 @@ struct TripWizardView: View { } } ) - .id("dates") - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - // Step 3: Sports (after dates set) - 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 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, @@ -101,26 +70,15 @@ struct TripWizardView: View { allowRepeatCities: viewModel.allowRepeatCities, mustStopLocations: viewModel.mustStopLocations, isPlanning: viewModel.isPlanning, + canPlanTrip: viewModel.canPlanTrip, onPlan: { Task { await planTrip() } } ) - .id("review") - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } - .padding(Theme.Spacing.md) - .animation(.easeInOut(duration: 0.15), value: viewModel.revealState) - } - .onChange(of: viewModel.revealState) { _, newState in - // Auto-scroll to newly revealed section after a delay - // to avoid interrupting user interactions - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { - // Only scroll if state hasn't changed (user stopped interacting) - guard viewModel.revealState == newState else { return } - withAnimation(.easeInOut(duration: 0.25)) { - scrollToLatestStep(proxy: proxy) } + .transition(.opacity) } } + .padding(Theme.Spacing.md) + .animation(.easeInOut(duration: 0.2), value: viewModel.areStepsVisible) } .themedBackground() .navigationTitle("Plan a Trip") @@ -148,26 +106,6 @@ struct TripWizardView: View { } } - // 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.isSportsStepVisible { - proxy.scrollTo("sports", anchor: .top) - } else if viewModel.isDatesStepVisible { - proxy.scrollTo("dates", anchor: .top) - } - } - // MARK: - Planning private func planTrip() async { diff --git a/SportsTimeTests/Trip/TripWizardViewModelTests.swift b/SportsTimeTests/Trip/TripWizardViewModelTests.swift index 3a77d18..1eaceb7 100644 --- a/SportsTimeTests/Trip/TripWizardViewModelTests.swift +++ b/SportsTimeTests/Trip/TripWizardViewModelTests.swift @@ -8,122 +8,90 @@ import XCTest final class TripWizardViewModelTests: XCTestCase { - func test_initialState_onlyPlanningModeStepVisible() { + // MARK: - Visibility Tests + + func test_initialState_stepsNotVisible() { let viewModel = TripWizardViewModel() - XCTAssertTrue(viewModel.isPlanningModeStepVisible) - XCTAssertFalse(viewModel.isSportsStepVisible) - XCTAssertFalse(viewModel.isDatesStepVisible) - XCTAssertFalse(viewModel.isRegionsStepVisible) + XCTAssertFalse(viewModel.areStepsVisible) } - func test_selectingPlanningMode_revealsSportsStep() { + func test_selectingPlanningMode_revealsAllSteps() { let viewModel = TripWizardViewModel() viewModel.planningMode = .dateRange - XCTAssertTrue(viewModel.isSportsStepVisible) + XCTAssertTrue(viewModel.areStepsVisible) } - func test_selectingSport_revealsDatesStep() { - let viewModel = TripWizardViewModel() - viewModel.planningMode = .dateRange + // MARK: - canPlanTrip Validation Tests + func test_canPlanTrip_initiallyFalse() { + let viewModel = TripWizardViewModel() + + XCTAssertFalse(viewModel.canPlanTrip) + } + + func test_canPlanTrip_requiresAllFields() { + let viewModel = TripWizardViewModel() + + // None set + XCTAssertFalse(viewModel.canPlanTrip) + + // Only planning mode + viewModel.planningMode = .dateRange + XCTAssertFalse(viewModel.canPlanTrip) + + // Add dates + viewModel.hasSetDates = true + XCTAssertFalse(viewModel.canPlanTrip) + + // Add sports viewModel.selectedSports = [.mlb] + XCTAssertFalse(viewModel.canPlanTrip) - XCTAssertTrue(viewModel.isDatesStepVisible) + // Add regions + viewModel.selectedRegions = [.east] + XCTAssertFalse(viewModel.canPlanTrip) + + // Add route preference + viewModel.hasSetRoutePreference = true + XCTAssertFalse(viewModel.canPlanTrip) + + // Add repeat cities - now all required fields are set + viewModel.hasSetRepeatCities = true + XCTAssertTrue(viewModel.canPlanTrip) } - func test_changingPlanningMode_resetsDownstreamSelections() { + func test_canPlanTrip_trueWhenAllFieldsSet() { let viewModel = TripWizardViewModel() viewModel.planningMode = .dateRange + viewModel.hasSetDates = true viewModel.selectedSports = [.mlb, .nba] - viewModel.hasSetDates = true - - viewModel.planningMode = .gameFirst - - XCTAssertTrue(viewModel.selectedSports.isEmpty) - XCTAssertFalse(viewModel.hasSetDates) - } - - // MARK: - Full Flow Integration Tests - - func test_fullWizardFlow_reachesReviewStep() { - let viewModel = TripWizardViewModel() - - // Step 1: Select planning mode - viewModel.planningMode = .dateRange - XCTAssertTrue(viewModel.isSportsStepVisible) - - // Step 2: Select sport - viewModel.selectedSports = [.mlb] - XCTAssertTrue(viewModel.isDatesStepVisible) - - // Step 3: Set dates - viewModel.hasSetDates = true - XCTAssertTrue(viewModel.isRegionsStepVisible) - - // Step 4: Select regions - viewModel.selectedRegions = [.east] - XCTAssertTrue(viewModel.isRoutePreferenceStepVisible) - - // Step 5: Set route preference + viewModel.selectedRegions = [.east, .central] viewModel.hasSetRoutePreference = true - XCTAssertTrue(viewModel.isRepeatCitiesStepVisible) - - // Step 6: Set repeat cities preference viewModel.hasSetRepeatCities = true - XCTAssertTrue(viewModel.isMustStopsStepVisible) - XCTAssertTrue(viewModel.isReviewStepVisible) + + XCTAssertTrue(viewModel.canPlanTrip) } - func test_regionSelection_revealsRoutePreference() { + func test_canPlanTrip_mustStopsOptional() { let viewModel = TripWizardViewModel() viewModel.planningMode = .dateRange + viewModel.hasSetDates = true viewModel.selectedSports = [.nba] - viewModel.hasSetDates = true - - XCTAssertFalse(viewModel.isRoutePreferenceStepVisible) - - viewModel.selectedRegions = [.central, .west] - - XCTAssertTrue(viewModel.isRoutePreferenceStepVisible) - } - - func test_routePreference_revealsRepeatCities() { - let viewModel = TripWizardViewModel() - viewModel.planningMode = .dateRange - viewModel.selectedSports = [.mlb] - viewModel.hasSetDates = true - viewModel.selectedRegions = [.east] - - XCTAssertFalse(viewModel.isRepeatCitiesStepVisible) - + viewModel.selectedRegions = [.west] viewModel.hasSetRoutePreference = true - - XCTAssertTrue(viewModel.isRepeatCitiesStepVisible) - } - - func test_repeatCities_revealsMustStopsAndReview() { - let viewModel = TripWizardViewModel() - viewModel.planningMode = .dateRange - viewModel.selectedSports = [.mlb] - viewModel.hasSetDates = true - viewModel.selectedRegions = [.east] - viewModel.hasSetRoutePreference = true - - XCTAssertFalse(viewModel.isMustStopsStepVisible) - XCTAssertFalse(viewModel.isReviewStepVisible) - viewModel.hasSetRepeatCities = true - XCTAssertTrue(viewModel.isMustStopsStepVisible) - XCTAssertTrue(viewModel.isReviewStepVisible) + // canPlanTrip should be true even without must stops + XCTAssertTrue(viewModel.canPlanTrip) + XCTAssertTrue(viewModel.mustStopLocations.isEmpty) } // MARK: - Reset Behavior Tests - func test_changingPlanningMode_resetsAllDownstreamState() { + func test_changingPlanningMode_resetsAllSelections() { let viewModel = TripWizardViewModel() // Set up full wizard state @@ -138,7 +106,7 @@ final class TripWizardViewModelTests: XCTestCase { // Change planning mode viewModel.planningMode = .locations - // Verify all downstream state is reset + // Verify all state is reset XCTAssertTrue(viewModel.selectedSports.isEmpty) XCTAssertFalse(viewModel.hasSetDates) XCTAssertTrue(viewModel.selectedRegions.isEmpty) @@ -161,77 +129,6 @@ final class TripWizardViewModelTests: XCTestCase { XCTAssertTrue(viewModel.hasSetDates) } - // MARK: - Edge Cases - - func test_emptyRegionSelection_hidesRoutePreference() { - let viewModel = TripWizardViewModel() - viewModel.planningMode = .dateRange - viewModel.selectedSports = [.mlb] - viewModel.hasSetDates = true - viewModel.selectedRegions = [.west] - XCTAssertTrue(viewModel.isRoutePreferenceStepVisible) - - viewModel.selectedRegions = [] - - XCTAssertFalse(viewModel.isRoutePreferenceStepVisible) - } - - func test_multipleSports_canBeSelected() { - let viewModel = TripWizardViewModel() - viewModel.planningMode = .dateRange - - viewModel.selectedSports = [.mlb, .nba, .nhl] - - XCTAssertEqual(viewModel.selectedSports.count, 3) - XCTAssertTrue(viewModel.isDatesStepVisible) - } - - func test_multipleRegions_canBeSelected() { - let viewModel = TripWizardViewModel() - viewModel.planningMode = .dateRange - viewModel.selectedSports = [.mlb] - viewModel.hasSetDates = true - - viewModel.selectedRegions = [.east, .central, .west] - - XCTAssertEqual(viewModel.selectedRegions.count, 3) - XCTAssertTrue(viewModel.isRoutePreferenceStepVisible) - } - - // MARK: - Reveal State Bitmask Tests - - func test_revealState_initialValue() { - let viewModel = TripWizardViewModel() - - XCTAssertEqual(viewModel.revealState, 0) - } - - func test_revealState_incrementsWithProgress() { - let viewModel = TripWizardViewModel() - - viewModel.planningMode = .dateRange - XCTAssertEqual(viewModel.revealState, 1) // Sports visible - - viewModel.selectedSports = [.mlb] - XCTAssertEqual(viewModel.revealState, 3) // Sports + Dates - - viewModel.hasSetDates = true - XCTAssertEqual(viewModel.revealState, 7) // Sports + Dates + Regions - } - - func test_revealState_fullFlow() { - let viewModel = TripWizardViewModel() - viewModel.planningMode = .dateRange - viewModel.selectedSports = [.mlb] - viewModel.hasSetDates = true - viewModel.selectedRegions = [.east] - viewModel.hasSetRoutePreference = true - viewModel.hasSetRepeatCities = true - - // 1 + 2 + 4 + 8 + 16 + 32 + 64 = 127 - XCTAssertEqual(viewModel.revealState, 127) - } - // MARK: - Sport Availability Tests func test_canSelectSport_defaultsToTrue() { @@ -251,6 +148,24 @@ final class TripWizardViewModelTests: XCTestCase { XCTAssertTrue(viewModel.canSelectSport(.nhl)) } + // MARK: - Multi-Selection Tests + + func test_multipleSports_canBeSelected() { + let viewModel = TripWizardViewModel() + + viewModel.selectedSports = [.mlb, .nba, .nhl] + + XCTAssertEqual(viewModel.selectedSports.count, 3) + } + + func test_multipleRegions_canBeSelected() { + let viewModel = TripWizardViewModel() + + viewModel.selectedRegions = [.east, .central, .west] + + XCTAssertEqual(viewModel.selectedRegions.count, 3) + } + // MARK: - Planning State Tests func test_isPlanning_defaultsToFalse() {