Files
tatuchat/lib/widgets/self_profile_dialog.dart

482 lines
18 KiB
Dart

// 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(
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<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: 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 }