Files
Sportstime/SportsTime/Core/Theme/AnimatedBackground.swift
Trey t dbb0099776 chore: remove scraper, add docs, add marketing-videos gitignore
- Remove Scripts/ directory (scraper no longer needed)
- Add themed background documentation to CLAUDE.md
- Add .gitignore for marketing-videos to prevent node_modules tracking

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:13:12 -06:00

195 lines
6.7 KiB
Swift

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