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