// 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( insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), content: SingleChildScrollView( child: SizedBox( width: MediaQuery.of(context).size.width > 720 ? 420 : MediaQuery.of(context).size.width, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 800), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: 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: 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.camera_alt_outlined, size: 18, 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: 0), _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: MainAxisSize.min, children: [ Flexible( child: Text( userId, maxLines: 1, overflow: TextOverflow.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: MainAxisAlignment.center, children: [ Flexible( child: Text( text, maxLines: 3, overflow: TextOverflow.ellipsis, textAlign: 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 }