From ce5c7b972b40befd55a0a00f94940c0ffcda1cf1 Mon Sep 17 00:00:00 2001 From: Henry-Hiles Date: Thu, 15 Oct 2026 10:25:20 -0400 Subject: [PATCH] make user sheets popovers on desktop --- lib/helpers/extensions/global_rect.dart | 15 ++++ .../extensions/show_adaptive_menu.dart | 7 +- .../extensions/show_adaptive_popover.dart | 35 +++++++++ lib/helpers/extensions/show_user_popover.dart | 4 +- lib/widgets/member_list.dart | 72 +++++++++---------- lib/widgets/popover_layout.dart | 33 +++++++++ 6 files changed, 122 insertions(+), 44 deletions(-) create mode 100644 lib/helpers/extensions/global_rect.dart create mode 100644 lib/helpers/extensions/show_adaptive_popover.dart create mode 100644 lib/widgets/popover_layout.dart diff --git a/lib/helpers/extensions/global_rect.dart b/lib/helpers/extensions/global_rect.dart new file mode 100644 index 0000000..91c8305 --- /dev/null +++ b/lib/helpers/extensions/global_rect.dart @@ -0,0 +1,15 @@ +import "package:material_ui/material_ui.dart"; + +extension GlobalRect on BuildContext { + /// Where the widget this context belongs to is on screen, e.g. to open a + /// menu or popover next to it. + Rect get globalRect { + final overlay = Overlay.of(this).context.findRenderObject() as RenderBox; + + return switch (findRenderObject()) { + final RenderBox box => + box.localToGlobal(.zero, ancestor: overlay) & box.size, + _ => Rect.zero, + }; + } +} diff --git a/lib/helpers/extensions/show_adaptive_menu.dart b/lib/helpers/extensions/show_adaptive_menu.dart index fcfb107..2fe21f5 100644 --- a/lib/helpers/extensions/show_adaptive_menu.dart +++ b/lib/helpers/extensions/show_adaptive_menu.dart @@ -1,4 +1,5 @@ import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/global_rect.dart"; import "package:nexus/helpers/extensions/is_desktop.dart"; extension ShowAdaptiveMenu on BuildContext { @@ -38,11 +39,7 @@ extension ShowAdaptiveMenu on BuildContext { final overlay = Overlay.of(this).context.findRenderObject() as RenderBox; final anchor = switch (globalPosition) { final position? => position & Size.zero, - null => switch (findRenderObject()) { - final RenderBox box => - box.localToGlobal(.zero, ancestor: overlay) & box.size, - _ => Rect.zero, - }, + null => globalRect, }; await showMenu( diff --git a/lib/helpers/extensions/show_adaptive_popover.dart b/lib/helpers/extensions/show_adaptive_popover.dart new file mode 100644 index 0000000..8a100e8 --- /dev/null +++ b/lib/helpers/extensions/show_adaptive_popover.dart @@ -0,0 +1,35 @@ +import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/global_rect.dart"; +import "package:nexus/helpers/extensions/is_desktop.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/widgets/popover_layout.dart"; + +extension ShowAdaptivePopover on BuildContext { + /// Shows the widget from [builder] in a popover next to the widget this + /// context belongs to on desktop, or a bottom sheet on mobile. Use the + /// context passed to [builder] to close it. + Future showAdaptivePopover(WidgetBuilder builder) { + if (!isDesktop) return showAdaptiveSheet(builder); + + final anchor = globalRect; + + return showDialog( + context: this, + barrierColor: Colors.transparent, + useSafeArea: false, + builder: (context) => CustomSingleChildLayout( + delegate: PopoverLayout(anchor), + child: ConstrainedBox( + constraints: const .new(maxWidth: 420, maxHeight: 600), + child: Material( + elevation: 8, + clipBehavior: .antiAlias, + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: .circular(16), + child: Builder(builder: builder), + ), + ), + ), + ); + } +} diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index 3e336f3..b1bfeff 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -1,5 +1,5 @@ import "package:material_ui/material_ui.dart"; -import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; +import "package:nexus/helpers/extensions/show_adaptive_popover.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/widgets/user_bottom_sheet.dart"; @@ -8,7 +8,7 @@ extension ShowUserPopover on BuildContext { MembershipContent member, String userId, { String? roomId, - }) => showAdaptiveSheet( + }) => showAdaptivePopover( (context) => UserBottomSheet(member, userId, roomId: roomId), ); } diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index 3b62ab6..85e9fcc 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -104,36 +104,45 @@ class const MemberList(final String roomId, {super.key}) itemBuilder: (context, index) => switch (members[index].content) { MembershipContent( - :final avatarUrl, - :final displayName, - ) => - ListTile( - title: Text( - displayName ?? - members[index] - .stateKey! - .localpart, - overflow: .ellipsis, - style: .new( - color: members[index] - .stateKey! - .colorHash, - fontWeight: .bold, - ), - ), - subtitle: Text( - members[index].stateKey!, - overflow: .ellipsis, - ), - leading: Transform.translate( - offset: .new(0, 2), - child: AvatarOrHash( - avatarUrl, - dimension: 40, + :final avatarUrl, + :final displayName, + ) && + final content => + Builder( + builder: (context) => ListTile( + onTap: () => + context.showUserPopover( + content, + members[index].stateKey!, + roomId: roomId, + ), + title: Text( displayName ?? members[index] .stateKey! .localpart, + overflow: .ellipsis, + style: .new( + color: members[index] + .stateKey! + .colorHash, + fontWeight: .bold, + ), + ), + subtitle: Text( + members[index].stateKey!, + overflow: .ellipsis, + ), + leading: Transform.translate( + offset: .new(0, 2), + child: AvatarOrHash( + avatarUrl, + dimension: 40, + displayName ?? + members[index] + .stateKey! + .localpart, + ), ), ), ), @@ -141,17 +150,6 @@ class const MemberList(final String roomId, {super.key}) "Member content was not MembershipContent", ), }, - onTap: (index) { - final member = members[index]; - if (member.content - case MembershipContent content) { - context.showUserPopover( - content, - member.stateKey!, - roomId: roomId, - ); - } - }, ), ]; }).toList(), diff --git a/lib/widgets/popover_layout.dart b/lib/widgets/popover_layout.dart new file mode 100644 index 0000000..0ef8ebe --- /dev/null +++ b/lib/widgets/popover_layout.dart @@ -0,0 +1,33 @@ +import "dart:math"; + +import "package:material_ui/material_ui.dart"; + +/// Positions a popover below [anchor], or above it if there isn't room below, +/// keeping it fully on screen. +class const PopoverLayout(final Rect anchor) extends SingleChildLayoutDelegate { + static const margin = 8.0; + + @override + BoxConstraints getConstraintsForChild(BoxConstraints constraints) => + constraints.loosen().deflate(const .all(margin)); + + @override + Offset getPositionForChild(Size size, Size childSize) { + final fitsBelow = anchor.bottom + childSize.height + margin <= size.height; + + return .new( + anchor.left.clamp( + margin, + max(margin, size.width - childSize.width - margin), + ), + (fitsBelow ? anchor.bottom : anchor.top - childSize.height).clamp( + margin, + max(margin, size.height - childSize.height - margin), + ), + ); + } + + @override + bool shouldRelayout(PopoverLayout oldDelegate) => + oldDelegate.anchor != anchor; +}