- 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>
148 lines
5.0 KiB
Swift
148 lines
5.0 KiB
Swift
//
|
|
// 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()
|
|
}
|