// // CelebrationAnimations.swift // Reflect // // Full-view celebration animations that play after voting. // import SwiftUI // MARK: - Animation Type Enum enum CelebrationAnimationType: String, CaseIterable, Identifiable { case confettiCannon = "Confetti" case vortexCheckmark = "Vortex" case explosionReveal = "Explosion" case flipReveal = "Flip" case shatterReform = "Shatter" case pulseWave = "Pulse Wave" case fireworks = "Fireworks" case morphBlob = "Morph" case zoomTunnel = "Tunnel" case gravityDrop = "Gravity" var id: String { rawValue } /// Int index for AppStorage persistence (enum uses String raw values) var index: Int { Self.allCases.firstIndex(of: self) ?? 0 } /// Restore from persisted Int index static func fromIndex(_ index: Int) -> CelebrationAnimationType { guard index >= 0, index < allCases.count else { return .confettiCannon } return allCases[index] } var icon: String { switch self { case .confettiCannon: return "party.popper.fill" case .vortexCheckmark: return "tornado" case .explosionReveal: return "sparkles" case .flipReveal: return "rectangle.portrait.rotate" case .shatterReform: return "square.grid.3x3" case .pulseWave: return "dot.radiowaves.left.and.right" case .fireworks: return "fireworks" case .morphBlob: return "drop.fill" case .zoomTunnel: return "circle.dashed" case .gravityDrop: return "arrow.down.to.line" } } var description: String { switch self { case .confettiCannon: return "Party confetti" case .vortexCheckmark: return "Sucked into center" case .explosionReveal: return "Explodes outward" case .flipReveal: return "3D card flip" case .shatterReform: return "Shatters & reforms" case .pulseWave: return "Expanding waves" case .fireworks: return "Celebration burst" case .morphBlob: return "Liquid transform" case .zoomTunnel: return "Warp speed zoom" case .gravityDrop: return "Falls with bounce" } } var accentColor: Color { switch self { case .confettiCannon: return .pink case .vortexCheckmark: return .purple case .explosionReveal: return .orange case .flipReveal: return .blue case .shatterReform: return .red case .pulseWave: return .green case .fireworks: return .yellow case .morphBlob: return .cyan case .zoomTunnel: return .indigo case .gravityDrop: return .mint } } var duration: Double { switch self { case .confettiCannon: return 2.2 case .vortexCheckmark: return 2.0 case .explosionReveal: return 1.8 case .flipReveal: return 1.5 case .shatterReform: return 2.2 case .pulseWave: return 1.6 case .fireworks: return 2.5 case .morphBlob: return 2.0 case .zoomTunnel: return 1.8 case .gravityDrop: return 2.0 } } static var random: CelebrationAnimationType { allCases.randomElement() ?? .confettiCannon } } // MARK: - Celebration Overlay View struct CelebrationOverlayView: View { let animationType: CelebrationAnimationType let mood: Mood let onComplete: () -> Void @State private var hasAppeared = false var body: some View { ZStack { if hasAppeared { animationView } } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear { hasAppeared = true Task { @MainActor in try? await Task.sleep(for: .seconds(animationType.duration)) onComplete() } } } @ViewBuilder private var animationView: some View { switch animationType { case .vortexCheckmark: VortexCheckmarkAnimation(mood: mood) case .explosionReveal: ExplosionRevealAnimation(mood: mood) case .flipReveal: FlipRevealAnimation(mood: mood) case .shatterReform: ShatterReformAnimation(mood: mood) case .pulseWave: PulseWaveAnimation(mood: mood) case .fireworks: FireworksAnimation(mood: mood) case .confettiCannon: ConfettiCannonAnimation(mood: mood) case .morphBlob: MorphBlobAnimation(mood: mood) case .zoomTunnel: ZoomTunnelAnimation(mood: mood) case .gravityDrop: GravityDropAnimation(mood: mood) } } } // MARK: - Animation 1: Vortex Checkmark struct VortexCheckmarkAnimation: View { let mood: Mood @State private var spiralRotation: Double = 0 @State private var checkmarkScale: CGFloat = 0 @State private var checkmarkOpacity: Double = 0 @State private var ringScale: CGFloat = 0.5 @State private var ringOpacity: Double = 0 var body: some View { ZStack { ForEach(0..<8, id: \.self) { i in Circle() .stroke(mood.color.opacity(0.3), lineWidth: 2) .frame(width: CGFloat(30 + i * 25), height: CGFloat(30 + i * 25)) .rotationEffect(.degrees(spiralRotation + Double(i * 15))) } Circle() .stroke(mood.color, lineWidth: 4) .frame(width: 100, height: 100) .scaleEffect(ringScale) .opacity(ringOpacity) Image(systemName: "checkmark") .font(.system(size: 50, weight: .bold)) .foregroundColor(mood.color) .scaleEffect(checkmarkScale) .opacity(checkmarkOpacity) } .onAppear { withAnimation(.easeOut(duration: 0.5)) { spiralRotation = 180 } withAnimation(.easeOut(duration: 0.3).delay(0.3)) { ringScale = 1.2; ringOpacity = 1 } withAnimation(.easeIn(duration: 0.2).delay(0.6)) { ringScale = 1.0 } withAnimation(.spring(response: 0.4, dampingFraction: 0.5).delay(0.5)) { checkmarkScale = 1.2; checkmarkOpacity = 1 } withAnimation(.spring(response: 0.2, dampingFraction: 0.6).delay(0.7)) { checkmarkScale = 1.0 } withAnimation(.easeOut(duration: 0.3).delay(1.5)) { checkmarkOpacity = 0; ringOpacity = 0 } } } } // MARK: - Animation 2: Explosion Reveal struct ExplosionRevealAnimation: View { let mood: Mood @State private var particles: [ExplosionParticle] = [] @State private var checkmarkOffset: CGFloat = -200 @State private var checkmarkScale: CGFloat = 1.5 @State private var checkmarkOpacity: Double = 0 @State private var glowOpacity: Double = 0 struct ExplosionParticle: Identifiable { let id = UUID() let angle: Double let distance: CGFloat let size: CGFloat let color: Color var offset: CGFloat = 0 var opacity: Double = 1 var rotation: Double = 0 } var body: some View { ZStack { ForEach(particles) { particle in RoundedRectangle(cornerRadius: 4) .fill(particle.color) .frame(width: particle.size, height: particle.size) .rotationEffect(.degrees(particle.rotation)) .offset(x: cos(particle.angle) * particle.offset, y: sin(particle.angle) * particle.offset) .opacity(particle.opacity) } Circle() .fill(RadialGradient(colors: [mood.color.opacity(0.5), .clear], center: .center, startRadius: 0, endRadius: 100)) .frame(width: 200, height: 200) .opacity(glowOpacity) Image(systemName: "checkmark.circle.fill") .font(.system(size: 70)) .foregroundColor(mood.color) .offset(y: checkmarkOffset) .scaleEffect(checkmarkScale) .opacity(checkmarkOpacity) } .onAppear { for i in 0..<20 { let angle = Double(i) * (2 * .pi / 20) + Double.random(in: -0.2...0.2) particles.append(ExplosionParticle(angle: angle, distance: CGFloat.random(in: 100...180), size: CGFloat.random(in: 8...20), color: [mood.color, mood.color.opacity(0.7), .white, .orange][i % 4])) } withAnimation(.easeOut(duration: 0.6)) { for i in particles.indices { particles[i].offset = particles[i].distance; particles[i].rotation = Double.random(in: 0...360) } } withAnimation(.easeOut(duration: 0.4).delay(0.4)) { for i in particles.indices { particles[i].opacity = 0 } } withAnimation(.easeInOut(duration: 0.3).delay(0.2)) { glowOpacity = 1 } withAnimation(.easeOut(duration: 0.5).delay(0.6)) { glowOpacity = 0 } withAnimation(.spring(response: 0.5, dampingFraction: 0.6).delay(0.3)) { checkmarkOffset = 0; checkmarkScale = 1.0; checkmarkOpacity = 1 } withAnimation(.easeOut(duration: 0.3).delay(1.4)) { checkmarkOpacity = 0 } } } } // MARK: - Animation 3: Flip Reveal struct FlipRevealAnimation: View { let mood: Mood @State private var showBack = false @State private var checkmarkScale: CGFloat = 0.5 @State private var shimmerOffset: CGFloat = -200 var body: some View { GeometryReader { geo in ZStack { RoundedRectangle(cornerRadius: 20) .fill(LinearGradient(colors: [mood.color, mood.color.opacity(0.7)], startPoint: .topLeading, endPoint: .bottomTrailing)) .overlay( Rectangle() .fill(LinearGradient(colors: [.clear, .white.opacity(0.4), .clear], startPoint: .leading, endPoint: .trailing)) .frame(width: 60) .offset(x: shimmerOffset) .blur(radius: 5) ) .clipShape(RoundedRectangle(cornerRadius: 20)) .overlay( VStack(spacing: 16) { Image(systemName: "checkmark.circle.fill") .font(.system(size: 60)) .foregroundColor(.white) .scaleEffect(checkmarkScale) Text("Logged!") .font(.title2.weight(.bold)) .foregroundColor(.white) } ) .opacity(showBack ? 1 : 0) } .frame(width: geo.size.width, height: geo.size.height) } .onAppear { withAnimation(.easeInOut(duration: 0.3).delay(0.2)) { showBack = true } withAnimation(.spring(response: 0.4, dampingFraction: 0.6).delay(0.4)) { checkmarkScale = 1.1 } withAnimation(.spring(response: 0.2, dampingFraction: 0.7).delay(0.6)) { checkmarkScale = 1.0 } withAnimation(.easeInOut(duration: 0.8).delay(0.5)) { shimmerOffset = 200 } } } } // MARK: - Animation 4: Shatter Reform struct ShatterReformAnimation: View { let mood: Mood private enum AnimationConstants { static let shatterPhaseDuration: TimeInterval = 0.6 static let checkmarkAppearDelay: TimeInterval = 1.1 static let fadeOutDelay: TimeInterval = 1.8 } @State private var shardOffsets: [CGSize] = [] @State private var shardRotations: [Double] = [] @State private var shardOpacities: [Double] = [] @State private var phase: AnimationPhase = .initial @State private var checkmarkOpacity: Double = 0 enum AnimationPhase { case initial, shatter, reform, complete } private let shardCount = 12 var body: some View { ZStack { // Shards that explode outward ForEach(0..