refactor: Replace user bottom sheet with menu and small dialog

Signed-off-by: Krille <c.kussowski@famedly.com>
This commit is contained in:
Krille 2025-04-10 18:27:16 +02:00
commit a12c48fae6
No known key found for this signature in database
GPG key ID: E067ECD60F1A0652
21 changed files with 734 additions and 851 deletions

View file

@ -5,12 +5,14 @@ class AdaptiveDialogAction extends StatelessWidget {
final VoidCallback? onPressed;
final bool autofocus;
final Widget child;
final bool bigButtons;
const AdaptiveDialogAction({
super.key,
required this.onPressed,
required this.child,
this.autofocus = false,
this.bigButtons = false,
});
@override
@ -21,6 +23,27 @@ class AdaptiveDialogAction extends StatelessWidget {
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
if (bigButtons) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: autofocus
? theme.colorScheme.primary
: theme.colorScheme.primaryContainer,
foregroundColor: autofocus
? theme.colorScheme.onPrimary
: theme.colorScheme.onPrimaryContainer,
),
onPressed: onPressed,
autofocus: autofocus,
child: child,
),
),
);
}
return TextButton(
onPressed: onPressed,
autofocus: autofocus,

View file

@ -0,0 +1,180 @@
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/presence_builder.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import '../../utils/url_launcher.dart';
import '../future_loading_dialog.dart';
import '../hover_builder.dart';
import '../matrix.dart';
class UserDialog extends StatelessWidget {
static Future<void> show({
required BuildContext context,
required Profile profile,
bool noProfileWarning = false,
}) =>
showAdaptiveDialog(
context: context,
builder: (context) => UserDialog(
profile,
noProfileWarning: noProfileWarning,
),
);
final Profile profile;
final bool noProfileWarning;
const UserDialog(this.profile, {this.noProfileWarning = false, super.key});
@override
Widget build(BuildContext context) {
final client = Matrix.of(context).client;
final dmRoomId = client.getDirectChatFromUserId(profile.userId);
final displayname = profile.displayName ??
profile.userId.localpart ??
L10n.of(context).user;
var copied = false;
final theme = Theme.of(context);
return AlertDialog.adaptive(
title: Center(child: Text(displayname, textAlign: TextAlign.center)),
content: SelectionArea(
child: PresenceBuilder(
userId: profile.userId,
client: Matrix.of(context).client,
builder: (context, presence) {
if (presence == null) return const SizedBox.shrink();
final statusMsg = presence.statusMsg;
final lastActiveTimestamp = presence.lastActiveTimestamp;
final presenceText = presence.currentlyActive == true
? L10n.of(context).currentlyActive
: lastActiveTimestamp != null
? L10n.of(context).lastActiveAgo(
lastActiveTimestamp.localizedTimeShort(context),
)
: null;
return Column(
spacing: 8,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
HoverBuilder(
builder: (context, hovered) => StatefulBuilder(
builder: (context, setState) => GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: profile.userId));
setState(() {
copied = true;
});
},
child: RichText(
text: TextSpan(
children: [
WidgetSpan(
child: Padding(
padding: const EdgeInsets.only(right: 4.0),
child: AnimatedScale(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
scale: hovered
? 1.33
: copied
? 1.25
: 1.0,
child: Icon(
copied ? Icons.check_circle : Icons.copy,
size: 12,
color: copied ? Colors.green : null,
),
),
),
),
TextSpan(text: profile.userId),
],
style: theme.textTheme.bodyMedium
?.copyWith(fontSize: 10),
),
textAlign: TextAlign.center,
),
),
),
),
Avatar(
mxContent: profile.avatarUrl,
name: displayname,
size: Avatar.defaultSize * 2,
),
if (presenceText != null)
Text(
presenceText,
style: const TextStyle(fontSize: 10),
),
if (statusMsg != null)
Linkify(
text: statusMsg,
textAlign: TextAlign.center,
options: const LinkifyOptions(humanize: false),
linkStyle: TextStyle(
color: theme.colorScheme.primary,
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
),
onOpen: (url) => UrlLauncher(context, url.url).launchUrl(),
),
],
);
},
),
),
actions: [
if (client.userID != profile.userId) ...[],
AdaptiveDialogAction(
bigButtons: true,
onPressed: () async {
final router = GoRouter.of(context);
Navigator.of(context).pop();
final roomIdResult = await showFutureLoadingDialog(
context: context,
future: () => client.startDirectChat(profile.userId),
);
final roomId = roomIdResult.result;
if (roomId == null) return;
router.go('/rooms/$roomId');
},
child: Text(
dmRoomId == null
? L10n.of(context).startConversation
: L10n.of(context).sendAMessage,
),
),
AdaptiveDialogAction(
bigButtons: true,
onPressed: () {
final router = GoRouter.of(context);
Navigator.of(context).pop();
router.go(
'/rooms/settings/security/ignorelist',
extra: profile.userId,
);
},
child: Text(
L10n.of(context).ignoreUser,
style: TextStyle(color: theme.colorScheme.error),
),
),
AdaptiveDialogAction(
bigButtons: true,
onPressed: Navigator.of(context).pop,
child: Text(L10n.of(context).close),
),
],
);
}
}

