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

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