feat: menu de contexto em bottom sheet, painel de anexos, perfil e feed do mural
- Salas (chat, canais, murais e salas de espacos) abrem bottom sheet com grid de acoes - Botao + da barra de digitacao abre painel de anexos em grid de 3 colunas que empurra a caixa de texto - Modal do perfil permite editar nome/status e ler QR code (mobile) - Alterar status atualiza localmente via extensao do SDK matrix - Mural pre-carrega historico para exibir todas as midias em dispositivos sem cache
This commit is contained in:
@@ -19,7 +19,7 @@ extension on TatuChatTester {
|
|||||||
await ensureLoggedIn();
|
await ensureLoggedIn();
|
||||||
|
|
||||||
// Login user 2
|
// Login user 2
|
||||||
await tapOn(Key('accounts_and_settings_buttons'));
|
await tapOn(Icons.menu);
|
||||||
await tapOn('Add account');
|
await tapOn('Add account');
|
||||||
await login(username: user2Name, password: user2Pw);
|
await login(username: user2Name, password: user2Pw);
|
||||||
await initCryptoIdentity(username: user2Name);
|
await initCryptoIdentity(username: user2Name);
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ class ChatController extends State<ChatPageWithRoom>
|
|||||||
|
|
||||||
bool showEmojiPicker = false;
|
bool showEmojiPicker = false;
|
||||||
|
|
||||||
|
bool showAttachmentPicker = false;
|
||||||
|
|
||||||
String? get threadLastEventId {
|
String? get threadLastEventId {
|
||||||
final threadId = activeThreadId;
|
final threadId = activeThreadId;
|
||||||
if (threadId == null) return null;
|
if (threadId == null) return null;
|
||||||
@@ -978,18 +980,40 @@ class ChatController extends State<ChatPageWithRoom>
|
|||||||
setState(() => showEmojiPicker = false);
|
setState(() => showEmojiPicker = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void hideAttachmentPicker() {
|
||||||
|
setState(() => showAttachmentPicker = false);
|
||||||
|
}
|
||||||
|
|
||||||
void emojiPickerAction() {
|
void emojiPickerAction() {
|
||||||
if (showEmojiPicker) {
|
if (showEmojiPicker) {
|
||||||
inputFocus.requestFocus();
|
inputFocus.requestFocus();
|
||||||
} else {
|
} else {
|
||||||
inputFocus.unfocus();
|
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() {
|
void _inputFocusListener() {
|
||||||
if (showEmojiPicker && inputFocus.hasFocus) {
|
if ((showEmojiPicker || showAttachmentPicker) && inputFocus.hasFocus) {
|
||||||
setState(() => showEmojiPicker = false);
|
setState(() {
|
||||||
|
showEmojiPicker = false;
|
||||||
|
showAttachmentPicker = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,6 +1040,7 @@ class ChatController extends State<ChatPageWithRoom>
|
|||||||
Clipboard.setData(ClipboardData(text: _getSelectedEventString()));
|
Clipboard.setData(ClipboardData(text: _getSelectedEventString()));
|
||||||
setState(() {
|
setState(() {
|
||||||
showEmojiPicker = false;
|
showEmojiPicker = false;
|
||||||
|
showAttachmentPicker = false;
|
||||||
selectedEvents.clear();
|
selectedEvents.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1044,6 +1069,7 @@ class ChatController extends State<ChatPageWithRoom>
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
showEmojiPicker = false;
|
showEmojiPicker = false;
|
||||||
|
showAttachmentPicker = false;
|
||||||
selectedEvents.clear();
|
selectedEvents.clear();
|
||||||
});
|
});
|
||||||
scaffoldMessenger.showSnackBar(
|
scaffoldMessenger.showSnackBar(
|
||||||
@@ -1120,6 +1146,7 @@ class ChatController extends State<ChatPageWithRoom>
|
|||||||
);
|
);
|
||||||
setState(() {
|
setState(() {
|
||||||
showEmojiPicker = false;
|
showEmojiPicker = false;
|
||||||
|
showAttachmentPicker = false;
|
||||||
selectedEvents.clear();
|
selectedEvents.clear();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1305,6 +1332,7 @@ class ChatController extends State<ChatPageWithRoom>
|
|||||||
void clearSelectedEvents() => setState(() {
|
void clearSelectedEvents() => setState(() {
|
||||||
selectedEvents.clear();
|
selectedEvents.clear();
|
||||||
showEmojiPicker = false;
|
showEmojiPicker = false;
|
||||||
|
showAttachmentPicker = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
void clearSingleSelectedEvent() {
|
void clearSingleSelectedEvent() {
|
||||||
|
|||||||
168
lib/pages/chat/chat_attachment_picker.dart
Normal file
168
lib/pages/chat/chat_attachment_picker.dart
Normal file
@@ -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 = <List<(IconData, String, AddPopupMenuActions)>>[];
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,102 +141,10 @@ class ChatInputRow extends StatelessWidget {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: const BoxDecoration(),
|
decoration: const BoxDecoration(),
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
child: PopupMenuButton<AddPopupMenuActions>(
|
child: IconButton(
|
||||||
useRootNavigator: true,
|
|
||||||
icon: const Icon(Icons.add_circle_outline),
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
iconColor: theme.colorScheme.onPrimaryContainer,
|
color: theme.colorScheme.onPrimaryContainer,
|
||||||
onSelected: controller.onAddPopupMenuButtonSelected,
|
onPressed: controller.attachmentPickerAction,
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import 'package:matrix/matrix.dart';
|
|||||||
|
|
||||||
import '../../utils/matrix_sdk_extensions/room_extension.dart';
|
import '../../utils/matrix_sdk_extensions/room_extension.dart';
|
||||||
import '../../utils/stream_extension.dart';
|
import '../../utils/stream_extension.dart';
|
||||||
|
import 'chat_attachment_picker.dart';
|
||||||
import 'chat_emoji_picker.dart';
|
import 'chat_emoji_picker.dart';
|
||||||
import 'chat_input_row.dart';
|
import 'chat_input_row.dart';
|
||||||
|
|
||||||
@@ -57,6 +58,7 @@ class ChatView extends StatelessWidget {
|
|||||||
canPop:
|
canPop:
|
||||||
controller.selectedEvents.isEmpty &&
|
controller.selectedEvents.isEmpty &&
|
||||||
!controller.showEmojiPicker &&
|
!controller.showEmojiPicker &&
|
||||||
|
!controller.showAttachmentPicker &&
|
||||||
controller.activeThreadId == null,
|
controller.activeThreadId == null,
|
||||||
onPopInvokedWithResult: (pop, _) async {
|
onPopInvokedWithResult: (pop, _) async {
|
||||||
if (pop) return;
|
if (pop) return;
|
||||||
@@ -64,6 +66,8 @@ class ChatView extends StatelessWidget {
|
|||||||
controller.clearSelectedEvents();
|
controller.clearSelectedEvents();
|
||||||
} else if (controller.showEmojiPicker) {
|
} else if (controller.showEmojiPicker) {
|
||||||
controller.emojiPickerAction();
|
controller.emojiPickerAction();
|
||||||
|
} else if (controller.showAttachmentPicker) {
|
||||||
|
controller.attachmentPickerAction();
|
||||||
} else if (controller.activeThreadId != null) {
|
} else if (controller.activeThreadId != null) {
|
||||||
controller.closeThread();
|
controller.closeThread();
|
||||||
}
|
}
|
||||||
@@ -473,6 +477,9 @@ class ChatView extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
ReplyDisplay(controller),
|
ReplyDisplay(controller),
|
||||||
ChatInputRow(controller),
|
ChatInputRow(controller),
|
||||||
|
ChatAttachmentPicker(
|
||||||
|
controller,
|
||||||
|
),
|
||||||
ChatEmojiPicker(controller),
|
ChatEmojiPicker(controller),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ import 'package:tatchat/widgets/adaptive_dialogs/dialog_text_field.dart';
|
|||||||
|
|
||||||
import '../../utils/resize_video.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<Uint8List?> editImageBytes(BuildContext context, Uint8List bytes) =>
|
||||||
|
showScaffoldDialog<Uint8List?>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => _ImageEditPage(bytes: bytes),
|
||||||
|
);
|
||||||
|
|
||||||
class SendFileDialog extends StatefulWidget {
|
class SendFileDialog extends StatefulWidget {
|
||||||
final Room room;
|
final Room room;
|
||||||
final List<XFile> files;
|
final List<XFile> files;
|
||||||
|
|||||||
@@ -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/pages/chat_list/tabs/home_tab.dart';
|
||||||
import 'package:tatchat/utils/error_reporter.dart';
|
import 'package:tatchat/utils/error_reporter.dart';
|
||||||
import 'package:tatchat/utils/localized_exception_extension.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/matrix_locals.dart';
|
||||||
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
|
import 'package:tatchat/utils/matrix_sdk_extensions/room_extension.dart';
|
||||||
import 'package:tatchat/utils/platform_infos.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_modal_action_popup.dart';
|
||||||
import 'package:tatchat/widgets/adaptive_dialogs/show_ok_cancel_alert_dialog.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/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/future_loading_dialog.dart';
|
||||||
import 'package:tatchat/widgets/share_scaffold_dialog.dart';
|
import 'package:tatchat/widgets/share_scaffold_dialog.dart';
|
||||||
|
|
||||||
@@ -514,21 +514,7 @@ class ChatListController extends State<ChatList>
|
|||||||
BuildContext posContext, [
|
BuildContext posContext, [
|
||||||
Room? space,
|
Room? space,
|
||||||
]) async {
|
]) async {
|
||||||
final overlay =
|
final l10n = L10n.of(posContext);
|
||||||
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 spacesWithPowerLevels = room.client.rooms
|
final spacesWithPowerLevels = room.client.rooms
|
||||||
.where(
|
.where(
|
||||||
@@ -539,202 +525,104 @@ class ChatListController extends State<ChatList>
|
|||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
var action = await showMenu<ChatContextAction>(
|
var action = await showModalActionPopup<ChatContextAction>(
|
||||||
context: posContext,
|
context: posContext,
|
||||||
position: position,
|
cancelLabel: l10n.cancel,
|
||||||
items: [
|
actions: [
|
||||||
if (space != null)
|
if (space != null)
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: ChatContextAction.goToSpace,
|
value: ChatContextAction.goToSpace,
|
||||||
child: Row(
|
label: l10n.goToSpace(space.getLocalizedDisplayname()),
|
||||||
mainAxisSize: .min,
|
icon: const Icon(Icons.workspaces_outlined),
|
||||||
children: [
|
|
||||||
Avatar(
|
|
||||||
mxContent: space.avatar,
|
|
||||||
size: Avatar.defaultSize / 2,
|
|
||||||
name: space.getLocalizedDisplayname(),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
L10n.of(context).goToSpace(space.getLocalizedDisplayname()),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (room.membership == Membership.join) ...[
|
if (room.membership == Membership.join) ...[
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: ChatContextAction.mute,
|
value: ChatContextAction.mute,
|
||||||
child: Row(
|
label: room.pushRuleState == PushRuleState.notify
|
||||||
mainAxisSize: .min,
|
? l10n.muteChat
|
||||||
children: [
|
: l10n.unmuteChat,
|
||||||
Icon(
|
icon: Icon(
|
||||||
room.pushRuleState == PushRuleState.notify
|
room.pushRuleState == PushRuleState.notify
|
||||||
? Icons.notifications_off_outlined
|
? Icons.notifications_off_outlined
|
||||||
: Icons.notifications_off,
|
: Icons.notifications_off,
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
room.pushRuleState == PushRuleState.notify
|
|
||||||
? L10n.of(context).muteChat
|
|
||||||
: L10n.of(context).unmuteChat,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: ChatContextAction.markUnread,
|
value: ChatContextAction.markUnread,
|
||||||
child: Row(
|
label: room.markedUnread ? l10n.markAsRead : l10n.markAsUnread,
|
||||||
mainAxisSize: .min,
|
icon: Icon(
|
||||||
children: [
|
room.markedUnread
|
||||||
Icon(
|
? Icons.mark_as_unread
|
||||||
room.markedUnread
|
: Icons.mark_as_unread_outlined,
|
||||||
? Icons.mark_as_unread
|
|
||||||
: Icons.mark_as_unread_outlined,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
room.markedUnread
|
|
||||||
? L10n.of(context).markAsRead
|
|
||||||
: L10n.of(context).markAsUnread,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!room.isLowPriority)
|
if (!room.isLowPriority)
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: ChatContextAction.favorite,
|
value: ChatContextAction.favorite,
|
||||||
child: Row(
|
label: room.isFavourite ? l10n.unpin : l10n.pin,
|
||||||
mainAxisSize: .min,
|
icon: Icon(
|
||||||
children: [
|
room.isFavourite ? Icons.push_pin : Icons.push_pin_outlined,
|
||||||
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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: ChatContextAction.leave,
|
value: ChatContextAction.leave,
|
||||||
child: Row(
|
label: room.membership == Membership.invite
|
||||||
mainAxisSize: .min,
|
? l10n.delete
|
||||||
children: [
|
: l10n.leave,
|
||||||
Icon(
|
icon: const Icon(Icons.delete_outlined),
|
||||||
Icons.delete_outlined,
|
isDestructive: true,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (room.membership == Membership.invite)
|
if (room.membership == Membership.invite)
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: ChatContextAction.block,
|
value: ChatContextAction.block,
|
||||||
child: Row(
|
label: l10n.block,
|
||||||
mainAxisSize: .min,
|
icon: const Icon(Icons.block_outlined),
|
||||||
children: [
|
isDestructive: true,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (room.membership == Membership.join)
|
if (room.membership == Membership.join)
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: ChatContextAction.showMore,
|
value: ChatContextAction.showMore,
|
||||||
child: Row(
|
label: l10n.more,
|
||||||
mainAxisSize: .min,
|
icon: Icon(Icons.adaptive.more_outlined),
|
||||||
children: [
|
|
||||||
Icon(Icons.adaptive.more_outlined),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(L10n.of(context).more),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
if (!posContext.mounted || !mounted) return;
|
if (!posContext.mounted || !mounted) return;
|
||||||
if (action == ChatContextAction.showMore) {
|
if (action == ChatContextAction.showMore) {
|
||||||
action = await showMenu<ChatContextAction>(
|
action = await showModalActionPopup<ChatContextAction>(
|
||||||
context: posContext,
|
context: posContext,
|
||||||
position: position,
|
cancelLabel: l10n.cancel,
|
||||||
items: [
|
actions: [
|
||||||
if (!room.isFavourite)
|
if (!room.isFavourite)
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: ChatContextAction.lowPriority,
|
value: ChatContextAction.lowPriority,
|
||||||
child: Row(
|
label: room.isLowPriority
|
||||||
mainAxisSize: .min,
|
? l10n.unsetLowPriority
|
||||||
children: [
|
: l10n.setLowPriority,
|
||||||
Icon(
|
icon: Icon(
|
||||||
room.isLowPriority
|
room.isLowPriority
|
||||||
? Icons.low_priority
|
? Icons.low_priority
|
||||||
: Icons.low_priority_outlined,
|
: Icons.low_priority_outlined,
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
room.isLowPriority
|
|
||||||
? L10n.of(context).unsetLowPriority
|
|
||||||
: L10n.of(context).setLowPriority,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (activeTag == null)
|
if (activeTag == null)
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: ChatContextAction.addTag,
|
value: ChatContextAction.addTag,
|
||||||
child: Row(
|
label: l10n.addTag,
|
||||||
mainAxisSize: .min,
|
icon: const Icon(Icons.bookmark_add_outlined),
|
||||||
children: [
|
|
||||||
Icon(Icons.bookmark_add_outlined),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(L10n.of(context).addTag),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: ChatContextAction.removeTag,
|
value: ChatContextAction.removeTag,
|
||||||
child: Row(
|
label: l10n.removeTag,
|
||||||
mainAxisSize: .min,
|
icon: const Icon(Icons.bookmark_remove_outlined),
|
||||||
children: [
|
|
||||||
Icon(Icons.bookmark_remove_outlined),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(L10n.of(context).removeTag),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (spacesWithPowerLevels.isNotEmpty)
|
if (spacesWithPowerLevels.isNotEmpty)
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: ChatContextAction.addToSpace,
|
value: ChatContextAction.addToSpace,
|
||||||
child: Row(
|
label: l10n.addToSpace,
|
||||||
mainAxisSize: .min,
|
icon: const Icon(Icons.group_work_outlined),
|
||||||
children: [
|
|
||||||
const Icon(Icons.group_work_outlined),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(L10n.of(context).addToSpace),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -909,11 +797,7 @@ class ChatListController extends State<ChatList>
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await showFutureLoadingDialog(
|
await showFutureLoadingDialog(
|
||||||
context: context,
|
context: context,
|
||||||
future: () => client.setPresence(
|
future: () => client.setStatusMessage(input),
|
||||||
client.userID!,
|
|
||||||
PresenceType.online,
|
|
||||||
statusMsg: input,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,255 +4,46 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import 'package:async/async.dart';
|
import 'package:async/async.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:material_ui/material_ui.dart';
|
import 'package:material_ui/material_ui.dart';
|
||||||
import 'package:matrix/matrix.dart' hide Result;
|
import 'package:matrix/matrix.dart' hide Result;
|
||||||
import 'package:tatchat/config/themes.dart';
|
|
||||||
import 'package:tatchat/l10n/l10n.dart';
|
import 'package:tatchat/l10n/l10n.dart';
|
||||||
import 'package:tatchat/utils/tatu_share.dart';
|
|
||||||
import 'package:tatchat/widgets/avatar.dart';
|
import 'package:tatchat/widgets/avatar.dart';
|
||||||
import 'package:tatchat/widgets/matrix.dart';
|
import 'package:tatchat/widgets/matrix.dart';
|
||||||
|
import 'package:tatchat/widgets/self_profile_dialog.dart';
|
||||||
|
|
||||||
import 'chat_list.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 {
|
class ClientChooserButton extends StatelessWidget {
|
||||||
final ChatListController controller;
|
final ChatListController controller;
|
||||||
|
|
||||||
const ClientChooserButton(this.controller, {super.key});
|
const ClientChooserButton(this.controller, {super.key});
|
||||||
|
|
||||||
List<PopupMenuEntry<Object>> _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 <PopupMenuEntry<Object>>[
|
|
||||||
..._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 <PopupMenuEntry<Object>>[
|
|
||||||
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<PopupMenuEntry<Object>> _bundleEntries(
|
|
||||||
BuildContext context,
|
|
||||||
MatrixState matrix,
|
|
||||||
List<String?> bundles,
|
|
||||||
) {
|
|
||||||
return <PopupMenuEntry<Object>>[
|
|
||||||
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<Client>()
|
|
||||||
.where((client) => client.isLogged())
|
|
||||||
.map(
|
|
||||||
(client) => PopupMenuItem(
|
|
||||||
value: client,
|
|
||||||
child: FutureBuilder<Profile?>(
|
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final matrix = Matrix.of(context);
|
final matrix = Matrix.of(context);
|
||||||
final client = Result(() => matrix.client).asValue?.value;
|
final client = Result(() => matrix.client).asValue?.value;
|
||||||
|
|
||||||
var clientCount = 0;
|
|
||||||
matrix.accountBundles.forEach((key, value) => clientCount += value.length);
|
|
||||||
return FutureBuilder<Profile>(
|
return FutureBuilder<Profile>(
|
||||||
future: client?.isLogged() == true ? client?.fetchOwnProfile() : null,
|
future: client?.isLogged() == true ? client?.fetchOwnProfile() : null,
|
||||||
builder: (context, snapshot) => Material(
|
builder: (context, snapshot) => Material(
|
||||||
clipBehavior: Clip.hardEdge,
|
clipBehavior: Clip.hardEdge,
|
||||||
borderRadius: BorderRadius.circular(99),
|
borderRadius: BorderRadius.circular(99),
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: PopupMenuButton<Object>(
|
child: Tooltip(
|
||||||
key: Key('accounts_and_settings_buttons'),
|
message: L10n.of(context).accountsAndSettings,
|
||||||
tooltip: L10n.of(context).accountsAndSettings,
|
child: InkWell(
|
||||||
onSelected: (o) => _clientSelected(o, context),
|
key: const Key('accounts_and_settings_buttons'),
|
||||||
itemBuilder: _bundleMenuItems,
|
onTap: () => showSelfProfileDialog(context),
|
||||||
icon: Avatar(
|
child: Avatar(
|
||||||
mxContent: snapshot.data?.avatarUrl,
|
mxContent: snapshot.data?.avatarUrl,
|
||||||
name: snapshot.data?.displayName ?? client?.userID?.localpart,
|
name: snapshot.data?.displayName ?? client?.userID?.localpart,
|
||||||
size: 32,
|
size: 32,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _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 }
|
|
||||||
|
|||||||
@@ -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/stream_extension.dart';
|
||||||
import 'package:tatchat/utils/string_color.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_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/avatar.dart';
|
||||||
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
import 'package:tatchat/widgets/future_loading_dialog.dart';
|
||||||
import 'package:tatchat/widgets/hover_builder.dart';
|
import 'package:tatchat/widgets/hover_builder.dart';
|
||||||
@@ -219,108 +220,50 @@ class _SpaceViewState extends State<SpaceView> {
|
|||||||
final space = client.getRoomById(widget.spaceId);
|
final space = client.getRoomById(widget.spaceId);
|
||||||
final room = client.getRoomById(roomId);
|
final room = client.getRoomById(roomId);
|
||||||
if (space == null) return;
|
if (space == null) return;
|
||||||
final overlay =
|
final l10n = L10n.of(posContext);
|
||||||
Overlay.of(posContext).context.findRenderObject() as RenderBox;
|
|
||||||
|
|
||||||
final button = posContext.findRenderObject() as RenderBox;
|
final action = await showModalActionPopup<SpaceChildAction>(
|
||||||
|
|
||||||
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<SpaceChildAction>(
|
|
||||||
context: posContext,
|
context: posContext,
|
||||||
position: position,
|
cancelLabel: l10n.cancel,
|
||||||
items: [
|
actions: [
|
||||||
if (room != null && room.membership == Membership.join) ...[
|
if (room != null && room.membership == Membership.join) ...[
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: room.pushRuleState == PushRuleState.notify
|
value: room.pushRuleState == PushRuleState.notify
|
||||||
? SpaceChildAction.mute
|
? SpaceChildAction.mute
|
||||||
: SpaceChildAction.unmute,
|
: SpaceChildAction.unmute,
|
||||||
child: Row(
|
label: room.pushRuleState == PushRuleState.notify
|
||||||
mainAxisSize: .min,
|
? l10n.muteChat
|
||||||
children: [
|
: l10n.unmuteChat,
|
||||||
Icon(
|
icon: Icon(
|
||||||
room.pushRuleState == PushRuleState.notify
|
room.pushRuleState == PushRuleState.notify
|
||||||
? Icons.notifications_off_outlined
|
? Icons.notifications_off_outlined
|
||||||
: Icons.notifications_on_outlined,
|
: Icons.notifications_on_outlined,
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
room.pushRuleState == PushRuleState.notify
|
|
||||||
? L10n.of(context).muteChat
|
|
||||||
: L10n.of(context).unmuteChat,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: room.markedUnread
|
value: room.markedUnread
|
||||||
? SpaceChildAction.markAsRead
|
? SpaceChildAction.markAsRead
|
||||||
: SpaceChildAction.markAsUnread,
|
: SpaceChildAction.markAsUnread,
|
||||||
child: Row(
|
label: room.isUnread ? l10n.markAsRead : l10n.markAsUnread,
|
||||||
mainAxisSize: .min,
|
icon: Icon(
|
||||||
children: [
|
room.markedUnread
|
||||||
Icon(
|
? Icons.mark_as_unread
|
||||||
room.markedUnread
|
: Icons.mark_as_unread_outlined,
|
||||||
? Icons.mark_as_unread
|
|
||||||
: Icons.mark_as_unread_outlined,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
room.isUnread
|
|
||||||
? L10n.of(context).markAsRead
|
|
||||||
: L10n.of(context).markAsUnread,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: SpaceChildAction.leave,
|
value: SpaceChildAction.leave,
|
||||||
child: Row(
|
label: l10n.leave,
|
||||||
mainAxisSize: .min,
|
icon: const Icon(Icons.delete_outlined),
|
||||||
children: [
|
isDestructive: true,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (space.canChangeStateEvent(EventTypes.SpaceChild) == true)
|
if (space.canChangeStateEvent(EventTypes.SpaceChild) == true)
|
||||||
PopupMenuItem(
|
AdaptiveModalAction(
|
||||||
value: SpaceChildAction.removeFromSpace,
|
value: SpaceChildAction.removeFromSpace,
|
||||||
child: Row(
|
label: l10n.removeFromSpace,
|
||||||
mainAxisSize: .min,
|
icon: const Icon(Icons.remove),
|
||||||
children: [
|
isDestructive: true,
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ class _ChannelsTabState extends State<ChannelsTab> {
|
|||||||
room,
|
room,
|
||||||
key: Key('channel_list_item_${room.id}'),
|
key: Key('channel_list_item_${room.id}'),
|
||||||
onTap: () => widget.controller.onChatTap(room),
|
onTap: () => widget.controller.onChatTap(room),
|
||||||
|
onLongPress: (context) =>
|
||||||
|
widget.controller.chatContextAction(room, context),
|
||||||
activeChat: widget.controller.activeChat == room.id,
|
activeChat: widget.controller.activeChat == room.id,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ class _MuralsTabState extends State<MuralsTab> {
|
|||||||
room,
|
room,
|
||||||
key: Key('mural_list_item_${room.id}'),
|
key: Key('mural_list_item_${room.id}'),
|
||||||
onTap: () => widget.controller.onChatTap(room),
|
onTap: () => widget.controller.onChatTap(room),
|
||||||
|
onLongPress: (context) =>
|
||||||
|
widget.controller.chatContextAction(room, context),
|
||||||
activeChat: widget.controller.activeChat == room.id,
|
activeChat: widget.controller.activeChat == room.id,
|
||||||
displayAvatar: Uri.tryParse(
|
displayAvatar: Uri.tryParse(
|
||||||
creatorState?.content.tryGet<String>('avatar_url') ??
|
creatorState?.content.tryGet<String>('avatar_url') ??
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ class _MuralChatViewState extends State<MuralChatView> {
|
|||||||
bool _descriptionExpanded = false;
|
bool _descriptionExpanded = false;
|
||||||
bool _loadingHistory = false;
|
bool _loadingHistory = false;
|
||||||
bool _historyEnded = false;
|
bool _historyEnded = false;
|
||||||
|
bool _initialLoading = false;
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -125,7 +126,8 @@ class _MuralChatViewState extends State<MuralChatView> {
|
|||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
Logs().w('Failed to load more mural posts', e, s);
|
Logs().w('Failed to load more mural posts', e, s);
|
||||||
} finally {
|
} finally {
|
||||||
if (timeline.events.length <= eventCountBefore) {
|
if (!timeline.canRequestHistory ||
|
||||||
|
timeline.events.length <= eventCountBefore) {
|
||||||
_historyEnded = true;
|
_historyEnded = true;
|
||||||
}
|
}
|
||||||
_loadingHistory = false;
|
_loadingHistory = false;
|
||||||
@@ -133,6 +135,25 @@ class _MuralChatViewState extends State<MuralChatView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<void> _fillInitialMedia() async {
|
||||||
|
var attempts = 0;
|
||||||
|
const maxAttempts = 20;
|
||||||
|
const targetMediaCount = 30;
|
||||||
|
while (mounted &&
|
||||||
|
!_historyEnded &&
|
||||||
|
_mediaEvents.length < targetMediaCount &&
|
||||||
|
attempts < maxAttempts) {
|
||||||
|
attempts++;
|
||||||
|
await _loadMore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void updateView() {
|
void updateView() {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
@@ -142,12 +163,16 @@ class _MuralChatViewState extends State<MuralChatView> {
|
|||||||
timeline?.cancelSubscriptions();
|
timeline?.cancelSubscriptions();
|
||||||
_loadingHistory = false;
|
_loadingHistory = false;
|
||||||
_historyEnded = false;
|
_historyEnded = false;
|
||||||
|
_initialLoading = true;
|
||||||
timeline = await room.getTimeline(
|
timeline = await room.getTimeline(
|
||||||
onUpdate: updateView,
|
onUpdate: updateView,
|
||||||
limit: Room.defaultHistoryCount * 3,
|
limit: Room.defaultHistoryCount * 3,
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
await _fillInitialMedia();
|
||||||
|
_initialLoading = false;
|
||||||
|
if (mounted) setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendFiles(List<XFile>? files) async {
|
Future<void> _sendFiles(List<XFile>? files) async {
|
||||||
@@ -318,7 +343,9 @@ class _MuralChatViewState extends State<MuralChatView> {
|
|||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: events.isEmpty
|
child: _initialLoading
|
||||||
|
? const Center(child: CircularProgressIndicator.adaptive())
|
||||||
|
: events.isEmpty
|
||||||
? Center(
|
? Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
|
|||||||
@@ -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<void> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
476
lib/widgets/self_profile_dialog.dart
Normal file
476
lib/widgets/self_profile_dialog.dart
Normal file
@@ -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<void> showSelfProfileDialog(BuildContext context) => showAdaptiveDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: true,
|
||||||
|
builder: (context) => const SelfProfileDialog(),
|
||||||
|
);
|
||||||
|
|
||||||
|
class SelfProfileDialog extends StatefulWidget {
|
||||||
|
const SelfProfileDialog({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SelfProfileDialog> createState() => _SelfProfileDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SelfProfileDialogState extends State<SelfProfileDialog> {
|
||||||
|
Future<Profile>? _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _openQrScanner() async {
|
||||||
|
final context = this.context;
|
||||||
|
await showAdaptiveBottomSheet(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => QrScannerModal(
|
||||||
|
onScan: (link) => UrlLauncher(context, link).openMatrixToUrl(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<Profile>(
|
||||||
|
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 }
|
||||||
Reference in New Issue
Block a user