From a12c48fae640b59babf9307830bd66daaf40aac6 Mon Sep 17 00:00:00 2001 From: Krille Date: Thu, 10 Apr 2025 18:27:16 +0200 Subject: [PATCH] refactor: Replace user bottom sheet with menu and small dialog Signed-off-by: Krille --- assets/l10n/intl_en.arb | 4 + .../xcshareddata/xcschemes/Runner.xcscheme | 1 + lib/config/themes.dart | 14 +- lib/pages/chat/chat_event_list.dart | 21 +- lib/pages/chat/events/message.dart | 35 +- .../events/room_creation_state_event.dart | 8 +- lib/pages/chat_details/chat_details_view.dart | 27 +- .../chat_details/participant_list_item.dart | 120 +++--- lib/pages/chat_list/chat_list_body.dart | 20 +- lib/pages/chat_list/status_msg_list.dart | 12 +- .../invitation_selection_view.dart | 20 +- .../new_private_chat/new_private_chat.dart | 20 +- .../user_bottom_sheet/user_bottom_sheet.dart | 285 ------------- .../user_bottom_sheet_view.dart | 374 ------------------ lib/utils/url_launcher.dart | 34 +- .../adaptive_dialog_action.dart | 23 ++ lib/widgets/adaptive_dialogs/user_dialog.dart | 180 +++++++++ lib/widgets/avatar.dart | 6 +- lib/widgets/future_loading_dialog.dart | 2 + .../member_actions_popup_menu_button.dart | 296 ++++++++++++++ lib/widgets/permission_slider_dialog.dart | 83 +++- 21 files changed, 734 insertions(+), 851 deletions(-) delete mode 100644 lib/pages/user_bottom_sheet/user_bottom_sheet.dart delete mode 100644 lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart create mode 100644 lib/widgets/adaptive_dialogs/user_dialog.dart create mode 100644 lib/widgets/member_actions_popup_menu_button.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 1a8fe0e46..49a1734f0 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -9,6 +9,10 @@ "@repeatPassword": {}, "notAnImage": "Not an image file.", "@notAnImage": {}, + "setCustomPermissionLevel": "Set custom permission level", + "setPermissionsLevelDescription": "Please choose a predefined role below or enter a custom permission level between 0 and 100.", + "ignoreUser": "Ignore user", + "normalUser": "Normal user", "remove": "Remove", "@remove": { "type": "String", diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e67b2808a..4f746537f 100644 --- a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -50,6 +50,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/lib/config/themes.dart b/lib/config/themes.dart index 963de3bca..3bea16eb5 100644 --- a/lib/config/themes.dart +++ b/lib/config/themes.dart @@ -69,12 +69,15 @@ abstract class FluffyThemes { selectionColor: colorScheme.onSurface.withAlpha(128), selectionHandleColor: colorScheme.secondary, ), - inputDecorationTheme: InputDecorationTheme( - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppConfig.borderRadius), + inputDecorationTheme: const InputDecorationTheme( + border: UnderlineInputBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(AppConfig.borderRadius), + topLeft: Radius.circular(AppConfig.borderRadius), + ), ), - contentPadding: const EdgeInsets.all(12), - filled: false, + //contentPadding: EdgeInsets.all(12), + filled: true, ), appBarTheme: AppBarTheme( toolbarHeight: isColumnMode ? 72 : 56, @@ -130,6 +133,7 @@ extension BubbleColorTheme on ThemeData { Color get bubbleColor => brightness == Brightness.light ? colorScheme.primary : colorScheme.primaryContainer; + Color get onBubbleColor => brightness == Brightness.light ? colorScheme.onPrimary : colorScheme.onPrimaryContainer; diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 7a54a49d4..6642c7715 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -1,21 +1,17 @@ -import 'package:flutter/material.dart'; - -import 'package:matrix/matrix.dart'; -import 'package:scroll_to_index/scroll_to_index.dart'; - import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/message.dart'; import 'package:fluffychat/pages/chat/seen_by_row.dart'; import 'package:fluffychat/pages/chat/typing_indicators.dart'; -import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/utils/account_config.dart'; -import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/filtered_timeline_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:flutter/material.dart'; +import 'package:scroll_to_index/scroll_to_index.dart'; class ChatEventList extends StatelessWidget { final ChatController controller; + const ChatEventList({ super.key, required this.controller, @@ -136,15 +132,8 @@ class ChatEventList extends StatelessWidget { }, onSwipe: () => controller.replyAction(replyTo: event), onInfoTab: controller.showEventInfo, - onAvatarTab: (Event event) => showAdaptiveBottomSheet( - context: context, - builder: (c) => UserBottomSheet( - user: event.senderFromMemoryOrFallback, - outerContext: context, - onMention: () => controller.sendController.text += - '${event.senderFromMemoryOrFallback.mention} ', - ), - ), + onMention: () => controller.sendController.text += + '${event.senderFromMemoryOrFallback.mention} ', highlightMarker: controller.scrollToEventIdMarker == event.eventId, onSelect: controller.onSelectMessage, diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index 34018d8e7..4ee4673e3 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -1,12 +1,5 @@ import 'dart:ui' as ui; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; -import 'package:swipe_to_action/swipe_to_action.dart'; - import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/events/room_creation_state_event.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; @@ -14,6 +7,13 @@ import 'package:fluffychat/utils/file_description.dart'; import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/member_actions_popup_menu_button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; +import 'package:swipe_to_action/swipe_to_action.dart'; + import '../../../config/app_config.dart'; import 'message_content.dart'; import 'message_reactions.dart'; @@ -27,10 +27,10 @@ class Message extends StatelessWidget { final Event? previousEvent; final bool displayReadMarker; final void Function(Event) onSelect; - final void Function(Event) onAvatarTab; final void Function(Event) onInfoTab; final void Function(String) scrollToEventId; final void Function() onSwipe; + final void Function() onMention; final bool longPressSelect; final bool selected; final Timeline timeline; @@ -49,7 +49,6 @@ class Message extends StatelessWidget { this.longPressSelect = false, required this.onSelect, required this.onInfoTab, - required this.onAvatarTab, required this.scrollToEventId, required this.onSwipe, this.selected = false, @@ -58,6 +57,7 @@ class Message extends StatelessWidget { this.animateIn = false, this.resetAnimateIn, this.wallpaperMode = false, + required this.onMention, required this.scrollController, required this.colors, super.key, @@ -236,13 +236,16 @@ class Message extends StatelessWidget { builder: (context, snapshot) { final user = snapshot.data ?? event.senderFromMemoryOrFallback; - return Avatar( - mxContent: user.avatarUrl, - name: user.calcDisplayname(), - presenceUserId: user.stateKey, - presenceBackgroundColor: - wallpaperMode ? Colors.transparent : null, - onTap: () => onAvatarTab(event), + return MemberActionsPopupMenuButton( + onMention: onMention, + user: user, + child: Avatar( + mxContent: user.avatarUrl, + name: user.calcDisplayname(), + presenceUserId: user.stateKey, + presenceBackgroundColor: + wallpaperMode ? Colors.transparent : null, + ), ); }, ), diff --git a/lib/pages/chat/events/room_creation_state_event.dart b/lib/pages/chat/events/room_creation_state_event.dart index ca0e64001..0e85faa2a 100644 --- a/lib/pages/chat/events/room_creation_state_event.dart +++ b/lib/pages/chat/events/room_creation_state_event.dart @@ -1,10 +1,12 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:fluffychat/widgets/avatar.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; class RoomCreationStateEvent extends StatelessWidget { final Event event; diff --git a/lib/pages/chat_details/chat_details_view.dart b/lib/pages/chat_details/chat_details_view.dart index 43b8d70e6..05ccad2fd 100644 --- a/lib/pages/chat_details/chat_details_view.dart +++ b/lib/pages/chat_details/chat_details_view.dart @@ -1,10 +1,3 @@ -import 'package:flutter/material.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/pages/chat_details/chat_details.dart'; import 'package:fluffychat/pages/chat_details/participant_list_item.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; @@ -13,6 +6,12 @@ import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/chat_settings_popup_menu.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.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 '../../utils/url_launcher.dart'; import '../../widgets/qr_code_viewer.dart'; @@ -37,6 +36,8 @@ class ChatDetailsView extends StatelessWidget { ); } + final directChatMatrixID = room.directChatMatrixID; + return StreamBuilder( stream: room.client.onRoomState.stream .where((update) => update.roomId == room.id), @@ -57,7 +58,7 @@ class ChatDetailsView extends StatelessWidget { const Center(child: BackButton()), elevation: theme.appBarTheme.elevation, actions: [ - if (room.canonicalAlias.isNotEmpty) ...[ + if (room.canonicalAlias.isNotEmpty) IconButton( tooltip: L10n.of(context).share, icon: const Icon(Icons.qr_code_rounded), @@ -65,8 +66,16 @@ class ChatDetailsView extends StatelessWidget { context, room.canonicalAlias, ), + ) + else if (directChatMatrixID != null) + IconButton( + tooltip: L10n.of(context).share, + icon: const Icon(Icons.qr_code_rounded), + onPressed: () => showQrCodeViewer( + context, + directChatMatrixID, + ), ), - ], if (controller.widget.embeddedCloseButton == null) ChatSettingsPopupMenu(room, false), ], diff --git a/lib/pages/chat_details/participant_list_item.dart b/lib/pages/chat_details/participant_list_item.dart index 67c80ef4e..d04005650 100644 --- a/lib/pages/chat_details/participant_list_item.dart +++ b/lib/pages/chat_details/participant_list_item.dart @@ -1,12 +1,10 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/widgets/member_actions_popup_menu_button.dart'; import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import '../../widgets/avatar.dart'; -import '../user_bottom_sheet/user_bottom_sheet.dart'; class ParticipantListItem extends StatelessWidget { final User user; @@ -33,72 +31,68 @@ class ParticipantListItem extends StatelessWidget { return Opacity( opacity: user.membership == Membership.join ? 1 : 0.5, - child: ListTile( - onTap: () => showAdaptiveBottomSheet( - context: context, - builder: (c) => UserBottomSheet( - user: user, - outerContext: context, - ), - ), - title: Row( - children: [ - Expanded( - child: Text( - user.calcDisplayname(), - overflow: TextOverflow.ellipsis, - ), - ), - if (permissionBatch.isNotEmpty) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, + child: MemberActionsPopupMenuButton( + user: user, + child: ListTile( + title: Row( + children: [ + Expanded( + child: Text( + user.calcDisplayname(), + overflow: TextOverflow.ellipsis, ), - decoration: BoxDecoration( - color: user.powerLevel >= 100 - ? theme.colorScheme.tertiary - : theme.colorScheme.tertiaryContainer, - borderRadius: BorderRadius.circular( - AppConfig.borderRadius, + ), + if (permissionBatch.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, ), - ), - child: Text( - permissionBatch, - style: theme.textTheme.labelSmall?.copyWith( + decoration: BoxDecoration( color: user.powerLevel >= 100 - ? theme.colorScheme.onTertiary - : theme.colorScheme.onTertiaryContainer, + ? theme.colorScheme.tertiary + : theme.colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), ), - ), - ), - membershipBatch == null - ? const SizedBox.shrink() - : Container( - padding: const EdgeInsets.all(4), - margin: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: theme.secondaryHeaderColor, - borderRadius: BorderRadius.circular(8), + child: Text( + permissionBatch, + style: theme.textTheme.labelSmall?.copyWith( + color: user.powerLevel >= 100 + ? theme.colorScheme.onTertiary + : theme.colorScheme.onTertiaryContainer, ), - child: Center( - child: Text( - membershipBatch, - style: theme.textTheme.labelSmall, + ), + ), + membershipBatch == null + ? const SizedBox.shrink() + : Container( + padding: const EdgeInsets.all(4), + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: theme.secondaryHeaderColor, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + membershipBatch, + style: theme.textTheme.labelSmall, + ), ), ), - ), - ], - ), - subtitle: Text( - user.id, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - leading: Avatar( - mxContent: user.avatarUrl, - name: user.calcDisplayname(), - presenceUserId: user.stateKey, + ], + ), + subtitle: Text( + user.id, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + leading: Avatar( + mxContent: user.avatarUrl, + name: user.calcDisplayname(), + presenceUserId: user.stateKey, + ), ), ), ); diff --git a/lib/pages/chat_list/chat_list_body.dart b/lib/pages/chat_list/chat_list_body.dart index ffe989c89..3bf84a1a4 100644 --- a/lib/pages/chat_list/chat_list_body.dart +++ b/lib/pages/chat_list/chat_list_body.dart @@ -1,9 +1,3 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pages/chat_list/chat_list.dart'; import 'package:fluffychat/pages/chat_list/chat_list_item.dart'; @@ -11,13 +5,18 @@ import 'package:fluffychat/pages/chat_list/dummy_chat_list_item.dart'; import 'package:fluffychat/pages/chat_list/search_title.dart'; import 'package:fluffychat/pages/chat_list/space_view.dart'; import 'package:fluffychat/pages/chat_list/status_msg_list.dart'; -import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; import 'package:fluffychat/widgets/public_room_bottom_sheet.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + import '../../config/themes.dart'; +import '../../widgets/adaptive_dialogs/user_dialog.dart'; import '../../widgets/matrix.dart'; import 'chat_list_header.dart'; @@ -117,12 +116,9 @@ class ChatListViewBody extends StatelessWidget { .results[i].userId.localpart ?? L10n.of(context).unknownDevice, avatar: userSearchResult.results[i].avatarUrl, - onPressed: () => showAdaptiveBottomSheet( + onPressed: () => UserDialog.show( context: context, - builder: (c) => UserBottomSheet( - profile: userSearchResult.results[i], - outerContext: context, - ), + profile: userSearchResult.results[i], ), ), ), diff --git a/lib/pages/chat_list/status_msg_list.dart b/lib/pages/chat_list/status_msg_list.dart index 563ceb9f2..f37732fdd 100644 --- a/lib/pages/chat_list/status_msg_list.dart +++ b/lib/pages/chat_list/status_msg_list.dart @@ -4,15 +4,15 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/themes.dart'; -import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; -import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/stream_extension.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/hover_builder.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import '../../widgets/adaptive_dialogs/user_dialog.dart'; class StatusMessageList extends StatelessWidget { final void Function() onStatusEdit; + const StatusMessageList({ required this.onStatusEdit, super.key, @@ -24,12 +24,9 @@ class StatusMessageList extends StatelessWidget { final client = Matrix.of(context).client; if (profile.userId == client.userID) return onStatusEdit(); - showAdaptiveBottomSheet( + UserDialog.show( context: context, - builder: (c) => UserBottomSheet( - profile: profile, - outerContext: context, - ), + profile: profile, ); return; } @@ -290,6 +287,7 @@ extension on CachedPresence { (currentlyActive == true ? DateTime.now() : DateTime.fromMillisecondsSinceEpoch(0)); + LinearGradient get gradient => presence.isOnline == true ? LinearGradient( colors: [ diff --git a/lib/pages/invitation_selection/invitation_selection_view.dart b/lib/pages/invitation_selection/invitation_selection_view.dart index 55671c03c..9ea38ce09 100644 --- a/lib/pages/invitation_selection/invitation_selection_view.dart +++ b/lib/pages/invitation_selection/invitation_selection_view.dart @@ -1,14 +1,12 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/pages/invitation_selection/invitation_selection.dart'; -import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; -import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/layouts/max_width_body.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import '../../widgets/adaptive_dialogs/user_dialog.dart'; class InvitationSelectionView extends StatelessWidget { final InvitationSelectionController controller; @@ -170,13 +168,9 @@ class _InviteContactListTile extends StatelessWidget { mxContent: profile.avatarUrl, name: profile.displayName, presenceUserId: profile.userId, - onTap: () => showAdaptiveBottomSheet( + onTap: () => UserDialog.show( context: context, - builder: (c) => UserBottomSheet( - user: user, - profile: profile, - outerContext: context, - ), + profile: profile, ), ), title: Text( diff --git a/lib/pages/new_private_chat/new_private_chat.dart b/lib/pages/new_private_chat/new_private_chat.dart index 7cb177afc..a8749f7f5 100644 --- a/lib/pages/new_private_chat/new_private_chat.dart +++ b/lib/pages/new_private_chat/new_private_chat.dart @@ -1,20 +1,19 @@ import 'dart:async'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - import 'package:device_info_plus/device_info_plus.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:matrix/matrix.dart'; - import 'package:fluffychat/pages/new_private_chat/new_private_chat_view.dart'; import 'package:fluffychat/pages/new_private_chat/qr_scanner_modal.dart'; -import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/fluffy_share.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import '../../widgets/adaptive_dialogs/user_dialog.dart'; class NewPrivateChat extends StatefulWidget { const NewPrivateChat({super.key}); @@ -98,12 +97,9 @@ class NewPrivateChatController extends State { ); } - void openUserModal(Profile profile) => showAdaptiveBottomSheet( + void openUserModal(Profile profile) => UserDialog.show( context: context, - builder: (c) => UserBottomSheet( - profile: profile, - outerContext: context, - ), + profile: profile, ); @override diff --git a/lib/pages/user_bottom_sheet/user_bottom_sheet.dart b/lib/pages/user_bottom_sheet/user_bottom_sheet.dart deleted file mode 100644 index e99d21c0b..000000000 --- a/lib/pages/user_bottom_sheet/user_bottom_sheet.dart +++ /dev/null @@ -1,285 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/config/themes.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/show_text_input_dialog.dart'; -import 'package:fluffychat/widgets/future_loading_dialog.dart'; -import 'package:fluffychat/widgets/permission_slider_dialog.dart'; -import '../../widgets/matrix.dart'; -import 'user_bottom_sheet_view.dart'; - -enum UserBottomSheetAction { - report, - mention, - ban, - kick, - unban, - message, - ignore, -} - -class LoadProfileBottomSheet extends StatelessWidget { - final String userId; - final BuildContext outerContext; - - const LoadProfileBottomSheet({ - super.key, - required this.userId, - required this.outerContext, - }); - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: Matrix.of(outerContext) - .client - .getUserProfile(userId) - .timeout(const Duration(seconds: 3)), - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done && - snapshot.data != null) { - return Scaffold( - appBar: AppBar( - leading: CloseButton( - onPressed: Navigator.of(context, rootNavigator: false).pop, - ), - ), - body: const Center( - child: CircularProgressIndicator.adaptive(), - ), - ); - } - return UserBottomSheet( - outerContext: outerContext, - profile: Profile( - userId: userId, - avatarUrl: snapshot.data?.avatarUrl, - displayName: snapshot.data?.displayname, - ), - profileSearchError: snapshot.error, - ); - }, - ); - } -} - -class UserBottomSheet extends StatefulWidget { - final User? user; - final Profile? profile; - final Function? onMention; - final BuildContext outerContext; - final Object? profileSearchError; - - const UserBottomSheet({ - super.key, - this.user, - this.profile, - required this.outerContext, - this.onMention, - this.profileSearchError, - }) : assert(user != null || profile != null); - - @override - UserBottomSheetController createState() => UserBottomSheetController(); -} - -class UserBottomSheetController extends State { - void participantAction(UserBottomSheetAction action) async { - final user = widget.user; - final userId = user?.id ?? widget.profile?.userId; - if (userId == null) throw ('user or profile must not be null!'); - - switch (action) { - case UserBottomSheetAction.report: - if (user == null) throw ('User must not be null for this action!'); - - final score = await showModalActionPopup( - context: context, - title: L10n.of(context).reportUser, - message: L10n.of(context).howOffensiveIsThisContent, - cancelLabel: L10n.of(context).cancel, - actions: [ - AdaptiveModalAction( - value: -100, - label: L10n.of(context).extremeOffensive, - ), - AdaptiveModalAction( - value: -50, - label: L10n.of(context).offensive, - ), - AdaptiveModalAction( - value: 0, - label: L10n.of(context).inoffensive, - ), - ], - ); - if (score == null) return; - final reason = await showTextInputDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context).whyDoYouWantToReportThis, - okLabel: L10n.of(context).ok, - cancelLabel: L10n.of(context).cancel, - hintText: L10n.of(context).reason, - ); - if (reason == null || reason.isEmpty) return; - - final result = await showFutureLoadingDialog( - context: context, - future: () => Matrix.of(widget.outerContext).client.reportEvent( - user.room.id, - user.id, - reason: reason, - score: score, - ), - ); - if (result.error != null) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(L10n.of(context).contentHasBeenReported)), - ); - break; - case UserBottomSheetAction.mention: - if (user == null) throw ('User must not be null for this action!'); - Navigator.of(context).pop(); - widget.onMention!(); - break; - case UserBottomSheetAction.ban: - if (user == null) throw ('User must not be null for this action!'); - if (await showOkCancelAlertDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context).areYouSure, - okLabel: L10n.of(context).yes, - cancelLabel: L10n.of(context).no, - message: L10n.of(context).banUserDescription, - ) == - OkCancelResult.ok) { - await showFutureLoadingDialog( - context: context, - future: () => user.ban(), - ); - Navigator.of(context).pop(); - } - break; - case UserBottomSheetAction.unban: - if (user == null) throw ('User must not be null for this action!'); - if (await showOkCancelAlertDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context).areYouSure, - okLabel: L10n.of(context).yes, - cancelLabel: L10n.of(context).no, - message: L10n.of(context).unbanUserDescription, - ) == - OkCancelResult.ok) { - await showFutureLoadingDialog( - context: context, - future: () => user.unban(), - ); - Navigator.of(context).pop(); - } - break; - case UserBottomSheetAction.kick: - if (user == null) throw ('User must not be null for this action!'); - if (await showOkCancelAlertDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context).areYouSure, - okLabel: L10n.of(context).yes, - cancelLabel: L10n.of(context).no, - message: L10n.of(context).kickUserDescription, - ) == - OkCancelResult.ok) { - await showFutureLoadingDialog( - context: context, - future: () => user.kick(), - ); - Navigator.of(context).pop(); - } - break; - case UserBottomSheetAction.message: - Navigator.of(context).pop(); - // Workaround for https://github.com/flutter/flutter/issues/27495 - await Future.delayed(FluffyThemes.animationDuration); - - final roomIdResult = await showFutureLoadingDialog( - context: widget.outerContext, - future: () => Matrix.of(widget.outerContext) - .client - .startDirectChat(user?.id ?? widget.profile!.userId), - ); - final roomId = roomIdResult.result; - if (roomId == null) return; - widget.outerContext.go('/rooms/$roomId'); - break; - case UserBottomSheetAction.ignore: - Navigator.of(context).pop(); - // Workaround for https://github.com/flutter/flutter/issues/27495 - await Future.delayed(FluffyThemes.animationDuration); - final userId = user?.id ?? widget.profile?.userId; - widget.outerContext - .go('/rooms/settings/security/ignorelist', extra: userId); - } - } - - Object? sendError; - - final TextEditingController sendController = TextEditingController(); - - void knockAccept() async { - final user = widget.user!; - final result = await showFutureLoadingDialog( - context: context, - future: () => user.room.invite(user.id), - ); - if (result.error != null) return; - Navigator.of(context).pop(); - } - - void knockDecline() async { - final user = widget.user!; - final result = await showFutureLoadingDialog( - context: context, - future: () => user.room.kick(user.id), - ); - if (result.error != null) return; - Navigator.of(context).pop(); - } - - void setPowerLevel(int? newLevel) async { - final user = widget.user; - if (user == null) throw ('User must not be null for this action!'); - - final level = newLevel ?? - await showPermissionChooser( - context, - currentLevel: user.powerLevel, - ); - if (level == null) return; - - if (level == 100) { - final consent = await showOkCancelAlertDialog( - useRootNavigator: false, - context: context, - title: L10n.of(context).areYouSure, - okLabel: L10n.of(context).yes, - cancelLabel: L10n.of(context).no, - message: L10n.of(context).makeAdminDescription, - ); - if (consent != OkCancelResult.ok) return; - } - - await showFutureLoadingDialog( - context: context, - future: () => user.setPower(level), - ); - } - - @override - Widget build(BuildContext context) => UserBottomSheetView(this); -} diff --git a/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart b/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart deleted file mode 100644 index 2974dec47..000000000 --- a/lib/pages/user_bottom_sheet/user_bottom_sheet_view.dart +++ /dev/null @@ -1,374 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; -import 'package:matrix/matrix.dart'; - -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/utils/date_time_extension.dart'; -import 'package:fluffychat/utils/fluffy_share.dart'; -import 'package:fluffychat/utils/url_launcher.dart'; -import 'package:fluffychat/widgets/avatar.dart'; -import 'package:fluffychat/widgets/presence_builder.dart'; -import 'package:fluffychat/widgets/qr_code_viewer.dart'; -import '../../widgets/matrix.dart'; -import 'user_bottom_sheet.dart'; - -class UserBottomSheetView extends StatelessWidget { - final UserBottomSheetController controller; - - const UserBottomSheetView(this.controller, {super.key}); - - @override - Widget build(BuildContext context) { - final user = controller.widget.user; - final userId = (user?.id ?? controller.widget.profile?.userId)!; - final displayname = (user?.calcDisplayname() ?? - controller.widget.profile?.displayName ?? - controller.widget.profile?.userId.localpart)!; - final avatarUrl = user?.avatarUrl ?? controller.widget.profile?.avatarUrl; - - final client = Matrix.of(controller.widget.outerContext).client; - final profileSearchError = controller.widget.profileSearchError; - final dmRoomId = client.getDirectChatFromUserId(userId); - return Scaffold( - appBar: AppBar( - leading: Center( - child: CloseButton( - onPressed: Navigator.of(context, rootNavigator: false).pop, - ), - ), - centerTitle: false, - title: Text(displayname), - actions: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: IconButton( - onPressed: () => showQrCodeViewer(context, userId), - icon: const Icon(Icons.qr_code_outlined), - ), - ), - ], - ), - body: StreamBuilder( - stream: user?.room.client.onSync.stream.where( - (syncUpdate) => - syncUpdate.rooms?.join?[user.room.id]?.timeline?.events?.any( - (state) => state.type == EventTypes.RoomPowerLevels, - ) ?? - false, - ), - builder: (context, snapshot) { - final theme = Theme.of(context); - return ListView( - children: [ - if (user?.membership == Membership.knock) - Padding( - padding: const EdgeInsets.all(12.0), - child: Material( - color: theme.colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - child: ListTile( - minVerticalPadding: 16, - title: Padding( - padding: const EdgeInsets.only(bottom: 12.0), - child: Text( - L10n.of(context) - .userWouldLikeToChangeTheChat(displayname), - ), - ), - subtitle: Row( - children: [ - TextButton.icon( - style: TextButton.styleFrom( - backgroundColor: theme.colorScheme.surface, - foregroundColor: theme.colorScheme.primary, - iconColor: theme.colorScheme.primary, - ), - onPressed: controller.knockAccept, - icon: const Icon(Icons.check_outlined), - label: Text(L10n.of(context).accept), - ), - const SizedBox(width: 12), - TextButton.icon( - style: TextButton.styleFrom( - backgroundColor: theme.colorScheme.errorContainer, - foregroundColor: - theme.colorScheme.onErrorContainer, - iconColor: theme.colorScheme.onErrorContainer, - ), - onPressed: controller.knockDecline, - icon: const Icon(Icons.cancel_outlined), - label: Text(L10n.of(context).decline), - ), - ], - ), - ), - ), - ), - Row( - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Avatar( - client: Matrix.of(controller.widget.outerContext).client, - mxContent: avatarUrl, - name: displayname, - size: Avatar.defaultSize * 2.5, - ), - ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextButton.icon( - onPressed: () => FluffyShare.share( - userId, - context, - copyOnly: true, - ), - icon: const Icon( - Icons.copy_outlined, - size: 14, - ), - style: TextButton.styleFrom( - foregroundColor: theme.colorScheme.onSurface, - iconColor: theme.colorScheme.onSurface, - ), - label: Text( - userId, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - PresenceBuilder( - userId: userId, - client: client, - builder: (context, presence) { - if (presence == null || - (presence.presence == PresenceType.offline && - presence.lastActiveTimestamp == null)) { - return const SizedBox.shrink(); - } - - final dotColor = presence.presence.isOnline - ? Colors.green - : presence.presence.isUnavailable - ? Colors.orange - : Colors.grey; - - final lastActiveTimestamp = - presence.lastActiveTimestamp; - - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 16), - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: dotColor, - borderRadius: BorderRadius.circular(16), - ), - ), - const SizedBox(width: 12), - if (presence.currentlyActive == true) - Text( - L10n.of(context).currentlyActive, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ) - else if (lastActiveTimestamp != null) - Text( - L10n.of(context).lastActiveAgo( - lastActiveTimestamp - .localizedTimeShort(context), - ), - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodySmall, - ), - ], - ); - }, - ), - ], - ), - ), - ], - ), - PresenceBuilder( - userId: userId, - client: client, - builder: (context, presence) { - final status = presence?.statusMsg; - if (status == null || status.isEmpty) { - return const SizedBox.shrink(); - } - return ListTile( - title: SelectableLinkify( - text: status, - style: const TextStyle(fontSize: 16), - options: const LinkifyOptions(humanize: false), - linkStyle: const TextStyle( - color: Colors.blueAccent, - decorationColor: Colors.blueAccent, - ), - onOpen: (url) => - UrlLauncher(context, url.url).launchUrl(), - ), - ); - }, - ), - if (userId != client.userID) - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, - vertical: 8.0, - ), - child: ElevatedButton.icon( - onPressed: () => controller.participantAction( - UserBottomSheetAction.message, - ), - icon: const Icon(Icons.forum_outlined), - label: Text( - dmRoomId == null - ? L10n.of(context).startConversation - : L10n.of(context).sendAMessage, - ), - ), - ), - if (controller.widget.onMention != null) - ListTile( - leading: const Icon(Icons.alternate_email_outlined), - title: Text(L10n.of(context).mention), - onTap: () => controller - .participantAction(UserBottomSheetAction.mention), - ), - if (user != null) ...[ - Divider(color: theme.dividerColor), - ListTile( - title: Text(L10n.of(context).userRole), - leading: const Icon(Icons.admin_panel_settings_outlined), - trailing: Material( - borderRadius: - BorderRadius.circular(AppConfig.borderRadius / 2), - color: theme.colorScheme.onInverseSurface, - child: DropdownButton( - onChanged: user.canChangeUserPowerLevel || - // Workaround until https://github.com/famedly/matrix-dart-sdk/pull/1765 - (user.room.canChangePowerLevel && - user.id == user.room.client.userID) - ? controller.setPowerLevel - : null, - value: {0, 50, 100}.contains(user.powerLevel) - ? user.powerLevel - : null, - padding: const EdgeInsets.symmetric(horizontal: 8.0), - borderRadius: - BorderRadius.circular(AppConfig.borderRadius / 2), - underline: const SizedBox.shrink(), - items: [ - DropdownMenuItem( - value: 0, - child: Text( - L10n.of(context).userLevel( - user.powerLevel < 50 ? user.powerLevel : 0, - ), - ), - ), - DropdownMenuItem( - value: 50, - child: Text( - L10n.of(context).moderatorLevel( - user.powerLevel >= 50 && user.powerLevel < 100 - ? user.powerLevel - : 50, - ), - ), - ), - DropdownMenuItem( - value: 100, - child: Text( - L10n.of(context).adminLevel( - user.powerLevel >= 100 ? user.powerLevel : 100, - ), - ), - ), - DropdownMenuItem( - value: null, - child: Text(L10n.of(context).custom), - ), - ], - ), - ), - ), - ], - Divider(color: theme.dividerColor), - if (user != null && user.canKick) - ListTile( - textColor: theme.colorScheme.error, - iconColor: theme.colorScheme.error, - title: Text(L10n.of(context).kickFromChat), - leading: const Icon(Icons.exit_to_app_outlined), - onTap: () => - controller.participantAction(UserBottomSheetAction.kick), - ), - if (user != null && - user.canBan && - user.membership != Membership.ban) - ListTile( - textColor: theme.colorScheme.onErrorContainer, - iconColor: theme.colorScheme.onErrorContainer, - title: Text(L10n.of(context).banFromChat), - leading: const Icon(Icons.warning_sharp), - onTap: () => - controller.participantAction(UserBottomSheetAction.ban), - ) - else if (user != null && - user.canBan && - user.membership == Membership.ban) - ListTile( - title: Text(L10n.of(context).unbanFromChat), - leading: const Icon(Icons.warning_outlined), - onTap: () => - controller.participantAction(UserBottomSheetAction.unban), - ), - if (user != null && user.id != client.userID) - ListTile( - textColor: theme.colorScheme.onErrorContainer, - iconColor: theme.colorScheme.onErrorContainer, - title: Text(L10n.of(context).reportUser), - leading: const Icon(Icons.gavel_outlined), - onTap: () => controller - .participantAction(UserBottomSheetAction.report), - ), - if (profileSearchError != null) - ListTile( - leading: const Icon( - Icons.warning_outlined, - color: Colors.orange, - ), - subtitle: Text( - L10n.of(context).profileNotFound, - style: const TextStyle(color: Colors.orange), - ), - ), - if (userId != client.userID && - !client.ignoredUsers.contains(userId)) - ListTile( - textColor: theme.colorScheme.onErrorContainer, - iconColor: theme.colorScheme.onErrorContainer, - leading: const Icon(Icons.block_outlined), - title: Text(L10n.of(context).block), - onTap: () => controller - .participantAction(UserBottomSheetAction.ignore), - ), - ], - ); - }, - ), - ); - } -} diff --git a/lib/utils/url_launcher.dart b/lib/utils/url_launcher.dart index 80efed80e..00e1c1ee8 100644 --- a/lib/utils/url_launcher.dart +++ b/lib/utils/url_launcher.dart @@ -1,19 +1,18 @@ -import 'package:flutter/material.dart'; - import 'package:collection/collection.dart' show IterableExtension; -import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:go_router/go_router.dart'; -import 'package:matrix/matrix.dart'; -import 'package:punycode/punycode.dart'; -import 'package:url_launcher/url_launcher_string.dart'; - import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pages/user_bottom_sheet/user_bottom_sheet.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.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/future_loading_dialog.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/public_room_bottom_sheet.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; +import 'package:punycode/punycode.dart'; +import 'package:url_launcher/url_launcher_string.dart'; + import 'platform_infos.dart'; class UrlLauncher { @@ -221,13 +220,22 @@ class UrlLauncher { } } } else if (identityParts.primaryIdentifier.sigil == '@') { - await showAdaptiveBottomSheet( + final userId = identityParts.primaryIdentifier; + var noProfileWarning = false; + final profileResult = await showFutureLoadingDialog( context: context, - builder: (c) => LoadProfileBottomSheet( - userId: identityParts.primaryIdentifier, - outerContext: context, + future: () => matrix.client.getProfileFromUserId(userId).catchError( + (_) { + noProfileWarning = true; + return Profile(userId: userId); + }, ), ); + await UserDialog.show( + context: context, + profile: profileResult.result!, + noProfileWarning: noProfileWarning, + ); } } } diff --git a/lib/widgets/adaptive_dialogs/adaptive_dialog_action.dart b/lib/widgets/adaptive_dialogs/adaptive_dialog_action.dart index 12ce7c8ae..6cf260872 100644 --- a/lib/widgets/adaptive_dialogs/adaptive_dialog_action.dart +++ b/lib/widgets/adaptive_dialogs/adaptive_dialog_action.dart @@ -5,12 +5,14 @@ class AdaptiveDialogAction extends StatelessWidget { final VoidCallback? onPressed; final bool autofocus; final Widget child; + final bool bigButtons; const AdaptiveDialogAction({ super.key, required this.onPressed, required this.child, this.autofocus = false, + this.bigButtons = false, }); @override @@ -21,6 +23,27 @@ class AdaptiveDialogAction extends StatelessWidget { case TargetPlatform.fuchsia: case TargetPlatform.linux: case TargetPlatform.windows: + if (bigButtons) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: autofocus + ? theme.colorScheme.primary + : theme.colorScheme.primaryContainer, + foregroundColor: autofocus + ? theme.colorScheme.onPrimary + : theme.colorScheme.onPrimaryContainer, + ), + onPressed: onPressed, + autofocus: autofocus, + child: child, + ), + ), + ); + } return TextButton( onPressed: onPressed, autofocus: autofocus, diff --git a/lib/widgets/adaptive_dialogs/user_dialog.dart b/lib/widgets/adaptive_dialogs/user_dialog.dart new file mode 100644 index 000000000..0aed4e97e --- /dev/null +++ b/lib/widgets/adaptive_dialogs/user_dialog.dart @@ -0,0 +1,180 @@ +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/utils/date_time_extension.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart'; +import 'package:fluffychat/widgets/avatar.dart'; +import 'package:fluffychat/widgets/presence_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.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 '../../utils/url_launcher.dart'; +import '../future_loading_dialog.dart'; +import '../hover_builder.dart'; +import '../matrix.dart'; + +class UserDialog extends StatelessWidget { + static Future show({ + required BuildContext context, + required Profile profile, + bool noProfileWarning = false, + }) => + showAdaptiveDialog( + context: context, + builder: (context) => UserDialog( + profile, + noProfileWarning: noProfileWarning, + ), + ); + + final Profile profile; + final bool noProfileWarning; + + const UserDialog(this.profile, {this.noProfileWarning = false, super.key}); + + @override + Widget build(BuildContext context) { + final client = Matrix.of(context).client; + final dmRoomId = client.getDirectChatFromUserId(profile.userId); + final displayname = profile.displayName ?? + profile.userId.localpart ?? + L10n.of(context).user; + var copied = false; + final theme = Theme.of(context); + return AlertDialog.adaptive( + title: Center(child: Text(displayname, textAlign: TextAlign.center)), + content: SelectionArea( + child: PresenceBuilder( + userId: profile.userId, + client: Matrix.of(context).client, + builder: (context, presence) { + if (presence == null) return const SizedBox.shrink(); + final statusMsg = presence.statusMsg; + final lastActiveTimestamp = presence.lastActiveTimestamp; + final presenceText = presence.currentlyActive == true + ? L10n.of(context).currentlyActive + : lastActiveTimestamp != null + ? L10n.of(context).lastActiveAgo( + lastActiveTimestamp.localizedTimeShort(context), + ) + : null; + return Column( + spacing: 8, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + HoverBuilder( + builder: (context, hovered) => StatefulBuilder( + builder: (context, setState) => GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: profile.userId)); + setState(() { + copied = true; + }); + }, + child: RichText( + text: TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: const EdgeInsets.only(right: 4.0), + child: AnimatedScale( + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + scale: hovered + ? 1.33 + : copied + ? 1.25 + : 1.0, + child: Icon( + copied ? Icons.check_circle : Icons.copy, + size: 12, + color: copied ? Colors.green : null, + ), + ), + ), + ), + TextSpan(text: profile.userId), + ], + style: theme.textTheme.bodyMedium + ?.copyWith(fontSize: 10), + ), + textAlign: TextAlign.center, + ), + ), + ), + ), + Avatar( + mxContent: profile.avatarUrl, + name: displayname, + size: Avatar.defaultSize * 2, + ), + if (presenceText != null) + Text( + presenceText, + style: const TextStyle(fontSize: 10), + ), + if (statusMsg != null) + Linkify( + text: statusMsg, + textAlign: TextAlign.center, + options: const LinkifyOptions(humanize: false), + linkStyle: TextStyle( + color: theme.colorScheme.primary, + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary, + ), + onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), + ), + ], + ); + }, + ), + ), + actions: [ + if (client.userID != profile.userId) ...[], + AdaptiveDialogAction( + bigButtons: true, + onPressed: () async { + final router = GoRouter.of(context); + Navigator.of(context).pop(); + final roomIdResult = await showFutureLoadingDialog( + context: context, + future: () => client.startDirectChat(profile.userId), + ); + final roomId = roomIdResult.result; + if (roomId == null) return; + router.go('/rooms/$roomId'); + }, + child: Text( + dmRoomId == null + ? L10n.of(context).startConversation + : L10n.of(context).sendAMessage, + ), + ), + AdaptiveDialogAction( + bigButtons: true, + onPressed: () { + final router = GoRouter.of(context); + Navigator.of(context).pop(); + router.go( + '/rooms/settings/security/ignorelist', + extra: profile.userId, + ); + }, + child: Text( + L10n.of(context).ignoreUser, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + AdaptiveDialogAction( + bigButtons: true, + onPressed: Navigator.of(context).pop, + child: Text(L10n.of(context).close), + ), + ], + ); + } +} diff --git a/lib/widgets/avatar.dart b/lib/widgets/avatar.dart index 1ce9933d7..17668b2be 100644 --- a/lib/widgets/avatar.dart +++ b/lib/widgets/avatar.dart @@ -1,8 +1,10 @@ +import 'package:flutter/material.dart'; + +import 'package:matrix/matrix.dart'; + import 'package:fluffychat/utils/string_color.dart'; import 'package:fluffychat/widgets/mxc_image.dart'; import 'package:fluffychat/widgets/presence_builder.dart'; -import 'package:flutter/material.dart'; -import 'package:matrix/matrix.dart'; class Avatar extends StatelessWidget { final Uri? mxContent; diff --git a/lib/widgets/future_loading_dialog.dart b/lib/widgets/future_loading_dialog.dart index 61a137c25..81d63ba45 100644 --- a/lib/widgets/future_loading_dialog.dart +++ b/lib/widgets/future_loading_dialog.dart @@ -20,6 +20,7 @@ Future> showFutureLoadingDialog({ bool barrierDismissible = false, bool delay = true, ExceptionContext? exceptionContext, + bool ignoreError = false, }) async { final futureExec = future(); final resultFuture = ResultFuture(futureExec); @@ -67,6 +68,7 @@ class LoadingDialog extends StatefulWidget { this.backLabel, this.exceptionContext, }); + @override LoadingDialogState createState() => LoadingDialogState(); } diff --git a/lib/widgets/member_actions_popup_menu_button.dart b/lib/widgets/member_actions_popup_menu_button.dart new file mode 100644 index 000000000..cc0fae68e --- /dev/null +++ b/lib/widgets/member_actions_popup_menu_button.dart @@ -0,0 +1,296 @@ +import 'package:fluffychat/widgets/permission_slider_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'adaptive_dialogs/show_modal_action_popup.dart'; +import 'adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; +import 'adaptive_dialogs/show_text_input_dialog.dart'; +import 'adaptive_dialogs/user_dialog.dart'; +import 'avatar.dart'; +import 'future_loading_dialog.dart'; + +class MemberActionsPopupMenuButton extends StatelessWidget { + final Widget child; + final User user; + final void Function()? onMention; + + const MemberActionsPopupMenuButton({ + required this.child, + required this.user, + this.onMention, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final displayname = user.calcDisplayname(); + final isMe = user.room.client.userID == user.id; + return PopupMenuButton( + onSelected: (action) async { + switch (action) { + case _MemberActions.mention: + onMention?.call(); + return; + case _MemberActions.setRole: + final power = await showPermissionChooser( + context, + currentLevel: user.powerLevel, + maxLevel: user.room.ownPowerLevel, + ); + if (power == null) return; + if (!context.mounted) return; + if (power >= 100) { + final consent = await showOkCancelAlertDialog( + context: context, + title: L10n.of(context).areYouSure, + message: L10n.of(context).makeAdminDescription, + ); + if (consent != OkCancelResult.ok) return; + if (!context.mounted) return; + } + await showFutureLoadingDialog( + context: context, + future: () => user.setPower(power), + ); + return; + case _MemberActions.kick: + if (await showOkCancelAlertDialog( + useRootNavigator: false, + context: context, + title: L10n.of(context).areYouSure, + okLabel: L10n.of(context).yes, + cancelLabel: L10n.of(context).no, + message: L10n.of(context).kickUserDescription, + ) == + OkCancelResult.ok) { + await showFutureLoadingDialog( + context: context, + future: () => user.kick(), + ); + } + return; + case _MemberActions.ban: + if (await showOkCancelAlertDialog( + useRootNavigator: false, + context: context, + title: L10n.of(context).areYouSure, + okLabel: L10n.of(context).yes, + cancelLabel: L10n.of(context).no, + message: L10n.of(context).banUserDescription, + ) == + OkCancelResult.ok) { + await showFutureLoadingDialog( + context: context, + future: () => user.ban(), + ); + } + return; + case _MemberActions.report: + final score = await showModalActionPopup( + context: context, + title: L10n.of(context).reportUser, + message: L10n.of(context).howOffensiveIsThisContent, + cancelLabel: L10n.of(context).cancel, + actions: [ + AdaptiveModalAction( + value: -100, + label: L10n.of(context).extremeOffensive, + ), + AdaptiveModalAction( + value: -50, + label: L10n.of(context).offensive, + ), + AdaptiveModalAction( + value: 0, + label: L10n.of(context).inoffensive, + ), + ], + ); + if (score == null) return; + final reason = await showTextInputDialog( + useRootNavigator: false, + context: context, + title: L10n.of(context).whyDoYouWantToReportThis, + okLabel: L10n.of(context).ok, + cancelLabel: L10n.of(context).cancel, + hintText: L10n.of(context).reason, + ); + if (reason == null || reason.isEmpty) return; + + final result = await showFutureLoadingDialog( + context: context, + future: () => user.room.client.reportEvent( + user.room.id, + user.id, + reason: reason, + score: score, + ), + ); + if (result.error != null) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(L10n.of(context).contentHasBeenReported)), + ); + return; + case _MemberActions.info: + await UserDialog.show( + context: context, + profile: Profile( + userId: user.id, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + ), + ); + return; + case _MemberActions.unban: + if (await showOkCancelAlertDialog( + useRootNavigator: false, + context: context, + title: L10n.of(context).areYouSure, + okLabel: L10n.of(context).yes, + cancelLabel: L10n.of(context).no, + message: L10n.of(context).unbanUserDescription, + ) == + OkCancelResult.ok) { + await showFutureLoadingDialog( + context: context, + future: () => user.unban(), + ); + } + return; + } + }, + itemBuilder: (context) => >[ + PopupMenuItem( + value: _MemberActions.info, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Avatar( + name: displayname, + mxContent: user.avatarUrl, + presenceUserId: user.id, + presenceBackgroundColor: theme.colorScheme.surfaceContainer, + ), + const SizedBox(height: 8), + Text( + displayname, + textAlign: TextAlign.center, + style: theme.textTheme.labelLarge, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + user.id, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 10), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + const PopupMenuDivider(), + if (onMention != null) + PopupMenuItem( + value: _MemberActions.mention, + child: ListTile( + leading: const Icon(Icons.alternate_email_outlined), + title: Text(L10n.of(context).mention), + ), + ), + PopupMenuItem( + enabled: + user.room.canChangePowerLevel && user.canChangeUserPowerLevel, + value: _MemberActions.setRole, + child: ListTile( + leading: const Icon(Icons.admin_panel_settings_outlined), + title: Text(L10n.of(context).chatPermissions), + subtitle: Text( + user.powerLevel < 50 + ? L10n.of(context).userLevel(user.powerLevel) + : user.powerLevel < 100 + ? L10n.of(context).moderatorLevel(user.powerLevel) + : L10n.of(context).adminLevel(user.powerLevel), + style: const TextStyle(fontSize: 10), + ), + ), + ), + if (user.canKick) + PopupMenuItem( + value: _MemberActions.kick, + child: ListTile( + leading: Icon( + Icons.person_remove_outlined, + color: theme.colorScheme.onErrorContainer, + ), + title: Text( + L10n.of(context).kickFromChat, + style: TextStyle(color: theme.colorScheme.onErrorContainer), + ), + ), + ), + if (user.canBan) + PopupMenuItem( + value: _MemberActions.ban, + child: ListTile( + leading: Icon( + Icons.block_outlined, + color: theme.colorScheme.onErrorContainer, + ), + title: Text( + L10n.of(context).banFromChat, + style: TextStyle(color: theme.colorScheme.onErrorContainer), + ), + ), + ), + if (user.canBan && user.membership == Membership.ban) + PopupMenuItem( + value: _MemberActions.ban, + child: ListTile( + leading: const Icon(Icons.warning), + title: Text( + L10n.of(context).unbanFromChat, + ), + ), + ), + if (user.canBan && user.membership == Membership.ban) + PopupMenuItem( + value: _MemberActions.unban, + child: ListTile( + leading: const Icon(Icons.warning_outlined), + title: Text(L10n.of(context).unbanFromChat), + ), + ), + if (!isMe) + PopupMenuItem( + value: _MemberActions.report, + child: ListTile( + leading: Icon( + Icons.gavel_outlined, + color: theme.colorScheme.onErrorContainer, + ), + title: Text( + L10n.of(context).reportUser, + style: TextStyle(color: theme.colorScheme.onErrorContainer), + ), + ), + ), + ], + child: child, + ); + } +} + +enum _MemberActions { + info, + mention, + setRole, + kick, + ban, + unban, + report, +} diff --git a/lib/widgets/permission_slider_dialog.dart b/lib/widgets/permission_slider_dialog.dart index cdff7b5cb..76bc8f6a3 100644 --- a/lib/widgets/permission_slider_dialog.dart +++ b/lib/widgets/permission_slider_dialog.dart @@ -1,31 +1,72 @@ -import 'package:flutter/cupertino.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart'; +import 'package:fluffychat/widgets/adaptive_dialogs/dialog_text_field.dart'; import 'package:flutter/material.dart'; - import 'package:flutter_gen/gen_l10n/l10n.dart'; -import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; - Future showPermissionChooser( BuildContext context, { int currentLevel = 0, + int maxLevel = 100, }) async { - final customLevel = await showTextInputDialog( + final controller = TextEditingController(); + final error = ValueNotifier(null); + return await showAdaptiveDialog( context: context, - title: L10n.of(context).setPermissionsLevel, - initialText: currentLevel.toString(), - keyboardType: TextInputType.number, - autocorrect: false, - validator: (text) { - if (text.isEmpty) { - return L10n.of(context).pleaseEnterANumber; - } - final level = int.tryParse(text); - if (level == null) { - return L10n.of(context).pleaseEnterANumber; - } - return null; - }, + builder: (context) => AlertDialog.adaptive( + title: Text(L10n.of(context).chatPermissions), + content: Column( + mainAxisSize: MainAxisSize.min, + spacing: 12.0, + children: [ + Text(L10n.of(context).setPermissionsLevelDescription), + ValueListenableBuilder( + valueListenable: error, + builder: (context, errorText, _) => DialogTextField( + controller: controller, + hintText: currentLevel.toString(), + keyboardType: TextInputType.number, + labelText: L10n.of(context).custom, + errorText: errorText, + ), + ), + ], + ), + actions: [ + AdaptiveDialogAction( + bigButtons: true, + onPressed: () { + final level = int.tryParse(controller.text.trim()); + if (level == null) { + error.value = L10n.of(context).pleaseEnterANumber; + return; + } + if (level > maxLevel) { + error.value = L10n.of(context).noPermission; + return; + } + Navigator.of(context).pop(level); + }, + child: Text(L10n.of(context).setCustomPermissionLevel), + ), + if (maxLevel >= 100 && currentLevel != 100) + AdaptiveDialogAction( + bigButtons: true, + onPressed: () => Navigator.of(context).pop(100), + child: Text(L10n.of(context).admin), + ), + if (maxLevel >= 50 && currentLevel != 50) + AdaptiveDialogAction( + bigButtons: true, + onPressed: () => Navigator.of(context).pop(50), + child: Text(L10n.of(context).moderator), + ), + if (currentLevel != 0) + AdaptiveDialogAction( + bigButtons: true, + onPressed: () => Navigator.of(context).pop(0), + child: Text(L10n.of(context).normalUser), + ), + ], + ), ); - if (customLevel == null) return null; - return int.tryParse(customLevel); }