Skip to content

Commit 0033f75

Browse files
authored
feat(ui): add custom context menu implementation (#2335)
1 parent 5d58914 commit 0033f75

File tree

7 files changed

+273
-22
lines changed

7 files changed

+273
-22
lines changed

melos.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ command:
2929
chewie: ^1.8.1
3030
collection: ^1.17.2
3131
connectivity_plus: ^6.0.3
32-
contextmenu: ^3.0.0
3332
cupertino_icons: ^1.0.3
3433
desktop_drop: '>=0.5.0 <0.7.0'
3534
device_info_plus: '>=10.1.2 <12.0.0'

packages/stream_chat_flutter/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
## Upcoming
22

3+
🐞 Fixed
4+
5+
- Fixed context menu being truncated and scrollable on web when there was enough space to display it
6+
fully. [[#2317]](https://github.com/GetStream/stream-chat-flutter/issues/2317)
7+
38
✅ Added
49

510
- Added `padding` and `textInputMargin` to `StreamMessageInput` to allow fine-tuning the layout.
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import 'package:flutter/material.dart';
2+
3+
const double _kContextMenuScreenPadding = 8;
4+
const double _kContextMenuWidth = 222;
5+
6+
/// Signature for a builder function that wraps the context menu widget.
7+
///
8+
/// This builder can be used to customize the appearance of the menu
9+
/// container by wrapping [child] in additional UI elements.
10+
typedef ContextMenuBuilder = Widget Function(
11+
BuildContext context,
12+
Widget child,
13+
);
14+
15+
/// A widget that displays a context menu anchored to a specific [Offset].
16+
///
17+
/// The menu will try to position itself as close to the [anchor] as possible,
18+
/// while respecting screen padding and safe areas.
19+
class ContextMenu extends StatelessWidget {
20+
/// Creates a [ContextMenu].
21+
///
22+
/// The [anchor] defines where the menu is shown, and [menuItems] are the
23+
/// widgets displayed within the menu. An optional [menuBuilder] can be used
24+
/// to customize the menu's container.
25+
///
26+
/// The [menuItems] list must not be empty.
27+
const ContextMenu({
28+
super.key,
29+
required this.anchor,
30+
required this.menuItems,
31+
this.menuBuilder = _defaultMenuBuilder,
32+
}) : assert(menuItems.length > 0, 'Context menu must have at least one item');
33+
34+
/// The target position on screen where the context menu should appear.
35+
final Offset anchor;
36+
37+
/// The list of menu items to display inside the menu.
38+
///
39+
/// These can be [ListTile] widgets, buttons, or any other widget.
40+
final List<Widget> menuItems;
41+
42+
/// Builds the outer container for the menu.
43+
///
44+
/// The [menuBuilder] receives the current context and a [child] widget
45+
/// containing all the [menuItems].
46+
///
47+
/// Defaults to a card-style scrollable container with fixed width.
48+
final ContextMenuBuilder menuBuilder;
49+
50+
/// Default menu container with standard styling.
51+
///
52+
/// Wraps the menu content in a card-like [Material] with scroll support,
53+
/// applying max width and height constraints.
54+
static Widget _defaultMenuBuilder(BuildContext context, Widget child) {
55+
final availableHeight = MediaQuery.of(context).size.height;
56+
final maxHeight = availableHeight - _kContextMenuScreenPadding * 2;
57+
58+
return ConstrainedBox(
59+
constraints: BoxConstraints(
60+
minWidth: _kContextMenuWidth,
61+
maxWidth: _kContextMenuWidth,
62+
maxHeight: maxHeight,
63+
),
64+
child: Material(
65+
elevation: 1,
66+
type: MaterialType.card,
67+
clipBehavior: Clip.antiAlias,
68+
borderRadius: const BorderRadius.all(Radius.circular(7)),
69+
child: SingleChildScrollView(child: child),
70+
),
71+
);
72+
}
73+
74+
@override
75+
Widget build(BuildContext context) {
76+
assert(debugCheckHasMediaQuery(context), '');
77+
78+
final safePadding = MediaQuery.paddingOf(context);
79+
final paddingAbove = safePadding.top + _kContextMenuScreenPadding;
80+
final localAdjustment = Offset(_kContextMenuScreenPadding, paddingAbove);
81+
82+
return Padding(
83+
padding: EdgeInsets.fromLTRB(
84+
_kContextMenuScreenPadding,
85+
paddingAbove,
86+
_kContextMenuScreenPadding,
87+
_kContextMenuScreenPadding,
88+
),
89+
child: CustomSingleChildLayout(
90+
delegate: DesktopTextSelectionToolbarLayoutDelegate(
91+
anchor: anchor - localAdjustment,
92+
),
93+
child: menuBuilder.call(
94+
context,
95+
Column(
96+
mainAxisSize: MainAxisSize.min,
97+
crossAxisAlignment: CrossAxisAlignment.stretch,
98+
children: menuItems,
99+
),
100+
),
101+
),
102+
);
103+
}
104+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter/services.dart';
3+
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
4+
5+
/// Signature for a function that builds a context menu widget.
6+
///
7+
/// The function receives the [BuildContext] and the [Offset] where
8+
/// the menu should appear.
9+
typedef ContextMenuBuilder = Widget Function(
10+
BuildContext context,
11+
Offset offset,
12+
);
13+
14+
/// Displays a custom context menu as a general dialog.
15+
///
16+
/// The [contextMenuBuilder] is used to construct the contents of the
17+
/// context menu, typically positioned based on the triggering gesture.
18+
///
19+
/// The dialog can be customized using parameters such as [barrierColor],
20+
/// [barrierLabel], [transitionDuration], [transitionBuilder], etc.
21+
///
22+
/// Returns a [Future] that resolves when the menu is dismissed.
23+
Future<T?> showContextMenu<T>({
24+
required BuildContext context,
25+
required WidgetBuilder contextMenuBuilder,
26+
String? barrierLabel,
27+
Color? barrierColor,
28+
Duration transitionDuration = const Duration(milliseconds: 150),
29+
RouteTransitionsBuilder? transitionBuilder,
30+
bool useRootNavigator = true,
31+
RouteSettings? routeSettings,
32+
}) async {
33+
assert(debugCheckHasMaterialLocalizations(context), '');
34+
final localizations = MaterialLocalizations.of(context);
35+
36+
final capturedThemes = InheritedTheme.capture(
37+
from: context,
38+
to: Navigator.of(context, rootNavigator: useRootNavigator).context,
39+
);
40+
41+
return showGeneralDialog(
42+
context: context,
43+
barrierDismissible: true,
44+
useRootNavigator: useRootNavigator,
45+
routeSettings: routeSettings,
46+
transitionDuration: transitionDuration,
47+
barrierColor: barrierColor ?? Colors.transparent,
48+
barrierLabel: barrierLabel ?? localizations.modalBarrierDismissLabel,
49+
transitionBuilder: (context, animation, secondaryAnimation, child) {
50+
if (transitionBuilder case final builder?) {
51+
return builder(context, animation, secondaryAnimation, child);
52+
}
53+
54+
final fadeAnimation = CurveTween(curve: const Interval(0, 0.3));
55+
56+
return FadeTransition(
57+
opacity: fadeAnimation.animate(animation),
58+
child: child,
59+
);
60+
},
61+
pageBuilder: (context, animation, secondaryAnimation) {
62+
final pageChild = Builder(builder: contextMenuBuilder);
63+
return capturedThemes.wrap(pageChild);
64+
},
65+
);
66+
}
67+
68+
/// A widget that provides a region for showing a context menu.
69+
///
70+
/// When the user performs a long-press (on touch devices) or right-click
71+
/// (on desktop/web), a custom context menu is displayed at the gesture location.
72+
class ContextMenuRegion extends StatefulWidget {
73+
/// Creates a [ContextMenuRegion].
74+
///
75+
/// The [child] is the widget wrapped by this region. When a gesture is
76+
/// detected on it, the [contextMenuBuilder] is used to construct the menu.
77+
const ContextMenuRegion({
78+
super.key,
79+
required this.child,
80+
required this.contextMenuBuilder,
81+
});
82+
83+
/// The widget below this widget in the tree.
84+
final Widget child;
85+
86+
/// Called to build the context menu when the gesture is triggered.
87+
///
88+
/// The builder is given the [BuildContext] and the [Offset] of the gesture.
89+
final ContextMenuBuilder contextMenuBuilder;
90+
91+
@override
92+
State<ContextMenuRegion> createState() => _ContextMenuRegionState();
93+
}
94+
95+
class _ContextMenuRegionState extends State<ContextMenuRegion> {
96+
@override
97+
void initState() {
98+
super.initState();
99+
// Prevent the browser's native context menu on web.
100+
if (CurrentPlatform.isWeb) BrowserContextMenu.disableContextMenu();
101+
}
102+
103+
@override
104+
void dispose() {
105+
// Restore browser context menu behavior.
106+
if (CurrentPlatform.isWeb) BrowserContextMenu.enableContextMenu();
107+
super.dispose();
108+
}
109+
110+
Future<void> _showContextMenu(BuildContext context, Offset position) async {
111+
print('ContextMenuRegion: Showing context menu at $position');
112+
await showContextMenu(
113+
context: context,
114+
contextMenuBuilder: (context) {
115+
return widget.contextMenuBuilder(context, position);
116+
},
117+
);
118+
}
119+
120+
@override
121+
Widget build(BuildContext context) {
122+
return GestureDetector(
123+
behavior: HitTestBehavior.opaque,
124+
onLongPressStart: (it) => _showContextMenu(context, it.globalPosition),
125+
onSecondaryTapUp: (it) => _showContextMenu(context, it.globalPosition),
126+
child: widget.child,
127+
);
128+
}
129+
}

packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import 'package:contextmenu/contextmenu.dart';
21
import 'package:flutter/material.dart';
32
import 'package:media_kit/media_kit.dart';
43
import 'package:media_kit_video/media_kit_video.dart';
54
import 'package:photo_view/photo_view.dart';
5+
import 'package:stream_chat_flutter/src/context_menu/context_menu.dart';
6+
import 'package:stream_chat_flutter/src/context_menu/context_menu_region.dart';
67
import 'package:stream_chat_flutter/src/context_menu_items/download_menu_item.dart';
78
import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart';
89
import 'package:stream_chat_flutter/src/fullscreen_media/gallery_navigation_item.dart';
@@ -121,14 +122,18 @@ class _FullScreenMediaDesktopState extends State<FullScreenMediaDesktop> {
121122
Widget _buildVideoPageView() {
122123
return Stack(
123124
children: [
124-
ContextMenuArea(
125-
verticalPadding: 0,
126-
builder: (_) => [
127-
DownloadMenuItem(
128-
attachment:
129-
widget.mediaAttachmentPackages[_currentPage.value].attachment,
130-
),
131-
],
125+
ContextMenuRegion(
126+
contextMenuBuilder: (_, anchor) {
127+
return ContextMenu(
128+
anchor: anchor,
129+
menuItems: [
130+
DownloadMenuItem(
131+
attachment: widget
132+
.mediaAttachmentPackages[_currentPage.value].attachment,
133+
),
134+
],
135+
);
136+
},
132137
child: _PlaylistPlayer(
133138
packages: videoPackages.values.toList(),
134139
autoStart: widget.autoplayVideos,
@@ -356,13 +361,17 @@ class _FullScreenMediaDesktopState extends State<FullScreenMediaDesktop> {
356361
? kToolbarHeight + bottomPadding
357362
: 0,
358363
),
359-
child: ContextMenuArea(
360-
verticalPadding: 0,
361-
builder: (_) => [
362-
DownloadMenuItem(
363-
attachment: attachment,
364-
),
365-
],
364+
child: ContextMenuRegion(
365+
contextMenuBuilder: (_, anchor) {
366+
return ContextMenu(
367+
anchor: anchor,
368+
menuItems: [
369+
DownloadMenuItem(
370+
attachment: attachment,
371+
),
372+
],
373+
);
374+
},
366375
child: Video(
367376
controller: package.controller,
368377
),

packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import 'package:contextmenu/contextmenu.dart';
21
import 'package:flutter/material.dart' hide ButtonStyle;
32
import 'package:flutter/services.dart';
43
import 'package:flutter_portal/flutter_portal.dart';
54
import 'package:stream_chat_flutter/conditional_parent_builder/conditional_parent_builder.dart';
65
import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart';
6+
import 'package:stream_chat_flutter/src/context_menu/context_menu.dart';
7+
import 'package:stream_chat_flutter/src/context_menu/context_menu_region.dart';
78
import 'package:stream_chat_flutter/src/context_menu_items/context_menu_reaction_picker.dart';
89
import 'package:stream_chat_flutter/src/context_menu_items/stream_chat_context_menu_item.dart';
910
import 'package:stream_chat_flutter/src/dialogs/dialogs.dart';
@@ -696,9 +697,14 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
696697
// context menu actions.
697698
if (message.state.isDeleted || message.state.isOutgoing) return child;
698699

699-
return ContextMenuArea(
700-
verticalPadding: 0,
701-
builder: (_) => _buildDesktopOrWebActions(context, message),
700+
final menuItems = _buildDesktopOrWebActions(context, message);
701+
if (menuItems.isEmpty) return child;
702+
703+
return ContextMenuRegion(
704+
contextMenuBuilder: (_, anchor) => ContextMenu(
705+
anchor: anchor,
706+
menuItems: menuItems,
707+
),
702708
child: child,
703709
);
704710
},

packages/stream_chat_flutter/pubspec.yaml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ dependencies:
2525
cached_network_image: ^3.3.1
2626
chewie: ^1.8.1
2727
collection: ^1.17.2
28-
contextmenu: ^3.0.0
2928
desktop_drop: ">=0.5.0 <0.7.0"
3029
diacritic: ^0.1.5
3130
dio: ^5.4.3+1

0 commit comments

Comments
 (0)