Replace all system colors (.secondary, Color(.secondarySystemBackground), etc.) with Theme.textPrimary/textSecondary/textMuted/cardBackground/ surfaceGlow across 13 views. Remove PostHog debug logging. Add debug settings for sample trips and hardcoded group poll preview. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
488 lines
14 KiB
Swift
488 lines
14 KiB
Swift
//
|
|
// 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()
|
|
// }
|
|
//}
|