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() - } - } -}