From d46646d7819c45e224a9bc535fc8ef1edf8b53e3 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Tue, 9 Jun 2026 20:34:56 -0400 Subject: [PATCH] redesign user popover --- lib/helpers/extensions/show_user_popover.dart | 23 +- lib/widgets/avatar_or_hash.dart | 2 +- lib/widgets/html/mention_chip.dart | 7 +- lib/widgets/lazy_loading/message_avatar.dart | 31 +-- .../lazy_loading/message_displayname.dart | 1 - lib/widgets/member_list.dart | 31 ++- lib/widgets/renderers/membership.dart | 1 - lib/widgets/user_bottom_sheet.dart | 254 ++++++++++++++++++ lib/widgets/user_popover.dart | 224 --------------- 9 files changed, 302 insertions(+), 272 deletions(-) create mode 100644 lib/widgets/user_bottom_sheet.dart delete mode 100644 lib/widgets/user_popover.dart diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index e703721..1ea3015 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -1,25 +1,18 @@ import "package:flutter/material.dart"; -import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/models/content/membership.dart"; -import "package:nexus/widgets/user_popover.dart"; +import "package:nexus/widgets/user_bottom_sheet.dart"; extension ShowUserPopover on BuildContext { void showUserPopover( MembershipContent member, String userId, { String? roomId, - required Offset globalPosition, - }) => showContextMenu( - globalPosition: globalPosition, - children: [ - PopupMenuItem( - enabled: false, - padding: .symmetric(horizontal: 16, vertical: 8), - child: IconTheme( - data: .new(), - child: UserPopover(member, userId, roomId: roomId), - ), - ), - ], + }) => showModalBottomSheet( + constraints: BoxConstraints.loose( + Size(500, View.of(this).physicalSize.height - 80), + ), + isScrollControlled: true, + context: this, + builder: (context) => UserBottomSheet(member, userId, roomId: roomId), ); } diff --git a/lib/widgets/avatar_or_hash.dart b/lib/widgets/avatar_or_hash.dart index 4931684..20f4eac 100644 --- a/lib/widgets/avatar_or_hash.dart +++ b/lib/widgets/avatar_or_hash.dart @@ -24,7 +24,7 @@ class AvatarOrHash extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final box = ColoredBox( color: ColorHash(title).color, - child: Center(child: Text(title.isEmpty ? "" : title[0])), + child: Center(child: Icon(Icons.person, size: height / 2)), ); final parsedAvatar = avatar?.mxcToHttps( diff --git a/lib/widgets/html/mention_chip.dart b/lib/widgets/html/mention_chip.dart index c757c3f..5a2b752 100644 --- a/lib/widgets/html/mention_chip.dart +++ b/lib/widgets/html/mention_chip.dart @@ -25,12 +25,7 @@ class MentionChip extends ConsumerWidget { : InkWell( onTapUp: (details) { if (membership != null) { - context.showUserPopover( - membership, - mention, - roomId: roomId, - globalPosition: details.globalPosition, - ); + context.showUserPopover(membership, mention, roomId: roomId); } }, child: IgnorePointer( diff --git a/lib/widgets/lazy_loading/message_avatar.dart b/lib/widgets/lazy_loading/message_avatar.dart index e7c7a77..62b5afd 100644 --- a/lib/widgets/lazy_loading/message_avatar.dart +++ b/lib/widgets/lazy_loading/message_avatar.dart @@ -12,21 +12,18 @@ class MessageAvatar extends ConsumerWidget { const MessageAvatar(this.event, {this.height = 24, super.key}); @override - Widget build(BuildContext context, WidgetRef ref) => - switch (ref.watch(AuthorController.provider(event))) { - AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( - onTapUp: (details) => context.showUserPopover( - value, - event.sender, - roomId: event.roomId, - globalPosition: details.globalPosition, - ), - child: AvatarOrHash( - value.avatarUrl, - value.displayName ?? event.sender.localpart, - height: height, - ), - ), - _ => AvatarOrHash(null, event.sender.localpart, height: height), - }; + Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch( + AuthorController.provider(event), + )) { + AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( + onTapUp: (details) => + context.showUserPopover(value, event.sender, roomId: event.roomId), + child: AvatarOrHash( + value.avatarUrl, + value.displayName ?? event.sender.localpart, + height: height, + ), + ), + _ => AvatarOrHash(null, event.sender.localpart, height: height), + }; } diff --git a/lib/widgets/lazy_loading/message_displayname.dart b/lib/widgets/lazy_loading/message_displayname.dart index ffa5dc0..4f00471 100644 --- a/lib/widgets/lazy_loading/message_displayname.dart +++ b/lib/widgets/lazy_loading/message_displayname.dart @@ -27,7 +27,6 @@ class MessageDisplayname extends ConsumerWidget { value, event.sender, roomId: event.roomId, - globalPosition: details.globalPosition, ) : null, child: Wrap( diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index d3c21b6..6326421 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -14,6 +14,7 @@ import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/loading.dart"; +import "package:nexus/widgets/user_bottom_sheet.dart"; class MemberList extends HookConsumerWidget { final String roomId; @@ -138,8 +139,11 @@ class MemberList extends HookConsumerWidget { ), leading: AvatarOrHash( avatarUrl, + height: 36, displayName ?? - members[index].sender.localpart, + members[index] + .stateKey! + .localpart, ), ), _ => throw Exception( @@ -147,12 +151,25 @@ class MemberList extends HookConsumerWidget { ), }, onTap: (index) { - // context.showUserPopover( - // member.content as MembershipContent, - // member.stateKey!, - // roomId: roomId, - // globalPosition: details.globalPosition, - // ), + final member = members[index]; + if (member.content + case MembershipContent content) { + showModalBottomSheet( + constraints: BoxConstraints.loose( + Size( + 500, + (context.size?.height ?? 1000) - 80, + ), + ), + isScrollControlled: true, + context: context, + builder: (context) => UserBottomSheet( + content, + member.stateKey!, + roomId: roomId, + ), + ); + } }, ), ], diff --git a/lib/widgets/renderers/membership.dart b/lib/widgets/renderers/membership.dart index c8c91ba..ba8741f 100644 --- a/lib/widgets/renderers/membership.dart +++ b/lib/widgets/renderers/membership.dart @@ -25,7 +25,6 @@ class MembershipRenderer extends StatelessWidget { content, event.stateKey!, roomId: event.roomId, - globalPosition: details.globalPosition, ), child: Text( overflow: .ellipsis, diff --git a/lib/widgets/user_bottom_sheet.dart b/lib/widgets/user_bottom_sheet.dart new file mode 100644 index 0000000..5cbb384 --- /dev/null +++ b/lib/widgets/user_bottom_sheet.dart @@ -0,0 +1,254 @@ +import "package:collection/collection.dart"; +import "package:flutter/material.dart"; +import "package:flutter_hooks/flutter_hooks.dart"; +import "package:flutter_riverpod/flutter_riverpod.dart"; +import "package:intl/intl.dart"; +import "package:m3e_buttons/m3e_buttons.dart"; +import "package:nexus/controllers/client_controller.dart"; +import "package:nexus/controllers/client_state_controller.dart"; +import "package:nexus/controllers/power_level_controller.dart"; +import "package:nexus/controllers/profile_controller.dart"; +import "package:nexus/helpers/extensions/better_when.dart"; +import "package:nexus/helpers/extensions/get_localpart.dart"; +import "package:nexus/helpers/extensions/mxc_to_https.dart"; +import "package:nexus/models/content/membership.dart"; +import "package:nexus/models/requests/membership_action.dart"; +import "package:nexus/widgets/avatar_or_hash.dart"; +import "package:nexus/main.dart"; +import "package:nexus/widgets/expandable_image.dart"; + +class UserBottomSheet extends ConsumerWidget { + final MembershipContent member; + final String userId; + final String? roomId; + const UserBottomSheet(this.member, this.userId, {this.roomId, super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final textTheme = theme.textTheme; + final client = ref.watch(ClientController.provider.notifier); + + void showMembershipDialog(MembershipAction action) => showDialog( + context: context, + builder: (context) => HookBuilder( + builder: (context) { + final actionReasonController = useTextEditingController(); + return AlertDialog( + title: Text("${toBeginningOfSentenceCase(action.name)} $userId"), + content: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text("Are you sure you want to ${action.name} $userId?"), + SizedBox(height: 12), + TextField( + textCapitalization: .sentences, + controller: actionReasonController, + decoration: .new( + labelText: "Reason for ${action.name} (optional)", + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: Navigator.of(context).pop, + child: Text("Cancel"), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + client + .setMembership( + .new( + userId: userId, + roomId: roomId!, + action: action, + reason: actionReasonController.text, + ), + ) + .onError(showError); + }, + child: Text(toBeginningOfSentenceCase(action.name)), + ), + ], + ); + }, + ), + ); + + return Padding( + padding: .all(42), + child: Column( + spacing: 4, + mainAxisSize: .min, + crossAxisAlignment: .center, + children: [ + Row( + mainAxisAlignment: .end, + children: [ + M3EButton( + onPressed: Navigator.of(context).pop, + child: Icon(Icons.close), + ), + ], + ), + SizedBox(height: 18), + + ExpandableImage( + member.avatarUrl + ?.mxcToHttps( + ref.watch( + ClientStateController.provider.select( + (value) => value!.homeserverUrl!, + ), + ), + ) + .toString(), + child: AvatarOrHash( + member.avatarUrl, + member.displayName ?? userId.localpart, + height: 200, + ), + ), + + SizedBox(height: 8), + + SelectableText( + member.displayName ?? userId.localpart, + style: textTheme.headlineLarge, + ), + SelectableText( + userId, + style: textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + + ref + .watch(ProfileController.provider(userId)) + .betterWhen( + loading: SizedBox.shrink, + data: (profile) => Column( + children: [ + Wrap( + crossAxisAlignment: .center, + spacing: 4, + runSpacing: 4, + children: [ + ...profile.pronouns + .where((pronoun) => pronoun.language == "en") + .mapIndexed( + (index, pronoun) => [ + if (index != 0) + Icon( + Icons.circle, + size: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + Text( + pronoun.summary, + style: textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ) + .flattened, + + if (profile.timezone != null) ...[ + if (profile.pronouns.isNotEmpty) + SizedBox( + height: 16, + child: VerticalDivider( + thickness: 1.5, + width: 4, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Text( + profile.timezone!, + style: textTheme.titleSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ], + ), + ), + + SizedBox(height: 8), + if (userId != ref.watch(ClientStateController.provider)?.userId && + roomId != null) ...[ + if (ref.watch( + PowerLevelController.provider( + .membershipAction( + action: .kick, + roomId: roomId!, + targetUser: userId, + ), + ), + ) && + member.status == .join || + member.status == .invite) + Padding( + padding: .only(bottom: 8), + child: Row( + mainAxisSize: MainAxisSize.max, + spacing: 8, + children: [ + M3EButton.icon( + onPressed: () => showMembershipDialog(.kick), + shape: .square, + icon: Icon(Icons.sports_martial_arts), + label: Text("Kick"), + decoration: .new( + backgroundColor: WidgetStatePropertyAll( + theme.colorScheme.error, + ), + foregroundColor: WidgetStatePropertyAll( + theme.colorScheme.onError, + ), + ), + ), + + M3EButton.icon( + onPressed: () => showMembershipDialog(.ban), + shape: .square, + icon: Icon(Icons.gavel), + label: Text("Ban"), + decoration: .new( + backgroundColor: WidgetStatePropertyAll( + theme.colorScheme.errorContainer, + ), + foregroundColor: WidgetStatePropertyAll( + theme.colorScheme.onErrorContainer, + ), + ), + ), + ].map((e) => Expanded(child: e)).toList(), + ), + ), + + Row( + children: [ + Expanded( + child: M3EButton.icon( + onPressed: null, + shape: .square, + style: .tonal, + icon: Icon(Icons.message), + label: Text("Message"), + ), + ), + ], + ), + ], + ], + ), + ); + } +} diff --git a/lib/widgets/user_popover.dart b/lib/widgets/user_popover.dart deleted file mode 100644 index 97d88c4..0000000 --- a/lib/widgets/user_popover.dart +++ /dev/null @@ -1,224 +0,0 @@ -import "package:flutter/material.dart"; -import "package:flutter_hooks/flutter_hooks.dart"; -import "package:flutter_riverpod/flutter_riverpod.dart"; -import "package:intl/intl.dart"; -import "package:nexus/controllers/client_controller.dart"; -import "package:nexus/controllers/client_state_controller.dart"; -import "package:nexus/controllers/power_level_controller.dart"; -import "package:nexus/controllers/profile_controller.dart"; -import "package:nexus/helpers/extensions/better_when.dart"; -import "package:nexus/helpers/extensions/get_localpart.dart"; -import "package:nexus/helpers/extensions/mxc_to_https.dart"; -import "package:nexus/models/content/membership.dart"; -import "package:nexus/models/requests/membership_action.dart"; -import "package:nexus/widgets/avatar_or_hash.dart"; -import "package:nexus/main.dart"; -import "package:nexus/widgets/expandable_image.dart"; - -class UserPopover extends ConsumerWidget { - final MembershipContent member; - final String userId; - final String? roomId; - const UserPopover(this.member, this.userId, {this.roomId, super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); - final textTheme = theme.textTheme; - final client = ref.watch(ClientController.provider.notifier); - - void showMembershipDialog(MembershipAction action) => showDialog( - context: context, - builder: (context) => HookBuilder( - builder: (context) { - final actionReasonController = useTextEditingController(); - return AlertDialog( - title: Text("${toBeginningOfSentenceCase(action.name)} $userId"), - content: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text("Are you sure you want to ${action.name} $userId?"), - SizedBox(height: 12), - TextField( - textCapitalization: .sentences, - controller: actionReasonController, - decoration: .new( - labelText: "Reason for ${action.name} (optional)", - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: Navigator.of(context).pop, - child: Text("Cancel"), - ), - TextButton( - onPressed: () { - Navigator.of(context).pop(); - client - .setMembership( - .new( - userId: userId, - roomId: roomId!, - action: action, - reason: actionReasonController.text, - ), - ) - .onError(showError); - }, - child: Text(toBeginningOfSentenceCase(action.name)), - ), - ], - ); - }, - ), - ); - - final actionButton = ButtonStyle( - padding: WidgetStatePropertyAll(.symmetric(horizontal: 24, vertical: 18)), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: .circular(8)), - ), - ); - - return Column( - spacing: 16, - crossAxisAlignment: .stretch, - children: [ - Wrap( - alignment: .center, - spacing: 16, - runSpacing: 8, - children: [ - ExpandableImage( - member.avatarUrl - ?.mxcToHttps( - ref.watch( - ClientStateController.provider.select( - (value) => value!.homeserverUrl!, - ), - ), - ) - .toString(), - child: AvatarOrHash( - member.avatarUrl, - member.displayName ?? userId.localpart, - height: 80, - ), - ), - Column( - children: [ - SelectableText( - member.displayName ?? userId.localpart, - style: textTheme.headlineSmall, - ), - SelectableText(userId, style: textTheme.titleSmall), - SizedBox(height: 4), - ref - .watch(ProfileController.provider(userId)) - .betterWhen( - loading: SizedBox.shrink, - data: (profile) => Wrap( - alignment: .center, - spacing: 4, - runSpacing: 4, - children: [ - for (final pronoun in profile.pronouns.where( - (pronoun) => pronoun.language == "en", - )) - Chip( - label: Text(pronoun.summary), - labelStyle: .new( - color: theme.colorScheme.onPrimary, - ), - color: WidgetStatePropertyAll( - theme.colorScheme.primary, - ), - ), - if (profile.timezone != null) - Chip( - label: Text(profile.timezone!), - labelStyle: .new( - color: theme.colorScheme.onPrimary, - ), - color: WidgetStatePropertyAll( - theme.colorScheme.primary, - ), - ), - ], - ), - ), - ], - ), - ], - ), - if (userId != ref.watch(ClientStateController.provider)?.userId && - roomId != null) - Wrap( - alignment: .center, - spacing: 8, - runSpacing: 8, - children: [ - FilledButton.icon( - onPressed: null, - label: Text("Message"), - icon: Icon(Icons.message), - style: actionButton, - ), - - if (ref.watch( - PowerLevelController.provider( - .membershipAction( - action: .kick, - roomId: roomId!, - targetUser: userId, - ), - ), - ) && - member.status == .join || - member.status == .invite) - FilledButton.icon( - onPressed: () => showMembershipDialog(.kick), - label: Text("Kick"), - style: actionButton.copyWith( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.error, - ), - foregroundColor: WidgetStatePropertyAll( - theme.colorScheme.onError, - ), - ), - icon: Icon(Icons.sports_martial_arts), - ), - if (ref.watch( - PowerLevelController.provider( - .membershipAction( - roomId: roomId!, - action: .ban, - targetUser: userId, - ), - ), - )) - ElevatedButton.icon( - onPressed: () => showMembershipDialog( - member.status == .ban ? .unban : .ban, - ), - icon: Icon(Icons.gavel), - label: Text(member.status == .ban ? "Unban" : "Ban"), - style: actionButton.copyWith( - backgroundColor: WidgetStatePropertyAll( - theme.colorScheme.errorContainer, - ), - foregroundColor: WidgetStatePropertyAll( - theme.colorScheme.onErrorContainer, - ), - ), - ), - ], - ), - ], - ); - } -}