// // AnimatedComponents.swift // SportsTime // // Animated UI components for visual delight. // import SwiftUI // MARK: - Animated Route Graphic /// A stylized animated route illustration for loading states struct AnimatedRouteGraphic: View { @State private var animationProgress: CGFloat = 0 @State private var dotPositions: [CGFloat] = [0.1, 0.4, 0.7] var body: some View { GeometryReader { geo in let width = geo.size.width let height = geo.size.height Canvas { context, size in // Draw the route path let path = createRoutePath(in: CGRect(origin: .zero, size: size)) // Glow layer context.addFilter(.blur(radius: 8)) context.stroke( path, with: .linearGradient( Gradient(colors: [Theme.routeGold.opacity(0.5), Theme.warmOrange.opacity(0.5)]), startPoint: .zero, endPoint: CGPoint(x: size.width, y: size.height) ), lineWidth: 6 ) // Reset filter and draw main line context.addFilter(.blur(radius: 0)) context.stroke( path, with: .linearGradient( Gradient(colors: [Theme.routeGold, Theme.warmOrange]), startPoint: .zero, endPoint: CGPoint(x: size.width, y: size.height) ), style: StrokeStyle(lineWidth: 3, lineCap: .round, lineJoin: .round) ) } // Animated traveling dot Circle() .fill(Theme.warmOrange) .frame(width: 12, height: 12) .glowEffect(color: Theme.warmOrange, radius: 12) .position( x: width * 0.1 + (width * 0.8) * animationProgress, y: height * 0.6 + sin(animationProgress * .pi * 2) * (height * 0.2) ) // Stadium markers ForEach(Array(dotPositions.enumerated()), id: \.offset) { index, pos in PulsingDot(color: index == 0 ? Theme.mlbRed : (index == 1 ? Theme.nbaOrange : Theme.nhlBlue)) .frame(width: 16, height: 16) .position( x: width * 0.1 + (width * 0.8) * pos, y: height * 0.6 + sin(pos * .pi * 2) * (height * 0.2) ) } } .onAppear { withAnimation(.easeInOut(duration: Theme.Animation.routeDrawDuration).repeatForever(autoreverses: false)) { animationProgress = 1 } } } private func createRoutePath(in rect: CGRect) -> Path { Path { path in let startX = rect.width * 0.1 let endX = rect.width * 0.9 let midY = rect.height * 0.6 let amplitude = rect.height * 0.2 path.move(to: CGPoint(x: startX, y: midY)) // Create a smooth curve through the points let controlPoints: [(CGFloat, CGFloat)] = [ (0.25, midY - amplitude * 0.5), (0.4, midY + amplitude * 0.3), (0.55, midY - amplitude * 0.4), (0.7, midY + amplitude * 0.2), (0.85, midY - amplitude * 0.1) ] for (progress, y) in controlPoints { let x = startX + (endX - startX) * progress path.addLine(to: CGPoint(x: x, y: y)) } path.addLine(to: CGPoint(x: endX, y: midY)) } } } // MARK: - Pulsing Dot struct PulsingDot: View { var color: Color = Theme.warmOrange var size: CGFloat = 12 @State private var isPulsing = false var body: some View { ZStack { // Outer pulse ring Circle() .stroke(color.opacity(0.3), lineWidth: 2) .frame(width: size * 2, height: size * 2) .scaleEffect(isPulsing ? 1.5 : 1) .opacity(isPulsing ? 0 : 1) // Inner dot Circle() .fill(color) .frame(width: size, height: size) .shadow(color: color.opacity(0.5), radius: 4) } .onAppear { withAnimation(.easeOut(duration: 1.5).repeatForever(autoreverses: false)) { isPulsing = true } } } } // MARK: - Route Preview Strip /// A compact horizontal visualization of route stops struct RoutePreviewStrip: View { let cities: [String] @Environment(\.colorScheme) private var colorScheme init(cities: [String]) { self.cities = cities } var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 4) { ForEach(Array(cities.enumerated()), id: \.offset) { index, city in if index > 0 { // Connector line Rectangle() .fill(Theme.routeGold.opacity(0.5)) .frame(width: 12, height: 2) } // City dot with label VStack(spacing: 4) { Circle() .fill(index == 0 || index == cities.count - 1 ? Theme.warmOrange : Theme.routeGold) .frame(width: 8, height: 8) Text(abbreviateCity(city)) .font(.caption2) .foregroundStyle(Theme.textSecondary(colorScheme)) .lineLimit(1) } } } .padding(.horizontal, Theme.Spacing.md) } } private func abbreviateCity(_ city: String) -> String { let words = city.split(separator: " ") if words.count > 1 { return String(words[0].prefix(3)) } return String(city.prefix(4)) } } // MARK: - Stat Pill struct StatPill: View { let icon: String let value: String @Environment(\.colorScheme) private var colorScheme var body: some View { HStack(spacing: 6) { Image(systemName: icon) .font(.caption) Text(value) .font(.footnote) } .foregroundStyle(Theme.textSecondary(colorScheme)) .padding(.horizontal, 12) .padding(.vertical, 6) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(Capsule()) } } // MARK: - Empty State View struct EmptyStateView: View { let icon: String let title: String let message: String var actionTitle: String? = nil var action: (() -> Void)? = nil @Environment(\.colorScheme) private var colorScheme var body: some View { VStack(spacing: 20) { Image(systemName: icon) .font(.largeTitle) .foregroundStyle(Theme.warmOrange.opacity(0.7)) VStack(spacing: 8) { Text(title) .font(.title3) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(message) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) .multilineTextAlignment(.center) } if let actionTitle = actionTitle, let action = action { Button(action: action) { Text(actionTitle) .font(.body) .foregroundStyle(.white) .padding(.horizontal, 24) .padding(.vertical, 12) .background(Theme.warmOrange) .clipShape(Capsule()) } .pressableStyle() .padding(.top, 8) } } .padding(40) } } // MARK: - Preview #Preview("Animated Components") { VStack(spacing: 40) { AnimatedRouteGraphic() .frame(height: 150) RoutePreviewStrip(cities: ["San Diego", "Los Angeles", "San Francisco", "Seattle", "Portland"]) HStack { StatPill(icon: "car", value: "450 mi") StatPill(icon: "clock", value: "8h driving") } PulsingDot(color: Theme.warmOrange) .frame(width: 40, height: 40) } .padding() .themedBackground() }