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 {