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)"
|
||||
}
|
||||
|
||||
var region: Region {
|
||||
Region.classify(longitude: longitude)
|
||||
}
|
||||
|
||||
func distance(to other: Stadium) -> CLLocationDistance {
|
||||
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: " - ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user