From 8f37701bd2cd9edd9c867aaa358a367323701efd Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 21:45:08 +0100 Subject: [PATCH 1/2] deps: add flutter_animate for intro polish Co-Authored-By: Claude Sonnet 4.6 --- pubspec.lock | 16 ++++++++++++++++ pubspec.yaml | 1 + 2 files changed, 17 insertions(+) diff --git a/pubspec.lock b/pubspec.lock index 7a42eb6..3f8be96 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -318,6 +318,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_animate: + dependency: "direct main" + description: + name: flutter_animate + sha256: "7befe2d3252728afb77aecaaea1dec88a89d35b9b1d2eea6d04479e8af9117b5" + url: "https://pub.dev" + source: hosted + version: "4.5.2" flutter_bloc: dependency: "direct main" description: @@ -342,6 +350,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_shaders: + dependency: transitive + description: + name: flutter_shaders + sha256: "34794acadd8275d971e02df03afee3dee0f98dbfb8c4837082ad0034f612a3e2" + url: "https://pub.dev" + source: hosted + version: "0.1.3" flutter_staggered_animations: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 5eb20da..d176686 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: flutter_svg: ^2.0.10+1 firebase_app_check: ^0.4.2 confetti: ^0.8.0 + flutter_animate: ^4.5.0 flutter_staggered_animations: ^1.1.1 dev_dependencies: flutter_test: From 9950de15787b007db8d917d6fd60981214c7ec55 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 12 Apr 2026 22:13:06 +0100 Subject: [PATCH 2/2] feat: wire up flutter_animate and confetti in intro - Step 0: title slides up on entry; postbox scales in with elastic bounce - Step 1: postbox fades in; 'Look, it's a Postie!' label fades in after 1s - Steps 2 & 3: character row fades in; speech bubble slides up from below - Step 4: confetti fires on entry; 'Points, baby!' text shimmers and pulses - Steps 5: overview rows stagger in with 150ms delay each - Step 6: thumbs-up bounces in; closing text fades in Co-Authored-By: Claude Sonnet 4.6 --- lib/intro.dart | 119 ++++++++++++++++++++++++++++++++++++------------- 1 file changed, 87 insertions(+), 32 deletions(-) diff --git a/lib/intro.dart b/lib/intro.dart index c9b109f..dc23c4c 100644 --- a/lib/intro.dart +++ b/lib/intro.dart @@ -1,5 +1,7 @@ import 'package:animated_text_kit/animated_text_kit.dart'; +import 'package:confetti/confetti.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:postbox_game/james_messages.dart'; import 'package:postbox_game/postman_james_svg.dart'; @@ -28,6 +30,7 @@ class _IntroState extends State with TickerProviderStateMixin { late AnimationController _jamesWalkController; late Animation _jamesSlide; + late final ConfettiController _confettiController; @override void initState() { @@ -39,17 +42,21 @@ class _IntroState extends State with TickerProviderStateMixin { _jamesSlide = Tween(begin: -1.0, end: 0.0).animate( CurvedAnimation(parent: _jamesWalkController, curve: Curves.easeOut), ); + _confettiController = + ConfettiController(duration: const Duration(seconds: 6)); } @override void dispose() { _jamesWalkController.dispose(); + _confettiController.dispose(); super.dispose(); } void _advance() { - // Start the walk animation when entering step 1 (James slides in from left). if (_step == 0) _jamesWalkController.forward(); + // Fire confetti when the user taps into the Mega Points step. + if (_step == 3) _confettiController.play(); if (_step < _totalSteps - 1) { setState(() => _step++); } else { @@ -128,7 +135,10 @@ class _IntroState extends State with TickerProviderStateMixin { color: Colors.white, fontWeight: FontWeight.bold, ), - ), + ) + .animate() + .fadeIn(duration: 600.ms) + .slideY(begin: 0.3, duration: 600.ms, curve: Curves.easeOut), const SizedBox(height: AppSpacing.xxl), Container( padding: const EdgeInsets.symmetric( @@ -140,7 +150,14 @@ class _IntroState extends State with TickerProviderStateMixin { ), child: Column( children: [ - SvgPicture.asset('assets/postbox.svg', width: 120, height: 120), + SvgPicture.asset('assets/postbox.svg', width: 120, height: 120) + .animate(delay: 200.ms) + .fadeIn(duration: 600.ms) + .scale( + begin: const Offset(0.6, 0.6), + duration: 600.ms, + curve: Curves.elasticOut, + ), const SizedBox(height: AppSpacing.sm), const Text( 'A brief introduction to postboxes...', @@ -167,7 +184,9 @@ class _IntroState extends State with TickerProviderStateMixin { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ - SvgPicture.asset('assets/postbox.svg', width: 80, height: 80), + SvgPicture.asset('assets/postbox.svg', width: 80, height: 80) + .animate() + .fadeIn(duration: 500.ms), const SizedBox(width: AppSpacing.lg), FractionalTranslation( translation: Offset(_jamesSlide.value, 0), @@ -179,7 +198,7 @@ class _IntroState extends State with TickerProviderStateMixin { const Text( 'Look, it\'s a Postie!', style: TextStyle(color: Colors.white70, fontSize: 20), - ), + ).animate(delay: 1000.ms).fadeIn(duration: 400.ms), ], ), ); @@ -202,7 +221,7 @@ class _IntroState extends State with TickerProviderStateMixin { const SizedBox(width: AppSpacing.md), const PostmanJamesSvg(size: 90, isTalking: true), ], - ), + ).animate().fadeIn(duration: 400.ms), const SizedBox(height: AppSpacing.xl), Container( padding: const EdgeInsets.all(AppSpacing.lg), @@ -224,7 +243,10 @@ class _IntroState extends State with TickerProviderStateMixin { totalRepeatCount: 1, displayFullTextOnTap: true, ), - ), + ) + .animate(delay: 200.ms) + .fadeIn(duration: 500.ms) + .slideY(begin: 0.2, duration: 400.ms, curve: Curves.easeOut), ], ), ), @@ -232,25 +254,49 @@ class _IntroState extends State with TickerProviderStateMixin { } Widget _buildMegaPoints() { - return Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const PostmanJamesSvg( - size: 160, showStarEyes: true, isTalking: true), - const SizedBox(height: AppSpacing.lg), - Text( - 'Points, baby! Sweet, beautiful, points!', - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - color: postalGold, - fontWeight: FontWeight.bold, - ), + return Stack( + alignment: Alignment.topCenter, + children: [ + // Confetti fires from the top-centre when this step is entered. + Align( + alignment: Alignment.topCenter, + child: ConfettiWidget( + confettiController: _confettiController, + blastDirectionality: BlastDirectionality.explosive, + colors: const [postalGold, postalRed, Colors.white], + numberOfParticles: 30, + gravity: 0.2, + ), + ), + Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const PostmanJamesSvg( + size: 160, showStarEyes: true, isTalking: true), + const SizedBox(height: AppSpacing.lg), + Text( + 'Points, baby! Sweet, beautiful, points!', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: postalGold, + fontWeight: FontWeight.bold, + ), + ) + .animate(onPlay: (c) => c.repeat(reverse: true)) + .shimmer(duration: 1200.ms, color: Colors.white70) + .scale( + begin: const Offset(0.95, 0.95), + end: const Offset(1.05, 1.05), + duration: 800.ms, + ), + ], ), - ], + ), ), - ), + ], ); } @@ -267,20 +313,20 @@ class _IntroState extends State with TickerProviderStateMixin { .textTheme .titleLarge ?.copyWith(color: Colors.white), - ), + ).animate().fadeIn(duration: 400.ms), const SizedBox(height: AppSpacing.lg), - _overviewRow(Icons.location_searching, 'Find postboxes near you'), + _overviewRow(Icons.location_searching, 'Find postboxes near you', 0), _overviewRow(Icons.add_location, - 'Claim them when you\'re there to score points'), + 'Claim them when you\'re there to score points', 1), _overviewRow(Icons.leaderboard, - 'Climb the leaderboard and compete with friends'), + 'Climb the leaderboard and compete with friends', 2), ], ), ), ); } - Widget _overviewRow(IconData icon, String text) { + Widget _overviewRow(IconData icon, String text, int index) { return Padding( padding: const EdgeInsets.only(bottom: AppSpacing.md), child: Row( @@ -297,7 +343,10 @@ class _IntroState extends State with TickerProviderStateMixin { ), ], ), - ); + ) + .animate(delay: (index * 150).ms) + .fadeIn(duration: 400.ms) + .slideX(begin: -0.2, duration: 400.ms, curve: Curves.easeOut); } Widget _buildOverviewEnd() { @@ -307,7 +356,13 @@ class _IntroState extends State with TickerProviderStateMixin { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.thumb_up, size: 64, color: postalGold), + Icon(Icons.thumb_up, size: 64, color: postalGold) + .animate() + .scale( + begin: const Offset(0.3, 0.3), + duration: 600.ms, + curve: Curves.elasticOut, + ), const SizedBox(height: AppSpacing.lg), Text( widget.replay @@ -318,7 +373,7 @@ class _IntroState extends State with TickerProviderStateMixin { color: Colors.white.withValues(alpha: 0.9), fontSize: 20, height: 1.4), - ), + ).animate(delay: 300.ms).fadeIn(duration: 500.ms), ], ), ),