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:
Trey t
2026-01-08 10:33:44 -06:00
parent aadc82db73
commit 415202e7f4
7 changed files with 984 additions and 2 deletions

View 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
}
}
}

View File

@@ -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)
}

View 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()
}
}

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

View File

@@ -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 {

View 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()
}
}

View 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()
}