redesign user popover

This commit is contained in:
Henry Hiles 2026-06-09 20:34:56 -04:00
commit d46646d781
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
9 changed files with 302 additions and 272 deletions

View file

@ -1,25 +1,18 @@
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:nexus/helpers/extensions/show_context_menu.dart";
import "package:nexus/models/content/membership.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 { extension ShowUserPopover on BuildContext {
void showUserPopover( void showUserPopover(
MembershipContent member, MembershipContent member,
String userId, { String userId, {
String? roomId, String? roomId,
required Offset globalPosition, }) => showModalBottomSheet(
}) => showContextMenu( constraints: BoxConstraints.loose(
globalPosition: globalPosition, Size(500, View.of(this).physicalSize.height - 80),
children: [ ),
PopupMenuItem( isScrollControlled: true,
enabled: false, context: this,
padding: .symmetric(horizontal: 16, vertical: 8), builder: (context) => UserBottomSheet(member, userId, roomId: roomId),
child: IconTheme(
data: .new(),
child: UserPopover(member, userId, roomId: roomId),
),
),
],
); );
} }

View file

@ -24,7 +24,7 @@ class AvatarOrHash extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final box = ColoredBox( final box = ColoredBox(
color: ColorHash(title).color, 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( final parsedAvatar = avatar?.mxcToHttps(

View file

@ -25,12 +25,7 @@ class MentionChip extends ConsumerWidget {
: InkWell( : InkWell(
onTapUp: (details) { onTapUp: (details) {
if (membership != null) { if (membership != null) {
context.showUserPopover( context.showUserPopover(membership, mention, roomId: roomId);
membership,
mention,
roomId: roomId,
globalPosition: details.globalPosition,
);
} }
}, },
child: IgnorePointer( child: IgnorePointer(

View file

@ -12,21 +12,18 @@ class MessageAvatar extends ConsumerWidget {
const MessageAvatar(this.event, {this.height = 24, super.key}); const MessageAvatar(this.event, {this.height = 24, super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) => Widget build(BuildContext context, WidgetRef ref) => switch (ref.watch(
switch (ref.watch(AuthorController.provider(event))) { AuthorController.provider(event),
AsyncData(:final value) || AsyncLoading(:final value?) => InkWell( )) {
onTapUp: (details) => context.showUserPopover( AsyncData(:final value) || AsyncLoading(:final value?) => InkWell(
value, onTapUp: (details) =>
event.sender, context.showUserPopover(value, event.sender, roomId: event.roomId),
roomId: event.roomId, child: AvatarOrHash(
globalPosition: details.globalPosition, value.avatarUrl,
), value.displayName ?? event.sender.localpart,
child: AvatarOrHash( height: height,
value.avatarUrl, ),
value.displayName ?? event.sender.localpart, ),
height: height, _ => AvatarOrHash(null, event.sender.localpart, height: height),
), };
),
_ => AvatarOrHash(null, event.sender.localpart, height: height),
};
} }

View file

@ -27,7 +27,6 @@ class MessageDisplayname extends ConsumerWidget {
value, value,
event.sender, event.sender,
roomId: event.roomId, roomId: event.roomId,
globalPosition: details.globalPosition,
) )
: null, : null,
child: Wrap( child: Wrap(

View file

@ -14,6 +14,7 @@ import "package:nexus/widgets/avatar_or_hash.dart";
import "package:nexus/widgets/divider_text.dart"; import "package:nexus/widgets/divider_text.dart";
import "package:nexus/widgets/error_dialog.dart"; import "package:nexus/widgets/error_dialog.dart";
import "package:nexus/widgets/loading.dart"; import "package:nexus/widgets/loading.dart";
import "package:nexus/widgets/user_bottom_sheet.dart";
class MemberList extends HookConsumerWidget { class MemberList extends HookConsumerWidget {
final String roomId; final String roomId;
@ -138,8 +139,11 @@ class MemberList extends HookConsumerWidget {
), ),
leading: AvatarOrHash( leading: AvatarOrHash(
avatarUrl, avatarUrl,
height: 36,
displayName ?? displayName ??
members[index].sender.localpart, members[index]
.stateKey!
.localpart,
), ),
), ),
_ => throw Exception( _ => throw Exception(
@ -147,12 +151,25 @@ class MemberList extends HookConsumerWidget {
), ),
}, },
onTap: (index) { onTap: (index) {
// context.showUserPopover( final member = members[index];
// member.content as MembershipContent, if (member.content
// member.stateKey!, case MembershipContent content) {
// roomId: roomId, showModalBottomSheet(
// globalPosition: details.globalPosition, constraints: BoxConstraints.loose(
// ), Size(
500,
(context.size?.height ?? 1000) - 80,
),
),
isScrollControlled: true,
context: context,
builder: (context) => UserBottomSheet(
content,
member.stateKey!,
roomId: roomId,
),
);
}
}, },
), ),
], ],

View file

@ -25,7 +25,6 @@ class MembershipRenderer extends StatelessWidget {
content, content,
event.stateKey!, event.stateKey!,
roomId: event.roomId, roomId: event.roomId,
globalPosition: details.globalPosition,
), ),
child: Text( child: Text(
overflow: .ellipsis, overflow: .ellipsis,

View file

@ -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"),
),
),
],
),
],
],
),
);
}
}

View file

@ -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,
),
),
),
],
),
],
);
}
}