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:
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