feat: show instruction overlay on first gain points in activity pointing user to click activity stats button (#4099)

This commit is contained in:
ggurdin 2025-09-23 10:55:53 -04:00 committed by GitHub
parent c8e67a5c89
commit e10f2bebeb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 388 additions and 158 deletions

View file

@ -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"
}

View file

@ -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<ActivityStatsButton> {
_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<ActivityStatsButton> {
}
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<void> _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",
),
],
),
],
),
),
);

View file

@ -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]) {

View file

@ -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;
}

View file

@ -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<TransparentBackdrop>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _opacityTween;
late Animation<double> _blurTween;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration:
const Duration(milliseconds: AppConfig.overlayAnimationDuration),
vsync: this,
);
_opacityTween = Tween<double>(begin: 0.0, end: 0.8).animate(
CurvedAnimation(
parent: _controller,
curve: FluffyThemes.animationCurve,
),
);
_blurTween = Tween<double>(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,
);
}
}

View file

@ -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<AnchoredOverlayWidget> createState() => _AnchoredOverlayWidgetState();
}
class _AnchoredOverlayWidgetState extends State<AnchoredOverlayWidget> {
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,
),
),
),
],
),
),
),
);
}
}

View file

@ -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<TransparentBackdrop>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _opacityTween;
late Animation<double> _blurTween;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration:
const Duration(milliseconds: AppConfig.overlayAnimationDuration),
vsync: this,
);
_opacityTween = Tween<double>(begin: 0.0, end: 0.8).animate(
CurvedAnimation(
parent: _controller,
curve: FluffyThemes.animationCurve,
),
);
_blurTween = Tween<double>(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,
),
);
},
),
),
);
},
);
}
}

View file

@ -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;
}
}