feat(wizard): add TripWizardViewModel with reveal state logic
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
148
SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift
Normal file
148
SportsTime/Features/Trip/ViewModels/TripWizardViewModel.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// TripWizardViewModel.swift
|
||||
// SportsTime
|
||||
//
|
||||
// ViewModel for progressive-reveal trip wizard.
|
||||
//
|
||||
|
||||
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<Region> = []
|
||||
|
||||
// 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: - Planning State
|
||||
|
||||
var isPlanning: Bool = false
|
||||
|
||||
// MARK: - Sport Availability
|
||||
|
||||
var sportAvailability: [Sport: Bool] = [:]
|
||||
var isLoadingSportAvailability: Bool = false
|
||||
|
||||
// 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: - Sport Availability
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reset Logic
|
||||
|
||||
private func resetDownstreamFromPlanningMode() {
|
||||
selectedSports = []
|
||||
hasSetDates = false
|
||||
selectedRegions = []
|
||||
hasSetRoutePreference = false
|
||||
hasSetRepeatCities = false
|
||||
mustStopLocations = []
|
||||
}
|
||||
}
|
||||
48
SportsTimeTests/Trip/TripWizardViewModelTests.swift
Normal file
48
SportsTimeTests/Trip/TripWizardViewModelTests.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// TripWizardViewModelTests.swift
|
||||
// SportsTimeTests
|
||||
//
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user