diff --git a/SportsTime/Core/Models/Domain/Region.swift b/SportsTime/Core/Models/Domain/Region.swift new file mode 100644 index 0000000..5664d6d --- /dev/null +++ b/SportsTime/Core/Models/Domain/Region.swift @@ -0,0 +1,54 @@ +// +// Region.swift +// SportsTime +// +// Geographic regions for trip classification. +// + +import Foundation + +enum Region: String, CaseIterable, Identifiable { + case east = "East Coast" + case central = "Central" + case west = "West Coast" + case crossCountry = "Cross-Country" + + var id: String { rawValue } + + var displayName: String { rawValue } + + var shortName: String { + switch self { + case .east: return "East" + case .central: return "Central" + case .west: return "West" + case .crossCountry: return "Coast to Coast" + } + } + + var iconName: String { + switch self { + case .east: return "building.2" + case .central: return "building" + case .west: return "sun.max" + case .crossCountry: return "arrow.left.arrow.right" + } + } + + /// Classifies a stadium's region based on longitude. + /// + /// Longitude boundaries: + /// - East: > -85 (NYC, Boston, Miami, Toronto, Montreal, Atlanta) + /// - Central: -110 to -85 (Chicago, Houston, Dallas, Minneapolis, Denver) + /// - West: < -110 (LA, SF, Seattle, Phoenix, Las Vegas) + static func classify(longitude: Double) -> Region { + switch longitude { + case _ where longitude > -85: + return .east + case -110...(-85): + return .central + default: + return .west + } + } +} diff --git a/SportsTime/Core/Models/Domain/Stadium.swift b/SportsTime/Core/Models/Domain/Stadium.swift index ba239d6..075dedf 100644 --- a/SportsTime/Core/Models/Domain/Stadium.swift +++ b/SportsTime/Core/Models/Domain/Stadium.swift @@ -51,6 +51,10 @@ struct Stadium: Identifiable, Codable, Hashable { "\(city), \(state)" } + var region: Region { + Region.classify(longitude: longitude) + } + func distance(to other: Stadium) -> CLLocationDistance { location.distance(from: other.location) } diff --git a/SportsTime/Core/Services/LoadingTextGenerator.swift b/SportsTime/Core/Services/LoadingTextGenerator.swift new file mode 100644 index 0000000..bd44b59 --- /dev/null +++ b/SportsTime/Core/Services/LoadingTextGenerator.swift @@ -0,0 +1,100 @@ +// +// LoadingTextGenerator.swift +// SportsTime +// +// Generates unique loading messages using Apple Foundation Models. +// Falls back to predefined messages on unsupported devices. +// + +import Foundation +#if canImport(FoundationModels) +import FoundationModels +#endif + +actor LoadingTextGenerator { + + static let shared = LoadingTextGenerator() + + private static let fallbackMessages = [ + "Hang tight, we're finding the best routes...", + "Scanning stadiums across the country...", + "Building your dream road trip...", + "Calculating the perfect game day schedule...", + "Finding the best matchups for you...", + "Mapping out your adventure...", + "Checking stadium schedules...", + "Putting together some epic trips...", + "Hold on, great trips incoming...", + "Crunching the numbers on routes...", + "Almost there, planning magic happening...", + "Finding games you'll love..." + ] + + private var usedMessages: Set = [] + + /// Generates a unique loading message. + /// Uses Foundation Models if available, falls back to predefined messages. + func generateMessage() async -> String { + #if canImport(FoundationModels) + // Try Foundation Models first + if let message = await generateWithFoundationModels() { + return message + } + #endif + + // Fall back to predefined messages + return getNextFallbackMessage() + } + + #if canImport(FoundationModels) + private func generateWithFoundationModels() async -> String? { + // Check availability + guard case .available = SystemLanguageModel.default.availability else { + return nil + } + + do { + let session = LanguageModelSession(instructions: """ + Generate a short, friendly loading message for a sports road trip planning app. + The message should be casual, fun, and 8-12 words. + Don't use emojis. Don't start with "We're" or "We are". + Examples: "Hang tight, finding the best routes for you...", + "Calculating the perfect game day adventure...", + "Almost there, great trips are brewing..." + """ + ) + + let response = try await session.respond(to: "Generate one loading message") + let message = response.content.trimmingCharacters(in: .whitespacesAndNewlines) + + // Validate message isn't empty and is reasonable length + guard message.count >= 10 && message.count <= 80 else { + return nil + } + + return message + } catch { + print("[LoadingTextGenerator] Foundation Models error: \(error)") + return nil + } + } + #endif + + private func getNextFallbackMessage() -> String { + // Reset if we've used all messages + if usedMessages.count >= Self.fallbackMessages.count { + usedMessages.removeAll() + } + + // Pick a random unused message + let availableMessages = Self.fallbackMessages.filter { !usedMessages.contains($0) } + let message = availableMessages.randomElement() ?? Self.fallbackMessages[0] + usedMessages.insert(message) + return message + } + + /// Reset used messages (for testing or new session) + func reset() { + usedMessages.removeAll() + } +} diff --git a/SportsTime/Core/Services/SuggestedTripsGenerator.swift b/SportsTime/Core/Services/SuggestedTripsGenerator.swift new file mode 100644 index 0000000..907a650 --- /dev/null +++ b/SportsTime/Core/Services/SuggestedTripsGenerator.swift @@ -0,0 +1,426 @@ +// +// SuggestedTripsGenerator.swift +// SportsTime +// +// Generates suggested trip options for the home screen. +// + +import Foundation +import SwiftUI + +// MARK: - Suggested Trip Model + +struct SuggestedTrip: Identifiable { + let id: UUID + let region: Region + let isSingleSport: Bool + let trip: Trip + let richGames: [UUID: RichGame] + let sports: Set + + var displaySports: [Sport] { + Array(sports).sorted { $0.rawValue < $1.rawValue } + } + + var sportLabel: String { + if sports.count == 1 { + return sports.first?.rawValue ?? "" + } else { + return "Multi-Sport" + } + } +} + +// MARK: - Generator + +@MainActor +@Observable +final class SuggestedTripsGenerator { + + // MARK: - Published State + + var isLoading = false + var suggestedTrips: [SuggestedTrip] = [] + var loadingMessage: String = "" + var error: String? + + // MARK: - Dependencies + + private let dataProvider = AppDataProvider.shared + private let planningEngine = TripPlanningEngine() + private let loadingTextGenerator = LoadingTextGenerator.shared + + // MARK: - Grouped Trips + + var tripsByRegion: [(region: Region, trips: [SuggestedTrip])] { + let grouped = Dictionary(grouping: suggestedTrips) { $0.region } + return Region.allCases.compactMap { region in + guard let trips = grouped[region], !trips.isEmpty else { return nil } + return (region: region, trips: trips) + } + } + + // MARK: - Generation + + func generateTrips() async { + guard !isLoading else { return } + + isLoading = true + error = nil + suggestedTrips = [] + + // Start with a loading message + loadingMessage = await loadingTextGenerator.generateMessage() + + // Ensure data is loaded + if dataProvider.teams.isEmpty { + await dataProvider.loadInitialData() + } + + guard !dataProvider.stadiums.isEmpty else { + error = "Unable to load stadium data" + isLoading = false + return + } + + // Calculate date window: 4-8 weeks from now + let calendar = Calendar.current + let today = Date() + guard let startDate = calendar.date(byAdding: .weekOfYear, value: 4, to: today), + let endDate = calendar.date(byAdding: .weekOfYear, value: 8, to: today) else { + error = "Failed to calculate date range" + isLoading = false + return + } + + do { + // Fetch all games in the window + let allSports = Set(Sport.supported) + let games = try await dataProvider.fetchGames( + sports: allSports, + startDate: startDate, + endDate: endDate + ) + + guard !games.isEmpty else { + error = "No games found in the next 4-8 weeks" + isLoading = false + return + } + + // Build lookups + let stadiumsById = Dictionary(uniqueKeysWithValues: dataProvider.stadiums.map { ($0.id, $0) }) + let teamsById = Dictionary(uniqueKeysWithValues: dataProvider.teams.map { ($0.id, $0) }) + + var generatedTrips: [SuggestedTrip] = [] + + // Generate regional trips (East, Central, West) + for region in [Region.east, Region.central, Region.west] { + let regionStadiumIds = Set( + dataProvider.stadiums + .filter { $0.region == region } + .map { $0.id } + ) + + let regionGames = games.filter { regionStadiumIds.contains($0.stadiumId) } + + guard !regionGames.isEmpty else { continue } + + // Single sport trip + if let singleSportTrip = generateRegionalTrip( + games: regionGames, + region: region, + singleSport: true, + stadiums: stadiumsById, + teams: teamsById, + startDate: startDate, + endDate: endDate + ) { + generatedTrips.append(singleSportTrip) + } + + // Multi-sport trip + if let multiSportTrip = generateRegionalTrip( + games: regionGames, + region: region, + singleSport: false, + stadiums: stadiumsById, + teams: teamsById, + startDate: startDate, + endDate: endDate + ) { + generatedTrips.append(multiSportTrip) + } else if let fallbackTrip = generateRegionalTrip( + // Fallback: if multi-sport fails, try another single-sport + games: regionGames, + region: region, + singleSport: true, + stadiums: stadiumsById, + teams: teamsById, + startDate: startDate, + endDate: endDate, + excludingSport: generatedTrips.last?.sports.first + ) { + generatedTrips.append(fallbackTrip) + } + } + + // Cross-country trips (2) + for i in 0..<2 { + if let crossCountryTrip = generateCrossCountryTrip( + games: games, + stadiums: stadiumsById, + teams: teamsById, + startDate: startDate, + endDate: endDate, + excludeGames: i > 0 ? generatedTrips.last?.richGames.values.map { $0.game } ?? [] : [] + ) { + generatedTrips.append(crossCountryTrip) + } + } + + suggestedTrips = generatedTrips + } catch { + self.error = "Failed to generate trips: \(error.localizedDescription)" + } + + isLoading = false + } + + func refreshTrips() async { + await loadingTextGenerator.reset() + await generateTrips() + } + + // MARK: - Trip Generation Helpers + + private func generateRegionalTrip( + games: [Game], + region: Region, + singleSport: Bool, + stadiums: [UUID: Stadium], + teams: [UUID: Team], + startDate: Date, + endDate: Date, + excludingSport: Sport? = nil + ) -> SuggestedTrip? { + + var filteredGames = games + + if singleSport { + // Pick a random sport that has games in this region + let sportsWithGames = Set(games.map { $0.sport }) + var availableSports = Array(sportsWithGames) + + if let exclude = excludingSport { + availableSports.removeAll { $0 == exclude } + } + + guard let selectedSport = availableSports.randomElement() else { return nil } + filteredGames = games.filter { $0.sport == selectedSport } + } + + guard !filteredGames.isEmpty else { return nil } + + // Randomly select a subset of games (3-6 games for a trip) + let gameCount = min(filteredGames.count, Int.random(in: 3...6)) + let selectedGames = Array(filteredGames.shuffled().prefix(gameCount)) + .sorted { $0.dateTime < $1.dateTime } + + // Calculate trip dates based on selected games + guard let firstGame = selectedGames.first, + let lastGame = selectedGames.last else { return nil } + + let tripStartDate = Calendar.current.date(byAdding: .day, value: -1, to: firstGame.dateTime) ?? firstGame.dateTime + let tripEndDate = Calendar.current.date(byAdding: .day, value: 1, to: lastGame.dateTime) ?? lastGame.dateTime + + let sports = Set(selectedGames.map { $0.sport }) + + // Build planning request + let preferences = TripPreferences( + planningMode: .dateRange, + sports: sports, + startDate: tripStartDate, + endDate: tripEndDate, + leisureLevel: .moderate, + maxTripOptions: 1 + ) + + let request = PlanningRequest( + preferences: preferences, + availableGames: selectedGames, + teams: teams, + stadiums: stadiums + ) + + // Run planning engine + let result = planningEngine.planItineraries(request: request) + + switch result { + case .success(let options): + guard let option = options.first else { return nil } + + let trip = convertToTrip(option: option, preferences: preferences) + + // Build richGames dictionary + let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums) + + return SuggestedTrip( + id: UUID(), + region: region, + isSingleSport: singleSport, + trip: trip, + richGames: richGames, + sports: sports + ) + + case .failure: + return nil + } + } + + private func buildRichGames(from games: [Game], teams: [UUID: Team], stadiums: [UUID: Stadium]) -> [UUID: RichGame] { + var result: [UUID: RichGame] = [:] + for game in games { + guard let homeTeam = teams[game.homeTeamId], + let awayTeam = teams[game.awayTeamId], + let stadium = stadiums[game.stadiumId] else { continue } + result[game.id] = RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium) + } + return result + } + + private func generateCrossCountryTrip( + games: [Game], + stadiums: [UUID: Stadium], + teams: [UUID: Team], + startDate: Date, + endDate: Date, + excludeGames: [Game] + ) -> SuggestedTrip? { + + let excludeIds = Set(excludeGames.map { $0.id }) + var availableGames = games.filter { !excludeIds.contains($0.id) } + + guard !availableGames.isEmpty else { return nil } + + // Group games by region + var gamesByRegion: [Region: [Game]] = [:] + for game in availableGames { + guard let stadium = stadiums[game.stadiumId] else { continue } + gamesByRegion[stadium.region, default: []].append(game) + } + + // Ensure we have games in at least 2 regions (ideally all 3) + let regionsWithGames = [Region.east, Region.central, Region.west].filter { + gamesByRegion[$0]?.isEmpty == false + } + + guard regionsWithGames.count >= 2 else { return nil } + + // Select games from each region to build a cross-country route + var selectedGames: [Game] = [] + + for region in regionsWithGames { + guard let regionGames = gamesByRegion[region] else { continue } + // Pick 2-3 games per region + let count = min(regionGames.count, Int.random(in: 2...3)) + let picked = Array(regionGames.shuffled().prefix(count)) + selectedGames.append(contentsOf: picked) + } + + // Sort by date + selectedGames.sort { $0.dateTime < $1.dateTime } + + // Limit to reasonable number (8-10 max) + if selectedGames.count > 10 { + selectedGames = Array(selectedGames.prefix(10)) + } + + guard selectedGames.count >= 4 else { return nil } + + // Calculate trip dates + guard let firstGame = selectedGames.first, + let lastGame = selectedGames.last else { return nil } + + let tripStartDate = Calendar.current.date(byAdding: .day, value: -1, to: firstGame.dateTime) ?? firstGame.dateTime + let tripEndDate = Calendar.current.date(byAdding: .day, value: 1, to: lastGame.dateTime) ?? lastGame.dateTime + + let sports = Set(selectedGames.map { $0.sport }) + + // Build planning request + let preferences = TripPreferences( + planningMode: .dateRange, + sports: sports, + startDate: tripStartDate, + endDate: tripEndDate, + leisureLevel: .moderate, + maxTripOptions: 1 + ) + + let request = PlanningRequest( + preferences: preferences, + availableGames: selectedGames, + teams: teams, + stadiums: stadiums + ) + + // Run planning engine + let result = planningEngine.planItineraries(request: request) + + switch result { + case .success(let options): + guard let option = options.first else { return nil } + + let trip = convertToTrip(option: option, preferences: preferences) + + // Build richGames dictionary + let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums) + + return SuggestedTrip( + id: UUID(), + region: .crossCountry, + isSingleSport: sports.count == 1, + trip: trip, + richGames: richGames, + sports: sports + ) + + case .failure: + return nil + } + } + + private func convertToTrip(option: ItineraryOption, preferences: TripPreferences) -> Trip { + let tripStops = option.stops.enumerated().map { index, stop in + TripStop( + stopNumber: index + 1, + city: stop.city, + state: stop.state, + coordinate: stop.coordinate, + arrivalDate: stop.arrivalDate, + departureDate: stop.departureDate, + games: stop.games, + isRestDay: stop.games.isEmpty + ) + } + + return Trip( + name: generateTripName(from: tripStops), + preferences: preferences, + stops: tripStops, + travelSegments: option.travelSegments, + totalGames: option.totalGames, + totalDistanceMeters: option.totalDistanceMiles * 1609.34, + totalDrivingSeconds: option.totalDrivingHours * 3600 + ) + } + + private func generateTripName(from stops: [TripStop]) -> String { + let cities = stops.compactMap { $0.city }.prefix(3) + if cities.count <= 1 { + return cities.first ?? "Road Trip" + } + return cities.joined(separator: " - ") + } +} diff --git a/SportsTime/Features/Home/Views/HomeView.swift b/SportsTime/Features/Home/Views/HomeView.swift index 1935b80..b06b975 100644 --- a/SportsTime/Features/Home/Views/HomeView.swift +++ b/SportsTime/Features/Home/Views/HomeView.swift @@ -14,6 +14,8 @@ struct HomeView: View { @State private var showNewTrip = false @State private var selectedSport: Sport? @State private var selectedTab = 0 + @State private var suggestedTripsGenerator = SuggestedTripsGenerator() + @State private var selectedSuggestedTrip: SuggestedTrip? var body: some View { TabView(selection: $selectedTab) { @@ -29,15 +31,19 @@ struct HomeView: View { quickActions .staggeredAnimation(index: 1) + // Suggested Trips + suggestedTripsSection + .staggeredAnimation(index: 2) + // Saved Trips if !savedTrips.isEmpty { savedTripsSection - .staggeredAnimation(index: 2) + .staggeredAnimation(index: 3) } // Featured / Tips tipsSection - .staggeredAnimation(index: 3) + .staggeredAnimation(index: 4) } .padding(Theme.Spacing.md) } @@ -96,6 +102,16 @@ struct HomeView: View { selectedSport = nil } } + .task { + if suggestedTripsGenerator.suggestedTrips.isEmpty && !suggestedTripsGenerator.isLoading { + await suggestedTripsGenerator.generateTrips() + } + } + .sheet(item: $selectedSuggestedTrip) { suggestedTrip in + NavigationStack { + TripDetailView(trip: suggestedTrip.trip, games: suggestedTrip.richGames) + } + } } // MARK: - Hero Card @@ -160,6 +176,95 @@ struct HomeView: View { } } + // MARK: - Suggested Trips + + @ViewBuilder + private var suggestedTripsSection: some View { + if suggestedTripsGenerator.isLoading { + LoadingTripsView(message: suggestedTripsGenerator.loadingMessage) + } else if !suggestedTripsGenerator.suggestedTrips.isEmpty { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Header with refresh button + HStack { + Text("Featured Trips") + .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Spacer() + + Button { + Task { + await suggestedTripsGenerator.refreshTrips() + } + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(Theme.warmOrange) + } + } + + // Horizontal carousel grouped by region + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Theme.Spacing.lg) { + ForEach(suggestedTripsGenerator.tripsByRegion, id: \.region) { regionGroup in + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Region header + HStack(spacing: Theme.Spacing.xs) { + Image(systemName: regionGroup.region.iconName) + .font(.system(size: 12)) + Text(regionGroup.region.shortName) + .font(.system(size: Theme.FontSize.caption, weight: .semibold)) + } + .foregroundStyle(Theme.textSecondary(colorScheme)) + + // Trip cards for this region + HStack(spacing: Theme.Spacing.md) { + ForEach(regionGroup.trips) { suggestedTrip in + Button { + selectedSuggestedTrip = suggestedTrip + } label: { + SuggestedTripCard(suggestedTrip: suggestedTrip) + } + .buttonStyle(.plain) + } + } + } + } + } + .padding(.horizontal, 1) // Prevent clipping + } + } + } else if let error = suggestedTripsGenerator.error { + // Error state + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Text("Featured Trips") + .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.orange) + Text(error) + .font(.system(size: Theme.FontSize.caption)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + Spacer() + + Button("Retry") { + Task { + await suggestedTripsGenerator.generateTrips() + } + } + .font(.system(size: Theme.FontSize.caption, weight: .medium)) + .foregroundStyle(Theme.warmOrange) + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + } + } + // MARK: - Saved Trips private var savedTripsSection: some View { diff --git a/SportsTime/Features/Home/Views/LoadingTripsView.swift b/SportsTime/Features/Home/Views/LoadingTripsView.swift new file mode 100644 index 0000000..7a9953c --- /dev/null +++ b/SportsTime/Features/Home/Views/LoadingTripsView.swift @@ -0,0 +1,146 @@ +// +// LoadingTripsView.swift +// SportsTime +// +// Animated loading state for suggested trips carousel. +// + +import SwiftUI + +struct LoadingTripsView: View { + let message: String + @Environment(\.colorScheme) private var colorScheme + @State private var animationPhase: Double = 0 + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Header + HStack { + Text("Featured Trips") + .font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded)) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Spacer() + } + + // Loading message with animation + HStack(spacing: Theme.Spacing.sm) { + LoadingDots() + + Text(message) + .font(.system(size: Theme.FontSize.body)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, Theme.Spacing.xs) + + // Placeholder cards + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Theme.Spacing.md) { + ForEach(0..<3, id: \.self) { index in + PlaceholderCard(animationPhase: animationPhase, index: index) + } + } + } + } + .onAppear { + withAnimation(.linear(duration: 1.5).repeatForever(autoreverses: false)) { + animationPhase = 1 + } + } + } +} + +// MARK: - Loading Dots + +struct LoadingDots: View { + @State private var dotIndex = 0 + + var body: some View { + HStack(spacing: 4) { + ForEach(0..<3, id: \.self) { index in + Circle() + .fill(Theme.warmOrange) + .frame(width: 6, height: 6) + .opacity(index == dotIndex ? 1.0 : 0.3) + } + } + .onAppear { + Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true) { _ in + withAnimation(.easeInOut(duration: 0.2)) { + dotIndex = (dotIndex + 1) % 3 + } + } + } + } +} + +// MARK: - Placeholder Card + +struct PlaceholderCard: View { + let animationPhase: Double + let index: Int + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Header placeholder + HStack { + shimmerRectangle(width: 60, height: 20) + Spacer() + shimmerRectangle(width: 40, height: 16) + } + + // Route placeholder + VStack(alignment: .leading, spacing: 4) { + shimmerRectangle(width: 100, height: 14) + shimmerRectangle(width: 20, height: 10) + shimmerRectangle(width: 80, height: 14) + } + + // Stats placeholder + HStack(spacing: Theme.Spacing.sm) { + shimmerRectangle(width: 70, height: 14) + shimmerRectangle(width: 60, height: 14) + } + + // Date placeholder + shimmerRectangle(width: 120, height: 12) + } + .padding(Theme.Spacing.md) + .frame(width: 200) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } + + private func shimmerRectangle(width: CGFloat, height: CGFloat) -> some View { + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: width, height: height) + } + + private var shimmerGradient: LinearGradient { + let baseColor = Theme.textMuted(colorScheme).opacity(0.2) + let highlightColor = Theme.textMuted(colorScheme).opacity(0.4) + + // Offset based on animation phase and index for staggered effect + let offset = animationPhase + Double(index) * 0.2 + + return LinearGradient( + colors: [baseColor, highlightColor, baseColor], + startPoint: UnitPoint(x: offset - 0.5, y: 0), + endPoint: UnitPoint(x: offset + 0.5, y: 0) + ) + } +} + +#Preview { + VStack { + LoadingTripsView(message: "Hang tight, we're finding the best routes...") + .padding() + } +} diff --git a/SportsTime/Features/Home/Views/SuggestedTripCard.swift b/SportsTime/Features/Home/Views/SuggestedTripCard.swift new file mode 100644 index 0000000..a83d4a9 --- /dev/null +++ b/SportsTime/Features/Home/Views/SuggestedTripCard.swift @@ -0,0 +1,147 @@ +// +// SuggestedTripCard.swift +// SportsTime +// +// Card component for displaying a suggested trip in the carousel. +// + +import SwiftUI + +struct SuggestedTripCard: View { + let suggestedTrip: SuggestedTrip + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Header: Region badge + Sport icons + HStack { + // Region badge + Text(suggestedTrip.region.shortName) + .font(.system(size: Theme.FontSize.micro, weight: .semibold)) + .foregroundStyle(.white) + .padding(.horizontal, Theme.Spacing.xs) + .padding(.vertical, 4) + .background(regionColor) + .clipShape(Capsule()) + + Spacer() + + // Sport icons + HStack(spacing: 4) { + ForEach(suggestedTrip.displaySports, id: \.self) { sport in + Image(systemName: sport.iconName) + .font(.system(size: 12)) + .foregroundStyle(sport.themeColor) + } + } + } + + // Route preview (vertical) + routePreview + + Spacer() + + // Stats row - inline compact display + HStack(spacing: 6) { + Label { + Text(suggestedTrip.trip.totalGames == 1 ? "1 game" : "\(suggestedTrip.trip.totalGames) games") + } icon: { + Image(systemName: "sportscourt") + } + + Text("•") + .foregroundStyle(Theme.textMuted(colorScheme).opacity(0.5)) + + Label { + Text(suggestedTrip.trip.stops.count == 1 ? "1 city" : "\(suggestedTrip.trip.stops.count) cities") + } icon: { + Image(systemName: "mappin") + } + } + .font(.system(size: 11)) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + // Date range + Text(suggestedTrip.trip.formattedDateRange) + .font(.system(size: Theme.FontSize.micro)) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(Theme.Spacing.md) + .frame(width: 200, height: 160) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + .shadow(color: Theme.cardShadow(colorScheme), radius: 8, y: 4) + } + + private var routePreview: some View { + let cities = suggestedTrip.trip.stops.map { $0.city } + let displayCities: [String] + + if cities.count <= 3 { + displayCities = cities + } else { + displayCities = [cities.first ?? "", "...", cities.last ?? ""] + } + + return VStack(alignment: .leading, spacing: 0) { + ForEach(Array(displayCities.enumerated()), id: \.offset) { index, city in + if index > 0 { + // Connector + HStack(spacing: 4) { + Text("|") + .font(.system(size: 10)) + Image(systemName: "chevron.down") + .font(.system(size: 8)) + } + .foregroundStyle(Theme.warmOrange.opacity(0.6)) + .padding(.leading, 4) + } + + Text(city) + .font(.system(size: Theme.FontSize.caption, weight: index == 0 ? .semibold : .regular)) + .foregroundStyle(index == 0 ? Theme.textPrimary(colorScheme) : Theme.textSecondary(colorScheme)) + .lineLimit(1) + } + } + } + + private var regionColor: Color { + switch suggestedTrip.region { + case .east: return .blue + case .central: return .green + case .west: return .orange + case .crossCountry: return .purple + } + } +} + + + +#Preview { + let trip = Trip( + name: "Test Trip", + preferences: TripPreferences(), + stops: [ + TripStop(stopNumber: 1, city: "New York", state: "NY", coordinate: nil, arrivalDate: Date(), departureDate: Date(), games: [], isRestDay: false), + TripStop(stopNumber: 2, city: "Boston", state: "MA", coordinate: nil, arrivalDate: Date(), departureDate: Date(), games: [], isRestDay: false), + TripStop(stopNumber: 3, city: "Philadelphia", state: "PA", coordinate: nil, arrivalDate: Date(), departureDate: Date(), games: [], isRestDay: false) + ], + totalGames: 5 + ) + + let suggestedTrip = SuggestedTrip( + id: UUID(), + region: .east, + isSingleSport: false, + trip: trip, + richGames: [:], + sports: [.mlb, .nba] + ) + + SuggestedTripCard(suggestedTrip: suggestedTrip) + .padding() +}