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
|
||||
}
|
||||
}
|
||||
831
SportsTime/Features/Trip/Views/TripCreationView.swift
Normal file
831
SportsTime/Features/Trip/Views/TripCreationView.swift
Normal file
@@ -0,0 +1,831 @@
|
||||
//
|
||||
// TripCreationView.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct TripCreationView: View {
|
||||
@State private var viewModel = TripCreationViewModel()
|
||||
@State private var showGamePicker = false
|
||||
@State private var showCityInput = false
|
||||
@State private var cityInputType: CityInputType = .mustStop
|
||||
@State private var showLocationBanner = true
|
||||
@State private var showTripDetail = false
|
||||
@State private var completedTrip: Trip?
|
||||
|
||||
enum CityInputType {
|
||||
case mustStop
|
||||
case preferred
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Planning Mode Selector
|
||||
planningModeSection
|
||||
|
||||
// Location Permission Banner (only for locations mode)
|
||||
if viewModel.planningMode == .locations && showLocationBanner {
|
||||
Section {
|
||||
LocationPermissionBanner(isPresented: $showLocationBanner)
|
||||
.listRowInsets(EdgeInsets())
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
}
|
||||
|
||||
// Mode-specific sections
|
||||
switch viewModel.planningMode {
|
||||
case .dateRange:
|
||||
// Sports + Dates
|
||||
sportsSection
|
||||
datesSection
|
||||
|
||||
case .gameFirst:
|
||||
// Sports + Game Picker
|
||||
sportsSection
|
||||
gameBrowserSection
|
||||
tripBufferSection
|
||||
|
||||
case .locations:
|
||||
// Locations + Sports + optional games
|
||||
locationSection
|
||||
sportsSection
|
||||
datesSection
|
||||
gamesSection
|
||||
}
|
||||
|
||||
// Common sections
|
||||
travelSection
|
||||
constraintsSection
|
||||
optionalSection
|
||||
|
||||
// Validation message
|
||||
if let message = viewModel.formValidationMessage {
|
||||
Section {
|
||||
Label(message, systemImage: "exclamationmark.triangle")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Plan Your Trip")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Plan") {
|
||||
Task {
|
||||
await viewModel.planTrip()
|
||||
}
|
||||
}
|
||||
.disabled(!viewModel.isFormValid)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if case .planning = viewModel.viewState {
|
||||
planningOverlay
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showGamePicker) {
|
||||
GamePickerSheet(
|
||||
games: viewModel.availableGames,
|
||||
selectedIds: $viewModel.mustSeeGameIds
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $showCityInput) {
|
||||
LocationSearchSheet(inputType: cityInputType) { location in
|
||||
switch cityInputType {
|
||||
case .mustStop:
|
||||
viewModel.addMustStopLocation(location)
|
||||
case .preferred:
|
||||
viewModel.addPreferredCity(location.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: Binding(
|
||||
get: { viewModel.viewState.isError },
|
||||
set: { if !$0 { viewModel.viewState = .editing } }
|
||||
)) {
|
||||
Button("OK") {
|
||||
viewModel.viewState = .editing
|
||||
}
|
||||
} message: {
|
||||
if case .error(let message) = viewModel.viewState {
|
||||
Text(message)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $showTripDetail) {
|
||||
if let trip = completedTrip {
|
||||
TripDetailView(trip: trip, games: buildGamesDictionary())
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.viewState) { _, newState in
|
||||
if case .completed(let trip) = newState {
|
||||
completedTrip = trip
|
||||
showTripDetail = true
|
||||
}
|
||||
}
|
||||
.onChange(of: showTripDetail) { _, isShowing in
|
||||
if !isShowing {
|
||||
// User navigated back, reset to editing state
|
||||
viewModel.viewState = .editing
|
||||
completedTrip = nil
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadScheduleData()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sections
|
||||
|
||||
private var planningModeSection: some View {
|
||||
Section {
|
||||
Picker("Planning Mode", selection: $viewModel.planningMode) {
|
||||
ForEach(PlanningMode.allCases) { mode in
|
||||
Label(mode.displayName, systemImage: mode.iconName)
|
||||
.tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
|
||||
|
||||
Text(viewModel.planningMode.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var locationSection: some View {
|
||||
Section("Locations") {
|
||||
TextField("Start Location", text: $viewModel.startLocationText)
|
||||
.textContentType(.addressCity)
|
||||
|
||||
TextField("End Location", text: $viewModel.endLocationText)
|
||||
.textContentType(.addressCity)
|
||||
}
|
||||
}
|
||||
|
||||
private var gameBrowserSection: some View {
|
||||
Section("Select Games") {
|
||||
if viewModel.isLoadingGames {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Loading games...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else if viewModel.availableGames.isEmpty {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("Loading games...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadGamesForBrowsing()
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
showGamePicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "sportscourt")
|
||||
.foregroundStyle(.blue)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Browse Teams & Games")
|
||||
.foregroundStyle(.primary)
|
||||
Text("\(viewModel.availableGames.count) games available")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// Show selected games summary
|
||||
if !viewModel.mustSeeGameIds.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("\(viewModel.mustSeeGameIds.count) game(s) selected")
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
|
||||
// Show selected games preview
|
||||
ForEach(viewModel.selectedGames.prefix(3)) { game in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: game.game.sport.iconName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
|
||||
.font(.caption)
|
||||
Spacer()
|
||||
Text(game.game.formattedDate)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.selectedGames.count > 3 {
|
||||
Text("+ \(viewModel.selectedGames.count - 3) more")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var tripBufferSection: some View {
|
||||
Section("Trip Duration") {
|
||||
Stepper("Buffer Days: \(viewModel.tripBufferDays)", value: $viewModel.tripBufferDays, in: 0...7)
|
||||
|
||||
if let dateRange = viewModel.gameFirstDateRange {
|
||||
HStack {
|
||||
Text("Trip window:")
|
||||
Spacer()
|
||||
Text("\(dateRange.start.formatted(date: .abbreviated, time: .omitted)) - \(dateRange.end.formatted(date: .abbreviated, time: .omitted))")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Text("Days before first game and after last game for travel/rest")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var sportsSection: some View {
|
||||
Section("Sports") {
|
||||
ForEach(Sport.supported) { sport in
|
||||
Toggle(isOn: binding(for: sport)) {
|
||||
Label(sport.rawValue, systemImage: sport.iconName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var datesSection: some View {
|
||||
Section("Dates") {
|
||||
DatePicker("Start Date", selection: $viewModel.startDate, displayedComponents: .date)
|
||||
|
||||
DatePicker("End Date", selection: $viewModel.endDate, displayedComponents: .date)
|
||||
|
||||
Text("\(viewModel.tripDurationDays) day trip")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var gamesSection: some View {
|
||||
Section("Must-See Games") {
|
||||
Button {
|
||||
showGamePicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Select Games")
|
||||
Spacer()
|
||||
Text("\(viewModel.selectedGamesCount) selected")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var travelSection: some View {
|
||||
Section("Travel") {
|
||||
Picker("Travel Mode", selection: $viewModel.travelMode) {
|
||||
ForEach(TravelMode.allCases) { mode in
|
||||
Label(mode.displayName, systemImage: mode.iconName)
|
||||
.tag(mode)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Route Preference", selection: $viewModel.routePreference) {
|
||||
ForEach(RoutePreference.allCases) { pref in
|
||||
Text(pref.displayName).tag(pref)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var constraintsSection: some View {
|
||||
Section("Trip Style") {
|
||||
Toggle("Use Stop Count", isOn: $viewModel.useStopCount)
|
||||
|
||||
if viewModel.useStopCount {
|
||||
Stepper("Number of Stops: \(viewModel.numberOfStops)", value: $viewModel.numberOfStops, in: 1...20)
|
||||
}
|
||||
|
||||
Picker("Pace", selection: $viewModel.leisureLevel) {
|
||||
ForEach(LeisureLevel.allCases) { level in
|
||||
VStack(alignment: .leading) {
|
||||
Text(level.displayName)
|
||||
}
|
||||
.tag(level)
|
||||
}
|
||||
}
|
||||
|
||||
Text(viewModel.leisureLevel.description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var optionalSection: some View {
|
||||
Section("Optional") {
|
||||
// Must-Stop Locations
|
||||
DisclosureGroup("Must-Stop Locations (\(viewModel.mustStopLocations.count))") {
|
||||
ForEach(viewModel.mustStopLocations, id: \.name) { location in
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(location.name)
|
||||
if let address = location.address, !address.isEmpty {
|
||||
Text(address)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
viewModel.removeMustStopLocation(location)
|
||||
} label: {
|
||||
Image(systemName: "minus.circle.fill")
|
||||
}
|
||||
}
|
||||
}
|
||||
Button("Add Location") {
|
||||
cityInputType = .mustStop
|
||||
showCityInput = true
|
||||
}
|
||||
}
|
||||
|
||||
// EV Charging
|
||||
if viewModel.travelMode == .drive {
|
||||
Toggle("EV Charging Needed", isOn: $viewModel.needsEVCharging)
|
||||
}
|
||||
|
||||
// Lodging
|
||||
Picker("Lodging Type", selection: $viewModel.lodgingType) {
|
||||
ForEach(LodgingType.allCases) { type in
|
||||
Label(type.displayName, systemImage: type.iconName)
|
||||
.tag(type)
|
||||
}
|
||||
}
|
||||
|
||||
// Drivers
|
||||
if viewModel.travelMode == .drive {
|
||||
Stepper("Drivers: \(viewModel.numberOfDrivers)", value: $viewModel.numberOfDrivers, in: 1...4)
|
||||
|
||||
HStack {
|
||||
Text("Max Hours/Driver/Day")
|
||||
Spacer()
|
||||
Text("\(Int(viewModel.maxDrivingHoursPerDriver))h")
|
||||
}
|
||||
Slider(value: $viewModel.maxDrivingHoursPerDriver, in: 4...12, step: 1)
|
||||
}
|
||||
|
||||
// Other Sports
|
||||
Toggle("Find Other Sports Along Route", isOn: $viewModel.catchOtherSports)
|
||||
}
|
||||
}
|
||||
|
||||
private var planningOverlay: some View {
|
||||
ZStack {
|
||||
Color.black.opacity(0.4)
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 20) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
|
||||
Text("Planning your trip...")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
|
||||
Text("Finding the best route and games")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.white.opacity(0.8))
|
||||
}
|
||||
.padding(40)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func binding(for sport: Sport) -> Binding<Bool> {
|
||||
Binding(
|
||||
get: { viewModel.selectedSports.contains(sport) },
|
||||
set: { isSelected in
|
||||
if isSelected {
|
||||
viewModel.selectedSports.insert(sport)
|
||||
} else {
|
||||
viewModel.selectedSports.remove(sport)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func buildGamesDictionary() -> [UUID: RichGame] {
|
||||
Dictionary(uniqueKeysWithValues: viewModel.availableGames.map { ($0.id, $0) })
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View State Extensions
|
||||
|
||||
extension TripCreationViewModel.ViewState {
|
||||
var isError: Bool {
|
||||
if case .error = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var isCompleted: Bool {
|
||||
if case .completed = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Game Picker Sheet (Team-based selection)
|
||||
|
||||
struct GamePickerSheet: View {
|
||||
let games: [RichGame]
|
||||
@Binding var selectedIds: Set<UUID>
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// Group games by team (both home and away)
|
||||
private var teamsList: [TeamWithGames] {
|
||||
var teamsDict: [UUID: TeamWithGames] = [:]
|
||||
|
||||
for game in games {
|
||||
// Add to home team
|
||||
if var teamData = teamsDict[game.homeTeam.id] {
|
||||
teamData.games.append(game)
|
||||
teamsDict[game.homeTeam.id] = teamData
|
||||
} else {
|
||||
teamsDict[game.homeTeam.id] = TeamWithGames(
|
||||
team: game.homeTeam,
|
||||
sport: game.game.sport,
|
||||
games: [game]
|
||||
)
|
||||
}
|
||||
|
||||
// Add to away team
|
||||
if var teamData = teamsDict[game.awayTeam.id] {
|
||||
teamData.games.append(game)
|
||||
teamsDict[game.awayTeam.id] = teamData
|
||||
} else {
|
||||
teamsDict[game.awayTeam.id] = TeamWithGames(
|
||||
team: game.awayTeam,
|
||||
sport: game.game.sport,
|
||||
games: [game]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return teamsDict.values
|
||||
.sorted { $0.team.name < $1.team.name }
|
||||
}
|
||||
|
||||
private var teamsBySport: [(sport: Sport, teams: [TeamWithGames])] {
|
||||
let grouped = Dictionary(grouping: teamsList) { $0.sport }
|
||||
return Sport.supported
|
||||
.filter { grouped[$0] != nil }
|
||||
.map { sport in
|
||||
(sport, grouped[sport]!.sorted { $0.team.name < $1.team.name })
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedGamesCount: Int {
|
||||
selectedIds.count
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List {
|
||||
// Selected games summary
|
||||
if !selectedIds.isEmpty {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("\(selectedGamesCount) game(s) selected")
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Teams by sport
|
||||
ForEach(teamsBySport, id: \.sport.id) { sportGroup in
|
||||
Section(sportGroup.sport.rawValue) {
|
||||
ForEach(sportGroup.teams) { teamData in
|
||||
NavigationLink {
|
||||
TeamGamesView(
|
||||
teamData: teamData,
|
||||
selectedIds: $selectedIds
|
||||
)
|
||||
} label: {
|
||||
TeamRow(teamData: teamData, selectedIds: selectedIds)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Teams")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
if !selectedIds.isEmpty {
|
||||
Button("Reset") {
|
||||
selectedIds.removeAll()
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Team With Games Model
|
||||
|
||||
struct TeamWithGames: Identifiable {
|
||||
let team: Team
|
||||
let sport: Sport
|
||||
var games: [RichGame]
|
||||
|
||||
var id: UUID { team.id }
|
||||
|
||||
var sortedGames: [RichGame] {
|
||||
games.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Team Row
|
||||
|
||||
struct TeamRow: View {
|
||||
let teamData: TeamWithGames
|
||||
let selectedIds: Set<UUID>
|
||||
|
||||
private var selectedCount: Int {
|
||||
teamData.games.filter { selectedIds.contains($0.id) }.count
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Team color indicator
|
||||
if let colorHex = teamData.team.primaryColor {
|
||||
Circle()
|
||||
.fill(Color(hex: colorHex) ?? .gray)
|
||||
.frame(width: 12, height: 12)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(teamData.team.city) \(teamData.team.name)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Text("\(teamData.games.count) game(s) available")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedCount > 0 {
|
||||
Text("\(selectedCount)")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.blue)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Team Games View
|
||||
|
||||
struct TeamGamesView: View {
|
||||
let teamData: TeamWithGames
|
||||
@Binding var selectedIds: Set<UUID>
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
ForEach(teamData.sortedGames) { game in
|
||||
GameRow(game: game, isSelected: selectedIds.contains(game.id)) {
|
||||
if selectedIds.contains(game.id) {
|
||||
selectedIds.remove(game.id)
|
||||
} else {
|
||||
selectedIds.insert(game.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("\(teamData.team.city) \(teamData.team.name)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
}
|
||||
|
||||
struct GameRow: View {
|
||||
let game: RichGame
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(game.matchupDescription)
|
||||
.font(.headline)
|
||||
|
||||
Text(game.venueDescription)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("\(game.game.formattedDate) • \(game.game.gameTime)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(isSelected ? .blue : .gray)
|
||||
.font(.title2)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct GameSelectRow: View {
|
||||
let game: RichGame
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 12) {
|
||||
// Sport icon
|
||||
Image(systemName: game.game.sport.iconName)
|
||||
.font(.title3)
|
||||
.foregroundStyle(isSelected ? .blue : .secondary)
|
||||
.frame(width: 24)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Text("\(game.game.formattedDate) • \(game.game.gameTime)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(game.stadium.city)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundStyle(isSelected ? .blue : .gray.opacity(0.5))
|
||||
.font(.title3)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Location Search Sheet
|
||||
|
||||
struct LocationSearchSheet: View {
|
||||
let inputType: TripCreationView.CityInputType
|
||||
let onAdd: (LocationInput) -> Void
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var searchText = ""
|
||||
@State private var searchResults: [LocationSearchResult] = []
|
||||
@State private var isSearching = false
|
||||
@State private var searchTask: Task<Void, Never>?
|
||||
|
||||
private let locationService = LocationService.shared
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
// Search field
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
TextField("Search cities, addresses, places...", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
if isSearching {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
} else if !searchText.isEmpty {
|
||||
Button {
|
||||
searchText = ""
|
||||
searchResults = []
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
.padding()
|
||||
|
||||
// Results list
|
||||
if searchResults.isEmpty && !searchText.isEmpty && !isSearching {
|
||||
ContentUnavailableView(
|
||||
"No Results",
|
||||
systemImage: "mappin.slash",
|
||||
description: Text("Try a different search term")
|
||||
)
|
||||
} else {
|
||||
List(searchResults) { result in
|
||||
Button {
|
||||
onAdd(result.toLocationInput())
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
.font(.title2)
|
||||
VStack(alignment: .leading) {
|
||||
Text(result.name)
|
||||
.foregroundStyle(.primary)
|
||||
if !result.address.isEmpty {
|
||||
Text(result.address)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "plus.circle")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle(inputType == .mustStop ? "Add Must-Stop" : "Add Location")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.onChange(of: searchText) { _, newValue in
|
||||
// Debounce search
|
||||
searchTask?.cancel()
|
||||
searchTask = Task {
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
guard !Task.isCancelled else { return }
|
||||
await performSearch(query: newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performSearch(query: String) async {
|
||||
guard !query.isEmpty else {
|
||||
searchResults = []
|
||||
return
|
||||
}
|
||||
|
||||
isSearching = true
|
||||
do {
|
||||
searchResults = try await locationService.searchLocations(query)
|
||||
} catch {
|
||||
searchResults = []
|
||||
}
|
||||
isSearching = false
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TripCreationView()
|
||||
}
|
||||
883
SportsTime/Features/Trip/Views/TripDetailView.swift
Normal file
883
SportsTime/Features/Trip/Views/TripDetailView.swift
Normal file
@@ -0,0 +1,883 @@
|
||||
//
|
||||
// TripDetailView.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import MapKit
|
||||
|
||||
struct TripDetailView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
let trip: Trip
|
||||
let games: [UUID: RichGame]
|
||||
|
||||
@State private var selectedDay: ItineraryDay?
|
||||
@State private var showExportSheet = false
|
||||
@State private var showShareSheet = false
|
||||
@State private var exportURL: URL?
|
||||
@State private var shareURL: URL?
|
||||
@State private var mapCameraPosition: MapCameraPosition = .automatic
|
||||
@State private var isSaved = false
|
||||
@State private var showSaveConfirmation = false
|
||||
@State private var routePolylines: [MKPolyline] = []
|
||||
@State private var isLoadingRoutes = false
|
||||
|
||||
private let exportService = ExportService()
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Header
|
||||
tripHeader
|
||||
|
||||
// Score Card
|
||||
if let score = trip.score {
|
||||
scoreCard(score)
|
||||
}
|
||||
|
||||
// Stats
|
||||
statsGrid
|
||||
|
||||
// Map Preview
|
||||
mapPreview
|
||||
|
||||
// Day-by-Day Itinerary
|
||||
itinerarySection
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle(trip.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
Task {
|
||||
await shareTrip()
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
|
||||
Menu {
|
||||
Button {
|
||||
Task {
|
||||
await exportPDF()
|
||||
}
|
||||
} label: {
|
||||
Label("Export PDF", systemImage: "doc.fill")
|
||||
}
|
||||
|
||||
Button {
|
||||
saveTrip()
|
||||
} label: {
|
||||
Label(isSaved ? "Saved" : "Save Trip", systemImage: isSaved ? "bookmark.fill" : "bookmark")
|
||||
}
|
||||
.disabled(isSaved)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showExportSheet) {
|
||||
if let url = exportURL {
|
||||
ShareSheet(items: [url])
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let url = shareURL {
|
||||
ShareSheet(items: [url])
|
||||
} else {
|
||||
ShareSheet(items: [trip.name, trip.formattedDateRange])
|
||||
}
|
||||
}
|
||||
.alert("Trip Saved", isPresented: $showSaveConfirmation) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text("Your trip has been saved and can be accessed from My Trips.")
|
||||
}
|
||||
.onAppear {
|
||||
checkIfSaved()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var tripHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(trip.formattedDateRange)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 16) {
|
||||
ForEach(Array(trip.uniqueSports), id: \.self) { sport in
|
||||
Label(sport.rawValue, systemImage: sport.iconName)
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(Color.blue.opacity(0.1))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - Score Card
|
||||
|
||||
private func scoreCard(_ score: TripScore) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack {
|
||||
Text("Trip Score")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(score.scoreGrade)
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
|
||||
HStack(spacing: 20) {
|
||||
scoreItem(label: "Games", value: score.gameQualityScore)
|
||||
scoreItem(label: "Route", value: score.routeEfficiencyScore)
|
||||
scoreItem(label: "Balance", value: score.leisureBalanceScore)
|
||||
scoreItem(label: "Prefs", value: score.preferenceAlignmentScore)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private func scoreItem(label: String, value: Double) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(String(format: "%.0f", value))
|
||||
.font(.headline)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stats Grid
|
||||
|
||||
private var statsGrid: some View {
|
||||
LazyVGrid(columns: [
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible()),
|
||||
GridItem(.flexible())
|
||||
], spacing: 16) {
|
||||
statCell(value: "\(trip.tripDuration)", label: "Days", icon: "calendar")
|
||||
statCell(value: "\(trip.stops.count)", label: "Cities", icon: "mappin.circle")
|
||||
statCell(value: "\(trip.totalGames)", label: "Games", icon: "sportscourt")
|
||||
statCell(value: trip.formattedTotalDistance, label: "Distance", icon: "road.lanes")
|
||||
statCell(value: trip.formattedTotalDriving, label: "Driving", icon: "car")
|
||||
statCell(value: String(format: "%.1fh", trip.averageDrivingHoursPerDay), label: "Avg/Day", icon: "gauge.medium")
|
||||
}
|
||||
}
|
||||
|
||||
private func statCell(value: String, label: String, icon: String) -> some View {
|
||||
VStack(spacing: 6) {
|
||||
Image(systemName: icon)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.blue)
|
||||
Text(value)
|
||||
.font(.headline)
|
||||
Text(label)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 12)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
|
||||
// MARK: - Map Preview
|
||||
|
||||
private var mapPreview: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Text("Route")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if isLoadingRoutes {
|
||||
ProgressView()
|
||||
.scaleEffect(0.7)
|
||||
}
|
||||
}
|
||||
|
||||
Map(position: $mapCameraPosition) {
|
||||
// Add markers for each stop
|
||||
ForEach(stopCoordinates.indices, id: \.self) { index in
|
||||
let stop = stopCoordinates[index]
|
||||
Marker(stop.name, coordinate: stop.coordinate)
|
||||
.tint(.blue)
|
||||
}
|
||||
|
||||
// Add actual driving route polylines
|
||||
ForEach(routePolylines.indices, id: \.self) { index in
|
||||
MapPolyline(routePolylines[index])
|
||||
.stroke(.blue, lineWidth: 3)
|
||||
}
|
||||
}
|
||||
.frame(height: 200)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.task {
|
||||
updateMapRegion()
|
||||
await fetchDrivingRoutes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch actual driving routes using MKDirections
|
||||
private func fetchDrivingRoutes() async {
|
||||
let stops = stopCoordinates
|
||||
guard stops.count >= 2 else { return }
|
||||
|
||||
isLoadingRoutes = true
|
||||
var polylines: [MKPolyline] = []
|
||||
|
||||
for i in 0..<(stops.count - 1) {
|
||||
let source = stops[i]
|
||||
let destination = stops[i + 1]
|
||||
|
||||
let request = MKDirections.Request()
|
||||
request.source = MKMapItem(placemark: MKPlacemark(coordinate: source.coordinate))
|
||||
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: destination.coordinate))
|
||||
request.transportType = .automobile
|
||||
|
||||
let directions = MKDirections(request: request)
|
||||
|
||||
do {
|
||||
let response = try await directions.calculate()
|
||||
if let route = response.routes.first {
|
||||
polylines.append(route.polyline)
|
||||
}
|
||||
} catch {
|
||||
// Fallback to straight line if directions fail
|
||||
print("Failed to get directions from \(source.name) to \(destination.name): \(error)")
|
||||
let straightLine = MKPolyline(coordinates: [source.coordinate, destination.coordinate], count: 2)
|
||||
polylines.append(straightLine)
|
||||
}
|
||||
}
|
||||
|
||||
routePolylines = polylines
|
||||
isLoadingRoutes = false
|
||||
}
|
||||
|
||||
/// Get coordinates for all stops (from stop coordinate or stadium)
|
||||
private var stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)] {
|
||||
trip.stops.compactMap { stop -> (String, CLLocationCoordinate2D)? in
|
||||
// First try to use the stop's stored coordinate
|
||||
if let coord = stop.coordinate {
|
||||
return (stop.city, coord)
|
||||
}
|
||||
// Fall back to stadium coordinate if available
|
||||
if let stadiumId = stop.stadium,
|
||||
let stadium = dataProvider.stadium(for: stadiumId) {
|
||||
return (stadium.name, stadium.coordinate)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolved stadiums from trip stops (for markers)
|
||||
private var tripStadiums: [Stadium] {
|
||||
trip.stops.compactMap { stop in
|
||||
guard let stadiumId = stop.stadium else { return nil }
|
||||
return dataProvider.stadium(for: stadiumId)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMapRegion() {
|
||||
guard !stopCoordinates.isEmpty else { return }
|
||||
|
||||
let coordinates = stopCoordinates.map(\.coordinate)
|
||||
let lats = coordinates.map(\.latitude)
|
||||
let lons = coordinates.map(\.longitude)
|
||||
|
||||
guard let minLat = lats.min(),
|
||||
let maxLat = lats.max(),
|
||||
let minLon = lons.min(),
|
||||
let maxLon = lons.max() else { return }
|
||||
|
||||
let center = CLLocationCoordinate2D(
|
||||
latitude: (minLat + maxLat) / 2,
|
||||
longitude: (minLon + maxLon) / 2
|
||||
)
|
||||
|
||||
// Add padding to the span
|
||||
let latSpan = (maxLat - minLat) * 1.3 + 0.5
|
||||
let lonSpan = (maxLon - minLon) * 1.3 + 0.5
|
||||
|
||||
mapCameraPosition = .region(MKCoordinateRegion(
|
||||
center: center,
|
||||
span: MKCoordinateSpan(latitudeDelta: max(latSpan, 1), longitudeDelta: max(lonSpan, 1))
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Itinerary
|
||||
|
||||
private var itinerarySection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Route Options")
|
||||
.font(.headline)
|
||||
|
||||
let combinations = computeRouteCombinations()
|
||||
|
||||
if combinations.count == 1 {
|
||||
// Single route - show fully expanded
|
||||
SingleRouteView(
|
||||
route: combinations[0],
|
||||
days: trip.itineraryDays(),
|
||||
games: games
|
||||
)
|
||||
} else {
|
||||
// Multiple combinations - show each as expandable row
|
||||
ForEach(Array(combinations.enumerated()), id: \.offset) { index, route in
|
||||
RouteCombinationRow(
|
||||
routeNumber: index + 1,
|
||||
route: route,
|
||||
days: trip.itineraryDays(),
|
||||
games: games,
|
||||
totalRoutes: combinations.count
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes all possible route combinations across days
|
||||
private func computeRouteCombinations() -> [[DayChoice]] {
|
||||
let days = trip.itineraryDays()
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Build options for each day
|
||||
var dayOptions: [[DayChoice]] = []
|
||||
|
||||
for day in days {
|
||||
let dayStart = calendar.startOfDay(for: day.date)
|
||||
|
||||
// Find stops with games on this day
|
||||
let stopsWithGames = day.stops.filter { stop in
|
||||
stop.games.compactMap { games[$0] }.contains { richGame in
|
||||
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||
}
|
||||
}
|
||||
|
||||
if stopsWithGames.isEmpty {
|
||||
// Rest day or travel day - use first stop or create empty
|
||||
if let firstStop = day.stops.first {
|
||||
dayOptions.append([DayChoice(dayNumber: day.dayNumber, stop: firstStop, game: nil)])
|
||||
}
|
||||
} else {
|
||||
// Create choices for each stop with games
|
||||
let choices = stopsWithGames.compactMap { stop -> DayChoice? in
|
||||
let gamesAtStop = stop.games.compactMap { games[$0] }.filter { richGame in
|
||||
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||
}
|
||||
return DayChoice(dayNumber: day.dayNumber, stop: stop, game: gamesAtStop.first)
|
||||
}
|
||||
if !choices.isEmpty {
|
||||
dayOptions.append(choices)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compute cartesian product of all day options
|
||||
return cartesianProduct(dayOptions)
|
||||
}
|
||||
|
||||
/// Computes cartesian product of arrays
|
||||
private func cartesianProduct(_ arrays: [[DayChoice]]) -> [[DayChoice]] {
|
||||
guard !arrays.isEmpty else { return [[]] }
|
||||
|
||||
var result: [[DayChoice]] = [[]]
|
||||
|
||||
for array in arrays {
|
||||
var newResult: [[DayChoice]] = []
|
||||
for existing in result {
|
||||
for element in array {
|
||||
newResult.append(existing + [element])
|
||||
}
|
||||
}
|
||||
result = newResult
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/// Detects if there are games in different cities on the same day
|
||||
private func detectConflicts(for day: ItineraryDay) -> DayConflictInfo {
|
||||
let calendar = Calendar.current
|
||||
let dayStart = calendar.startOfDay(for: day.date)
|
||||
|
||||
// Find all stops that have games on this specific day
|
||||
let stopsWithGamesToday = day.stops.filter { stop in
|
||||
stop.games.compactMap { games[$0] }.contains { richGame in
|
||||
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||
}
|
||||
}
|
||||
|
||||
// Get unique cities with games today
|
||||
let citiesWithGames = Set(stopsWithGamesToday.map { $0.city })
|
||||
|
||||
if citiesWithGames.count > 1 {
|
||||
return DayConflictInfo(
|
||||
hasConflict: true,
|
||||
conflictingStops: stopsWithGamesToday,
|
||||
conflictingCities: Array(citiesWithGames)
|
||||
)
|
||||
}
|
||||
|
||||
return DayConflictInfo(hasConflict: false, conflictingStops: [], conflictingCities: [])
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func exportPDF() async {
|
||||
do {
|
||||
let url = try await exportService.exportToPDF(trip: trip, games: games)
|
||||
exportURL = url
|
||||
showExportSheet = true
|
||||
} catch {
|
||||
print("Failed to export PDF: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func shareTrip() async {
|
||||
shareURL = await exportService.shareTrip(trip)
|
||||
showShareSheet = true
|
||||
}
|
||||
|
||||
private func saveTrip() {
|
||||
guard let savedTrip = SavedTrip.from(trip, status: .planned) else {
|
||||
print("Failed to create SavedTrip")
|
||||
return
|
||||
}
|
||||
|
||||
modelContext.insert(savedTrip)
|
||||
|
||||
do {
|
||||
try modelContext.save()
|
||||
isSaved = true
|
||||
showSaveConfirmation = true
|
||||
} catch {
|
||||
print("Failed to save trip: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func checkIfSaved() {
|
||||
let tripId = trip.id
|
||||
let descriptor = FetchDescriptor<SavedTrip>(
|
||||
predicate: #Predicate { $0.id == tripId }
|
||||
)
|
||||
|
||||
if let count = try? modelContext.fetchCount(descriptor), count > 0 {
|
||||
isSaved = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Day Conflict Info
|
||||
|
||||
struct DayConflictInfo {
|
||||
let hasConflict: Bool
|
||||
let conflictingStops: [TripStop]
|
||||
let conflictingCities: [String]
|
||||
|
||||
var warningMessage: String {
|
||||
guard hasConflict else { return "" }
|
||||
let otherCities = conflictingCities.joined(separator: ", ")
|
||||
return "Scheduling conflict: Games in \(otherCities) on the same day"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Day Choice (Route Option)
|
||||
|
||||
/// Represents a choice for a single day in a route
|
||||
struct DayChoice: Hashable {
|
||||
let dayNumber: Int
|
||||
let stop: TripStop
|
||||
let game: RichGame?
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(dayNumber)
|
||||
hasher.combine(stop.city)
|
||||
}
|
||||
|
||||
static func == (lhs: DayChoice, rhs: DayChoice) -> Bool {
|
||||
lhs.dayNumber == rhs.dayNumber && lhs.stop.city == rhs.stop.city
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route Combination Row (Expandable full route)
|
||||
|
||||
struct RouteCombinationRow: View {
|
||||
let routeNumber: Int
|
||||
let route: [DayChoice]
|
||||
let days: [ItineraryDay]
|
||||
let games: [UUID: RichGame]
|
||||
let totalRoutes: Int
|
||||
|
||||
@State private var isExpanded = false
|
||||
|
||||
/// Summary string like "CLE @ SD → CHC @ ATH → ATL @ LAD"
|
||||
private var routeSummary: String {
|
||||
route.compactMap { choice -> String? in
|
||||
guard let game = choice.game else { return nil }
|
||||
return game.matchupDescription
|
||||
}.joined(separator: " → ")
|
||||
}
|
||||
|
||||
/// Cities in the route
|
||||
private var routeCities: String {
|
||||
route.map { $0.stop.city }.joined(separator: " → ")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header (always visible, tappable)
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Route number badge
|
||||
Text("Route \(routeNumber)")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.blue)
|
||||
.clipShape(Capsule())
|
||||
|
||||
// Game sequence summary
|
||||
Text(routeSummary)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
// Cities
|
||||
Text(routeCities)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(8)
|
||||
.background(Color(.tertiarySystemFill))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
// Expanded content - full day-by-day itinerary
|
||||
if isExpanded {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(route, id: \.dayNumber) { choice in
|
||||
if let day = days.first(where: { $0.dayNumber == choice.dayNumber }) {
|
||||
RouteDayCard(day: day, choice: choice, games: games)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
}
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.blue.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Single Route View (Auto-expanded when only one option)
|
||||
|
||||
struct SingleRouteView: View {
|
||||
let route: [DayChoice]
|
||||
let days: [ItineraryDay]
|
||||
let games: [UUID: RichGame]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 12) {
|
||||
ForEach(route, id: \.dayNumber) { choice in
|
||||
if let day = days.first(where: { $0.dayNumber == choice.dayNumber }) {
|
||||
RouteDayCard(day: day, choice: choice, games: games)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route Day Card (Individual day within a route)
|
||||
|
||||
struct RouteDayCard: View {
|
||||
let day: ItineraryDay
|
||||
let choice: DayChoice
|
||||
let games: [UUID: RichGame]
|
||||
|
||||
private var gamesOnThisDay: [RichGame] {
|
||||
let calendar = Calendar.current
|
||||
let dayStart = calendar.startOfDay(for: day.date)
|
||||
|
||||
return choice.stop.games.compactMap { games[$0] }.filter { richGame in
|
||||
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Day header
|
||||
HStack {
|
||||
Text("Day \(day.dayNumber)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text(day.formattedDate)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if gamesOnThisDay.isEmpty {
|
||||
Text("Rest Day")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.green.opacity(0.2))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// City
|
||||
Label(choice.stop.city, systemImage: "mappin")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// Travel
|
||||
if day.hasTravelSegment {
|
||||
ForEach(day.travelSegments) { segment in
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: segment.travelMode.iconName)
|
||||
Text("\(segment.formattedDistance) • \(segment.formattedDuration)")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
// Games
|
||||
ForEach(gamesOnThisDay, id: \.game.id) { richGame in
|
||||
HStack {
|
||||
Image(systemName: richGame.game.sport.iconName)
|
||||
.foregroundStyle(.blue)
|
||||
Text(richGame.matchupDescription)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text(richGame.game.gameTime)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Day Card
|
||||
|
||||
struct DayCard: View {
|
||||
let day: ItineraryDay
|
||||
let games: [UUID: RichGame]
|
||||
var specificStop: TripStop? = nil
|
||||
var conflictInfo: DayConflictInfo? = nil
|
||||
|
||||
/// The city to display for this card
|
||||
var primaryCityForDay: String? {
|
||||
// If a specific stop is provided (conflict mode), use that stop's city
|
||||
if let stop = specificStop {
|
||||
return stop.city
|
||||
}
|
||||
|
||||
let calendar = Calendar.current
|
||||
let dayStart = calendar.startOfDay(for: day.date)
|
||||
|
||||
// Find the stop with a game on this day
|
||||
let primaryStop = day.stops.first { stop in
|
||||
stop.games.compactMap { games[$0] }.contains { richGame in
|
||||
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||
}
|
||||
} ?? day.stops.first
|
||||
|
||||
return primaryStop?.city
|
||||
}
|
||||
|
||||
/// Games to display on this card
|
||||
var gamesOnThisDay: [RichGame] {
|
||||
let calendar = Calendar.current
|
||||
let dayStart = calendar.startOfDay(for: day.date)
|
||||
|
||||
// If a specific stop is provided (conflict mode), only show that stop's games
|
||||
if let stop = specificStop {
|
||||
return stop.games.compactMap { games[$0] }.filter { richGame in
|
||||
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||
}
|
||||
}
|
||||
|
||||
// Find the stop where we're actually located on this day
|
||||
let primaryStop = day.stops.first { stop in
|
||||
stop.games.compactMap { games[$0] }.contains { richGame in
|
||||
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||
}
|
||||
} ?? day.stops.first
|
||||
|
||||
guard let stop = primaryStop else { return [] }
|
||||
|
||||
return stop.games.compactMap { games[$0] }.filter { richGame in
|
||||
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this card has a scheduling conflict
|
||||
var hasConflict: Bool {
|
||||
conflictInfo?.hasConflict ?? false
|
||||
}
|
||||
|
||||
/// Other cities with conflicting games (excluding current city)
|
||||
var otherConflictingCities: [String] {
|
||||
guard let info = conflictInfo, let currentCity = primaryCityForDay else { return [] }
|
||||
return info.conflictingCities.filter { $0 != currentCity }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Conflict warning banner
|
||||
if hasConflict {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text("Conflict: Also scheduled in \(otherConflictingCities.joined(separator: ", "))")
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.orange.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
// Day header
|
||||
HStack {
|
||||
Text("Day \(day.dayNumber)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text(day.formattedDate)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if day.isRestDay && !hasConflict {
|
||||
Text("Rest Day")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.green.opacity(0.2))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// City
|
||||
if let city = primaryCityForDay {
|
||||
Label(city, systemImage: "mappin")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Travel (only show if not in conflict mode, to avoid duplication)
|
||||
if day.hasTravelSegment && specificStop == nil {
|
||||
ForEach(day.travelSegments) { segment in
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: segment.travelMode.iconName)
|
||||
Text("\(segment.formattedDistance) • \(segment.formattedDuration)")
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
|
||||
// Games
|
||||
ForEach(gamesOnThisDay, id: \.game.id) { richGame in
|
||||
HStack {
|
||||
Image(systemName: richGame.game.sport.iconName)
|
||||
.foregroundStyle(.blue)
|
||||
Text(richGame.matchupDescription)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text(richGame.game.gameTime)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(hasConflict ? Color.orange.opacity(0.05) : Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(hasConflict ? Color.orange.opacity(0.3) : Color.clear, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Share Sheet
|
||||
|
||||
struct ShareSheet: UIViewControllerRepresentable {
|
||||
let items: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
UIActivityViewController(activityItems: items, applicationActivities: nil)
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
TripDetailView(
|
||||
trip: Trip(
|
||||
name: "MLB Road Trip",
|
||||
preferences: TripPreferences(
|
||||
startLocation: LocationInput(name: "New York"),
|
||||
endLocation: LocationInput(name: "Chicago")
|
||||
)
|
||||
),
|
||||
games: [:]
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user