482 lines
18 KiB
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 } |