Refactor trip planning: DAG router + trip options UI + simplified itinerary
- Replace O(2^n) GeographicRouteExplorer with O(n) GameDAGRouter using DAG + beam search - Add geographic diversity to route selection (returns routes from distinct regions) - Add trip options selector UI (TripOptionsView, TripOptionCard) to choose between routes - Simplify itinerary display: separate games and travel segments by date - Remove complex ItineraryDay bundling, query games/travel directly per day - Update ScenarioA/B/C planners to use GameDAGRouter - Add new test suites for planners and travel estimator 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ final class TripCreationViewModel {
|
||||
enum ViewState: Equatable {
|
||||
case editing
|
||||
case planning
|
||||
case selectingOption([ItineraryOption]) // Multiple options to choose from
|
||||
case completed(Trip)
|
||||
case error(String)
|
||||
|
||||
@@ -23,6 +24,7 @@ final class TripCreationViewModel {
|
||||
switch (lhs, rhs) {
|
||||
case (.editing, .editing): return true
|
||||
case (.planning, .planning): return true
|
||||
case (.selectingOption(let o1), .selectingOption(let o2)): return o1.count == o2.count
|
||||
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
|
||||
@@ -88,6 +90,7 @@ final class TripCreationViewModel {
|
||||
private var teams: [UUID: Team] = [:]
|
||||
private var stadiums: [UUID: Stadium] = [:]
|
||||
private var games: [Game] = []
|
||||
private(set) var currentPreferences: TripPreferences?
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
@@ -300,13 +303,21 @@ final class TripCreationViewModel {
|
||||
|
||||
switch result {
|
||||
case .success(let options):
|
||||
guard let bestOption = options.first else {
|
||||
guard !options.isEmpty else {
|
||||
viewState = .error("No valid itinerary found")
|
||||
return
|
||||
}
|
||||
// Convert ItineraryOption to Trip
|
||||
let trip = convertToTrip(option: bestOption, preferences: preferences)
|
||||
viewState = .completed(trip)
|
||||
// Store preferences for later conversion
|
||||
currentPreferences = preferences
|
||||
|
||||
if options.count == 1 {
|
||||
// Only one option - go directly to detail
|
||||
let trip = convertToTrip(option: options[0], preferences: preferences)
|
||||
viewState = .completed(trip)
|
||||
} else {
|
||||
// Multiple options - show selection view
|
||||
viewState = .selectingOption(options)
|
||||
}
|
||||
|
||||
case .failure(let failure):
|
||||
viewState = .error(failureMessage(for: failure))
|
||||
@@ -423,6 +434,51 @@ final class TripCreationViewModel {
|
||||
preferredCities = []
|
||||
availableGames = []
|
||||
isLoadingGames = false
|
||||
currentPreferences = nil
|
||||
}
|
||||
|
||||
/// Select a specific itinerary option and navigate to its detail
|
||||
func selectOption(_ option: ItineraryOption) {
|
||||
guard let preferences = currentPreferences else {
|
||||
viewState = .error("Unable to load trip preferences")
|
||||
return
|
||||
}
|
||||
let trip = convertToTrip(option: option, preferences: preferences)
|
||||
viewState = .completed(trip)
|
||||
}
|
||||
|
||||
/// Convert an itinerary option to a Trip (public for use by TripOptionsView)
|
||||
func convertOptionToTrip(_ option: ItineraryOption) -> Trip {
|
||||
let preferences = currentPreferences ?? TripPreferences(
|
||||
planningMode: planningMode,
|
||||
startLocation: nil,
|
||||
endLocation: nil,
|
||||
sports: selectedSports,
|
||||
mustSeeGameIds: mustSeeGameIds,
|
||||
travelMode: travelMode,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
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
|
||||
)
|
||||
return convertToTrip(option: option, preferences: preferences)
|
||||
}
|
||||
|
||||
/// Go back to option selection from trip detail
|
||||
func backToOptions() {
|
||||
if case .completed = viewState {
|
||||
// We'd need to store options to go back - for now, restart planning
|
||||
viewState = .editing
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Conversion Helpers
|
||||
|
||||
472
SportsTime/Features/Trip/Views/TimelineItemView.swift
Normal file
472
SportsTime/Features/Trip/Views/TimelineItemView.swift
Normal file
@@ -0,0 +1,472 @@
|
||||
//
|
||||
// TimelineItemView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Unified timeline view components for displaying trip itinerary.
|
||||
// Renders stops, travel segments, and rest days in a consistent format.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Timeline Item View
|
||||
|
||||
/// Renders a single timeline item (stop, travel, or rest).
|
||||
struct TimelineItemView: View {
|
||||
let item: TimelineItem
|
||||
let games: [UUID: RichGame]
|
||||
let isFirst: Bool
|
||||
let isLast: Bool
|
||||
|
||||
init(
|
||||
item: TimelineItem,
|
||||
games: [UUID: RichGame],
|
||||
isFirst: Bool = false,
|
||||
isLast: Bool = false
|
||||
) {
|
||||
self.item = item
|
||||
self.games = games
|
||||
self.isFirst = isFirst
|
||||
self.isLast = isLast
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
// Timeline connector
|
||||
timelineConnector
|
||||
|
||||
// Content
|
||||
itemContent
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Connector
|
||||
|
||||
@ViewBuilder
|
||||
private var timelineConnector: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Line from previous
|
||||
if !isFirst {
|
||||
Rectangle()
|
||||
.fill(connectorColor)
|
||||
.frame(width: 2, height: 16)
|
||||
} else {
|
||||
Spacer().frame(height: 16)
|
||||
}
|
||||
|
||||
// Icon
|
||||
itemIcon
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
// Line to next
|
||||
if !isLast {
|
||||
Rectangle()
|
||||
.fill(connectorColor)
|
||||
.frame(width: 2)
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(width: 32)
|
||||
}
|
||||
|
||||
private var connectorColor: Color {
|
||||
Color.secondary.opacity(0.3)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var itemIcon: some View {
|
||||
switch item {
|
||||
case .stop(let stop):
|
||||
if stop.hasGames {
|
||||
Image(systemName: "sportscourt.fill")
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 32, height: 32)
|
||||
.background(Circle().fill(.blue))
|
||||
} else {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
.font(.title2)
|
||||
}
|
||||
|
||||
case .travel(let segment):
|
||||
Image(systemName: segment.travelMode == .drive ? "car.fill" : "airplane")
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(Circle().fill(.green))
|
||||
|
||||
case .rest:
|
||||
Image(systemName: "bed.double.fill")
|
||||
.foregroundStyle(.white)
|
||||
.frame(width: 28, height: 28)
|
||||
.background(Circle().fill(.purple))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Item Content
|
||||
|
||||
@ViewBuilder
|
||||
private var itemContent: some View {
|
||||
switch item {
|
||||
case .stop(let stop):
|
||||
StopItemContent(stop: stop, games: games)
|
||||
|
||||
case .travel(let segment):
|
||||
TravelItemContent(segment: segment)
|
||||
|
||||
case .rest(let rest):
|
||||
RestItemContent(rest: rest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stop Item Content
|
||||
|
||||
struct StopItemContent: View {
|
||||
let stop: ItineraryStop
|
||||
let games: [UUID: RichGame]
|
||||
|
||||
private var gamesAtStop: [RichGame] {
|
||||
stop.games.compactMap { games[$0] }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Header
|
||||
HStack {
|
||||
Text(stop.city)
|
||||
.font(.headline)
|
||||
|
||||
if !stop.state.isEmpty {
|
||||
Text(stop.state)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(stop.arrivalDate.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Games
|
||||
if !gamesAtStop.isEmpty {
|
||||
ForEach(gamesAtStop, id: \.game.id) { richGame in
|
||||
TimelineGameRow(richGame: richGame)
|
||||
}
|
||||
} else {
|
||||
Text(stop.hasGames ? "Game details loading..." : "Waypoint")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Travel Item Content
|
||||
|
||||
struct TravelItemContent: View {
|
||||
let segment: TravelSegment
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(segment.travelMode == .drive ? "Drive" : "Fly")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Text("•")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(segment.formattedDistance)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("•")
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(segment.formattedDuration)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text("\(segment.fromLocation.name) → \(segment.toLocation.name)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
// EV Charging stops if applicable
|
||||
if !segment.evChargingStops.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "bolt.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("\(segment.evChargingStops.count) charging stop(s)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.background(Color(.tertiarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rest Item Content
|
||||
|
||||
struct RestItemContent: View {
|
||||
let rest: RestDay
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text("Rest Day")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(rest.date.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(rest.location.name)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
if let notes = rest.notes {
|
||||
Text(notes)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.italic()
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 12)
|
||||
.background(Color.purple.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline Game Row
|
||||
|
||||
struct TimelineGameRow: View {
|
||||
let richGame: RichGame
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
// Sport icon
|
||||
Image(systemName: richGame.game.sport.iconName)
|
||||
.foregroundStyle(richGame.game.sport.color)
|
||||
.frame(width: 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
// Matchup
|
||||
Text(richGame.matchupDescription)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
|
||||
// Time and venue
|
||||
HStack(spacing: 4) {
|
||||
Text(richGame.game.dateTime.formatted(date: .omitted, time: .shortened))
|
||||
Text("•")
|
||||
Text(richGame.stadium.name)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Timeline View
|
||||
|
||||
/// Full timeline view for an itinerary option.
|
||||
struct TimelineView: View {
|
||||
let option: ItineraryOption
|
||||
let games: [UUID: RichGame]
|
||||
|
||||
private var timeline: [TimelineItem] {
|
||||
option.generateTimeline()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(Array(timeline.enumerated()), id: \.element.id) { index, item in
|
||||
TimelineItemView(
|
||||
item: item,
|
||||
games: games,
|
||||
isFirst: index == 0,
|
||||
isLast: index == timeline.count - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal Timeline View
|
||||
|
||||
/// Horizontal scrolling timeline for compact display.
|
||||
struct HorizontalTimelineView: View {
|
||||
let option: ItineraryOption
|
||||
let games: [UUID: RichGame]
|
||||
|
||||
private var timeline: [TimelineItem] {
|
||||
option.generateTimeline()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(Array(timeline.enumerated()), id: \.element.id) { index, item in
|
||||
HStack(spacing: 0) {
|
||||
HorizontalTimelineItemView(item: item, games: games)
|
||||
|
||||
// Connector to next
|
||||
if index < timeline.count - 1 {
|
||||
timelineConnector(for: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func timelineConnector(for item: TimelineItem) -> some View {
|
||||
if item.isTravel {
|
||||
// Travel already shows direction, minimal connector
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.3))
|
||||
.frame(width: 20, height: 2)
|
||||
} else {
|
||||
// Standard connector with arrow
|
||||
HStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.3))
|
||||
.frame(width: 16, height: 2)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
Rectangle()
|
||||
.fill(Color.secondary.opacity(0.3))
|
||||
.frame(width: 16, height: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Horizontal Timeline Item View
|
||||
|
||||
struct HorizontalTimelineItemView: View {
|
||||
let item: TimelineItem
|
||||
let games: [UUID: RichGame]
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
itemIcon
|
||||
|
||||
Text(shortLabel)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.frame(width: 60)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var itemIcon: some View {
|
||||
switch item {
|
||||
case .stop(let stop):
|
||||
VStack(spacing: 2) {
|
||||
Image(systemName: stop.hasGames ? "sportscourt.fill" : "mappin")
|
||||
.foregroundStyle(stop.hasGames ? .blue : .orange)
|
||||
Text(String(stop.city.prefix(3)).uppercased())
|
||||
.font(.caption2)
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
.background(Circle().fill(Color(.secondarySystemBackground)))
|
||||
|
||||
case .travel(let segment):
|
||||
Image(systemName: segment.travelMode == .drive ? "car.fill" : "airplane")
|
||||
.foregroundStyle(.green)
|
||||
.frame(width: 32, height: 32)
|
||||
|
||||
case .rest:
|
||||
Image(systemName: "bed.double.fill")
|
||||
.foregroundStyle(.purple)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
}
|
||||
|
||||
private var shortLabel: String {
|
||||
switch item {
|
||||
case .stop(let stop):
|
||||
return stop.city
|
||||
case .travel(let segment):
|
||||
return segment.formattedDuration
|
||||
case .rest(let rest):
|
||||
return rest.date.formatted(.dateTime.weekday(.abbreviated))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
let stop1 = ItineraryStop(
|
||||
city: "Los Angeles",
|
||||
state: "CA",
|
||||
coordinate: nil,
|
||||
games: [],
|
||||
arrivalDate: Date(),
|
||||
departureDate: Date(),
|
||||
location: LocationInput(name: "Los Angeles"),
|
||||
firstGameStart: nil
|
||||
)
|
||||
|
||||
let stop2 = ItineraryStop(
|
||||
city: "San Francisco",
|
||||
state: "CA",
|
||||
coordinate: nil,
|
||||
games: [],
|
||||
arrivalDate: Date().addingTimeInterval(86400),
|
||||
departureDate: Date().addingTimeInterval(86400),
|
||||
location: LocationInput(name: "San Francisco"),
|
||||
firstGameStart: nil
|
||||
)
|
||||
|
||||
let segment = TravelSegment(
|
||||
fromLocation: LocationInput(name: "Los Angeles"),
|
||||
toLocation: LocationInput(name: "San Francisco"),
|
||||
travelMode: .drive,
|
||||
distanceMeters: 600000,
|
||||
durationSeconds: 21600,
|
||||
departureTime: Date(),
|
||||
arrivalTime: Date().addingTimeInterval(21600)
|
||||
)
|
||||
|
||||
let option = ItineraryOption(
|
||||
rank: 1,
|
||||
stops: [stop1, stop2],
|
||||
travelSegments: [segment],
|
||||
totalDrivingHours: 6,
|
||||
totalDistanceMiles: 380,
|
||||
geographicRationale: "LA → SF"
|
||||
)
|
||||
|
||||
return ScrollView {
|
||||
TimelineView(option: option, games: [:])
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,9 @@ struct TripCreationView: View {
|
||||
@State private var cityInputType: CityInputType = .mustStop
|
||||
@State private var showLocationBanner = true
|
||||
@State private var showTripDetail = false
|
||||
@State private var showTripOptions = false
|
||||
@State private var completedTrip: Trip?
|
||||
@State private var tripOptions: [ItineraryOption] = []
|
||||
|
||||
enum CityInputType {
|
||||
case mustStop
|
||||
@@ -112,22 +114,45 @@ struct TripCreationView: View {
|
||||
Text(message)
|
||||
}
|
||||
}
|
||||
.navigationDestination(isPresented: $showTripOptions) {
|
||||
TripOptionsView(
|
||||
options: tripOptions,
|
||||
games: buildGamesDictionary(),
|
||||
preferences: viewModel.currentPreferences,
|
||||
convertToTrip: { option in
|
||||
viewModel.convertOptionToTrip(option)
|
||||
}
|
||||
)
|
||||
}
|
||||
.navigationDestination(isPresented: $showTripDetail) {
|
||||
if let trip = completedTrip {
|
||||
TripDetailView(trip: trip, games: buildGamesDictionary())
|
||||
}
|
||||
}
|
||||
.onChange(of: viewModel.viewState) { _, newState in
|
||||
if case .completed(let trip) = newState {
|
||||
switch newState {
|
||||
case .selectingOption(let options):
|
||||
tripOptions = options
|
||||
showTripOptions = true
|
||||
case .completed(let trip):
|
||||
completedTrip = trip
|
||||
showTripDetail = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
.onChange(of: showTripOptions) { _, isShowing in
|
||||
if !isShowing {
|
||||
// User navigated back from options to editing
|
||||
viewModel.viewState = .editing
|
||||
tripOptions = []
|
||||
}
|
||||
}
|
||||
.onChange(of: showTripDetail) { _, isShowing in
|
||||
if !isShowing {
|
||||
// User navigated back, reset to editing state
|
||||
viewModel.viewState = .editing
|
||||
// User navigated back from single-option detail to editing
|
||||
completedTrip = nil
|
||||
viewModel.viewState = .editing
|
||||
}
|
||||
}
|
||||
.task {
|
||||
@@ -826,6 +851,229 @@ struct LocationSearchSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Options View
|
||||
|
||||
struct TripOptionsView: View {
|
||||
let options: [ItineraryOption]
|
||||
let games: [UUID: RichGame]
|
||||
let preferences: TripPreferences?
|
||||
let convertToTrip: (ItineraryOption) -> Trip
|
||||
|
||||
@State private var selectedTrip: Trip?
|
||||
@State private var showTripDetail = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 16) {
|
||||
// Header
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("\(options.count) Trip Options Found")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text("Select a trip to view details")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal)
|
||||
.padding(.top)
|
||||
|
||||
// Options list
|
||||
ForEach(Array(options.enumerated()), id: \.offset) { index, option in
|
||||
TripOptionCard(
|
||||
option: option,
|
||||
rank: index + 1,
|
||||
games: games,
|
||||
onSelect: {
|
||||
selectedTrip = convertToTrip(option)
|
||||
showTripDetail = true
|
||||
}
|
||||
)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
}
|
||||
.navigationTitle("Choose Your Trip")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationDestination(isPresented: $showTripDetail) {
|
||||
if let trip = selectedTrip {
|
||||
TripDetailView(trip: trip, games: games)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Option Card
|
||||
|
||||
struct TripOptionCard: View {
|
||||
let option: ItineraryOption
|
||||
let rank: Int
|
||||
let games: [UUID: RichGame]
|
||||
let onSelect: () -> Void
|
||||
|
||||
private var cities: [String] {
|
||||
option.stops.map { $0.city }
|
||||
}
|
||||
|
||||
private var uniqueCities: Int {
|
||||
Set(cities).count
|
||||
}
|
||||
|
||||
private var totalGames: Int {
|
||||
option.stops.flatMap { $0.games }.count
|
||||
}
|
||||
|
||||
private var primaryCity: String {
|
||||
// Find the city with most games
|
||||
var cityCounts: [String: Int] = [:]
|
||||
for stop in option.stops {
|
||||
cityCounts[stop.city, default: 0] += stop.games.count
|
||||
}
|
||||
return cityCounts.max(by: { $0.value < $1.value })?.key ?? cities.first ?? "Unknown"
|
||||
}
|
||||
|
||||
private var routeSummary: String {
|
||||
let uniqueCityList = cities.removingDuplicates()
|
||||
if uniqueCityList.count <= 3 {
|
||||
return uniqueCityList.joined(separator: " → ")
|
||||
}
|
||||
return "\(uniqueCityList[0]) → ... → \(uniqueCityList.last ?? "")"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: onSelect) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Header with rank and primary city
|
||||
HStack(alignment: .center) {
|
||||
// Rank badge
|
||||
Text("Option \(rank)")
|
||||
.font(.caption)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 4)
|
||||
.background(rank == 1 ? Color.blue : Color.gray)
|
||||
.clipShape(Capsule())
|
||||
|
||||
Spacer()
|
||||
|
||||
// Primary city label
|
||||
Text(primaryCity)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
// Route summary
|
||||
Text(routeSummary)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
// Stats row
|
||||
HStack(spacing: 20) {
|
||||
StatPill(icon: "sportscourt.fill", value: "\(totalGames)", label: "games")
|
||||
StatPill(icon: "mappin.circle.fill", value: "\(uniqueCities)", label: "cities")
|
||||
StatPill(icon: "car.fill", value: formatDriving(option.totalDrivingHours), label: "driving")
|
||||
}
|
||||
|
||||
// Games preview
|
||||
if !option.stops.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ForEach(option.stops.prefix(3), id: \.city) { stop in
|
||||
HStack(spacing: 8) {
|
||||
Circle()
|
||||
.fill(Color.blue.opacity(0.3))
|
||||
.frame(width: 8, height: 8)
|
||||
|
||||
Text(stop.city)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Text("• \(stop.games.count) game\(stop.games.count == 1 ? "" : "s")")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
if option.stops.count > 3 {
|
||||
Text("+ \(option.stops.count - 3) more stops")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tap to view hint
|
||||
HStack {
|
||||
Spacer()
|
||||
Text("Tap to view details")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(rank == 1 ? Color.blue.opacity(0.3) : Color.clear, lineWidth: 2)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private func formatDriving(_ hours: Double) -> String {
|
||||
if hours < 1 {
|
||||
return "\(Int(hours * 60))m"
|
||||
}
|
||||
let h = Int(hours)
|
||||
let m = Int((hours - Double(h)) * 60)
|
||||
if m == 0 {
|
||||
return "\(h)h"
|
||||
}
|
||||
return "\(h)h \(m)m"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stat Pill
|
||||
|
||||
struct StatPill: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let label: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.blue)
|
||||
Text(value)
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Array Extension for Removing Duplicates
|
||||
|
||||
extension Array where Element: Hashable {
|
||||
func removingDuplicates() -> [Element] {
|
||||
var seen = Set<Element>()
|
||||
return filter { seen.insert($0).inserted }
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TripCreationView()
|
||||
}
|
||||
|
||||
@@ -323,117 +323,66 @@ struct TripDetailView: View {
|
||||
|
||||
private var itinerarySection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Route Options")
|
||||
Text("Itinerary")
|
||||
.font(.headline)
|
||||
|
||||
let combinations = computeRouteCombinations()
|
||||
|
||||
if combinations.count == 1 {
|
||||
// Single route - show fully expanded
|
||||
SingleRouteView(
|
||||
route: combinations[0],
|
||||
days: trip.itineraryDays(),
|
||||
games: games
|
||||
ForEach(tripDays, id: \.self) { dayDate in
|
||||
SimpleDayCard(
|
||||
dayNumber: dayNumber(for: dayDate),
|
||||
date: dayDate,
|
||||
gamesOnDay: gamesOn(date: dayDate),
|
||||
travelOnDay: travelOn(date: dayDate)
|
||||
)
|
||||
} 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()
|
||||
/// All calendar days in the trip
|
||||
private var tripDays: [Date] {
|
||||
let calendar = Calendar.current
|
||||
guard let startDate = trip.stops.first?.arrivalDate,
|
||||
let endDate = trip.stops.last?.departureDate else { return [] }
|
||||
|
||||
// Build options for each day
|
||||
var dayOptions: [[DayChoice]] = []
|
||||
var days: [Date] = []
|
||||
var current = calendar.startOfDay(for: startDate)
|
||||
let end = calendar.startOfDay(for: endDate)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
while current <= end {
|
||||
days.append(current)
|
||||
current = calendar.date(byAdding: .day, value: 1, to: current)!
|
||||
}
|
||||
|
||||
// Compute cartesian product of all day options
|
||||
return cartesianProduct(dayOptions)
|
||||
return days
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Day number for a given date
|
||||
private func dayNumber(for date: Date) -> Int {
|
||||
guard let firstDay = tripDays.first else { return 1 }
|
||||
let calendar = Calendar.current
|
||||
let dayStart = calendar.startOfDay(for: day.date)
|
||||
let days = calendar.dateComponents([.day], from: firstDay, to: date).day ?? 0
|
||||
return days + 1
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
/// Games scheduled on a specific date
|
||||
private func gamesOn(date: Date) -> [RichGame] {
|
||||
let calendar = Calendar.current
|
||||
let dayStart = calendar.startOfDay(for: date)
|
||||
|
||||
// Get all game IDs from all stops
|
||||
let allGameIds = trip.stops.flatMap { $0.games }
|
||||
|
||||
return allGameIds.compactMap { games[$0] }.filter { richGame in
|
||||
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||
}
|
||||
}
|
||||
|
||||
// Get unique cities with games today
|
||||
let citiesWithGames = Set(stopsWithGamesToday.map { $0.city })
|
||||
/// Travel segments departing on a specific date
|
||||
private func travelOn(date: Date) -> [TravelSegment] {
|
||||
let calendar = Calendar.current
|
||||
let dayStart = calendar.startOfDay(for: date)
|
||||
|
||||
if citiesWithGames.count > 1 {
|
||||
return DayConflictInfo(
|
||||
hasConflict: true,
|
||||
conflictingStops: stopsWithGamesToday,
|
||||
conflictingCities: Array(citiesWithGames)
|
||||
)
|
||||
return trip.travelSegments.filter { segment in
|
||||
calendar.startOfDay(for: segment.departureTime) == dayStart
|
||||
}
|
||||
|
||||
return DayConflictInfo(hasConflict: false, conflictingStops: [], conflictingCities: [])
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
@@ -482,376 +431,116 @@ struct TripDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Day Conflict Info
|
||||
// MARK: - Simple Day Card (queries games and travel separately by date)
|
||||
|
||||
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 {
|
||||
struct SimpleDayCard: View {
|
||||
let dayNumber: Int
|
||||
let stop: TripStop
|
||||
let game: RichGame?
|
||||
let date: Date
|
||||
let gamesOnDay: [RichGame]
|
||||
let travelOnDay: [TravelSegment]
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(dayNumber)
|
||||
hasher.combine(stop.city)
|
||||
private var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE, MMM d"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
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: " → ")
|
||||
private var isRestDay: Bool {
|
||||
gamesOnDay.isEmpty && travelOnDay.isEmpty
|
||||
}
|
||||
|
||||
/// Cities in the route
|
||||
private var routeCities: String {
|
||||
route.map { $0.stop.city }.joined(separator: " → ")
|
||||
/// City where games are (from stadium)
|
||||
private var gameCity: String? {
|
||||
gamesOnDay.first?.stadium.city
|
||||
}
|
||||
|
||||
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())
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
// Day header
|
||||
HStack {
|
||||
Text("Day \(dayNumber)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
// Game sequence summary
|
||||
Text(routeSummary)
|
||||
Text(formattedDate)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer()
|
||||
|
||||
if isRestDay {
|
||||
Text("Rest Day")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.green.opacity(0.2))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
// Games (each as its own row)
|
||||
if !gamesOnDay.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// City label
|
||||
if let city = gameCity {
|
||||
Label(city, systemImage: "mappin")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
ForEach(gamesOnDay, id: \.game.id) { richGame in
|
||||
HStack {
|
||||
Image(systemName: richGame.game.sport.iconName)
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 20)
|
||||
Text(richGame.matchupDescription)
|
||||
.font(.subheadline)
|
||||
Spacer()
|
||||
Text(richGame.game.gameTime)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Travel segments (each as its own row, separate from games)
|
||||
ForEach(travelOnDay) { segment in
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: segment.travelMode.iconName)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Drive to \(segment.toLocation.name)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
// Cities
|
||||
Text(routeCities)
|
||||
Text("\(segment.formattedDistance) • \(segment.formattedDuration)")
|
||||
.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(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(Color.orange.opacity(0.15))
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.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)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.orange.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(hasConflict ? Color.orange.opacity(0.05) : Color(.secondarySystemBackground))
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(hasConflict ? Color.orange.opacity(0.3) : Color.clear, lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user