flutter_preview_context_menu 0.3.0
flutter_preview_context_menu: ^0.3.0 copied to clipboard
A flexible WhatsApp-style context menu for Flutter with custom previews, top content, actions, animation, and viewport-aware overflow handling.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_preview_context_menu/flutter_preview_context_menu.dart';
void main() => runApp(const _ExampleApp());
class _ExampleApp extends StatelessWidget {
const _ExampleApp();
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF087E8B),
brightness: Brightness.light,
),
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF40C9A2),
brightness: Brightness.dark,
),
),
home: const _ConversationExamplePage(),
);
}
}
class _ConversationExamplePage extends StatelessWidget {
const _ConversationExamplePage();
static const _messages = [
_Message(
text: 'The context menu now works with touch, mouse, and trackpad.',
time: '10:24',
isMine: false,
),
_Message(
text: 'The preview can stay aligned with bubbles of different widths.',
time: '10:25',
isMine: true,
),
_Message(
text: 'Actions and top content remain fully customizable.',
time: '10:26',
isMine: false,
),
];
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Preview context menu')),
body: ListView.separated(
padding: EdgeInsets.fromLTRB(
16,
24,
16,
32 + MediaQuery.paddingOf(context).bottom,
),
itemCount: _messages.length,
separatorBuilder: (_, _) => const SizedBox(height: 14),
itemBuilder: (context, index) {
final message = _messages[index];
return Align(
alignment: message.isMine
? Alignment.centerRight
: Alignment.centerLeft,
child: ContextMenuRegion(
topContent: const _ReactionBar(),
preview: _MessageBubble(message: message, isPreview: true),
actions: [
ContextMenuActionData(
label: 'Reply',
icon: const Icon(Icons.reply_rounded),
onPressed: () => _showResult(context, 'Reply'),
),
ContextMenuActionData(
label: 'Copy',
icon: const Icon(Icons.content_copy_rounded),
onPressed: () async {
await Clipboard.setData(ClipboardData(text: message.text));
if (context.mounted) _showResult(context, 'Copied');
},
),
ContextMenuActionData(
label: 'Forward',
description: 'Choose another conversation',
icon: const Icon(Icons.forward_rounded),
onPressed: () => _showResult(context, 'Forward'),
),
if (message.isMine)
ContextMenuActionData(
label: 'Delete',
icon: const Icon(Icons.delete_outline_rounded),
destructive: true,
separatedBefore: true,
onPressed: () => _showResult(context, 'Delete'),
),
],
child: _MessageBubble(message: message),
),
);
},
),
);
}
void _showResult(BuildContext context, String action) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(action)));
}
}
class _MessageBubble extends StatelessWidget {
const _MessageBubble({required this.message, this.isPreview = false});
final _Message message;
final bool isPreview;
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final background = message.isMine
? colors.primaryContainer
: colors.surfaceContainerHigh;
final foreground = message.isMine
? colors.onPrimaryContainer
: colors.onSurface;
return ConstrainedBox(
constraints: BoxConstraints(maxWidth: isPreview ? 300 : 340),
child: DecoratedBox(
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(18),
border: isPreview ? Border.all(color: colors.outlineVariant) : null,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 12, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisSize: MainAxisSize.min,
children: [
Text(message.text, style: TextStyle(color: foreground)),
const SizedBox(height: 4),
Text(
message.time,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: foreground.withValues(alpha: 0.68),
),
),
],
),
),
),
);
}
}
class _ReactionBar extends StatelessWidget {
const _ReactionBar();
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Material(
color: colors.surfaceContainerHighest,
shape: const StadiumBorder(),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 14, vertical: 9),
child: Text('👍 ❤️ 😂 😮 😢'),
),
);
}
}
class _Message {
const _Message({
required this.text,
required this.time,
required this.isMine,
});
final String text;
final String time;
final bool isMine;
}