Files
Sportstime/docs/plans/2026-01-12-trip-planning-enhancements.md
Trey t 9531ed1008 docs: add Trip Planning Enhancements implementation plan
12 tasks with TDD approach:
- TripWizardViewModel with reveal state logic
- 8 step components (PlanningMode, Sports, Dates, Regions, etc.)
- TripWizardView container with auto-scroll
- Settings toggle for classic vs wizard mode
- Integration tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:38:05 -06:00

1691 lines
52 KiB
Markdown

# 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<Sport> = []
// MARK: - Dates
var startDate: Date = Date()
var endDate: Date = Date().addingTimeInterval(86400 * 7)
var hasSetDates: Bool = false
// MARK: - Regions
var selectedRegions: Set<USRegion> = []
// 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<Sport>
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<USRegion>
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<Sport>
let startDate: Date
let endDate: Date
let selectedRegions: Set<USRegion>
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