show bottom sheets / dialogs / menus based on screen size

Fixes #76
This commit is contained in:
Henry Hiles 2026-10-15 10:20:27 -04:00
commit 76d78596cd
Signed by: Henry-Hiles
SSH key fingerprint: SHA256:VKQUdS31Q90KvX7EkKMHMBpUspcmItAh86a+v7PGiIs
12 changed files with 141 additions and 49 deletions

View file

@ -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();

View file

@ -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;
}

View file

@ -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<void> showAdaptiveMenu(
List<PopupMenuEntry> 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,
);
}
}

View file

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

View file

@ -1,22 +0,0 @@
import "package:material_ui/material_ui.dart";
extension ShowContextMenu on BuildContext {
void showContextMenu({
required Offset globalPosition,
required List<PopupMenuEntry> 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,
);
}
}

View file

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