Implementation
Future<String?> showPopupMenu(BuildContext context, PopupType type,
{List<PopupMenuEntry<String>>? items,
bool? isPin,
bool? isMute,
bool? canRecall,
bool? canQuote,
bool? canCopy,
bool? canEdit,
String editLabel = '编辑',
bool? canSpeechToText,
bool? canCancelSpeechToText,
bool conversationIsSystem = false}) {
// 默认菜单项列表
final defaultItemsChat = [
if (((canSpeechToText ?? false) || (canCancelSpeechToText ?? false)) &&
!conversationIsSystem)
PopupMenuItem<String>(
value: 'speechToText',
height: _popupMenuItemHeight,
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageWidget(
RCKThemeProvider().themeIcon.speechToText ?? '',
width: kPopupIconSize,
height: kPopupIconSize,
color: RCKThemeProvider().themeColor.textPrimary),
Text(
(canCancelSpeechToText ?? false) ? '取消转文字' : '转文字',
style: TextStyle(
fontSize: convoPopupFontSize,
color: RCKThemeProvider().themeColor.textPrimary),
),
],
),
),
),
if (canCopy ?? true)
PopupMenuItem<String>(
value: 'copy',
height: _popupMenuItemHeight,
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageWidget(
RCKThemeProvider().themeIcon.copy ?? '',
width: kPopupIconSize,
height: kPopupIconSize),
Text(
'复制',
style: TextStyle(
fontSize: convoPopupFontSize,
color: RCKThemeProvider().themeColor.textPrimary),
),
],
)),
),
if ((canEdit ?? false) && !conversationIsSystem)
PopupMenuItem<String>(
value: 'edit',
height: _popupMenuItemHeight,
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageWidget(
RCKThemeProvider().themeIcon.edit ?? '',
width: kPopupIconSize,
height: kPopupIconSize,
color: RCKThemeProvider().themeColor.textPrimary,
),
Text(
editLabel,
style: TextStyle(
fontSize: convoPopupFontSize,
color: RCKThemeProvider().themeColor.textPrimary,
),
),
],
),
),
),
if (!conversationIsSystem)
PopupMenuItem<String>(
value: 'forward',
height: _popupMenuItemHeight,
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageWidget(
RCKThemeProvider().themeIcon.forwardSingle ?? '',
width: kPopupIconSize,
height: kPopupIconSize),
Text(
'转发',
style: TextStyle(
fontSize: convoPopupFontSize,
color: RCKThemeProvider().themeColor.textPrimary),
),
],
)),
),
PopupMenuItem<String>(
value: 'delete',
height: _popupMenuItemHeight,
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageWidget(RCKThemeProvider().themeIcon.delete ?? '',
width: kPopupIconSize, height: kPopupIconSize),
Text(
'删除',
style: TextStyle(
fontSize: convoPopupFontSize,
color: RCKThemeProvider().themeColor.textPrimary),
),
],
),
),
),
if (!conversationIsSystem)
PopupMenuItem<String>(
value: 'multi',
height: _popupMenuItemHeight,
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageWidget(
RCKThemeProvider().themeIcon.multiSelect ?? '',
width: kPopupIconSize,
height: kPopupIconSize,
color: RCKThemeProvider().themeColor.textPrimary),
Text(
'多选',
style: TextStyle(
fontSize: convoPopupFontSize,
color: RCKThemeProvider().themeColor.textPrimary),
),
],
)),
),
if ((canQuote ?? true) && !conversationIsSystem)
PopupMenuItem<String>(
value: 'quote',
height: _popupMenuItemHeight,
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageWidget(RCKThemeProvider().themeIcon.reply ?? '',
width: kPopupIconSize,
height: kPopupIconSize,
color: RCKThemeProvider().themeColor.textPrimary),
Text(
'引用',
style: TextStyle(
fontSize: convoPopupFontSize,
color: RCKThemeProvider().themeColor.textPrimary),
),
],
),
),
),
if ((canRecall ?? false) && !conversationIsSystem)
PopupMenuItem<String>(
value: 'recall',
height: _popupMenuItemHeight,
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageWidget(
RCKThemeProvider().themeIcon.recall ?? '',
width: kPopupIconSize,
height: kPopupIconSize,
color: RCKThemeProvider().themeColor.textPrimary),
Text(
'撤回',
style: TextStyle(
fontSize: convoPopupFontSize,
color: RCKThemeProvider().themeColor.textPrimary),
),
],
),
),
),
];
const chatMenuOrder = <String, int>{
'speechToText': 0,
'copy': 1,
'delete': 2,
'recall': 3,
'quote': 4,
'edit': 5,
'forward': 6,
'multi': 7,
};
defaultItemsChat.sort((left, right) {
final leftOrder = chatMenuOrder[left.value] ?? chatMenuOrder.length;
final rightOrder = chatMenuOrder[right.value] ?? chatMenuOrder.length;
return leftOrder.compareTo(rightOrder);
});
// 附加气泡菜单项列表
final defaultItemsBubbleAppend = [
if ((canCancelSpeechToText ?? false) && !conversationIsSystem)
PopupMenuItem<String>(
value: 'speechToText',
height: _popupMenuItemHeight,
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageWidget(
RCKThemeProvider().themeIcon.speechToText ?? '',
width: kPopupIconSize,
height: kPopupIconSize,
color: RCKThemeProvider().themeColor.textPrimary),
Text(
'取消转文字',
style: TextStyle(
fontSize: convoPopupFontSize,
color: RCKThemeProvider().themeColor.textPrimary),
),
],
),
),
),
PopupMenuItem<String>(
value: 'copy',
height: _popupMenuItemHeight,
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageWidget(RCKThemeProvider().themeIcon.copy ?? '',
width: kPopupIconSize, height: kPopupIconSize),
Text(
'复制',
style: TextStyle(
fontSize: convoPopupFontSize,
color: RCKThemeProvider().themeColor.textPrimary),
),
],
)),
),
];
// 默认菜单项列表
final defaultItemsConvo = [
PopupMenuItem<String>(
value: 'pin',
height: _popupMenuItemHeight,
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageWidget(
isPin ?? false
? RCKThemeProvider().themeIcon.unpin ?? ''
: RCKThemeProvider().themeIcon.pin ?? '',
width: kPopupIconSize,
height: kPopupIconSize,
color: RCKThemeProvider().themeColor.textPrimary),
Text(
isPin ?? false ? '取消置顶' : '置顶',
style: TextStyle(
fontSize: convoPopupFontSize,
color: RCKThemeProvider().themeColor.textPrimary),
),
],
)),
),
PopupMenuItem<String>(
value: 'mute',
height: _popupMenuItemHeight,
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageWidget(
isMute ?? false
? RCKThemeProvider().themeIcon.allowNotification ?? ''
: RCKThemeProvider().themeIcon.doNotDisturb1 ?? '',
width: kPopupIconSize,
height: kPopupIconSize),
Text(
isMute ?? false ? '允许消息通知' : '免打扰',
style: TextStyle(
fontSize: convoPopupFontSize,
color: RCKThemeProvider().themeColor.textPrimary),
),
],
),
),
),
PopupMenuItem<String>(
value: 'delete',
height: _popupMenuItemHeight,
padding: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ImageUtil.getImageWidget(RCKThemeProvider().themeIcon.delete ?? '',
width: kPopupIconSize, height: kPopupIconSize),
Text(
'删除',
style: TextStyle(
fontSize: convoPopupFontSize,
color: RCKThemeProvider().themeColor.textPrimary),
),
],
),
),
),
];
// 获取点击 widget 的全局坐标
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final Offset offset =
renderBox.localToGlobal(renderBox.size.center(Offset.zero));
final RelativeRect position =
RelativeRect.fromLTRB(offset.dx, offset.dy, offset.dx, offset.dy);
final popupItems = items ??
(switch (type) {
PopupType.convo => defaultItemsConvo,
PopupType.chat => defaultItemsChat,
PopupType.bubbleAppend => defaultItemsBubbleAppend,
});
// 如果是在对话详情页面,调整菜单位置以避免遮挡输入框
RelativeRect menuPosition = position;
double? menuMaxHeight;
if (type == PopupType.chat || type == PopupType.bubbleAppend) {
// 获取屏幕高度
final double screenHeight = MediaQuery.of(context).size.height;
final double screenWidth = MediaQuery.of(context).size.width;
// 获取底部安全区域高度
final double bottomSafeArea = MediaQuery.of(context).padding.bottom;
// 键盘弹出时,viewInsets.bottom 才是内容实际可用区域的底边。
final double bottomOcclusion = math.max(
bottomSafeArea,
MediaQuery.viewInsetsOf(context).bottom,
);
// 获取顶部安全区域高度(状态栏)
final double topSafeArea = MediaQuery.of(context).padding.top;
// 计算header高度(状态栏高度 + 标题栏高度)
final double headerHeight = topSafeArea + kChatAppBarHeight;
final messageInputProvider =
Provider.of<RCKMessageInputProvider>(context, listen: false);
// 底部按钮高度
final double heightBottomBar =
messageInputProvider.inputType == RCIMIWMessageInputType.emoji ||
messageInputProvider.inputType == RCIMIWMessageInputType.more
? kInputExtentionHeight
: 0;
// 消息列表的实际底边就是输入区域顶部,可覆盖多行输入和引用预览。
final messageListContext =
Provider.of<RCKChatProvider>(context, listen: false)
.messageListKey
.currentContext;
final messageListRenderObject = messageListContext?.findRenderObject();
final messageListRenderBox =
messageListRenderObject is RenderBox ? messageListRenderObject : null;
final double fallbackInputFieldTop = screenHeight -
heightBottomBar -
kInputMultiSelectHeight -
bottomOcclusion;
final double inputFieldTop = messageListRenderBox?.hasSize == true
? messageListRenderBox!.localToGlobal(Offset.zero).dy +
messageListRenderBox.size.height
: fallbackInputFieldTop;
// 给 PopupRoute 的屏幕内二次布局和输入区域留出余量。
const double menuEdgeSpacing = 16;
final double menuTopLimit = headerHeight + menuEdgeSpacing;
final double menuBottomLimit = math.min(
inputFieldTop,
screenHeight - bottomOcclusion,
) -
menuEdgeSpacing;
final double availableMenuHeight =
math.max(0, menuBottomLimit - menuTopLimit);
final double desiredMenuHeight = popupItems.fold<double>(
8,
(height, item) => height + item.height,
);
final double menuHeight = math.min(desiredMenuHeight, availableMenuHeight);
final double menuTop = math.max(
menuTopLimit,
math.min(offset.dy, menuBottomLimit - menuHeight),
);
// PopupMenuItem 的实际高度可能大于声明的 height,最大高度必须按
// 当前菜单顶部到可用底边的剩余空间计算,才能形成不可越过的硬边界。
menuMaxHeight = math.max(0, menuBottomLimit - menuTop);
menuPosition = RelativeRect.fromLTRB(
offset.dx,
menuTop,
math.max(0, screenWidth - offset.dx),
math.max(0, screenHeight - menuBottomLimit),
);
}
// 使用自定义主题和样式设置
return showMenu<String>(
menuPadding: EdgeInsets.zero,
context: context,
position: menuPosition,
// 弹出消息菜单时保留输入框焦点,避免键盘被 PopupRoute 收起。
requestFocus: false,
elevation: 8.0, // 阴影高度
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), // 圆角为10
),
constraints: BoxConstraints(
minWidth: 160,
maxWidth: 160, // 固定宽度为160
maxHeight: menuMaxHeight ?? double.infinity,
),
color: RCKThemeProvider().themeColor.textInverse, // 背景色
items: [
// 顶部填充
const PopupMenuItem<String>(
enabled: false,
height: 4, // 顶部padding高度
padding: EdgeInsets.zero,
child: SizedBox(),
),
// 真实菜单项
...popupItems,
// 底部填充
const PopupMenuItem<String>(
enabled: false,
height: 4, // 底部padding高度
padding: EdgeInsets.zero,
child: SizedBox(),
),
],
// 自定义内边距和项目间距
surfaceTintColor: Colors.transparent,
shadowColor: const Color(0x33000000), // 阴影颜色对应 #00000033
);
}