mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-06-25 08:44:08 +00:00
added chain link animation
This commit is contained in:
parent
cef58119c5
commit
0e377d0ebc
1 changed files with 475 additions and 0 deletions
|
|
@ -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<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: 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;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue