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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user