From 542afa0506cadc459297afbb72f841105e475096 Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Mon, 12 May 2025 14:28:33 -0400 Subject: [PATCH] feat: initial work for participant list leaderboard (#2579) * feat: initial work for participant list leaderboard * chore: normalize leaderboard header * chore: fix some errors after fluffychat merge updates --- assets/l10n/intl_en.arb | 3 +- lib/config/routes.dart | 6 +- lib/pages/chat/chat.dart | 7 + lib/pages/chat_list/chat_list.dart | 2 +- lib/pages/chat_list/space_view.dart | 11 + .../invitation_selection_view.dart | 4 +- .../utils/chat_list_handle_space_tap.dart | 2 +- .../pages/pangea_chat_details.dart | 5 +- .../events/utils/message_text_util.dart | 1 + .../spaces/pages/pangea_space_page.dart | 71 ++ .../spaces/pages/pangea_space_page_view.dart | 622 ++++++++++++++++++ .../spaces/utils/load_participants_util.dart | 115 ++++ .../widgets/space_view_leaderboard.dart | 96 +++ lib/widgets/avatar.dart | 4 +- 14 files changed, 934 insertions(+), 15 deletions(-) create mode 100644 lib/pangea/spaces/pages/pangea_space_page.dart create mode 100644 lib/pangea/spaces/pages/pangea_space_page_view.dart create mode 100644 lib/pangea/spaces/utils/load_participants_util.dart create mode 100644 lib/pangea/spaces/widgets/space_view_leaderboard.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 9135c4788..e0c3740fd 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4921,5 +4921,6 @@ "type": "String" } } - } + }, + "leaderboard": "Leaderboard" } diff --git a/lib/config/routes.dart b/lib/config/routes.dart index 7363cce27..b6616227c 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -245,13 +245,11 @@ abstract class AppRoutes { !state.uri.queryParameters.containsKey('spaceId') || spaceId == 'clear' || !FluffyThemes.isColumnMode(context) || - (state.fullPath?.contains('details') ?? true)) { + state.path == ':roomid') { return resp; } - return !FluffyThemes.isColumnMode(context) - ? resp - : '/rooms/$spaceId/details?spaceId=${spaceId ?? 'clear'}'; + return '/rooms/$spaceId?spaceId=${spaceId ?? 'clear'}'; }, // Pangea# pageBuilder: (context, state) => defaultPageBuilder( diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 23e213d09..85577e8e0 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -50,6 +50,7 @@ import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart' import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/instructions/instructions_enum.dart'; import 'package:fluffychat/pangea/learning_settings/widgets/p_language_dialog.dart'; +import 'package:fluffychat/pangea/spaces/pages/pangea_space_page.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; import 'package:fluffychat/utils/error_reporter.dart'; @@ -101,6 +102,12 @@ class ChatPage extends StatelessWidget { ); } + // #Pangea + if (room.isSpace) { + return PangeaSpacePage(space: room); + } + // Pangea# + return ChatPageWithRoom( key: Key('chat_page_${roomId}_$eventId'), room: room, diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index bbd599f46..3318c198b 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -120,7 +120,7 @@ class ChatListController extends State // #Pangea if (FluffyThemes.isColumnMode(context)) { - context.push("/rooms/$spaceId/details"); + context.push("/rooms/$spaceId"); } // Pangea# diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index ccddfddee..1500da23e 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -17,6 +17,7 @@ import 'package:fluffychat/pages/chat_list/search_title.dart'; import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/spaces/widgets/knocking_users_indicator.dart'; +import 'package:fluffychat/pangea/spaces/widgets/space_view_leaderboard.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/public_room_dialog.dart'; @@ -631,6 +632,16 @@ class _SpaceViewState extends State { final filter = _filterController.text.trim().toLowerCase(); return CustomScrollView( slivers: [ + // #Pangea + SliverList.builder( + itemCount: 1, + itemBuilder: (context, i) { + return SpaceViewLeaderboard( + space: room, + ); + }, + ), + // Pangea# SliverAppBar( floating: true, toolbarHeight: 72, diff --git a/lib/pages/invitation_selection/invitation_selection_view.dart b/lib/pages/invitation_selection/invitation_selection_view.dart index b3c54d3e0..ac27bf091 100644 --- a/lib/pages/invitation_selection/invitation_selection_view.dart +++ b/lib/pages/invitation_selection/invitation_selection_view.dart @@ -288,9 +288,7 @@ class InvitationSelectionView extends StatelessWidget { ), ], ), - onPressed: () => room.isSpace - ? context.push("/rooms/${room.id}/details") - : context.go("/rooms/${room.id}"), + onPressed: () => context.go("/rooms/${room.id}"), ), ), ], diff --git a/lib/pangea/chat_list/utils/chat_list_handle_space_tap.dart b/lib/pangea/chat_list/utils/chat_list_handle_space_tap.dart index 6f3b05613..7ee15eb63 100644 --- a/lib/pangea/chat_list/utils/chat_list_handle_space_tap.dart +++ b/lib/pangea/chat_list/utils/chat_list_handle_space_tap.dart @@ -21,7 +21,7 @@ void chatListHandleSpaceTap( controller.setActiveSpace(space.id); if (FluffyThemes.isColumnMode(context)) { - context.go('/rooms/${space.id}/details'); + context.go('/rooms/${space.id}'); } else if (controller.activeChat != null && !space.isFirstOrSecondChild(controller.activeChat!)) { context.go("/rooms"); diff --git a/lib/pangea/chat_settings/pages/pangea_chat_details.dart b/lib/pangea/chat_settings/pages/pangea_chat_details.dart index 49e22be3f..ac9e2480d 100644 --- a/lib/pangea/chat_settings/pages/pangea_chat_details.dart +++ b/lib/pangea/chat_settings/pages/pangea_chat_details.dart @@ -6,7 +6,6 @@ import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pages/chat_details/participant_list_item.dart'; import 'package:fluffychat/pangea/chat_settings/utils/download_chat.dart'; @@ -96,9 +95,7 @@ class PangeaChatDetailsView extends StatelessWidget { return Scaffold( appBar: AppBar( leading: controller.widget.embeddedCloseButton ?? - (room.isSpace && FluffyThemes.isColumnMode(context) - ? const SizedBox() - : const Center(child: BackButton())), + const Center(child: BackButton()), elevation: theme.appBarTheme.elevation, title: ClassNameHeader( controller: controller, diff --git a/lib/pangea/events/utils/message_text_util.dart b/lib/pangea/events/utils/message_text_util.dart index c306d6715..edc2c2544 100644 --- a/lib/pangea/events/utils/message_text_util.dart +++ b/lib/pangea/events/utils/message_text_util.dart @@ -155,6 +155,7 @@ class MessageTextUtil { } _tokenPositionsCache[pangeaMessageEvent.eventId] = tokenPositions; + return tokenPositions; } catch (err, s) { ErrorHandler.logError( diff --git a/lib/pangea/spaces/pages/pangea_space_page.dart b/lib/pangea/spaces/pages/pangea_space_page.dart new file mode 100644 index 000000000..0c86a5168 --- /dev/null +++ b/lib/pangea/spaces/pages/pangea_space_page.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/spaces/pages/pangea_space_page_view.dart'; +import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart'; + +class PangeaSpacePage extends StatefulWidget { + final Room space; + + const PangeaSpacePage({ + required this.space, + super.key, + }); + + @override + State createState() => PangeaSpacePageState(); +} + +class PangeaSpacePageState extends State { + bool expanded = true; + + final TextEditingController searchController = TextEditingController(); + final FocusNode searchFocusNode = FocusNode(); + + @override + void initState() { + super.initState(); + searchController.addListener(() { + if (mounted) { + setState(() {}); + } + }); + } + + @override + void dispose() { + searchController.dispose(); + searchFocusNode.dispose(); + super.dispose(); + } + + void startSearch() { + setState(() {}); + searchFocusNode.requestFocus(); + } + + void cancelSearch({bool unfocus = true}) { + setState(() { + searchController.clear(); + }); + if (unfocus) searchFocusNode.unfocus(); + } + + void toggleExpanded() { + setState(() { + expanded = !expanded; + }); + } + + @override + Widget build(BuildContext context) { + return LoadParticipantsUtil( + space: widget.space, + builder: (util) => PangeaSpacePageView( + this, + participantsLoader: util, + ), + ); + } +} diff --git a/lib/pangea/spaces/pages/pangea_space_page_view.dart b/lib/pangea/spaces/pages/pangea_space_page_view.dart new file mode 100644 index 000000000..913504007 --- /dev/null +++ b/lib/pangea/spaces/pages/pangea_space_page_view.dart @@ -0,0 +1,622 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:go_router/go_router.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/pages/chat_details/participant_list_item.dart'; +import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +import 'package:fluffychat/pangea/spaces/pages/pangea_space_page.dart'; +import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart'; +import 'package:fluffychat/utils/fluffy_share.dart'; +import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; +import 'package:fluffychat/utils/url_launcher.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/user_dialog.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/layouts/max_width_body.dart'; + +class PangeaSpacePageView extends StatelessWidget { + final PangeaSpacePageState controller; + final LoadParticipantsUtilState participantsLoader; + const PangeaSpacePageView( + this.controller, { + required this.participantsLoader, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final room = controller.widget.space; + + final displayname = room.getLocalizedDisplayname( + MatrixLocals(L10n.of(context)), + ); + + final filteredParticipants = participantsLoader + .filteredParticipants("") + .where((u) => u.id != BotName.byEnvironment) + .toList(); + + final bool showMedals = !participantsLoader.loading && + controller.searchController.text.isEmpty && + filteredParticipants.isNotEmpty; + + final Widget leaderboardHeader = ListTile( + tileColor: Color.lerp(AppConfig.gold, Colors.black, 0.3), + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0), + visualDensity: const VisualDensity(vertical: -4.0), + title: Text( + L10n.of(context).leaderboard, + style: Theme.of(context).textTheme.headlineSmall, + ), + trailing: Icon( + controller.expanded + ? Icons.keyboard_arrow_down_outlined + : Icons.keyboard_arrow_right_outlined, + ), + onTap: controller.toggleExpanded, + ); + + return LayoutBuilder( + builder: (context, constraints) { + return Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + elevation: theme.appBarTheme.elevation, + backgroundColor: theme.appBarTheme.backgroundColor, + actions: [ + IconButton( + icon: const Icon(Icons.settings_outlined), + onPressed: () => context.go( + '/rooms/${room.id}/details', + ), + ), + ], + shape: Border( + bottom: BorderSide( + color: theme.dividerColor, + ), + ), + ), + body: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: MaxWidthBody( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Padding( + padding: const EdgeInsets.all(32.0), + child: Avatar( + mxContent: room.avatar, + name: displayname, + size: Avatar.defaultSize * 2.5, + ), + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextButton.icon( + onPressed: () => FluffyShare.share( + displayname, + context, + copyOnly: true, + ), + icon: const Icon( + Icons.copy_outlined, + size: 16, + ), + style: TextButton.styleFrom( + foregroundColor: + theme.colorScheme.onSurface, + ), + label: Text( + displayname, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 18), + ), + ), + Row( + spacing: 8.0, + children: [ + TextButton.icon( + onPressed: () => context.push( + '/rooms/${room.id}/details/members', + ), + icon: const Icon( + Icons.group_outlined, + size: 14, + ), + style: TextButton.styleFrom( + foregroundColor: + theme.colorScheme.secondary, + ), + label: Text( + L10n.of(context).countParticipants( + (room.summary.mInvitedMemberCount ?? + 0) + + (room.summary + .mJoinedMemberCount ?? + 0), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + TextButton.icon( + onPressed: () => context.push( + '/rooms/${room.id}/details/invite', + ), + icon: const Icon( + Icons.group_add_outlined, + size: 14, + ), + style: TextButton.styleFrom( + foregroundColor: + theme.colorScheme.secondary, + ), + label: Text( + L10n.of(context).invite, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + ], + ), + Divider(color: theme.dividerColor, height: 1), + Padding( + padding: const EdgeInsets.only( + left: 24.0, + right: 24.0, + top: 16.0, + bottom: 16.0, + ), + child: SelectableLinkify( + text: room.topic.isEmpty + ? room.isSpace + ? L10n.of(context).noSpaceDescriptionYet + : L10n.of(context).noChatDescriptionYet + : room.topic, + options: const LinkifyOptions(humanize: false), + linkStyle: const TextStyle( + color: Colors.blueAccent, + decorationColor: Colors.blueAccent, + ), + style: TextStyle( + fontSize: 14, + fontStyle: room.topic.isEmpty + ? FontStyle.italic + : FontStyle.normal, + color: theme.textTheme.bodyMedium!.color, + decorationColor: theme.textTheme.bodyMedium!.color, + ), + onOpen: (url) => + UrlLauncher(context, url.url).launchUrl(), + ), + ), + if (constraints.maxWidth <= 800) leaderboardHeader, + if (constraints.maxWidth <= 800 && controller.expanded) + Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + spacing: 16.0, + children: [ + SizedBox( + width: 200.0, + child: LeaderboardMedals( + isVisible: showMedals, + participants: filteredParticipants, + smallRadius: Avatar.defaultSize * 0.7, + largeRadius: Avatar.defaultSize, + ), + ), + if (filteredParticipants.isNotEmpty) + Expanded( + child: Column( + children: filteredParticipants + .take(3) + .mapIndexed((i, user) { + return TrophyParticipantListItem( + index: i, + user: user, + ); + }).toList(), + ), + ), + ], + ), + ), + ], + ), + ), + ), + if (constraints.maxWidth > 800) + Container( + decoration: BoxDecoration( + border: Border( + left: BorderSide( + color: theme.dividerColor, + width: 1.0, + ), + ), + ), + width: 350.0, + child: Column( + spacing: 16.0, + children: [ + leaderboardHeader, + if (controller.expanded) + Expanded( + child: Column( + children: [ + LeaderboardMedals( + isVisible: showMedals, + participants: filteredParticipants, + padding: EdgeInsets.only( + top: showMedals ? 16.0 : 0, + left: showMedals ? 42.0 : 0, + right: showMedals ? 42.0 : 0, + ), + ), + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + ), + child: participantsLoader.loading + ? const CircularProgressIndicator + .adaptive() + : Text( + L10n.of(context) + .countParticipants( + participantsLoader + .participants.length, + ), + ), + ), + IconButton( + icon: const Icon( + Icons.group_add_outlined, + ), + iconSize: 20.0, + onPressed: () => context.push( + '/rooms/${room.id}/details/members', + ), + ), + ], + ), + TextField( + controller: controller.searchController, + focusNode: controller.searchFocusNode, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + filled: true, + fillColor: theme + .colorScheme.secondaryContainer, + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: + BorderRadius.circular(99), + ), + contentPadding: EdgeInsets.zero, + hintText: L10n.of(context).search, + hintStyle: TextStyle( + color: theme + .colorScheme.onPrimaryContainer, + fontWeight: FontWeight.normal, + ), + prefixIcon: controller.searchController + .text.isNotEmpty + ? IconButton( + tooltip: + L10n.of(context).cancel, + icon: const Icon( + Icons.close_outlined, + ), + onPressed: + controller.cancelSearch, + color: theme.colorScheme + .onPrimaryContainer, + ) + : IconButton( + onPressed: + controller.startSearch, + icon: Icon( + Icons.search_outlined, + color: theme.colorScheme + .onPrimaryContainer, + ), + ), + ), + ), + ], + ), + ), + Expanded( + child: Builder( + builder: (context) { + if (participantsLoader.loading) { + return const Column( + children: [ + CircularProgressIndicator.adaptive(), + ], + ); + } + + if (participantsLoader.error != null) { + return Text( + L10n.of(context).oopsSomethingWentWrong, + style: TextStyle( + color: theme.colorScheme.error, + ), + ); + } + + return ListView.builder( + itemCount: filteredParticipants.length, + itemBuilder: (context, index) { + return TrophyParticipantListItem( + index: index, + user: filteredParticipants[index], + ); + }, + ); + }, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + }, + ); + } +} + +class LeaderboardMedal extends StatelessWidget { + final User user; + final Color color; + final double radius; + final double iconSize; + final double iconRadius; + + final double? top; + final double? left; + final double? right; + final double? bottom; + + const LeaderboardMedal( + this.user, { + required this.color, + required this.radius, + required this.iconSize, + required this.iconRadius, + this.top, + this.left, + this.right, + this.bottom, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Positioned( + top: top, + left: left, + right: right, + bottom: bottom != null ? bottom! + 10.0 : null, + child: CircleAvatar( + radius: radius + 3.0, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: const Alignment(0.5, -0.5), + end: const Alignment(-0.5, 0.5), + colors: [ + color, + Colors.white, + color, + ], + ), + ), + ), + ), + ), + Positioned( + top: top != null ? 3.0 : null, + left: left != null ? 3.0 : null, + right: right != null ? 3.0 : null, + bottom: bottom != null ? bottom! + 10.0 + 3.0 : null, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => UserDialog.show( + context: context, + profile: Profile( + userId: user.id, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + ), + ), + child: Center( + child: Avatar( + mxContent: user.avatarUrl, + name: user.calcDisplayname(), + size: radius * 2, + presenceUserId: user.id, + showPresence: false, + ), + ), + ), + ), + ), + Positioned( + top: top != null ? ((radius + 3.0) * 2) - iconRadius : null, + left: left != null ? radius + 3.0 - iconRadius : null, + right: right != null ? radius + 3.0 - iconRadius : null, + bottom: bottom, + child: CircleAvatar( + backgroundColor: color, + radius: iconRadius, + child: Icon( + Symbols.trophy, + color: Colors.white, + size: iconSize, + ), + ), + ), + ], + ); + } +} + +class LeaderboardMedals extends StatelessWidget { + final bool isVisible; + final List participants; + final EdgeInsets? padding; + + final double? largeRadius; + final double? smallRadius; + + const LeaderboardMedals({ + super.key, + required this.isVisible, + required this.participants, + this.largeRadius, + this.smallRadius, + this.padding, + }); + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: FluffyThemes.animationDuration, + height: isVisible ? Avatar.defaultSize * 3.5 : 0.0, + // padding: EdgeInsets.only( + // top: isVisible ? 16.0 : 0, + // left: isVisible ? 42.0 : 0, + // right: isVisible ? 42.0 : 0, + // ), + padding: padding, + child: !isVisible + ? const SizedBox.shrink() + : Stack( + children: [ + if (participants.length > 1) + LeaderboardMedal( + participants[1], + color: Colors.grey[400]!, + radius: smallRadius ?? Avatar.defaultSize * 0.75, + iconSize: 16.0, + iconRadius: 10.0, + bottom: 0.0, + left: 0.0, + ), + if (participants.isNotEmpty) + LeaderboardMedal( + participants[0], + color: AppConfig.gold, + radius: largeRadius ?? Avatar.defaultSize * 1.25, + iconSize: 20.0, + iconRadius: 16.0, + top: 0.0, + right: 0.0, + left: 0.0, + ), + if (participants.length > 2) + LeaderboardMedal( + participants[2], + color: Colors.brown[400]!, + radius: smallRadius ?? Avatar.defaultSize * 0.75, + bottom: 0.0, + right: 0.0, + iconSize: 16.0, + iconRadius: 10.0, + ), + ], + ), + ); + } +} + +class TrophyParticipantListItem extends StatelessWidget { + final int index; + final User user; + + const TrophyParticipantListItem({ + required this.index, + required this.user, + super.key, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => UserDialog.show( + context: context, + profile: Profile( + userId: user.id, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + ), + ), + child: Row( + children: [ + Container( + alignment: Alignment.centerRight, + width: 32.0, + child: (index < 3) + ? Icon( + Symbols.trophy, + color: index == 0 + ? AppConfig.gold + : index == 1 + ? Colors.grey[400] + : index == 2 + ? Colors.brown[400] + : null, + ) + : null, + ), + Expanded( + child: AbsorbPointer( + child: ParticipantListItem(user), + ), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/spaces/utils/load_participants_util.dart b/lib/pangea/spaces/utils/load_participants_util.dart new file mode 100644 index 000000000..f4a5037ce --- /dev/null +++ b/lib/pangea/spaces/utils/load_participants_util.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/user/models/profile_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class LoadParticipantsUtil extends StatefulWidget { + final Room space; + final Widget Function(LoadParticipantsUtilState) builder; + + const LoadParticipantsUtil({ + required this.space, + required this.builder, + super.key, + }); + + @override + State createState() => LoadParticipantsUtilState(); +} + +class LoadParticipantsUtilState extends State { + bool loading = true; + String? error; + + final Map _levelsCache = {}; + + List get participants => widget.space.getParticipants(); + + @override + void initState() { + super.initState(); + _loadParticipants(); + } + + @override + void didUpdateWidget(LoadParticipantsUtil oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.space != widget.space) { + _loadParticipants(); + } + } + + Future _loadParticipants() async { + try { + setState(() { + loading = true; + error = null; + }); + + await widget.space.requestParticipants( + [Membership.join, Membership.invite, Membership.knock], + false, + true, + ); + + await _cacheLevels(); + } catch (err, s) { + error = err.toString(); + ErrorHandler.logError( + e: err, + s: s, + data: { + 'spaceId': widget.space.id, + }, + ); + } finally { + if (mounted) { + setState(() => loading = false); + } + } + } + + List filteredParticipants(String filter) { + final searchText = filter.toLowerCase(); + final filtered = participants.where((user) { + final displayName = user.displayName?.toLowerCase() ?? ''; + return displayName.contains(searchText) || + user.id.toLowerCase().contains(searchText); + }).toList(); + + filtered.sort((a, b) { + if (a.id == BotName.byEnvironment) { + return 1; + } + if (b.id == BotName.byEnvironment) { + return -1; + } + + final PublicProfileModel? aProfile = _levelsCache[a.id]; + final PublicProfileModel? bProfile = _levelsCache[b.id]; + + return (bProfile?.level ?? 0).compareTo(aProfile?.level ?? 0); + }); + + return filtered; + } + + Future _cacheLevels() async { + for (final user in participants) { + if (_levelsCache[user.id] == null) { + _levelsCache[user.id] = await MatrixState + .pangeaController.userController + .getPublicProfile(user.id); + } + } + } + + @override + Widget build(BuildContext context) { + return widget.builder(this); + } +} diff --git a/lib/pangea/spaces/widgets/space_view_leaderboard.dart b/lib/pangea/spaces/widgets/space_view_leaderboard.dart new file mode 100644 index 000000000..13fc81fbc --- /dev/null +++ b/lib/pangea/spaces/widgets/space_view_leaderboard.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +import 'package:fluffychat/pangea/spaces/pages/pangea_space_page_view.dart'; +import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart'; +import 'package:fluffychat/widgets/avatar.dart'; + +class SpaceViewLeaderboard extends StatefulWidget { + final Room space; + + const SpaceViewLeaderboard({ + required this.space, + super.key, + }); + + @override + State createState() => SpaceViewLeaderboardState(); +} + +class SpaceViewLeaderboardState extends State { + bool _expanded = true; + + void _toggleExpanded() { + setState(() => _expanded = !_expanded); + } + + @override + Widget build(BuildContext context) { + if (FluffyThemes.isColumnMode(context)) { + return const SizedBox.shrink(); + } + + return LoadParticipantsUtil( + space: widget.space, + builder: (participantsLoader) { + final filteredParticipants = participantsLoader + .filteredParticipants("") + .where((u) => u.id != BotName.byEnvironment) + .toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 16.0, + children: [ + ListTile( + tileColor: Color.lerp(AppConfig.gold, Colors.black, 0.3), + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0), + visualDensity: const VisualDensity(vertical: -4.0), + title: Text( + L10n.of(context).leaderboard, + style: Theme.of(context).textTheme.headlineSmall, + ), + trailing: Icon( + _expanded + ? Icons.keyboard_arrow_down_outlined + : Icons.keyboard_arrow_right_outlined, + ), + onTap: _toggleExpanded, + ), + if (_expanded) + Column( + children: [ + SizedBox( + width: 225.0, + child: LeaderboardMedals( + isVisible: !participantsLoader.loading && + filteredParticipants.isNotEmpty, + participants: filteredParticipants, + smallRadius: Avatar.defaultSize * 0.7, + largeRadius: Avatar.defaultSize, + ), + ), + Column( + children: filteredParticipants.take(3).mapIndexed( + (index, user) { + return TrophyParticipantListItem( + index: index, + user: filteredParticipants[index], + ); + }, + ).toList(), + ), + ], + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/avatar.dart b/lib/widgets/avatar.dart index bf2f567eb..b850b40e9 100644 --- a/lib/widgets/avatar.dart +++ b/lib/widgets/avatar.dart @@ -22,6 +22,7 @@ class Avatar extends StatelessWidget { final BorderSide? border; // #Pangea final bool useRive; + final bool showPresence; final String? userId; // Pangea# @@ -38,6 +39,7 @@ class Avatar extends StatelessWidget { this.icon, // #Pangea this.useRive = false, + this.showPresence = true, this.userId, // Pangea# super.key, @@ -117,7 +119,7 @@ class Avatar extends StatelessWidget { ), // #Pangea // if (presenceUserId != null) - if (presenceUserId != null && size >= 32.0) + if (presenceUserId != null && size >= 32.0 && showPresence) // Pangea# PresenceBuilder( client: client,