feat(send): animated hash-bag on the "Transaction Sent" screen

Replace the static success illustration with HashBagSuccessAnimation: the money
sack sits at the bottom and #/$/₿ glyphs erupt from its mouth, rising and fading
on a loop. Glyphs are PNG assets (the app bundles no font with a ₿ glyph).

InfoPage gains an overridable buildIllustration() hook (default unchanged:
static HashImageWidget); only TransactionSuccessPage overrides it, so every
other InfoPage is untouched.

NOTE: written without a local Flutter build (Dart pub can't resolve here) —
verify on the next CI/device build. Self-contained and revertible; the static
hash_bag.png remains the fallback via imageLightPath/imageDarkPath.
This commit is contained in:
jwinterm
2026-08-07 03:02:37 -04:00
parent 7e368f92e6
commit 2e33f8324e
7 changed files with 136 additions and 1 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

+7 -1
View File
@@ -21,6 +21,12 @@ abstract class InfoPage extends BasePage {
Key? get buttonKey;
void Function(BuildContext) get onPressed;
/// The illustration shown at the top of the page. Defaults to the static
/// image at [imageLightPath]/[imageDarkPath]; subclasses can override to
/// supply an animated widget instead.
Widget buildIllustration(BuildContext context, String image) =>
HashImageWidget(imageUrl: image);
@override
bool get gradientBackground => true;
@@ -56,7 +62,7 @@ abstract class InfoPage extends BasePage {
),
child: AspectRatio(
aspectRatio: 0.9,
child: HashImageWidget(imageUrl: image),
child: buildIllustration(context, image),
),
),
),
@@ -1,5 +1,6 @@
import 'package:hash_wallet/generated/i18n.dart';
import 'package:hash_wallet/src/screens/Info_page.dart';
import 'package:hash_wallet/src/widgets/hash_bag_success_animation.dart';
import 'package:flutter/cupertino.dart';
class TransactionSuccessPage extends InfoPage {
@@ -11,6 +12,10 @@ class TransactionSuccessPage extends InfoPage {
final String content;
@override
Widget buildIllustration(BuildContext context, String image) =>
const HashBagSuccessAnimation();
@override
bool get onWillPop => false;
@@ -0,0 +1,124 @@
import 'package:flutter/material.dart';
/// "Transaction sent" celebration: the Hash Bags money sack sits at the bottom
/// and money glyphs (#, $, ₿) erupt from its mouth, rising and fading on a loop.
///
/// The glyphs are image assets (not text) because the app doesn't bundle a font
/// containing a bitcoin glyph — rendering them as PNGs keeps them consistent
/// across platforms.
class HashBagSuccessAnimation extends StatefulWidget {
const HashBagSuccessAnimation({super.key});
@override
State<HashBagSuccessAnimation> createState() => _HashBagSuccessAnimationState();
}
class _Particle {
final String asset;
final double dx; // horizontal fan, fraction of width
final double phase; // stagger, 0..1
final double size; // fraction of width
const _Particle(this.asset, this.dx, this.phase, this.size);
}
class _HashBagSuccessAnimationState extends State<HashBagSuccessAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
static const String _hash = 'assets/images/hashbag_sym_hash.png';
static const String _dollar = 'assets/images/hashbag_sym_dollar.png';
static const String _btc = 'assets/images/hashbag_sym_btc.png';
static const List<_Particle> _particles = <_Particle>[
_Particle(_btc, 0.00, 0.00, 0.20),
_Particle(_hash, -0.18, 0.13, 0.16),
_Particle(_dollar, 0.19, 0.24, 0.16),
_Particle(_hash, 0.11, 0.40, 0.12),
_Particle(_dollar, -0.13, 0.54, 0.11),
_Particle(_btc, 0.06, 0.68, 0.12),
_Particle(_hash, -0.07, 0.83, 0.10),
];
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2800),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final double w = constraints.maxWidth;
final double h = constraints.maxHeight;
// Sack anchored at the bottom (~62% of the height); mouth ≈ its top.
final double bagH = h * 0.62;
final double bagW = bagH * 0.825; // hash_bag_base.png aspect (w/h)
final double bagLeft = (w - bagW) / 2;
final double bagTop = h - bagH;
final double mouthX = w / 2;
final double mouthY = bagTop + bagH * 0.06;
return AnimatedBuilder(
animation: _controller,
builder: (BuildContext context, Widget? _) {
final List<Widget> children = <Widget>[
Positioned(
left: bagLeft,
top: bagTop,
width: bagW,
height: bagH,
child: Image.asset(
'assets/images/hash_bag_base.png',
fit: BoxFit.contain,
),
),
];
for (final _Particle p in _particles) {
final double t = (_controller.value + p.phase) % 1.0;
final double sz = w * p.size;
final double rise = h * 0.52 * t;
final double spread = w * p.dx * (0.35 + t);
final double x = mouthX + spread - sz / 2;
final double y = mouthY - rise - sz / 2;
final double opacity = (t < 0.15
? t / 0.15
: (t > 0.78 ? (1.0 - t) / 0.22 : 1.0))
.clamp(0.0, 1.0)
.toDouble();
final double scale = 0.55 + 0.45 * (t < 0.3 ? t / 0.3 : 1.0);
children.add(
Positioned(
left: x,
top: y,
width: sz,
height: sz,
child: Opacity(
opacity: opacity,
child: Transform.scale(
scale: scale,
child: Image.asset(p.asset, fit: BoxFit.contain),
),
),
),
);
}
return Stack(clipBehavior: Clip.none, children: children);
},
);
},
);
}
}