refactor: replace activity stats menu with smaller button

This commit is contained in:
ggurdin 2025-11-05 13:42:44 -05:00
parent 8d69de4586
commit ff21ab8608
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
9 changed files with 204 additions and 316 deletions

View file

@ -5317,5 +5317,6 @@
"feedbackDialogDesc": "I make mistakes too! Anything to help me improve?",
"getStartedFriendsButton": "Invite a friend",
"contactHasBeenInvitedToTheCourse": "Contact has been invited to the course",
"inviteFriends": "Invite friends"
"inviteFriends": "Invite friends",
"activityStatsButtonTooltip": "Activity info"
}

View file

@ -2285,8 +2285,8 @@ class ChatController extends State<ChatPageWithRoom>
}
bool showActivityDropdown = false;
void setShowDropdown(bool show) async {
setState(() => showActivityDropdown = show);
void toggleShowDropdown() async {
setState(() => showActivityDropdown = !showActivityDropdown);
}
bool hasRainedConfetti = false;

View file

@ -6,8 +6,6 @@ import 'package:matrix/matrix.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_sessions/activity_session_chat/activity_stats_button.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/sync_status_localization.dart';
@ -30,23 +28,6 @@ class ChatAppBarTitle extends StatelessWidget {
// ),
// );
// }
if (controller.room.showActivityChatUI) {
return Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
spacing: 4.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
controller.room.getLocalizedDisplayname(),
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
ActivityStatsButton(controller: controller),
],
),
);
}
// Pangea#
return InkWell(
hoverColor: Colors.transparent,

View file

@ -15,6 +15,7 @@ import 'package:fluffychat/pages/chat/chat_event_list.dart';
import 'package:fluffychat/pages/chat/pinned_events.dart';
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_menu_button.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';
@ -147,6 +148,8 @@ class ChatView extends StatelessWidget {
context.go('/rooms/${controller.room.id}/search');
},
),
if (controller.room.showActivityChatUI)
ActivityMenuButton(controller: controller),
IconButton(
icon: const Icon(Icons.settings_outlined),
tooltip: L10n.of(context).chatDetails,
@ -216,9 +219,6 @@ class ChatView extends StatelessWidget {
// backgroundColor: controller.selectedEvents.isEmpty
// ? null
// : theme.colorScheme.tertiaryContainer,
toolbarHeight:
controller.room.showActivityChatUI ? 106.0 : null,
centerTitle: controller.room.showActivityChatUI,
// Pangea#
automaticallyImplyLeading: false,
leading: controller.selectMode

View file

@ -0,0 +1,36 @@
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
extension ActivityMenuLogic on ChatController {
bool get shouldShowActivityInstructions {
if (InstructionsEnum.activityStatsMenu.isToggledOff ||
MatrixState.pAnyState.isOverlayOpen(RegExp(r"^word-zoom-card-.*$")) ||
timeline == null) {
return false;
}
final userID = Matrix.of(context).client.userID!;
final activityRoles = room.activityRoles?.roles.values ?? [];
final finishedRoles = activityRoles.where((r) => r.isFinished);
if (finishedRoles.isNotEmpty) {
return !finishedRoles.any((r) => r.userId == userID);
}
final count = timeline!.events
.where(
(event) =>
event.senderId == userID &&
event.type == EventTypes.Message &&
{MessageTypes.Text, MessageTypes.Audio}
.contains(event.messageType),
)
.length;
return count >= 3;
}
}

View file

@ -0,0 +1,111 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_session_chat/activity_chat_extension.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/common/widgets/tutorial_overlay_message.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
class ActivityMenuButton extends StatefulWidget {
final ChatController controller;
const ActivityMenuButton({
super.key,
required this.controller,
});
@override
State<ActivityMenuButton> createState() => _ActivityMenuButtonState();
}
class _ActivityMenuButtonState extends State<ActivityMenuButton> {
bool _showShimmer = false;
StreamSubscription? _rolesSubscription;
StreamSubscription? _analyticsSubscription;
@override
void initState() {
super.initState();
_analyticsSubscription = widget
.controller.pangeaController.getAnalytics.analyticsStream.stream
.listen(_showStatsMenuDropdownInstructions);
_rolesSubscription = widget.controller.room.client.onRoomState.stream
.where(
(u) =>
u.roomId == widget.controller.room.id &&
u.state.type == PangeaEventTypes.activityRole,
)
.listen(_showStatsMenuDropdownInstructions);
}
@override
void dispose() {
_analyticsSubscription?.cancel();
_rolesSubscription?.cancel();
super.dispose();
}
/// Show a tutorial overlay that blocks the screen and points
/// to the stats menu button with an explanation of what it does.
void _showStatsMenuDropdownInstructions(_) {
if (!mounted) return;
if (!widget.controller.shouldShowActivityInstructions) {
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,
overlayContent: TutorialOverlayMessage(
L10n.of(context).activityStatsButtonInstruction,
),
overlayKey: "activity_stats_menu_instruction",
anchorRect: cellRect,
borderRadius: 12.0,
padding: 8.0,
onClick: () {
setState(() => _showShimmer = false);
InstructionsEnum.activityStatsMenu.setToggledOff(true);
widget.controller.toggleShowDropdown();
},
);
setState(() => _showShimmer = true);
}
@override
Widget build(BuildContext context) {
final content = IconButton(
icon: const Icon(Icons.radar_outlined),
tooltip: L10n.of(context).activityStatsButtonTooltip,
onPressed: widget.controller.toggleShowDropdown,
);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: _showShimmer
? Shimmer.fromColors(
enabled: _showShimmer,
baseColor: Theme.of(context).iconTheme.color!,
highlightColor: AppConfig.gold,
child: content,
)
: content,
);
}
}

View file

@ -1,288 +0,0 @@
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/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/common/widgets/pressable_button.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
class ActivityStatsButton extends StatefulWidget {
final ChatController controller;
const ActivityStatsButton({
super.key,
required this.controller,
});
@override
State<ActivityStatsButton> createState() => _ActivityStatsButtonState();
}
class _ActivityStatsButtonState extends State<ActivityStatsButton> {
StreamSubscription? _analyticsSubscription;
StreamSubscription? _rolesSubscription;
ActivitySummaryAnalyticsModel? analytics;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) => _updateAnalytics(),
);
_analyticsSubscription = widget
.controller.pangeaController.getAnalytics.analyticsStream.stream
.listen((_) => _updateAnalytics());
_rolesSubscription = widget.controller.room.client.onRoomState.stream
.where(
(u) =>
u.roomId == widget.controller.room.id &&
u.state.type == PangeaEventTypes.activityRole,
)
.listen((_) {
_showStatsMenuDropdownInstructions();
});
}
@override
void dispose() {
_analyticsSubscription?.cancel();
_rolesSubscription?.cancel();
super.dispose();
}
Client get _client => widget.controller.room.client;
bool get _shouldShowInstructions {
if (InstructionsEnum.activityStatsMenu.isToggledOff ||
MatrixState.pAnyState.isOverlayOpen(
RegExp(r"^word-zoom-card-.*$"),
) ||
widget.controller.timeline == null) {
return false;
}
// if someone has finished the activity, enable the tooltip
final activityRoles =
widget.controller.room.activityRoles?.roles.values.toList() ?? [];
final finishedRoles = activityRoles.where((r) => r.isFinished).toList();
if (finishedRoles.isNotEmpty) {
return !finishedRoles.any((r) => r.userId == _client.userID);
}
// otherwise, if no one has finished, only show if the user has sent >= 3 messages
int count = 0;
for (final event in widget.controller.timeline!.events) {
if (event.senderId == _client.userID &&
event.type == EventTypes.Message &&
[
MessageTypes.Text,
MessageTypes.Audio,
].contains(event.messageType)) {
count++;
}
if (count >= 3) return true;
}
return false;
}
int get _xpCount =>
analytics?.totalXPForUser(
_client.userID!,
) ??
0;
int? get _vocabCount => analytics?.uniqueConstructCountForUser(
_client.userID!,
ConstructTypeEnum.vocab,
);
int? get _grammarCount => analytics?.uniqueConstructCountForUser(
_client.userID!,
ConstructTypeEnum.morph,
);
/// Show a tutorial overlay that blocks the screen and points
/// to the stats menu button with an explanation of what it does.
void _showStatsMenuDropdownInstructions() {
if (!_shouldShowInstructions) {
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,
overlayContent: 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,
),
),
),
overlayKey: "activity_stats_menu_instruction",
anchorRect: cellRect,
borderRadius: 12.0,
padding: 8.0,
onClick: () {
InstructionsEnum.activityStatsMenu.setToggledOff(true);
widget.controller.setShowDropdown(true);
},
);
}
Future<void> _updateAnalytics() async {
final analytics = await widget.controller.room.getActivityAnalytics();
if (mounted) {
setState(() => this.analytics = analytics);
_showStatsMenuDropdownInstructions();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final roleState =
widget.controller.room.activityRoles?.roles.values.toList() ?? [];
final enabled = _xpCount > 0 || roleState.any((r) => r.isFinished);
return PressableButton(
onPressed: () => widget.controller.setShowDropdown(
!widget.controller.showActivityDropdown,
),
borderRadius: BorderRadius.circular(12),
color: enabled
? Color.alphaBlend(
Theme.of(context).colorScheme.surface.withAlpha(70),
AppConfig.gold,
)
: theme.colorScheme.surface,
depressed: !enabled || widget.controller.showActivityDropdown,
child: AnimatedContainer(
duration: FluffyThemes.animationDuration,
width: 280,
height: 40,
decoration: BoxDecoration(
color: enabled
? Color.alphaBlend(
Theme.of(context).colorScheme.surface.withAlpha(70),
AppConfig.gold,
)
: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
),
child: analytics == null
? const CircularProgressIndicator.adaptive()
: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_StatsBadge(
icon: Icons.star,
value: "$_xpCount XP",
),
_StatsBadge(
icon: Symbols.dictionary,
value: "$_vocabCount",
),
_StatsBadge(
icon: Symbols.toys_and_games,
value: "$_grammarCount",
),
],
),
),
);
}
}
class _StatsBadge extends StatelessWidget {
final IconData icon;
final String value;
const _StatsBadge({
required this.icon,
required this.value,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final baseStyle = theme.textTheme.bodyMedium;
final double fontSize = FluffyThemes.isColumnMode(context) ? 18 : 14;
final double iconSize = FluffyThemes.isColumnMode(context) ? 22 : 18;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: iconSize,
color: theme.colorScheme.onSurface,
),
const SizedBox(width: 4),
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
value,
style: baseStyle?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
fontSize: fontSize,
),
),
],
),
],
);
}
}

View file

@ -98,7 +98,7 @@ class ActivityStatsMenuState extends State<ActivityStatsMenu> {
? await room.finishActivityForAll()
: await room.finishActivity();
if (mounted) {
widget.controller.setShowDropdown(false);
widget.controller.toggleShowDropdown();
}
},
);
@ -150,7 +150,7 @@ class ActivityStatsMenuState extends State<ActivityStatsMenu> {
child: GestureDetector(
onPanUpdate: (details) {
if (details.delta.dy < -2) {
widget.controller.setShowDropdown(false);
widget.controller.toggleShowDropdown();
}
},
child: Container(
@ -263,7 +263,7 @@ class ActivityStatsMenuState extends State<ActivityStatsMenu> {
if (widget.controller.showActivityDropdown)
Expanded(
child: GestureDetector(
onTap: () => widget.controller.setShowDropdown(false),
onTap: widget.controller.toggleShowDropdown,
child: Container(color: Colors.black.withAlpha(100)),
),
),

View file

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
class TutorialOverlayMessage extends StatelessWidget {
final String message;
const TutorialOverlayMessage(
this.message, {
super.key,
});
@override
Widget build(BuildContext context) {
return 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: message,
),
],
),
textAlign: TextAlign.center,
),
),
);
}
}