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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user