View file

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/utils/string_color.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
import 'package:fluffychat/widgets/presence_builder.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
class Avatar extends StatelessWidget {
final Uri? mxContent;

View file

@ -20,6 +20,7 @@ Future<Result<T>> showFutureLoadingDialog<T>({
bool barrierDismissible = false,
bool delay = true,
ExceptionContext? exceptionContext,
bool ignoreError = false,
}) async {
final futureExec = future();
final resultFuture = ResultFuture(futureExec);
@ -67,6 +68,7 @@ class LoadingDialog<T> extends StatefulWidget {
this.backLabel,
this.exceptionContext,
});
@override
LoadingDialogState<T> createState() => LoadingDialogState<T>();
}

View file

@ -0,0 +1,296 @@
import 'package:fluffychat/widgets/permission_slider_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'adaptive_dialogs/show_modal_action_popup.dart';
import 'adaptive_dialogs/show_ok_cancel_alert_dialog.dart';
import 'adaptive_dialogs/show_text_input_dialog.dart';
import 'adaptive_dialogs/user_dialog.dart';
import 'avatar.dart';
import 'future_loading_dialog.dart';
class MemberActionsPopupMenuButton extends StatelessWidget {
final Widget child;
final User user;
final void Function()? onMention;
const MemberActionsPopupMenuButton({
required this.child,
required this.user,
this.onMention,
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final displayname = user.calcDisplayname();
final isMe = user.room.client.userID == user.id;
return PopupMenuButton(
onSelected: (action) async {
switch (action) {
case _MemberActions.mention:
onMention?.call();
return;
case _MemberActions.setRole:
final power = await showPermissionChooser(
context,
currentLevel: user.powerLevel,
maxLevel: user.room.ownPowerLevel,
);
if (power == null) return;
if (!context.mounted) return;
if (power >= 100) {
final consent = await showOkCancelAlertDialog(
context: context,
title: L10n.of(context).areYouSure,
message: L10n.of(context).makeAdminDescription,
);
if (consent != OkCancelResult.ok) return;
if (!context.mounted) return;
}
await showFutureLoadingDialog(
context: context,
future: () => user.setPower(power),
);
return;
case _MemberActions.kick:
if (await showOkCancelAlertDialog(
useRootNavigator: false,
context: context,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).yes,
cancelLabel: L10n.of(context).no,
message: L10n.of(context).kickUserDescription,
) ==
OkCancelResult.ok) {
await showFutureLoadingDialog(
context: context,
future: () => user.kick(),
);
}
return;
case _MemberActions.ban:
if (await showOkCancelAlertDialog(
useRootNavigator: false,
context: context,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).yes,
cancelLabel: L10n.of(context).no,
message: L10n.of(context).banUserDescription,
) ==
OkCancelResult.ok) {
await showFutureLoadingDialog(
context: context,
future: () => user.ban(),
);
}
return;
case _MemberActions.report:
final score = await showModalActionPopup<int>(
context: context,
title: L10n.of(context).reportUser,
message: L10n.of(context).howOffensiveIsThisContent,
cancelLabel: L10n.of(context).cancel,
actions: [
AdaptiveModalAction(
value: -100,
label: L10n.of(context).extremeOffensive,
),
AdaptiveModalAction(
value: -50,
label: L10n.of(context).offensive,
),
AdaptiveModalAction(
value: 0,
label: L10n.of(context).inoffensive,
),
],
);
if (score == null) return;
final reason = await showTextInputDialog(
useRootNavigator: false,
context: context,
title: L10n.of(context).whyDoYouWantToReportThis,
okLabel: L10n.of(context).ok,
cancelLabel: L10n.of(context).cancel,
hintText: L10n.of(context).reason,
);
if (reason == null || reason.isEmpty) return;
final result = await showFutureLoadingDialog(
context: context,
future: () => user.room.client.reportEvent(
user.room.id,
user.id,
reason: reason,
score: score,
),
);
if (result.error != null) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).contentHasBeenReported)),
);
return;
case _MemberActions.info:
await UserDialog.show(
context: context,
profile: Profile(
userId: user.id,
displayName: user.displayName,
avatarUrl: user.avatarUrl,
),
);
return;
case _MemberActions.unban:
if (await showOkCancelAlertDialog(
useRootNavigator: false,
context: context,
title: L10n.of(context).areYouSure,
okLabel: L10n.of(context).yes,
cancelLabel: L10n.of(context).no,
message: L10n.of(context).unbanUserDescription,
) ==
OkCancelResult.ok) {
await showFutureLoadingDialog(
context: context,
future: () => user.unban(),
);
}
return;
}
},
itemBuilder: (context) => <PopupMenuEntry<_MemberActions>>[
PopupMenuItem(
value: _MemberActions.info,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Avatar(
name: displayname,
mxContent: user.avatarUrl,
presenceUserId: user.id,
presenceBackgroundColor: theme.colorScheme.surfaceContainer,
),
const SizedBox(height: 8),
Text(
displayname,
textAlign: TextAlign.center,
style: theme.textTheme.labelLarge,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
user.id,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 10),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
const PopupMenuDivider(),
if (onMention != null)
PopupMenuItem(
value: _MemberActions.mention,
child: ListTile(
leading: const Icon(Icons.alternate_email_outlined),
title: Text(L10n.of(context).mention),
),
),
PopupMenuItem(
enabled:
user.room.canChangePowerLevel && user.canChangeUserPowerLevel,
value: _MemberActions.setRole,
child: ListTile(
leading: const Icon(Icons.admin_panel_settings_outlined),
title: Text(L10n.of(context).chatPermissions),
subtitle: Text(
user.powerLevel < 50
? L10n.of(context).userLevel(user.powerLevel)
: user.powerLevel < 100
? L10n.of(context).moderatorLevel(user.powerLevel)
: L10n.of(context).adminLevel(user.powerLevel),
style: const TextStyle(fontSize: 10),
),
),
),
if (user.canKick)
PopupMenuItem(
value: _MemberActions.kick,
child: ListTile(
leading: Icon(
Icons.person_remove_outlined,
color: theme.colorScheme.onErrorContainer,
),
title: Text(
L10n.of(context).kickFromChat,
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
),
),
if (user.canBan)
PopupMenuItem(
value: _MemberActions.ban,
child: ListTile(
leading: Icon(
Icons.block_outlined,
color: theme.colorScheme.onErrorContainer,
),
title: Text(
L10n.of(context).banFromChat,
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
),
),
if (user.canBan && user.membership == Membership.ban)
PopupMenuItem(
value: _MemberActions.ban,
child: ListTile(
leading: const Icon(Icons.warning),
title: Text(
L10n.of(context).unbanFromChat,
),
),
),
if (user.canBan && user.membership == Membership.ban)
PopupMenuItem(
value: _MemberActions.unban,
child: ListTile(
leading: const Icon(Icons.warning_outlined),
title: Text(L10n.of(context).unbanFromChat),
),
),
if (!isMe)
PopupMenuItem(
value: _MemberActions.report,
child: ListTile(
leading: Icon(
Icons.gavel_outlined,
color: theme.colorScheme.onErrorContainer,
),
title: Text(
L10n.of(context).reportUser,
style: TextStyle(color: theme.colorScheme.onErrorContainer),
),
),
),
],
child: child,
);
}
}
enum _MemberActions {
info,
mention,
setRole,
kick,
ban,
unban,
report,
}

