diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index 5a84d57..69d024f 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -11,8 +11,6 @@ import "package:nexus/controllers/rooms.dart"; import "package:nexus/controllers/room_chat.dart"; import "package:nexus/controllers/via.dart"; import "package:nexus/helpers/extensions/event_id_is_fake.dart"; -import "package:nexus/helpers/extensions/is_desktop.dart"; -import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/models/content/message.dart"; import "package:nexus/models/event.dart"; import "package:nexus/models/relation_type.dart"; @@ -29,7 +27,6 @@ extension BuildEventOptions on Event { }) { final theme = Theme.of(context); final danger = theme.colorScheme.error; - final isDesktop = context.isDesktop; final notifier = RoomChatController.provider(( roomId: roomId, @@ -99,7 +96,7 @@ extension BuildEventOptions on Event { child: IconTheme( data: theme.iconTheme, child: Row( - mainAxisAlignment: isDesktop ? .center : .spaceEvenly, + mainAxisAlignment: .center, children: [ ...{ ...ref @@ -109,15 +106,11 @@ extension BuildEventOptions on Event { "🤣", "😭", "🤔", - "❤️", } - .take(isDesktop ? 3 : 5) + .take(3) .map( (emoji) => IconButton( - icon: Text( - emoji, - style: .new(fontSize: isDesktop ? null : 18), - ), + icon: Text(emoji), onPressed: () async { Navigator.of(context).pop(); await ref @@ -137,8 +130,10 @@ extension BuildEventOptions on Event { onPressed: () { Navigator.of(context).pop(); - context.showAdaptiveSheet( - (context) => EmojiPicker( + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => EmojiPicker( allowFreeText: true, onSelection: (emoji) { Navigator.of(context).pop(); diff --git a/lib/helpers/extensions/global_rect.dart b/lib/helpers/extensions/global_rect.dart deleted file mode 100644 index 91c8305..0000000 --- a/lib/helpers/extensions/global_rect.dart +++ /dev/null @@ -1,15 +0,0 @@ -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/is_desktop.dart b/lib/helpers/extensions/is_desktop.dart deleted file mode 100644 index d4161dd..0000000 --- a/lib/helpers/extensions/is_desktop.dart +++ /dev/null @@ -1,7 +0,0 @@ -import "package:material_ui/material_ui.dart"; - -extension IsDesktop on BuildContext { - /// Whether the window is wide enough for the desktop layout, with the - /// sidebar always visible, menus instead of bottom sheets, etc. - bool get isDesktop => MediaQuery.sizeOf(this).width > 650; -} diff --git a/lib/helpers/extensions/show_adaptive_menu.dart b/lib/helpers/extensions/show_adaptive_menu.dart deleted file mode 100644 index 2fe21f5..0000000 --- a/lib/helpers/extensions/show_adaptive_menu.dart +++ /dev/null @@ -1,52 +0,0 @@ -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 { - /// Shows [items] in a popup menu on desktop, or a bottom sheet on mobile. - /// - /// On desktop, the menu opens at [globalPosition] if given (e.g. where the - /// user right clicked), or over the widget this context belongs to. - Future showAdaptiveMenu( - List items, { - Offset? globalPosition, - }) async { - if (!isDesktop) { - await showModalBottomSheet( - context: this, - isScrollControlled: true, - showDragHandle: true, - builder: (context) => SafeArea( - child: SingleChildScrollView( - padding: const .only(bottom: 8), - // PopupMenuItems must be inside something with the menu role - child: Semantics( - role: .menu, - explicitChildNodes: true, - child: ListTileTheme.merge( - contentPadding: const .symmetric(horizontal: 24), - minTileHeight: 48, - titleTextStyle: Theme.of(context).textTheme.titleMedium, - child: Column(mainAxisSize: .min, children: items), - ), - ), - ), - ), - ); - return; - } - - final overlay = Overlay.of(this).context.findRenderObject() as RenderBox; - final anchor = switch (globalPosition) { - final position? => position & Size.zero, - null => globalRect, - }; - - await showMenu( - context: this, - constraints: .loose(.infinite), - position: .fromRect(anchor, Offset.zero & overlay.size), - items: items, - ); - } -} diff --git a/lib/helpers/extensions/show_adaptive_popover.dart b/lib/helpers/extensions/show_adaptive_popover.dart deleted file mode 100644 index 8a100e8..0000000 --- a/lib/helpers/extensions/show_adaptive_popover.dart +++ /dev/null @@ -1,35 +0,0 @@ -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_adaptive_sheet.dart b/lib/helpers/extensions/show_adaptive_sheet.dart deleted file mode 100644 index 475289e..0000000 --- a/lib/helpers/extensions/show_adaptive_sheet.dart +++ /dev/null @@ -1,24 +0,0 @@ -import "package:material_ui/material_ui.dart"; -import "package:nexus/helpers/extensions/is_desktop.dart"; - -extension ShowAdaptiveSheet on BuildContext { - /// Shows the widget from [builder] in a dialog on desktop, or a bottom sheet - /// on mobile. Use the context passed to [builder] to close it. - Future showAdaptiveSheet(WidgetBuilder builder) => isDesktop - ? showDialog( - context: this, - builder: (context) => Dialog( - clipBehavior: .antiAlias, - child: ConstrainedBox( - constraints: const .new(maxWidth: 450, maxHeight: 600), - child: Builder(builder: builder), - ), - ), - ) - : showModalBottomSheet( - context: this, - isScrollControlled: true, - constraints: .new(maxHeight: MediaQuery.sizeOf(this).height - 80), - builder: builder, - ); -} diff --git a/lib/helpers/extensions/show_context_menu.dart b/lib/helpers/extensions/show_context_menu.dart new file mode 100644 index 0000000..ccef3b5 --- /dev/null +++ b/lib/helpers/extensions/show_context_menu.dart @@ -0,0 +1,22 @@ +import "package:material_ui/material_ui.dart"; + +extension ShowContextMenu on BuildContext { + void showContextMenu({ + required Offset globalPosition, + required List children, + }) { + final overlay = Overlay.of(this).context.findRenderObject() as RenderBox; + + showMenu( + context: this, + constraints: .loose(.infinite), + position: .fromLTRB( + globalPosition.dx, + globalPosition.dy, + overlay.size.width - globalPosition.dx, + overlay.size.height - globalPosition.dy, + ), + items: children, + ); + } +} diff --git a/lib/helpers/extensions/show_user_popover.dart b/lib/helpers/extensions/show_user_popover.dart index b1bfeff..f42cd57 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -1,5 +1,4 @@ import "package:material_ui/material_ui.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 +7,12 @@ extension ShowUserPopover on BuildContext { MembershipContent member, String userId, { String? roomId, - }) => showAdaptivePopover( - (context) => UserBottomSheet(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/adaptive_menu_button.dart b/lib/widgets/adaptive_menu_button.dart deleted file mode 100644 index 1928358..0000000 --- a/lib/widgets/adaptive_menu_button.dart +++ /dev/null @@ -1,21 +0,0 @@ -import "package:material_ui/material_ui.dart"; -import "package:nexus/helpers/extensions/show_adaptive_menu.dart"; - -/// Like [PopupMenuButton], but shows a bottom sheet on mobile. -class const AdaptiveMenuButton({ - required final Widget icon, - required final List Function(BuildContext context) - itemBuilder, - final String? tooltip, - final bool enabled = true, - super.key, -}) extends StatelessWidget { - @override - Widget build(BuildContext context) => IconButton( - icon: icon, - tooltip: tooltip, - onPressed: enabled - ? () => context.showAdaptiveMenu(itemBuilder(context)) - : null, - ); -} diff --git a/lib/widgets/composer/composer.dart b/lib/widgets/composer/composer.dart index 31583be..7bf5d0e 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -4,8 +4,6 @@ import "package:fast_immutable_collections/fast_immutable_collections.dart"; import "package:file_selector/file_selector.dart"; import "package:nexus/controllers/rooms.dart"; import "package:nexus/widgets/emoji_picker.dart"; -import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; -import "package:nexus/widgets/adaptive_menu_button.dart"; import "package:material_ui/material_ui.dart"; import "package:flutter/services.dart"; import "package:flutter_hooks/flutter_hooks.dart"; @@ -167,8 +165,10 @@ class const Composer( ) ? [ IconButton( - onPressed: () => context.showAdaptiveSheet( - (context) => EmojiPicker( + onPressed: () => showModalBottomSheet( + isScrollControlled: true, + context: context, + builder: (context) => EmojiPicker( onSelection: (value) { Navigator.of(context).pop(); controller.text += value; @@ -178,7 +178,7 @@ class const Composer( ), icon: Icon(Icons.emoji_emotions), ), - AdaptiveMenuButton( + PopupMenuButton( tooltip: "Add media", enabled: attachment == null, itemBuilder: (context) => [ diff --git a/lib/widgets/member_list.dart b/lib/widgets/member_list.dart index 85e9fcc..3b62ab6 100644 --- a/lib/widgets/member_list.dart +++ b/lib/widgets/member_list.dart @@ -104,45 +104,36 @@ class const MemberList(final String roomId, {super.key}) itemBuilder: (context, index) => switch (members[index].content) { MembershipContent( - :final avatarUrl, - :final displayName, - ) && - final content => - Builder( - builder: (context) => ListTile( - onTap: () => - context.showUserPopover( - content, - members[index].stateKey!, - roomId: roomId, - ), - title: Text( + :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, 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, - ), ), ), ), @@ -150,6 +141,17 @@ 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/pages/chat.dart b/lib/widgets/pages/chat.dart index 8502d02..5b39636 100644 --- a/lib/widgets/pages/chat.dart +++ b/lib/widgets/pages/chat.dart @@ -4,7 +4,6 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/init_complete.dart"; import "package:nexus/controllers/key.dart"; import "package:nexus/controllers/notification_request.dart"; -import "package:nexus/helpers/extensions/is_desktop.dart"; import "package:nexus/widgets/appbar.dart"; import "package:nexus/widgets/sidebar.dart"; import "package:nexus/widgets/sync_status_banner.dart"; @@ -35,7 +34,7 @@ class const ChatPage({super.key}) extends HookConsumerWidget { return LayoutBuilder( builder: (context, constraints) { - final isDesktop = context.isDesktop; + final isDesktop = constraints.maxWidth > 650; final showMembersByDefault = constraints.maxWidth > 1000; final roomId = ref .watch(KeyController.provider(KeyController.roomKey)) diff --git a/lib/widgets/popover_layout.dart b/lib/widgets/popover_layout.dart deleted file mode 100644 index 0ef8ebe..0000000 --- a/lib/widgets/popover_layout.dart +++ /dev/null @@ -1,33 +0,0 @@ -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; -} diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index 14e5c94..bd0f5f3 100644 --- a/lib/widgets/renderers/event.dart +++ b/lib/widgets/renderers/event.dart @@ -6,7 +6,7 @@ import "package:hooks_riverpod/hooks_riverpod.dart"; import "package:nexus/controllers/event.dart"; import "package:nexus/controllers/room_chat.dart"; import "package:nexus/controllers/rooms.dart"; -import "package:nexus/helpers/extensions/show_adaptive_menu.dart"; +import "package:nexus/helpers/extensions/show_context_menu.dart"; import "package:nexus/helpers/hooks/chat_scroll.dart"; import "package:nexus/models/content/avatar.dart"; import "package:nexus/models/content/canonical_alias.dart"; @@ -197,9 +197,9 @@ class const EventRenderer( final contextMenuCallback = getEventOptions == null ? null - : (PositionedGestureDetails details) => context.showAdaptiveMenu( - getEventOptions!(event).toList(), + : (PositionedGestureDetails details) => context.showContextMenu( globalPosition: details.globalPosition, + children: getEventOptions!(event).toList(), ); return Column( @@ -222,9 +222,9 @@ class const EventRenderer( final topLeft = renderBox.localToGlobal( Offset.zero, ); - context.showAdaptiveMenu( - getEventOptions!(event).toList(), + context.showContextMenu( globalPosition: topLeft, + children: getEventOptions!(event).toList(), ); return null; }, diff --git a/lib/widgets/room_list.dart b/lib/widgets/room_list.dart index 150db04..05c1219 100644 --- a/lib/widgets/room_list.dart +++ b/lib/widgets/room_list.dart @@ -5,7 +5,6 @@ import "package:material_ui/material_ui.dart"; import "package:navigation_rail_m3e/navigation_rail_m3e.dart"; import "package:nexus/helpers/extensions/get_all_child_rooms.dart"; import "package:nexus/helpers/extensions/room_destinations.dart"; -import "package:nexus/widgets/adaptive_menu_button.dart"; import "package:nexus/models/nav_page.dart"; import "package:nexus/widgets/avatar_or_hash.dart"; import "package:nexus/widgets/divider_widget.dart"; @@ -150,8 +149,7 @@ class const SpacesRail({super.key}) extends ConsumerWidget { padding: .only(top: 8), child: Column( children: [ - AdaptiveMenuButton( - tooltip: "Add a room", + PopupMenuButton( itemBuilder: (_) => [ PopupMenuItem( onTap: () => showDialog( diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/room_menu_button.dart index 2c5b347..35a2f2e 100644 --- a/lib/widgets/room_menu_button.dart +++ b/lib/widgets/room_menu_button.dart @@ -3,7 +3,6 @@ import "package:material_ui/material_ui.dart"; import "package:flutter/services.dart"; import "package:flutter_riverpod/flutter_riverpod.dart"; import "package:nexus/controllers/client.dart"; -import "package:nexus/widgets/adaptive_menu_button.dart"; import "package:nexus/controllers/via.dart"; import "package:nexus/models/room.dart"; import "package:flutter_hooks/flutter_hooks.dart"; @@ -19,9 +18,7 @@ final class const RoomMenuButton( final danger = Theme.of(context).colorScheme.error; final client = ref.read(ClientController.provider.notifier); - return AdaptiveMenuButton( - icon: const Icon(Icons.more_vert), - tooltip: "Room options", + return PopupMenuButton( itemBuilder: (_) => [ PopupMenuItem( onTap: () async {