diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index cc5a604f8..9cbc11a46 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -2214,6 +2214,13 @@ class ChatController extends State void setShowDropdown(bool show) async { setState(() => showActivityDropdown = show); } + + bool hasRainedConfetti = false; + void setHasRainedConfetti(bool show) { + if (mounted) { + setState(() => hasRainedConfetti = show); + } + } // Pangea# late final ValueNotifier _displayChatDetailsColumn; diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index 500fd1cfd..32d923f3c 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -17,6 +17,7 @@ import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart import 'package:fluffychat/pangea/activity_sessions/activity_session_chat/activity_finished_status_message.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_session_chat/activity_stats_menu.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_session_chat/load_activity_summary_widget.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart'; import 'package:fluffychat/pangea/chat/widgets/chat_input_bar.dart'; import 'package:fluffychat/pangea/chat/widgets/chat_input_bar_header.dart'; import 'package:fluffychat/pangea/chat/widgets/chat_view_background.dart'; @@ -456,7 +457,10 @@ class ChatView extends StatelessWidget { ), ), if (controller.room.activityIsFinished) - LoadActivitySummaryWidget(room: controller.room), + LoadActivitySummaryWidget( + room: controller.room, + ), + ActivityFinishedStatusMessage( controller: controller, ), @@ -491,6 +495,13 @@ class ChatView extends StatelessWidget { ), ), ActivityStatsMenu(controller), + if (controller.room.activitySummary?.summary != null && + controller.hasRainedConfetti == false) + StarRainWidget( + showBlast: true, + onFinished: () => + controller.setHasRainedConfetti(true), + ), // Pangea# ], ), diff --git a/lib/pangea/activity_sessions/activity_session_chat/activity_stats_button.dart b/lib/pangea/activity_sessions/activity_session_chat/activity_stats_button.dart index 3200488e5..1da3f0823 100644 --- a/lib/pangea/activity_sessions/activity_session_chat/activity_stats_button.dart +++ b/lib/pangea/activity_sessions/activity_session_chat/activity_stats_button.dart @@ -71,7 +71,7 @@ class _ActivityStatsButtonState extends State { @override Widget build(BuildContext context) { return Container( - width: 350, + width: 300, height: 55, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: InkWell( @@ -80,11 +80,17 @@ class _ActivityStatsButtonState extends State { !widget.controller.showActivityDropdown, ), child: Container( - decoration: BoxDecoration( + decoration: ShapeDecoration( color: AppConfig.goldLight.withAlpha(100), - borderRadius: BorderRadius.circular(20), + shape: RoundedRectangleBorder( + side: const BorderSide( + width: 0.20, + color: AppConfig.gold, + ), + borderRadius: BorderRadius.circular(12), + ), ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ diff --git a/lib/pangea/activity_sessions/activity_user_summaries_widget.dart b/lib/pangea/activity_sessions/activity_user_summaries_widget.dart index 4bc2e6de8..a985ef279 100644 --- a/lib/pangea/activity_sessions/activity_user_summaries_widget.dart +++ b/lib/pangea/activity_sessions/activity_user_summaries_widget.dart @@ -120,10 +120,11 @@ class ButtonControlledCarouselView extends StatelessWidget { margin: const EdgeInsets.only(right: 5.0), padding: const EdgeInsets.all(12.0), decoration: ShapeDecoration( + color: AppConfig.goldLight.withAlpha(100), shape: RoundedRectangleBorder( - side: BorderSide( - width: 0.10, - color: Theme.of(context).colorScheme.outline, + side: const BorderSide( + width: 0.20, + color: AppConfig.gold, ), borderRadius: BorderRadius.circular(12), ), @@ -175,7 +176,6 @@ class ButtonControlledCarouselView extends StatelessWidget { Text( p.cefrLevel, style: const TextStyle( - color: AppConfig.yellowDark, fontSize: 12.0, ), ), @@ -264,12 +264,11 @@ class SuperlativeTile extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 14, color: AppConfig.gold), + Icon(icon, size: 14, color: Theme.of(context).colorScheme.onSurface), const SizedBox(width: 2), const Text( "1st", style: TextStyle( - color: AppConfig.gold, fontSize: 12.0, ), ), 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 3a28a3368..5d9f7192e 100644 --- a/lib/pangea/analytics_misc/level_up/level_up_popup.dart +++ b/lib/pangea/analytics_misc/level_up/level_up_popup.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; 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:material_symbols_icons/symbols.dart'; import 'package:matrix/matrix_api_lite/generated/model.dart'; @@ -15,7 +14,7 @@ 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_misc/level_up/star_rain_widget.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/error_indicator.dart'; @@ -26,37 +25,56 @@ import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; -class LevelUpPopup extends StatelessWidget { +class LevelUpPopup extends StatefulWidget { final Completer constructSummaryCompleter; const LevelUpPopup({ required this.constructSummaryCompleter, super.key, }); + @override + State createState() => _LevelUpPopupState(); +} + +class _LevelUpPopupState extends State { + bool shouldShowRain = false; + + void setShowRain(bool show) { + setState(() { + shouldShowRain = show; + }); + } + @override Widget build(BuildContext context) { - return FullWidthDialog( - maxWidth: 400, - maxHeight: 800, - dialogContent: Scaffold( - appBar: AppBar( - centerTitle: true, - title: kIsWeb - ? Text( - L10n.of(context).youHaveLeveledUp, - style: const TextStyle( - color: AppConfig.gold, - fontWeight: FontWeight.w600, - ), - ) - : null, + return Stack( + children: [ + FullWidthDialog( + maxWidth: 400, + maxHeight: 800, + dialogContent: Scaffold( + appBar: AppBar( + centerTitle: true, + title: kIsWeb + ? Text( + L10n.of(context).youHaveLeveledUp, + style: const TextStyle( + color: AppConfig.gold, + fontWeight: FontWeight.w600, + ), + ) + : null, + ), + body: LevelUpPopupContent( + prevLevel: LevelUpManager.instance.prevLevel, + level: LevelUpManager.instance.level, + constructSummaryCompleter: widget.constructSummaryCompleter, + onRainTrigger: () => setShowRain(true), + ), + ), ), - body: LevelUpPopupContent( - prevLevel: LevelUpManager.instance.prevLevel, - level: LevelUpManager.instance.level, - constructSummaryCompleter: constructSummaryCompleter, - ), - ), + if (shouldShowRain) const StarRainWidget(showBlast: true), + ], ); } } @@ -66,11 +84,14 @@ class LevelUpPopupContent extends StatefulWidget { final int level; final Completer constructSummaryCompleter; + final VoidCallback? onRainTrigger; + const LevelUpPopupContent({ super.key, required this.prevLevel, required this.level, required this.constructSummaryCompleter, + this.onRainTrigger, }); @override @@ -80,12 +101,11 @@ class LevelUpPopupContent extends StatefulWidget { class _LevelUpPopupContentState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; - late final ConfettiController _confettiController; late final Future profile; int displayedLevel = -1; Uri? avatarUrl; - bool _hasBlastedConfetti = false; + final bool _hasBlastedConfetti = false; String language = MatrixState.pangeaController.languageController .activeL2Code() @@ -102,8 +122,6 @@ class _LevelUpPopupContentState extends State _loadConstructSummary(); LevelUpManager.instance.markPopupSeen(); displayedLevel = widget.prevLevel; - _confettiController = - ConfettiController(duration: const Duration(seconds: 1)); final client = Matrix.of(context).client; client.fetchOwnProfile().then((profile) { @@ -124,10 +142,11 @@ class _LevelUpPopupContentState extends State } }); + // Listener to trigger rain confetti via callback _controller.addListener(() { if (_controller.value >= 0.5 && !_hasBlastedConfetti) { - _hasBlastedConfetti = true; - rainConfetti(context); + // _hasBlastedConfetti = true; + if (widget.onRainTrigger != null) widget.onRainTrigger!(); } }); @@ -137,9 +156,7 @@ class _LevelUpPopupContentState extends State @override void dispose() { _controller.dispose(); - _confettiController.dispose(); LevelUpManager.instance.reset(); - stopConfetti(); super.dispose(); } diff --git a/lib/pangea/analytics_misc/level_up/rain_confetti.dart b/lib/pangea/analytics_misc/level_up/rain_confetti.dart deleted file mode 100644 index 4dc7cb968..000000000 --- a/lib/pangea/analytics_misc/level_up/rain_confetti.dart +++ /dev/null @@ -1,127 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; - -import 'package:confetti/confetti.dart'; - -import 'package:fluffychat/config/app_config.dart'; - -OverlayEntry? _confettiEntry; -ConfettiController? _blastController; -ConfettiController? _rainController; - -void rainConfetti(BuildContext context) { - if (_confettiEntry != null) return; // Prevent duplicates - int numParticles = 2; - - _blastController = ConfettiController(duration: const Duration(seconds: 1)); - _rainController = ConfettiController(duration: const Duration(seconds: 8)); - Future.delayed(const Duration(seconds: 4), () { - if (_rainController?.state == ConfettiControllerState.playing) { - numParticles = 1; - } - }); - - _blastController!.play(); - _rainController!.play(); - - final screenWidth = MediaQuery.of(context).size.width; - 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: false, - maxBlastForce: 5, - minBlastForce: 2, - minimumSize: const Size(20, 20), - maximumSize: const Size(25, 25), - gravity: 0.07, - emissionFrequency: 0.1, - numberOfParticles: numParticles, - 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; -} diff --git a/lib/pangea/analytics_misc/level_up/star_rain_widget.dart b/lib/pangea/analytics_misc/level_up/star_rain_widget.dart new file mode 100644 index 000000000..56a730176 --- /dev/null +++ b/lib/pangea/analytics_misc/level_up/star_rain_widget.dart @@ -0,0 +1,170 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +import 'package:confetti/confetti.dart'; + +import 'package:fluffychat/config/app_config.dart'; + +class StarRainWidget extends StatefulWidget { + final bool showBlast; + final Duration rainDuration; + final Duration blastDuration; + final VoidCallback? onFinished; + + const StarRainWidget({ + super.key, + this.showBlast = true, + this.rainDuration = const Duration(seconds: 8), + this.blastDuration = const Duration(seconds: 1), + this.onFinished, + }); + + @override + State createState() => _StarRainWidgetState(); +} + +class _StarRainWidgetState extends State { + late ConfettiController _blastController; + late ConfettiController _rainController; + int numParticles = 2; + double _fadeOpacity = 1.0; + + @override + void initState() { + super.initState(); + _blastController = ConfettiController(duration: widget.blastDuration); + _rainController = ConfettiController(duration: widget.rainDuration); + + if (widget.showBlast) { + _blastController.play(); + } + _rainController.play(); + + Future.delayed(const Duration(seconds: 4), () { + if (_rainController.state == ConfettiControllerState.playing) { + setState(() { + numParticles = 1; + }); + } + }); + + _fadeOpacity = 1.0; + Future.delayed(widget.rainDuration, () async { + if (mounted) { + setState(() { + _fadeOpacity = 0.0; + }); + } + await Future.delayed(const Duration(milliseconds: 800)); + widget.onFinished?.call(); + if (mounted) { + _blastController.stop(); + _rainController.stop(); + } + }); + } + + @override + void dispose() { + _blastController.dispose(); + _rainController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const count = 3; + const spawnOffsets = [0.2, 0.5, 0.8]; // Relative horizontal positions + + return IgnorePointer( + ignoring: true, + child: AnimatedOpacity( + opacity: _fadeOpacity, + duration: const Duration(milliseconds: 800), + child: Stack( + fit: StackFit.expand, + children: [ + // Initial center blast (top center) + Positioned( + top: 0, + left: 0, + right: 0, + child: Align( + alignment: Alignment.topCenter, + 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 (3 fixed spawn points) + ...List.generate(count, (index) { + return Positioned( + top: -30, + left: null, + right: null, + child: FractionallySizedBox( + widthFactor: 0, + alignment: Alignment(spawnOffsets[index] * 2 - 1, -1), + child: ConfettiWidget( + confettiController: _rainController, + blastDirectionality: BlastDirectionality.directional, + blastDirection: 3 * pi / 2, + shouldLoop: false, + maxBlastForce: 5, + minBlastForce: 2, + minimumSize: const Size(20, 20), + maximumSize: const Size(25, 25), + gravity: 0.07, + emissionFrequency: 0.1, + numberOfParticles: numParticles, + colors: const [AppConfig.goldLight, AppConfig.gold], + createParticlePath: drawStar, + ), + ), + ); + }), + ], + ), + ), + ); + } +} + +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; +}