From e20844fe86df94d843780089c51f16c4377173f2 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Fri, 31 Jan 2025 16:40:41 -0500 Subject: [PATCH] 1512 level up notification (#1570) * feat: initial work for level up notification * feat: initial animation * feat: level up slide animation, wait for image data to load * feat: trigger animation on level up * feat: added sound to level up notificaton --- assets/l10n/intl_en.arb | 8 + lib/config/app_config.dart | 2 +- lib/pages/chat/chat.dart | 13 +- .../constants/analytics_constants.dart | 2 + .../controllers/get_analytics_controller.dart | 21 ++- .../analytics/enums/lemma_category_enum.dart | 6 +- lib/pangea/analytics/utils/get_svg_link.dart | 2 +- .../analytics/widgets/level_up/level_up.dart | 164 ++++++++++++++++++ lib/pangea/bot/widgets/bot_face_svg.dart | 2 +- 9 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 lib/pangea/analytics/widgets/level_up/level_up.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 6f90fdb02..c515859eb 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4745,6 +4745,14 @@ }, "notInClass": "Not in a class!", "noClassCode": "No class code!", + "chooseCorrectLabel": "Choose the correct label", + "levelPopupTitle": "Congratulations on reaching\nLevel {level}", + "@levelPopupTitle": { + "type": "text", + "placeholders": { + "level": {} + } + }, "chooseCorrectLabel": "Choose the correct label.", "activityPlannerTitle": "Activity Planner", "topicLabel": "Topic", diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 690756a8b..4742b0d4e 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -170,7 +170,7 @@ abstract class AppConfig { "https://support.microsoft.com/en-us/topic/download-languages-and-voices-for-immersive-reader-read-mode-and-read-aloud-4c83a8d8-7486-42f7-8e46-2b0fdf753130"; static String androidTTSDownloadInstructions = "https://support.google.com/accessibility/android/answer/6006983?hl=en"; - static String svgAssetsBaseURL = + static String assetsBaseURL = "https://pangea-chat-client-assets.s3.us-east-1.amazonaws.com"; // Pangea# diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 319396d7e..0b2370519 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -31,6 +31,7 @@ import 'package:fluffychat/pages/chat/recording_dialog.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pangea/analytics/controllers/put_analytics_controller.dart'; import 'package:fluffychat/pangea/analytics/models/constructs_model.dart'; +import 'package:fluffychat/pangea/analytics/widgets/level_up/level_up.dart'; import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart'; import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart'; import 'package:fluffychat/pangea/choreographer/widgets/igc/pangea_text_controller.dart'; @@ -120,8 +121,8 @@ class ChatController extends State with WidgetsBindingObserver { // #Pangea final PangeaController pangeaController = MatrixState.pangeaController; - late Choreographer choreographer = Choreographer(pangeaController, this); + StreamSubscription? _levelSubscription; // Pangea# Room get room => sendingClient.getRoomById(roomId) ?? widget.room; @@ -309,6 +310,15 @@ class ChatController extends State ); } }); + + _levelSubscription = pangeaController.getAnalytics.analyticsStream.stream + .where((update) => update.levelUp) + .listen( + (update) => LevelUpUtil.showLevelUpDialog( + pangeaController.getAnalytics.constructListModel.level, + context, + ), + ); // Pangea# tryLoadTimeline(); if (kIsWeb) { @@ -553,6 +563,7 @@ class ChatController extends State MatrixState.pAnyState.closeOverlay(); showToolbarStream.close(); hideTextController.dispose(); + _levelSubscription?.cancel(); //Pangea# super.dispose(); } diff --git a/lib/pangea/analytics/constants/analytics_constants.dart b/lib/pangea/analytics/constants/analytics_constants.dart index ec1a2c911..87fee5acc 100644 --- a/lib/pangea/analytics/constants/analytics_constants.dart +++ b/lib/pangea/analytics/constants/analytics_constants.dart @@ -10,4 +10,6 @@ class AnalyticsConstants { static const String emojiForSeed = "🫛"; static const String emojiForGreen = "🌱"; static const String emojiForFlower = "🌸"; + static const levelUpAudioFileName = "LevelUp_chime.mp3"; + static const levelUpImageFileName = "LvL_Up_Full_Banner.png"; } diff --git a/lib/pangea/analytics/controllers/get_analytics_controller.dart b/lib/pangea/analytics/controllers/get_analytics_controller.dart index 60df91eab..21c266d93 100644 --- a/lib/pangea/analytics/controllers/get_analytics_controller.dart +++ b/lib/pangea/analytics/controllers/get_analytics_controller.dart @@ -115,17 +115,28 @@ class GetAnalyticsController { // perMessage.dispose(); } - Future _onAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async { + Future _onAnalyticsUpdate( + AnalyticsUpdate analyticsUpdate, + ) async { if (analyticsUpdate.isLogout) return; + final oldLevel = constructListModel.level; constructListModel.updateConstructs(analyticsUpdate.newConstructs); if (analyticsUpdate.type == AnalyticsUpdateType.server) { await _getConstructs(forceUpdate: true); } - _updateAnalyticsStream(origin: analyticsUpdate.origin); + _updateAnalyticsStream( + origin: analyticsUpdate.origin, + levelUp: oldLevel < constructListModel.level, + ); } - void _updateAnalyticsStream({AnalyticsUpdateOrigin? origin}) { - analyticsStream.add(AnalyticsStreamUpdate(origin: origin)); + void _updateAnalyticsStream({ + bool levelUp = false, + AnalyticsUpdateOrigin? origin, + }) { + analyticsStream.add( + AnalyticsStreamUpdate(origin: origin, levelUp: levelUp), + ); } /// A local cache of eventIds and construct uses for messages sent since the last update. @@ -331,8 +342,10 @@ class AnalyticsCacheEntry { class AnalyticsStreamUpdate { final AnalyticsUpdateOrigin? origin; + final bool levelUp; AnalyticsStreamUpdate({ this.origin, + this.levelUp = false, }); } diff --git a/lib/pangea/analytics/enums/lemma_category_enum.dart b/lib/pangea/analytics/enums/lemma_category_enum.dart index e774e7175..eb52eb685 100644 --- a/lib/pangea/analytics/enums/lemma_category_enum.dart +++ b/lib/pangea/analytics/enums/lemma_category_enum.dart @@ -39,11 +39,11 @@ extension LemmaCategoryExtension on LemmaCategoryEnum { String get svgURL { switch (this) { case LemmaCategoryEnum.seeds: - return "${AppConfig.svgAssetsBaseURL}/${AnalyticsConstants.seedSvgFileName}"; + return "${AppConfig.assetsBaseURL}/${AnalyticsConstants.seedSvgFileName}"; case LemmaCategoryEnum.greens: - return "${AppConfig.svgAssetsBaseURL}/${AnalyticsConstants.leafSvgFileName}"; + return "${AppConfig.assetsBaseURL}/${AnalyticsConstants.leafSvgFileName}"; case LemmaCategoryEnum.flowers: - return "${AppConfig.svgAssetsBaseURL}/${AnalyticsConstants.flowerSvgFileName}"; + return "${AppConfig.assetsBaseURL}/${AnalyticsConstants.flowerSvgFileName}"; } } diff --git a/lib/pangea/analytics/utils/get_svg_link.dart b/lib/pangea/analytics/utils/get_svg_link.dart index 21e5a15cb..332f09f59 100644 --- a/lib/pangea/analytics/utils/get_svg_link.dart +++ b/lib/pangea/analytics/utils/get_svg_link.dart @@ -7,4 +7,4 @@ String getMorphSvgLink({ String? morphTag, required BuildContext context, }) => - "${AppConfig.svgAssetsBaseURL}/${morphFeature.toLowerCase()}_${morphTag?.toLowerCase() ?? ''}.svg"; + "${AppConfig.assetsBaseURL}/${morphFeature.toLowerCase()}_${morphTag?.toLowerCase() ?? ''}.svg"; diff --git a/lib/pangea/analytics/widgets/level_up/level_up.dart b/lib/pangea/analytics/widgets/level_up/level_up.dart new file mode 100644 index 000000000..078b7c2ba --- /dev/null +++ b/lib/pangea/analytics/widgets/level_up/level_up.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:http/http.dart' as http; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/analytics/constants/analytics_constants.dart'; + +class LevelUpUtil { + static void showLevelUpDialog( + int level, + BuildContext context, + ) { + final player = AudioPlayer(); + player.play( + UrlSource( + "${AppConfig.assetsBaseURL}/${AnalyticsConstants.levelUpAudioFileName}", + ), + ); + + showDialog( + context: context, + builder: (context) => LevelUpAnimation( + level: level, + ), + ).then((_) => player.dispose()); + } +} + +class LevelUpAnimation extends StatefulWidget { + final int level; + + const LevelUpAnimation({ + required this.level, + super.key, + }); + + @override + LevelUpAnimationState createState() => LevelUpAnimationState(); +} + +class LevelUpAnimationState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animationController; + late Animation _slideAnimation; + + Uint8List? bytes; + final imageURL = + "${AppConfig.assetsBaseURL}/${AnalyticsConstants.levelUpImageFileName}"; + + @override + void initState() { + super.initState(); + _loadImageData().then((resp) { + if (bytes == null) return; + _animationController.forward().then((_) { + if (mounted) Navigator.of(context).pop(); + }); + }).catchError((e) { + if (mounted) Navigator.of(context).pop(); + }); + + _animationController = AnimationController( + duration: const Duration(milliseconds: 2500), + vsync: this, + ); + + _slideAnimation = TweenSequence( + >[ + // Slide up from the bottom of the screen to the middle + TweenSequenceItem( + tween: Tween(begin: const Offset(0, 2), end: Offset.zero) + .chain(CurveTween(curve: Curves.easeInOut)), + weight: 2.0, // Adjust weight for the duration of the slide-up + ), + // Pause in the middle + TweenSequenceItem( + tween: Tween(begin: Offset.zero, end: Offset.zero) + .chain(CurveTween(curve: Curves.linear)), + weight: 8.0, // Adjust weight for the pause duration + ), + // Slide up and off the screen + TweenSequenceItem( + tween: Tween(begin: Offset.zero, end: const Offset(0, -2)) + .chain(CurveTween(curve: Curves.easeInOut)), + weight: 2.0, // Adjust weight for the slide-off duration + ), + ], + ).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.linear, // Keep overall animation smooth + ), + ); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + Future _loadImageData() async { + final resp = + await http.get(Uri.parse(imageURL)).timeout(const Duration(seconds: 5)); + if (resp.statusCode != 200) return; + if (mounted) { + setState(() => bytes = resp.bodyBytes); + } + } + + @override + Widget build(BuildContext context) { + if (bytes == null) { + return const SizedBox(); + } + + Widget content = Image.memory( + bytes!, + height: kIsWeb ? 350 : 250, + ); + + if (!kIsWeb) { + content = OverflowBox( + maxWidth: double.infinity, + child: content, + ); + } + + return GestureDetector( + onDoubleTap: Navigator.of(context).pop, + child: Dialog.fullscreen( + backgroundColor: Colors.transparent, + child: Center( + child: SlideTransition( + position: _slideAnimation, + child: Stack( + alignment: Alignment.center, + children: [ + content, + Padding( + padding: const EdgeInsets.only(top: 100), + child: Text( + L10n.of(context).levelPopupTitle(widget.level), + style: const TextStyle( + fontSize: kIsWeb ? 40 : 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/pangea/bot/widgets/bot_face_svg.dart b/lib/pangea/bot/widgets/bot_face_svg.dart index b9140b5d2..f22155013 100644 --- a/lib/pangea/bot/widgets/bot_face_svg.dart +++ b/lib/pangea/bot/widgets/bot_face_svg.dart @@ -31,7 +31,7 @@ class BotFaceState extends State { Artboard? _artboard; StateMachineController? _controller; final Random _random = Random(); - final String svgURL = "${AppConfig.svgAssetsBaseURL}/bot_face_neutral.png"; + final String svgURL = "${AppConfig.assetsBaseURL}/bot_face_neutral.png"; @override void initState() {