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:
@@ -72,7 +72,7 @@ extension Game: Equatable {
|
|||||||
|
|
||||||
// MARK: - Rich Game Model (with resolved references)
|
// MARK: - Rich Game Model (with resolved references)
|
||||||
|
|
||||||
struct RichGame: Identifiable, Hashable {
|
struct RichGame: Identifiable, Hashable, Codable {
|
||||||
let game: Game
|
let game: Game
|
||||||
let homeTeam: Team
|
let homeTeam: Team
|
||||||
let awayTeam: Team
|
let awayTeam: Team
|
||||||
|
|||||||
@@ -45,13 +45,14 @@ enum Sport: String, Codable, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var seasonMonths: ClosedRange<Int> {
|
/// Season start and end months (1-12). End may be less than start for seasons that wrap around the year.
|
||||||
|
var seasonMonths: (start: Int, end: Int) {
|
||||||
switch self {
|
switch self {
|
||||||
case .mlb: return 3...10 // March - October
|
case .mlb: return (3, 10) // March - October
|
||||||
case .nba: return 10...6 // October - June (wraps)
|
case .nba: return (10, 6) // October - June (wraps)
|
||||||
case .nhl: return 10...6 // October - June (wraps)
|
case .nhl: return (10, 6) // October - June (wraps)
|
||||||
case .nfl: return 9...2 // September - February (wraps)
|
case .nfl: return (9, 2) // September - February (wraps)
|
||||||
case .mls: return 2...12 // February - December
|
case .mls: return (2, 12) // February - December
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +60,13 @@ enum Sport: String, Codable, CaseIterable, Identifiable {
|
|||||||
let calendar = Calendar.current
|
let calendar = Calendar.current
|
||||||
let month = calendar.component(.month, from: date)
|
let month = calendar.component(.month, from: date)
|
||||||
|
|
||||||
let range = seasonMonths
|
let (start, end) = seasonMonths
|
||||||
if range.lowerBound <= range.upperBound {
|
if start <= end {
|
||||||
return range.contains(month)
|
// Normal range (e.g., March to October)
|
||||||
|
return month >= start && month <= end
|
||||||
} else {
|
} else {
|
||||||
// Season wraps around year boundary
|
// Season wraps around year boundary (e.g., October to June)
|
||||||
return month >= range.lowerBound || month <= range.upperBound
|
return month >= start || month <= end
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ final class SavedTrip {
|
|||||||
var updatedAt: Date
|
var updatedAt: Date
|
||||||
var status: String
|
var status: String
|
||||||
var tripData: Data // Encoded Trip struct
|
var tripData: Data // Encoded Trip struct
|
||||||
|
var gamesData: Data? // Encoded [UUID: RichGame] dictionary
|
||||||
|
|
||||||
@Relationship(deleteRule: .cascade)
|
@Relationship(deleteRule: .cascade)
|
||||||
var votes: [TripVote]?
|
var votes: [TripVote]?
|
||||||
@@ -26,7 +27,8 @@ final class SavedTrip {
|
|||||||
createdAt: Date = Date(),
|
createdAt: Date = Date(),
|
||||||
updatedAt: Date = Date(),
|
updatedAt: Date = Date(),
|
||||||
status: TripStatus = .planned,
|
status: TripStatus = .planned,
|
||||||
tripData: Data
|
tripData: Data,
|
||||||
|
gamesData: Data? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.name = name
|
self.name = name
|
||||||
@@ -34,25 +36,33 @@ final class SavedTrip {
|
|||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
self.status = status.rawValue
|
self.status = status.rawValue
|
||||||
self.tripData = tripData
|
self.tripData = tripData
|
||||||
|
self.gamesData = gamesData
|
||||||
}
|
}
|
||||||
|
|
||||||
var trip: Trip? {
|
var trip: Trip? {
|
||||||
try? JSONDecoder().decode(Trip.self, from: tripData)
|
try? JSONDecoder().decode(Trip.self, from: tripData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var games: [UUID: RichGame] {
|
||||||
|
guard let data = gamesData else { return [:] }
|
||||||
|
return (try? JSONDecoder().decode([UUID: RichGame].self, from: data)) ?? [:]
|
||||||
|
}
|
||||||
|
|
||||||
var tripStatus: TripStatus {
|
var tripStatus: TripStatus {
|
||||||
TripStatus(rawValue: status) ?? .draft
|
TripStatus(rawValue: status) ?? .draft
|
||||||
}
|
}
|
||||||
|
|
||||||
static func from(_ trip: Trip, status: TripStatus = .planned) -> SavedTrip? {
|
static func from(_ trip: Trip, games: [UUID: RichGame] = [:], status: TripStatus = .planned) -> SavedTrip? {
|
||||||
guard let data = try? JSONEncoder().encode(trip) else { return nil }
|
guard let tripData = try? JSONEncoder().encode(trip) else { return nil }
|
||||||
|
let gamesData = try? JSONEncoder().encode(games)
|
||||||
return SavedTrip(
|
return SavedTrip(
|
||||||
id: trip.id,
|
id: trip.id,
|
||||||
name: trip.name,
|
name: trip.name,
|
||||||
createdAt: trip.createdAt,
|
createdAt: trip.createdAt,
|
||||||
updatedAt: trip.updatedAt,
|
updatedAt: trip.updatedAt,
|
||||||
status: status,
|
status: status,
|
||||||
tripData: data
|
tripData: tripData,
|
||||||
|
gamesData: gamesData
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
148
SportsTime/Core/Services/RouteDescriptionGenerator.swift
Normal file
148
SportsTime/Core/Services/RouteDescriptionGenerator.swift
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
//
|
||||||
|
// RouteDescriptionGenerator.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// On-device AI route description generation using Foundation Models
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import FoundationModels
|
||||||
|
|
||||||
|
/// Generates human-readable route descriptions using on-device AI
|
||||||
|
@MainActor
|
||||||
|
@Observable
|
||||||
|
final class RouteDescriptionGenerator {
|
||||||
|
static let shared = RouteDescriptionGenerator()
|
||||||
|
|
||||||
|
private(set) var isAvailable = false
|
||||||
|
private var session: LanguageModelSession?
|
||||||
|
|
||||||
|
// Cache generated descriptions by option ID
|
||||||
|
private var cache: [UUID: String] = [:]
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
checkAvailability()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func checkAvailability() {
|
||||||
|
// TEMPORARILY DISABLED: Foundation Models crashes in iOS 26.2 Simulator
|
||||||
|
// due to NLLanguageRecognizer.processString: assertion failure in Collection.suffix(from:)
|
||||||
|
// TODO: Re-enable when Apple fixes the Foundation Models framework
|
||||||
|
isAvailable = false
|
||||||
|
return
|
||||||
|
|
||||||
|
// Original code (disabled):
|
||||||
|
// switch SystemLanguageModel.default.availability {
|
||||||
|
// case .available:
|
||||||
|
// isAvailable = true
|
||||||
|
// session = LanguageModelSession(instructions: """
|
||||||
|
// You are a travel copywriter creating exciting, brief descriptions for sports road trips.
|
||||||
|
// Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip.
|
||||||
|
// Keep descriptions to 1-2 short sentences maximum.
|
||||||
|
// """)
|
||||||
|
// case .unavailable:
|
||||||
|
// isAvailable = false
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a brief, exciting description for a route option
|
||||||
|
func generateDescription(for option: RouteDescriptionInput) async -> String? {
|
||||||
|
// Check cache first
|
||||||
|
if let cached = cache[option.id] {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
guard isAvailable, let session = session else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = buildPrompt(for: option)
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await session.respond(
|
||||||
|
to: prompt,
|
||||||
|
generating: RouteDescription.self
|
||||||
|
)
|
||||||
|
|
||||||
|
let description = response.content.description
|
||||||
|
cache[option.id] = description
|
||||||
|
return description
|
||||||
|
|
||||||
|
} catch LanguageModelSession.GenerationError.guardrailViolation {
|
||||||
|
return nil
|
||||||
|
} catch LanguageModelSession.GenerationError.exceededContextWindowSize {
|
||||||
|
// Reset session if context exceeded
|
||||||
|
self.session = LanguageModelSession(instructions: """
|
||||||
|
You are a travel copywriter creating exciting, brief descriptions for sports road trips.
|
||||||
|
Write in an enthusiastic but concise style. Focus on the adventure and variety of the trip.
|
||||||
|
Keep descriptions to 1-2 short sentences maximum.
|
||||||
|
""")
|
||||||
|
return nil
|
||||||
|
} catch {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildPrompt(for option: RouteDescriptionInput) -> String {
|
||||||
|
let citiesText = option.cities.joined(separator: ", ")
|
||||||
|
let sportsText = option.sports.isEmpty ? "sports" : option.sports.joined(separator: ", ")
|
||||||
|
|
||||||
|
var details = [String]()
|
||||||
|
details.append("\(option.totalGames) games")
|
||||||
|
details.append("\(option.cities.count) cities: \(citiesText)")
|
||||||
|
|
||||||
|
if option.totalMiles > 0 {
|
||||||
|
details.append("\(Int(option.totalMiles)) miles")
|
||||||
|
}
|
||||||
|
if option.totalDrivingHours > 0 {
|
||||||
|
details.append("\(String(format: "%.1f", option.totalDrivingHours)) hours driving")
|
||||||
|
}
|
||||||
|
|
||||||
|
return """
|
||||||
|
Write a brief, exciting 1-sentence description for this sports road trip:
|
||||||
|
- Route: \(citiesText)
|
||||||
|
- Sports: \(sportsText)
|
||||||
|
- Details: \(details.joined(separator: ", "))
|
||||||
|
|
||||||
|
Make it sound like an adventure. Be concise.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the cache (e.g., when starting a new trip search)
|
||||||
|
func clearCache() {
|
||||||
|
cache.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Generable Types
|
||||||
|
|
||||||
|
@Generable
|
||||||
|
struct RouteDescription {
|
||||||
|
@Guide(description: "A brief, exciting 1-2 sentence description of the road trip route")
|
||||||
|
let description: String
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Input Model
|
||||||
|
|
||||||
|
struct RouteDescriptionInput: Identifiable {
|
||||||
|
let id: UUID
|
||||||
|
let cities: [String]
|
||||||
|
let sports: [String]
|
||||||
|
let totalGames: Int
|
||||||
|
let totalMiles: Double
|
||||||
|
let totalDrivingHours: Double
|
||||||
|
|
||||||
|
init(from option: ItineraryOption, games: [UUID: RichGame]) {
|
||||||
|
self.id = option.id
|
||||||
|
self.cities = Array(NSOrderedSet(array: option.stops.map { $0.city })) as? [String] ?? []
|
||||||
|
|
||||||
|
// Extract sports from games
|
||||||
|
let gameIds = option.stops.flatMap { $0.games }
|
||||||
|
let sportSet = Set(gameIds.compactMap { games[$0]?.game.sport.rawValue })
|
||||||
|
self.sports = Array(sportSet)
|
||||||
|
|
||||||
|
self.totalGames = option.totalGames
|
||||||
|
self.totalMiles = option.totalDistanceMiles
|
||||||
|
self.totalDrivingHours = option.totalDrivingHours
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -282,7 +282,7 @@ actor StubDataProvider: DataProvider {
|
|||||||
let timeWithoutAMPM = cleanTime.replacingOccurrences(of: "p", with: "").replacingOccurrences(of: "a", with: "")
|
let timeWithoutAMPM = cleanTime.replacingOccurrences(of: "p", with: "").replacingOccurrences(of: "a", with: "")
|
||||||
|
|
||||||
let components = timeWithoutAMPM.split(separator: ":")
|
let components = timeWithoutAMPM.split(separator: ":")
|
||||||
if let h = Int(components[0]) {
|
if !components.isEmpty, let h = Int(components[0]) {
|
||||||
hour = h
|
hour = h
|
||||||
if isPM && hour != 12 {
|
if isPM && hour != 12 {
|
||||||
hour += 12
|
hour += 12
|
||||||
|
|||||||
324
SportsTime/Core/Theme/AnimatedComponents.swift
Normal file
324
SportsTime/Core/Theme/AnimatedComponents.swift
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
//
|
||||||
|
// AnimatedComponents.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Animated UI components for visual delight.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Animated Route Graphic
|
||||||
|
|
||||||
|
/// A stylized animated route illustration for loading states
|
||||||
|
struct AnimatedRouteGraphic: View {
|
||||||
|
@State private var animationProgress: CGFloat = 0
|
||||||
|
@State private var dotPositions: [CGFloat] = [0.1, 0.4, 0.7]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let width = geo.size.width
|
||||||
|
let height = geo.size.height
|
||||||
|
|
||||||
|
Canvas { context, size in
|
||||||
|
// Draw the route path
|
||||||
|
let path = createRoutePath(in: CGRect(origin: .zero, size: size))
|
||||||
|
|
||||||
|
// Glow layer
|
||||||
|
context.addFilter(.blur(radius: 8))
|
||||||
|
context.stroke(
|
||||||
|
path,
|
||||||
|
with: .linearGradient(
|
||||||
|
Gradient(colors: [Theme.routeGold.opacity(0.5), Theme.warmOrange.opacity(0.5)]),
|
||||||
|
startPoint: .zero,
|
||||||
|
endPoint: CGPoint(x: size.width, y: size.height)
|
||||||
|
),
|
||||||
|
lineWidth: 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset filter and draw main line
|
||||||
|
context.addFilter(.blur(radius: 0))
|
||||||
|
context.stroke(
|
||||||
|
path,
|
||||||
|
with: .linearGradient(
|
||||||
|
Gradient(colors: [Theme.routeGold, Theme.warmOrange]),
|
||||||
|
startPoint: .zero,
|
||||||
|
endPoint: CGPoint(x: size.width, y: size.height)
|
||||||
|
),
|
||||||
|
style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animated traveling dot
|
||||||
|
Circle()
|
||||||
|
.fill(Theme.warmOrange)
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.glowEffect(color: Theme.warmOrange, radius: 12)
|
||||||
|
.position(
|
||||||
|
x: width * 0.1 + (width * 0.8) * animationProgress,
|
||||||
|
y: height * 0.6 + sin(animationProgress * .pi * 2) * (height * 0.2)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Stadium markers
|
||||||
|
ForEach(Array(dotPositions.enumerated()), id: \.offset) { index, pos in
|
||||||
|
PulsingDot(color: index == 0 ? Theme.mlbRed : (index == 1 ? Theme.nbaOrange : Theme.nhlBlue))
|
||||||
|
.frame(width: 16, height: 16)
|
||||||
|
.position(
|
||||||
|
x: width * 0.1 + (width * 0.8) * pos,
|
||||||
|
y: height * 0.6 + sin(pos * .pi * 2) * (height * 0.2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeInOut(duration: Theme.Animation.routeDrawDuration).repeatForever(autoreverses: false)) {
|
||||||
|
animationProgress = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createRoutePath(in rect: CGRect) -> Path {
|
||||||
|
Path { path in
|
||||||
|
let startX = rect.width * 0.1
|
||||||
|
let endX = rect.width * 0.9
|
||||||
|
let midY = rect.height * 0.6
|
||||||
|
let amplitude = rect.height * 0.2
|
||||||
|
|
||||||
|
path.move(to: CGPoint(x: startX, y: midY))
|
||||||
|
|
||||||
|
// Create a smooth curve through the points
|
||||||
|
let controlPoints: [(CGFloat, CGFloat)] = [
|
||||||
|
(0.25, midY - amplitude * 0.5),
|
||||||
|
(0.4, midY + amplitude * 0.3),
|
||||||
|
(0.55, midY - amplitude * 0.4),
|
||||||
|
(0.7, midY + amplitude * 0.2),
|
||||||
|
(0.85, midY - amplitude * 0.1)
|
||||||
|
]
|
||||||
|
|
||||||
|
for (progress, y) in controlPoints {
|
||||||
|
let x = startX + (endX - startX) * progress
|
||||||
|
path.addLine(to: CGPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
|
||||||
|
path.addLine(to: CGPoint(x: endX, y: midY))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pulsing Dot
|
||||||
|
|
||||||
|
struct PulsingDot: View {
|
||||||
|
var color: Color = Theme.warmOrange
|
||||||
|
var size: CGFloat = 12
|
||||||
|
|
||||||
|
@State private var isPulsing = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
// Outer pulse ring
|
||||||
|
Circle()
|
||||||
|
.stroke(color.opacity(0.3), lineWidth: 2)
|
||||||
|
.frame(width: size * 2, height: size * 2)
|
||||||
|
.scaleEffect(isPulsing ? 1.5 : 1)
|
||||||
|
.opacity(isPulsing ? 0 : 1)
|
||||||
|
|
||||||
|
// Inner dot
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.shadow(color: color.opacity(0.5), radius: 4)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.easeOut(duration: 1.5).repeatForever(autoreverses: false)) {
|
||||||
|
isPulsing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Route Preview Strip
|
||||||
|
|
||||||
|
/// A compact horizontal visualization of route stops
|
||||||
|
struct RoutePreviewStrip: View {
|
||||||
|
let cities: [String]
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
init(cities: [String]) {
|
||||||
|
self.cities = cities
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
ForEach(Array(cities.prefix(5).enumerated()), id: \.offset) { index, city in
|
||||||
|
if index > 0 {
|
||||||
|
// Connector line
|
||||||
|
Rectangle()
|
||||||
|
.fill(Theme.routeGold.opacity(0.5))
|
||||||
|
.frame(width: 16, height: 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// City dot with label
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Circle()
|
||||||
|
.fill(index == 0 || index == cities.count - 1 ? Theme.warmOrange : Theme.routeGold)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
|
||||||
|
Text(abbreviateCity(city))
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cities.count > 5 {
|
||||||
|
Text("+\(cities.count - 5)")
|
||||||
|
.font(.system(size: 10, weight: .medium))
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func abbreviateCity(_ city: String) -> String {
|
||||||
|
let words = city.split(separator: " ")
|
||||||
|
if words.count > 1 {
|
||||||
|
return String(words[0].prefix(3))
|
||||||
|
}
|
||||||
|
return String(city.prefix(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Planning Progress View
|
||||||
|
|
||||||
|
struct PlanningProgressView: View {
|
||||||
|
@State private var currentStep = 0
|
||||||
|
let steps = ["Finding games...", "Calculating routes...", "Optimizing itinerary..."]
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 32) {
|
||||||
|
// Animated route illustration
|
||||||
|
AnimatedRouteGraphic()
|
||||||
|
.frame(height: 150)
|
||||||
|
.padding(.horizontal, 40)
|
||||||
|
|
||||||
|
// Current step text
|
||||||
|
Text(steps[currentStep])
|
||||||
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
.animation(.easeInOut, value: currentStep)
|
||||||
|
|
||||||
|
// Progress dots
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(0..<steps.count, id: \.self) { i in
|
||||||
|
Circle()
|
||||||
|
.fill(i <= currentStep ? Theme.warmOrange : Theme.textMuted(colorScheme).opacity(0.3))
|
||||||
|
.frame(width: i == currentStep ? 10 : 8, height: i == currentStep ? 10 : 8)
|
||||||
|
.animation(Theme.Animation.spring, value: currentStep)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 60)
|
||||||
|
.task {
|
||||||
|
await animateSteps()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func animateSteps() async {
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .milliseconds(1500))
|
||||||
|
guard !Task.isCancelled else { break }
|
||||||
|
withAnimation(Theme.Animation.spring) {
|
||||||
|
currentStep = (currentStep + 1) % steps.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stat Pill
|
||||||
|
|
||||||
|
struct StatPill: View {
|
||||||
|
let icon: String
|
||||||
|
let value: String
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 12))
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
}
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Empty State View
|
||||||
|
|
||||||
|
struct EmptyStateView: View {
|
||||||
|
let icon: String
|
||||||
|
let title: String
|
||||||
|
let message: String
|
||||||
|
var actionTitle: String? = nil
|
||||||
|
var action: (() -> Void)? = nil
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 48))
|
||||||
|
.foregroundStyle(Theme.warmOrange.opacity(0.7))
|
||||||
|
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 20, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
Text(message)
|
||||||
|
.font(.system(size: 15))
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let actionTitle = actionTitle, let action = action {
|
||||||
|
Button(action: action) {
|
||||||
|
Text(actionTitle)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(Theme.warmOrange)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.pressableStyle()
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Preview
|
||||||
|
|
||||||
|
#Preview("Animated Components") {
|
||||||
|
VStack(spacing: 40) {
|
||||||
|
AnimatedRouteGraphic()
|
||||||
|
.frame(height: 150)
|
||||||
|
|
||||||
|
RoutePreviewStrip(cities: ["San Diego", "Los Angeles", "San Francisco", "Seattle", "Portland"])
|
||||||
|
|
||||||
|
PlanningProgressView()
|
||||||
|
|
||||||
|
HStack {
|
||||||
|
StatPill(icon: "car", value: "450 mi")
|
||||||
|
StatPill(icon: "clock", value: "8h driving")
|
||||||
|
}
|
||||||
|
|
||||||
|
PulsingDot(color: Theme.warmOrange)
|
||||||
|
.frame(width: 40, height: 40)
|
||||||
|
}
|
||||||
|
.padding()
|
||||||
|
.themedBackground()
|
||||||
|
}
|
||||||
186
SportsTime/Core/Theme/Theme.swift
Normal file
186
SportsTime/Core/Theme/Theme.swift
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
//
|
||||||
|
// Theme.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Central design system for colors, typography, spacing, and animations.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Theme
|
||||||
|
|
||||||
|
enum Theme {
|
||||||
|
|
||||||
|
// MARK: - Accent Colors (Same in Light/Dark)
|
||||||
|
|
||||||
|
static let warmOrange = Color(hex: "4ECDC4") // Strong Cyan (primary accent)
|
||||||
|
static let warmOrangeGlow = Color(hex: "6ED9D1") // Lighter cyan glow
|
||||||
|
|
||||||
|
static let routeGold = Color(hex: "FFE66D") // Royal Gold
|
||||||
|
static let routeAmber = Color(hex: "FF6B6B") // Grapefruit Pink
|
||||||
|
|
||||||
|
// New palette colors
|
||||||
|
static let primaryCyan = Color(hex: "4ECDC4") // Strong Cyan
|
||||||
|
static let darkTeal = Color(hex: "1A535C") // Dark Teal
|
||||||
|
static let mintCream = Color(hex: "F7FFF7") // Mint Cream
|
||||||
|
static let grapefruit = Color(hex: "FF6B6B") // Grapefruit Pink
|
||||||
|
static let royalGold = Color(hex: "FFE66D") // Royal Gold
|
||||||
|
|
||||||
|
// MARK: - Sport Colors
|
||||||
|
|
||||||
|
static let mlbRed = Color(hex: "E31937")
|
||||||
|
static let nbaOrange = Color(hex: "F58426")
|
||||||
|
static let nhlBlue = Color(hex: "003087")
|
||||||
|
static let nflBrown = Color(hex: "8B5A2B")
|
||||||
|
static let mlsGreen = Color(hex: "00A651")
|
||||||
|
|
||||||
|
// MARK: - Dark Mode Colors
|
||||||
|
|
||||||
|
static let darkBackground1 = Color(hex: "1A535C") // Dark Teal
|
||||||
|
static let darkBackground2 = Color(hex: "143F46") // Darker teal
|
||||||
|
static let darkCardBackground = Color(hex: "1E5A64") // Slightly lighter teal
|
||||||
|
static let darkCardBackgroundLight = Color(hex: "2A6B75") // Card elevated
|
||||||
|
static let darkSurfaceGlow = Color(hex: "4ECDC4").opacity(0.15) // Cyan glow
|
||||||
|
static let darkTextPrimary = Color(hex: "F7FFF7") // Mint Cream
|
||||||
|
static let darkTextSecondary = Color(hex: "B8E8E4") // Light cyan-tinted
|
||||||
|
static let darkTextMuted = Color(hex: "7FADA8") // Muted teal
|
||||||
|
|
||||||
|
// MARK: - Light Mode Colors
|
||||||
|
|
||||||
|
static let lightBackground1 = Color(hex: "F7FFF7") // Mint Cream
|
||||||
|
static let lightBackground2 = Color(hex: "E8F8F5") // Slightly darker mint
|
||||||
|
static let lightCardBackground = Color.white
|
||||||
|
static let lightCardBackgroundElevated = Color(hex: "F7FFF7") // Mint cream
|
||||||
|
static let lightSurfaceBorder = Color(hex: "4ECDC4").opacity(0.3) // Cyan border
|
||||||
|
static let lightTextPrimary = Color(hex: "1A535C") // Dark Teal
|
||||||
|
static let lightTextSecondary = Color(hex: "2A6B75") // Medium teal
|
||||||
|
static let lightTextMuted = Color(hex: "5A9A94") // Light teal
|
||||||
|
|
||||||
|
// MARK: - Adaptive Gradients
|
||||||
|
|
||||||
|
static func backgroundGradient(_ colorScheme: ColorScheme) -> LinearGradient {
|
||||||
|
colorScheme == .dark
|
||||||
|
? LinearGradient(colors: [darkBackground1, darkBackground2], startPoint: .top, endPoint: .bottom)
|
||||||
|
: LinearGradient(colors: [lightBackground1, lightBackground2], startPoint: .top, endPoint: .bottom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Adaptive Colors
|
||||||
|
|
||||||
|
static func cardBackground(_ colorScheme: ColorScheme) -> Color {
|
||||||
|
colorScheme == .dark ? darkCardBackground : lightCardBackground
|
||||||
|
}
|
||||||
|
|
||||||
|
static func cardBackgroundElevated(_ colorScheme: ColorScheme) -> Color {
|
||||||
|
colorScheme == .dark ? darkCardBackgroundLight : lightCardBackgroundElevated
|
||||||
|
}
|
||||||
|
|
||||||
|
static func textPrimary(_ colorScheme: ColorScheme) -> Color {
|
||||||
|
colorScheme == .dark ? darkTextPrimary : lightTextPrimary
|
||||||
|
}
|
||||||
|
|
||||||
|
static func textSecondary(_ colorScheme: ColorScheme) -> Color {
|
||||||
|
colorScheme == .dark ? darkTextSecondary : lightTextSecondary
|
||||||
|
}
|
||||||
|
|
||||||
|
static func textMuted(_ colorScheme: ColorScheme) -> Color {
|
||||||
|
colorScheme == .dark ? darkTextMuted : lightTextMuted
|
||||||
|
}
|
||||||
|
|
||||||
|
static func surfaceGlow(_ colorScheme: ColorScheme) -> Color {
|
||||||
|
colorScheme == .dark ? darkSurfaceGlow : lightSurfaceBorder
|
||||||
|
}
|
||||||
|
|
||||||
|
static func cardShadow(_ colorScheme: ColorScheme) -> Color {
|
||||||
|
colorScheme == .dark ? Color.black.opacity(0.3) : Color.black.opacity(0.08)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Typography
|
||||||
|
|
||||||
|
enum FontSize {
|
||||||
|
static let heroTitle: CGFloat = 34
|
||||||
|
static let sectionTitle: CGFloat = 24
|
||||||
|
static let cardTitle: CGFloat = 18
|
||||||
|
static let body: CGFloat = 16
|
||||||
|
static let caption: CGFloat = 14
|
||||||
|
static let micro: CGFloat = 12
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Spacing
|
||||||
|
|
||||||
|
enum Spacing {
|
||||||
|
static let xxs: CGFloat = 4
|
||||||
|
static let xs: CGFloat = 8
|
||||||
|
static let sm: CGFloat = 12
|
||||||
|
static let md: CGFloat = 16
|
||||||
|
static let lg: CGFloat = 20
|
||||||
|
static let xl: CGFloat = 24
|
||||||
|
static let xxl: CGFloat = 32
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Corner Radius
|
||||||
|
|
||||||
|
enum CornerRadius {
|
||||||
|
static let small: CGFloat = 8
|
||||||
|
static let medium: CGFloat = 12
|
||||||
|
static let large: CGFloat = 16
|
||||||
|
static let xlarge: CGFloat = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Animation
|
||||||
|
|
||||||
|
enum Animation {
|
||||||
|
static let springResponse: Double = 0.3
|
||||||
|
static let springDamping: Double = 0.7
|
||||||
|
static let staggerDelay: Double = 0.1
|
||||||
|
static let routeDrawDuration: Double = 2.0
|
||||||
|
|
||||||
|
static var spring: SwiftUI.Animation {
|
||||||
|
.spring(response: springResponse, dampingFraction: springDamping)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var gentleSpring: SwiftUI.Animation {
|
||||||
|
.spring(response: 0.5, dampingFraction: 0.8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Color Hex Extension
|
||||||
|
|
||||||
|
extension Color {
|
||||||
|
init(hex: String) {
|
||||||
|
let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
|
||||||
|
var int: UInt64 = 0
|
||||||
|
Scanner(string: hex).scanHexInt64(&int)
|
||||||
|
let a, r, g, b: UInt64
|
||||||
|
switch hex.count {
|
||||||
|
case 3: // RGB (12-bit)
|
||||||
|
(a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
|
||||||
|
case 6: // RGB (24-bit)
|
||||||
|
(a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
|
||||||
|
case 8: // ARGB (32-bit)
|
||||||
|
(a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
|
||||||
|
default:
|
||||||
|
(a, r, g, b) = (255, 0, 0, 0)
|
||||||
|
}
|
||||||
|
self.init(
|
||||||
|
.sRGB,
|
||||||
|
red: Double(r) / 255,
|
||||||
|
green: Double(g) / 255,
|
||||||
|
blue: Double(b) / 255,
|
||||||
|
opacity: Double(a) / 255
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Environment Key for Theme
|
||||||
|
|
||||||
|
private struct ColorSchemeKey: EnvironmentKey {
|
||||||
|
static let defaultValue: ColorScheme = .light
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var themeColorScheme: ColorScheme {
|
||||||
|
get { self[ColorSchemeKey.self] }
|
||||||
|
set { self[ColorSchemeKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
220
SportsTime/Core/Theme/ViewModifiers.swift
Normal file
220
SportsTime/Core/Theme/ViewModifiers.swift
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
//
|
||||||
|
// ViewModifiers.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Reusable view modifiers for consistent styling across the app.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
// MARK: - Card Style Modifier
|
||||||
|
|
||||||
|
struct CardStyle: ViewModifier {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
var cornerRadius: CGFloat = Theme.CornerRadius.large
|
||||||
|
var padding: CGFloat = Theme.Spacing.lg
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.padding(padding)
|
||||||
|
.background(Theme.cardBackground(colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: cornerRadius)
|
||||||
|
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||||
|
}
|
||||||
|
.shadow(color: Theme.cardShadow(colorScheme), radius: 10, y: 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func cardStyle(cornerRadius: CGFloat = Theme.CornerRadius.large, padding: CGFloat = Theme.Spacing.lg) -> some View {
|
||||||
|
modifier(CardStyle(cornerRadius: cornerRadius, padding: padding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Glow Effect Modifier
|
||||||
|
|
||||||
|
struct GlowEffect: ViewModifier {
|
||||||
|
var color: Color = Theme.warmOrange
|
||||||
|
var radius: CGFloat = 8
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.shadow(color: color.opacity(0.5), radius: radius / 2, y: 0)
|
||||||
|
.shadow(color: color.opacity(0.3), radius: radius, y: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func glowEffect(color: Color = Theme.warmOrange, radius: CGFloat = 8) -> some View {
|
||||||
|
modifier(GlowEffect(color: color, radius: radius))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Pressable Button Style
|
||||||
|
|
||||||
|
struct PressableButtonStyle: ButtonStyle {
|
||||||
|
var scale: CGFloat = 0.96
|
||||||
|
|
||||||
|
func makeBody(configuration: Configuration) -> some View {
|
||||||
|
configuration.label
|
||||||
|
.scaleEffect(configuration.isPressed ? scale : 1.0)
|
||||||
|
.animation(Theme.Animation.spring, value: configuration.isPressed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func pressableStyle(scale: CGFloat = 0.96) -> some View {
|
||||||
|
buttonStyle(PressableButtonStyle(scale: scale))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Shimmer Effect Modifier
|
||||||
|
|
||||||
|
struct ShimmerEffect: ViewModifier {
|
||||||
|
@State private var phase: CGFloat = 0
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.overlay {
|
||||||
|
GeometryReader { geo in
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
.clear,
|
||||||
|
Color.white.opacity(0.3),
|
||||||
|
.clear
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
.frame(width: geo.size.width * 2)
|
||||||
|
.offset(x: -geo.size.width + (geo.size.width * 2 * phase))
|
||||||
|
}
|
||||||
|
.mask(content)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) {
|
||||||
|
phase = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func shimmer() -> some View {
|
||||||
|
modifier(ShimmerEffect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Staggered Animation Modifier
|
||||||
|
|
||||||
|
struct StaggeredAnimation: ViewModifier {
|
||||||
|
var index: Int
|
||||||
|
var delay: Double = Theme.Animation.staggerDelay
|
||||||
|
@State private var appeared = false
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.opacity(appeared ? 1 : 0)
|
||||||
|
.offset(y: appeared ? 0 : 20)
|
||||||
|
.onAppear {
|
||||||
|
withAnimation(Theme.Animation.spring.delay(Double(index) * delay)) {
|
||||||
|
appeared = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func staggeredAnimation(index: Int, delay: Double = Theme.Animation.staggerDelay) -> some View {
|
||||||
|
modifier(StaggeredAnimation(index: index, delay: delay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Badge Style Modifier
|
||||||
|
|
||||||
|
struct BadgeStyle: ViewModifier {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
var color: Color = Theme.warmOrange
|
||||||
|
var filled: Bool = true
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.font(.system(size: Theme.FontSize.micro, weight: .semibold))
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(filled ? color : color.opacity(0.2))
|
||||||
|
.foregroundStyle(filled ? .white : color)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func badgeStyle(color: Color = Theme.warmOrange, filled: Bool = true) -> some View {
|
||||||
|
modifier(BadgeStyle(color: color, filled: filled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Section Header Style
|
||||||
|
|
||||||
|
struct SectionHeaderStyle: ViewModifier {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func sectionHeaderStyle() -> some View {
|
||||||
|
modifier(SectionHeaderStyle())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Themed Background Modifier
|
||||||
|
|
||||||
|
struct ThemedBackground: ViewModifier {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.background(Theme.backgroundGradient(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func themedBackground() -> some View {
|
||||||
|
modifier(ThemedBackground())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sport Color Bar
|
||||||
|
|
||||||
|
struct SportColorBar: View {
|
||||||
|
let sport: Sport
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(sport.themeColor)
|
||||||
|
.frame(width: 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sport Extension for Theme Colors
|
||||||
|
|
||||||
|
extension Sport {
|
||||||
|
var themeColor: Color {
|
||||||
|
switch self {
|
||||||
|
case .mlb: return Theme.mlbRed
|
||||||
|
case .nba: return Theme.nbaOrange
|
||||||
|
case .nhl: return Theme.nhlBlue
|
||||||
|
case .nfl: return Theme.nflBrown
|
||||||
|
case .mls: return Theme.mlsGreen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import SwiftData
|
|||||||
|
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@Query(sort: \SavedTrip.updatedAt, order: .reverse) private var savedTrips: [SavedTrip]
|
@Query(sort: \SavedTrip.updatedAt, order: .reverse) private var savedTrips: [SavedTrip]
|
||||||
|
|
||||||
@State private var showNewTrip = false
|
@State private var showNewTrip = false
|
||||||
@@ -18,30 +19,37 @@ struct HomeView: View {
|
|||||||
// Home Tab
|
// Home Tab
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: Theme.Spacing.xl) {
|
||||||
// Hero Card
|
// Hero Card
|
||||||
heroCard
|
heroCard
|
||||||
|
.staggeredAnimation(index: 0)
|
||||||
|
|
||||||
// Quick Actions
|
// Quick Actions
|
||||||
quickActions
|
quickActions
|
||||||
|
.staggeredAnimation(index: 1)
|
||||||
|
|
||||||
// Saved Trips
|
// Saved Trips
|
||||||
if !savedTrips.isEmpty {
|
if !savedTrips.isEmpty {
|
||||||
savedTripsSection
|
savedTripsSection
|
||||||
|
.staggeredAnimation(index: 2)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Featured / Tips
|
// Featured / Tips
|
||||||
tipsSection
|
tipsSection
|
||||||
|
.staggeredAnimation(index: 3)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(Theme.Spacing.md)
|
||||||
}
|
}
|
||||||
.navigationTitle("Sport Travel Planner")
|
.themedBackground()
|
||||||
|
.navigationTitle("SportsTime")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .primaryAction) {
|
ToolbarItem(placement: .primaryAction) {
|
||||||
Button {
|
Button {
|
||||||
showNewTrip = true
|
showNewTrip = true
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus.circle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,6 +86,7 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
.tag(3)
|
.tag(3)
|
||||||
}
|
}
|
||||||
|
.tint(Theme.warmOrange)
|
||||||
.sheet(isPresented: $showNewTrip) {
|
.sheet(isPresented: $showNewTrip) {
|
||||||
TripCreationView()
|
TripCreationView()
|
||||||
}
|
}
|
||||||
@@ -86,49 +95,57 @@ struct HomeView: View {
|
|||||||
// MARK: - Hero Card
|
// MARK: - Hero Card
|
||||||
|
|
||||||
private var heroCard: some View {
|
private var heroCard: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(spacing: Theme.Spacing.lg) {
|
||||||
Text("Plan Your Ultimate Sports Road Trip")
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
.font(.title2)
|
Text("Adventure Awaits")
|
||||||
.fontWeight(.bold)
|
.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.")
|
Text("Plan your ultimate sports road trip. Visit stadiums, catch games, and create unforgettable memories.")
|
||||||
.font(.subheadline)
|
.font(.system(size: Theme.FontSize.body))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
showNewTrip = true
|
showNewTrip = true
|
||||||
} label: {
|
} label: {
|
||||||
Text("Start Planning")
|
HStack(spacing: Theme.Spacing.xs) {
|
||||||
.fontWeight(.semibold)
|
Image(systemName: "map.fill")
|
||||||
.frame(maxWidth: .infinity)
|
Text("Start Planning")
|
||||||
.padding()
|
.fontWeight(.semibold)
|
||||||
.background(Color.blue)
|
}
|
||||||
.foregroundStyle(.white)
|
.frame(maxWidth: .infinity)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.padding(Theme.Spacing.md)
|
||||||
|
.background(Theme.warmOrange)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
}
|
}
|
||||||
|
.pressableStyle()
|
||||||
|
.glowEffect(color: Theme.warmOrange, radius: 12)
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(Theme.Spacing.lg)
|
||||||
.background(
|
.background(Theme.cardBackground(colorScheme))
|
||||||
LinearGradient(
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.xlarge))
|
||||||
colors: [.blue.opacity(0.1), .green.opacity(0.1)],
|
.overlay {
|
||||||
startPoint: .topLeading,
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.xlarge)
|
||||||
endPoint: .bottomTrailing
|
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||||
)
|
}
|
||||||
)
|
.shadow(color: Theme.cardShadow(colorScheme), radius: 15, y: 8)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Quick Actions
|
// MARK: - Quick Actions
|
||||||
|
|
||||||
private var quickActions: some View {
|
private var quickActions: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
Text("Quick Start")
|
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
|
ForEach(Sport.supported) { sport in
|
||||||
QuickSportButton(sport: sport) {
|
QuickSportButton(sport: sport) {
|
||||||
// Start trip with this sport pre-selected
|
|
||||||
showNewTrip = true
|
showNewTrip = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,20 +156,29 @@ struct HomeView: View {
|
|||||||
// MARK: - Saved Trips
|
// MARK: - Saved Trips
|
||||||
|
|
||||||
private var savedTripsSection: some View {
|
private var savedTripsSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Recent Trips")
|
Text("Recent Trips")
|
||||||
.font(.headline)
|
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
Spacer()
|
Spacer()
|
||||||
Button("See All") {
|
Button {
|
||||||
selectedTab = 2
|
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 {
|
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
|
// MARK: - Tips
|
||||||
|
|
||||||
private var tipsSection: some View {
|
private var tipsSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
Text("Planning Tips")
|
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: "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: "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")
|
TipRow(icon: "star.fill", title: "Mark must-sees", subtitle: "Ensure your favorite matchups are included")
|
||||||
}
|
}
|
||||||
.padding()
|
.padding(Theme.Spacing.md)
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Theme.cardBackground(colorScheme))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.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 {
|
struct QuickSportButton: View {
|
||||||
let sport: Sport
|
let sport: Sport
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
@State private var isPressed = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
VStack(spacing: 8) {
|
VStack(spacing: Theme.Spacing.xs) {
|
||||||
Image(systemName: sport.iconName)
|
ZStack {
|
||||||
.font(.title)
|
Circle()
|
||||||
|
.fill(sport.themeColor.opacity(0.15))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
Image(systemName: sport.iconName)
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(sport.themeColor)
|
||||||
|
}
|
||||||
|
|
||||||
Text(sport.rawValue)
|
Text(sport.rawValue)
|
||||||
.font(.caption)
|
.font(.system(size: Theme.FontSize.micro, weight: .medium))
|
||||||
.fontWeight(.medium)
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
.padding(.vertical, 16)
|
.padding(.vertical, Theme.Spacing.sm)
|
||||||
.background(Color(.secondarySystemBackground))
|
.background(Theme.cardBackground(colorScheme))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.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)
|
.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 {
|
struct SavedTripCard: View {
|
||||||
|
let savedTrip: SavedTrip
|
||||||
let trip: Trip
|
let trip: Trip
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationLink {
|
NavigationLink {
|
||||||
TripDetailView(trip: trip, games: [:])
|
TripDetailView(trip: trip, games: savedTrip.games)
|
||||||
} label: {
|
} 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) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(trip.name)
|
Text(trip.name)
|
||||||
.font(.subheadline)
|
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||||
.fontWeight(.semibold)
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
Text(trip.formattedDateRange)
|
Text(trip.formattedDateRange)
|
||||||
.font(.caption)
|
.font(.system(size: Theme.FontSize.caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
Label("\(trip.stops.count) cities", systemImage: "mappin")
|
HStack(spacing: 4) {
|
||||||
Label("\(trip.totalGames) games", systemImage: "sportscourt")
|
Image(systemName: "mappin")
|
||||||
|
.font(.caption2)
|
||||||
|
Text("\(trip.stops.count) cities")
|
||||||
|
}
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Image(systemName: "sportscourt")
|
||||||
|
.font(.caption2)
|
||||||
|
Text("\(trip.totalGames) games")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.font(.caption2)
|
.font(.system(size: Theme.FontSize.micro))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
Image(systemName: "chevron.right")
|
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)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
@@ -243,21 +323,27 @@ struct TipRow: View {
|
|||||||
let icon: String
|
let icon: String
|
||||||
let title: String
|
let title: String
|
||||||
let subtitle: String
|
let subtitle: String
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
Image(systemName: icon)
|
ZStack {
|
||||||
.font(.title3)
|
Circle()
|
||||||
.foregroundStyle(.blue)
|
.fill(Theme.routeGold.opacity(0.15))
|
||||||
.frame(width: 30)
|
.frame(width: 36, height: 36)
|
||||||
|
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 14))
|
||||||
|
.foregroundStyle(Theme.routeGold)
|
||||||
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(title)
|
Text(title)
|
||||||
.font(.subheadline)
|
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||||
.fontWeight(.medium)
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.caption)
|
.font(.system(size: Theme.FontSize.micro))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -269,37 +355,100 @@ struct TipRow: View {
|
|||||||
|
|
||||||
struct SavedTripsListView: View {
|
struct SavedTripsListView: View {
|
||||||
let trips: [SavedTrip]
|
let trips: [SavedTrip]
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
List {
|
Group {
|
||||||
if trips.isEmpty {
|
if trips.isEmpty {
|
||||||
ContentUnavailableView(
|
EmptyStateView(
|
||||||
"No Saved Trips",
|
icon: "suitcase",
|
||||||
systemImage: "suitcase",
|
title: "No Saved Trips",
|
||||||
description: Text("Your planned trips will appear here")
|
message: "Your planned adventures will appear here. Start planning your first sports road trip!"
|
||||||
)
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
} else {
|
} else {
|
||||||
ForEach(trips) { savedTrip in
|
ScrollView {
|
||||||
if let trip = savedTrip.trip {
|
LazyVStack(spacing: Theme.Spacing.md) {
|
||||||
NavigationLink {
|
ForEach(Array(trips.enumerated()), id: \.element.id) { index, savedTrip in
|
||||||
TripDetailView(trip: trip, games: [:])
|
if let trip = savedTrip.trip {
|
||||||
} label: {
|
NavigationLink {
|
||||||
VStack(alignment: .leading) {
|
TripDetailView(trip: trip, games: savedTrip.games)
|
||||||
Text(trip.name)
|
} label: {
|
||||||
.font(.headline)
|
SavedTripListRow(trip: trip)
|
||||||
Text(trip.formattedDateRange)
|
}
|
||||||
.font(.caption)
|
.buttonStyle(.plain)
|
||||||
.foregroundStyle(.secondary)
|
.staggeredAnimation(index: index)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.padding(Theme.Spacing.md)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.themedBackground()
|
||||||
.navigationTitle("My Trips")
|
.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 {
|
#Preview {
|
||||||
HomeView()
|
HomeView()
|
||||||
.modelContainer(for: SavedTrip.self, inMemory: true)
|
.modelContainer(for: SavedTrip.self, inMemory: true)
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ struct TeamBadge: View {
|
|||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
if let colorHex = team.primaryColor {
|
if let colorHex = team.primaryColor {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Color(hex: colorHex) ?? .gray)
|
.fill(Color(hex: colorHex))
|
||||||
.frame(width: 8, height: 8)
|
.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 {
|
#Preview {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ScheduleListView()
|
ScheduleListView()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import MapKit
|
|||||||
|
|
||||||
struct TripDetailView: View {
|
struct TripDetailView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
let trip: Trip
|
let trip: Trip
|
||||||
let games: [UUID: RichGame]
|
let games: [UUID: RichGame]
|
||||||
@@ -29,28 +30,36 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(spacing: 20) {
|
VStack(spacing: 0) {
|
||||||
// Header
|
// Hero Map
|
||||||
tripHeader
|
heroMapSection
|
||||||
|
.frame(height: 280)
|
||||||
|
|
||||||
// Score Card
|
// Content
|
||||||
if let score = trip.score {
|
VStack(spacing: Theme.Spacing.lg) {
|
||||||
scoreCard(score)
|
// Header
|
||||||
|
tripHeader
|
||||||
|
.padding(.top, Theme.Spacing.lg)
|
||||||
|
|
||||||
|
// Stats Row
|
||||||
|
statsRow
|
||||||
|
|
||||||
|
// Score Card
|
||||||
|
if let score = trip.score {
|
||||||
|
scoreCard(score)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Day-by-Day Itinerary
|
||||||
|
itinerarySection
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, Theme.Spacing.lg)
|
||||||
// Stats
|
.padding(.bottom, Theme.Spacing.xxl)
|
||||||
statsGrid
|
|
||||||
|
|
||||||
// Map Preview
|
|
||||||
mapPreview
|
|
||||||
|
|
||||||
// Day-by-Day Itinerary
|
|
||||||
itinerarySection
|
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
|
.background(Theme.backgroundGradient(colorScheme))
|
||||||
.navigationTitle(trip.name)
|
.navigationTitle(trip.name)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItemGroup(placement: .primaryAction) {
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
Button {
|
Button {
|
||||||
@@ -59,6 +68,7 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "square.and.arrow.up")
|
Image(systemName: "square.and.arrow.up")
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
}
|
}
|
||||||
|
|
||||||
Menu {
|
Menu {
|
||||||
@@ -78,6 +88,7 @@ struct TripDetailView: View {
|
|||||||
.disabled(isSaved)
|
.disabled(isSaved)
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "ellipsis.circle")
|
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
|
// MARK: - Header
|
||||||
|
|
||||||
private var tripHeader: some View {
|
private var tripHeader: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
|
// Date range
|
||||||
Text(trip.formattedDateRange)
|
Text(trip.formattedDateRange)
|
||||||
.font(.subheadline)
|
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||||
.foregroundStyle(.secondary)
|
.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
|
ForEach(Array(trip.uniqueSports), id: \.self) { sport in
|
||||||
Label(sport.rawValue, systemImage: sport.iconName)
|
HStack(spacing: 4) {
|
||||||
.font(.caption)
|
Image(systemName: sport.iconName)
|
||||||
.padding(.horizontal, 10)
|
.font(.system(size: 10))
|
||||||
.padding(.vertical, 5)
|
Text(sport.rawValue)
|
||||||
.background(Color.blue.opacity(0.1))
|
.font(.system(size: 11, weight: .medium))
|
||||||
.clipShape(Capsule())
|
}
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(sport.themeColor.opacity(0.2))
|
||||||
|
.foregroundStyle(sport.themeColor)
|
||||||
|
.clipShape(Capsule())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.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
|
// MARK: - Score Card
|
||||||
|
|
||||||
private func scoreCard(_ score: TripScore) -> some View {
|
private func scoreCard(_ score: TripScore) -> some View {
|
||||||
VStack(spacing: 12) {
|
VStack(spacing: Theme.Spacing.md) {
|
||||||
HStack {
|
HStack {
|
||||||
Text("Trip Score")
|
Text("Trip Score")
|
||||||
.font(.headline)
|
.font(.system(size: Theme.FontSize.cardTitle, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
Spacer()
|
Spacer()
|
||||||
Text(score.scoreGrade)
|
Text(score.scoreGrade)
|
||||||
.font(.largeTitle)
|
.font(.system(size: 32, weight: .bold, design: .rounded))
|
||||||
.fontWeight(.bold)
|
.foregroundStyle(Theme.warmOrange)
|
||||||
.foregroundStyle(.green)
|
.glowEffect(color: Theme.warmOrange, radius: 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
HStack(spacing: 20) {
|
HStack(spacing: Theme.Spacing.lg) {
|
||||||
scoreItem(label: "Games", value: score.gameQualityScore)
|
scoreItem(label: "Games", value: score.gameQualityScore, color: Theme.mlbRed)
|
||||||
scoreItem(label: "Route", value: score.routeEfficiencyScore)
|
scoreItem(label: "Route", value: score.routeEfficiencyScore, color: Theme.routeGold)
|
||||||
scoreItem(label: "Balance", value: score.leisureBalanceScore)
|
scoreItem(label: "Balance", value: score.leisureBalanceScore, color: Theme.mlsGreen)
|
||||||
scoreItem(label: "Prefs", value: score.preferenceAlignmentScore)
|
scoreItem(label: "Prefs", value: score.preferenceAlignmentScore, color: Theme.nbaOrange)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.cardStyle()
|
||||||
.background(Color(.secondarySystemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scoreItem(label: String, value: Double) -> some View {
|
private func scoreItem(label: String, value: Double, color: Color) -> some View {
|
||||||
VStack(spacing: 4) {
|
VStack(spacing: 4) {
|
||||||
Text(String(format: "%.0f", value))
|
Text(String(format: "%.0f", value))
|
||||||
.font(.headline)
|
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold))
|
||||||
|
.foregroundStyle(color)
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.caption2)
|
.font(.system(size: Theme.FontSize.micro))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Stats Grid
|
// MARK: - Itinerary
|
||||||
|
|
||||||
private var statsGrid: some View {
|
private var itinerarySection: some View {
|
||||||
LazyVGrid(columns: [
|
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||||
GridItem(.flexible()),
|
Text("Itinerary")
|
||||||
GridItem(.flexible()),
|
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||||
GridItem(.flexible())
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
], spacing: 16) {
|
|
||||||
statCell(value: "\(trip.tripDuration)", label: "Days", icon: "calendar")
|
|
||||||
statCell(value: "\(trip.stops.count)", label: "Cities", icon: "mappin.circle")
|
|
||||||
statCell(value: "\(trip.totalGames)", label: "Games", icon: "sportscourt")
|
|
||||||
statCell(value: trip.formattedTotalDistance, label: "Distance", icon: "road.lanes")
|
|
||||||
statCell(value: trip.formattedTotalDriving, label: "Driving", icon: "car")
|
|
||||||
statCell(value: String(format: "%.1fh", trip.averageDrivingHoursPerDay), label: "Avg/Day", icon: "gauge.medium")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func statCell(value: String, label: String, icon: String) -> some View {
|
ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in
|
||||||
VStack(spacing: 6) {
|
switch section {
|
||||||
Image(systemName: icon)
|
case .day(let dayNumber, let date, let gamesOnDay):
|
||||||
.font(.title2)
|
DaySection(
|
||||||
.foregroundStyle(.blue)
|
dayNumber: dayNumber,
|
||||||
Text(value)
|
date: date,
|
||||||
.font(.headline)
|
games: gamesOnDay
|
||||||
Text(label)
|
)
|
||||||
.font(.caption)
|
.staggeredAnimation(index: index)
|
||||||
.foregroundStyle(.secondary)
|
case .travel(let segment):
|
||||||
}
|
TravelSection(segment: segment)
|
||||||
.frame(maxWidth: .infinity)
|
.staggeredAnimation(index: index)
|
||||||
.padding(.vertical, 12)
|
|
||||||
.background(Color(.secondarySystemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Map Preview
|
|
||||||
|
|
||||||
private var mapPreview: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
HStack {
|
|
||||||
Text("Route")
|
|
||||||
.font(.headline)
|
|
||||||
Spacer()
|
|
||||||
if isLoadingRoutes {
|
|
||||||
ProgressView()
|
|
||||||
.scaleEffect(0.7)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Map(position: $mapCameraPosition) {
|
|
||||||
// Add markers for each stop
|
|
||||||
ForEach(stopCoordinates.indices, id: \.self) { index in
|
|
||||||
let stop = stopCoordinates[index]
|
|
||||||
Marker(stop.name, coordinate: stop.coordinate)
|
|
||||||
.tint(.blue)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add actual driving route polylines
|
|
||||||
ForEach(routePolylines.indices, id: \.self) { index in
|
|
||||||
MapPolyline(routePolylines[index])
|
|
||||||
.stroke(.blue, lineWidth: 3)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: 200)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
.task {
|
|
||||||
updateMapRegion()
|
|
||||||
await fetchDrivingRoutes()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch actual driving routes using MKDirections
|
/// 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 {
|
private func fetchDrivingRoutes() async {
|
||||||
let stops = stopCoordinates
|
let stops = stopCoordinates
|
||||||
guard stops.count >= 2 else { return }
|
guard stops.count >= 2 else { return }
|
||||||
@@ -257,8 +347,6 @@ struct TripDetailView: View {
|
|||||||
polylines.append(route.polyline)
|
polylines.append(route.polyline)
|
||||||
}
|
}
|
||||||
} catch {
|
} 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)
|
let straightLine = MKPolyline(coordinates: [source.coordinate, destination.coordinate], count: 2)
|
||||||
polylines.append(straightLine)
|
polylines.append(straightLine)
|
||||||
}
|
}
|
||||||
@@ -268,14 +356,11 @@ struct TripDetailView: View {
|
|||||||
isLoadingRoutes = false
|
isLoadingRoutes = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get coordinates for all stops (from stop coordinate or stadium)
|
|
||||||
private var stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)] {
|
private var stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)] {
|
||||||
trip.stops.compactMap { stop -> (String, CLLocationCoordinate2D)? in
|
trip.stops.compactMap { stop -> (String, CLLocationCoordinate2D)? in
|
||||||
// First try to use the stop's stored coordinate
|
|
||||||
if let coord = stop.coordinate {
|
if let coord = stop.coordinate {
|
||||||
return (stop.city, coord)
|
return (stop.city, coord)
|
||||||
}
|
}
|
||||||
// Fall back to stadium coordinate if available
|
|
||||||
if let stadiumId = stop.stadium,
|
if let stadiumId = stop.stadium,
|
||||||
let stadium = dataProvider.stadium(for: stadiumId) {
|
let stadium = dataProvider.stadium(for: stadiumId) {
|
||||||
return (stadium.name, stadium.coordinate)
|
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() {
|
private func updateMapRegion() {
|
||||||
guard !stopCoordinates.isEmpty else { return }
|
guard !stopCoordinates.isEmpty else { return }
|
||||||
|
|
||||||
@@ -309,7 +386,6 @@ struct TripDetailView: View {
|
|||||||
longitude: (minLon + maxLon) / 2
|
longitude: (minLon + maxLon) / 2
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add padding to the span
|
|
||||||
let latSpan = (maxLat - minLat) * 1.3 + 0.5
|
let latSpan = (maxLat - minLat) * 1.3 + 0.5
|
||||||
let lonSpan = (maxLon - minLon) * 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
|
// MARK: - Actions
|
||||||
|
|
||||||
private func exportPDF() async {
|
private func exportPDF() async {
|
||||||
@@ -430,7 +413,7 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func saveTrip() {
|
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")
|
print("Failed to create SavedTrip")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -458,24 +441,23 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Itinerary Section (enum for day vs travel sections)
|
// MARK: - Itinerary Section
|
||||||
|
|
||||||
enum ItinerarySection {
|
enum ItinerarySection {
|
||||||
case day(dayNumber: Int, date: Date, games: [RichGame])
|
case day(dayNumber: Int, date: Date, games: [RichGame])
|
||||||
case travel(TravelSegment)
|
case travel(TravelSegment)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Day Section (header + games)
|
// MARK: - Day Section
|
||||||
|
|
||||||
struct DaySection: View {
|
struct DaySection: View {
|
||||||
let dayNumber: Int
|
let dayNumber: Int
|
||||||
let date: Date
|
let date: Date
|
||||||
let games: [RichGame]
|
let games: [RichGame]
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
private var formattedDate: String {
|
private var formattedDate: String {
|
||||||
let formatter = DateFormatter()
|
date.formatted(.dateTime.weekday(.wide).month().day())
|
||||||
formatter.dateFormat = "EEEE, MMM d"
|
|
||||||
return formatter.string(from: date)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var gameCity: String? {
|
private var gameCity: String? {
|
||||||
@@ -487,105 +469,151 @@ struct DaySection: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||||
// Day header
|
// Day header
|
||||||
HStack {
|
HStack {
|
||||||
Text("Day \(dayNumber)")
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
.font(.subheadline)
|
Text("Day \(dayNumber)")
|
||||||
.fontWeight(.semibold)
|
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||||
.foregroundStyle(.blue)
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
Text(formattedDate)
|
Text(formattedDate)
|
||||||
.font(.subheadline)
|
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
}
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if isRestDay {
|
if isRestDay {
|
||||||
Text("Rest Day")
|
Text("Rest Day")
|
||||||
.font(.caption)
|
.badgeStyle(color: Theme.mlsGreen, filled: false)
|
||||||
.padding(.horizontal, 8)
|
} else if !games.isEmpty {
|
||||||
.padding(.vertical, 3)
|
Text("\(games.count) game\(games.count > 1 ? "s" : "")")
|
||||||
.background(Color.green.opacity(0.2))
|
.badgeStyle(color: Theme.warmOrange, filled: false)
|
||||||
.clipShape(Capsule())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// City label (if games exist)
|
// City label
|
||||||
if let city = gameCity {
|
if let city = gameCity {
|
||||||
Label(city, systemImage: "mappin")
|
Label(city, systemImage: "mappin")
|
||||||
.font(.caption)
|
.font(.system(size: Theme.FontSize.caption))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Games
|
// Games
|
||||||
ForEach(games, id: \.game.id) { richGame in
|
ForEach(games, id: \.game.id) { richGame in
|
||||||
HStack {
|
GameRow(game: richGame)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
.cardStyle()
|
||||||
.background(Color(.secondarySystemBackground))
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
struct TravelSection: View {
|
||||||
let segment: TravelSegment
|
let segment: TravelSegment
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(spacing: 0) {
|
||||||
// Travel header
|
// Top connector
|
||||||
Text("Travel")
|
Rectangle()
|
||||||
.font(.subheadline)
|
.fill(Theme.routeGold.opacity(0.4))
|
||||||
.fontWeight(.semibold)
|
.frame(width: 2, height: 16)
|
||||||
.foregroundStyle(.orange)
|
|
||||||
|
|
||||||
// Travel details
|
// Travel card
|
||||||
HStack(spacing: 8) {
|
HStack(spacing: Theme.Spacing.md) {
|
||||||
Image(systemName: segment.travelMode.iconName)
|
// Icon
|
||||||
.foregroundStyle(.orange)
|
ZStack {
|
||||||
.frame(width: 20)
|
Circle()
|
||||||
|
.fill(Theme.cardBackgroundElevated(colorScheme))
|
||||||
|
.frame(width: 44, height: 44)
|
||||||
|
|
||||||
|
Image(systemName: "car.fill")
|
||||||
|
.foregroundStyle(Theme.routeGold)
|
||||||
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text("\(segment.fromLocation.name) → \(segment.toLocation.name)")
|
Text("Travel")
|
||||||
.font(.subheadline)
|
.font(.system(size: Theme.FontSize.micro, weight: .semibold))
|
||||||
.fontWeight(.medium)
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
|
||||||
Text("\(segment.formattedDistance) • \(segment.formattedDuration)")
|
Text("\(segment.fromLocation.name) → \(segment.toLocation.name)")
|
||||||
.font(.caption)
|
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer()
|
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(Theme.Spacing.md)
|
||||||
.padding(.horizontal, 10)
|
.background(Theme.cardBackground(colorScheme).opacity(0.7))
|
||||||
.background(Color.orange.opacity(0.1))
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
.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)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -352,6 +352,11 @@ enum GameDAGRouter {
|
|||||||
/// Scores a path. Higher = better.
|
/// Scores a path. Higher = better.
|
||||||
/// Prefers: more games, less driving, geographic coherence
|
/// Prefers: more games, less driving, geographic coherence
|
||||||
private static func scorePath(_ path: [Game], stadiums: [UUID: Stadium]) -> Double {
|
private static func scorePath(_ path: [Game], stadiums: [UUID: Stadium]) -> Double {
|
||||||
|
// Handle empty or single-game paths
|
||||||
|
guard path.count > 1 else {
|
||||||
|
return Double(path.count) * 100.0
|
||||||
|
}
|
||||||
|
|
||||||
let gameCount = Double(path.count)
|
let gameCount = Double(path.count)
|
||||||
|
|
||||||
// Calculate total driving
|
// Calculate total driving
|
||||||
|
|||||||
Reference in New Issue
Block a user