import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:twonly/src/visual/themes/light.dart'; import 'package:vector_graphics/vector_graphics.dart'; /// Animated chain-link logo for the "verification success" moment. /// /// Sequence: /// 1. Two chain links fly in from opposite sides with a bounce. /// 2. Colour shifts from muted grey to the brand success-green. /// 3. A scale-pulse + expanding ripple rings signal the connection. /// 4. Sparkle particles radiate outward. /// 5. A secure-checkmark badge pops in at the centre. class VerificationSuccessAnimation extends StatefulWidget { const VerificationSuccessAnimation({ super.key, this.size = 200, this.onComplete, this.autoStart = true, }); /// Logical size of the animation widget (width = height). final double size; /// Called once the full sequence finishes. final VoidCallback? onComplete; /// When `true` the animation begins automatically after the first frame. final bool autoStart; @override State createState() => VerificationSuccessAnimationState(); } class VerificationSuccessAnimationState extends State with TickerProviderStateMixin { late final AnimationController _ctrl; late final AnimationController _idleCtrl; late final Animation _flyIn; late final Animation _colorFade; late final Animation _pulse; late final Animation _glow; late final Animation _ripple; late final Animation _sparkle; late final Animation _badge; late final Animation _idleBadgeScale; late final Animation _chainsFade; bool _hapticTriggered = false; // Upper-right chain link static const _path1 = 'M451.5 160C434.9 160 418.8 164.5 404.7 172.7C388.9 156.7 ' '370.5 143.3 350.2 133.2C378.4 109.2 414.3 96 451.5 96C537.9 ' '96 608 166 608 252.5C608 294 591.5 333.8 562.2 363.1L491.1 ' '434.2C461.8 463.5 422 480 380.5 480C294.1 480 224 410 224 ' '323.5C224 322 224 320.5 224.1 319C224.6 301.3 239.3 287.4 257 ' '287.9C274.7 288.4 288.6 303.1 288.1 320.8C288.1 321.7 288.1 ' '322.6 288.1 323.4C288.1 374.5 329.5 415.9 380.6 415.9C405.1 ' '415.9 428.6 406.2 446 388.8L517.1 317.7C534.4 300.4 544.2 ' '276.8 544.2 252.3C544.2 201.2 502.8 159.8 451.7 159.8z'; // Lower-left chain link static const _path2 = 'M307.2 237.3C305.3 236.5 303.4 235.4 301.7 234.2C289.1 ' '227.7 274.7 224 259.6 224C235.1 224 211.6 233.7 194.2 ' '251.1L123.1 322.2C105.8 339.5 96 363.1 96 387.6C96 438.7 ' '137.4 480.1 188.5 480.1C205 480.1 221.1 475.7 235.2 ' '467.5C251 483.5 269.4 496.9 289.8 507C261.6 530.9 225.8 ' '544.2 188.5 544.2C102.1 544.2 32 474.2 32 387.7C32 346.2 ' '48.5 306.4 77.8 277.1L148.9 206C178.2 176.7 218 160.2 259.5 ' '160.2C346.1 160.2 416 230.8 416 317.1C416 318.4 416 319.7 ' '416 321C415.6 338.7 400.9 352.6 383.2 352.2C365.5 351.8 ' '351.6 337.1 352 319.4C352 318.6 352 317.9 352 317.1C352 ' '283.4 334 253.8 307.2 237.5z'; // Pre-built SVG markup (white fill – colour applied via ColorFilter // so flutter_svg can cache the parsed picture). static const _svg1 = ''; static const _svg2 = ''; static const _grey = Color(0xFF8E9AAF); static const Color _green = primaryColor; @override void initState() { super.initState(); _ctrl = AnimationController( duration: const Duration(milliseconds: 2800), vsync: this, ); // Continuous idle pulse for the badge (makes it feel alive) _idleCtrl = AnimationController( duration: const Duration(milliseconds: 900), vsync: this, )..repeat(reverse: true); _idleBadgeScale = Tween( begin: 0.99, end: 1.01, ).animate( CurvedAnimation(parent: _idleCtrl, curve: Curves.easeInOut), ); // Stage 1 – links fly in with a satisfying bounce _flyIn = CurvedAnimation( parent: _ctrl, curve: const Interval(0, 0.38, curve: _SmoothBounceCurve()), ); // Stage 2 – colour shift grey → green _colorFade = CurvedAnimation( parent: _ctrl, curve: const Interval(0.15, 0.42, curve: Curves.easeInOut), ); // Ambient glow ramps up around connection _glow = CurvedAnimation( parent: _ctrl, curve: const Interval(0.30, 0.50, curve: Curves.easeIn), ); // Scale-pulse on impact _pulse = CurvedAnimation( parent: _ctrl, curve: const Interval(0.34, 0.46, curve: Curves.easeInOut), ); // Expanding ripple rings _ripple = CurvedAnimation( parent: _ctrl, curve: const Interval(0.36, 0.62, curve: Curves.easeOut), ); // Sparkle particles _sparkle = CurvedAnimation( parent: _ctrl, curve: const Interval(0.38, 0.68, curve: Curves.easeOut), ); // Verification badge _badge = CurvedAnimation( parent: _ctrl, curve: const Interval(0.44, 0.74, curve: Curves.elasticOut), ); // Monotonic fade out for the chains, aligned with badge appearance _chainsFade = CurvedAnimation( parent: _ctrl, curve: const Interval(0.44, 0.56, curve: Curves.easeOut), ); _ctrl ..addListener(() { if (_ctrl.value >= 0.44 && !_hapticTriggered) { _hapticTriggered = true; HapticFeedback.successNotification(); } }) ..addStatusListener((status) { if (status == AnimationStatus.completed) { widget.onComplete?.call(); } }); if (widget.autoStart) { WidgetsBinding.instance.addPostFrameCallback((_) => play()); } } /// Start (or replay) the full animation from the beginning. void play() { _hapticTriggered = false; _ctrl.forward(from: 0); } @override void dispose() { _ctrl.dispose(); _idleCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: Listenable.merge([_ctrl, _idleCtrl]), builder: (context, _) { final scale = widget.size / 640; final t = _flyIn.value; // 0 → 1 (fly-in progress) final sep = 1.0 - t; // 1 → 0 (separation) // Fly-in: upper-right link arrives from upper-right corner, // lower-left link arrives from lower-left corner. final dx = sep * 100 * scale; final dy = sep * -60 * scale; // Fly-in rotation: each link starts rotated ~50° from its // interlocked position so they visually thread into each other. final rotation = sep * 0.7; // Interpolated colour. final color = Color.lerp(_grey, _green, _colorFade.value)!; // Pulse: 1 → 1.08 → 1 (smooth sine bump). final pulseScale = 1.0 + math.sin(_pulse.value * math.pi) * 0.08; // Chains fade out as badge appears (monotonic to avoid springy pop-in glitches). final chainsOpacity = 1.0 - _chainsFade.value; return SizedBox( width: widget.size, height: widget.size, child: Stack( children: [ // ── Ambient glow ── if (_glow.value > 0) Center( child: Container( width: widget.size * 0.5, height: widget.size * 0.5, decoration: BoxDecoration( shape: BoxShape.circle, boxShadow: [ BoxShadow( color: _green.withValues(alpha: 0.3 * _glow.value), blurRadius: widget.size * 0.45 * _glow.value, spreadRadius: widget.size * 0.08 * _glow.value, ), ], ), ), ), // ── Ripple rings ── if (_ripple.value > 0) Positioned.fill( child: CustomPaint( painter: _RipplePainter( progress: _ripple.value, color: _green, maxRadius: widget.size * 0.45, ), ), ), // ── Chain links ── if (chainsOpacity > 0) Center( child: Opacity( opacity: chainsOpacity, child: Transform.scale( scale: pulseScale, child: SizedBox( width: widget.size, height: widget.size, child: Stack( children: [ // Upper-right link — pivots around (416, 288) in // the 640×640 viewport, matching link_logo_animation. Positioned.fill( child: Transform( alignment: const Alignment( (416 * 2 / 640) - 1, // ≈ 0.3 (288 * 2 / 640) - 1, // ≈ -0.1 ), transform: Matrix4.identity() ..translateByDouble(dx, dy, 0, 1) ..rotateZ(-rotation), child: Opacity( opacity: (0.35 + 0.65 * t).clamp(0.0, 1.0), child: SvgPicture.string( _svg1, colorFilter: ColorFilter.mode( color, BlendMode.srcIn, ), ), ), ), ), // Lower-left link — pivots around (224, 352). Positioned.fill( child: Transform( alignment: const Alignment( (224 * 2 / 640) - 1, // ≈ -0.3 (352 * 2 / 640) - 1, // ≈ 0.1 ), transform: Matrix4.identity() ..translateByDouble(-dx, -dy, 0, 1) ..rotateZ(-rotation), child: Opacity( opacity: (0.35 + 0.65 * t).clamp(0.0, 1.0), child: SvgPicture.string( _svg2, colorFilter: ColorFilter.mode( color, BlendMode.srcIn, ), ), ), ), ), ], ), ), ), ), ), // ── Sparkle particles ── if (_sparkle.value > 0) Positioned.fill( child: CustomPaint( painter: _SparklePainter( progress: _sparkle.value, color: _green, maxRadius: widget.size * 0.4, ), ), ), // ── Verification badge ── if (_badge.value > 0) Center( child: Transform.scale( scale: _badge.value * _idleBadgeScale.value, child: Opacity( opacity: _badge.value.clamp(0.0, 1.0), child: SizedBox( width: widget.size * 0.5, height: widget.size * 0.5, child: const SvgPicture( AssetBytesLoader( 'assets/icons/verified_badge_green.svg.vec', ), ), ), ), ), ), ], ), ); }, ); } } /// A custom curve that simulates a smooth, organic spring/bounce overshoot. /// It overshoots, returns, and settles without any sharp direction changes. class _SmoothBounceCurve extends Curve { const _SmoothBounceCurve(); @override double transformInternal(double t) { // Damped harmonic oscillator equation: 1 - e^(-6*t) * cos(2.5 * pi * t) // Starts exactly at 0.0 (t=0) and ends exactly at 1.0 (t=1). return 1.0 - math.exp(-6.0 * t) * math.cos(2.5 * math.pi * t); } } /// Draws 3 staggered, concentric ring outlines that expand and fade out. class _RipplePainter extends CustomPainter { const _RipplePainter({ required this.progress, required this.color, required this.maxRadius, }); final double progress; final Color color; final double maxRadius; @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); for (var i = 0; i < 3; i++) { final delay = i * 0.15; final p = ((progress - delay) / (1.0 - delay)).clamp(0.0, 1.0); if (p <= 0) continue; final radius = p * maxRadius; final opacity = (1.0 - p) * 0.35; if (opacity <= 0) continue; canvas.drawCircle( center, radius, Paint() ..color = color.withValues(alpha: opacity) ..style = PaintingStyle.stroke ..strokeWidth = 2.5 * (1.0 - p) + 0.5, ); } } @override bool shouldRepaint(_RipplePainter old) => old.progress != progress; } /// Draws 12 small dots that radiate outward from the centre and fade away. class _SparklePainter extends CustomPainter { const _SparklePainter({ required this.progress, required this.color, required this.maxRadius, }); final double progress; final Color color; final double maxRadius; static const _outerCount = 8; static const _innerCount = 8; @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); // Outer ring — 8 larger dots for (var i = 0; i < _outerCount; i++) { _drawDot( canvas, center, angle: (i / _outerCount) * 2 * math.pi + math.pi / 8, radius: progress * maxRadius, opacity: (1.0 - progress) * 0.7, dotSize: (1.0 - progress) * 3.0 + 0.8, ); } // Inner ring — 8 smaller dots, offset angle, shorter travel final innerProgress = (progress * 1.15).clamp(0.0, 1.0); for (var i = 0; i < _innerCount; i++) { _drawDot( canvas, center, angle: (i / _innerCount) * 2 * math.pi, radius: innerProgress * maxRadius * 0.6, opacity: (1.0 - innerProgress) * 0.5, dotSize: (1.0 - innerProgress) * 2.0 + 0.5, ); } } void _drawDot( Canvas canvas, Offset center, { required double angle, required double radius, required double opacity, required double dotSize, }) { if (opacity <= 0) return; canvas.drawCircle( Offset( center.dx + math.cos(angle) * radius, center.dy + math.sin(angle) * radius, ), dotSize, Paint()..color = color.withValues(alpha: opacity), ); } @override bool shouldRepaint(_SparklePainter old) => old.progress != progress; }