From 0e377d0ebcc4a6f28bb14e81f65d78049b8f9df1 Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 15 Jun 2026 17:33:54 +0200 Subject: [PATCH] added chain link animation --- .../verification_success_animation.comp.dart | 475 ++++++++++++++++++ 1 file changed, 475 insertions(+) create mode 100644 lib/src/visual/components/verification_success_animation.comp.dart diff --git a/lib/src/visual/components/verification_success_animation.comp.dart b/lib/src/visual/components/verification_success_animation.comp.dart new file mode 100644 index 00000000..dfa8d77d --- /dev/null +++ b/lib/src/visual/components/verification_success_animation.comp.dart @@ -0,0 +1,475 @@ +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'; + +/// 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: SvgPicture.asset( + 'assets/icons/verified_badge_green.svg', + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +/// 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; +}