# Trip Planning Enhancements Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Transform trip creation into a progressive-reveal wizard flow where sections appear as the user makes selections, while keeping everything on a single scrolling screen. **Architecture:** New `TripWizardView` container with `TripWizardViewModel` manages step visibility state. Each step is a small, focused view component (~100-150 lines). Existing `TripCreationViewModel` logic is reused—wizard just controls reveal timing. Settings toggle allows power users to use classic form. **Tech Stack:** SwiftUI, @Observable, existing TripCreationViewModel, AppDataProvider --- ## Task 1: Create TripWizardViewModel **Files:** - Create: `SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift` - Test: `SportsTimeTests/Trip/TripWizardViewModelTests.swift` **Step 1: Write the failing test for reveal state tracking** ```swift // SportsTimeTests/Trip/TripWizardViewModelTests.swift import XCTest @testable import SportsTime final class TripWizardViewModelTests: XCTestCase { func test_initialState_onlyPlanningModeStepVisible() { let viewModel = TripWizardViewModel() XCTAssertTrue(viewModel.isPlanningModeStepVisible) XCTAssertFalse(viewModel.isSportsStepVisible) XCTAssertFalse(viewModel.isDatesStepVisible) XCTAssertFalse(viewModel.isRegionsStepVisible) } func test_selectingPlanningMode_revealsSportsStep() { let viewModel = TripWizardViewModel() viewModel.planningMode = .dateRange XCTAssertTrue(viewModel.isSportsStepVisible) } func test_selectingSport_revealsDatesStep() { let viewModel = TripWizardViewModel() viewModel.planningMode = .dateRange viewModel.selectedSports = [.mlb] XCTAssertTrue(viewModel.isDatesStepVisible) } func test_changingPlanningMode_resetsDownstreamSelections() { let viewModel = TripWizardViewModel() viewModel.planningMode = .dateRange viewModel.selectedSports = [.mlb, .nba] viewModel.hasSetDates = true viewModel.planningMode = .gameFirst XCTAssertTrue(viewModel.selectedSports.isEmpty) XCTAssertFalse(viewModel.hasSetDates) } } ``` **Step 2: Run test to verify it fails** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripWizardViewModelTests test 2>&1 | tail -20` Expected: FAIL - file not found **Step 3: Create test directory and file** ```bash mkdir -p SportsTimeTests/Trip ``` Then create the test file with content from Step 1. **Step 4: Write minimal TripWizardViewModel implementation** ```swift // SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift import Foundation import SwiftUI @Observable final class TripWizardViewModel { // MARK: - Planning Mode var planningMode: PlanningMode? = nil { didSet { if oldValue != nil && oldValue != planningMode { resetDownstreamFromPlanningMode() } } } // MARK: - Sports Selection var selectedSports: Set = [] // MARK: - Dates var startDate: Date = Date() var endDate: Date = Date().addingTimeInterval(86400 * 7) var hasSetDates: Bool = false // MARK: - Regions var selectedRegions: Set = [] // MARK: - Route Preferences var routePreference: RoutePreference = .balanced var hasSetRoutePreference: Bool = false // MARK: - Repeat Cities var allowRepeatCities: Bool = false var hasSetRepeatCities: Bool = false // MARK: - Must Stops var mustStopLocations: [LocationInput] = [] // MARK: - Reveal State (computed) var isPlanningModeStepVisible: Bool { true } var isSportsStepVisible: Bool { planningMode != nil } var isDatesStepVisible: Bool { isSportsStepVisible && !selectedSports.isEmpty } var isRegionsStepVisible: Bool { isDatesStepVisible && hasSetDates } 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 } // MARK: - Reset Logic private func resetDownstreamFromPlanningMode() { selectedSports = [] hasSetDates = false selectedRegions = [] hasSetRoutePreference = false hasSetRepeatCities = false mustStopLocations = [] } } ``` **Step 5: Run test to verify it passes** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripWizardViewModelTests test 2>&1 | tail -20` Expected: PASS (4 tests) **Step 6: Commit** ```bash git add SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift SportsTimeTests/Trip/TripWizardViewModelTests.swift git commit -m "feat(wizard): add TripWizardViewModel with reveal state logic" ``` --- ## Task 2: Create PlanningModeStep Component **Files:** - Create: `SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift` **Step 1: Create directory structure** ```bash mkdir -p SportsTime/Features/Trip/Views/Wizard/Steps ``` **Step 2: Write PlanningModeStep view** ```swift // SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift import SwiftUI struct PlanningModeStep: View { @Binding var selection: PlanningMode? var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { StepHeader( title: "How do you want to plan?", subtitle: "Choose your starting point" ) VStack(spacing: Theme.Spacing.sm) { ForEach(PlanningMode.allCases) { mode in PlanningModeCard( mode: mode, isSelected: selection == mode, onTap: { selection = mode } ) } } } .padding(Theme.Spacing.md) .background(Theme.Colors.cardBackground) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium)) } } // MARK: - Planning Mode Card private struct PlanningModeCard: View { let mode: PlanningMode let isSelected: Bool let onTap: () -> Void var body: some View { Button(action: onTap) { HStack(spacing: Theme.Spacing.md) { Image(systemName: mode.iconName) .font(.title2) .foregroundStyle(isSelected ? Theme.Colors.primary : .secondary) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text(mode.displayName) .font(.headline) .foregroundStyle(.primary) Text(mode.description) .font(.caption) .foregroundStyle(.secondary) } Spacer() if isSelected { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Theme.Colors.primary) } } .padding(Theme.Spacing.md) .background(isSelected ? Theme.Colors.primary.opacity(0.1) : Color.clear) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small)) .overlay( RoundedRectangle(cornerRadius: Theme.Radius.small) .stroke(isSelected ? Theme.Colors.primary : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1) ) } .buttonStyle(.plain) } } // MARK: - Step Header (Reusable) struct StepHeader: View { let title: String var subtitle: String? = nil var body: some View { VStack(alignment: .leading, spacing: 4) { Text(title) .font(.title3) .fontWeight(.semibold) .foregroundStyle(.primary) if let subtitle { Text(subtitle) .font(.subheadline) .foregroundStyle(.secondary) } } } } // MARK: - PlanningMode Extensions extension PlanningMode { var iconName: String { switch self { case .dateRange: return "calendar" case .gameFirst: return "sportscourt.fill" case .locations: return "mappin.and.ellipse" case .followTeam: return "person.3.fill" } } var displayName: String { switch self { case .dateRange: return "By Date Range" case .gameFirst: return "By Games" case .locations: return "By Locations" case .followTeam: return "Follow a Team" } } var description: String { switch self { case .dateRange: return "Set dates first, find games in that window" case .gameFirst: return "Pick the games you want to see" case .locations: return "Choose start/end cities for your trip" case .followTeam: return "Follow one team's home and away games" } } } // MARK: - Preview #Preview { PlanningModeStep(selection: .constant(.dateRange)) .padding() .themedBackground() } ``` **Step 3: Build to verify compilation** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10` Expected: BUILD SUCCEEDED **Step 4: Commit** ```bash git add SportsTime/Features/Trip/Views/Wizard/Steps/PlanningModeStep.swift git commit -m "feat(wizard): add PlanningModeStep component" ``` --- ## Task 3: Create SportsStep Component with Availability Graying **Files:** - Create: `SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift` - Test: `SportsTimeTests/Trip/TripWizardViewModelTests.swift` (add sport availability tests) **Step 1: Add sport availability tests** Add to `TripWizardViewModelTests.swift`: ```swift func test_sportAvailability_sportsWithNoGamesAreUnavailable() async { let viewModel = TripWizardViewModel() viewModel.planningMode = .dateRange viewModel.startDate = Date() viewModel.endDate = Date().addingTimeInterval(86400 * 7) // Simulate fetching availability (MLB has games, WNBA doesn't in January) await viewModel.fetchSportAvailability() // This test verifies the mechanism exists - actual availability depends on data XCTAssertNotNil(viewModel.sportAvailability) } func test_selectingUnavailableSport_isNotAllowed() { let viewModel = TripWizardViewModel() viewModel.sportAvailability = [.mlb: true, .wnba: false] let canSelect = viewModel.canSelectSport(.wnba) XCTAssertFalse(canSelect) } ``` **Step 2: Run tests to verify they fail** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripWizardViewModelTests test 2>&1 | tail -20` Expected: FAIL - sportAvailability not defined **Step 3: Add sport availability to TripWizardViewModel** Add to `TripWizardViewModel.swift`: ```swift // MARK: - Sport Availability var sportAvailability: [Sport: Bool] = [:] var isLoadingSportAvailability: Bool = false func canSelectSport(_ sport: Sport) -> Bool { sportAvailability[sport] ?? true // Default to available if not checked } func fetchSportAvailability() async { guard hasSetDates else { return } isLoadingSportAvailability = true defer { isLoadingSportAvailability = false } var availability: [Sport: Bool] = [:] for sport in Sport.supported { do { let games = try await AppDataProvider.shared.filterGames( sports: [sport], startDate: startDate, endDate: endDate ) availability[sport] = !games.isEmpty } catch { availability[sport] = true // Default to available on error } } await MainActor.run { self.sportAvailability = availability } } ``` **Step 4: Run tests to verify they pass** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripWizardViewModelTests test 2>&1 | tail -20` Expected: PASS **Step 5: Write SportsStep view** ```swift // SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift import SwiftUI struct SportsStep: View { @Binding var selectedSports: Set let sportAvailability: [Sport: Bool] let isLoading: Bool let canSelectSport: (Sport) -> Bool private let columns = [ GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()) ] var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { StepHeader( title: "Which sports interest you?", subtitle: "Select one or more leagues" ) if isLoading { HStack { ProgressView() .scaleEffect(0.8) Text("Checking game availability...") .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, Theme.Spacing.sm) } LazyVGrid(columns: columns, spacing: Theme.Spacing.sm) { ForEach(Sport.supported, id: \.self) { sport in SportCard( sport: sport, isSelected: selectedSports.contains(sport), isAvailable: canSelectSport(sport), onTap: { if canSelectSport(sport) { toggleSport(sport) } } ) } } } .padding(Theme.Spacing.md) .background(Theme.Colors.cardBackground) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium)) } private func toggleSport(_ sport: Sport) { if selectedSports.contains(sport) { selectedSports.remove(sport) } else { selectedSports.insert(sport) } } } // MARK: - Sport Card private struct SportCard: View { let sport: Sport let isSelected: Bool let isAvailable: Bool let onTap: () -> Void var body: some View { Button(action: onTap) { VStack(spacing: Theme.Spacing.xs) { Image(systemName: sport.iconName) .font(.title2) .foregroundStyle(cardColor) Text(sport.rawValue) .font(.caption) .fontWeight(.medium) .foregroundStyle(cardColor) if !isAvailable { Text("No games") .font(.caption2) .foregroundStyle(.secondary) } } .frame(maxWidth: .infinity) .padding(.vertical, Theme.Spacing.md) .background(backgroundColor) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small)) .overlay( RoundedRectangle(cornerRadius: Theme.Radius.small) .stroke(borderColor, lineWidth: isSelected ? 2 : 1) ) } .buttonStyle(.plain) .opacity(isAvailable ? 1.0 : 0.5) .disabled(!isAvailable) } private var cardColor: Color { if !isAvailable { return .gray } return isSelected ? sport.color : .secondary } private var backgroundColor: Color { if !isAvailable { return Color.gray.opacity(0.1) } return isSelected ? sport.color.opacity(0.15) : Color.clear } private var borderColor: Color { if !isAvailable { return Color.gray.opacity(0.3) } return isSelected ? sport.color : Color.gray.opacity(0.3) } } // MARK: - Preview #Preview { SportsStep( selectedSports: .constant([.mlb]), sportAvailability: [.mlb: true, .nba: true, .wnba: false], isLoading: false, canSelectSport: { sport in sport != .wnba } ) .padding() .themedBackground() } ``` **Step 6: Build to verify compilation** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10` Expected: BUILD SUCCEEDED **Step 7: Commit** ```bash git add SportsTime/Features/Trip/Views/Wizard/Steps/SportsStep.swift SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift SportsTimeTests/Trip/TripWizardViewModelTests.swift git commit -m "feat(wizard): add SportsStep with availability graying" ``` --- ## Task 4: Create DatesStep Component **Files:** - Create: `SportsTime/Features/Trip/Views/Wizard/Steps/DatesStep.swift` **Step 1: Write DatesStep view** ```swift // SportsTime/Features/Trip/Views/Wizard/Steps/DatesStep.swift import SwiftUI struct DatesStep: View { @Binding var startDate: Date @Binding var endDate: Date @Binding var hasSetDates: Bool let onDatesChanged: () -> Void var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { StepHeader( title: "When would you like to travel?", subtitle: "Pick your trip dates" ) VStack(spacing: Theme.Spacing.md) { DatePicker( "Start Date", selection: $startDate, in: Date()..., displayedComponents: .date ) .datePickerStyle(.compact) .onChange(of: startDate) { _, newValue in // Ensure end date is after start date if endDate < newValue { endDate = newValue.addingTimeInterval(86400) } hasSetDates = true onDatesChanged() } DatePicker( "End Date", selection: $endDate, in: startDate..., displayedComponents: .date ) .datePickerStyle(.compact) .onChange(of: endDate) { _, _ in hasSetDates = true onDatesChanged() } } .padding(Theme.Spacing.sm) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small)) // Trip duration indicator HStack { Image(systemName: "calendar.badge.clock") .foregroundStyle(.secondary) Text(durationText) .font(.subheadline) .foregroundStyle(.secondary) } } .padding(Theme.Spacing.md) .background(Theme.Colors.cardBackground) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium)) } private var durationText: String { let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0 if days == 0 { return "Same day trip" } else if days == 1 { return "1 day trip" } else { return "\(days) day trip" } } } // MARK: - Preview #Preview { DatesStep( startDate: .constant(Date()), endDate: .constant(Date().addingTimeInterval(86400 * 5)), hasSetDates: .constant(true), onDatesChanged: {} ) .padding() .themedBackground() } ``` **Step 2: Build to verify compilation** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10` Expected: BUILD SUCCEEDED **Step 3: Commit** ```bash git add SportsTime/Features/Trip/Views/Wizard/Steps/DatesStep.swift git commit -m "feat(wizard): add DatesStep component" ``` --- ## Task 5: Create RegionsStep Component **Files:** - Create: `SportsTime/Features/Trip/Views/Wizard/Steps/RegionsStep.swift` **Step 1: Write RegionsStep view** ```swift // SportsTime/Features/Trip/Views/Wizard/Steps/RegionsStep.swift import SwiftUI struct RegionsStep: View { @Binding var selectedRegions: Set private let columns = [ GridItem(.flexible()), GridItem(.flexible()) ] var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { StepHeader( title: "Where do you want to go?", subtitle: "Select one or more regions" ) LazyVGrid(columns: columns, spacing: Theme.Spacing.sm) { ForEach(USRegion.allCases) { region in RegionCard( region: region, isSelected: selectedRegions.contains(region), onTap: { toggleRegion(region) } ) } } if !selectedRegions.isEmpty { HStack { Image(systemName: "mappin.circle.fill") .foregroundStyle(Theme.Colors.primary) Text("\(selectedRegions.count) region\(selectedRegions.count == 1 ? "" : "s") selected") .font(.subheadline) .foregroundStyle(.secondary) } } } .padding(Theme.Spacing.md) .background(Theme.Colors.cardBackground) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium)) } private func toggleRegion(_ region: USRegion) { if selectedRegions.contains(region) { selectedRegions.remove(region) } else { selectedRegions.insert(region) } } } // MARK: - Region Card private struct RegionCard: View { let region: USRegion let isSelected: Bool let onTap: () -> Void var body: some View { Button(action: onTap) { VStack(spacing: Theme.Spacing.xs) { Text(region.emoji) .font(.title) Text(region.displayName) .font(.caption) .fontWeight(.medium) .foregroundStyle(isSelected ? Theme.Colors.primary : .primary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(.vertical, Theme.Spacing.md) .background(isSelected ? Theme.Colors.primary.opacity(0.1) : Color.clear) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small)) .overlay( RoundedRectangle(cornerRadius: Theme.Radius.small) .stroke(isSelected ? Theme.Colors.primary : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1) ) } .buttonStyle(.plain) } } // MARK: - USRegion Extensions extension USRegion { var emoji: String { switch self { case .northeast: return "🗽" case .southeast: return "🌴" case .midwest: return "🌾" case .southwest: return "🌵" case .west: return "🌁" case .northwest: return "🌲" } } var displayName: String { switch self { case .northeast: return "Northeast" case .southeast: return "Southeast" case .midwest: return "Midwest" case .southwest: return "Southwest" case .west: return "West" case .northwest: return "Northwest" } } } // MARK: - Preview #Preview { RegionsStep(selectedRegions: .constant([.northeast, .midwest])) .padding() .themedBackground() } ``` **Step 2: Build to verify compilation** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10` Expected: BUILD SUCCEEDED **Step 3: Commit** ```bash git add SportsTime/Features/Trip/Views/Wizard/Steps/RegionsStep.swift git commit -m "feat(wizard): add RegionsStep component" ``` --- ## Task 6: Create RoutePreferenceStep Component **Files:** - Create: `SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift` **Step 1: Write RoutePreferenceStep view** ```swift // SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift import SwiftUI struct RoutePreferenceStep: View { @Binding var routePreference: RoutePreference @Binding var hasSetRoutePreference: Bool var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { StepHeader( title: "What's your route preference?", subtitle: "Balance efficiency vs. exploration" ) VStack(spacing: Theme.Spacing.sm) { ForEach(RoutePreference.allCases) { preference in RoutePreferenceCard( preference: preference, isSelected: routePreference == preference, onTap: { routePreference = preference hasSetRoutePreference = true } ) } } } .padding(Theme.Spacing.md) .background(Theme.Colors.cardBackground) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium)) } } // MARK: - Route Preference Card private struct RoutePreferenceCard: View { let preference: RoutePreference let isSelected: Bool let onTap: () -> Void var body: some View { Button(action: onTap) { HStack(spacing: Theme.Spacing.md) { Image(systemName: preference.iconName) .font(.title2) .foregroundStyle(isSelected ? Theme.Colors.primary : .secondary) .frame(width: 32) VStack(alignment: .leading, spacing: 2) { Text(preference.displayName) .font(.headline) .foregroundStyle(.primary) Text(preference.description) .font(.caption) .foregroundStyle(.secondary) } Spacer() if isSelected { Image(systemName: "checkmark.circle.fill") .foregroundStyle(Theme.Colors.primary) } } .padding(Theme.Spacing.md) .background(isSelected ? Theme.Colors.primary.opacity(0.1) : Color.clear) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small)) .overlay( RoundedRectangle(cornerRadius: Theme.Radius.small) .stroke(isSelected ? Theme.Colors.primary : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1) ) } .buttonStyle(.plain) } } // MARK: - RoutePreference Extensions extension RoutePreference { var iconName: String { switch self { case .efficient: return "bolt.fill" case .scenic: return "binoculars.fill" case .balanced: return "scale.3d" } } var displayName: String { switch self { case .efficient: return "Efficient" case .scenic: return "Scenic" case .balanced: return "Balanced" } } var description: String { switch self { case .efficient: return "Minimize driving time between games" case .scenic: return "Prioritize interesting stops and routes" case .balanced: return "Mix of efficiency and exploration" } } } // MARK: - Preview #Preview { RoutePreferenceStep( routePreference: .constant(.balanced), hasSetRoutePreference: .constant(true) ) .padding() .themedBackground() } ``` **Step 2: Build and commit** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10` ```bash git add SportsTime/Features/Trip/Views/Wizard/Steps/RoutePreferenceStep.swift git commit -m "feat(wizard): add RoutePreferenceStep component" ``` --- ## Task 7: Create RepeatCitiesStep and MustStopsStep Components **Files:** - Create: `SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift` - Create: `SportsTime/Features/Trip/Views/Wizard/Steps/MustStopsStep.swift` **Step 1: Write RepeatCitiesStep** ```swift // SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift import SwiftUI struct RepeatCitiesStep: View { @Binding var allowRepeatCities: Bool @Binding var hasSetRepeatCities: Bool var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { StepHeader( title: "Visit cities more than once?", subtitle: "Some trips work better with return visits" ) HStack(spacing: Theme.Spacing.md) { OptionButton( title: "No, unique cities only", icon: "arrow.right", isSelected: hasSetRepeatCities && !allowRepeatCities, onTap: { allowRepeatCities = false hasSetRepeatCities = true } ) OptionButton( title: "Yes, allow repeats", icon: "arrow.triangle.2.circlepath", isSelected: hasSetRepeatCities && allowRepeatCities, onTap: { allowRepeatCities = true hasSetRepeatCities = true } ) } } .padding(Theme.Spacing.md) .background(Theme.Colors.cardBackground) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium)) } } // MARK: - Option Button private struct OptionButton: View { let title: String let icon: String let isSelected: Bool let onTap: () -> Void var body: some View { Button(action: onTap) { VStack(spacing: Theme.Spacing.sm) { Image(systemName: icon) .font(.title2) .foregroundStyle(isSelected ? Theme.Colors.primary : .secondary) Text(title) .font(.caption) .fontWeight(.medium) .foregroundStyle(isSelected ? Theme.Colors.primary : .primary) .multilineTextAlignment(.center) } .frame(maxWidth: .infinity) .padding(Theme.Spacing.md) .background(isSelected ? Theme.Colors.primary.opacity(0.1) : Color.clear) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small)) .overlay( RoundedRectangle(cornerRadius: Theme.Radius.small) .stroke(isSelected ? Theme.Colors.primary : Color.gray.opacity(0.3), lineWidth: isSelected ? 2 : 1) ) } .buttonStyle(.plain) } } // MARK: - Preview #Preview { RepeatCitiesStep( allowRepeatCities: .constant(false), hasSetRepeatCities: .constant(true) ) .padding() .themedBackground() } ``` **Step 2: Write MustStopsStep** ```swift // SportsTime/Features/Trip/Views/Wizard/Steps/MustStopsStep.swift import SwiftUI struct MustStopsStep: View { @Binding var mustStopLocations: [LocationInput] @State private var showLocationSearch = false var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { StepHeader( title: "Any must-stop locations?", subtitle: "Optional - add cities you want to visit" ) if !mustStopLocations.isEmpty { VStack(spacing: Theme.Spacing.xs) { ForEach(mustStopLocations, id: \.name) { location in HStack { Image(systemName: "mappin.circle.fill") .foregroundStyle(Theme.Colors.primary) Text(location.name) .font(.subheadline) Spacer() Button { mustStopLocations.removeAll { $0.name == location.name } } label: { Image(systemName: "xmark.circle.fill") .foregroundStyle(.secondary) } } .padding(Theme.Spacing.sm) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small)) } } } Button { showLocationSearch = true } label: { HStack { Image(systemName: "plus.circle.fill") Text(mustStopLocations.isEmpty ? "Add a location" : "Add another") } .font(.subheadline) .foregroundStyle(Theme.Colors.primary) } Text("Skip this step if you don't have specific cities in mind") .font(.caption) .foregroundStyle(.secondary) } .padding(Theme.Spacing.md) .background(Theme.Colors.cardBackground) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium)) .sheet(isPresented: $showLocationSearch) { LocationSearchSheet(inputType: .mustStop) { location in mustStopLocations.append(location) } } } } // MARK: - Preview #Preview { MustStopsStep(mustStopLocations: .constant([])) .padding() .themedBackground() } ``` **Step 3: Build and commit** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10` ```bash git add SportsTime/Features/Trip/Views/Wizard/Steps/RepeatCitiesStep.swift SportsTime/Features/Trip/Views/Wizard/Steps/MustStopsStep.swift git commit -m "feat(wizard): add RepeatCitiesStep and MustStopsStep components" ``` --- ## Task 8: Create ReviewStep Component **Files:** - Create: `SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift` **Step 1: Write ReviewStep view** ```swift // SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift import SwiftUI struct ReviewStep: View { let planningMode: PlanningMode let selectedSports: Set let startDate: Date let endDate: Date let selectedRegions: Set let routePreference: RoutePreference let allowRepeatCities: Bool let mustStopLocations: [LocationInput] let isPlanning: Bool let onPlan: () -> Void var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { StepHeader( title: "Ready to plan your trip!", subtitle: "Review your selections" ) VStack(alignment: .leading, spacing: Theme.Spacing.sm) { ReviewRow(label: "Mode", value: planningMode.displayName) ReviewRow(label: "Sports", value: selectedSports.map(\.rawValue).sorted().joined(separator: ", ")) ReviewRow(label: "Dates", value: dateRangeText) ReviewRow(label: "Regions", value: selectedRegions.map(\.displayName).sorted().joined(separator: ", ")) ReviewRow(label: "Route", value: routePreference.displayName) ReviewRow(label: "Repeat cities", value: allowRepeatCities ? "Yes" : "No") if !mustStopLocations.isEmpty { ReviewRow(label: "Must-stops", value: mustStopLocations.map(\.name).joined(separator: ", ")) } } .padding(Theme.Spacing.sm) .background(Color(.secondarySystemBackground)) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.small)) Button(action: onPlan) { HStack { if isPlanning { ProgressView() .scaleEffect(0.8) .tint(.white) } Text(isPlanning ? "Planning..." : "Plan My Trip") .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding(Theme.Spacing.md) .background(Theme.Colors.primary) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium)) } .disabled(isPlanning) } .padding(Theme.Spacing.md) .background(Theme.Colors.cardBackground) .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.medium)) } private var dateRangeText: String { let formatter = DateFormatter() formatter.dateStyle = .medium return "\(formatter.string(from: startDate)) - \(formatter.string(from: endDate))" } } // MARK: - Review Row private struct ReviewRow: View { let label: String let value: String var body: some View { HStack(alignment: .top) { Text(label) .font(.caption) .foregroundStyle(.secondary) .frame(width: 80, alignment: .leading) Text(value) .font(.subheadline) .foregroundStyle(.primary) Spacer() } } } // MARK: - Preview #Preview { ReviewStep( planningMode: .dateRange, selectedSports: [.mlb, .nba], startDate: Date(), endDate: Date().addingTimeInterval(86400 * 7), selectedRegions: [.northeast, .midwest], routePreference: .balanced, allowRepeatCities: false, mustStopLocations: [], isPlanning: false, onPlan: {} ) .padding() .themedBackground() } ``` **Step 2: Build and commit** ```bash git add SportsTime/Features/Trip/Views/Wizard/Steps/ReviewStep.swift git commit -m "feat(wizard): add ReviewStep component" ``` --- ## Task 9: Create TripWizardView Container **Files:** - Create: `SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift` **Step 1: Write TripWizardView with progressive reveal** ```swift // SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift import SwiftUI struct TripWizardView: View { @Environment(\.dismiss) private var dismiss @State private var viewModel = TripWizardViewModel() @State private var scrollProxy: ScrollViewProxy? @State private var showTripOptions = false @State private var tripOptions: [ItineraryOption] = [] 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) } .onAppear { scrollProxy = proxy } .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: [:], // Will be populated from planning result preferences: buildPreferences(), convertToTrip: { _ in nil } // Placeholder ) } } } // 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 { // TODO: Integrate with TripPlanningEngine // For now, this is a placeholder viewModel.isPlanning = true // Simulate planning delay try? await Task.sleep(nanoseconds: 2_000_000_000) viewModel.isPlanning = false } private func buildPreferences() -> TripPreferences { TripPreferences( sports: viewModel.selectedSports, startDate: viewModel.startDate, endDate: viewModel.endDate, regions: viewModel.selectedRegions, routePreference: viewModel.routePreference, allowRepeatCities: viewModel.allowRepeatCities, mustStopLocations: viewModel.mustStopLocations ) } } // MARK: - Preview #Preview { TripWizardView() } ``` **Step 2: Add isPlanning to TripWizardViewModel** Add to `TripWizardViewModel.swift`: ```swift // MARK: - Planning State var isPlanning: Bool = false ``` **Step 3: Build to verify compilation** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10` **Step 4: Commit** ```bash git add SportsTime/Features/Trip/Views/Wizard/TripWizardView.swift SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift git commit -m "feat(wizard): add TripWizardView container with progressive reveal" ``` --- ## Task 10: Add Settings Toggle and Wire Up Navigation **Files:** - Modify: `SportsTime/Features/Settings/Views/SettingsView.swift` - Modify: `SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift` - Modify: `SportsTime/Features/Home/Views/HomeView.swift` **Step 1: Add useClassicTripCreation setting to SettingsViewModel** Check if SettingsViewModel exists and add: ```swift // Add to SettingsViewModel var useClassicTripCreation: Bool { get { UserDefaults.standard.bool(forKey: "useClassicTripCreation") } set { UserDefaults.standard.set(newValue, forKey: "useClassicTripCreation") } } ``` **Step 2: Add toggle to SettingsView** Add a new section to SettingsView: ```swift // Add after existing sections in SettingsView private var tripPlanningSection: some View { Section { Toggle("Use Classic Trip Form", isOn: $viewModel.useClassicTripCreation) } header: { Text("Trip Planning") } footer: { Text("Enable to use the original all-in-one trip creation form instead of the guided wizard.") } } ``` Add `tripPlanningSection` to the List body. **Step 3: Update HomeView to conditionally show wizard or classic** Modify HomeView navigation to check the setting: ```swift // In HomeView, update the sheet presentation .sheet(isPresented: $showTripCreation) { if UserDefaults.standard.bool(forKey: "useClassicTripCreation") { TripCreationView(viewModel: tripCreationViewModel, initialSport: selectedSport) } else { TripWizardView() } } ``` **Step 4: Build and test** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build 2>&1 | tail -10` **Step 5: Commit** ```bash git add SportsTime/Features/Settings/Views/SettingsView.swift SportsTime/Features/Settings/ViewModels/SettingsViewModel.swift SportsTime/Features/Home/Views/HomeView.swift git commit -m "feat(wizard): add Settings toggle and wire up navigation" ``` --- ## Task 11: Integration Testing **Files:** - Test: `SportsTimeTests/Trip/TripWizardViewModelTests.swift` (add integration tests) **Step 1: Add full flow integration tests** Add to `TripWizardViewModelTests.swift`: ```swift func test_fullDateRangeFlow_allStepsRevealInOrder() { let viewModel = TripWizardViewModel() // Initially only planning mode visible XCTAssertTrue(viewModel.isPlanningModeStepVisible) XCTAssertFalse(viewModel.isSportsStepVisible) // Select planning mode viewModel.planningMode = .dateRange XCTAssertTrue(viewModel.isSportsStepVisible) XCTAssertFalse(viewModel.isDatesStepVisible) // Select sport viewModel.selectedSports = [.mlb] XCTAssertTrue(viewModel.isDatesStepVisible) XCTAssertFalse(viewModel.isRegionsStepVisible) // Set dates viewModel.hasSetDates = true XCTAssertTrue(viewModel.isRegionsStepVisible) XCTAssertFalse(viewModel.isRoutePreferenceStepVisible) // Select region viewModel.selectedRegions = [.northeast] XCTAssertTrue(viewModel.isRoutePreferenceStepVisible) XCTAssertFalse(viewModel.isRepeatCitiesStepVisible) // Set route preference viewModel.hasSetRoutePreference = true XCTAssertTrue(viewModel.isRepeatCitiesStepVisible) XCTAssertFalse(viewModel.isMustStopsStepVisible) // Set repeat cities viewModel.hasSetRepeatCities = true XCTAssertTrue(viewModel.isMustStopsStepVisible) XCTAssertTrue(viewModel.isReviewStepVisible) } func test_revealState_changesWithEachStep() { let viewModel = TripWizardViewModel() let initialState = viewModel.revealState viewModel.planningMode = .dateRange XCTAssertNotEqual(viewModel.revealState, initialState) let afterModeState = viewModel.revealState viewModel.selectedSports = [.mlb] XCTAssertNotEqual(viewModel.revealState, afterModeState) } ``` **Step 2: Run all wizard tests** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/TripWizardViewModelTests test 2>&1 | tail -30` Expected: All tests PASS **Step 3: Commit** ```bash git add SportsTimeTests/Trip/TripWizardViewModelTests.swift git commit -m "test(wizard): add integration tests for full flow" ``` --- ## Task 12: Final Build and Full Test Suite **Step 1: Run full test suite** Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test 2>&1 | tail -50` Expected: All tests PASS **Step 2: Final commit if any cleanup needed** ```bash git status # If any uncommitted changes, commit them ``` --- ## Summary This plan creates: - `TripWizardViewModel` - State management for progressive reveal - 8 step components (PlanningModeStep, SportsStep, DatesStep, RegionsStep, RoutePreferenceStep, RepeatCitiesStep, MustStopsStep, ReviewStep) - `TripWizardView` - Container with auto-scroll - Settings toggle for classic vs wizard mode - Comprehensive test coverage Total: ~12 tasks, ~40 steps