diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index e4ebbbed4..83af130c2 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4956,5 +4956,9 @@ "changeTheDescription": "Change the description", "changeThePermissions": "Change the permissions", "introductions": "Introductions", - "announcements": "Announcements" + "announcements": "Announcements", + "activities": "Activities", + "access": "Access", + "addSubspace": "Add subspace", + "botSettings": "Bot settings" } diff --git a/lib/config/routes.dart b/lib/config/routes.dart index e1d6bd72a..0e9c0a78a 100644 --- a/lib/config/routes.dart +++ b/lib/config/routes.dart @@ -227,27 +227,19 @@ abstract class AppRoutes { : null; if (room != null && room.isSpace) { - // If a user is on mobile and they end up on the space - // page, redirect them and set the activeSpaceId - if (!isColumnMode && + if (isColumnMode && (state.fullPath?.endsWith(':roomid') ?? false)) { - return '/rooms?spaceId=${room.id}'; + return '/rooms/${room.id}/details?spaceId=${room.id}'; } } - if (state.uri.queryParameters.containsKey('spaceId')) { - final spaceId = state.uri.queryParameters['spaceId']; - if (spaceId == null || spaceId == 'clear') { - // Have to load chat list to clear the spaceId, so don't redirect - return null; - } - - // If spaceId is not null, and on web, and not on the space page, - // redirect to the space page - if (isColumnMode && - !(state.fullPath?.endsWith(':roomid') ?? false)) { - return '/rooms/$spaceId?spaceId=$spaceId'; - } + final spaceId = state.uri.queryParameters['spaceId']; + if (spaceId != null && + spaceId != 'clear' && + isColumnMode && + state.fullPath != null && + !state.fullPath!.contains('details')) { + return '/rooms/$spaceId/details?spaceId=$spaceId'; } return null; @@ -595,30 +587,6 @@ abstract class AppRoutes { redirect: loggedOutRedirect, ), // #Pangea - GoRoute( - path: 'planner', - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - ActivityPlannerPage( - roomID: state.pathParameters['roomid']!, - ), - ), - redirect: loggedOutRedirect, - routes: [ - GoRoute( - path: '/generator', - redirect: loggedOutRedirect, - pageBuilder: (context, state) => defaultPageBuilder( - context, - state, - ActivityGenerator( - roomID: state.pathParameters['roomid']!, - ), - ), - ), - ], - ), // GoRoute( // path: 'encryption', // pageBuilder: (context, state) => defaultPageBuilder( @@ -650,6 +618,32 @@ abstract class AppRoutes { ), ), routes: [ + // #Pangea + GoRoute( + path: 'planner', + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ActivityPlannerPage( + roomID: state.pathParameters['roomid']!, + ), + ), + redirect: loggedOutRedirect, + routes: [ + GoRoute( + path: '/generator', + redirect: loggedOutRedirect, + pageBuilder: (context, state) => defaultPageBuilder( + context, + state, + ActivityGenerator( + roomID: state.pathParameters['roomid']!, + ), + ), + ), + ], + ), + // Pangea# GoRoute( path: 'access', pageBuilder: (context, state) => defaultPageBuilder( diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 23c27b682..88785cfa8 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -50,7 +50,6 @@ 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'; @@ -88,6 +87,16 @@ class ChatPage extends StatelessWidget { Widget build(BuildContext context) { final room = Matrix.of(context).client.getRoomById(roomId); // #Pangea + + if (room?.isSpace ?? false) { + ErrorHandler.logError( + e: "Space chat opened", + s: StackTrace.current, + data: {"roomId": roomId}, + ); + context.go("/rooms"); + } + if (room == null || room.membership == Membership.leave) { // if (room == null) { // Pangea# @@ -102,12 +111,6 @@ 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/chat_view.dart b/lib/pages/chat/chat_view.dart index 1a366e6da..7a5f79220 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -13,7 +13,6 @@ import 'package:fluffychat/pages/chat/chat_app_bar_list_tile.dart'; import 'package:fluffychat/pages/chat/chat_app_bar_title.dart'; import 'package:fluffychat/pages/chat/chat_event_list.dart'; import 'package:fluffychat/pages/chat/pinned_events.dart'; -import 'package:fluffychat/pangea/activity_planner/activity_plan_page_launch_icon_button.dart'; import 'package:fluffychat/pangea/chat/widgets/chat_input_bar.dart'; import 'package:fluffychat/pangea/chat/widgets/chat_input_bar_header.dart'; import 'package:fluffychat/pangea/chat/widgets/chat_view_background.dart'; @@ -135,7 +134,6 @@ class ChatView extends StatelessWidget { context.go('/rooms/${controller.room.id}/search'); }, ), - ActivityPlanPageLaunchIconButton(controller: controller), IconButton( icon: const Icon(Icons.settings_outlined), tooltip: L10n.of(context).chatDetails, diff --git a/lib/pages/chat_details/chat_details.dart b/lib/pages/chat_details/chat_details.dart index dca2f7db3..5846241c5 100644 --- a/lib/pages/chat_details/chat_details.dart +++ b/lib/pages/chat_details/chat_details.dart @@ -1,3 +1,6 @@ +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; @@ -7,7 +10,13 @@ import 'package:matrix/matrix.dart' as sdk; import 'package:matrix/matrix.dart'; import 'package:fluffychat/pages/settings/settings.dart'; +import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart'; import 'package:fluffychat/pangea/chat_settings/pages/pangea_chat_details.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/download_chat.dart'; +import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart'; +import 'package:fluffychat/pangea/common/utils/error_handler.dart'; +import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/spaces/utils/set_class_name.dart'; import 'package:fluffychat/utils/file_selector.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -264,9 +273,138 @@ class ChatDetailsController extends State { if (mounted) setState(() {}); } - @override - void dispose() { - super.dispose(); + void downloadChatAction() async { + if (roomId == null) return; + final Room? room = Matrix.of(context).client.getRoomById(roomId!); + if (room == null) return; + + final type = await showModalActionPopup( + context: context, + title: L10n.of(context).downloadGroupText, + actions: [ + AdaptiveModalAction( + value: DownloadType.csv, + label: L10n.of(context).downloadCSVFile, + ), + AdaptiveModalAction( + value: DownloadType.txt, + label: L10n.of(context).downloadTxtFile, + ), + AdaptiveModalAction( + value: DownloadType.xlsx, + label: L10n.of(context).downloadXLSXFile, + ), + ], + ); + if (type == null) return; + downloadChat(room, type, context); + } + + Future setBotOptions(BotOptionsModel botOptions) async { + if (roomId == null) return; + final Room? room = Matrix.of(context).client.getRoomById(roomId!); + if (room == null) return; + + try { + await Matrix.of(context).client.setRoomStateWithKey( + room.id, + PangeaEventTypes.botOptions, + '', + botOptions.toJson(), + ); + } catch (err, stack) { + debugger(when: kDebugMode); + ErrorHandler.logError( + e: err, + s: stack, + data: { + "botOptions": botOptions.toJson(), + "roomID": room.id, + }, + ); + } + } + + Future setRoomCapacity() async { + if (roomId == null) return; + final Room? room = Matrix.of(context).client.getRoomById(roomId!); + if (room == null) return; + + final input = await showTextInputDialog( + context: context, + title: L10n.of(context).chatCapacity, + message: L10n.of(context).chatCapacityExplanation, + okLabel: L10n.of(context).ok, + cancelLabel: L10n.of(context).cancel, + initialText: ((room.capacity != null) ? '${room.capacity}' : ''), + keyboardType: TextInputType.number, + maxLength: 3, + validator: (value) { + if (value.isEmpty || + int.tryParse(value) == null || + int.parse(value) < 0) { + return L10n.of(context).enterNumber; + } + if (int.parse(value) < (room.summary.mJoinedMemberCount ?? 1)) { + return L10n.of(context).chatCapacitySetTooLow; + } + return null; + }, + ); + if (input == null || input.isEmpty || int.tryParse(input) == null) { + return; + } + + final newCapacity = int.parse(input); + final success = await showFutureLoadingDialog( + context: context, + future: () => room.updateRoomCapacity(newCapacity), + ); + if (success.error == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(L10n.of(context).chatCapacityHasBeenChanged), + ), + ); + setState(() {}); + } + } + + Future addSubspace() async { + final names = await showTextInputDialog( + context: context, + title: L10n.of(context).createNewSpace, + hintText: L10n.of(context).spaceName, + minLines: 1, + maxLines: 1, + maxLength: 64, + validator: (text) { + if (text.isEmpty) { + return L10n.of(context).pleaseChoose; + } + return null; + }, + okLabel: L10n.of(context).create, + cancelLabel: L10n.of(context).cancel, + ); + if (names == null) return; + final client = Matrix.of(context).client; + final result = await showFutureLoadingDialog( + context: context, + future: () async { + final activeSpace = client.getRoomById(roomId!)!; + await activeSpace.postLoad(); + + final resp = await client.createSpace( + name: names, + visibility: activeSpace.joinRules == JoinRules.public + ? sdk.Visibility.public + : sdk.Visibility.private, + ); + await activeSpace.pangeaSetSpaceChild(resp); + }, + ); + if (result.error != null) return; } // Pangea# } diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index df76ce4b0..130163462 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -1022,17 +1022,7 @@ class ChatListController extends State context: context, // #Pangea // future: () => space.setSpaceChild(room.id), - future: () async { - try { - await space.pangeaSetSpaceChild(room.id); - } catch (err) { - if (err is NestedSpaceError) { - throw L10n.of(context).nestedSpaceError; - } else { - rethrow; - } - } - }, + future: () => space.pangeaSetSpaceChild(room.id), // Pangea# ); // #Pangea diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index 879a57213..d1d6dbf0d 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -20,7 +20,6 @@ import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart'; import 'package:fluffychat/pangea/spaces/constants/space_constants.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/show_ok_cancel_alert_dialog.dart'; @@ -722,16 +721,6 @@ 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/pangea/activity_planner/activity_plan_page_launch_icon_button.dart b/lib/pangea/activity_planner/activity_plan_page_launch_icon_button.dart deleted file mode 100644 index 8a498c99d..000000000 --- a/lib/pangea/activity_planner/activity_plan_page_launch_icon_button.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:go_router/go_router.dart'; - -import 'package:fluffychat/pages/chat/chat.dart'; -import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; - -class ActivityPlanPageLaunchIconButton extends StatelessWidget { - const ActivityPlanPageLaunchIconButton({ - super.key, - required this.controller, - }); - - final ChatController controller; - - @override - Widget build(BuildContext context) { - if (!controller.room.canSendDefaultStates) { - return const SizedBox(); - } - - return FutureBuilder( - future: controller.room.isBotDM, - builder: (BuildContext context, snapshot) { - final isBotDM = snapshot.data; - if (isBotDM == true || isBotDM == null) { - return const SizedBox(); - } - return IconButton( - icon: const Icon(Icons.event_note_outlined), - tooltip: L10n.of(context).activityPlannerTitle, - onPressed: () { - context.go('/rooms/${controller.room.id}/planner'); - }, - ); - }, - ); - } -} diff --git a/lib/pangea/activity_planner/activity_planner_page_appbar.dart b/lib/pangea/activity_planner/activity_planner_page_appbar.dart index 36dae1223..cdccf5950 100644 --- a/lib/pangea/activity_planner/activity_planner_page_appbar.dart +++ b/lib/pangea/activity_planner/activity_planner_page_appbar.dart @@ -33,7 +33,7 @@ class ActivityPlannerPageAppBar extends StatelessWidget children: [ const SizedBox(width: 8.0), IconButton( - icon: const Icon(Icons.close), + icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), ), ], @@ -69,7 +69,7 @@ class ActivityPlannerPageAppBar extends StatelessWidget child: InkWell( customBorder: const CircleBorder(), onTap: () => roomID != null - ? context.go('/rooms/$roomID/planner/generator') + ? context.go('/rooms/$roomID/details/planner/generator') : context.go("/rooms/homepage/planner/generator"), child: Container( decoration: BoxDecoration( diff --git a/lib/pangea/analytics_misc/level_display_name.dart b/lib/pangea/analytics_misc/level_display_name.dart index 1e206fb7f..c0f8bd995 100644 --- a/lib/pangea/analytics_misc/level_display_name.dart +++ b/lib/pangea/analytics_misc/level_display_name.dart @@ -1,97 +1,94 @@ import 'package:flutter/material.dart'; -import 'package:fluffychat/pangea/user/models/profile_model.dart'; import 'package:fluffychat/widgets/matrix.dart'; -class LevelDisplayName extends StatefulWidget { +class LevelDisplayName extends StatelessWidget { final String userId; + final TextStyle? textStyle; + final double? iconSize; + const LevelDisplayName({ required this.userId, + this.textStyle, + this.iconSize, super.key, }); - @override - State createState() => LevelDisplayNameState(); -} - -class LevelDisplayNameState extends State { - PublicProfileModel? _profile; - bool _loading = true; - String? _error; - - @override - void initState() { - super.initState(); - _fetchProfile(); - } - - Future _fetchProfile() async { - try { - final userController = MatrixState.pangeaController.userController; - _profile = await userController.getPublicProfile(widget.userId); - } catch (e) { - _error = e.toString(); - } finally { - if (mounted) setState(() => _loading = false); - } - } - @override Widget build(BuildContext context) { - if (_profile != null && _profile!.isEmpty) { - return const SizedBox(); - } - return Padding( padding: const EdgeInsets.symmetric( horizontal: 0, vertical: 2.0, ), - child: Row( - children: [ - if (_loading) - const CircularProgressIndicator() - else if (_error != null || _profile == null) - const SizedBox() - else - Row( - spacing: 4.0, - children: [ - if (_profile?.baseLanguage != null && - _profile?.targetLanguage != null) - Text( - _profile!.baseLanguage!.langCodeShort.toUpperCase(), - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), + child: FutureBuilder( + future: MatrixState.pangeaController.userController + .getPublicProfile(userId), + builder: (context, snapshot) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!snapshot.hasData) + const Padding( + padding: EdgeInsets.all(4.0), + child: SizedBox( + width: 12.0, + height: 12.0, + child: CircularProgressIndicator.adaptive(), ), - if (_profile?.baseLanguage != null && - _profile?.targetLanguage != null) - const Icon( - Icons.arrow_forward_outlined, - size: 16.0, - ), - if (_profile?.targetLanguage != null) - Text( - _profile!.targetLanguage!.langCodeShort.toUpperCase(), - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - if (_profile?.level != null) const Text("⭐"), - if (_profile?.level != null) - Text( - "${_profile!.level!}", - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.primary, - ), - ), - ], - ), - ], + ) + else if (snapshot.hasError || snapshot.data == null) + const SizedBox() + else + Row( + children: [ + if (snapshot.data?.baseLanguage != null && + snapshot.data?.targetLanguage != null) + Text( + snapshot.data!.baseLanguage!.langCodeShort + .toUpperCase(), + style: textStyle ?? + TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + if (snapshot.data?.baseLanguage != null && + snapshot.data?.targetLanguage != null) + Icon( + Icons.chevron_right_outlined, + size: iconSize ?? 16.0, + ), + if (snapshot.data?.targetLanguage != null) + Text( + snapshot.data!.targetLanguage!.langCodeShort + .toUpperCase(), + style: textStyle ?? + TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 4.0), + if (snapshot.data?.level != null) + Text( + "⭐", + style: textStyle, + ), + if (snapshot.data?.level != null) + Text( + "${snapshot.data!.level!}", + style: textStyle ?? + TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.primary, + ), + ), + ], + ), + ], + ); + }, ), ); } diff --git a/lib/pangea/chat_settings/pages/pangea_chat_details.dart b/lib/pangea/chat_settings/pages/pangea_chat_details.dart index 7c50f9dc1..e973971eb 100644 --- a/lib/pangea/chat_settings/pages/pangea_chat_details.dart +++ b/lib/pangea/chat_settings/pages/pangea_chat_details.dart @@ -1,30 +1,36 @@ -import 'package:flutter/foundation.dart'; +import 'dart:async'; +import 'dart:math'; + 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:matrix/matrix.dart'; +import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pages/chat_details/participant_list_item.dart'; +import 'package:fluffychat/pangea/analytics_misc/level_display_name.dart'; +import 'package:fluffychat/pangea/bot/utils/bot_name.dart'; +import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart'; +import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart'; import 'package:fluffychat/pangea/chat_settings/utils/delete_room.dart'; -import 'package:fluffychat/pangea/chat_settings/utils/download_chat.dart'; -import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/class_name_header.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/conversation_bot/conversation_bot_settings.dart'; import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart'; -import 'package:fluffychat/pangea/chat_settings/widgets/download_space_analytics_button.dart'; -import 'package:fluffychat/pangea/chat_settings/widgets/room_capacity_button.dart'; -import 'package:fluffychat/pangea/chat_settings/widgets/visibility_toggle.dart'; import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart'; +import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart'; +import 'package:fluffychat/pangea/spaces/widgets/download_space_analytics_dialog.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/show_modal_action_popup.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/user_dialog.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/future_loading_dialog.dart'; +import 'package:fluffychat/widgets/hover_builder.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -33,34 +39,6 @@ class PangeaChatDetailsView extends StatelessWidget { const PangeaChatDetailsView(this.controller, {super.key}); - void _downloadChat(BuildContext context) async { - if (controller.roomId == null) return; - final Room? room = - Matrix.of(context).client.getRoomById(controller.roomId!); - if (room == null) return; - - final type = await showModalActionPopup( - context: context, - title: L10n.of(context).downloadGroupText, - actions: [ - AdaptiveModalAction( - value: DownloadType.csv, - label: L10n.of(context).downloadCSVFile, - ), - AdaptiveModalAction( - value: DownloadType.txt, - label: L10n.of(context).downloadTxtFile, - ), - AdaptiveModalAction( - value: DownloadType.xlsx, - label: L10n.of(context).downloadXLSXFile, - ), - ], - ); - if (type == null) return; - downloadChat(room, type, context); - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -77,8 +55,6 @@ class PangeaChatDetailsView extends StatelessWidget { ); } - final bool isGroupChat = !room.isDirectChat && !room.isSpace; - return StreamBuilder( stream: room.client.onRoomState.stream .where((update) => update.roomId == room.id), @@ -89,14 +65,15 @@ class PangeaChatDetailsView extends StatelessWidget { final actualMembersCount = (room.summary.mInvitedMemberCount ?? 0) + (room.summary.mJoinedMemberCount ?? 0); final canRequestMoreMembers = members.length < actualMembersCount; - final iconColor = theme.textTheme.bodyLarge!.color; final displayname = room.getLocalizedDisplayname( MatrixLocals(L10n.of(context)), ); return Scaffold( appBar: AppBar( leading: controller.widget.embeddedCloseButton ?? - const Center(child: BackButton()), + (room.isSpace + ? const SizedBox() + : const Center(child: BackButton())), elevation: theme.appBarTheme.elevation, title: ClassNameHeader( controller: controller, @@ -109,7 +86,7 @@ class PangeaChatDetailsView extends StatelessWidget { child: ListView.builder( physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, - itemCount: members.length + 1 + (canRequestMoreMembers ? 1 : 0), + itemCount: 2, itemBuilder: (BuildContext context, int i) => i == 0 ? Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -265,254 +242,16 @@ class PangeaChatDetailsView extends StatelessWidget { ), ], ), - Divider(color: theme.dividerColor, height: 1), - if (isGroupChat && room.canInvite) - ConversationBotSettings( - key: controller.addConversationBotKey, - room: room, - ), - if (isGroupChat && room.canInvite) - Divider(color: theme.dividerColor, height: 1), - if (room.canInvite && !room.isDirectChat) - ListTile( - title: Text( - L10n.of(context).inviteContact, - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - ), - leading: CircleAvatar( - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, - foregroundColor: - Theme.of(context).textTheme.bodyLarge!.color, - child: const Icon( - Icons.person_add_outlined, - ), - ), - onTap: () => - context.push('/rooms/${room.id}/invite'), - ), - if (room.canInvite && !room.isDirectChat) - Divider(color: theme.dividerColor, height: 1), - if (room.isRoomAdmin && - room.isSpace && - room.spaceParents.isEmpty) - VisibilityToggle( - room: room, - setVisibility: controller.setVisibility, - setJoinRules: controller.setJoinRules, - iconColor: iconColor, - ), - if (room.isRoomAdmin && - room.isSpace && - room.spaceParents.isEmpty) - Divider(color: theme.dividerColor, height: 1), - if (room.isRoomAdmin && !room.isDirectChat) - ListTile( - title: Text( - L10n.of(context).permissions, - style: TextStyle( - color: theme.colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - ), - subtitle: Text( - L10n.of(context).whoCanPerformWhichAction, - ), - leading: CircleAvatar( - backgroundColor: theme.scaffoldBackgroundColor, - foregroundColor: iconColor, - child: const Icon( - Icons.edit_attributes_outlined, - ), - ), - onTap: () => context.push( - '/rooms/${room.id}/details/permissions', - ), - ), - if (room.isRoomAdmin && !room.isDirectChat) - Divider(color: theme.dividerColor, height: 1), - if (!room.isSpace && !room.isDirectChat) - RoomCapacityButton( - room: room, - controller: controller, - ), - if (room.isSpace && room.isRoomAdmin && kIsWeb) - DownloadSpaceAnalyticsButton(space: room), - Divider(color: theme.dividerColor, height: 1), - if (room.ownPowerLevel >= 50 && !room.isSpace) - ListTile( - title: Text( - L10n.of(context).downloadGroupText, - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - ), - leading: CircleAvatar( - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, - foregroundColor: iconColor, - child: const Icon( - Icons.download_outlined, - ), - ), - onTap: () => _downloadChat(context), - ), - if (room.ownPowerLevel >= 50 && !room.isSpace) - Divider(color: theme.dividerColor, height: 1), - if (isGroupChat) - ListTile( - title: Text( - room.pushRuleState == PushRuleState.notify - ? L10n.of(context).notificationsOn - : L10n.of(context).notificationsOff, - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - ), - leading: CircleAvatar( - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, - foregroundColor: iconColor, - child: Icon( - room.pushRuleState == PushRuleState.notify - ? Icons.notifications_on_outlined - : Icons.notifications_off_outlined, - ), - ), - onTap: controller.toggleMute, - ), - if (isGroupChat) - Divider(color: theme.dividerColor, height: 1), - ListTile( - title: Text( - L10n.of(context).leave, - style: TextStyle( - color: Theme.of(context).colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - ), - leading: CircleAvatar( - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, - foregroundColor: iconColor, - child: const Icon( - Icons.logout_outlined, - ), - ), - onTap: () async { - final confirmed = await showOkCancelAlertDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context).areYouSure, - okLabel: L10n.of(context).leave, - cancelLabel: L10n.of(context).no, - message: room.isSpace - ? L10n.of(context).leaveSpaceDescription - : L10n.of(context).leaveRoomDescription, - isDestructive: true, - ); - if (confirmed != OkCancelResult.ok) return; - final resp = await showFutureLoadingDialog( - context: context, - future: - room.isSpace ? room.leaveSpace : room.leave, - ); - if (!resp.isError) { - context.go("/rooms?spaceId=clear"); - } - }, - ), - Divider(color: theme.dividerColor, height: 1), - if (room.isRoomAdmin) - ListTile( - title: Text( - L10n.of(context).delete, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontWeight: FontWeight.bold, - ), - ), - leading: CircleAvatar( - backgroundColor: - Theme.of(context).scaffoldBackgroundColor, - foregroundColor: iconColor, - child: Icon( - Icons.delete_outline, - color: Theme.of(context).colorScheme.error, - ), - ), - onTap: () async { - if (room.isSpace) { - final resp = await showDialog( - context: context, - builder: (_) => - DeleteSpaceDialog(space: room), - ); - - if (resp == true) { - context.go("/rooms?spaceId=clear"); - } - } else { - final confirmed = await showOkCancelAlertDialog( - context: context, - title: L10n.of(context).areYouSure, - okLabel: L10n.of(context).delete, - cancelLabel: L10n.of(context).cancel, - isDestructive: true, - message: room.isSpace - ? L10n.of(context).deleteSpaceDesc - : L10n.of(context).deleteChatDesc, - ); - if (confirmed != OkCancelResult.ok) return; - - final resp = await showFutureLoadingDialog( - context: context, - future: room.delete, - ); - if (resp.isError) return; - context.go("/rooms?spaceId=clear"); - } - }, - ), - Divider(color: theme.dividerColor, height: 1), - ListTile( - title: Text( - L10n.of(context).countParticipants( - actualMembersCount, - ), - style: TextStyle( - color: theme.colorScheme.secondary, - fontWeight: FontWeight.bold, - ), - ), + RoomDetailsButtonRow( + controller: controller, + room: room, ), ], ) - : i < members.length + 1 - ? ParticipantListItem(members[i - 1]) - : ListTile( - title: Text( - L10n.of(context).loadCountMoreParticipants( - (actualMembersCount - members.length), - ), - ), - leading: CircleAvatar( - backgroundColor: theme.scaffoldBackgroundColor, - child: const Icon( - Icons.group_outlined, - color: Colors.grey, - ), - ), - onTap: () => context.push( - '/rooms/${controller.roomId!}/details/members', - ), - trailing: const Icon(Icons.chevron_right_outlined), - ), + : Padding( + padding: const EdgeInsets.all(16.0), + child: RoomParticipantsSection(room: room), + ), ), ), ); @@ -520,3 +259,554 @@ class PangeaChatDetailsView extends StatelessWidget { ); } } + +class RoomDetailsButtonRow extends StatefulWidget { + final ChatDetailsController controller; + final Room room; + + const RoomDetailsButtonRow({ + super.key, + required this.controller, + required this.room, + }); + + @override + State createState() => RoomDetailsButtonRowState(); +} + +class RoomDetailsButtonRowState extends State { + StreamSubscription? notificationChangeSub; + + @override + void initState() { + super.initState(); + notificationChangeSub ??= Matrix.of(context) + .client + .onSync + .stream + .where( + (syncUpdate) => + syncUpdate.accountData?.any( + (accountData) => accountData.type == 'm.push_rules', + ) ?? + false, + ) + .listen( + (u) => setState(() {}), + ); + } + + @override + void dispose() { + notificationChangeSub?.cancel(); + super.dispose(); + } + + final double _buttonWidth = 130.0; + final double _buttonHeight = 80.0; + + final double _miniButtonWidth = 50.0; + final double _buttonPadding = 4.0; + + double get _fullButtonWidth => _buttonWidth + (_buttonPadding * 2); + double get _fullMiniButtonWidth => _miniButtonWidth + (_buttonPadding * 2); + + Room get room => widget.room; + + List _buttons(BuildContext context) { + final L10n l10n = L10n.of(context); + return [ + ButtonDetails( + title: l10n.activities, + icon: const Icon(Icons.event_note_outlined), + onPressed: () => room.isSpace + ? context.go("/rooms/homepage/planner") + : context.go("/rooms/${room.id}/details/planner"), + visible: (room) => room.canSendDefaultStates, + ), + ButtonDetails( + title: l10n.permissions, + icon: const Icon(Icons.edit_attributes_outlined), + onPressed: () => context.go('/rooms/${room.id}/details/permissions'), + visible: (room) => room.isRoomAdmin && !room.isDirectChat, + ), + ButtonDetails( + title: l10n.access, + icon: const Icon(Icons.shield_outlined), + onPressed: () => context.go('/rooms/${room.id}/details/access'), + visible: (room) => room.isSpace && room.isRoomAdmin, + ), + ButtonDetails( + title: room.pushRuleState == PushRuleState.notify + ? l10n.notificationsOn + : l10n.notificationsOff, + icon: Icon( + room.pushRuleState == PushRuleState.notify + ? Icons.notifications_on_outlined + : Icons.notifications_off_outlined, + ), + onPressed: () => showFutureLoadingDialog( + context: context, + future: () => room.setPushRuleState( + room.pushRuleState == PushRuleState.notify + ? PushRuleState.mentionsOnly + : PushRuleState.notify, + ), + ), + visible: (room) => !room.isSpace, + ), + ButtonDetails( + title: l10n.invite, + icon: const Icon(Icons.person_add_outlined), + onPressed: () => context.go('/rooms/${room.id}/details/invite'), + visible: (room) => room.canInvite && !room.isDirectChat, + ), + ButtonDetails( + title: l10n.addSubspace, + icon: const Icon(Icons.add_outlined), + onPressed: widget.controller.addSubspace, + visible: (room) => + room.isSpace && + room.canSendEvent( + EventTypes.SpaceChild, + ), + ), + ButtonDetails( + title: l10n.downloadSpaceAnalytics, + icon: const Icon(Icons.download_outlined), + onPressed: () { + showDialog( + context: context, + builder: (context) => DownloadAnalyticsDialog(space: room), + ); + }, + visible: (room) => room.isSpace && room.isRoomAdmin, + ), + ButtonDetails( + title: l10n.download, + icon: const Icon(Icons.download_outlined), + onPressed: widget.controller.downloadChatAction, + visible: (room) => room.ownPowerLevel >= 50 && !room.isSpace, + ), + ButtonDetails( + title: l10n.botSettings, + icon: const BotFace( + width: 30.0, + expression: BotExpression.idle, + ), + onPressed: () => showDialog( + context: context, + builder: (BuildContext context) => ConversationBotSettingsDialog( + room: room, + onSubmit: widget.controller.setBotOptions, + ), + ), + visible: (room) => + !room.isSpace && !room.isDirectChat && room.canInvite, + ), + ButtonDetails( + title: l10n.chatCapacity, + icon: const Icon(Icons.reduce_capacity), + onPressed: widget.controller.setRoomCapacity, + visible: (room) => + !room.isSpace && !room.isDirectChat && room.canSendDefaultStates, + ), + ButtonDetails( + title: l10n.leave, + icon: const Icon(Icons.logout_outlined), + onPressed: () async { + final confirmed = await showOkCancelAlertDialog( + useRootNavigator: false, + context: context, + title: L10n.of(context).areYouSure, + okLabel: L10n.of(context).leave, + cancelLabel: L10n.of(context).no, + message: room.isSpace + ? L10n.of(context).leaveSpaceDescription + : L10n.of(context).leaveRoomDescription, + isDestructive: true, + ); + if (confirmed != OkCancelResult.ok) return; + final resp = await showFutureLoadingDialog( + context: context, + future: room.isSpace ? room.leaveSpace : room.leave, + ); + if (!resp.isError) { + context.go("/rooms?spaceId=clear"); + } + }, + visible: (room) => room.membership == Membership.join, + ), + ButtonDetails( + title: l10n.delete, + icon: const Icon(Icons.delete_outline), + onPressed: () async { + if (room.isSpace) { + final resp = await showDialog( + context: context, + builder: (_) => DeleteSpaceDialog(space: room), + ); + + if (resp == true) { + context.go("/rooms?spaceId=clear"); + } + } else { + final confirmed = await showOkCancelAlertDialog( + context: context, + title: L10n.of(context).areYouSure, + okLabel: L10n.of(context).delete, + cancelLabel: L10n.of(context).cancel, + isDestructive: true, + message: room.isSpace + ? L10n.of(context).deleteSpaceDesc + : L10n.of(context).deleteChatDesc, + ); + if (confirmed != OkCancelResult.ok) return; + + final resp = await showFutureLoadingDialog( + context: context, + future: room.delete, + ); + if (resp.isError) return; + context.go("/rooms?spaceId=clear"); + } + }, + visible: (room) => room.isRoomAdmin, + ), + ]; + } + + @override + Widget build(BuildContext context) { + final buttons = _buttons(context) + .where( + (button) => button.visible(room), + ) + .toList(); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final fullButtonCapacity = + (availableWidth / _fullButtonWidth).floor() - 1; + final miniButtonCapacity = + (availableWidth / _fullMiniButtonWidth).floor() - 1; + + final mini = fullButtonCapacity < 3; + final capacity = mini ? miniButtonCapacity : fullButtonCapacity; + + final numVisibleButtons = min(buttons.length, capacity); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(numVisibleButtons + 1, (index) { + if (index == numVisibleButtons) { + if (buttons.length == numVisibleButtons) { + return const SizedBox(); + } else if (buttons.length == numVisibleButtons + 1) { + return RoomDetailsButton( + mini: mini, + visible: true, + title: buttons[index].title, + icon: buttons[index].icon, + onPressed: buttons[index].onPressed, + width: mini ? _miniButtonWidth : _buttonWidth, + height: mini ? _miniButtonWidth : _buttonHeight, + ); + } + return PopupMenuButton( + onSelected: (button) => button.onPressed(), + itemBuilder: (context) { + return buttons + .skip(numVisibleButtons) + .map( + (button) => PopupMenuItem( + value: button, + child: Row( + children: [ + button.icon, + const SizedBox(width: 8), + Text(button.title), + ], + ), + ), + ) + .toList(); + }, + child: RoomDetailsButton( + mini: mini, + visible: true, + title: L10n.of(context).more, + icon: const Icon(Icons.more_horiz_outlined), + width: mini ? _miniButtonWidth : _buttonWidth, + height: mini ? _miniButtonWidth : _buttonHeight, + ), + ); + } + + final button = buttons[index]; + return Padding( + padding: EdgeInsets.symmetric(horizontal: _buttonPadding), + child: RoomDetailsButton( + mini: mini, + visible: button.visible(room), + title: button.title, + icon: button.icon, + onPressed: button.onPressed, + width: mini ? _miniButtonWidth : _buttonWidth, + height: mini ? _miniButtonWidth : _buttonHeight, + ), + ); + }), + ); + }, + ), + ); + } +} + +class RoomDetailsButton extends StatelessWidget { + final bool mini; + final bool visible; + + final String title; + final Widget icon; + final VoidCallback? onPressed; + + final double width; + final double height; + + const RoomDetailsButton({ + super.key, + required this.visible, + required this.title, + required this.icon, + required this.mini, + required this.width, + required this.height, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + if (!visible) { + return const SizedBox(); + } + + return MouseRegion( + cursor: SystemMouseCursors.click, + child: HoverBuilder( + builder: (context, hovered) { + return GestureDetector( + onTap: onPressed, + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + color: hovered + ? Theme.of(context).colorScheme.primary.withAlpha(50) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.all(8.0), + child: mini + ? icon + : Column( + spacing: 8.0, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + icon, + Text( + title, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + }, + ), + ); + } +} + +class ButtonDetails { + final String title; + final Widget icon; + final VoidCallback onPressed; + final bool Function(Room) visible; + + const ButtonDetails({ + required this.title, + required this.icon, + required this.onPressed, + required this.visible, + }); +} + +class RoomParticipantsSection extends StatelessWidget { + final Room room; + + const RoomParticipantsSection({ + required this.room, + super.key, + }); + + final double _width = 90.0; + final double _padding = 12.0; + + double get _fullWidth => _width + (_padding * 2); + + @override + Widget build(BuildContext context) { + final List members = room.getParticipants().toList() + ..sort((b, a) => a.powerLevel.compareTo(b.powerLevel)); + + final actualMembersCount = (room.summary.mInvitedMemberCount ?? 0) + + (room.summary.mJoinedMemberCount ?? 0); + + return LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final capacity = (availableWidth / _fullWidth).floor(); + + if (capacity < 4) { + return Column( + children: [ + ...members.map((member) => ParticipantListItem(member)), + if (actualMembersCount - members.length > 0) + ListTile( + title: Text( + L10n.of(context).loadCountMoreParticipants( + (actualMembersCount - members.length), + ), + ), + leading: CircleAvatar( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + child: const Icon( + Icons.group_outlined, + color: Colors.grey, + ), + ), + onTap: () => context.push( + '/rooms/${room.id}/details/members', + ), + trailing: const Icon(Icons.chevron_right_outlined), + ), + ], + ); + } + + return LoadParticipantsUtil( + space: room, + builder: (participantsLoader) { + final filteredParticipants = + participantsLoader.filteredParticipants(""); + return Wrap( + alignment: WrapAlignment.center, + runAlignment: WrapAlignment.center, + children: [ + ...filteredParticipants.mapIndexed((index, user) { + Color? color = index == 0 + ? AppConfig.gold + : index == 1 + ? Colors.grey[400]! + : index == 2 + ? Colors.brown[400]! + : null; + + final publicProfile = participantsLoader.getPublicProfile( + user.id, + ); + + if (user.id == BotName.byEnvironment || + publicProfile == null || + publicProfile.level == null) { + color = null; + } + + return Padding( + padding: EdgeInsets.all(_padding), + child: SizedBox( + width: _width, + child: Column( + children: [ + Stack( + alignment: Alignment.center, + children: [ + if (color != null) + CircleAvatar( + radius: _width / 2, + 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, + ], + ), + ), + ), + ) + else + SizedBox( + height: _width, + width: _width, + ), + 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: _width - 6.0, + presenceUserId: user.id, + showPresence: false, + ), + ), + ), + ), + ], + ), + Text( + user.calcDisplayname(), + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + LevelDisplayName( + userId: user.id, + textStyle: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ), + ); + }), + ], + ); + }, + ); + }, + ); + } +} diff --git a/lib/pangea/extensions/room_children_and_parents_extension.dart b/lib/pangea/extensions/room_children_and_parents_extension.dart index 16c2d0f43..73aee8a12 100644 --- a/lib/pangea/extensions/room_children_and_parents_extension.dart +++ b/lib/pangea/extensions/room_children_and_parents_extension.dart @@ -21,9 +21,6 @@ extension ChildrenAndParentsRoomExtension on Room { }) async { final Room? child = client.getRoomById(roomId); if (child == null) return; - if (child.isSpace) { - throw NestedSpaceError(); - } for (final Room parent in pangeaSpaceParents) { try { @@ -79,8 +76,3 @@ extension ChildrenAndParentsRoomExtension on Room { ) .length; } - -class NestedSpaceError extends Error { - @override - String toString() => 'Cannot add a space to another space'; -} diff --git a/lib/pangea/spaces/pages/pangea_space_page.dart b/lib/pangea/spaces/pages/pangea_space_page.dart deleted file mode 100644 index 0c86a5168..000000000 --- a/lib/pangea/spaces/pages/pangea_space_page.dart +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 913504007..000000000 --- a/lib/pangea/spaces/pages/pangea_space_page_view.dart +++ /dev/null @@ -1,622 +0,0 @@ -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 index f4a5037ce..5b3d9c24a 100644 --- a/lib/pangea/spaces/utils/load_participants_util.dart +++ b/lib/pangea/spaces/utils/load_participants_util.dart @@ -108,6 +108,10 @@ class LoadParticipantsUtilState extends State { } } + PublicProfileModel? getPublicProfile(String userId) { + return _levelsCache[userId]; + } + @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 deleted file mode 100644 index 13fc81fbc..000000000 --- a/lib/pangea/spaces/widgets/space_view_leaderboard.dart +++ /dev/null @@ -1,96 +0,0 @@ -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(), - ), - ], - ), - ], - ); - }, - ); - } -}