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>
1691 lines
52 KiB
Markdown
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
|