From e10f2bebeb6b644f09ff7c18b51e127c1c0d0225 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:55:53 -0400 Subject: [PATCH] feat: show instruction overlay on first gain points in activity pointing user to click activity stats button (#4099) --- lib/l10n/intl_en.arb | 3 +- .../activity_stats_button.dart | 140 +++++++++++++----- lib/pangea/common/utils/any_state_holder.dart | 6 +- lib/pangea/common/utils/cutout_painter.dart | 40 +++++ lib/pangea/common/utils/overlay.dart | 133 ++++------------- .../widgets/anchored_overlay_widget.dart | 97 ++++++++++++ .../common/widgets/transparent_backdrop.dart | 108 ++++++++++++++ .../instructions/instructions_enum.dart | 19 +-- 8 files changed, 388 insertions(+), 158 deletions(-) create mode 100644 lib/pangea/common/utils/cutout_painter.dart create mode 100644 lib/pangea/common/widgets/anchored_overlay_widget.dart create mode 100644 lib/pangea/common/widgets/transparent_backdrop.dart diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 33b6f55e3..d0001808d 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -5251,5 +5251,6 @@ "courseTitle": "Course title", "courseDesc": "Course description", "courseSavedSuccessfully": "Course saved successfully", - "addCoursePlan": "Add a course plan" + "addCoursePlan": "Add a course plan", + "activityStatsButtonInstruction": "Click here to view your activity stats and to close the activity when finished" } 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 1da3f0823..f844d386c 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 @@ -3,13 +3,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart'; import 'package:fluffychat/pangea/activity_summary/activity_summary_analytics_model.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/pangea/common/utils/overlay.dart'; +import 'package:fluffychat/pangea/common/widgets/pressable_button.dart'; +import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; class ActivityStatsButton extends StatefulWidget { final ChatController controller; @@ -36,9 +41,69 @@ class _ActivityStatsButtonState extends State { _analyticsSubscription = widget .controller.pangeaController.getAnalytics.analyticsStream.stream - .listen((_) { - _updateAnalytics(); - }); + .listen((_) => _updateAnalytics()); + } + + Client get client => widget.controller.room.client; + + void _showInstructionPopup() { + if (InstructionsEnum.activityStatsMenu.isToggledOff || xpCount <= 0) { + return; + } + + final renderObject = context.findRenderObject() as RenderBox; + final offset = renderObject.localToGlobal(Offset.zero); + + final cellRect = Rect.fromLTWH( + offset.dx, + offset.dy, + renderObject.size.width, + renderObject.size.height, + ); + + OverlayUtil.showTutorialOverlay( + context, + Center( + child: Container( + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurface, + borderRadius: BorderRadius.circular(12.0), + ), + width: 200, + alignment: Alignment.center, + child: RichText( + text: TextSpan( + style: TextStyle( + color: Theme.of(context).colorScheme.surface, + ), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Icon( + Icons.info_outlined, + size: 16.0, + color: Theme.of(context).colorScheme.surface, + ), + ), + const WidgetSpan(child: SizedBox(width: 4.0)), + TextSpan( + text: L10n.of(context).activityStatsButtonInstruction, + ), + ], + ), + textAlign: TextAlign.center, + ), + ), + ), + cellRect, + borderRadius: 12.0, + padding: 8.0, + onClick: () => widget.controller.setShowDropdown(true), + onDismiss: () { + InstructionsEnum.activityStatsMenu.setToggledOff(true); + }, + ); } @override @@ -48,60 +113,61 @@ class _ActivityStatsButtonState extends State { } int get xpCount => analytics.totalXPForUser( - Matrix.of(context).client.userID ?? '', + client.userID!, ); int get vocabCount => analytics.uniqueConstructCountForUser( - widget.controller.room.client.userID!, + client.userID!, ConstructTypeEnum.vocab, ); int get grammarCount => analytics.uniqueConstructCountForUser( - widget.controller.room.client.userID!, + client.userID!, ConstructTypeEnum.morph, ); Future _updateAnalytics() async { + final prevXP = xpCount; final analytics = await widget.controller.room.getActivityAnalytics(); if (mounted) { setState(() => this.analytics = analytics); + if (prevXP == 0 && xpCount > 0) _showInstructionPopup(); } } @override Widget build(BuildContext context) { - return Container( - width: 300, - height: 55, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: InkWell( - borderRadius: BorderRadius.circular(20), - onTap: () => widget.controller.setShowDropdown( - !widget.controller.showActivityDropdown, + final theme = Theme.of(context); + return PressableButton( + onPressed: () => widget.controller.setShowDropdown( + !widget.controller.showActivityDropdown, + ), + borderRadius: BorderRadius.circular(12), + color: xpCount > 0 + ? AppConfig.gold.withAlpha(180) + : theme.colorScheme.surface, + depressed: xpCount <= 0 || widget.controller.showActivityDropdown, + child: AnimatedContainer( + duration: FluffyThemes.animationDuration, + width: 300, + height: 55, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: xpCount > 0 + ? AppConfig.gold.withAlpha(180) + : theme.colorScheme.surface, + borderRadius: BorderRadius.circular(12), ), - child: Container( - decoration: ShapeDecoration( - color: AppConfig.goldLight.withAlpha(100), - shape: RoundedRectangleBorder( - side: const BorderSide( - width: 0.20, - color: AppConfig.gold, - ), - borderRadius: BorderRadius.circular(12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _StatsBadge(icon: Icons.radar, value: "$xpCount XP"), + _StatsBadge(icon: Symbols.dictionary, value: "$vocabCount"), + _StatsBadge( + icon: Symbols.toys_and_games, + value: "$grammarCount", ), - ), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _StatsBadge(icon: Icons.radar, value: "$xpCount XP"), - _StatsBadge(icon: Symbols.dictionary, value: "$vocabCount"), - _StatsBadge( - icon: Symbols.toys_and_games, - value: "$grammarCount", - ), - ], - ), + ], ), ), ); diff --git a/lib/pangea/common/utils/any_state_holder.dart b/lib/pangea/common/utils/any_state_holder.dart index 6fbb02a33..201d9686b 100644 --- a/lib/pangea/common/utils/any_state_holder.dart +++ b/lib/pangea/common/utils/any_state_holder.dart @@ -44,6 +44,7 @@ class PangeaAnyState { BuildContext context, { String? overlayKey, bool canPop = true, + bool rootOverlay = false, }) { if (overlayKey != null && entries.any((element) => element.key == overlayKey)) { @@ -62,7 +63,10 @@ class PangeaAnyState { activeOverlays.add(overlayKey); } - Overlay.of(context).insert(entry); + Overlay.of( + context, + rootOverlay: rootOverlay, + ).insert(entry); } void closeOverlay([String? overlayKey]) { diff --git a/lib/pangea/common/utils/cutout_painter.dart b/lib/pangea/common/utils/cutout_painter.dart new file mode 100644 index 000000000..91c419b9d --- /dev/null +++ b/lib/pangea/common/utils/cutout_painter.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class CutoutBackgroundPainter extends CustomPainter { + final Rect holeRect; + final Color backgroundColor; + final double borderRadius; + final double padding; + + CutoutBackgroundPainter({ + required this.holeRect, + required this.backgroundColor, + required this.borderRadius, + this.padding = 6.0, + }); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = backgroundColor; + + final path = Path() + ..addRect(Rect.fromLTWH(0, 0, size.width, size.height)) + ..addRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH( + holeRect.left - padding, + holeRect.top - padding, + holeRect.width + 2 * padding, + holeRect.height + 2 * padding, + ), + Radius.circular(borderRadius), + ), + ) + ..fillType = PathFillType.evenOdd; + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => true; +} diff --git a/lib/pangea/common/utils/overlay.dart b/lib/pangea/common/utils/overlay.dart index 70ed108e6..99b37c2e8 100644 --- a/lib/pangea/common/utils/overlay.dart +++ b/lib/pangea/common/utils/overlay.dart @@ -1,12 +1,12 @@ import 'dart:developer'; -import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/common/utils/any_state_holder.dart'; +import 'package:fluffychat/pangea/common/widgets/anchored_overlay_widget.dart'; import 'package:fluffychat/pangea/common/widgets/overlay_container.dart'; +import 'package:fluffychat/pangea/common/widgets/transparent_backdrop.dart'; import '../../../config/themes.dart'; import '../../../widgets/matrix.dart'; import 'error_handler.dart'; @@ -22,8 +22,8 @@ class OverlayUtil { required BuildContext context, required Widget child, String? transformTargetId, - backDropToDismiss = true, - blurBackground = false, + bool backDropToDismiss = true, + bool blurBackground = false, Color? borderColor, Color? backgroundColor, bool closePrevOverlay = true, @@ -118,7 +118,7 @@ class OverlayUtil { required String transformTargetId, required double maxHeight, required double maxWidth, - backDropToDismiss = true, + bool backDropToDismiss = true, Color? borderColor, bool closePrevOverlay = true, String? overlayKey, @@ -214,107 +214,32 @@ class OverlayUtil { } } - static bool get isOverlayOpen => MatrixState.pAnyState.entries.isNotEmpty; -} - -class TransparentBackdrop extends StatefulWidget { - final Color? backgroundColor; - final VoidCallback? onDismiss; - final bool blurBackground; - - const TransparentBackdrop({ - super.key, - this.onDismiss, - this.backgroundColor, - this.blurBackground = false, - }); - - @override - TransparentBackdropState createState() => TransparentBackdropState(); -} - -class TransparentBackdropState extends State - with SingleTickerProviderStateMixin { - late AnimationController _controller; - late Animation _opacityTween; - late Animation _blurTween; - - @override - void initState() { - super.initState(); - _controller = AnimationController( - duration: - const Duration(milliseconds: AppConfig.overlayAnimationDuration), - vsync: this, - ); - _opacityTween = Tween(begin: 0.0, end: 0.8).animate( - CurvedAnimation( - parent: _controller, - curve: FluffyThemes.animationCurve, - ), - ); - _blurTween = Tween(begin: 0.0, end: 3.0).animate( - CurvedAnimation( - parent: _controller, - curve: FluffyThemes.animationCurve, - ), - ); - - Future.delayed(const Duration(milliseconds: 100), () { - if (mounted) _controller.forward(); - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: _opacityTween, - builder: (context, _) { - return Material( - borderOnForeground: false, - color: widget.backgroundColor - ?.withAlpha((_opacityTween.value * 255).round()) ?? - Colors.transparent, - clipBehavior: Clip.antiAlias, - child: InkWell( - hoverColor: Colors.transparent, - splashColor: Colors.transparent, - focusColor: Colors.transparent, - highlightColor: Colors.transparent, - onTap: () { - if (widget.onDismiss != null) { - widget.onDismiss!(); - } - MatrixState.pAnyState.closeOverlay(); - }, - child: AnimatedBuilder( - animation: _blurTween, - builder: (context, _) { - return BackdropFilter( - filter: widget.blurBackground - ? ImageFilter.blur( - sigmaX: _blurTween.value, - sigmaY: _blurTween.value, - ) - : ImageFilter.blur(sigmaX: 0, sigmaY: 0), - child: Container( - height: double.infinity, - width: double.infinity, - color: Colors.transparent, - ), - ); - }, - ), - ), - // ), + static void showTutorialOverlay( + BuildContext context, + Widget overlayContent, + Rect anchorRect, { + double? borderRadius, + double? padding, + final VoidCallback? onClick, + final VoidCallback? onDismiss, + }) { + MatrixState.pAnyState.closeAllOverlays(); + final entry = OverlayEntry( + builder: (context) { + return AnchoredOverlayWidget( + anchorRect: anchorRect, + borderRadius: borderRadius, + padding: padding, + onClick: onClick, + onDismiss: onDismiss, + child: overlayContent, ); }, ); + MatrixState.pAnyState.openOverlay( + entry, + context, + rootOverlay: true, + ); } } diff --git a/lib/pangea/common/widgets/anchored_overlay_widget.dart b/lib/pangea/common/widgets/anchored_overlay_widget.dart new file mode 100644 index 000000000..177450f75 --- /dev/null +++ b/lib/pangea/common/widgets/anchored_overlay_widget.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/common/utils/cutout_painter.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class AnchoredOverlayWidget extends StatefulWidget { + final Widget child; + final Rect anchorRect; + final double? borderRadius; + final double? padding; + final VoidCallback? onClick; + final VoidCallback? onDismiss; + + const AnchoredOverlayWidget({ + required this.child, + required this.anchorRect, + this.borderRadius, + this.padding, + this.onClick, + this.onDismiss, + super.key, + }); + + @override + State createState() => _AnchoredOverlayWidgetState(); +} + +class _AnchoredOverlayWidgetState extends State { + bool _visible = false; + + static const double overlayWidth = 200.0; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => setState(() { + _visible = true; + }), + ); + } + + @override + Widget build(BuildContext context) { + final leftPosition = (widget.anchorRect.left + + (widget.anchorRect.width / 2) - + (overlayWidth / 2)) + .clamp(8.0, MediaQuery.sizeOf(context).width - overlayWidth - 8.0); + + return AnimatedOpacity( + opacity: _visible ? 1.0 : 0.0, + duration: FluffyThemes.animationDuration, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTapDown: (details) { + final tapPos = details.globalPosition; + if (widget.anchorRect.contains(tapPos)) { + widget.onClick?.call(); + } + + widget.onDismiss?.call(); + MatrixState.pAnyState.closeOverlay(); + }, + child: Stack( + children: [ + Positioned.fill( + child: CustomPaint( + painter: CutoutBackgroundPainter( + holeRect: widget.anchorRect, + backgroundColor: Colors.black54, + borderRadius: widget.borderRadius ?? 0.0, + padding: widget.padding ?? 6.0, + ), + ), + ), + Positioned( + left: leftPosition, + top: widget.anchorRect.bottom + (widget.padding ?? 6.0), + child: Material( + color: Colors.transparent, + elevation: 4, + child: SizedBox( + width: overlayWidth, + child: widget.child, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pangea/common/widgets/transparent_backdrop.dart b/lib/pangea/common/widgets/transparent_backdrop.dart new file mode 100644 index 000000000..2c9b22a80 --- /dev/null +++ b/lib/pangea/common/widgets/transparent_backdrop.dart @@ -0,0 +1,108 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import '../../../config/themes.dart'; +import '../../../widgets/matrix.dart'; + +class TransparentBackdrop extends StatefulWidget { + final Color? backgroundColor; + final VoidCallback? onDismiss; + final bool blurBackground; + + const TransparentBackdrop({ + super.key, + this.onDismiss, + this.backgroundColor, + this.blurBackground = false, + }); + + @override + TransparentBackdropState createState() => TransparentBackdropState(); +} + +class TransparentBackdropState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _opacityTween; + late Animation _blurTween; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: + const Duration(milliseconds: AppConfig.overlayAnimationDuration), + vsync: this, + ); + _opacityTween = Tween(begin: 0.0, end: 0.8).animate( + CurvedAnimation( + parent: _controller, + curve: FluffyThemes.animationCurve, + ), + ); + _blurTween = Tween(begin: 0.0, end: 3.0).animate( + CurvedAnimation( + parent: _controller, + curve: FluffyThemes.animationCurve, + ), + ); + + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) _controller.forward(); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _opacityTween, + builder: (context, _) { + return Material( + borderOnForeground: false, + color: widget.backgroundColor + ?.withAlpha((_opacityTween.value * 255).round()) ?? + Colors.transparent, + clipBehavior: Clip.antiAlias, + child: InkWell( + hoverColor: Colors.transparent, + splashColor: Colors.transparent, + focusColor: Colors.transparent, + highlightColor: Colors.transparent, + onTap: () { + if (widget.onDismiss != null) { + widget.onDismiss!(); + } + MatrixState.pAnyState.closeOverlay(); + }, + child: AnimatedBuilder( + animation: _blurTween, + builder: (context, _) { + return BackdropFilter( + filter: widget.blurBackground + ? ImageFilter.blur( + sigmaX: _blurTween.value, + sigmaY: _blurTween.value, + ) + : ImageFilter.blur(sigmaX: 0, sigmaY: 0), + child: Container( + height: double.infinity, + width: double.infinity, + color: Colors.transparent, + ), + ); + }, + ), + ), + ); + }, + ); + } +} diff --git a/lib/pangea/instructions/instructions_enum.dart b/lib/pangea/instructions/instructions_enum.dart index 0ad1a75a8..8f9d74890 100644 --- a/lib/pangea/instructions/instructions_enum.dart +++ b/lib/pangea/instructions/instructions_enum.dart @@ -29,6 +29,7 @@ enum InstructionsEnum { morphAnalyticsList, readingAssistanceOverview, emptyChatWarning, + activityStatsMenu, } extension InstructionsEnumExtension on InstructionsEnum { @@ -60,6 +61,7 @@ extension InstructionsEnumExtension on InstructionsEnum { case InstructionsEnum.analyticsVocabList: case InstructionsEnum.morphAnalyticsList: case InstructionsEnum.readingAssistanceOverview: + case InstructionsEnum.activityStatsMenu: ErrorHandler.logError( e: Exception("No title for this instruction"), m: 'InstructionsEnumExtension.title', @@ -74,21 +76,6 @@ extension InstructionsEnumExtension on InstructionsEnum { } } - // IconData? get icon { - // switch (this) { - // case InstructionsEnum.itInstructions: - // return Icons.translate; - // case InstructionsEnum.clickMessage: - // return Icons.touch_app; - // case InstructionsEnum.blurMeansTranslate: - // return Icons.blur_on; - // case InstructionsEnum.tooltipInstructions: - // return Icons.help; - // case InstructionsEnum.missingVoice: - // return Icons.mic_off; - // } - // } - String body(L10n l10n) { switch (this) { case InstructionsEnum.itInstructions: @@ -135,6 +122,8 @@ extension InstructionsEnumExtension on InstructionsEnum { return l10n.readingAssistanceOverviewBody; case InstructionsEnum.emptyChatWarning: return l10n.emptyChatWarningDesc; + case InstructionsEnum.activityStatsMenu: + return l10n.activityStatsButtonInstruction; } }