// // 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: - Themed Spinner /// A custom animated spinner matching the app's visual style /// Both ThemedSpinner and ThemedSpinnerCompact use the same visual style for consistency struct ThemedSpinner: View { var size: CGFloat = 40 var lineWidth: CGFloat = 4 var color: Color = Theme.warmOrange @State private var rotation: Double = 0 var body: some View { ZStack { // Background track Circle() .stroke(color.opacity(0.2), lineWidth: lineWidth) // Animated arc Circle() .trim(from: 0, to: 0.7) .stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) .rotationEffect(.degrees(rotation)) } .frame(width: size, height: size) .onAppear { withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) { rotation = 360 } } } } /// Compact themed spinner for inline use (same style as ThemedSpinner, smaller default) struct ThemedSpinnerCompact: View { var size: CGFloat = 20 var color: Color = Theme.warmOrange @State private var rotation: Double = 0 var body: some View { ZStack { // Background track Circle() .stroke(color.opacity(0.2), lineWidth: size > 16 ? 2.5 : 2) // Animated arc Circle() .trim(from: 0, to: 0.7) .stroke(color, style: StrokeStyle(lineWidth: size > 16 ? 2.5 : 2, lineCap: .round)) .rotationEffect(.degrees(rotation)) } .frame(width: size, height: size) .onAppear { withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) { rotation = 360 } } } } // 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 { 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(.system(size: 10, weight: .medium)) .foregroundStyle(Theme.textSecondary(colorScheme)) .lineLimit(1) } } } } 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: - Planning Progress View struct PlanningProgressView: View { @State private var currentStep = 0 let steps = ["Finding games...", "Calculating routes...", "Optimizing itinerary..."] @Environment(\.colorScheme) private var colorScheme var body: some View { VStack(spacing: 24) { // Themed spinner ThemedSpinner(size: 56, lineWidth: 5) // Current step text Text(steps[currentStep]) .font(.system(size: 16, weight: .medium)) .foregroundStyle(Theme.textSecondary(colorScheme)) .animation(.easeInOut, value: currentStep) } .frame(maxWidth: .infinity) .padding(.vertical, 40) .task { await animateSteps() } } private func animateSteps() async { while !Task.isCancelled { try? await Task.sleep(for: .milliseconds(1500)) guard !Task.isCancelled else { break } withAnimation(.easeInOut) { currentStep = (currentStep + 1) % steps.count } } } } // 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(.system(size: 12)) Text(value) .font(.system(size: 13, weight: .medium)) } .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(.system(size: 48)) .foregroundStyle(Theme.warmOrange.opacity(0.7)) VStack(spacing: 8) { Text(title) .font(.system(size: 20, weight: .semibold)) .foregroundStyle(Theme.textPrimary(colorScheme)) Text(message) .font(.system(size: 15)) .foregroundStyle(Theme.textSecondary(colorScheme)) .multilineTextAlignment(.center) } if let actionTitle = actionTitle, let action = action { Button(action: action) { Text(actionTitle) .font(.system(size: 16, weight: .semibold)) .foregroundStyle(.white) .padding(.horizontal, 24) .padding(.vertical, 12) .background(Theme.warmOrange) .clipShape(Capsule()) } .pressableStyle() .padding(.top, 8) } } .padding(40) } } // MARK: - Loading Overlay /// A modal loading overlay with progress indication /// Reusable pattern from PDF export overlay struct LoadingOverlay: View { let message: String var detail: String? var progress: Double? var icon: String = "hourglass" @Environment(\.colorScheme) private var colorScheme var body: some View { ZStack { // Background dimmer Color.black.opacity(0.6) .ignoresSafeArea() // Progress card VStack(spacing: Theme.Spacing.lg) { // Progress ring or spinner ZStack { Circle() .stroke(Theme.cardBackgroundElevated(colorScheme), lineWidth: 8) .frame(width: 80, height: 80) if let progress = progress { Circle() .trim(from: 0, to: progress) .stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round)) .frame(width: 80, height: 80) .rotationEffect(.degrees(-90)) .animation(.easeInOut(duration: 0.3), value: progress) } else { ThemedSpinner(size: 48, lineWidth: 5) } Image(systemName: icon) .font(.system(size: 24)) .foregroundStyle(Theme.warmOrange) .opacity(progress != nil ? 1 : 0) } VStack(spacing: Theme.Spacing.xs) { Text(message) .font(.system(size: Theme.FontSize.cardTitle, weight: .semibold)) .foregroundStyle(Theme.textPrimary(colorScheme)) if let detail = detail { Text(detail) .font(.system(size: Theme.FontSize.caption)) .foregroundStyle(Theme.textSecondary(colorScheme)) .multilineTextAlignment(.center) } if let progress = progress { Text("\(Int(progress * 100))%") .font(.system(size: Theme.FontSize.micro, weight: .medium, design: .monospaced)) .foregroundStyle(Theme.textMuted(colorScheme)) } } } .padding(Theme.Spacing.xl) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) .shadow(color: .black.opacity(0.3), radius: 20, y: 10) } .transition(.opacity) } } // MARK: - Preview #Preview("Themed Spinners") { VStack(spacing: 40) { ThemedSpinner(size: 60, lineWidth: 5) ThemedSpinner(size: 40) ThemedSpinnerCompact() HStack(spacing: 20) { ThemedSpinnerCompact(size: 16) Text("Loading...") } } .padding(40) .themedBackground() } #Preview("Animated Components") { VStack(spacing: 40) { AnimatedRouteGraphic() .frame(height: 150) RoutePreviewStrip(cities: ["San Diego", "Los Angeles", "San Francisco", "Seattle", "Portland"]) PlanningProgressView() HStack { StatPill(icon: "car", value: "450 mi") StatPill(icon: "clock", value: "8h driving") } PulsingDot(color: Theme.warmOrange) .frame(width: 40, height: 40) } .padding() .themedBackground() } #Preview("Loading Overlay") { ZStack { Color.gray LoadingOverlay( message: "Planning Your Trip", detail: "Finding the best route..." ) } } #Preview("Loading Overlay with Progress") { ZStack { Color.gray LoadingOverlay( message: "Creating PDF", detail: "Processing images...", progress: 0.65, icon: "doc.fill" ) } }