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: " - ")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
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