View file

@ -1,31 +1,72 @@
import 'package:flutter/cupertino.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/adaptive_dialog_action.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/dialog_text_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart';
Future<int?> showPermissionChooser(
BuildContext context, {
int currentLevel = 0,
int maxLevel = 100,
}) async {
final customLevel = await showTextInputDialog(
final controller = TextEditingController();
final error = ValueNotifier<String?>(null);
return await showAdaptiveDialog<int>(
context: context,
title: L10n.of(context).setPermissionsLevel,
initialText: currentLevel.toString(),
keyboardType: TextInputType.number,
autocorrect: false,
validator: (text) {
if (text.isEmpty) {
return L10n.of(context).pleaseEnterANumber;
}
final level = int.tryParse(text);
if (level == null) {
return L10n.of(context).pleaseEnterANumber;
}
return null;
},
builder: (context) => AlertDialog.adaptive(
title: Text(L10n.of(context).chatPermissions),
content: Column(
mainAxisSize: MainAxisSize.min,
spacing: 12.0,
children: [
Text(L10n.of(context).setPermissionsLevelDescription),
ValueListenableBuilder(
valueListenable: error,
builder: (context, errorText, _) => DialogTextField(
controller: controller,
hintText: currentLevel.toString(),
keyboardType: TextInputType.number,
labelText: L10n.of(context).custom,
errorText: errorText,
),
),
],
),
actions: [
AdaptiveDialogAction(
bigButtons: true,
onPressed: () {
final level = int.tryParse(controller.text.trim());
if (level == null) {
error.value = L10n.of(context).pleaseEnterANumber;
return;
}
if (level > maxLevel) {
error.value = L10n.of(context).noPermission;
return;
}
Navigator.of(context).pop<int>(level);
},
child: Text(L10n.of(context).setCustomPermissionLevel),
),
if (maxLevel >= 100 && currentLevel != 100)
AdaptiveDialogAction(
bigButtons: true,
onPressed: () => Navigator.of(context).pop<int>(100),
child: Text(L10n.of(context).admin),
),
if (maxLevel >= 50 && currentLevel != 50)
AdaptiveDialogAction(
bigButtons: true,
onPressed: () => Navigator.of(context).pop<int>(50),
child: Text(L10n.of(context).moderator),
),
if (currentLevel != 0)
AdaptiveDialogAction(
bigButtons: true,
onPressed: () => Navigator.of(context).pop<int>(0),
child: Text(L10n.of(context).normalUser),
),
],
),
);
if (customLevel == null) return null;
return int.tryParse(customLevel);
}