Fixes ~95 issues from deep audit across 12 categories in 82 files: - Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files - Silent failure elimination: all 34 try? sites replaced with do/try/catch + logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService, CanonicalModels, CKModels, SportsTimeApp, and more) - Performance: cached DateFormatters (7 files), O(1) team lookups via AppDataProvider, achievement definition dictionary, AnimatedBackground consolidated from 19 Tasks to 1, task cancellation in SharePreviewView - Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard, @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix - Planning engine: game end time in travel feasibility, state-aware city normalization, exact city matching, DrivingConstraints parameter propagation - IAP: unknown subscription states → expired, unverified transaction logging, entitlements updated before paywall dismiss, restore visible to all users - Security: API key to Info.plist lookup, filename sanitization in PDF export, honest User-Agent, removed stale "Feels" analytics super properties - Navigation: consolidated competing navigationDestination, boolean → value-based - Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat - Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel MKDirections, Sendable-safe POI struct Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
222 lines
8.4 KiB
Swift
222 lines
8.4 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
|
|
@State private var glowOpacities: [Double] = Array(repeating: 0, count: AnimatedSportsIcon.configs.count)
|
|
@State private var glowDriverTask: Task<Void, Never>?
|
|
|
|
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..<AnimatedSportsIcon.configs.count, id: \.self) { index in
|
|
AnimatedSportsIcon(index: index, animate: animate, glowOpacity: glowOpacities[index])
|
|
}
|
|
}
|
|
.accessibilityHidden(true)
|
|
.onAppear {
|
|
guard !Theme.Animation.prefersReducedMotion else { return }
|
|
withAnimation(.easeInOut(duration: 5.0).repeatForever(autoreverses: true)) {
|
|
animate = true
|
|
}
|
|
startGlowDriver()
|
|
}
|
|
.onDisappear {
|
|
animate = false
|
|
glowDriverTask?.cancel()
|
|
glowDriverTask = nil
|
|
}
|
|
}
|
|
|
|
/// Single task that drives glow animations for all icons
|
|
private func startGlowDriver() {
|
|
glowDriverTask = Task { @MainActor in
|
|
let iconCount = AnimatedSportsIcon.configs.count
|
|
// Track next glow time for each icon
|
|
var nextGlowTime: [TimeInterval] = (0..<iconCount).map { _ in
|
|
Double.random(in: 2.0...8.0)
|
|
}
|
|
// Track glow state: 0 = waiting, 1 = glowing, 2 = fading out
|
|
var glowState: [Int] = Array(repeating: 0, count: iconCount)
|
|
var stateTimer: [TimeInterval] = Array(repeating: 0, count: iconCount)
|
|
|
|
let tickInterval: TimeInterval = 0.5
|
|
let startTime = Date.timeIntervalSinceReferenceDate
|
|
|
|
while !Task.isCancelled {
|
|
try? await Task.sleep(for: .seconds(tickInterval))
|
|
guard !Task.isCancelled else { break }
|
|
|
|
let elapsed = Date.timeIntervalSinceReferenceDate - startTime
|
|
|
|
for i in 0..<iconCount {
|
|
switch glowState[i] {
|
|
case 0: // Waiting
|
|
if elapsed >= nextGlowTime[i] {
|
|
withAnimation(.easeIn(duration: 0.8)) { glowOpacities[i] = 1 }
|
|
glowState[i] = 1
|
|
stateTimer[i] = elapsed
|
|
}
|
|
case 1: // Glowing - hold for 1.2s
|
|
if elapsed - stateTimer[i] >= 1.2 {
|
|
withAnimation(.easeOut(duration: 1.0)) { glowOpacities[i] = 0 }
|
|
glowState[i] = 2
|
|
stateTimer[i] = elapsed
|
|
}
|
|
case 2: // Fading out - wait 1.0s then schedule next
|
|
if elapsed - stateTimer[i] >= 1.0 {
|
|
glowState[i] = 0
|
|
nextGlowTime[i] = elapsed + Double.random(in: 6.0...12.0)
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
var glowOpacity: Double = 0
|
|
|
|
static 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 = Self.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
|
|
)
|
|
}
|
|
}
|
|
}
|