From 40a6f879e3c774257d340d052621437886a6acc1 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 7 Jan 2026 15:34:27 -0600 Subject: [PATCH] UI overhaul: new color palette, trip creation improvements, crash fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- SportsTime/Core/Models/Domain/Game.swift | 2 +- SportsTime/Core/Models/Domain/Sport.swift | 24 +- SportsTime/Core/Models/Local/SavedTrip.swift | 18 +- .../Services/RouteDescriptionGenerator.swift | 148 ++ .../Core/Services/StubDataProvider.swift | 2 +- .../Core/Theme/AnimatedComponents.swift | 324 ++++ SportsTime/Core/Theme/Theme.swift | 186 +++ SportsTime/Core/Theme/ViewModifiers.swift | 220 +++ SportsTime/Features/Home/Views/HomeView.swift | 331 +++-- .../Schedule/Views/ScheduleListView.swift | 20 +- .../Trip/Views/TripCreationView.swift | 1298 ++++++++++++----- .../Features/Trip/Views/TripDetailView.swift | 596 ++++---- .../Planning/Engine/GameDAGRouter.swift | 5 + 13 files changed, 2429 insertions(+), 745 deletions(-) create mode 100644 SportsTime/Core/Services/RouteDescriptionGenerator.swift create mode 100644 SportsTime/Core/Theme/AnimatedComponents.swift create mode 100644 SportsTime/Core/Theme/Theme.swift create mode 100644 SportsTime/Core/Theme/ViewModifiers.swift diff --git a/SportsTime/Core/Models/Domain/Game.swift b/SportsTime/Core/Models/Domain/Game.swift index 3b99b45..0b94a0b 100644 --- a/SportsTime/Core/Models/Domain/Game.swift +++ b/SportsTime/Core/Models/Domain/Game.swift @@ -72,7 +72,7 @@ extension Game: Equatable { // MARK: - Rich Game Model (with resolved references) -struct RichGame: Identifiable, Hashable { +struct RichGame: Identifiable, Hashable, Codable { let game: Game let homeTeam: Team let awayTeam: Team diff --git a/SportsTime/Core/Models/Domain/Sport.swift b/SportsTime/Core/Models/Domain/Sport.swift index d03d1fb..a42806c 100644 --- a/SportsTime/Core/Models/Domain/Sport.swift +++ b/SportsTime/Core/Models/Domain/Sport.swift @@ -45,13 +45,14 @@ enum Sport: String, Codable, CaseIterable, Identifiable { } } - var seasonMonths: ClosedRange { + /// 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 { - case .mlb: return 3...10 // March - October - case .nba: return 10...6 // October - June (wraps) - case .nhl: return 10...6 // October - June (wraps) - case .nfl: return 9...2 // September - February (wraps) - case .mls: return 2...12 // February - December + case .mlb: return (3, 10) // March - October + case .nba: return (10, 6) // October - June (wraps) + case .nhl: return (10, 6) // October - June (wraps) + case .nfl: return (9, 2) // September - February (wraps) + case .mls: return (2, 12) // February - December } } @@ -59,12 +60,13 @@ enum Sport: String, Codable, CaseIterable, Identifiable { let calendar = Calendar.current let month = calendar.component(.month, from: date) - let range = seasonMonths - if range.lowerBound <= range.upperBound { - return range.contains(month) + let (start, end) = seasonMonths + if start <= end { + // Normal range (e.g., March to October) + return month >= start && month <= end } else { - // Season wraps around year boundary - return month >= range.lowerBound || month <= range.upperBound + // Season wraps around year boundary (e.g., October to June) + return month >= start || month <= end } } diff --git a/SportsTime/Core/Models/Local/SavedTrip.swift b/SportsTime/Core/Models/Local/SavedTrip.swift index 6a12563..13a3ec9 100644 --- a/SportsTime/Core/Models/Local/SavedTrip.swift +++ b/SportsTime/Core/Models/Local/SavedTrip.swift @@ -16,6 +16,7 @@ final class SavedTrip { var updatedAt: Date var status: String var tripData: Data // Encoded Trip struct + var gamesData: Data? // Encoded [UUID: RichGame] dictionary @Relationship(deleteRule: .cascade) var votes: [TripVote]? @@ -26,7 +27,8 @@ final class SavedTrip { createdAt: Date = Date(), updatedAt: Date = Date(), status: TripStatus = .planned, - tripData: Data + tripData: Data, + gamesData: Data? = nil ) { self.id = id self.name = name @@ -34,25 +36,33 @@ final class SavedTrip { self.updatedAt = updatedAt self.status = status.rawValue self.tripData = tripData + self.gamesData = gamesData } var trip: Trip? { 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 { TripStatus(rawValue: status) ?? .draft } - static func from(_ trip: Trip, status: TripStatus = .planned) -> SavedTrip? { - guard let data = try? JSONEncoder().encode(trip) else { return nil } + static func from(_ trip: Trip, games: [UUID: RichGame] = [:], status: TripStatus = .planned) -> SavedTrip? { + guard let tripData = try? JSONEncoder().encode(trip) else { return nil } + let gamesData = try? JSONEncoder().encode(games) return SavedTrip( id: trip.id, name: trip.name, createdAt: trip.createdAt, updatedAt: trip.updatedAt, status: status, - tripData: data + tripData: tripData, + gamesData: gamesData ) } } diff --git a/SportsTime/Core/Services/RouteDescriptionGenerator.swift b/SportsTime/Core/Services/RouteDescriptionGenerator.swift new file mode 100644 index 0000000..655ec2c --- /dev/null +++ b/SportsTime/Core/Services/RouteDescriptionGenerator.swift @@ -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 + } +} diff --git a/SportsTime/Core/Services/StubDataProvider.swift b/SportsTime/Core/Services/StubDataProvider.swift index d035f34..8c61411 100644 --- a/SportsTime/Core/Services/StubDataProvider.swift +++ b/SportsTime/Core/Services/StubDataProvider.swift @@ -282,7 +282,7 @@ actor StubDataProvider: DataProvider { let timeWithoutAMPM = cleanTime.replacingOccurrences(of: "p", with: "").replacingOccurrences(of: "a", with: "") let components = timeWithoutAMPM.split(separator: ":") - if let h = Int(components[0]) { + if !components.isEmpty, let h = Int(components[0]) { hour = h if isPM && hour != 12 { hour += 12 diff --git a/SportsTime/Core/Theme/AnimatedComponents.swift b/SportsTime/Core/Theme/AnimatedComponents.swift new file mode 100644 index 0000000..7ed742d --- /dev/null +++ b/SportsTime/Core/Theme/AnimatedComponents.swift @@ -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.. 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() +} diff --git a/SportsTime/Core/Theme/Theme.swift b/SportsTime/Core/Theme/Theme.swift new file mode 100644 index 0000000..fdb4103 --- /dev/null +++ b/SportsTime/Core/Theme/Theme.swift @@ -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 } + } +} diff --git a/SportsTime/Core/Theme/ViewModifiers.swift b/SportsTime/Core/Theme/ViewModifiers.swift new file mode 100644 index 0000000..5f9b5c3 --- /dev/null +++ b/SportsTime/Core/Theme/ViewModifiers.swift @@ -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 + } + } +} diff --git a/SportsTime/Features/Home/Views/HomeView.swift b/SportsTime/Features/Home/Views/HomeView.swift index 312f83c..1942858 100644 --- a/SportsTime/Features/Home/Views/HomeView.swift +++ b/SportsTime/Features/Home/Views/HomeView.swift @@ -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..> 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() diff --git a/SportsTime/Features/Trip/Views/TripCreationView.swift b/SportsTime/Features/Trip/Views/TripCreationView.swift index aabc802..6dc77eb 100644 --- a/SportsTime/Features/Trip/Views/TripCreationView.swift +++ b/SportsTime/Features/Trip/Views/TripCreationView.swift @@ -6,6 +6,9 @@ import SwiftUI struct TripCreationView: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + @State private var viewModel = TripCreationViewModel() @State private var showGamePicker = false @State private var showCityInput = false @@ -23,62 +26,62 @@ struct TripCreationView: View { var body: some View { NavigationStack { - Form { - // Planning Mode Selector - planningModeSection + ScrollView { + VStack(spacing: Theme.Spacing.lg) { + // Hero header + heroHeader - // Location Permission Banner (only for locations mode) - if viewModel.planningMode == .locations && showLocationBanner { - Section { + // Planning Mode Selector + planningModeSection + + // Location Permission Banner (only for locations mode) + if viewModel.planningMode == .locations && showLocationBanner { LocationPermissionBanner(isPresented: $showLocationBanner) - .listRowInsets(EdgeInsets()) - .listRowBackground(Color.clear) } - } - // Mode-specific sections - switch viewModel.planningMode { - case .dateRange: - // Sports + Dates - sportsSection - datesSection + // Mode-specific sections + switch viewModel.planningMode { + case .dateRange: + // Sports + Dates + sportsSection + datesSection - case .gameFirst: - // Sports + Game Picker - sportsSection - gameBrowserSection - tripBufferSection + case .gameFirst: + // Sports + Game Picker + sportsSection + gameBrowserSection + tripBufferSection - case .locations: - // Locations + Sports + optional games - locationSection - sportsSection - datesSection - gamesSection - } - - // Common sections - travelSection - constraintsSection - optionalSection - - // Validation message - if let message = viewModel.formValidationMessage { - Section { - Label(message, systemImage: "exclamationmark.triangle") - .foregroundStyle(.orange) + case .locations: + // Locations + Sports + optional games + locationSection + sportsSection + datesSection + gamesSection } + + // Common sections + travelSection + constraintsSection + optionalSection + + // Validation message + if let message = viewModel.formValidationMessage { + validationBanner(message: message) + } + + // Plan button + planButton } + .padding(Theme.Spacing.md) } + .themedBackground() .navigationTitle("Plan Your Trip") .toolbar { - ToolbarItem(placement: .confirmationAction) { - Button("Plan") { - Task { - await viewModel.planTrip() - } + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() } - .disabled(!viewModel.isFormValid) } } .overlay { @@ -161,284 +164,460 @@ struct TripCreationView: View { } } + // MARK: - Hero Header + + private var heroHeader: some View { + VStack(spacing: Theme.Spacing.sm) { + Image(systemName: "map.fill") + .font(.system(size: 40)) + .foregroundStyle(Theme.warmOrange) + + Text("Plan Your Adventure") + .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text("Select your games, set your route, and hit the road") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .multilineTextAlignment(.center) + } + .padding(.vertical, Theme.Spacing.md) + } + // MARK: - Sections private var planningModeSection: some View { - Section { + ThemedSection(title: "How do you want to plan?") { Picker("Planning Mode", selection: $viewModel.planningMode) { ForEach(PlanningMode.allCases) { mode in - Label(mode.displayName, systemImage: mode.iconName) - .tag(mode) + Text(mode.displayName).tag(mode) } } .pickerStyle(.segmented) - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) Text(viewModel.planningMode.description) - .font(.caption) - .foregroundStyle(.secondary) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .padding(.top, Theme.Spacing.xs) } } private var locationSection: some View { - Section("Locations") { - TextField("Start Location", text: $viewModel.startLocationText) - .textContentType(.addressCity) + ThemedSection(title: "Locations") { + ThemedTextField( + label: "Start Location", + placeholder: "Where are you starting from?", + text: $viewModel.startLocationText, + icon: "location.circle.fill" + ) - TextField("End Location", text: $viewModel.endLocationText) - .textContentType(.addressCity) + ThemedTextField( + label: "End Location", + placeholder: "Where do you want to end up?", + text: $viewModel.endLocationText, + icon: "mappin.circle.fill" + ) } } private var gameBrowserSection: some View { - Section("Select Games") { - if viewModel.isLoadingGames { - HStack { + ThemedSection(title: "Select Games") { + if viewModel.isLoadingGames || viewModel.availableGames.isEmpty { + HStack(spacing: Theme.Spacing.sm) { ProgressView() + .tint(Theme.warmOrange) Text("Loading games...") - .foregroundStyle(.secondary) - } - } else if viewModel.availableGames.isEmpty { - HStack { - ProgressView() - Text("Loading games...") - .foregroundStyle(.secondary) + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textSecondary(colorScheme)) } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, Theme.Spacing.md) .task { - await viewModel.loadGamesForBrowsing() + if viewModel.availableGames.isEmpty { + await viewModel.loadGamesForBrowsing() + } } } else { Button { showGamePicker = true } label: { - HStack { - Image(systemName: "sportscourt") - .foregroundStyle(.blue) - VStack(alignment: .leading, spacing: 2) { - Text("Browse Teams & Games") - .foregroundStyle(.primary) - Text("\(viewModel.availableGames.count) games available") - .font(.caption) - .foregroundStyle(.secondary) + HStack(spacing: Theme.Spacing.md) { + ZStack { + Circle() + .fill(Theme.warmOrange.opacity(0.15)) + .frame(width: 44, height: 44) + Image(systemName: "sportscourt.fill") + .foregroundStyle(Theme.warmOrange) } + + VStack(alignment: .leading, spacing: 4) { + Text("Browse Teams & Games") + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Text("\(viewModel.availableGames.count) games available") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + Spacer() + Image(systemName: "chevron.right") - .foregroundStyle(.secondary) + .foregroundStyle(Theme.textMuted(colorScheme)) } + .padding(Theme.Spacing.md) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } .buttonStyle(.plain) } // Show selected games summary if !viewModel.mustSeeGameIds.isEmpty { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { HStack { Image(systemName: "checkmark.circle.fill") .foregroundStyle(.green) Text("\(viewModel.mustSeeGameIds.count) game(s) selected") - .fontWeight(.medium) + .font(.system(size: Theme.FontSize.body, weight: .medium)) + .foregroundStyle(Theme.textPrimary(colorScheme)) } // Show selected games preview ForEach(viewModel.selectedGames.prefix(3)) { game in - HStack(spacing: 8) { - Image(systemName: game.game.sport.iconName) - .font(.caption) - .foregroundStyle(.secondary) + HStack(spacing: Theme.Spacing.sm) { + SportColorBar(sport: game.game.sport) Text("\(game.awayTeam.abbreviation) @ \(game.homeTeam.abbreviation)") - .font(.caption) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textPrimary(colorScheme)) Spacer() Text(game.game.formattedDate) - .font(.caption2) - .foregroundStyle(.secondary) + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textMuted(colorScheme)) } } if viewModel.selectedGames.count > 3 { Text("+ \(viewModel.selectedGames.count - 3) more") - .font(.caption) - .foregroundStyle(.secondary) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) } } + .padding(Theme.Spacing.md) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } } } private var tripBufferSection: some View { - Section("Trip Duration") { - Stepper("Buffer Days: \(viewModel.tripBufferDays)", value: $viewModel.tripBufferDays, in: 0...7) + ThemedSection(title: "Trip Duration") { + ThemedStepper( + label: "Buffer Days", + value: viewModel.tripBufferDays, + range: 0...7, + onIncrement: { viewModel.tripBufferDays += 1 }, + onDecrement: { viewModel.tripBufferDays -= 1 } + ) if let dateRange = viewModel.gameFirstDateRange { HStack { Text("Trip window:") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) Spacer() Text("\(dateRange.start.formatted(date: .abbreviated, time: .omitted)) - \(dateRange.end.formatted(date: .abbreviated, time: .omitted))") - .foregroundStyle(.secondary) + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.warmOrange) } } Text("Days before first game and after last game for travel/rest") - .font(.caption) - .foregroundStyle(.secondary) + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textMuted(colorScheme)) } } private var sportsSection: some View { - Section("Sports") { - ForEach(Sport.supported) { sport in - Toggle(isOn: binding(for: sport)) { - Label(sport.rawValue, systemImage: sport.iconName) + ThemedSection(title: "Sports") { + HStack(spacing: Theme.Spacing.sm) { + ForEach(Sport.supported) { sport in + SportSelectionChip( + sport: sport, + isSelected: viewModel.selectedSports.contains(sport), + onTap: { + if viewModel.selectedSports.contains(sport) { + viewModel.selectedSports.remove(sport) + } else { + viewModel.selectedSports.insert(sport) + } + } + ) } } } } private var datesSection: some View { - Section("Dates") { - DatePicker("Start Date", selection: $viewModel.startDate, displayedComponents: .date) - - DatePicker("End Date", selection: $viewModel.endDate, displayedComponents: .date) - - Text("\(viewModel.tripDurationDays) day trip") - .foregroundStyle(.secondary) + ThemedSection(title: "Dates") { + DateRangePicker( + startDate: $viewModel.startDate, + endDate: $viewModel.endDate + ) } } private var gamesSection: some View { - Section("Must-See Games") { + ThemedSection(title: "Must-See Games") { Button { showGamePicker = true } label: { - HStack { + HStack(spacing: Theme.Spacing.md) { + ZStack { + Circle() + .fill(Theme.warmOrange.opacity(0.15)) + .frame(width: 40, height: 40) + Image(systemName: "star.fill") + .foregroundStyle(Theme.warmOrange) + } + Text("Select Games") + .font(.system(size: Theme.FontSize.body, weight: .medium)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Spacer() + Text("\(viewModel.selectedGamesCount) selected") - .foregroundStyle(.secondary) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + Image(systemName: "chevron.right") + .foregroundStyle(Theme.textMuted(colorScheme)) } + .padding(Theme.Spacing.md) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) } + .buttonStyle(.plain) } } private var travelSection: some View { - Section("Travel") { - Picker("Travel Mode", selection: $viewModel.travelMode) { - ForEach(TravelMode.allCases) { mode in - Label(mode.displayName, systemImage: mode.iconName) - .tag(mode) - } - } + ThemedSection(title: "Travel") { + VStack(spacing: Theme.Spacing.md) { + // Route preference + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text("Route Preference") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) - Picker("Route Preference", selection: $viewModel.routePreference) { - ForEach(RoutePreference.allCases) { pref in - Text(pref.displayName).tag(pref) + Picker("Route Preference", selection: $viewModel.routePreference) { + ForEach(RoutePreference.allCases) { pref in + Text(pref.displayName).tag(pref) + } + } + .pickerStyle(.segmented) } } } } private var constraintsSection: some View { - Section("Trip Style") { - Toggle("Use Stop Count", isOn: $viewModel.useStopCount) + ThemedSection(title: "Trip Style") { + VStack(spacing: Theme.Spacing.md) { + ThemedToggle( + label: "Limit Cities", + isOn: $viewModel.useStopCount, + icon: "mappin.and.ellipse" + ) - if viewModel.useStopCount { - Stepper("Number of Stops: \(viewModel.numberOfStops)", value: $viewModel.numberOfStops, in: 1...20) - } + if viewModel.useStopCount { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + ThemedStepper( + label: "Number of Cities", + value: viewModel.numberOfStops, + range: 1...20, + onIncrement: { viewModel.numberOfStops += 1 }, + onDecrement: { viewModel.numberOfStops -= 1 } + ) - Picker("Pace", selection: $viewModel.leisureLevel) { - ForEach(LeisureLevel.allCases) { level in - VStack(alignment: .leading) { - Text(level.displayName) + Text("How many different cities to visit on your trip. More cities = more variety, but more driving between them.") + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textMuted(colorScheme)) } - .tag(level) + } + + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text("Trip Pace") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + Picker("Pace", selection: $viewModel.leisureLevel) { + ForEach(LeisureLevel.allCases) { level in + Text(level.displayName).tag(level) + } + } + .pickerStyle(.segmented) + + Text(viewModel.leisureLevel.description) + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textMuted(colorScheme)) + .padding(.top, Theme.Spacing.xxs) } } - - Text(viewModel.leisureLevel.description) - .font(.caption) - .foregroundStyle(.secondary) } } private var optionalSection: some View { - Section("Optional") { - // Must-Stop Locations - DisclosureGroup("Must-Stop Locations (\(viewModel.mustStopLocations.count))") { - ForEach(viewModel.mustStopLocations, id: \.name) { location in + ThemedSection(title: "More Options") { + VStack(spacing: Theme.Spacing.md) { + // Must-Stop Locations + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { HStack { - VStack(alignment: .leading) { - Text(location.name) - if let address = location.address, !address.isEmpty { - Text(address) - .font(.caption) - .foregroundStyle(.secondary) + Image(systemName: "mappin.circle") + .foregroundStyle(Theme.warmOrange) + Text("Must-Stop Locations") + .font(.system(size: Theme.FontSize.body, weight: .medium)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Spacer() + Text("\(viewModel.mustStopLocations.count)") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + + ForEach(viewModel.mustStopLocations, id: \.name) { location in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(location.name) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + if let address = location.address, !address.isEmpty { + Text(address) + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + Spacer() + Button { + viewModel.removeMustStopLocation(location) + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Theme.textMuted(colorScheme)) } } - Spacer() - Button(role: .destructive) { - viewModel.removeMustStopLocation(location) - } label: { - Image(systemName: "minus.circle.fill") + .padding(Theme.Spacing.sm) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small)) + } + + Button { + cityInputType = .mustStop + showCityInput = true + } label: { + HStack { + Image(systemName: "plus.circle.fill") + Text("Add Location") } + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.warmOrange) } } - Button("Add Location") { - cityInputType = .mustStop - showCityInput = true + + Divider() + .overlay(Theme.surfaceGlow(colorScheme)) + + // EV Charging + ThemedToggle( + label: "EV Charging Needed", + isOn: $viewModel.needsEVCharging, + icon: "bolt.car" + ) + + // Drivers + ThemedStepper( + label: "Number of Drivers", + value: viewModel.numberOfDrivers, + range: 1...4, + onIncrement: { viewModel.numberOfDrivers += 1 }, + onDecrement: { viewModel.numberOfDrivers -= 1 } + ) + + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + HStack { + Text("Max Hours/Driver/Day") + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + Spacer() + Text("\(Int(viewModel.maxDrivingHoursPerDriver))h") + .font(.system(size: Theme.FontSize.caption, weight: .bold)) + .foregroundStyle(Theme.warmOrange) + } + Slider(value: $viewModel.maxDrivingHoursPerDriver, in: 4...12, step: 1) + .tint(Theme.warmOrange) + } + + // Other Sports + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + ThemedToggle( + label: "Find Other Sports Along Route", + isOn: $viewModel.catchOtherSports, + icon: "sportscourt" + ) + + Text("When enabled, we'll look for games from other sports happening along your route that fit your schedule.") + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textMuted(colorScheme)) } } - - // EV Charging - if viewModel.travelMode == .drive { - Toggle("EV Charging Needed", isOn: $viewModel.needsEVCharging) - } - - // Lodging - Picker("Lodging Type", selection: $viewModel.lodgingType) { - ForEach(LodgingType.allCases) { type in - Label(type.displayName, systemImage: type.iconName) - .tag(type) - } - } - - // Drivers - if viewModel.travelMode == .drive { - Stepper("Drivers: \(viewModel.numberOfDrivers)", value: $viewModel.numberOfDrivers, in: 1...4) - - HStack { - Text("Max Hours/Driver/Day") - Spacer() - Text("\(Int(viewModel.maxDrivingHoursPerDriver))h") - } - Slider(value: $viewModel.maxDrivingHoursPerDriver, in: 4...12, step: 1) - } - - // Other Sports - Toggle("Find Other Sports Along Route", isOn: $viewModel.catchOtherSports) } } private var planningOverlay: some View { ZStack { - Color.black.opacity(0.4) + Color.black.opacity(0.5) .ignoresSafeArea() - VStack(spacing: 20) { - ProgressView() - .scaleEffect(1.5) - - Text("Planning your trip...") - .font(.headline) - .foregroundStyle(.white) - - Text("Finding the best route and games") - .font(.subheadline) - .foregroundStyle(.white.opacity(0.8)) - } - .padding(40) - .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 20)) + PlanningProgressView() + .padding(40) + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24)) } } + private var planButton: some View { + Button { + Task { + await viewModel.planTrip() + } + } label: { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: "map.fill") + Text("Plan My Trip") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.md) + .background(viewModel.isFormValid ? Theme.warmOrange : Theme.textMuted(colorScheme)) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + .disabled(!viewModel.isFormValid) + .padding(.top, Theme.Spacing.md) + .glowEffect(color: viewModel.isFormValid ? Theme.warmOrange : .clear, radius: 12) + } + + private func validationBanner(message: String) -> some View { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text(message) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + .padding(Theme.Spacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.orange.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + // MARK: - Helpers private func binding(for sport: Sport) -> Binding { @@ -608,7 +787,7 @@ struct TeamRow: View { // Team color indicator if let colorHex = teamData.team.primaryColor { Circle() - .fill(Color(hex: colorHex) ?? .gray) + .fill(Color(hex: colorHex)) .frame(width: 12, height: 12) } @@ -647,7 +826,7 @@ struct TeamGamesView: View { var body: some View { List { ForEach(teamData.sortedGames) { game in - GameRow(game: game, isSelected: selectedIds.contains(game.id)) { + GamePickerRow(game: game, isSelected: selectedIds.contains(game.id)) { if selectedIds.contains(game.id) { selectedIds.remove(game.id) } else { @@ -661,31 +840,36 @@ struct TeamGamesView: View { } } -struct GameRow: View { +struct GamePickerRow: View { let game: RichGame let isSelected: Bool let onTap: () -> Void + @Environment(\.colorScheme) private var colorScheme var body: some View { Button(action: onTap) { - HStack { + HStack(spacing: 12) { + // Sport color bar + SportColorBar(sport: game.game.sport) + VStack(alignment: .leading, spacing: 4) { Text(game.matchupDescription) - .font(.headline) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) Text(game.venueDescription) .font(.caption) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.textSecondary(colorScheme)) Text("\(game.game.formattedDate) • \(game.game.gameTime)") .font(.caption2) - .foregroundStyle(.secondary) + .foregroundStyle(Theme.textMuted(colorScheme)) } Spacer() Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isSelected ? .blue : .gray) + .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme)) .font(.title2) } } @@ -861,25 +1045,29 @@ struct TripOptionsView: View { @State private var selectedTrip: Trip? @State private var showTripDetail = false + @Environment(\.colorScheme) private var colorScheme var body: some View { ScrollView { - LazyVStack(spacing: 16) { - // Header - VStack(alignment: .leading, spacing: 8) { - Text("\(options.count) Trip Options Found") - .font(.title2) - .fontWeight(.bold) + LazyVStack(spacing: 20) { + // Hero header + VStack(spacing: 12) { + Image(systemName: "point.topright.arrow.triangle.backward.to.point.bottomleft.scurvepath.fill") + .font(.system(size: 44)) + .foregroundStyle(Theme.warmOrange) - Text("Select a trip to view details") - .font(.subheadline) - .foregroundStyle(.secondary) + Text("\(options.count) Routes Found") + .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text("Each route offers a unique adventure") + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textSecondary(colorScheme)) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - .padding(.top) + .padding(.top, Theme.Spacing.xl) + .padding(.bottom, Theme.Spacing.md) - // Options list + // Options list with staggered animation ForEach(Array(options.enumerated()), id: \.offset) { index, option in TripOptionCard( option: option, @@ -890,11 +1078,12 @@ struct TripOptionsView: View { showTripDetail = true } ) - .padding(.horizontal) + .padding(.horizontal, Theme.Spacing.md) } } - .padding(.bottom) + .padding(.bottom, Theme.Spacing.xxl) } + .themedBackground() .navigationTitle("Choose Your Trip") .navigationBarTitleDisplayMode(.inline) .navigationDestination(isPresented: $showTripDetail) { @@ -912,156 +1101,111 @@ struct TripOptionCard: View { let rank: Int let games: [UUID: RichGame] let onSelect: () -> Void + @Environment(\.colorScheme) private var colorScheme - private var cities: [String] { - option.stops.map { $0.city } - } + @State private var aiDescription: String? + @State private var isLoadingDescription = false - private var uniqueCities: Int { - Set(cities).count + private var uniqueCities: [String] { + option.stops.map { $0.city }.removingDuplicates() } private var totalGames: Int { option.stops.flatMap { $0.games }.count } - private var primaryCity: String { - // Find the city with most games - var cityCounts: [String: Int] = [:] - for stop in option.stops { - cityCounts[stop.city, default: 0] += stop.games.count + private var routeDescription: String { + if uniqueCities.count <= 2 { + return uniqueCities.joined(separator: " → ") } - return cityCounts.max(by: { $0.value < $1.value })?.key ?? cities.first ?? "Unknown" - } - - private var routeSummary: String { - let uniqueCityList = cities.removingDuplicates() - if uniqueCityList.count <= 3 { - return uniqueCityList.joined(separator: " → ") - } - return "\(uniqueCityList[0]) → ... → \(uniqueCityList.last ?? "")" + return "\(uniqueCities.first ?? "") → \(uniqueCities.count - 2) stops → \(uniqueCities.last ?? "")" } var body: some View { Button(action: onSelect) { - VStack(alignment: .leading, spacing: 12) { - // Header with rank and primary city - HStack(alignment: .center) { - // Rank badge - Text("Option \(rank)") - .font(.caption) - .fontWeight(.bold) - .foregroundStyle(.white) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(rank == 1 ? Color.blue : Color.gray) - .clipShape(Capsule()) + HStack(spacing: Theme.Spacing.md) { + // Left: Rank badge + Text("\(rank)") + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(rank == 1 ? Theme.warmOrange : Theme.textMuted(colorScheme)) + .clipShape(Circle()) - Spacer() + // Middle: Route info + VStack(alignment: .leading, spacing: 6) { + Text(routeDescription) + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .lineLimit(1) - // Primary city label - Text(primaryCity) - .font(.headline) - .foregroundStyle(.primary) - } - - // Route summary - Text(routeSummary) - .font(.subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - - // Stats row - HStack(spacing: 20) { - StatPill(icon: "sportscourt.fill", value: "\(totalGames)", label: "games") - StatPill(icon: "mappin.circle.fill", value: "\(uniqueCities)", label: "cities") - StatPill(icon: "car.fill", value: formatDriving(option.totalDrivingHours), label: "driving") - } - - // Games preview - if !option.stops.isEmpty { - VStack(alignment: .leading, spacing: 4) { - ForEach(option.stops.prefix(3), id: \.city) { stop in - HStack(spacing: 8) { - Circle() - .fill(Color.blue.opacity(0.3)) - .frame(width: 8, height: 8) - - Text(stop.city) - .font(.caption) - .fontWeight(.medium) - - Text("• \(stop.games.count) game\(stop.games.count == 1 ? "" : "s")") - .font(.caption) - .foregroundStyle(.secondary) - - Spacer() - } + // Stats row + HStack(spacing: 12) { + Label("\(totalGames) games", systemImage: "sportscourt") + Label("\(uniqueCities.count) cities", systemImage: "mappin") + if option.totalDistanceMiles > 0 { + Label("\(Int(option.totalDistanceMiles)) mi", systemImage: "car") } + } + .font(.system(size: 12)) + .foregroundStyle(Theme.textSecondary(colorScheme)) - if option.stops.count > 3 { - Text("+ \(option.stops.count - 3) more stops") - .font(.caption) - .foregroundStyle(.tertiary) - .padding(.leading, 16) + // AI-generated description (after stats) + if let description = aiDescription { + Text(description) + .font(.system(size: 13, weight: .regular)) + .foregroundStyle(Theme.textMuted(colorScheme)) + .fixedSize(horizontal: false, vertical: true) + .transition(.opacity) + } else if isLoadingDescription { + HStack(spacing: 4) { + ProgressView() + .scaleEffect(0.6) + Text("Generating...") + .font(.system(size: 11)) + .foregroundStyle(Theme.textMuted(colorScheme)) } } } - // Tap to view hint - HStack { - Spacer() - Text("Tap to view details") - .font(.caption2) - .foregroundStyle(.tertiary) - Image(systemName: "chevron.right") - .font(.caption2) - .foregroundStyle(.tertiary) - } + Spacer() + + // Right: Chevron + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .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(rank == 1 ? Theme.warmOrange : Theme.surfaceGlow(colorScheme), lineWidth: rank == 1 ? 2 : 1) } - .padding() - .background(Color(.secondarySystemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .strokeBorder(rank == 1 ? Color.blue.opacity(0.3) : Color.clear, lineWidth: 2) - ) } .buttonStyle(.plain) + .task(id: option.id) { + // Reset state when option changes + aiDescription = nil + isLoadingDescription = false + await generateDescription() + } } - private func formatDriving(_ hours: Double) -> String { - if hours < 1 { - return "\(Int(hours * 60))m" - } - let h = Int(hours) - let m = Int((hours - Double(h)) * 60) - if m == 0 { - return "\(h)h" - } - return "\(h)h \(m)m" - } -} + private func generateDescription() async { + guard RouteDescriptionGenerator.shared.isAvailable else { return } -// MARK: - Stat Pill + isLoadingDescription = true -struct StatPill: View { - let icon: String - let value: String - let label: String + // Build input from THIS specific option + let input = RouteDescriptionInput(from: option, games: games) - var body: some View { - HStack(spacing: 4) { - Image(systemName: icon) - .font(.caption2) - .foregroundStyle(.blue) - Text(value) - .font(.caption) - .fontWeight(.semibold) - Text(label) - .font(.caption2) - .foregroundStyle(.secondary) + if let description = await RouteDescriptionGenerator.shared.generateDescription(for: input) { + withAnimation(.easeInOut(duration: 0.3)) { + aiDescription = description + } } + isLoadingDescription = false } } @@ -1074,6 +1218,492 @@ extension Array where Element: Hashable { } } +// MARK: - Themed Form Components + +struct ThemedSection: View { + let title: String + @ViewBuilder let content: () -> Content + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Text(title) + .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + VStack(alignment: .leading, spacing: Theme.Spacing.md) { + content() + } + .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) + } + } + } +} + +struct ThemedTextField: View { + let label: String + let placeholder: String + @Binding var text: String + var icon: String = "mappin" + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.xs) { + Text(label) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: icon) + .foregroundStyle(Theme.warmOrange) + .frame(width: 24) + + TextField(placeholder, text: $text) + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + } +} + +struct ThemedToggle: View { + let label: String + @Binding var isOn: Bool + var icon: String = "checkmark.circle" + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: icon) + .foregroundStyle(isOn ? Theme.warmOrange : Theme.textMuted(colorScheme)) + .frame(width: 24) + + Text(label) + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Spacer() + + Toggle("", isOn: $isOn) + .labelsHidden() + .tint(Theme.warmOrange) + } + } +} + +struct ThemedStepper: View { + let label: String + let value: Int + let range: ClosedRange + let onIncrement: () -> Void + let onDecrement: () -> Void + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack { + Text(label) + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Spacer() + + HStack(spacing: Theme.Spacing.sm) { + Button { + if value > range.lowerBound { + onDecrement() + } + } label: { + Image(systemName: "minus.circle.fill") + .font(.title2) + .foregroundStyle(value > range.lowerBound ? Theme.warmOrange : Theme.textMuted(colorScheme)) + } + .disabled(value <= range.lowerBound) + + Text("\(value)") + .font(.system(size: Theme.FontSize.body, weight: .bold)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .frame(minWidth: 30) + + Button { + if value < range.upperBound { + onIncrement() + } + } label: { + Image(systemName: "plus.circle.fill") + .font(.title2) + .foregroundStyle(value < range.upperBound ? Theme.warmOrange : Theme.textMuted(colorScheme)) + } + .disabled(value >= range.upperBound) + } + } + } +} + +struct ThemedDatePicker: View { + let label: String + @Binding var selection: Date + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + HStack { + HStack(spacing: Theme.Spacing.sm) { + Image(systemName: "calendar") + .foregroundStyle(Theme.warmOrange) + Text(label) + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + } + + Spacer() + + DatePicker("", selection: $selection, displayedComponents: .date) + .labelsHidden() + .tint(Theme.warmOrange) + } + } +} + +// MARK: - Date Range Picker + +struct DateRangePicker: View { + @Binding var startDate: Date + @Binding var endDate: Date + @Environment(\.colorScheme) private var colorScheme + + @State private var displayedMonth: Date = Date() + @State private var selectionState: SelectionState = .none + + enum SelectionState { + case none + case startSelected + case complete + } + + private let calendar = Calendar.current + private let daysOfWeek = ["S", "M", "T", "W", "T", "F", "S"] + + private var monthYearString: String { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM yyyy" + return formatter.string(from: displayedMonth) + } + + private var daysInMonth: [Date?] { + guard let monthInterval = calendar.dateInterval(of: .month, for: displayedMonth), + let monthFirstWeek = calendar.dateInterval(of: .weekOfMonth, for: monthInterval.start) else { + return [] + } + + var days: [Date?] = [] + let startOfMonth = monthInterval.start + let endOfMonth = calendar.date(byAdding: .day, value: -1, to: monthInterval.end)! + + // Get the first day of the week containing the first day of the month + var currentDate = monthFirstWeek.start + + // Add days until we've covered the month + while currentDate <= endOfMonth || days.count % 7 != 0 { + if currentDate >= startOfMonth && currentDate <= endOfMonth { + days.append(currentDate) + } else if currentDate < startOfMonth { + days.append(nil) // Placeholder for days before month starts + } else if days.count % 7 != 0 { + days.append(nil) // Placeholder to complete the last week + } else { + break + } + currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)! + } + + return days + } + + private var tripDuration: Int { + let components = calendar.dateComponents([.day], from: startDate, to: endDate) + return (components.day ?? 0) + 1 + } + + var body: some View { + VStack(spacing: Theme.Spacing.md) { + // Selected range summary + selectedRangeSummary + + // Month navigation + monthNavigation + + // Days of week header + daysOfWeekHeader + + // Calendar grid + calendarGrid + + // Trip duration + tripDurationBadge + } + } + + private var selectedRangeSummary: some View { + HStack(spacing: Theme.Spacing.md) { + // Start date + VStack(alignment: .leading, spacing: 4) { + Text("START") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Theme.textMuted(colorScheme)) + Text(startDate.formatted(.dateTime.month(.abbreviated).day().year())) + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.warmOrange) + } + .frame(maxWidth: .infinity, alignment: .leading) + + // Arrow + Image(systemName: "arrow.right") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Theme.textMuted(colorScheme)) + + // End date + VStack(alignment: .trailing, spacing: 4) { + Text("END") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Theme.textMuted(colorScheme)) + Text(endDate.formatted(.dateTime.month(.abbreviated).day().year())) + .font(.system(size: Theme.FontSize.body, weight: .semibold)) + .foregroundStyle(Theme.warmOrange) + } + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackgroundElevated(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + + private var monthNavigation: some View { + HStack { + Button { + withAnimation(.easeInOut(duration: 0.2)) { + displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth + } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Theme.warmOrange) + .frame(width: 36, height: 36) + .background(Theme.warmOrange.opacity(0.15)) + .clipShape(Circle()) + } + + Spacer() + + Text(monthYearString) + .font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Spacer() + + Button { + withAnimation(.easeInOut(duration: 0.2)) { + displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth + } + } label: { + Image(systemName: "chevron.right") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(Theme.warmOrange) + .frame(width: 36, height: 36) + .background(Theme.warmOrange.opacity(0.15)) + .clipShape(Circle()) + } + } + } + + private var daysOfWeekHeader: some View { + HStack(spacing: 0) { + ForEach(daysOfWeek, id: \.self) { day in + Text(day) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(Theme.textMuted(colorScheme)) + .frame(maxWidth: .infinity) + } + } + } + + private var calendarGrid: some View { + let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) + + return LazyVGrid(columns: columns, spacing: 4) { + ForEach(Array(daysInMonth.enumerated()), id: \.offset) { _, date in + if let date = date { + DayCell( + date: date, + isStart: calendar.isDate(date, inSameDayAs: startDate), + isEnd: calendar.isDate(date, inSameDayAs: endDate), + isInRange: isDateInRange(date), + isToday: calendar.isDateInToday(date), + onTap: { handleDateTap(date) } + ) + } else { + Color.clear + .frame(height: 40) + } + } + } + } + + private var tripDurationBadge: some View { + HStack(spacing: Theme.Spacing.xs) { + Image(systemName: "calendar.badge.clock") + .foregroundStyle(Theme.warmOrange) + Text("\(tripDuration) day\(tripDuration == 1 ? "" : "s")") + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, Theme.Spacing.xs) + } + + private func isDateInRange(_ date: Date) -> Bool { + let start = calendar.startOfDay(for: startDate) + let end = calendar.startOfDay(for: endDate) + let current = calendar.startOfDay(for: date) + return current > start && current < end + } + + private func handleDateTap(_ date: Date) { + let today = calendar.startOfDay(for: Date()) + let tappedDate = calendar.startOfDay(for: date) + + // Don't allow selecting dates in the past + if tappedDate < today { + return + } + + switch selectionState { + case .none, .complete: + // First tap: set start date, reset end to same day + startDate = date + endDate = date + selectionState = .startSelected + + case .startSelected: + // Second tap: set end date (if after start) + if date >= startDate { + endDate = date + } else { + // If tapped date is before start, make it the new start + endDate = startDate + startDate = date + } + selectionState = .complete + } + } +} + +// MARK: - Day Cell + +struct DayCell: View { + let date: Date + let isStart: Bool + let isEnd: Bool + let isInRange: Bool + let isToday: Bool + let onTap: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + private let calendar = Calendar.current + + private var dayNumber: String { + "\(calendar.component(.day, from: date))" + } + + private var isPast: Bool { + calendar.startOfDay(for: date) < calendar.startOfDay(for: Date()) + } + + var body: some View { + Button(action: onTap) { + ZStack { + // Range highlight background (stretches edge to edge) + if isInRange || isStart || isEnd { + HStack(spacing: 0) { + Rectangle() + .fill(Theme.warmOrange.opacity(0.15)) + .frame(maxWidth: .infinity) + .opacity(isStart && !isEnd ? 0 : 1) + .offset(x: isStart ? 20 : 0) + + Rectangle() + .fill(Theme.warmOrange.opacity(0.15)) + .frame(maxWidth: .infinity) + .opacity(isEnd && !isStart ? 0 : 1) + .offset(x: isEnd ? -20 : 0) + } + .opacity(isStart && isEnd ? 0 : 1) // Hide when start == end + } + + // Day circle + ZStack { + if isStart || isEnd { + Circle() + .fill(Theme.warmOrange) + } else if isToday { + Circle() + .stroke(Theme.warmOrange, lineWidth: 2) + } + + Text(dayNumber) + .font(.system(size: 14, weight: (isStart || isEnd) ? .bold : .medium)) + .foregroundStyle( + isPast ? Theme.textMuted(colorScheme).opacity(0.5) : + (isStart || isEnd) ? .white : + isToday ? Theme.warmOrange : + Theme.textPrimary(colorScheme) + ) + } + .frame(width: 36, height: 36) + } + } + .buttonStyle(.plain) + .disabled(isPast) + .frame(height: 40) + } +} + +struct SportSelectionChip: View { + let sport: Sport + let isSelected: Bool + let onTap: () -> Void + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button(action: onTap) { + VStack(spacing: Theme.Spacing.xs) { + ZStack { + Circle() + .fill(isSelected ? sport.themeColor : sport.themeColor.opacity(0.15)) + .frame(width: 44, height: 44) + + Image(systemName: sport.iconName) + .font(.title3) + .foregroundStyle(isSelected ? .white : sport.themeColor) + } + + Text(sport.rawValue) + .font(.system(size: Theme.FontSize.micro, weight: .medium)) + .foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme)) + } + .frame(maxWidth: .infinity) + } + .buttonStyle(.plain) + } +} + #Preview { TripCreationView() } diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 14c2ad9..5f69868 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -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) - ) } } diff --git a/SportsTime/Planning/Engine/GameDAGRouter.swift b/SportsTime/Planning/Engine/GameDAGRouter.swift index 06befc5..093c319 100644 --- a/SportsTime/Planning/Engine/GameDAGRouter.swift +++ b/SportsTime/Planning/Engine/GameDAGRouter.swift @@ -352,6 +352,11 @@ enum GameDAGRouter { /// Scores a path. Higher = better. /// Prefers: more games, less driving, geographic coherence 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) // Calculate total driving