diff --git a/docs/plans/2026-01-12-trip-planning-enhancements.md b/docs/plans/2026-01-12-trip-planning-enhancements.md new file mode 100644 index 0000000..30c9518 --- /dev/null +++ b/docs/plans/2026-01-12-trip-planning-enhancements.md @@ -0,0 +1,1690 @@ +# 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