From d34be05d61549846ea601a07e3c4c6baea7f6407 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 14 Jan 2026 13:23:13 -0600 Subject: [PATCH] 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 --- SportsTime/Core/Design/UIDesignStyle.swift | 5 + .../Home/Views/AdaptiveHomeContent.swift | 10 + .../Classic/HomeContent_ClassicAnimated.swift | 509 ++++++++++++++++++ 3 files changed, 524 insertions(+) create mode 100644 SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift diff --git a/SportsTime/Core/Design/UIDesignStyle.swift b/SportsTime/Core/Design/UIDesignStyle.swift index 741c99e..2f71e84 100644 --- a/SportsTime/Core/Design/UIDesignStyle.swift +++ b/SportsTime/Core/Design/UIDesignStyle.swift @@ -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 diff --git a/SportsTime/Features/Home/Views/AdaptiveHomeContent.swift b/SportsTime/Features/Home/Views/AdaptiveHomeContent.swift index b72db22..16caebe 100644 --- a/SportsTime/Features/Home/Views/AdaptiveHomeContent.swift +++ b/SportsTime/Features/Home/Views/AdaptiveHomeContent.swift @@ -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, diff --git a/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift b/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift new file mode 100644 index 0000000..2395064 --- /dev/null +++ b/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift @@ -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() + } + } +}