twonly-app/lib/src/visual/components/verification_success_animation.comp.dart
otsmr 2687545e33
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
use svg vector format
2026-06-16 19:14:14 +02:00

478 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<VerificationSuccessAnimation> createState() =>
VerificationSuccessAnimationState();
}
class VerificationSuccessAnimationState
extends State<VerificationSuccessAnimation>
with TickerProviderStateMixin {
late final AnimationController _ctrl;
late final AnimationController _idleCtrl;
late final Animation<double> _flyIn;
late final Animation<double> _colorFade;
late final Animation<double> _pulse;
late final Animation<double> _glow;
late final Animation<double> _ripple;
late final Animation<double> _sparkle;
late final Animation<double> _badge;
late final Animation<double> _idleBadgeScale;
late final Animation<double> _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 =
'<svg viewBox="0 0 640 640"><path d="$_path1" fill="white"/></svg>';
static const _svg2 =
'<svg viewBox="0 0 640 640"><path d="$_path2" fill="white"/></svg>';
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<double>(
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;
}