feat(design): add Classic Animated home screen style

New design style combines the Classic layout with subtle animated
backgrounds featuring floating sports icons and route lines.
Animations are slow and unobtrusive to avoid distracting from content.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-14 13:23:13 -06:00
parent f7f1bbd87a
commit d34be05d61
3 changed files with 524 additions and 0 deletions

View File

@@ -11,6 +11,7 @@ import SwiftUI
enum UIDesignStyle: String, CaseIterable, Identifiable, Codable {
// Default
case classic = "Classic"
case classicAnimated = "Classic Animated"
// Original experimental aesthetics
case brutalist = "Brutalist"
@@ -44,6 +45,8 @@ enum UIDesignStyle: String, CaseIterable, Identifiable, Codable {
switch self {
case .classic:
return "The original SportsTime design"
case .classicAnimated:
return "Classic with animated sports backgrounds"
case .brutalist:
return "Raw, unpolished anti-design rebellion"
case .luxuryEditorial:
@@ -94,6 +97,7 @@ enum UIDesignStyle: String, CaseIterable, Identifiable, Codable {
var iconName: String {
switch self {
case .classic: return "star.fill"
case .classicAnimated: return "sparkles"
case .brutalist: return "hammer.fill"
case .luxuryEditorial: return "book.fill"
case .retroFuturism: return "tv.fill"
@@ -122,6 +126,7 @@ enum UIDesignStyle: String, CaseIterable, Identifiable, Codable {
var accentColor: Color {
switch self {
case .classic: return Color(red: 1.0, green: 0.45, blue: 0.2) // Warm Orange
case .classicAnimated: return Color(red: 1.0, green: 0.45, blue: 0.2) // Warm Orange (same as Classic)
case .brutalist: return .red
case .luxuryEditorial: return Color(red: 0.85, green: 0.65, blue: 0.13) // Gold
case .retroFuturism: return Color(red: 0.0, green: 1.0, blue: 0.8) // Cyan

View File

@@ -29,6 +29,16 @@ struct AdaptiveHomeContent: View {
displayedTips: displayedTips
)
case .classicAnimated:
HomeContent_ClassicAnimated(
showNewTrip: $showNewTrip,
selectedTab: $selectedTab,
selectedSuggestedTrip: $selectedSuggestedTrip,
savedTrips: savedTrips,
suggestedTripsGenerator: suggestedTripsGenerator,
displayedTips: displayedTips
)
case .brutalist:
HomeContent_Brutalist(
showNewTrip: $showNewTrip,

View File

@@ -0,0 +1,509 @@
//
// HomeContent_ClassicAnimated.swift
// SportsTime
//
// CLASSIC ANIMATED: The original SportsTime design with animated backgrounds.
// Uses the app's Theme system with warm orange accents.
// Clean cards, glow effects, familiar layout, plus floating sports icons.
//
import SwiftUI
import SwiftData
struct HomeContent_ClassicAnimated: View {
@Environment(\.colorScheme) private var colorScheme
@Binding var showNewTrip: Bool
@Binding var selectedTab: Int
@Binding var selectedSuggestedTrip: SuggestedTrip?
let savedTrips: [SavedTrip]
let suggestedTripsGenerator: SuggestedTripsGenerator
let displayedTips: [PlanningTip]
var body: some View {
ZStack {
// Animated background layer
AnimatedSportsBackground()
.ignoresSafeArea()
// Content layer
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Hero Card
heroCard
.padding(.horizontal, Theme.Spacing.md)
.padding(.top, Theme.Spacing.sm)
// Suggested Trips
suggestedTripsSection
.padding(.horizontal, Theme.Spacing.md)
// Saved Trips
if !savedTrips.isEmpty {
savedTripsSection
.padding(.horizontal, Theme.Spacing.md)
}
// Planning Tips
if !displayedTips.isEmpty {
tipsSection
.padding(.horizontal, Theme.Spacing.md)
}
Spacer(minLength: 40)
}
}
}
.themedBackground()
}
// MARK: - Hero Card
private var heroCard: some View {
VStack(spacing: Theme.Spacing.lg) {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Adventure Awaits")
.font(.largeTitle)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Plan your ultimate sports road trip. Visit stadiums, catch games, and create unforgettable memories.")
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
Button {
showNewTrip = true
} label: {
HStack(spacing: Theme.Spacing.xs) {
Image(systemName: "map.fill")
Text("Start Planning")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.warmOrange)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.pressableStyle()
.glowEffect(color: Theme.warmOrange, radius: 12)
}
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.xlarge))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.xlarge)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.shadow(color: Theme.cardShadow(colorScheme), radius: 15, y: 8)
}
// 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(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Button {
Task {
await suggestedTripsGenerator.refreshTrips()
}
} label: {
Image(systemName: "arrow.clockwise")
.font(.subheadline)
.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(.caption)
Text(regionGroup.region.shortName)
.font(.subheadline)
}
.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)
}
}
} else if let error = suggestedTripsGenerator.error {
// Error state
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Featured Trips")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange)
Text(error)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Button("Retry") {
Task {
await suggestedTripsGenerator.generateTrips()
}
}
.font(.subheadline)
.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 {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Text("Recent Trips")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Button {
selectedTab = 2
} label: {
HStack(spacing: 4) {
Text("See All")
Image(systemName: "chevron.right")
.font(.caption)
}
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
}
}
ForEach(Array(savedTrips.prefix(3).enumerated()), id: \.element.id) { index, savedTrip in
if let trip = savedTrip.trip {
NavigationLink {
TripDetailView(trip: trip)
} label: {
classicTripCard(savedTrip: savedTrip, trip: trip)
}
.buttonStyle(.plain)
.staggeredAnimation(index: index, delay: 0.05)
}
}
}
}
private func classicTripCard(savedTrip: SavedTrip, trip: Trip) -> some View {
HStack(spacing: Theme.Spacing.md) {
// Route preview icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 44, height: 44)
Image(systemName: "map.fill")
.foregroundStyle(Theme.warmOrange)
}
VStack(alignment: .leading, spacing: 4) {
Text(trip.displayName)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(trip.formattedDateRange)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
HStack(spacing: Theme.Spacing.sm) {
HStack(spacing: 4) {
Image(systemName: "mappin")
.font(.caption2)
Text("\(trip.stops.count) cities")
}
HStack(spacing: 4) {
Image(systemName: "sportscourt")
.font(.caption2)
Text("\(trip.totalGames) games")
}
}
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
// MARK: - Tips Section
private var tipsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Planning Tips")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
VStack(spacing: Theme.Spacing.xs) {
ForEach(displayedTips) { tip in
classicTipRow(icon: tip.icon, title: tip.title, subtitle: tip.subtitle)
}
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
}
private func classicTipRow(icon: String, title: String, subtitle: String) -> some View {
HStack(spacing: Theme.Spacing.sm) {
ZStack {
Circle()
.fill(Theme.routeGold.opacity(0.15))
.frame(width: 36, height: 36)
Image(systemName: icon)
.font(.subheadline)
.foregroundStyle(Theme.routeGold)
}
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(subtitle)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
}
}
}
// MARK: - Animated Sports Background
/// Floating sports icons with route lines and subtle glow effects
private struct AnimatedSportsBackground: View {
@Environment(\.colorScheme) private var colorScheme
@State private var animate = false
var body: some View {
ZStack {
// Base gradient
Theme.backgroundGradient(colorScheme)
// Route lines with city dots (subtle background element)
RouteMapLayer(animate: animate)
// Floating sports icons with gentle glow
ForEach(0..<20, id: \.self) { index in
AnimatedSportsIcon(index: index, animate: animate)
}
}
.onAppear {
withAnimation(.easeInOut(duration: 5.0).repeatForever(autoreverses: true)) {
animate = true
}
}
}
}
/// Background route lines connecting city dots (very subtle)
private struct RouteMapLayer: View {
@Environment(\.colorScheme) private var colorScheme
let animate: Bool
var body: some View {
Canvas { context, size in
// City points scattered across the view
let points: [CGPoint] = [
CGPoint(x: size.width * 0.1, y: size.height * 0.15),
CGPoint(x: size.width * 0.3, y: size.height * 0.25),
CGPoint(x: size.width * 0.55, y: size.height * 0.1),
CGPoint(x: size.width * 0.75, y: size.height * 0.3),
CGPoint(x: size.width * 0.2, y: size.height * 0.45),
CGPoint(x: size.width * 0.6, y: size.height * 0.5),
CGPoint(x: size.width * 0.85, y: size.height * 0.2),
CGPoint(x: size.width * 0.4, y: size.height * 0.65),
CGPoint(x: size.width * 0.8, y: size.height * 0.6),
CGPoint(x: size.width * 0.15, y: size.height * 0.75),
CGPoint(x: size.width * 0.5, y: size.height * 0.8),
CGPoint(x: size.width * 0.9, y: size.height * 0.85),
]
// Draw dotted route lines connecting points
let routePairs: [(Int, Int)] = [
(0, 1), (1, 3), (3, 6), (2, 6),
(1, 4), (4, 5), (5, 8), (4, 9),
(5, 7), (7, 10), (9, 10), (10, 11),
(2, 3), (8, 11)
]
let lineColor = Theme.warmOrange.resolve(in: .init())
for (start, end) in routePairs {
var path = Path()
path.move(to: points[start])
path.addLine(to: points[end])
context.stroke(
path,
with: .color(Color(lineColor).opacity(0.05)),
style: StrokeStyle(lineWidth: 1, dash: [5, 5])
)
}
// Draw city dots (very subtle)
for (index, point) in points.enumerated() {
let isMainCity = index % 4 == 0
let dotSize: CGFloat = isMainCity ? 5 : 3
let dotPath = Path(ellipseIn: CGRect(
x: point.x - dotSize / 2,
y: point.y - dotSize / 2,
width: dotSize,
height: dotSize
))
context.fill(dotPath, with: .color(Color(lineColor).opacity(isMainCity ? 0.1 : 0.05)))
}
}
}
}
/// Individual floating sports icon with subtle glow animation
private struct AnimatedSportsIcon: View {
let index: Int
let animate: Bool
@State private var glowOpacity: Double = 0
private let configs: [(x: CGFloat, y: CGFloat, icon: String, rotation: Double, scale: CGFloat)] = [
// Edge icons
(0.06, 0.08, "football.fill", -15, 0.85),
(0.94, 0.1, "basketball.fill", 12, 0.8),
(0.04, 0.28, "baseball.fill", 8, 0.75),
(0.96, 0.32, "hockey.puck.fill", -10, 0.7),
(0.08, 0.48, "soccerball", 6, 0.8),
(0.92, 0.45, "figure.run", -6, 0.85),
(0.05, 0.68, "sportscourt.fill", 4, 0.75),
(0.95, 0.65, "trophy.fill", -12, 0.8),
(0.1, 0.88, "ticket.fill", 10, 0.7),
(0.9, 0.85, "mappin.circle.fill", -8, 0.75),
(0.5, 0.03, "car.fill", 0, 0.7),
(0.5, 0.97, "map.fill", 3, 0.75),
(0.25, 0.93, "stadium.fill", -5, 0.7),
(0.75, 0.95, "flag.checkered", 7, 0.7),
// Middle area icons (will appear behind cards)
(0.35, 0.22, "tennisball.fill", -8, 0.65),
(0.65, 0.35, "volleyball.fill", 10, 0.6),
(0.3, 0.52, "figure.baseball", -5, 0.65),
(0.7, 0.58, "figure.basketball", 8, 0.6),
(0.4, 0.72, "figure.hockey", -10, 0.65),
(0.6, 0.82, "figure.soccer", 5, 0.6),
]
var body: some View {
let config = configs[index]
GeometryReader { geo in
ZStack {
// Subtle glow circle behind icon when active
Circle()
.fill(Theme.warmOrange)
.frame(width: 28 * config.scale, height: 28 * config.scale)
.blur(radius: 8)
.opacity(glowOpacity * 0.2)
Image(systemName: config.icon)
.font(.system(size: 20 * config.scale))
.foregroundStyle(Theme.warmOrange.opacity(0.08 + glowOpacity * 0.1))
.rotationEffect(.degrees(config.rotation))
}
.position(x: geo.size.width * config.x, y: geo.size.height * config.y)
.scaleEffect(animate ? 1.02 : 0.98)
.scaleEffect(1 + glowOpacity * 0.05)
.animation(
.easeInOut(duration: 4.0 + Double(index) * 0.15)
.repeatForever(autoreverses: true)
.delay(Double(index) * 0.2),
value: animate
)
}
.onAppear {
startRandomGlow()
}
}
private func startRandomGlow() {
let initialDelay = Double.random(in: 2.0...8.0)
DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) {
triggerGlow()
}
}
private func triggerGlow() {
// Slow fade in
withAnimation(.easeIn(duration: 0.8)) {
glowOpacity = 1
}
// Hold briefly then slow fade out
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) {
withAnimation(.easeOut(duration: 1.0)) {
glowOpacity = 0
}
}
// Longer interval between glows
let nextGlow = Double.random(in: 6.0...12.0)
DispatchQueue.main.asyncAfter(deadline: .now() + nextGlow) {
triggerGlow()
}
}
}