diff --git a/integration_test/flows/multi_account.dart b/integration_test/flows/multi_account.dart index 1ab254a..93d202d 100644 --- a/integration_test/flows/multi_account.dart +++ b/integration_test/flows/multi_account.dart @@ -19,7 +19,7 @@ extension on TatuChatTester { await ensureLoggedIn(); // Login user 2 - await tapOn(Key('accounts_and_settings_buttons')); + await tapOn(Icons.menu); await tapOn('Add account'); await login(username: user2Name, password: user2Pw); await initCryptoIdentity(username: user2Name); diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index affdf88..8cf1948 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -204,6 +204,8 @@ class ChatController extends State bool showEmojiPicker = false; + bool showAttachmentPicker = false; + String? get threadLastEventId { final threadId = activeThreadId; if (threadId == null) return null; @@ -978,18 +980,40 @@ class ChatController extends State setState(() => showEmojiPicker = false); } + void hideAttachmentPicker() { + setState(() => showAttachmentPicker = false); + } + void emojiPickerAction() { if (showEmojiPicker) { inputFocus.requestFocus(); } else { inputFocus.unfocus(); } - setState(() => showEmojiPicker = !showEmojiPicker); + setState(() { + showEmojiPicker = !showEmojiPicker; + showAttachmentPicker = false; + }); + } + + void attachmentPickerAction() { + if (showAttachmentPicker) { + inputFocus.requestFocus(); + } else { + inputFocus.unfocus(); + } + setState(() { + showAttachmentPicker = !showAttachmentPicker; + showEmojiPicker = false; + }); } void _inputFocusListener() { - if (showEmojiPicker && inputFocus.hasFocus) { - setState(() => showEmojiPicker = false); + if ((showEmojiPicker || showAttachmentPicker) && inputFocus.hasFocus) { + setState(() { + showEmojiPicker = false; + showAttachmentPicker = false; + }); } } @@ -1016,6 +1040,7 @@ class ChatController extends State Clipboard.setData(ClipboardData(text: _getSelectedEventString())); setState(() { showEmojiPicker = false; + showAttachmentPicker = false; selectedEvents.clear(); }); } @@ -1044,6 +1069,7 @@ class ChatController extends State if (!mounted) return; setState(() { showEmojiPicker = false; + showAttachmentPicker = false; selectedEvents.clear(); }); scaffoldMessenger.showSnackBar( @@ -1120,6 +1146,7 @@ class ChatController extends State ); setState(() { showEmojiPicker = false; + showAttachmentPicker = false; selectedEvents.clear(); }); } @@ -1305,6 +1332,7 @@ class ChatController extends State void clearSelectedEvents() => setState(() { selectedEvents.clear(); showEmojiPicker = false; + showAttachmentPicker = false; }); void clearSingleSelectedEvent() { diff --git a/lib/pages/chat/chat_attachment_picker.dart b/lib/pages/chat/chat_attachment_picker.dart new file mode 100644 index 0000000..51cac73 --- /dev/null +++ b/lib/pages/chat/chat_attachment_picker.dart @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: 2019-Present Christian Kußowski +// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:tatchat/config/themes.dart'; +import 'package:tatchat/l10n/l10n.dart'; +import 'package:tatchat/pages/chat/chat.dart'; +import 'package:tatchat/utils/platform_infos.dart'; +import 'package:material_ui/material_ui.dart'; + +class ChatAttachmentPicker extends StatelessWidget { + final ChatController controller; + + const ChatAttachmentPicker(this.controller, {super.key}); + + static const double _itemSpacing = 8; + static const double _handleSize = 4; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final l10n = L10n.of(context); + final mobile = PlatformInfos.isMobile; + + final items = <(IconData, String, AddPopupMenuActions)>[ + if (mobile) + ( + Icons.gps_fixed_outlined, + l10n.shareLocation, + AddPopupMenuActions.location, + ), + (Icons.poll_outlined, l10n.startPoll, AddPopupMenuActions.poll), + if (mobile) + ( + Icons.videocam_outlined, + l10n.recordAVideo, + AddPopupMenuActions.videoCamera, + ), + if (mobile) + ( + Icons.camera_alt_outlined, + l10n.takeAPhoto, + AddPopupMenuActions.photoCamera, + ), + (Icons.image_outlined, l10n.openGallery, AddPopupMenuActions.media), + (Icons.attachment_outlined, l10n.sendFile, AddPopupMenuActions.file), + ]; + if (items.isEmpty) return const SizedBox.shrink(); + + final rows = >[]; + for (var i = 0; i < items.length; i += 3) { + rows.add(items.sublist(i, i + 3 > items.length ? items.length : i + 3)); + } + + return ClipRRect( + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + child: AnimatedSize( + duration: FluffyThemes.animationDuration, + curve: FluffyThemes.animationCurve, + alignment: Alignment.bottomCenter, + child: controller.showAttachmentPicker + ? Material( + color: theme.colorScheme.surfaceContainer, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 12), + child: Column( + mainAxisSize: .min, + children: [ + Container( + width: 32, + height: _handleSize, + decoration: BoxDecoration( + color: theme.colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(_handleSize / 2), + ), + ), + const SizedBox(height: 12), + Column( + mainAxisSize: .min, + children: [ + for (final (i, row) in rows.indexed) ...[ + if (i > 0) const SizedBox(height: _itemSpacing), + Row( + children: [ + for (final item in row) ...[ + if (row.indexOf(item) > 0) + const SizedBox(width: _itemSpacing), + Expanded( + child: AspectRatio( + aspectRatio: 1, + child: _AttachmentItem( + icon: item.$1, + label: item.$2, + onTap: () { + controller.hideAttachmentPicker(); + controller + .onAddPopupMenuButtonSelected( + item.$3, + ); + }, + ), + ), + ), + ], + if (row.length < 3) + for (var i = 0; i < 3 - row.length; i++) ...[ + const SizedBox(width: _itemSpacing), + const Expanded(child: SizedBox()), + ], + ], + ), + ], + ], + ), + ], + ), + ), + ) + : const SizedBox.shrink(), + ), + ); + } +} + +class _AttachmentItem extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _AttachmentItem({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(20), + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: onTap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 30, color: theme.colorScheme.onSecondaryContainer), + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/chat/chat_input_row.dart b/lib/pages/chat/chat_input_row.dart index 580d50b..9898be6 100644 --- a/lib/pages/chat/chat_input_row.dart +++ b/lib/pages/chat/chat_input_row.dart @@ -141,102 +141,10 @@ class ChatInputRow extends StatelessWidget { alignment: Alignment.center, decoration: const BoxDecoration(), clipBehavior: Clip.hardEdge, - child: PopupMenuButton( - useRootNavigator: true, + child: IconButton( icon: const Icon(Icons.add_circle_outline), - iconColor: theme.colorScheme.onPrimaryContainer, - onSelected: controller.onAddPopupMenuButtonSelected, - itemBuilder: (BuildContext context) => [ - if (PlatformInfos.isMobile) - PopupMenuItem( - value: AddPopupMenuActions.location, - child: ListTile( - leading: CircleAvatar( - backgroundColor: - theme.colorScheme.onPrimaryContainer, - foregroundColor: - theme.colorScheme.primaryContainer, - child: const Icon(Icons.gps_fixed_outlined), - ), - title: Text(L10n.of(context).shareLocation), - contentPadding: const EdgeInsets.all(0), - ), - ), - PopupMenuItem( - value: AddPopupMenuActions.poll, - child: ListTile( - leading: CircleAvatar( - backgroundColor: - theme.colorScheme.onPrimaryContainer, - foregroundColor: - theme.colorScheme.primaryContainer, - child: const Icon(Icons.poll_outlined), - ), - title: Text(L10n.of(context).startPoll), - contentPadding: const EdgeInsets.all(0), - ), - ), - PopupMenuDivider(), - if (PlatformInfos.isMobile) ...[ - PopupMenuItem( - value: AddPopupMenuActions.videoCamera, - child: ListTile( - leading: CircleAvatar( - backgroundColor: - theme.colorScheme.onPrimaryContainer, - foregroundColor: - theme.colorScheme.primaryContainer, - child: const Icon(Icons.videocam_outlined), - ), - title: Text(L10n.of(context).recordAVideo), - contentPadding: const EdgeInsets.all(0), - ), - ), - PopupMenuItem( - value: AddPopupMenuActions.photoCamera, - child: ListTile( - leading: CircleAvatar( - backgroundColor: - theme.colorScheme.onPrimaryContainer, - foregroundColor: - theme.colorScheme.primaryContainer, - child: const Icon(Icons.camera_alt_outlined), - ), - title: Text(L10n.of(context).takeAPhoto), - contentPadding: const EdgeInsets.all(0), - ), - ), - PopupMenuDivider(), - ], - PopupMenuItem( - value: AddPopupMenuActions.media, - child: ListTile( - leading: CircleAvatar( - backgroundColor: - theme.colorScheme.onPrimaryContainer, - foregroundColor: - theme.colorScheme.primaryContainer, - child: const Icon(Icons.image_outlined), - ), - title: Text(L10n.of(context).openGallery), - contentPadding: const EdgeInsets.all(0), - ), - ), - PopupMenuItem( - value: AddPopupMenuActions.file, - child: ListTile( - leading: CircleAvatar( - backgroundColor: - theme.colorScheme.onPrimaryContainer, - foregroundColor: - theme.colorScheme.primaryContainer, - child: const Icon(Icons.attachment_outlined), - ), - title: Text(L10n.of(context).sendFile), - contentPadding: const EdgeInsets.all(0), - ), - ), - ], + color: theme.colorScheme.onPrimaryContainer, + onPressed: controller.attachmentPickerAction, ), ), Container( diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index a533d72..8bc00ef 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -27,6 +27,7 @@ import 'package:matrix/matrix.dart'; import '../../utils/matrix_sdk_extensions/room_extension.dart'; import '../../utils/stream_extension.dart'; +import 'chat_attachment_picker.dart'; import 'chat_emoji_picker.dart'; import 'chat_input_row.dart'; @@ -57,6 +58,7 @@ class ChatView extends StatelessWidget { canPop: controller.selectedEvents.isEmpty && !controller.showEmojiPicker && + !controller.showAttachmentPicker && controller.activeThreadId == null, onPopInvokedWithResult: (pop, _) async { if (pop) return; @@ -64,6 +66,8 @@ class ChatView extends StatelessWidget { controller.clearSelectedEvents(); } else if (controller.showEmojiPicker) { controller.emojiPickerAction(); + } else if (controller.showAttachmentPicker) { + controller.attachmentPickerAction(); } else if (controller.activeThreadId != null) { controller.closeThread(); } @@ -473,6 +477,9 @@ class ChatView extends StatelessWidget { children: [ ReplyDisplay(controller), ChatInputRow(controller), + ChatAttachmentPicker( + controller, + ), ChatEmojiPicker(controller), ], ), diff --git a/lib/pages/chat/send_file_dialog.dart b/lib/pages/chat/send_file_dialog.dart index 68dad71..3cffc8c 100644 --- a/lib/pages/chat/send_file_dialog.dart +++ b/lib/pages/chat/send_file_dialog.dart @@ -31,6 +31,14 @@ import 'package:tatchat/widgets/adaptive_dialogs/dialog_text_field.dart'; import '../../utils/resize_video.dart'; +/// Opens the crop/draw/rotate image editor with [bytes] and returns the edited +/// PNG bytes, or null if the user cancelled or made no changes. +Future editImageBytes(BuildContext context, Uint8List bytes) => + showScaffoldDialog( + context: context, + builder: (_) => _ImageEditPage(bytes: bytes), + ); + class SendFileDialog extends StatefulWidget { final Room room; final List files; diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart index 07dc57d..e53db5e 100644 --- a/lib/pages/chat_list/chat_list.dart +++ b/lib/pages/chat_list/chat_list.dart @@ -22,6 +22,7 @@ import 'package:tatchat/pages/chat_list/chat_list_view.dart'; import 'package:tatchat/pages/chat_list/tabs/home_tab.dart'; import 'package:tatchat/utils/error_reporter.dart'; import 'package:tatchat/utils/localized_exception_extension.dart'; +import 'package:tatchat/utils/matrix_sdk_extensions/client_presence_extension.dart'; import 'package:tatchat/utils/matrix_sdk_extensions/matrix_locals.dart'; import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart'; import 'package:tatchat/utils/platform_infos.dart'; @@ -30,7 +31,6 @@ import 'package:tatchat/utils/show_update_snackbar.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_modal_action_popup.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; -import 'package:tatchat/widgets/avatar.dart'; import 'package:tatchat/widgets/future_loading_dialog.dart'; import 'package:tatchat/widgets/share_scaffold_dialog.dart'; @@ -514,21 +514,7 @@ class ChatListController extends State BuildContext posContext, [ Room? space, ]) async { - final overlay = - Overlay.of(posContext).context.findRenderObject() as RenderBox; - - final button = posContext.findRenderObject() as RenderBox; - - final position = RelativeRect.fromRect( - Rect.fromPoints( - button.localToGlobal(const Offset(0, -65), ancestor: overlay), - button.localToGlobal( - button.size.bottomRight(Offset.zero) + const Offset(-50, 0), - ancestor: overlay, - ), - ), - Offset.zero & overlay.size, - ); + final l10n = L10n.of(posContext); final spacesWithPowerLevels = room.client.rooms .where( @@ -539,202 +525,104 @@ class ChatListController extends State ) .toList(); - var action = await showMenu( + var action = await showModalActionPopup( context: posContext, - position: position, - items: [ + cancelLabel: l10n.cancel, + actions: [ if (space != null) - PopupMenuItem( + AdaptiveModalAction( value: ChatContextAction.goToSpace, - child: Row( - mainAxisSize: .min, - children: [ - Avatar( - mxContent: space.avatar, - size: Avatar.defaultSize / 2, - name: space.getLocalizedDisplayname(), - ), - const SizedBox(width: 12), - Text( - L10n.of(context).goToSpace(space.getLocalizedDisplayname()), - ), - ], - ), + label: l10n.goToSpace(space.getLocalizedDisplayname()), + icon: const Icon(Icons.workspaces_outlined), ), if (room.membership == Membership.join) ...[ - PopupMenuItem( + AdaptiveModalAction( value: ChatContextAction.mute, - child: Row( - mainAxisSize: .min, - children: [ - Icon( - room.pushRuleState == PushRuleState.notify - ? Icons.notifications_off_outlined - : Icons.notifications_off, - ), - const SizedBox(width: 12), - Text( - room.pushRuleState == PushRuleState.notify - ? L10n.of(context).muteChat - : L10n.of(context).unmuteChat, - ), - ], + label: room.pushRuleState == PushRuleState.notify + ? l10n.muteChat + : l10n.unmuteChat, + icon: Icon( + room.pushRuleState == PushRuleState.notify + ? Icons.notifications_off_outlined + : Icons.notifications_off, ), ), - PopupMenuItem( + AdaptiveModalAction( value: ChatContextAction.markUnread, - child: Row( - mainAxisSize: .min, - children: [ - Icon( - room.markedUnread - ? Icons.mark_as_unread - : Icons.mark_as_unread_outlined, - ), - const SizedBox(width: 12), - Text( - room.markedUnread - ? L10n.of(context).markAsRead - : L10n.of(context).markAsUnread, - ), - ], + label: room.markedUnread ? l10n.markAsRead : l10n.markAsUnread, + icon: Icon( + room.markedUnread + ? Icons.mark_as_unread + : Icons.mark_as_unread_outlined, ), ), if (!room.isLowPriority) - PopupMenuItem( + AdaptiveModalAction( value: ChatContextAction.favorite, - child: Row( - mainAxisSize: .min, - children: [ - Icon( - room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined, - ), - const SizedBox(width: 12), - Text( - room.isFavourite - ? L10n.of(context).unpin - : L10n.of(context).pin, - ), - ], + label: room.isFavourite ? l10n.unpin : l10n.pin, + icon: Icon( + room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined, ), ), ], - PopupMenuItem( + AdaptiveModalAction( value: ChatContextAction.leave, - child: Row( - mainAxisSize: .min, - children: [ - Icon( - Icons.delete_outlined, - color: Theme.of(context).colorScheme.onErrorContainer, - ), - const SizedBox(width: 12), - Text( - room.membership == Membership.invite - ? L10n.of(context).delete - : L10n.of(context).leave, - style: TextStyle( - color: Theme.of(context).colorScheme.onErrorContainer, - ), - ), - ], - ), + label: room.membership == Membership.invite + ? l10n.delete + : l10n.leave, + icon: const Icon(Icons.delete_outlined), + isDestructive: true, ), if (room.membership == Membership.invite) - PopupMenuItem( + AdaptiveModalAction( value: ChatContextAction.block, - child: Row( - mainAxisSize: .min, - children: [ - Icon( - Icons.block_outlined, - color: Theme.of(context).colorScheme.onErrorContainer, - ), - const SizedBox(width: 12), - Text( - L10n.of(context).block, - style: TextStyle( - color: Theme.of(context).colorScheme.onErrorContainer, - ), - ), - ], - ), + label: l10n.block, + icon: const Icon(Icons.block_outlined), + isDestructive: true, ), if (room.membership == Membership.join) - PopupMenuItem( + AdaptiveModalAction( value: ChatContextAction.showMore, - child: Row( - mainAxisSize: .min, - children: [ - Icon(Icons.adaptive.more_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).more), - ], - ), + label: l10n.more, + icon: Icon(Icons.adaptive.more_outlined), ), ], ); if (!posContext.mounted || !mounted) return; if (action == ChatContextAction.showMore) { - action = await showMenu( + action = await showModalActionPopup( context: posContext, - position: position, - items: [ + cancelLabel: l10n.cancel, + actions: [ if (!room.isFavourite) - PopupMenuItem( + AdaptiveModalAction( value: ChatContextAction.lowPriority, - child: Row( - mainAxisSize: .min, - children: [ - Icon( - room.isLowPriority - ? Icons.low_priority - : Icons.low_priority_outlined, - ), - const SizedBox(width: 12), - Text( - room.isLowPriority - ? L10n.of(context).unsetLowPriority - : L10n.of(context).setLowPriority, - ), - ], + label: room.isLowPriority + ? l10n.unsetLowPriority + : l10n.setLowPriority, + icon: Icon( + room.isLowPriority + ? Icons.low_priority + : Icons.low_priority_outlined, ), ), if (activeTag == null) - PopupMenuItem( + AdaptiveModalAction( value: ChatContextAction.addTag, - child: Row( - mainAxisSize: .min, - children: [ - Icon(Icons.bookmark_add_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).addTag), - ], - ), + label: l10n.addTag, + icon: const Icon(Icons.bookmark_add_outlined), ) else - PopupMenuItem( + AdaptiveModalAction( value: ChatContextAction.removeTag, - child: Row( - mainAxisSize: .min, - children: [ - Icon(Icons.bookmark_remove_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).removeTag), - ], - ), + label: l10n.removeTag, + icon: const Icon(Icons.bookmark_remove_outlined), ), if (spacesWithPowerLevels.isNotEmpty) - PopupMenuItem( + AdaptiveModalAction( value: ChatContextAction.addToSpace, - child: Row( - mainAxisSize: .min, - children: [ - const Icon(Icons.group_work_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).addToSpace), - ], - ), + label: l10n.addToSpace, + icon: const Icon(Icons.group_work_outlined), ), ], ); @@ -909,11 +797,7 @@ class ChatListController extends State if (!mounted) return; await showFutureLoadingDialog( context: context, - future: () => client.setPresence( - client.userID!, - PresenceType.online, - statusMsg: input, - ), + future: () => client.setStatusMessage(input), ); } diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart index ffd738f..cda6076 100644 --- a/lib/pages/chat_list/client_chooser_button.dart +++ b/lib/pages/chat_list/client_chooser_button.dart @@ -4,255 +4,46 @@ // SPDX-License-Identifier: AGPL-3.0-or-later import 'package:async/async.dart'; -import 'package:go_router/go_router.dart'; import 'package:material_ui/material_ui.dart'; import 'package:matrix/matrix.dart' hide Result; -import 'package:tatchat/config/themes.dart'; import 'package:tatchat/l10n/l10n.dart'; -import 'package:tatchat/utils/tatu_share.dart'; import 'package:tatchat/widgets/avatar.dart'; import 'package:tatchat/widgets/matrix.dart'; +import 'package:tatchat/widgets/self_profile_dialog.dart'; import 'chat_list.dart'; +/// The header avatar of the active account. Tapping it opens the own profile +/// dialog. Account switching and the former avatar popup actions live in the +/// navigation drawer (Sidebar). class ClientChooserButton extends StatelessWidget { final ChatListController controller; const ClientChooserButton(this.controller, {super.key}); - List> _bundleMenuItems(BuildContext context) { - final matrix = Matrix.of(context); - final bundles = matrix.accountBundles.keys.toList() - ..sort( - (a, b) => a!.isValidMatrixIdStrict() == b!.isValidMatrixIdStrict() - ? 0 - : a.isValidMatrixIdStrict() && !b.isValidMatrixIdStrict() - ? -1 - : 1, - ); - if (FluffyThemes.isColumnMode(context)) { - // In column mode the settings/actions live in the navigation sidebar, - // so the avatar popup only offers the account switcher. - return >[ - ..._bundleEntries(context, matrix, bundles), - PopupMenuItem( - value: SettingsAction.addAccount, - child: Row( - children: [ - const Icon(Icons.person_add_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).addAccount), - ], - ), - ), - ]; - } - return >[ - PopupMenuItem( - value: SettingsAction.newGroup, - child: Row( - children: [ - const Icon(Icons.group_add_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).createGroup), - ], - ), - ), - PopupMenuItem( - value: SettingsAction.setStatus, - child: Row( - children: [ - const Icon(Icons.edit_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).setStatus), - ], - ), - ), - PopupMenuItem( - value: SettingsAction.invite, - child: Row( - children: [ - Icon(Icons.adaptive.share_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).inviteContact), - ], - ), - ), - PopupMenuItem( - value: SettingsAction.archive, - child: Row( - children: [ - const Icon(Icons.archive_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).archive), - ], - ), - ), - PopupMenuItem( - value: SettingsAction.apps, - child: Row( - children: [ - const Icon(Icons.apps_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).apps), - ], - ), - ), - PopupMenuItem( - value: SettingsAction.settings, - child: Row( - children: [ - const Icon(Icons.settings_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).settings), - ], - ), - ), - const PopupMenuDivider(), - ..._bundleEntries(context, matrix, bundles), - PopupMenuItem( - value: SettingsAction.addAccount, - child: Row( - children: [ - const Icon(Icons.person_add_outlined), - const SizedBox(width: 18), - Text(L10n.of(context).addAccount), - ], - ), - ), - ]; - } - - List> _bundleEntries( - BuildContext context, - MatrixState matrix, - List bundles, - ) { - return >[ - for (final bundle in bundles) ...[ - if (matrix.accountBundles[bundle]!.length != 1 || - matrix.accountBundles[bundle]!.single!.userID != bundle) - PopupMenuItem( - value: null, - child: Column( - crossAxisAlignment: .start, - mainAxisSize: .min, - children: [ - Text( - bundle!, - style: TextStyle( - color: Theme.of(context).textTheme.titleMedium!.color, - fontSize: 14, - ), - ), - const Divider(height: 1), - ], - ), - ), - ...matrix.accountBundles[bundle]! - .whereType() - .where((client) => client.isLogged()) - .map( - (client) => PopupMenuItem( - value: client, - child: FutureBuilder( - future: client.fetchOwnProfile(), - builder: (context, snapshot) { - final displayname = - snapshot.data?.displayName ?? client.userID!.localpart!; - return Row( - key: ValueKey('switch_account_$displayname'), - children: [ - Avatar( - mxContent: snapshot.data?.avatarUrl, - name: displayname, - size: 32, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - displayname, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 12), - IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: () => controller.editBundlesForAccount( - client.userID, - bundle, - ), - ), - ], - ); - }, - ), - ), - ), - ], - ]; - } - @override Widget build(BuildContext context) { final matrix = Matrix.of(context); final client = Result(() => matrix.client).asValue?.value; - - var clientCount = 0; - matrix.accountBundles.forEach((key, value) => clientCount += value.length); return FutureBuilder( future: client?.isLogged() == true ? client?.fetchOwnProfile() : null, builder: (context, snapshot) => Material( clipBehavior: Clip.hardEdge, borderRadius: BorderRadius.circular(99), color: Colors.transparent, - child: PopupMenuButton( - key: Key('accounts_and_settings_buttons'), - tooltip: L10n.of(context).accountsAndSettings, - onSelected: (o) => _clientSelected(o, context), - itemBuilder: _bundleMenuItems, - icon: Avatar( - mxContent: snapshot.data?.avatarUrl, - name: snapshot.data?.displayName ?? client?.userID?.localpart, - size: 32, + child: Tooltip( + message: L10n.of(context).accountsAndSettings, + child: InkWell( + key: const Key('accounts_and_settings_buttons'), + onTap: () => showSelfProfileDialog(context), + child: Avatar( + mxContent: snapshot.data?.avatarUrl, + name: snapshot.data?.displayName ?? client?.userID?.localpart, + size: 32, + ), ), ), ), ); } - - Future _clientSelected(Object object, BuildContext context) async { - if (object is Client) { - controller.setActiveClient(object); - } else if (object is String) { - controller.setActiveBundle(object); - } else if (object is SettingsAction) { - switch (object) { - case SettingsAction.addAccount: - if (!context.mounted) return; - context.go('/rooms/settings/addaccount'); - break; - case SettingsAction.newGroup: - context.go('/rooms/newgroup'); - break; - case SettingsAction.invite: - TatuShare.shareInviteLink(context); - break; - case SettingsAction.settings: - context.go('/rooms/settings'); - break; - case SettingsAction.archive: - context.go('/rooms/archive'); - break; - case SettingsAction.apps: - context.go('/rooms/apps'); - break; - case SettingsAction.setStatus: - controller.setStatus(); - break; - } - } - } } - -enum SettingsAction { addAccount, newGroup, setStatus, invite, settings, archive, apps } diff --git a/lib/pages/chat_list/space_view.dart b/lib/pages/chat_list/space_view.dart index 3e5ebce..be1bed5 100644 --- a/lib/pages/chat_list/space_view.dart +++ b/lib/pages/chat_list/space_view.dart @@ -17,6 +17,7 @@ import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart'; import 'package:tatchat/utils/stream_extension.dart'; import 'package:tatchat/utils/string_color.dart'; import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.dart'; +import 'package:tatchat/widgets/adaptive_dialogs/show_modal_action_popup.dart'; import 'package:tatchat/widgets/avatar.dart'; import 'package:tatchat/widgets/future_loading_dialog.dart'; import 'package:tatchat/widgets/hover_builder.dart'; @@ -219,108 +220,50 @@ class _SpaceViewState extends State { final space = client.getRoomById(widget.spaceId); final room = client.getRoomById(roomId); if (space == null) return; - final overlay = - Overlay.of(posContext).context.findRenderObject() as RenderBox; + final l10n = L10n.of(posContext); - final button = posContext.findRenderObject() as RenderBox; - - final position = RelativeRect.fromRect( - Rect.fromPoints( - button.localToGlobal(const Offset(0, -65), ancestor: overlay), - button.localToGlobal( - button.size.bottomRight(Offset.zero) + const Offset(-50, 0), - ancestor: overlay, - ), - ), - Offset.zero & overlay.size, - ); - - final action = await showMenu( + final action = await showModalActionPopup( context: posContext, - position: position, - items: [ + cancelLabel: l10n.cancel, + actions: [ if (room != null && room.membership == Membership.join) ...[ - PopupMenuItem( + AdaptiveModalAction( value: room.pushRuleState == PushRuleState.notify ? SpaceChildAction.mute : SpaceChildAction.unmute, - child: Row( - mainAxisSize: .min, - children: [ - Icon( - room.pushRuleState == PushRuleState.notify - ? Icons.notifications_off_outlined - : Icons.notifications_on_outlined, - ), - const SizedBox(width: 12), - Text( - room.pushRuleState == PushRuleState.notify - ? L10n.of(context).muteChat - : L10n.of(context).unmuteChat, - ), - ], + label: room.pushRuleState == PushRuleState.notify + ? l10n.muteChat + : l10n.unmuteChat, + icon: Icon( + room.pushRuleState == PushRuleState.notify + ? Icons.notifications_off_outlined + : Icons.notifications_on_outlined, ), ), - PopupMenuItem( + AdaptiveModalAction( value: room.markedUnread ? SpaceChildAction.markAsRead : SpaceChildAction.markAsUnread, - child: Row( - mainAxisSize: .min, - children: [ - Icon( - room.markedUnread - ? Icons.mark_as_unread - : Icons.mark_as_unread_outlined, - ), - const SizedBox(width: 12), - Text( - room.isUnread - ? L10n.of(context).markAsRead - : L10n.of(context).markAsUnread, - ), - ], + label: room.isUnread ? l10n.markAsRead : l10n.markAsUnread, + icon: Icon( + room.markedUnread + ? Icons.mark_as_unread + : Icons.mark_as_unread_outlined, ), ), - PopupMenuItem( + AdaptiveModalAction( value: SpaceChildAction.leave, - child: Row( - mainAxisSize: .min, - children: [ - Icon( - Icons.delete_outlined, - color: Theme.of(context).colorScheme.onErrorContainer, - ), - const SizedBox(width: 12), - Text( - L10n.of(context).leave, - style: TextStyle( - color: Theme.of(context).colorScheme.onErrorContainer, - ), - ), - ], - ), + label: l10n.leave, + icon: const Icon(Icons.delete_outlined), + isDestructive: true, ), ], if (space.canChangeStateEvent(EventTypes.SpaceChild) == true) - PopupMenuItem( + AdaptiveModalAction( value: SpaceChildAction.removeFromSpace, - child: Row( - mainAxisSize: .min, - children: [ - Icon( - Icons.remove, - color: Theme.of(context).colorScheme.onErrorContainer, - ), - const SizedBox(width: 12), - Text( - L10n.of(context).removeFromSpace, - style: TextStyle( - color: Theme.of(context).colorScheme.onErrorContainer, - ), - ), - ], - ), + label: l10n.removeFromSpace, + icon: const Icon(Icons.remove), + isDestructive: true, ), ], ); diff --git a/lib/pages/chat_list/tabs/channels_tab.dart b/lib/pages/chat_list/tabs/channels_tab.dart index af3f772..87514b2 100644 --- a/lib/pages/chat_list/tabs/channels_tab.dart +++ b/lib/pages/chat_list/tabs/channels_tab.dart @@ -95,6 +95,8 @@ class _ChannelsTabState extends State { room, key: Key('channel_list_item_${room.id}'), onTap: () => widget.controller.onChatTap(room), + onLongPress: (context) => + widget.controller.chatContextAction(room, context), activeChat: widget.controller.activeChat == room.id, ); }, diff --git a/lib/pages/chat_list/tabs/murals_tab.dart b/lib/pages/chat_list/tabs/murals_tab.dart index 327204b..68cb86e 100644 --- a/lib/pages/chat_list/tabs/murals_tab.dart +++ b/lib/pages/chat_list/tabs/murals_tab.dart @@ -101,6 +101,8 @@ class _MuralsTabState extends State { room, key: Key('mural_list_item_${room.id}'), onTap: () => widget.controller.onChatTap(room), + onLongPress: (context) => + widget.controller.chatContextAction(room, context), activeChat: widget.controller.activeChat == room.id, displayAvatar: Uri.tryParse( creatorState?.content.tryGet('avatar_url') ?? diff --git a/lib/pages/mural/mural_chat_page.dart b/lib/pages/mural/mural_chat_page.dart index 415aeeb..e2599ea 100644 --- a/lib/pages/mural/mural_chat_page.dart +++ b/lib/pages/mural/mural_chat_page.dart @@ -88,6 +88,7 @@ class _MuralChatViewState extends State { bool _descriptionExpanded = false; bool _loadingHistory = false; bool _historyEnded = false; + bool _initialLoading = false; final ScrollController _scrollController = ScrollController(); @override @@ -125,7 +126,8 @@ class _MuralChatViewState extends State { } catch (e, s) { Logs().w('Failed to load more mural posts', e, s); } finally { - if (timeline.events.length <= eventCountBefore) { + if (!timeline.canRequestHistory || + timeline.events.length <= eventCountBefore) { _historyEnded = true; } _loadingHistory = false; @@ -133,6 +135,25 @@ class _MuralChatViewState extends State { } } + /// Fetches older events from the server until enough media posts were + /// loaded or the entire history was consumed. This makes the mural behave + /// like a chat: after the initial sync only a handful of recent events are + /// in the timeline which usually contain very few media posts (most events + /// are thread comments), so we must actively fetch batches from the server + /// until the feed is properly populated. + Future _fillInitialMedia() async { + var attempts = 0; + const maxAttempts = 20; + const targetMediaCount = 30; + while (mounted && + !_historyEnded && + _mediaEvents.length < targetMediaCount && + attempts < maxAttempts) { + attempts++; + await _loadMore(); + } + } + void updateView() { if (!mounted) return; setState(() {}); @@ -142,12 +163,16 @@ class _MuralChatViewState extends State { timeline?.cancelSubscriptions(); _loadingHistory = false; _historyEnded = false; + _initialLoading = true; timeline = await room.getTimeline( onUpdate: updateView, limit: Room.defaultHistoryCount * 3, ); if (!mounted) return; setState(() {}); + await _fillInitialMedia(); + _initialLoading = false; + if (mounted) setState(() {}); } Future _sendFiles(List? files) async { @@ -318,7 +343,9 @@ class _MuralChatViewState extends State { ), const Divider(height: 1), Expanded( - child: events.isEmpty + child: _initialLoading + ? const Center(child: CircularProgressIndicator.adaptive()) + : events.isEmpty ? Center( child: Padding( padding: const EdgeInsets.all(24), diff --git a/lib/utils/matrix_sdk_extensions/client_presence_extension.dart b/lib/utils/matrix_sdk_extensions/client_presence_extension.dart new file mode 100644 index 0000000..753b8f2 --- /dev/null +++ b/lib/utils/matrix_sdk_extensions/client_presence_extension.dart @@ -0,0 +1,33 @@ +// SPDX-FileCopyrightText: 2019-Present Christian Kußowski +// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:matrix/matrix.dart'; + +extension ClientPresenceExtension on Client { + /// Sets the own presence status message. + /// + /// The SDK's [Client.setPresence] only performs the HTTP request and does not + /// update the local presence cache, the database or the + /// [Client.onPresenceChanged] stream. As a result + /// [Client.fetchCurrentPresence] keeps returning the previous status until the + /// homeserver echoes it back over `/sync`. This helper updates all local + /// caches after a successful request so the new status is shown immediately. + Future setStatusMessage(String statusMsg) async { + final userId = userID; + if (userId == null) return; + await setPresence(userId, PresenceType.online, statusMsg: statusMsg); + final presence = CachedPresence( + PresenceType.online, + null, + statusMsg, + null, + userId, + ); + // ignore: deprecated_member_use + presences[userId] = presence; + await database.storePresence(userId, presence); + onPresenceChanged.add(presence); + } +} diff --git a/lib/widgets/self_profile_dialog.dart b/lib/widgets/self_profile_dialog.dart new file mode 100644 index 0000000..40eb9c3 --- /dev/null +++ b/lib/widgets/self_profile_dialog.dart @@ -0,0 +1,476 @@ +// SPDX-FileCopyrightText: 2019-Present Christian Kußowski +// SPDX-FileCopyrightText: 2019-Present Contributors to FluffyChat +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +import 'package:collection/collection.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:material_ui/material_ui.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pretty_qr_code/pretty_qr_code.dart'; +import 'package:tatchat/config/app_config.dart'; +import 'package:tatchat/l10n/l10n.dart'; +import 'package:tatchat/pages/chat/send_file_dialog.dart'; +import 'package:tatchat/pages/new_private_chat/qr_scanner_modal.dart'; +import 'package:tatchat/utils/adaptive_bottom_sheet.dart'; +import 'package:tatchat/utils/file_selector.dart'; +import 'package:tatchat/utils/matrix_sdk_extensions/client_presence_extension.dart'; +import 'package:tatchat/utils/platform_infos.dart'; +import 'package:tatchat/utils/url_launcher.dart'; +import 'package:tatchat/widgets/adaptive_dialogs/show_modal_action_popup.dart'; +import 'package:tatchat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; +import 'package:tatchat/widgets/avatar.dart'; +import 'package:tatchat/widgets/future_loading_dialog.dart'; +import 'package:tatchat/widgets/matrix.dart'; +import 'package:tatchat/widgets/mxc_image_viewer.dart'; +import 'package:tatchat/widgets/qr_code_viewer.dart'; + +/// Shows the profile of the active account: avatar, display name, status, the +/// own QR code, the global user ID and an optional QR code scanner. +Future showSelfProfileDialog(BuildContext context) => showAdaptiveDialog( + context: context, + barrierDismissible: true, + builder: (context) => const SelfProfileDialog(), +); + +class SelfProfileDialog extends StatefulWidget { + const SelfProfileDialog({super.key}); + + @override + State createState() => _SelfProfileDialogState(); +} + +class _SelfProfileDialogState extends State { + Future? _profileFuture; + String? _statusMsg; + bool _copied = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _loadStatus()); + } + + /// The SDK does not update its presence cache after [Client.setPresence], so + /// the status is kept in local state and only fetched once when opening. + Future _loadStatus() async { + final client = Matrix.of(context).client; + final userId = client.userID; + if (userId == null) return; + try { + final presence = await client.fetchCurrentPresence(userId); + if (!mounted) return; + setState(() => _statusMsg = presence.statusMsg); + } catch (_) { + // Presence is optional, ignore failures and keep the status empty. + } + } + + void _refresh() { + setState(() => _profileFuture = null); + } + + Future _editAvatar() async { + final l10n = L10n.of(context); + final client = Matrix.of(context).client; + final profile = await _profileFuture; + if (!mounted) return; + final actions = [ + if (PlatformInfos.isMobile) + AdaptiveModalAction( + value: _AvatarAction.camera, + label: l10n.openCamera, + isDefaultAction: true, + icon: const Icon(Icons.camera_alt_outlined), + ), + AdaptiveModalAction( + value: _AvatarAction.file, + label: l10n.openGallery, + icon: const Icon(Icons.photo_outlined), + ), + if (profile?.avatarUrl != null) + AdaptiveModalAction( + value: _AvatarAction.remove, + label: l10n.removeYourAvatar, + isDestructive: true, + icon: const Icon(Icons.delete_outlined), + ), + ]; + final action = actions.length == 1 + ? actions.single.value + : await showModalActionPopup<_AvatarAction>( + context: context, + title: l10n.changeYourAvatar, + cancelLabel: l10n.cancel, + actions: actions, + ); + if (action == null) return; + if (!mounted) return; + if (action == _AvatarAction.remove) { + final success = await showFutureLoadingDialog( + context: context, + future: () => client.setAvatar(null), + ); + if (success.error == null) _refresh(); + return; + } + final Uint8List bytes; + final String name; + if (PlatformInfos.isMobile) { + final result = await ImagePicker().pickImage( + source: action == _AvatarAction.camera + ? ImageSource.camera + : ImageSource.gallery, + imageQuality: 50, + ); + if (result == null) return; + bytes = await result.readAsBytes(); + name = result.path; + } else { + if (!mounted) return; + final result = await selectFiles(context, type: FileType.image); + final pickedFile = result.firstOrNull; + if (pickedFile == null) return; + bytes = await pickedFile.readAsBytes(); + name = pickedFile.name; + } + if (!mounted) return; + final edited = await editImageBytes(context, bytes); + if (!mounted) return; + final file = MatrixFile( + bytes: edited ?? bytes, + name: edited != null ? 'avatar.png' : name, + ); + if (!mounted) return; + final success = await showFutureLoadingDialog( + context: context, + future: () => client.setAvatar(file), + ); + if (success.error == null) _refresh(); + } + + Future _editDisplayName() async { + final l10n = L10n.of(context); + final client = Matrix.of(context).client; + final profile = await _profileFuture; + if (!mounted) return; + final input = await showTextInputDialog( + useRootNavigator: false, + context: context, + title: l10n.editDisplayname, + okLabel: l10n.ok, + cancelLabel: l10n.cancel, + initialText: profile?.displayName ?? client.userID!.localpart, + ); + if (input == null) return; + if (!mounted) return; + final success = await showFutureLoadingDialog( + context: context, + future: () => client.setProfileField(client.userID!, 'displayname', { + 'displayname': input, + }), + ); + if (success.error == null) _refresh(); + } + + Future _editStatus() async { + final l10n = L10n.of(context); + final client = Matrix.of(context).client; + final input = await showTextInputDialog( + useRootNavigator: false, + context: context, + title: l10n.setStatus, + message: l10n.leaveEmptyToClearStatus, + okLabel: l10n.ok, + cancelLabel: l10n.cancel, + hintText: l10n.statusExampleMessage, + maxLines: 6, + minLines: 1, + maxLength: 255, + initialText: _statusMsg, + ); + if (input == null) return; + if (!mounted) return; + final success = await showFutureLoadingDialog( + context: context, + future: () => client.setStatusMessage(input), + ); + if (success.error != null) return; + if (!mounted) return; + setState(() => _statusMsg = input); + } + + Future _copyUserId() async { + final client = Matrix.of(context).client; + await Clipboard.setData(ClipboardData(text: client.userID!)); + if (!mounted) return; + setState(() => _copied = true); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(L10n.of(context).copiedToClipboard))); + } + + Future _openQrScanner() async { + final context = this.context; + await showAdaptiveBottomSheet( + context: context, + builder: (_) => QrScannerModal( + onScan: (link) => UrlLauncher(context, link).openMatrixToUrl(), + ), + ); + } + + Future _openSettingsAction() async { + Navigator.of(context).pop(); + if (context.mounted) context.go('/rooms/settings'); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final matrix = Matrix.of(context); + final client = matrix.client; + final userId = client.userID; + final l10n = L10n.of(context); + _profileFuture ??= client.fetchOwnProfile(); + return AlertDialog.adaptive( + content: SingleChildScrollView( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + mainAxisSize: .min, + crossAxisAlignment: .stretch, + children: [ + FutureBuilder( + future: _profileFuture, + builder: (context, snapshot) { + final profile = snapshot.data; + final avatar = profile?.avatarUrl; + final displayname = + profile?.displayName ?? userId?.localpart ?? l10n.user; + return Column( + children: [ + Center( + child: Stack( + alignment: .center, + children: [ + Avatar( + mxContent: avatar, + name: displayname, + size: 96, + shapeBorder: const CircleBorder(), + onTap: avatar != null + ? () => showDialog( + context: context, + builder: (_) => MxcImageViewer(avatar), + ) + : null, + ), + Positioned( + right: 0, + bottom: 0, + child: Material( + color: theme.colorScheme.primary, + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: _editAvatar, + child: SizedBox( + width: 30, + height: 30, + child: Icon( + Icons.edit_outlined, + size: 16, + color: theme.colorScheme.onPrimary, + ), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _EditableLine( + text: displayname, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + tooltip: l10n.editDisplayname, + onEdit: _editDisplayName, + ), + const SizedBox(height: 8), + _EditableLine( + text: _statusMsg?.trim().isNotEmpty == true + ? _statusMsg! + : l10n.setStatus, + style: _statusMsg?.trim().isNotEmpty == true + ? theme.textTheme.bodyMedium + : theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontStyle: FontStyle.italic, + ), + tooltip: l10n.setStatus, + onEdit: _editStatus, + ), + ], + ); + }, + ), + const SizedBox(height: 24), + if (userId != null) ...[ + Center( + child: Material( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + side: BorderSide( + width: 2, + color: theme.colorScheme.primary, + ), + ), + color: Colors.transparent, + clipBehavior: Clip.hardEdge, + child: InkWell( + borderRadius: BorderRadius.circular( + AppConfig.borderRadius, + ), + onTap: () => showQrCodeViewer(context, userId), + child: Padding( + padding: const EdgeInsets.all(16), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 160), + child: PrettyQrView.data( + data: 'https://matrix.to/#/$userId', + decoration: PrettyQrDecoration( + shape: PrettyQrSmoothSymbol( + roundFactor: 1, + color: theme.colorScheme.primary, + ), + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 12), + Center( + child: Material( + color: theme.colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(99), + child: InkWell( + borderRadius: BorderRadius.circular(99), + onTap: _copyUserId, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Row( + mainAxisSize: .min, + children: [ + Flexible( + child: Text( + userId, + maxLines: 1, + overflow: .ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'RobotoMono', + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(width: 8), + Icon( + _copied + ? Icons.check_circle + : Icons.copy_outlined, + size: 14, + color: _copied + ? Colors.green + : theme.colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ), + ], + const Divider(height: 32), + if (PlatformInfos.isMobile) + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: CircleAvatar( + backgroundColor: theme.colorScheme.primaryContainer, + foregroundColor: theme.colorScheme.onPrimaryContainer, + child: const Icon(Icons.qr_code_scanner_outlined), + ), + title: Text(l10n.scanQrCode), + onTap: _openQrScanner, + ), + ListTile( + contentPadding: EdgeInsets.zero, + dense: true, + leading: const Icon(Icons.settings_outlined), + title: Text(l10n.settings), + onTap: _openSettingsAction, + ), + ], + ), + ), + ), + ); + } +} + +/// A single line of profile data (displayname/status) with a small edit +/// button that invokes [onEdit] when tapped. +class _EditableLine extends StatelessWidget { + final String text; + final TextStyle? style; + final String tooltip; + final VoidCallback onEdit; + + const _EditableLine({ + required this.text, + required this.style, + required this.tooltip, + required this.onEdit, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: .center, + children: [ + Flexible( + child: Text( + text, + maxLines: 3, + overflow: .ellipsis, + textAlign: .center, + style: style, + ), + ), + const SizedBox(width: 4), + IconButton( + tooltip: tooltip, + onPressed: onEdit, + icon: const Icon(Icons.edit_outlined, size: 16), + iconSize: 16, + visualDensity: VisualDensity.compact, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ], + ); + } +} + +enum _AvatarAction { camera, file, remove }