Initial commit: SportsTime trip planning app
- Three-scenario planning engine (A: date range, B: selected games, C: directional routes) - GeographicRouteExplorer with anchor game support for route exploration - Shared ItineraryBuilder for travel segment calculation - TravelEstimator for driving time/distance estimation - SwiftUI views for trip creation and detail display - CloudKit integration for schedule data - Python scraping scripts for sports schedules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
467
SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift
Normal file
467
SportsTime/Features/Trip/ViewModels/TripCreationViewModel.swift
Normal file
@@ -0,0 +1,467 @@
|
||||
//
|
||||
// TripCreationViewModel.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import Observation
|
||||
import CoreLocation
|
||||
|
||||
@Observable
|
||||
final class TripCreationViewModel {
|
||||
|
||||
// MARK: - State
|
||||
|
||||
enum ViewState: Equatable {
|
||||
case editing
|
||||
case planning
|
||||
case completed(Trip)
|
||||
case error(String)
|
||||
|
||||
static func == (lhs: ViewState, rhs: ViewState) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.editing, .editing): return true
|
||||
case (.planning, .planning): return true
|
||||
case (.completed(let t1), .completed(let t2)): return t1.id == t2.id
|
||||
case (.error(let e1), .error(let e2)): return e1 == e2
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var viewState: ViewState = .editing
|
||||
|
||||
// MARK: - Planning Mode
|
||||
|
||||
var planningMode: PlanningMode = .dateRange
|
||||
|
||||
// MARK: - Form Fields
|
||||
|
||||
// Locations (used in .locations mode)
|
||||
var startLocationText: String = ""
|
||||
var endLocationText: String = ""
|
||||
var startLocation: LocationInput?
|
||||
var endLocation: LocationInput?
|
||||
|
||||
// Sports
|
||||
var selectedSports: Set<Sport> = [.mlb]
|
||||
|
||||
// Dates
|
||||
var startDate: Date = Date()
|
||||
var endDate: Date = Date().addingTimeInterval(86400 * 7)
|
||||
|
||||
// Trip duration for game-first mode (days before/after selected games)
|
||||
var tripBufferDays: Int = 2
|
||||
|
||||
// Games
|
||||
var mustSeeGameIds: Set<UUID> = []
|
||||
var availableGames: [RichGame] = []
|
||||
var isLoadingGames: Bool = false
|
||||
|
||||
// Travel
|
||||
var travelMode: TravelMode = .drive
|
||||
var routePreference: RoutePreference = .balanced
|
||||
|
||||
// Constraints
|
||||
var useStopCount: Bool = true
|
||||
var numberOfStops: Int = 5
|
||||
var leisureLevel: LeisureLevel = .moderate
|
||||
|
||||
// Optional
|
||||
var mustStopLocations: [LocationInput] = []
|
||||
var preferredCities: [String] = []
|
||||
var needsEVCharging: Bool = false
|
||||
var lodgingType: LodgingType = .hotel
|
||||
var numberOfDrivers: Int = 1
|
||||
var maxDrivingHoursPerDriver: Double = 8
|
||||
var catchOtherSports: Bool = false
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private let planningEngine = TripPlanningEngine()
|
||||
private let locationService = LocationService.shared
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
|
||||
// MARK: - Cached Data
|
||||
|
||||
private var teams: [UUID: Team] = [:]
|
||||
private var stadiums: [UUID: Stadium] = [:]
|
||||
private var games: [Game] = []
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var isFormValid: Bool {
|
||||
switch planningMode {
|
||||
case .dateRange:
|
||||
// Need: sports + valid date range
|
||||
return !selectedSports.isEmpty && endDate > startDate
|
||||
|
||||
case .gameFirst:
|
||||
// Need: at least one selected game + sports
|
||||
return !mustSeeGameIds.isEmpty && !selectedSports.isEmpty
|
||||
|
||||
case .locations:
|
||||
// Need: start + end locations + sports
|
||||
return !startLocationText.isEmpty &&
|
||||
!endLocationText.isEmpty &&
|
||||
!selectedSports.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
var formValidationMessage: String? {
|
||||
switch planningMode {
|
||||
case .dateRange:
|
||||
if selectedSports.isEmpty { return "Select at least one sport" }
|
||||
if endDate <= startDate { return "End date must be after start date" }
|
||||
case .gameFirst:
|
||||
if mustSeeGameIds.isEmpty { return "Select at least one game" }
|
||||
if selectedSports.isEmpty { return "Select at least one sport" }
|
||||
case .locations:
|
||||
if startLocationText.isEmpty { return "Enter a starting location" }
|
||||
if endLocationText.isEmpty { return "Enter an ending location" }
|
||||
if selectedSports.isEmpty { return "Select at least one sport" }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var tripDurationDays: Int {
|
||||
let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 0
|
||||
return max(1, days)
|
||||
}
|
||||
|
||||
var selectedGamesCount: Int {
|
||||
mustSeeGameIds.count
|
||||
}
|
||||
|
||||
var selectedGames: [RichGame] {
|
||||
availableGames.filter { mustSeeGameIds.contains($0.game.id) }
|
||||
}
|
||||
|
||||
/// Computed date range for game-first mode based on selected games
|
||||
var gameFirstDateRange: (start: Date, end: Date)? {
|
||||
guard !selectedGames.isEmpty else { return nil }
|
||||
let gameDates = selectedGames.map { $0.game.dateTime }
|
||||
guard let earliest = gameDates.min(),
|
||||
let latest = gameDates.max() else { return nil }
|
||||
|
||||
let calendar = Calendar.current
|
||||
let bufferedStart = calendar.date(byAdding: .day, value: -tripBufferDays, to: earliest) ?? earliest
|
||||
let bufferedEnd = calendar.date(byAdding: .day, value: tripBufferDays, to: latest) ?? latest
|
||||
return (bufferedStart, bufferedEnd)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func loadScheduleData() async {
|
||||
do {
|
||||
// Ensure initial data is loaded
|
||||
if dataProvider.teams.isEmpty {
|
||||
await dataProvider.loadInitialData()
|
||||
}
|
||||
|
||||
// Use cached teams and stadiums from data provider
|
||||
for team in dataProvider.teams {
|
||||
teams[team.id] = team
|
||||
}
|
||||
for stadium in dataProvider.stadiums {
|
||||
stadiums[stadium.id] = stadium
|
||||
}
|
||||
|
||||
// Fetch games
|
||||
games = try await dataProvider.fetchGames(
|
||||
sports: selectedSports,
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
)
|
||||
|
||||
// Build rich games for display
|
||||
availableGames = games.compactMap { game -> RichGame? in
|
||||
guard let homeTeam = teams[game.homeTeamId],
|
||||
let awayTeam = teams[game.awayTeamId],
|
||||
let stadium = stadiums[game.stadiumId] else {
|
||||
return nil
|
||||
}
|
||||
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||
}
|
||||
} catch {
|
||||
viewState = .error("Failed to load schedule data: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func resolveLocations() async {
|
||||
do {
|
||||
if !startLocationText.isEmpty {
|
||||
startLocation = try await locationService.resolveLocation(
|
||||
LocationInput(name: startLocationText, address: startLocationText)
|
||||
)
|
||||
}
|
||||
|
||||
if !endLocationText.isEmpty {
|
||||
endLocation = try await locationService.resolveLocation(
|
||||
LocationInput(name: endLocationText, address: endLocationText)
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
viewState = .error("Failed to resolve locations: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func planTrip() async {
|
||||
guard isFormValid else { return }
|
||||
|
||||
viewState = .planning
|
||||
|
||||
do {
|
||||
// Mode-specific setup
|
||||
var effectiveStartDate = startDate
|
||||
var effectiveEndDate = endDate
|
||||
var resolvedStartLocation: LocationInput?
|
||||
var resolvedEndLocation: LocationInput?
|
||||
|
||||
switch planningMode {
|
||||
case .dateRange:
|
||||
// Use provided date range, no location needed
|
||||
// Games will be found within the date range across all regions
|
||||
effectiveStartDate = startDate
|
||||
effectiveEndDate = endDate
|
||||
|
||||
case .gameFirst:
|
||||
// Calculate date range from selected games + buffer
|
||||
if let dateRange = gameFirstDateRange {
|
||||
effectiveStartDate = dateRange.start
|
||||
effectiveEndDate = dateRange.end
|
||||
}
|
||||
// Derive start/end locations from first/last game stadiums
|
||||
if let firstGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).first,
|
||||
let lastGame = selectedGames.sorted(by: { $0.game.dateTime < $1.game.dateTime }).last {
|
||||
resolvedStartLocation = LocationInput(
|
||||
name: firstGame.stadium.city,
|
||||
coordinate: firstGame.stadium.coordinate,
|
||||
address: "\(firstGame.stadium.city), \(firstGame.stadium.state)"
|
||||
)
|
||||
resolvedEndLocation = LocationInput(
|
||||
name: lastGame.stadium.city,
|
||||
coordinate: lastGame.stadium.coordinate,
|
||||
address: "\(lastGame.stadium.city), \(lastGame.stadium.state)"
|
||||
)
|
||||
}
|
||||
|
||||
case .locations:
|
||||
// Resolve provided locations
|
||||
await resolveLocations()
|
||||
resolvedStartLocation = startLocation
|
||||
resolvedEndLocation = endLocation
|
||||
|
||||
guard resolvedStartLocation != nil, resolvedEndLocation != nil else {
|
||||
viewState = .error("Could not resolve start or end location")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure we have games data
|
||||
if games.isEmpty {
|
||||
await loadScheduleData()
|
||||
}
|
||||
|
||||
// Build preferences
|
||||
let preferences = TripPreferences(
|
||||
planningMode: planningMode,
|
||||
startLocation: resolvedStartLocation,
|
||||
endLocation: resolvedEndLocation,
|
||||
sports: selectedSports,
|
||||
mustSeeGameIds: mustSeeGameIds,
|
||||
travelMode: travelMode,
|
||||
startDate: effectiveStartDate,
|
||||
endDate: effectiveEndDate,
|
||||
numberOfStops: useStopCount ? numberOfStops : nil,
|
||||
tripDuration: useStopCount ? nil : tripDurationDays,
|
||||
leisureLevel: leisureLevel,
|
||||
mustStopLocations: mustStopLocations,
|
||||
preferredCities: preferredCities,
|
||||
routePreference: routePreference,
|
||||
needsEVCharging: needsEVCharging,
|
||||
lodgingType: lodgingType,
|
||||
numberOfDrivers: numberOfDrivers,
|
||||
maxDrivingHoursPerDriver: maxDrivingHoursPerDriver,
|
||||
catchOtherSports: catchOtherSports
|
||||
)
|
||||
|
||||
// Build planning request
|
||||
let request = PlanningRequest(
|
||||
preferences: preferences,
|
||||
availableGames: games,
|
||||
teams: teams,
|
||||
stadiums: stadiums
|
||||
)
|
||||
|
||||
// Plan the trip
|
||||
let result = planningEngine.planItineraries(request: request)
|
||||
|
||||
switch result {
|
||||
case .success(let options):
|
||||
guard let bestOption = options.first else {
|
||||
viewState = .error("No valid itinerary found")
|
||||
return
|
||||
}
|
||||
// Convert ItineraryOption to Trip
|
||||
let trip = convertToTrip(option: bestOption, preferences: preferences)
|
||||
viewState = .completed(trip)
|
||||
|
||||
case .failure(let failure):
|
||||
viewState = .error(failureMessage(for: failure))
|
||||
}
|
||||
|
||||
} catch {
|
||||
viewState = .error("Trip planning failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
func toggleMustSeeGame(_ gameId: UUID) {
|
||||
if mustSeeGameIds.contains(gameId) {
|
||||
mustSeeGameIds.remove(gameId)
|
||||
} else {
|
||||
mustSeeGameIds.insert(gameId)
|
||||
}
|
||||
}
|
||||
|
||||
func switchPlanningMode(_ mode: PlanningMode) {
|
||||
planningMode = mode
|
||||
// Clear mode-specific selections when switching
|
||||
switch mode {
|
||||
case .dateRange:
|
||||
startLocationText = ""
|
||||
endLocationText = ""
|
||||
startLocation = nil
|
||||
endLocation = nil
|
||||
case .gameFirst:
|
||||
// Keep games, clear locations
|
||||
startLocationText = ""
|
||||
endLocationText = ""
|
||||
startLocation = nil
|
||||
endLocation = nil
|
||||
case .locations:
|
||||
// Keep locations, optionally keep selected games
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Load games for browsing in game-first mode
|
||||
func loadGamesForBrowsing() async {
|
||||
isLoadingGames = true
|
||||
do {
|
||||
// Ensure initial data is loaded
|
||||
if dataProvider.teams.isEmpty {
|
||||
await dataProvider.loadInitialData()
|
||||
}
|
||||
|
||||
// Use cached teams and stadiums from data provider
|
||||
for team in dataProvider.teams {
|
||||
teams[team.id] = team
|
||||
}
|
||||
for stadium in dataProvider.stadiums {
|
||||
stadiums[stadium.id] = stadium
|
||||
}
|
||||
|
||||
// Fetch games for next 90 days for browsing
|
||||
let browseEndDate = Calendar.current.date(byAdding: .day, value: 90, to: Date()) ?? endDate
|
||||
games = try await dataProvider.fetchGames(
|
||||
sports: selectedSports,
|
||||
startDate: Date(),
|
||||
endDate: browseEndDate
|
||||
)
|
||||
|
||||
// Build rich games for display
|
||||
availableGames = games.compactMap { game -> RichGame? in
|
||||
guard let homeTeam = teams[game.homeTeamId],
|
||||
let awayTeam = teams[game.awayTeamId],
|
||||
let stadium = stadiums[game.stadiumId] else {
|
||||
return nil
|
||||
}
|
||||
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
|
||||
} catch {
|
||||
viewState = .error("Failed to load games: \(error.localizedDescription)")
|
||||
}
|
||||
isLoadingGames = false
|
||||
}
|
||||
|
||||
func addMustStopLocation(_ location: LocationInput) {
|
||||
guard !mustStopLocations.contains(where: { $0.name == location.name }) else { return }
|
||||
mustStopLocations.append(location)
|
||||
}
|
||||
|
||||
func removeMustStopLocation(_ location: LocationInput) {
|
||||
mustStopLocations.removeAll { $0.name == location.name }
|
||||
}
|
||||
|
||||
func addPreferredCity(_ city: String) {
|
||||
guard !city.isEmpty, !preferredCities.contains(city) else { return }
|
||||
preferredCities.append(city)
|
||||
}
|
||||
|
||||
func removePreferredCity(_ city: String) {
|
||||
preferredCities.removeAll { $0 == city }
|
||||
}
|
||||
|
||||
func reset() {
|
||||
viewState = .editing
|
||||
planningMode = .dateRange
|
||||
startLocationText = ""
|
||||
endLocationText = ""
|
||||
startLocation = nil
|
||||
endLocation = nil
|
||||
selectedSports = [.mlb]
|
||||
startDate = Date()
|
||||
endDate = Date().addingTimeInterval(86400 * 7)
|
||||
tripBufferDays = 2
|
||||
mustSeeGameIds = []
|
||||
numberOfStops = 5
|
||||
leisureLevel = .moderate
|
||||
mustStopLocations = []
|
||||
preferredCities = []
|
||||
availableGames = []
|
||||
isLoadingGames = false
|
||||
}
|
||||
|
||||
// MARK: - Conversion Helpers
|
||||
|
||||
private func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip {
|
||||
// Convert ItineraryStops to TripStops
|
||||
let tripStops = option.stops.enumerated().map { index, stop in
|
||||
TripStop(
|
||||
stopNumber: index + 1,
|
||||
city: stop.city,
|
||||
state: stop.state,
|
||||
coordinate: stop.coordinate,
|
||||
arrivalDate: stop.arrivalDate,
|
||||
departureDate: stop.departureDate,
|
||||
games: stop.games,
|
||||
isRestDay: stop.games.isEmpty
|
||||
)
|
||||
}
|
||||
|
||||
return Trip(
|
||||
name: generateTripName(from: tripStops),
|
||||
preferences: preferences,
|
||||
stops: tripStops,
|
||||
travelSegments: option.travelSegments,
|
||||
totalGames: option.totalGames,
|
||||
totalDistanceMeters: option.totalDistanceMiles * 1609.34,
|
||||
totalDrivingSeconds: option.totalDrivingHours * 3600
|
||||
)
|
||||
}
|
||||
|
||||
private func generateTripName(from stops: [TripStop]) -> String {
|
||||
let cities = stops.compactMap { $0.city }.prefix(3)
|
||||
if cities.count <= 1 {
|
||||
return cities.first ?? "Road Trip"
|
||||
}
|
||||
return cities.joined(separator: " → ")
|
||||
}
|
||||
|
||||
private func failureMessage(for failure: PlanningFailure) -> String {
|
||||
failure.message
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user