From e1d84ac769e0a7cce74c11c0335574a130b97162 Mon Sep 17 00:00:00 2001 From: Trey t Date: Wed, 21 Jan 2026 18:06:58 -0600 Subject: [PATCH] feat(ui): apply animated background to all screens via themedBackground modifier Extract AnimatedSportsBackground components to shared file and update ThemedBackground modifier to conditionally show animations when enabled in settings. All views using .themedBackground() now get animated background. Co-Authored-By: Claude Opus 4.5 --- .../Core/Theme/AnimatedBackground.swift | 195 +++++++++++++++ SportsTime/Core/Theme/ViewModifiers.swift | 10 +- .../Classic/HomeContent_ClassicAnimated.swift | 231 ++---------------- 3 files changed, 225 insertions(+), 211 deletions(-) create mode 100644 SportsTime/Core/Theme/AnimatedBackground.swift diff --git a/SportsTime/Core/Theme/AnimatedBackground.swift b/SportsTime/Core/Theme/AnimatedBackground.swift new file mode 100644 index 0000000..535bf59 --- /dev/null +++ b/SportsTime/Core/Theme/AnimatedBackground.swift @@ -0,0 +1,195 @@ +// +// AnimatedBackground.swift +// SportsTime +// +// Animated sports background with floating icons and route lines. +// Used by ThemedBackground modifier when animations are enabled. +// + +import SwiftUI + +// MARK: - Animated Sports Background + +/// Floating sports icons with route lines and subtle glow effects +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 + } + } + } +} + +// MARK: - Route Map Layer + +/// Background route lines connecting city dots (very subtle) +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))) + } + } + } +} + +// MARK: - Animated Sports Icon + +/// Individual floating sports icon with subtle glow animation +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() + } + } +} diff --git a/SportsTime/Core/Theme/ViewModifiers.swift b/SportsTime/Core/Theme/ViewModifiers.swift index 76e940a..1d8c318 100644 --- a/SportsTime/Core/Theme/ViewModifiers.swift +++ b/SportsTime/Core/Theme/ViewModifiers.swift @@ -183,7 +183,15 @@ struct ThemedBackground: ViewModifier { func body(content: Content) -> some View { content - .background(Theme.backgroundGradient(colorScheme)) + .background { + if DesignStyleManager.shared.animationsEnabled { + AnimatedSportsBackground() + .ignoresSafeArea() + } else { + Theme.backgroundGradient(colorScheme) + .ignoresSafeArea() + } + } } } diff --git a/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift b/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift index 2395064..64f9b35 100644 --- a/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift +++ b/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift @@ -21,37 +21,30 @@ struct HomeContent_ClassicAnimated: View { let displayedTips: [PlanningTip] var body: some View { - ZStack { - // Animated background layer - AnimatedSportsBackground() - .ignoresSafeArea() + ScrollView { + VStack(spacing: Theme.Spacing.lg) { + // Hero Card + heroCard + .padding(.horizontal, Theme.Spacing.md) + .padding(.top, Theme.Spacing.sm) - // Content layer - ScrollView { - VStack(spacing: Theme.Spacing.lg) { - // Hero Card - heroCard + // Suggested Trips + suggestedTripsSection + .padding(.horizontal, Theme.Spacing.md) + + // Saved Trips + if !savedTrips.isEmpty { + savedTripsSection .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) } + + // Planning Tips + if !displayedTips.isEmpty { + tipsSection + .padding(.horizontal, Theme.Spacing.md) + } + + Spacer(minLength: 40) } } .themedBackground() @@ -325,185 +318,3 @@ struct HomeContent_ClassicAnimated: View { } } } - -// 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() - } - } -}