Add featured trips carousel on home screen
- Generate 8 suggested trips on app launch (2 per region: East, Central, West, Cross-Country) - Each region has single-sport and multi-sport trip options - Region classification based on stadium longitude - Animated loading state with shimmer placeholders - Loading messages use Foundation Models when available, fallback otherwise - Tap card to view trip details in sheet - Refresh button to regenerate trips - Fixed-height cards with aligned top/bottom layout New files: - Region.swift: Geographic region enum with longitude classification - LoadingTextGenerator.swift: On-device AI loading messages - SuggestedTripsGenerator.swift: Trip generation service - SuggestedTripCard.swift: Carousel card component - LoadingTripsView.swift: Animated loading state 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
54
SportsTime/Core/Models/Domain/Region.swift
Normal file
54
SportsTime/Core/Models/Domain/Region.swift
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,10 @@ struct Stadium: Identifiable, Codable, Hashable {
|
|||||||
"\(city), \(state)"
|
"\(city), \(state)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var region: Region {
|
||||||
|
Region.classify(longitude: longitude)
|
||||||
|
}
|
||||||
|
|
||||||
func distance(to other: Stadium) -> CLLocationDistance {
|
func distance(to other: Stadium) -> CLLocationDistance {
|
||||||
location.distance(from: other.location)
|
location.distance(from: other.location)
|
||||||
}
|
}
|
||||||
|
|||||||
100
SportsTime/Core/Services/LoadingTextGenerator.swift
Normal file
100
SportsTime/Core/Services/LoadingTextGenerator.swift
Normal file
@@ -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<String> = []
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
426
SportsTime/Core/Services/SuggestedTripsGenerator.swift
Normal file
426
SportsTime/Core/Services/SuggestedTripsGenerator.swift
Normal file
@@ -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<Sport>
|
||||||
|
|
||||||
|
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: " - ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ struct HomeView: View {
|
|||||||
@State private var showNewTrip = false
|
@State private var showNewTrip = false
|
||||||
@State private var selectedSport: Sport?
|
@State private var selectedSport: Sport?
|
||||||
@State private var selectedTab = 0
|
@State private var selectedTab = 0
|
||||||
|
@State private var suggestedTripsGenerator = SuggestedTripsGenerator()
|
||||||
|
@State private var selectedSuggestedTrip: SuggestedTrip?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
@@ -29,15 +31,19 @@ struct HomeView: View {
|
|||||||
quickActions
|
quickActions
|
||||||
.staggeredAnimation(index: 1)
|
.staggeredAnimation(index: 1)
|
||||||
|
|
||||||
|
// Suggested Trips
|
||||||
|
suggestedTripsSection
|
||||||
|
.staggeredAnimation(index: 2)
|
||||||
|
|
||||||
// Saved Trips
|
// Saved Trips
|
||||||
if !savedTrips.isEmpty {
|
if !savedTrips.isEmpty {
|
||||||
savedTripsSection
|
savedTripsSection
|
||||||
.staggeredAnimation(index: 2)
|
.staggeredAnimation(index: 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Featured / Tips
|
// Featured / Tips
|
||||||
tipsSection
|
tipsSection
|
||||||
.staggeredAnimation(index: 3)
|
.staggeredAnimation(index: 4)
|
||||||
}
|
}
|
||||||
.padding(Theme.Spacing.md)
|
.padding(Theme.Spacing.md)
|
||||||
}
|
}
|
||||||
@@ -96,6 +102,16 @@ struct HomeView: View {
|
|||||||
selectedSport = nil
|
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
|
// 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
|
// MARK: - Saved Trips
|
||||||
|
|
||||||
private var savedTripsSection: some View {
|
private var savedTripsSection: some View {
|
||||||
|
|||||||
146
SportsTime/Features/Home/Views/LoadingTripsView.swift
Normal file
146
SportsTime/Features/Home/Views/LoadingTripsView.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
147
SportsTime/Features/Home/Views/SuggestedTripCard.swift
Normal file
147
SportsTime/Features/Home/Views/SuggestedTripCard.swift
Normal file
@@ -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()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user