/** * MatchConfetti - Premium celebration animation for match moments * * Uses Reanimated for smooth animations. Displays an elegant burst of * hearts and sparkles that explode outward, plus a prominent "It's a Match!" popup. */ import React, { useEffect, useMemo } from 'react'; import { View, StyleSheet, Dimensions, Text } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, withTiming, withDelay, withSpring, withSequence, Easing, interpolate, } from 'react-native-reanimated'; import { LinearGradient } from 'expo-linear-gradient'; import { Ionicons } from '@expo/vector-icons'; import * as Haptics from 'expo-haptics'; import { Platform } from 'react-native'; import { colors, typography, spacing, radius } from '../theme'; const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window'); interface Props { visible: boolean; onAnimationComplete?: () => void; } type ParticleShape = 'heart' | 'sparkle' | 'circle' | 'star'; interface Particle { id: number; startX: number; startY: number; endX: number; endY: number; rotation: number; color: string; size: number; delay: number; shape: ParticleShape; } const CONFETTI_COLORS = [ colors.primary, // Brand pink #e1306c '#FF6B9D', // Hot pink '#FFB3C6', // Light pink colors.premium.rose, // Soft rose '#9b59b6', // Secondary purple colors.premium.gold, // Gold accent '#FF8A5B', // Coral '#FFFFFF', // White sparkle ]; const PARTICLE_COUNT = 40; // Particle shapes distribution const SHAPES: ParticleShape[] = ['heart', 'heart', 'sparkle', 'sparkle', 'circle', 'star']; // Generate particle with physics-based trajectory const generateParticle = (index: number): Particle => { // Start from center of screen const startX = SCREEN_WIDTH / 2; const startY = SCREEN_HEIGHT / 2 - 80; // Random angle for explosion (360 degrees) with more upward bias const baseAngle = (index / PARTICLE_COUNT) * Math.PI * 2; const angle = baseAngle + (Math.random() - 0.5) * 0.8; const velocity = 180 + Math.random() * 280; // Calculate end position - more spread, less gravity drop for hearts const endX = startX + Math.cos(angle) * velocity; const endY = startY + Math.sin(angle) * velocity + 250; const shape = SHAPES[Math.floor(Math.random() * SHAPES.length)]; return { id: index, startX, startY, endX, endY, rotation: Math.random() * 540 - 270, color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)], size: shape === 'heart' ? 14 + Math.random() * 10 : 8 + Math.random() * 8, delay: Math.random() * 150, shape, }; }; // Heart shape component const HeartShape: React.FC<{ size: number; color: string }> = ({ size, color }) => ( ); // Sparkle/star shape component const SparkleShape: React.FC<{ size: number; color: string }> = ({ size, color }) => ( ); // Star shape component const StarShape: React.FC<{ size: number; color: string }> = ({ size, color }) => ( ); const ConfettiParticle: React.FC<{ particle: Particle; visible: boolean }> = ({ particle, visible, }) => { const progress = useSharedValue(0); const opacity = useSharedValue(0); useEffect(() => { if (visible) { progress.value = 0; opacity.value = 1; progress.value = withDelay( particle.delay, withTiming(1, { duration: 1000, easing: Easing.out(Easing.cubic), }) ); opacity.value = withDelay( particle.delay + 600, withTiming(0, { duration: 400, easing: Easing.out(Easing.ease), }) ); } else { progress.value = 0; opacity.value = 0; } }, [visible]); const animatedStyle = useAnimatedStyle(() => { const x = particle.startX + (particle.endX - particle.startX) * progress.value; const y = particle.startY + (particle.endY - particle.startY) * progress.value; const rotation = particle.rotation * progress.value; // Scale pulses slightly then shrinks const scale = interpolate(progress.value, [0, 0.2, 1], [0.5, 1.2, 0.6]); return { transform: [ { translateX: x }, { translateY: y }, { rotate: `${rotation}deg` }, { scale }, ], opacity: opacity.value, }; }); const renderShape = () => { switch (particle.shape) { case 'heart': return ; case 'sparkle': return ; case 'star': return ; case 'circle': default: return ( ); } }; return ( {renderShape()} ); }; // Animated popup message component - prominent and celebratory const MatchPopup: React.FC<{ visible: boolean }> = ({ visible }) => { const scale = useSharedValue(0); const opacity = useSharedValue(0); const iconScale = useSharedValue(0); useEffect(() => { if (visible) { // Pop in with bouncy spring, then fade out after delay scale.value = withSequence( withSpring(1, { damping: 10, stiffness: 180 }), withDelay(3200, withTiming(0.9, { duration: 400 })) ); // Fade in quickly, stay visible, then fade out opacity.value = withSequence( withTiming(1, { duration: 150 }), withDelay(3200, withTiming(0, { duration: 400 })) ); // Heart icon bounces after text appears iconScale.value = withDelay( 150, withSequence( withSpring(1.3, { damping: 8, stiffness: 200 }), withSpring(1, { damping: 12, stiffness: 200 }) ) ); } else { scale.value = 0; opacity.value = 0; iconScale.value = 0; } }, [visible]); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: scale.value }], opacity: opacity.value, })); const iconAnimatedStyle = useAnimatedStyle(() => ({ transform: [{ scale: iconScale.value }], })); return ( You both answered! ); }; export const MatchConfetti: React.FC = ({ visible, onAnimationComplete }) => { const particles = useMemo( () => Array.from({ length: PARTICLE_COUNT }, (_, i) => generateParticle(i)), [] ); // Trigger haptic and callback when animation starts useEffect(() => { if (visible) { // Celebration haptic pattern - satisfying burst if (Platform.OS !== 'web') { // Initial success notification Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); // Follow up with impacts for celebration feel setTimeout(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); }, 80); setTimeout(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); }, 180); setTimeout(() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy); }, 300); } // Notify parent when animation completes (after popup fades) if (onAnimationComplete) { const timer = setTimeout(() => { onAnimationComplete(); }, 4000); return () => clearTimeout(timer); } } }, [visible, onAnimationComplete]); if (!visible) return null; return ( {/* Confetti particles */} {particles.map((particle) => ( ))} {/* Match popup message */} ); }; const styles = StyleSheet.create({ container: { ...StyleSheet.absoluteFillObject, zIndex: 1000, }, particle: { position: 'absolute', left: 0, top: 0, }, popupContainer: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, justifyContent: 'center', alignItems: 'center', zIndex: 10, }, popup: { borderRadius: radius.xl, shadowColor: colors.primary, shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.6, shadowRadius: 24, elevation: 15, overflow: 'hidden', }, popupGradient: { paddingHorizontal: spacing.xl + spacing.md, paddingVertical: spacing.lg + spacing.sm, alignItems: 'center', }, popupTitle: { ...typography.title1, color: '#fff', fontWeight: '800', textAlign: 'center', textShadowColor: 'rgba(0, 0, 0, 0.3)', textShadowOffset: { width: 0, height: 2 }, textShadowRadius: 4, }, popupSubtext: { ...typography.callout, color: 'rgba(255, 255, 255, 0.9)', textAlign: 'center', marginTop: spacing.xs, }, popupIcon: { marginTop: spacing.sm, }, });