Files
Sportstime/SportsTime/Core/Theme/AnimatedComponents.swift
Trey t 5bbfd30a70 Redesign trip option cards and fix various UI/planning issues
TripOptionCard improvements:
- Replace horizontal route with vertical layout (start → end with arrow)
- Remove rank badges (1, 2, 3, etc.)
- Split stats into two rows: cities/miles and sports with game counts
- Clear selection when navigating back from detail view

Settings cleanup:
- Remove unused settings (preferred game time, playoff games, notifications)
- Convert remaining settings to sliders

Planning fixes:
- Fix multi-day driving calculation in canTransition
- Remove over-restrictive trip rejection in TravelEstimator
- Clear games cache when sport selection changes

UI polish:
- RoutePreviewStrip shows all cities (abbreviated)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:05:25 -06:00

309 lines
9.5 KiB
Swift

//
// 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 {
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) {
// Simple spinner
ProgressView()
.scaleEffect(1.5)
.tint(Theme.warmOrange)
// 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: - Preview
#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()
}