From 64aba1d6e4b5999c6db33ded9e6d009ab138fe1c Mon Sep 17 00:00:00 2001 From: avashilling <165050625+avashilling@users.noreply.github.com> Date: Wed, 18 Jun 2025 14:13:53 -0400 Subject: [PATCH] Add rain confetti and animated flip counter Change continuous blast of confetti to one blast with rain and a new animation type, and changed skills names for cleaner skills table look --- lib/l10n/intl_en.arb | 8 +- .../level_up/level_up_popup.dart | 86 +++++++------ .../level_up/rain_confetti.dart | 120 ++++++++++++++++++ 3 files changed, 168 insertions(+), 46 deletions(-) create mode 100644 lib/pangea/analytics_misc/level_up/rain_confetti.dart diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 6f9712e16..827817645 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -4630,9 +4630,9 @@ "meaningSectionHeader": "Meaning:", "formSectionHeader": "Forms used in chats:", "noEmojiSelectedTooltip": "No emoji selected", - "writingExercisesTooltip": "Writing practice", - "listeningExercisesTooltip": "Listening practice", - "readingExercisesTooltip": "Reading practice", + "writingExercisesTooltip": "Writing", + "listeningExercisesTooltip": "Listening", + "readingExercisesTooltip": "Reading", "meaningNotFound": "Meaning could not be found.", "formsNotFound": "Forms could not be found.", "chooseBaseForm": "Choose the base form", @@ -5016,6 +5016,6 @@ "groupChat": "Group Chat", "directMessage": "Direct Message", "newDirectMessage": "New direct message", - "speakingExercisesTooltip": "Speaking practice", + "speakingExercisesTooltip": "Speaking", "noChatsFoundHereYet": "No chats found here yet" } diff --git a/lib/pangea/analytics_misc/level_up/level_up_popup.dart b/lib/pangea/analytics_misc/level_up/level_up_popup.dart index 977183b62..4ef50782e 100644 --- a/lib/pangea/analytics_misc/level_up/level_up_popup.dart +++ b/lib/pangea/analytics_misc/level_up/level_up_popup.dart @@ -1,12 +1,15 @@ import 'dart:async'; import 'dart:math'; +import 'package:animated_flip_counter/animated_flip_counter.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:confetti/confetti.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_banner.dart'; import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_manager.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_up/rain_confetti.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_bar/level_bar.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_bar/progress_bar_details.dart'; import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart'; @@ -15,7 +18,6 @@ import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:matrix/matrix_api_lite/generated/model.dart'; @@ -43,8 +45,8 @@ class LevelUpPopup extends StatelessWidget { : null, ), body: LevelUpPopupContent( - prevLevel: LevelUpManager.instance.prevLevel ?? 0, - level: LevelUpManager.instance.level ?? 0, + prevLevel: LevelUpManager.instance.prevLevel, + level: LevelUpManager.instance.level, ), ), ); @@ -80,12 +82,11 @@ class _LevelUpPopupContentState extends State int displayedLevel = -1; bool _hasBlastedConfetti = false; - static final int _startGrammar = LevelUpManager.instance.prevGrammar ?? 0; - static final int _startVocab = LevelUpManager.instance.prevVocab ?? 0; - static final ConstructSummary? _constructSummary = - LevelUpManager.instance.constructSummary; - static final String? _error = LevelUpManager.instance.error; - static final String language = LevelUpManager.instance.userL2Code ?? "N/A"; + final int _startGrammar = LevelUpManager.instance.prevGrammar; + final int _startVocab = LevelUpManager.instance.prevVocab; + late ConstructSummary? _constructSummary; + final String? _error = LevelUpManager.instance.error; + String language = LevelUpManager.instance.userL2Code ?? "N/A"; static const Duration _animationDuration = Duration(seconds: 5); @@ -96,11 +97,12 @@ class _LevelUpPopupContentState extends State displayedLevel = widget.prevLevel; _confettiController = - ConfettiController(duration: const Duration(seconds: 3)); + ConfettiController(duration: const Duration(seconds: 1)); // Use LevelUpManager stats instead of fetching separately - _endGrammar = LevelUpManager.instance.nextGrammar ?? 0; - _endVocab = LevelUpManager.instance.nextVocab ?? 0; + _endGrammar = LevelUpManager.instance.nextGrammar; + _endVocab = LevelUpManager.instance.nextVocab; + _constructSummary = LevelUpManager.instance.constructSummary; final client = Matrix.of(context).client; client.fetchOwnProfile().then((profile) { @@ -124,9 +126,10 @@ class _LevelUpPopupContentState extends State }); _controller.addListener(() { - if (_controller.value >= 0.4 && !_hasBlastedConfetti) { - _confettiController.play(); + if (_controller.value >= 0.5 && !_hasBlastedConfetti) { + //_confettiController.play(); _hasBlastedConfetti = true; + rainConfetti(context); } }); @@ -153,6 +156,7 @@ class _LevelUpPopupContentState extends State _controller.dispose(); _confettiController.dispose(); LevelUpManager.instance.reset(); + stopConfetti(); super.dispose(); } @@ -217,7 +221,12 @@ class _LevelUpPopupContentState extends State Padding( padding: const EdgeInsets.all(24.0), child: avatarUrl == null - ? const CircularProgressIndicator() + ? MxcImage( + client: Matrix.of(context).client, + fit: BoxFit.cover, + width: 150 * shrinkMultiplier.value, + height: 150 * shrinkMultiplier.value, + ) : ClipOval( child: MxcImage( uri: avatarUrl, @@ -255,7 +264,7 @@ class _LevelUpPopupContentState extends State totalWidth: constraints.maxWidth * progressAnimation.value, height: 20, - borderColor: colorScheme.surface, + borderColor: colorScheme.primary, ), ); }, @@ -263,11 +272,23 @@ class _LevelUpPopupContentState extends State ), const SizedBox(width: 8), Text( - "⭐ $displayedLevel", - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - color: AppConfig.goldLight, - ), + "⭐", + style: Theme.of(context).textTheme.titleLarge, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: AnimatedFlipCounter( + value: displayedLevel, + textStyle: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith( + fontWeight: FontWeight.bold, + color: AppConfig.goldLight, + ), + duration: const Duration(milliseconds: 1000), + curve: Curves.easeInOut, + ), ), ], ), @@ -388,35 +409,16 @@ class _LevelUpPopupContentState extends State ], ), ), - // Confetti overlay - Align( - alignment: Alignment.topCenter, - child: ConfettiWidget( - confettiController: _confettiController, - blastDirectionality: BlastDirectionality - .explosive, // don't specify a direction, blast randomly - shouldLoop: - true, // start again as soon as the animation is finished - emissionFrequency: 0.2, - numberOfParticles: 15, - gravity: 0.1, - colors: const [ - AppConfig.goldLight, - AppConfig.gold, - ], // manually specify the colors to be used - createParticlePath: drawStar, // define a custom shape/path. - ), - ), ], ); } Widget _buildSkillsTable(BuildContext context) { final visibleSkills = LearningSkillsEnum.values - .where((skill) => _getSkillXP(skill) > -1) + .where((skill) => (_getSkillXP(skill) > -1) && skill.isVisible) .toList(); - const itemsPerRow = 3; + const itemsPerRow = 4; // chunk into rows of up to 3 final rows = >[ for (var i = 0; i < visibleSkills.length; i += itemsPerRow) diff --git a/lib/pangea/analytics_misc/level_up/rain_confetti.dart b/lib/pangea/analytics_misc/level_up/rain_confetti.dart new file mode 100644 index 000000000..f4d3d7df4 --- /dev/null +++ b/lib/pangea/analytics_misc/level_up/rain_confetti.dart @@ -0,0 +1,120 @@ +import 'dart:math'; + +import 'package:confetti/confetti.dart'; +import 'package:fluffychat/config/app_config.dart'; +import 'package:flutter/material.dart'; + +OverlayEntry? _confettiEntry; +ConfettiController? _blastController; +ConfettiController? _rainController; + +void rainConfetti(BuildContext context) { + if (_confettiEntry != null) return; // Prevent duplicates + + _blastController = ConfettiController(duration: const Duration(seconds: 1)); + _rainController = ConfettiController(duration: const Duration(seconds: 3)); + + _blastController!.play(); + _rainController!.play(); + + final screenWidth = MediaQuery.of(context).size.width; + final screenHeight = MediaQuery.of(context).size.height; + final isSmallScreen = screenWidth < 600; + final count = isSmallScreen ? 2 : 5; + final spacing = screenWidth / (count + 1); + + _confettiEntry = OverlayEntry( + builder: (context) => Stack( + children: [ + // Initial center blast + Positioned( + top: 0, + left: screenWidth / 2, + child: IgnorePointer( + child: ConfettiWidget( + confettiController: _blastController!, + blastDirectionality: BlastDirectionality.explosive, + shouldLoop: false, + emissionFrequency: .02, + numberOfParticles: 40, + minimumSize: const Size(20, 20), + maximumSize: const Size(25, 25), + minBlastForce: 10, + maxBlastForce: 40, + gravity: 0.07, + colors: const [AppConfig.goldLight, AppConfig.gold], + createParticlePath: drawStar, + ), + ), + ), + + // Rain confetti from the top + ...List.generate(count, (index) { + final left = spacing * (index + 1) - 10; + + return Positioned( + top: -30, // Small buffer above top edge + left: left, + child: IgnorePointer( + child: ConfettiWidget( + confettiController: _rainController!, + blastDirectionality: BlastDirectionality.directional, + blastDirection: 3 * pi / 2, + shouldLoop: true, + maxBlastForce: 5, + minBlastForce: 2, + minimumSize: const Size(20, 20), + maximumSize: const Size(25, 25), + gravity: 0.07, + emissionFrequency: 0.1, + numberOfParticles: 2, + colors: const [AppConfig.goldLight, AppConfig.gold], + createParticlePath: drawStar, + ), + ), + ); + }), + ], + ), + ); + + Overlay.of(context, rootOverlay: true).insert(_confettiEntry!); +} + +void stopConfetti() { + _confettiEntry?.remove(); + _confettiEntry = null; + + _blastController?.dispose(); + _blastController = null; + + _rainController?.dispose(); + _rainController = null; +} + +Path drawStar(Size size) { + double degToRad(double deg) => deg * (pi / 180.0); + + const numberOfPoints = 5; + final halfWidth = size.width / 2; + final externalRadius = halfWidth; + final internalRadius = halfWidth / 2.5; + final degreesPerStep = degToRad(360 / numberOfPoints); + final halfDegreesPerStep = degreesPerStep / 2; + final path = Path(); + final fullAngle = degToRad(360); + path.moveTo(size.width, halfWidth); + + for (double step = 0; step < fullAngle; step += degreesPerStep) { + path.lineTo( + halfWidth + externalRadius * cos(step), + halfWidth + externalRadius * sin(step), + ); + path.lineTo( + halfWidth + internalRadius * cos(step + halfDegreesPerStep), + halfWidth + internalRadius * sin(step + halfDegreesPerStep), + ); + } + path.close(); + return path; +}