diff --git a/lib/helpers/extensions/build_event_options.dart b/lib/helpers/extensions/build_event_options.dart index 69d024f..5a84d57 100644 --- a/lib/helpers/extensions/build_event_options.dart +++ b/lib/helpers/extensions/build_event_options.dart @@ -11,6 +11,8 @@ 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"; @@ -27,6 +29,7 @@ extension BuildEventOptions on Event { }) { final theme = Theme.of(context); final danger = theme.colorScheme.error; + final isDesktop = context.isDesktop; final notifier = RoomChatController.provider(( roomId: roomId, @@ -96,7 +99,7 @@ extension BuildEventOptions on Event { child: IconTheme( data: theme.iconTheme, child: Row( - mainAxisAlignment: .center, + mainAxisAlignment: isDesktop ? .center : .spaceEvenly, children: [ ...{ ...ref @@ -106,11 +109,15 @@ extension BuildEventOptions on Event { "🤣", "😭", "🤔", + "❤️", } - .take(3) + .take(isDesktop ? 3 : 5) .map( (emoji) => IconButton( - icon: Text(emoji), + icon: Text( + emoji, + style: .new(fontSize: isDesktop ? null : 18), + ), onPressed: () async { Navigator.of(context).pop(); await ref @@ -130,10 +137,8 @@ extension BuildEventOptions on Event { onPressed: () { Navigator.of(context).pop(); - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => EmojiPicker( + context.showAdaptiveSheet( + (context) => EmojiPicker( allowFreeText: true, onSelection: (emoji) { Navigator.of(context).pop(); diff --git a/lib/helpers/extensions/is_desktop.dart b/lib/helpers/extensions/is_desktop.dart new file mode 100644 index 0000000..d4161dd --- /dev/null +++ b/lib/helpers/extensions/is_desktop.dart @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..fcfb107 --- /dev/null +++ b/lib/helpers/extensions/show_adaptive_menu.dart @@ -0,0 +1,55 @@ +import "package:material_ui/material_ui.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 => switch (findRenderObject()) { + final RenderBox box => + box.localToGlobal(.zero, ancestor: overlay) & box.size, + _ => Rect.zero, + }, + }; + + await showMenu( + context: this, + constraints: .loose(.infinite), + position: .fromRect(anchor, Offset.zero & overlay.size), + items: items, + ); + } +} diff --git a/lib/helpers/extensions/show_adaptive_sheet.dart b/lib/helpers/extensions/show_adaptive_sheet.dart new file mode 100644 index 0000000..475289e --- /dev/null +++ b/lib/helpers/extensions/show_adaptive_sheet.dart @@ -0,0 +1,24 @@ +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 deleted file mode 100644 index ccef3b5..0000000 --- a/lib/helpers/extensions/show_context_menu.dart +++ /dev/null @@ -1,22 +0,0 @@ -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 f42cd57..3e336f3 100644 --- a/lib/helpers/extensions/show_user_popover.dart +++ b/lib/helpers/extensions/show_user_popover.dart @@ -1,4 +1,5 @@ import "package:material_ui/material_ui.dart"; +import "package:nexus/helpers/extensions/show_adaptive_sheet.dart"; import "package:nexus/models/content/membership.dart"; import "package:nexus/widgets/user_bottom_sheet.dart"; @@ -7,12 +8,7 @@ extension ShowUserPopover on BuildContext { MembershipContent member, String userId, { String? roomId, - }) => showModalBottomSheet( - constraints: BoxConstraints.loose( - Size(500, View.of(this).physicalSize.height - 80), - ), - isScrollControlled: true, - context: this, - builder: (context) => UserBottomSheet(member, userId, roomId: roomId), + }) => showAdaptiveSheet( + (context) => UserBottomSheet(member, userId, roomId: roomId), ); } diff --git a/lib/widgets/adaptive_menu_button.dart b/lib/widgets/adaptive_menu_button.dart new file mode 100644 index 0000000..1928358 --- /dev/null +++ b/lib/widgets/adaptive_menu_button.dart @@ -0,0 +1,21 @@ +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 7bf5d0e..31583be 100644 --- a/lib/widgets/composer/composer.dart +++ b/lib/widgets/composer/composer.dart @@ -4,6 +4,8 @@ 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"; @@ -165,10 +167,8 @@ class const Composer( ) ? [ IconButton( - onPressed: () => showModalBottomSheet( - isScrollControlled: true, - context: context, - builder: (context) => EmojiPicker( + onPressed: () => context.showAdaptiveSheet( + (context) => EmojiPicker( onSelection: (value) { Navigator.of(context).pop(); controller.text += value; @@ -178,7 +178,7 @@ class const Composer( ), icon: Icon(Icons.emoji_emotions), ), - PopupMenuButton( + AdaptiveMenuButton( tooltip: "Add media", enabled: attachment == null, itemBuilder: (context) => [ diff --git a/lib/widgets/pages/chat.dart b/lib/widgets/pages/chat.dart index 5b39636..8502d02 100644 --- a/lib/widgets/pages/chat.dart +++ b/lib/widgets/pages/chat.dart @@ -4,6 +4,7 @@ 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"; @@ -34,7 +35,7 @@ class const ChatPage({super.key}) extends HookConsumerWidget { return LayoutBuilder( builder: (context, constraints) { - final isDesktop = constraints.maxWidth > 650; + final isDesktop = context.isDesktop; final showMembersByDefault = constraints.maxWidth > 1000; final roomId = ref .watch(KeyController.provider(KeyController.roomKey)) diff --git a/lib/widgets/renderers/event.dart b/lib/widgets/renderers/event.dart index bd0f5f3..14e5c94 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_context_menu.dart"; +import "package:nexus/helpers/extensions/show_adaptive_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.showContextMenu( + : (PositionedGestureDetails details) => context.showAdaptiveMenu( + getEventOptions!(event).toList(), globalPosition: details.globalPosition, - children: getEventOptions!(event).toList(), ); return Column( @@ -222,9 +222,9 @@ class const EventRenderer( final topLeft = renderBox.localToGlobal( Offset.zero, ); - context.showContextMenu( + context.showAdaptiveMenu( + getEventOptions!(event).toList(), globalPosition: topLeft, - children: getEventOptions!(event).toList(), ); return null; }, diff --git a/lib/widgets/room_list.dart b/lib/widgets/room_list.dart index 05c1219..150db04 100644 --- a/lib/widgets/room_list.dart +++ b/lib/widgets/room_list.dart @@ -5,6 +5,7 @@ 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"; @@ -149,7 +150,8 @@ class const SpacesRail({super.key}) extends ConsumerWidget { padding: .only(top: 8), child: Column( children: [ - PopupMenuButton( + AdaptiveMenuButton( + tooltip: "Add a room", itemBuilder: (_) => [ PopupMenuItem( onTap: () => showDialog( diff --git a/lib/widgets/room_menu_button.dart b/lib/widgets/room_menu_button.dart index 35a2f2e..2c5b347 100644 --- a/lib/widgets/room_menu_button.dart +++ b/lib/widgets/room_menu_button.dart @@ -3,6 +3,7 @@ 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"; @@ -18,7 +19,9 @@ final class const RoomMenuButton( final danger = Theme.of(context).colorScheme.error; final client = ref.read(ClientController.provider.notifier); - return PopupMenuButton( + return AdaptiveMenuButton( + icon: const Icon(Icons.more_vert), + tooltip: "Room options", itemBuilder: (_) => [ PopupMenuItem( onTap: () async {