UI overhaul: new color palette, trip creation improvements, crash fix
Theme: - New teal/cyan/mint/pink/gold color palette replacing orange/cream - Added Theme.swift, ViewModifiers.swift, AnimatedComponents.swift Trip Creation: - Removed Drive/Fly toggle (drive-only for now) - Removed Lodging Type picker - Renamed "Number of Stops" to "Number of Cities" with explanation - Added explanation for "Find Other Sports Along Route" - Removed staggered animation from trip options list Bug Fix: - Disabled AI route description generation (Foundation Models crashes in iOS 26.2 Simulator due to NLLanguageRecognizer assertion failure) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import SwiftData
|
||||
|
||||
struct HomeView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Query(sort: \SavedTrip.updatedAt, order: .reverse) private var savedTrips: [SavedTrip]
|
||||
|
||||
@State private var showNewTrip = false
|
||||
@@ -18,30 +19,37 @@ struct HomeView: View {
|
||||
// Home Tab
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
VStack(spacing: Theme.Spacing.xl) {
|
||||
// Hero Card
|
||||
heroCard
|
||||
.staggeredAnimation(index: 0)
|
||||
|
||||
// Quick Actions
|
||||
quickActions
|
||||
.staggeredAnimation(index: 1)
|
||||
|
||||
// Saved Trips
|
||||
if !savedTrips.isEmpty {
|
||||
savedTripsSection
|
||||
.staggeredAnimation(index: 2)
|
||||
}
|
||||
|
||||
// Featured / Tips
|
||||
tipsSection
|
||||
.staggeredAnimation(index: 3)
|
||||
}
|
||||
.padding()
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
.navigationTitle("Sport Travel Planner")
|
||||
.themedBackground()
|
||||
.navigationTitle("SportsTime")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Button {
|
||||
showNewTrip = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +86,7 @@ struct HomeView: View {
|
||||
}
|
||||
.tag(3)
|
||||
}
|
||||
.tint(Theme.warmOrange)
|
||||
.sheet(isPresented: $showNewTrip) {
|
||||
TripCreationView()
|
||||
}
|
||||
@@ -86,49 +95,57 @@ struct HomeView: View {
|
||||
// MARK: - Hero Card
|
||||
|
||||
private var heroCard: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Plan Your Ultimate Sports Road Trip")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
Text("Adventure Awaits")
|
||||
.font(.system(size: Theme.FontSize.heroTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text("Visit multiple stadiums, catch live games, and create unforgettable memories.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Plan your ultimate sports road trip. Visit stadiums, catch games, and create unforgettable memories.")
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Button {
|
||||
showNewTrip = true
|
||||
} label: {
|
||||
Text("Start Planning")
|
||||
.fontWeight(.semibold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.blue)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
Image(systemName: "map.fill")
|
||||
Text("Start Planning")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.warmOrange)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
.pressableStyle()
|
||||
.glowEffect(color: Theme.warmOrange, radius: 12)
|
||||
}
|
||||
.padding()
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [.blue.opacity(0.1), .green.opacity(0.1)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.xlarge))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.xlarge)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: Theme.cardShadow(colorScheme), radius: 15, y: 8)
|
||||
}
|
||||
|
||||
// MARK: - Quick Actions
|
||||
|
||||
private var quickActions: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
Text("Quick Start")
|
||||
.font(.headline)
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
HStack(spacing: 12) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(Sport.supported) { sport in
|
||||
QuickSportButton(sport: sport) {
|
||||
// Start trip with this sport pre-selected
|
||||
showNewTrip = true
|
||||
}
|
||||
}
|
||||
@@ -139,20 +156,29 @@ struct HomeView: View {
|
||||
// MARK: - Saved Trips
|
||||
|
||||
private var savedTripsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
HStack {
|
||||
Text("Recent Trips")
|
||||
.font(.headline)
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Spacer()
|
||||
Button("See All") {
|
||||
Button {
|
||||
selectedTab = 2
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("See All")
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
.font(.subheadline)
|
||||
}
|
||||
|
||||
ForEach(savedTrips.prefix(3)) { savedTrip in
|
||||
ForEach(Array(savedTrips.prefix(3).enumerated()), id: \.element.id) { index, savedTrip in
|
||||
if let trip = savedTrip.trip {
|
||||
SavedTripCard(trip: trip)
|
||||
SavedTripCard(savedTrip: savedTrip, trip: trip)
|
||||
.staggeredAnimation(index: index, delay: 0.05)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,18 +187,23 @@ struct HomeView: View {
|
||||
// MARK: - Tips
|
||||
|
||||
private var tipsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
Text("Planning Tips")
|
||||
.font(.headline)
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
VStack(spacing: 8) {
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
TipRow(icon: "calendar.badge.clock", title: "Check schedules early", subtitle: "Game times can change, sync often")
|
||||
TipRow(icon: "car.fill", title: "Plan rest days", subtitle: "Don't overdo the driving")
|
||||
TipRow(icon: "star.fill", title: "Mark must-sees", subtitle: "Ensure your favorite matchups are included")
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -182,58 +213,107 @@ struct HomeView: View {
|
||||
struct QuickSportButton: View {
|
||||
let sport: Sport
|
||||
let action: () -> Void
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var isPressed = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: sport.iconName)
|
||||
.font(.title)
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(sport.themeColor.opacity(0.15))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: sport.iconName)
|
||||
.font(.title2)
|
||||
.foregroundStyle(sport.themeColor)
|
||||
}
|
||||
|
||||
Text(sport.rawValue)
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.font(.system(size: Theme.FontSize.micro, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
.scaleEffect(isPressed ? 0.95 : 1.0)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
withAnimation(Theme.Animation.spring) { isPressed = true }
|
||||
}
|
||||
.onEnded { _ in
|
||||
withAnimation(Theme.Animation.spring) { isPressed = false }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SavedTripCard: View {
|
||||
let savedTrip: SavedTrip
|
||||
let trip: Trip
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
NavigationLink {
|
||||
TripDetailView(trip: trip, games: [:])
|
||||
TripDetailView(trip: trip, games: savedTrip.games)
|
||||
} label: {
|
||||
HStack {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Route preview icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: "map.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(trip.name)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(trip.formattedDateRange)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Label("\(trip.stops.count) cities", systemImage: "mappin")
|
||||
Label("\(trip.totalGames) games", systemImage: "sportscourt")
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "mappin")
|
||||
.font(.caption2)
|
||||
Text("\(trip.stops.count) cities")
|
||||
}
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "sportscourt")
|
||||
.font(.caption2)
|
||||
Text("\(trip.totalGames) games")
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -243,21 +323,27 @@ struct TipRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let subtitle: String
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.blue)
|
||||
.frame(width: 30)
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.routeGold.opacity(0.15))
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(Theme.routeGold)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
@@ -269,37 +355,100 @@ struct TipRow: View {
|
||||
|
||||
struct SavedTripsListView: View {
|
||||
let trips: [SavedTrip]
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Group {
|
||||
if trips.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Saved Trips",
|
||||
systemImage: "suitcase",
|
||||
description: Text("Your planned trips will appear here")
|
||||
EmptyStateView(
|
||||
icon: "suitcase",
|
||||
title: "No Saved Trips",
|
||||
message: "Your planned adventures will appear here. Start planning your first sports road trip!"
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
ForEach(trips) { savedTrip in
|
||||
if let trip = savedTrip.trip {
|
||||
NavigationLink {
|
||||
TripDetailView(trip: trip, games: [:])
|
||||
} label: {
|
||||
VStack(alignment: .leading) {
|
||||
Text(trip.name)
|
||||
.font(.headline)
|
||||
Text(trip.formattedDateRange)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
LazyVStack(spacing: Theme.Spacing.md) {
|
||||
ForEach(Array(trips.enumerated()), id: \.element.id) { index, savedTrip in
|
||||
if let trip = savedTrip.trip {
|
||||
NavigationLink {
|
||||
TripDetailView(trip: trip, games: savedTrip.games)
|
||||
} label: {
|
||||
SavedTripListRow(trip: trip)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.staggeredAnimation(index: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
}
|
||||
}
|
||||
.themedBackground()
|
||||
.navigationTitle("My Trips")
|
||||
}
|
||||
}
|
||||
|
||||
struct SavedTripListRow: View {
|
||||
let trip: Trip
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Route preview
|
||||
VStack(spacing: 4) {
|
||||
ForEach(0..<min(3, trip.stops.count), id: \.self) { i in
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(Double(3 - i) / 3))
|
||||
.frame(width: 8, height: 8)
|
||||
if i < min(2, trip.stops.count - 1) {
|
||||
Rectangle()
|
||||
.fill(Theme.routeGold.opacity(0.5))
|
||||
.frame(width: 2, height: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(width: 20)
|
||||
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text(trip.name)
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(trip.formattedDateRange)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
// Route preview strip
|
||||
if !trip.stops.isEmpty {
|
||||
RoutePreviewStrip(cities: trip.stops.map { $0.city })
|
||||
}
|
||||
|
||||
// Stats
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
StatPill(icon: "mappin.circle", value: "\(trip.stops.count) cities")
|
||||
StatPill(icon: "sportscourt", value: "\(trip.totalGames) games")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: Theme.cardShadow(colorScheme), radius: 8, y: 4)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HomeView()
|
||||
.modelContainer(for: SavedTrip.self, inMemory: true)
|
||||
|
||||
@@ -250,7 +250,7 @@ struct TeamBadge: View {
|
||||
HStack(spacing: 4) {
|
||||
if let colorHex = team.primaryColor {
|
||||
Circle()
|
||||
.fill(Color(hex: colorHex) ?? .gray)
|
||||
.fill(Color(hex: colorHex))
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
|
||||
@@ -318,24 +318,6 @@ struct DateRangePickerSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Color Extension
|
||||
|
||||
extension Color {
|
||||
init?(hex: String) {
|
||||
var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")
|
||||
|
||||
var rgb: UInt64 = 0
|
||||
guard Scanner(string: hexSanitized).scanHexInt64(&rgb) else { return nil }
|
||||
|
||||
let r = Double((rgb & 0xFF0000) >> 16) / 255.0
|
||||
let g = Double((rgb & 0x00FF00) >> 8) / 255.0
|
||||
let b = Double(rgb & 0x0000FF) / 255.0
|
||||
|
||||
self.init(red: r, green: g, blue: b)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
ScheduleListView()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import MapKit
|
||||
|
||||
struct TripDetailView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
let trip: Trip
|
||||
let games: [UUID: RichGame]
|
||||
@@ -29,28 +30,36 @@ struct TripDetailView: View {
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
// Header
|
||||
tripHeader
|
||||
VStack(spacing: 0) {
|
||||
// Hero Map
|
||||
heroMapSection
|
||||
.frame(height: 280)
|
||||
|
||||
// Score Card
|
||||
if let score = trip.score {
|
||||
scoreCard(score)
|
||||
// Content
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Header
|
||||
tripHeader
|
||||
.padding(.top, Theme.Spacing.lg)
|
||||
|
||||
// Stats Row
|
||||
statsRow
|
||||
|
||||
// Score Card
|
||||
if let score = trip.score {
|
||||
scoreCard(score)
|
||||
}
|
||||
|
||||
// Day-by-Day Itinerary
|
||||
itinerarySection
|
||||
}
|
||||
|
||||
// Stats
|
||||
statsGrid
|
||||
|
||||
// Map Preview
|
||||
mapPreview
|
||||
|
||||
// Day-by-Day Itinerary
|
||||
itinerarySection
|
||||
.padding(.horizontal, Theme.Spacing.lg)
|
||||
.padding(.bottom, Theme.Spacing.xxl)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.background(Theme.backgroundGradient(colorScheme))
|
||||
.navigationTitle(trip.name)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button {
|
||||
@@ -59,6 +68,7 @@ struct TripDetailView: View {
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
Menu {
|
||||
@@ -78,6 +88,7 @@ struct TripDetailView: View {
|
||||
.disabled(isSaved)
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,136 +114,215 @@ struct TripDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Hero Map Section
|
||||
|
||||
private var heroMapSection: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
Map(position: $mapCameraPosition) {
|
||||
ForEach(stopCoordinates.indices, id: \.self) { index in
|
||||
let stop = stopCoordinates[index]
|
||||
Annotation(stop.name, coordinate: stop.coordinate) {
|
||||
PulsingDot(color: index == 0 ? Theme.warmOrange : Theme.routeGold, size: 10)
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(routePolylines.indices, id: \.self) { index in
|
||||
MapPolyline(routePolylines[index])
|
||||
.stroke(Theme.routeGold, lineWidth: 4)
|
||||
}
|
||||
}
|
||||
.mapStyle(colorScheme == .dark ? .standard(elevation: .flat, emphasis: .muted) : .standard)
|
||||
|
||||
// Gradient overlay at bottom
|
||||
LinearGradient(
|
||||
colors: [.clear, Theme.cardBackground(colorScheme).opacity(0.8), Theme.cardBackground(colorScheme)],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 80)
|
||||
|
||||
// Loading indicator
|
||||
if isLoadingRoutes {
|
||||
ProgressView()
|
||||
.tint(Theme.warmOrange)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
.task {
|
||||
updateMapRegion()
|
||||
await fetchDrivingRoutes()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var tripHeader: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Date range
|
||||
Text(trip.formattedDateRange)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
HStack(spacing: 16) {
|
||||
// Route preview
|
||||
RoutePreviewStrip(cities: trip.stops.map { $0.city })
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
|
||||
// Sport badges
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
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())
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: sport.iconName)
|
||||
.font(.system(size: 10))
|
||||
Text(sport.rawValue)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(sport.themeColor.opacity(0.2))
|
||||
.foregroundStyle(sport.themeColor)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
// MARK: - Stats Row
|
||||
|
||||
private var statsRow: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
StatPill(icon: "calendar", value: "\(trip.tripDuration) days")
|
||||
StatPill(icon: "mappin.circle", value: "\(trip.stops.count) cities")
|
||||
StatPill(icon: "sportscourt", value: "\(trip.totalGames) games")
|
||||
StatPill(icon: "road.lanes", value: trip.formattedTotalDistance)
|
||||
StatPill(icon: "car", value: trip.formattedTotalDriving)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Score Card
|
||||
|
||||
private func scoreCard(_ score: TripScore) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
HStack {
|
||||
Text("Trip Score")
|
||||
.font(.headline)
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Spacer()
|
||||
Text(score.scoreGrade)
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.green)
|
||||
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
.glowEffect(color: Theme.warmOrange, radius: 8)
|
||||
}
|
||||
|
||||
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)
|
||||
HStack(spacing: Theme.Spacing.lg) {
|
||||
scoreItem(label: "Games", value: score.gameQualityScore, color: Theme.mlbRed)
|
||||
scoreItem(label: "Route", value: score.routeEfficiencyScore, color: Theme.routeGold)
|
||||
scoreItem(label: "Balance", value: score.leisureBalanceScore, color: Theme.mlsGreen)
|
||||
scoreItem(label: "Prefs", value: score.preferenceAlignmentScore, color: Theme.nbaOrange)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.cardStyle()
|
||||
}
|
||||
|
||||
private func scoreItem(label: String, value: Double) -> some View {
|
||||
private func scoreItem(label: String, value: Double, color: Color) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(String(format: "%.0f", value))
|
||||
.font(.headline)
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold))
|
||||
.foregroundStyle(color)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stats Grid
|
||||
// MARK: - Itinerary
|
||||
|
||||
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 var itinerarySection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
Text("Itinerary")
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
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)
|
||||
ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in
|
||||
switch section {
|
||||
case .day(let dayNumber, let date, let gamesOnDay):
|
||||
DaySection(
|
||||
dayNumber: dayNumber,
|
||||
date: date,
|
||||
games: gamesOnDay
|
||||
)
|
||||
.staggeredAnimation(index: index)
|
||||
case .travel(let segment):
|
||||
TravelSection(segment: segment)
|
||||
.staggeredAnimation(index: index)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// Build itinerary sections: days and travel between days
|
||||
private var itinerarySections: [ItinerarySection] {
|
||||
var sections: [ItinerarySection] = []
|
||||
let calendar = Calendar.current
|
||||
let days = tripDays
|
||||
|
||||
for (index, dayDate) in days.enumerated() {
|
||||
let dayNum = index + 1
|
||||
let gamesOnDay = gamesOn(date: dayDate)
|
||||
|
||||
if !gamesOnDay.isEmpty || index == 0 || index == days.count - 1 {
|
||||
sections.append(.day(dayNumber: dayNum, date: dayDate, games: gamesOnDay))
|
||||
}
|
||||
|
||||
let travelAfterDay = travelDepartingAfter(date: dayDate, beforeNextGameDay: days.indices.contains(index + 1) ? days[index + 1] : nil)
|
||||
for segment in travelAfterDay {
|
||||
sections.append(.travel(segment))
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
private var tripDays: [Date] {
|
||||
let calendar = Calendar.current
|
||||
guard let startDate = trip.stops.first?.arrivalDate,
|
||||
let endDate = trip.stops.last?.departureDate else { return [] }
|
||||
|
||||
var days: [Date] = []
|
||||
var current = calendar.startOfDay(for: startDate)
|
||||
let end = calendar.startOfDay(for: endDate)
|
||||
|
||||
while current <= end {
|
||||
days.append(current)
|
||||
current = calendar.date(byAdding: .day, value: 1, to: current)!
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
private func gamesOn(date: Date) -> [RichGame] {
|
||||
let calendar = Calendar.current
|
||||
let dayStart = calendar.startOfDay(for: date)
|
||||
let allGameIds = trip.stops.flatMap { $0.games }
|
||||
|
||||
return allGameIds.compactMap { games[$0] }.filter { richGame in
|
||||
calendar.startOfDay(for: richGame.game.dateTime) == dayStart
|
||||
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
}
|
||||
|
||||
private func travelDepartingAfter(date: Date, beforeNextGameDay: Date?) -> [TravelSegment] {
|
||||
let calendar = Calendar.current
|
||||
let dayEnd = calendar.startOfDay(for: date)
|
||||
|
||||
return trip.travelSegments.filter { segment in
|
||||
let segmentDay = calendar.startOfDay(for: segment.departureTime)
|
||||
return segmentDay == dayEnd
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Map Helpers
|
||||
|
||||
private func fetchDrivingRoutes() async {
|
||||
let stops = stopCoordinates
|
||||
guard stops.count >= 2 else { return }
|
||||
@@ -257,8 +347,6 @@ struct TripDetailView: View {
|
||||
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)
|
||||
}
|
||||
@@ -268,14 +356,11 @@ struct TripDetailView: View {
|
||||
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)
|
||||
@@ -284,14 +369,6 @@ struct TripDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
|
||||
@@ -309,7 +386,6 @@ struct TripDetailView: View {
|
||||
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
|
||||
|
||||
@@ -319,99 +395,6 @@ struct TripDetailView: View {
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - Itinerary
|
||||
|
||||
private var itinerarySection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Itinerary")
|
||||
.font(.headline)
|
||||
|
||||
ForEach(itinerarySections.indices, id: \.self) { index in
|
||||
let section = itinerarySections[index]
|
||||
switch section {
|
||||
case .day(let dayNumber, let date, let gamesOnDay):
|
||||
DaySection(
|
||||
dayNumber: dayNumber,
|
||||
date: date,
|
||||
games: gamesOnDay
|
||||
)
|
||||
case .travel(let segment):
|
||||
TravelSection(segment: segment)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build itinerary sections: days and travel between days
|
||||
private var itinerarySections: [ItinerarySection] {
|
||||
var sections: [ItinerarySection] = []
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Get all days
|
||||
let days = tripDays
|
||||
|
||||
for (index, dayDate) in days.enumerated() {
|
||||
let dayNum = index + 1
|
||||
let gamesOnDay = gamesOn(date: dayDate)
|
||||
|
||||
// Add day section (even if no games - could be rest day)
|
||||
if !gamesOnDay.isEmpty || index == 0 || index == days.count - 1 {
|
||||
sections.append(.day(dayNumber: dayNum, date: dayDate, games: gamesOnDay))
|
||||
}
|
||||
|
||||
// Check for travel AFTER this day (between this day and next)
|
||||
let travelAfterDay = travelDepartingAfter(date: dayDate, beforeNextGameDay: days.indices.contains(index + 1) ? days[index + 1] : nil)
|
||||
for segment in travelAfterDay {
|
||||
sections.append(.travel(segment))
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
/// 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 [] }
|
||||
|
||||
var days: [Date] = []
|
||||
var current = calendar.startOfDay(for: startDate)
|
||||
let end = calendar.startOfDay(for: endDate)
|
||||
|
||||
while current <= end {
|
||||
days.append(current)
|
||||
current = calendar.date(byAdding: .day, value: 1, to: current)!
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
/// 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
|
||||
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
}
|
||||
|
||||
/// Travel segments that depart after a given day (for between-day travel)
|
||||
private func travelDepartingAfter(date: Date, beforeNextGameDay: Date?) -> [TravelSegment] {
|
||||
let calendar = Calendar.current
|
||||
let dayEnd = calendar.startOfDay(for: date)
|
||||
|
||||
return trip.travelSegments.filter { segment in
|
||||
let segmentDay = calendar.startOfDay(for: segment.departureTime)
|
||||
// Travel is "after" this day if it departs on or after this day
|
||||
// and arrives at a different city
|
||||
return segmentDay == dayEnd
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func exportPDF() async {
|
||||
@@ -430,7 +413,7 @@ struct TripDetailView: View {
|
||||
}
|
||||
|
||||
private func saveTrip() {
|
||||
guard let savedTrip = SavedTrip.from(trip, status: .planned) else {
|
||||
guard let savedTrip = SavedTrip.from(trip, games: games, status: .planned) else {
|
||||
print("Failed to create SavedTrip")
|
||||
return
|
||||
}
|
||||
@@ -458,24 +441,23 @@ struct TripDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Itinerary Section (enum for day vs travel sections)
|
||||
// MARK: - Itinerary Section
|
||||
|
||||
enum ItinerarySection {
|
||||
case day(dayNumber: Int, date: Date, games: [RichGame])
|
||||
case travel(TravelSegment)
|
||||
}
|
||||
|
||||
// MARK: - Day Section (header + games)
|
||||
// MARK: - Day Section
|
||||
|
||||
struct DaySection: View {
|
||||
let dayNumber: Int
|
||||
let date: Date
|
||||
let games: [RichGame]
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
private var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE, MMM d"
|
||||
return formatter.string(from: date)
|
||||
date.formatted(.dateTime.weekday(.wide).month().day())
|
||||
}
|
||||
|
||||
private var gameCity: String? {
|
||||
@@ -487,105 +469,151 @@ struct DaySection: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Day header
|
||||
HStack {
|
||||
Text("Day \(dayNumber)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.blue)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Day \(dayNumber)")
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(formattedDate)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(formattedDate)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isRestDay {
|
||||
Text("Rest Day")
|
||||
.font(.caption)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 3)
|
||||
.background(Color.green.opacity(0.2))
|
||||
.clipShape(Capsule())
|
||||
.badgeStyle(color: Theme.mlsGreen, filled: false)
|
||||
} else if !games.isEmpty {
|
||||
Text("\(games.count) game\(games.count > 1 ? "s" : "")")
|
||||
.badgeStyle(color: Theme.warmOrange, filled: false)
|
||||
}
|
||||
}
|
||||
|
||||
// City label (if games exist)
|
||||
// City label
|
||||
if let city = gameCity {
|
||||
Label(city, systemImage: "mappin")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
// Games
|
||||
ForEach(games, 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(.tertiarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
GameRow(game: richGame)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.cardStyle()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Travel Section (standalone travel between days)
|
||||
// MARK: - Game Row
|
||||
|
||||
struct GameRow: View {
|
||||
let game: RichGame
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Sport color bar
|
||||
SportColorBar(sport: game.game.sport)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Matchup
|
||||
HStack(spacing: 4) {
|
||||
Text(game.awayTeam.abbreviation)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .bold))
|
||||
Text("@")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
Text(game.homeTeam.abbreviation)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
// Stadium
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "building.2")
|
||||
.font(.system(size: 10))
|
||||
Text(game.stadium.name)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
}
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Time
|
||||
Text(game.game.gameTime)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
.padding(Theme.Spacing.sm)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Travel Section
|
||||
|
||||
struct TravelSection: View {
|
||||
let segment: TravelSegment
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Travel header
|
||||
Text("Travel")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(.orange)
|
||||
VStack(spacing: 0) {
|
||||
// Top connector
|
||||
Rectangle()
|
||||
.fill(Theme.routeGold.opacity(0.4))
|
||||
.frame(width: 2, height: 16)
|
||||
|
||||
// Travel details
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: segment.travelMode.iconName)
|
||||
.foregroundStyle(.orange)
|
||||
.frame(width: 20)
|
||||
// Travel card
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.cardBackgroundElevated(colorScheme))
|
||||
.frame(width: 44, height: 44)
|
||||
|
||||
Image(systemName: "car.fill")
|
||||
.foregroundStyle(Theme.routeGold)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(segment.fromLocation.name) → \(segment.toLocation.name)")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
Text("Travel")
|
||||
.font(.system(size: Theme.FontSize.micro, weight: .semibold))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
Text("\(segment.formattedDistance) • \(segment.formattedDuration)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("\(segment.fromLocation.name) → \(segment.toLocation.name)")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(segment.formattedDistance)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text(segment.formattedDuration)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 10)
|
||||
.background(Color.orange.opacity(0.1))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme).opacity(0.7))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.strokeBorder(Theme.routeGold.opacity(0.3), lineWidth: 1)
|
||||
}
|
||||
|
||||
// Bottom connector
|
||||
Rectangle()
|
||||
.fill(Theme.routeGold.opacity(0.4))
|
||||
.frame(width: 2, height: 16)
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.stroke(Color.orange.opacity(0.3), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user