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 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-21 18:06:58 -06:00
parent 3a135743f8
commit e1d84ac769
3 changed files with 225 additions and 211 deletions

View File

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

View File

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

View File

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