diff --git a/SportsTime/Core/Design/UIDesignStyle.swift b/SportsTime/Core/Design/UIDesignStyle.swift index 741c99e..2f71e84 100644 --- a/SportsTime/Core/Design/UIDesignStyle.swift +++ b/SportsTime/Core/Design/UIDesignStyle.swift @@ -11,6 +11,7 @@ import SwiftUI enum UIDesignStyle: String, CaseIterable, Identifiable, Codable { // Default case classic = "Classic" + case classicAnimated = "Classic Animated" // Original experimental aesthetics case brutalist = "Brutalist" @@ -44,6 +45,8 @@ enum UIDesignStyle: String, CaseIterable, Identifiable, Codable { switch self { case .classic: return "The original SportsTime design" + case .classicAnimated: + return "Classic with animated sports backgrounds" case .brutalist: return "Raw, unpolished anti-design rebellion" case .luxuryEditorial: @@ -94,6 +97,7 @@ enum UIDesignStyle: String, CaseIterable, Identifiable, Codable { var iconName: String { switch self { case .classic: return "star.fill" + case .classicAnimated: return "sparkles" case .brutalist: return "hammer.fill" case .luxuryEditorial: return "book.fill" case .retroFuturism: return "tv.fill" @@ -122,6 +126,7 @@ enum UIDesignStyle: String, CaseIterable, Identifiable, Codable { var accentColor: Color { switch self { case .classic: return Color(red: 1.0, green: 0.45, blue: 0.2) // Warm Orange + case .classicAnimated: return Color(red: 1.0, green: 0.45, blue: 0.2) // Warm Orange (same as Classic) case .brutalist: return .red case .luxuryEditorial: return Color(red: 0.85, green: 0.65, blue: 0.13) // Gold case .retroFuturism: return Color(red: 0.0, green: 1.0, blue: 0.8) // Cyan diff --git a/SportsTime/Features/Home/Views/AdaptiveHomeContent.swift b/SportsTime/Features/Home/Views/AdaptiveHomeContent.swift index b72db22..16caebe 100644 --- a/SportsTime/Features/Home/Views/AdaptiveHomeContent.swift +++ b/SportsTime/Features/Home/Views/AdaptiveHomeContent.swift @@ -29,6 +29,16 @@ struct AdaptiveHomeContent: View { displayedTips: displayedTips ) + case .classicAnimated: + HomeContent_ClassicAnimated( + showNewTrip: $showNewTrip, + selectedTab: $selectedTab, + selectedSuggestedTrip: $selectedSuggestedTrip, + savedTrips: savedTrips, + suggestedTripsGenerator: suggestedTripsGenerator, + displayedTips: displayedTips + ) + case .brutalist: HomeContent_Brutalist( showNewTrip: $showNewTrip, diff --git a/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift b/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift new file mode 100644 index 0000000..2395064 --- /dev/null +++ b/SportsTime/Features/Home/Views/Variants/Classic/HomeContent_ClassicAnimated.swift @@ -0,0 +1,509 @@ +// +// HomeContent_ClassicAnimated.swift +// SportsTime +// +// CLASSIC ANIMATED: The original SportsTime design with animated backgrounds. +// Uses the app's Theme system with warm orange accents. +// Clean cards, glow effects, familiar layout, plus floating sports icons. +// + +import SwiftUI +import SwiftData + +struct HomeContent_ClassicAnimated: View { + @Environment(\.colorScheme) private var colorScheme + @Binding var showNewTrip: Bool + @Binding var selectedTab: Int + @Binding var selectedSuggestedTrip: SuggestedTrip? + + let savedTrips: [SavedTrip] + let suggestedTripsGenerator: SuggestedTripsGenerator + let displayedTips: [PlanningTip] + + var body: some View { + ZStack { + // Animated background layer + AnimatedSportsBackground() + .ignoresSafeArea() + + // Content layer + ScrollView { + VStack(spacing: Theme.Spacing.lg) { + // Hero Card + heroCard + .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) + } + } + } + .themedBackground() + } + + // MARK: - Hero Card + + private var heroCard: some View { + VStack(spacing: Theme.Spacing.lg) { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Text("Adventure Awaits") + .font(.largeTitle) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text("Plan your ultimate sports road trip. Visit stadiums, catch games, and create unforgettable memories.") + .font(.body) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + showNewTrip = true + } label: { + HStack(spacing: Theme.Spacing.xs) { + Image(systemName: "map.fill") + Text("Start Planning") + .fontWeight(.semibold) + } + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.md) + .background(Theme.warmOrange) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + .pressableStyle() + .glowEffect(color: Theme.warmOrange, radius: 12) + } + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.xlarge)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.xlarge) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + .shadow(color: Theme.cardShadow(colorScheme), radius: 15, y: 8) + } + + // MARK: - Suggested Trips + + @ViewBuilder + private var suggestedTripsSection: some View { + if suggestedTripsGenerator.isLoading { + LoadingTripsView(message: suggestedTripsGenerator.loadingMessage) + } else if !suggestedTripsGenerator.suggestedTrips.isEmpty { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Header with refresh button + HStack { + Text("Featured Trips") + .font(.title2) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Spacer() + + Button { + Task { + await suggestedTripsGenerator.refreshTrips() + } + } label: { + Image(systemName: "arrow.clockwise") + .font(.subheadline) + .foregroundStyle(Theme.warmOrange) + } + } + + // Horizontal carousel grouped by region + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: Theme.Spacing.lg) { + ForEach(suggestedTripsGenerator.tripsByRegion, id: \.region) { regionGroup in + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + // Region header + HStack(spacing: Theme.Spacing.xs) { + Image(systemName: regionGroup.region.iconName) + .font(.caption) + Text(regionGroup.region.shortName) + .font(.subheadline) + } + .foregroundStyle(Theme.textSecondary(colorScheme)) + + // Trip cards for this region + HStack(spacing: Theme.Spacing.md) { + ForEach(regionGroup.trips) { suggestedTrip in + Button { + selectedSuggestedTrip = suggestedTrip + } label: { + SuggestedTripCard(suggestedTrip: suggestedTrip) + } + .buttonStyle(.plain) + } + } + } + } + } + .padding(.horizontal, 1) + } + } + } else if let error = suggestedTripsGenerator.error { + // Error state + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Text("Featured Trips") + .font(.title2) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + HStack { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.orange) + Text(error) + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + Spacer() + + Button("Retry") { + Task { + await suggestedTripsGenerator.generateTrips() + } + } + .font(.subheadline) + .foregroundStyle(Theme.warmOrange) + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + } + } + + // MARK: - Saved Trips + + private var savedTripsSection: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + HStack { + Text("Recent Trips") + .font(.title2) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Spacer() + Button { + selectedTab = 2 + } label: { + HStack(spacing: 4) { + Text("See All") + Image(systemName: "chevron.right") + .font(.caption) + } + .font(.subheadline) + .foregroundStyle(Theme.warmOrange) + } + } + + ForEach(Array(savedTrips.prefix(3).enumerated()), id: \.element.id) { index, savedTrip in + if let trip = savedTrip.trip { + NavigationLink { + TripDetailView(trip: trip) + } label: { + classicTripCard(savedTrip: savedTrip, trip: trip) + } + .buttonStyle(.plain) + .staggeredAnimation(index: index, delay: 0.05) + } + } + } + } + + private func classicTripCard(savedTrip: SavedTrip, trip: Trip) -> some View { + HStack(spacing: Theme.Spacing.md) { + // Route preview icon + ZStack { + Circle() + .fill(Theme.warmOrange.opacity(0.15)) + .frame(width: 44, height: 44) + + Image(systemName: "map.fill") + .foregroundStyle(Theme.warmOrange) + } + + VStack(alignment: .leading, spacing: 4) { + Text(trip.displayName) + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(trip.formattedDateRange) + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + HStack(spacing: Theme.Spacing.sm) { + HStack(spacing: 4) { + Image(systemName: "mappin") + .font(.caption2) + Text("\(trip.stops.count) cities") + } + HStack(spacing: 4) { + Image(systemName: "sportscourt") + .font(.caption2) + Text("\(trip.totalGames) games") + } + } + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } + + // MARK: - Tips Section + + private var tipsSection: some View { + VStack(alignment: .leading, spacing: Theme.Spacing.sm) { + Text("Planning Tips") + .font(.title2) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + VStack(spacing: Theme.Spacing.xs) { + ForEach(displayedTips) { tip in + classicTipRow(icon: tip.icon, title: tip.title, subtitle: tip.subtitle) + } + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1) + } + } + } + + private func classicTipRow(icon: String, title: String, subtitle: String) -> some View { + HStack(spacing: Theme.Spacing.sm) { + ZStack { + Circle() + .fill(Theme.routeGold.opacity(0.15)) + .frame(width: 36, height: 36) + + Image(systemName: icon) + .font(.subheadline) + .foregroundStyle(Theme.routeGold) + } + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline) + .foregroundStyle(Theme.textPrimary(colorScheme)) + Text(subtitle) + .font(.caption) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + + Spacer() + } + } +} + +// 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() + } + } +}