// // 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 { @Environment(\.colorScheme) private var colorScheme let item: TimelineItem let games: [String: RichGame] let isFirst: Bool let isLast: Bool init( item: TimelineItem, games: [String: 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 { Theme.surfaceGlow(colorScheme) } @ViewBuilder private var itemIcon: some View { Group { 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)) } } .accessibilityHidden(true) } // 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 { @Environment(\.colorScheme) private var colorScheme let stop: ItineraryStop let games: [String: 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(Theme.textSecondary(colorScheme)) } Spacer() Text(stop.arrivalDate.formatted(date: .abbreviated, time: .omitted)) .font(.caption) .foregroundStyle(Theme.textSecondary(colorScheme)) } // 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(Theme.textMuted(colorScheme)) .italic() } } .padding() .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: 12)) } } // MARK: - Travel Item Content struct TravelItemContent: View { @Environment(\.colorScheme) private var colorScheme let segment: TravelSegment var body: some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(segment.travelMode == .drive ? "Drive" : "Fly") .font(.subheadline) .fontWeight(.medium) Text("\u{2022}") .foregroundStyle(Theme.textMuted(colorScheme)) .accessibilityHidden(true) Text(segment.formattedDistance) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) Text("\u{2022}") .foregroundStyle(Theme.textMuted(colorScheme)) .accessibilityHidden(true) Text(segment.formattedDuration) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) } Text("\(segment.fromLocation.name) \u{2192} \(segment.toLocation.name)") .font(.caption) .foregroundStyle(Theme.textSecondary(colorScheme)) .accessibilityLabel("\(segment.fromLocation.name) to \(segment.toLocation.name)") // EV Charging stops if applicable if !segment.evChargingStops.isEmpty { HStack(spacing: 4) { Image(systemName: "bolt.fill") .foregroundStyle(.green) .accessibilityHidden(true) Text("\(segment.evChargingStops.count) charging stop(s)") .font(.caption) .foregroundStyle(Theme.textSecondary(colorScheme)) } } } .padding(.vertical, 8) .padding(.horizontal, 12) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: 8)) } } // MARK: - Rest Item Content struct RestItemContent: View { @Environment(\.colorScheme) private var colorScheme 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(Theme.textSecondary(colorScheme)) } Text(rest.location.name) .font(.caption) .foregroundStyle(Theme.textSecondary(colorScheme)) if let notes = rest.notes { Text(notes) .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) .italic() } } .padding(.vertical, 8) .padding(.horizontal, 12) .background(Color.purple.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 8)) } } // MARK: - Timeline Game Row struct TimelineGameRow: View { @Environment(\.colorScheme) private var colorScheme 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) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { // Matchup Text(richGame.matchupDescription) .font(.subheadline) .fontWeight(.medium) // Time and venue (stadium local time) HStack(spacing: 4) { Text(richGame.localGameTimeShort) Text("\u{2022}") .accessibilityHidden(true) Text(richGame.stadium.name) } .font(.caption) .foregroundStyle(Theme.textSecondary(colorScheme)) } Spacer() } .accessibilityElement(children: .combine) .padding(.vertical, 4) } } // MARK: - Timeline View /// Full timeline view for an itinerary option. struct TimelineView: View { let option: ItineraryOption let games: [String: 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 { @Environment(\.colorScheme) private var colorScheme let option: ItineraryOption let games: [String: 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(Theme.surfaceGlow(colorScheme)) .frame(width: 20, height: 2) } else { // Standard connector with arrow HStack(spacing: 0) { Rectangle() .fill(Theme.surfaceGlow(colorScheme)) .frame(width: 16, height: 2) Image(systemName: "chevron.right") .font(.caption2) .foregroundStyle(Theme.textMuted(colorScheme)) Rectangle() .fill(Theme.surfaceGlow(colorScheme)) .frame(width: 16, height: 2) } } } } // MARK: - Horizontal Timeline Item View struct HorizontalTimelineItemView: View { @Environment(\.colorScheme) private var colorScheme let item: TimelineItem let games: [String: RichGame] var body: some View { VStack(spacing: 4) { itemIcon Text(shortLabel) .font(.caption2) .foregroundStyle(Theme.textSecondary(colorScheme)) .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(Theme.cardBackgroundElevated(colorScheme))) 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 // ) // // let option = ItineraryOption( // rank: 1, // stops: [stop1, stop2], // travelSegments: [segment], // totalDrivingHours: 6, // totalDistanceMiles: 380, // geographicRationale: "LA → SF" // ) // // return ScrollView { // TimelineView(option: option, games: [:]) // .padding() // } //}