import 'dart:math' as math; import 'dart:ui' as ui; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../core/config/app_message_actions_style.dart'; import '../../core/utils/emoji_keyword_index.dart'; import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; import '../../l10n/app_localizations.dart'; import 'custom_notification.dart'; import 'qlyra_avatar.dart'; import 'lottie_image.dart'; import 'small_spinner.dart'; class ReactionEmoji { final String emoji; final String? animationUrl; final String? staticUrl; const ReactionEmoji({required this.emoji, this.animationUrl, this.staticUrl}); } class MessageReader { final int id; final String name; final String? avatarUrl; final ReactionEmoji? reaction; const MessageReader({ required this.id, required this.name, this.avatarUrl, this.reaction, }); } enum MessageActionsInteraction { dragAndRelease, click, tap } enum _RadialSide { below, above, left, right } class MessageActionsController extends ChangeNotifier { Offset? pointer; Offset? initialPointer; bool committed = false; bool movedSignificantly = false; bool _attached = false; void attach(Offset initial) { if (_attached) return; _attached = true; initialPointer = initial; pointer = initial; GestureBinding.instance.pointerRouter.addGlobalRoute(_onPointerEvent); } void updatePointer(Offset p) { if (committed) return; pointer = p; if (initialPointer != null && !movedSignificantly && (p - initialPointer!).distance > 18) { movedSignificantly = true; } notifyListeners(); } void _onPointerEvent(PointerEvent event) { if (committed) return; if (event is PointerMoveEvent) { updatePointer(event.position); } else if (event is PointerUpEvent || event is PointerCancelEvent) { commit(); } } void commit() { if (committed) return; committed = true; notifyListeners(); } @override void dispose() { if (_attached) { GestureBinding.instance.pointerRouter.removeGlobalRoute(_onPointerEvent); _attached = false; } super.dispose(); } } void showMessageActions({ required BuildContext context, ui.Image? snapshot, required Rect originRect, required Offset tapPoint, required bool isMe, required String? messageText, required String? copyText, required MessageActionsController controller, required MessageActionsStyle style, required VoidCallback onDispose, List>? editHistory, Future> Function()? loadReadBy, void Function(int userId)? onReaderTap, Future> Function()? loadReportReasons, Future Function(int reasonId)? onReport, VoidCallback? onDelete, bool allowDelete = true, bool allowCopy = true, VoidCallback? onEdit, VoidCallback? onReply, VoidCallback? onForward, VoidCallback? onMarkUnread, VoidCallback? onPin, bool isPinned = false, void Function(String emoji)? onReact, String? selectedReaction, List quickReactions = const [ ReactionEmoji(emoji: '👍'), ReactionEmoji(emoji: '❤️'), ReactionEmoji(emoji: '🔥'), ReactionEmoji(emoji: '🤣'), ReactionEmoji(emoji: '😭'), ReactionEmoji(emoji: '😍'), ], Future> Function()? loadReactionEmojis, MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease, }) { final overlay = Overlay.of(context, rootOverlay: true); late OverlayEntry entry; entry = OverlayEntry( builder: (ctx) => _MessageActionsLayer( snapshot: snapshot, originRect: originRect, tapPoint: tapPoint, isMe: isMe, messageText: messageText, copyText: copyText, controller: controller, style: style, interaction: interaction, editHistory: editHistory, loadReadBy: loadReadBy, onReaderTap: onReaderTap, loadReportReasons: loadReportReasons, onReport: onReport, onDelete: onDelete, allowDelete: allowDelete, allowCopy: allowCopy, onEdit: onEdit, onReply: onReply, onForward: onForward, onMarkUnread: onMarkUnread, onPin: onPin, isPinned: isPinned, onReact: onReact, selectedReaction: selectedReaction, quickReactions: quickReactions, loadReactionEmojis: loadReactionEmojis, onDismiss: () { if (entry.mounted) entry.remove(); onDispose(); }, ), ); overlay.insert(entry); } class _MessageActionsLayer extends StatefulWidget { final ui.Image? snapshot; final Rect originRect; final Offset tapPoint; final bool isMe; final String? messageText; final String? copyText; final MessageActionsController controller; final MessageActionsStyle style; final MessageActionsInteraction interaction; final VoidCallback onDismiss; final List>? editHistory; final Future> Function()? loadReadBy; final void Function(int userId)? onReaderTap; final Future> Function()? loadReportReasons; final Future Function(int reasonId)? onReport; final VoidCallback? onDelete; final bool allowDelete; final bool allowCopy; final VoidCallback? onEdit; final VoidCallback? onReply; final VoidCallback? onForward; final VoidCallback? onMarkUnread; final VoidCallback? onPin; final bool isPinned; final void Function(String emoji)? onReact; final String? selectedReaction; final List quickReactions; final Future> Function()? loadReactionEmojis; const _MessageActionsLayer({ required this.snapshot, required this.originRect, required this.tapPoint, required this.isMe, required this.messageText, required this.copyText, required this.controller, required this.style, required this.interaction, required this.onDismiss, this.editHistory, this.loadReadBy, this.onReaderTap, this.loadReportReasons, this.onReport, this.onDelete, this.allowDelete = true, this.allowCopy = true, this.onEdit, this.onReply, this.onForward, this.onMarkUnread, this.onPin, this.isPinned = false, this.onReact, this.selectedReaction, this.quickReactions = const [], this.loadReactionEmojis, }); @override State<_MessageActionsLayer> createState() => _MessageActionsLayerState(); } class _MessageActionsLayerState extends State<_MessageActionsLayer> with TickerProviderStateMixin { late final AnimationController _animController; late final Animation _animation; late final AnimationController _expandController; late final Animation _expandAnim; bool _reactionsExpanded = false; bool _reactionsPanelReady = false; Widget? _pickerCache; bool _closing = false; static const double _radius = 92.0; static const double _arcSpan = math.pi * 0.62; static const double _btnSize = 52.0; static const double _hitRadius = 40.0; static const double _hMargin = 8.0; late List<_Action> _actions; late MessageActionsStyle _effectiveStyle; bool _showBelow = true; Offset _anchor = Offset.zero; List _buttonCenters = const []; List _buttonHitRects = const []; Rect _menuRect = Rect.zero; bool _initialized = false; int _hoveredIndex = -1; bool _committedFired = false; bool _showHistory = false; bool _showReport = false; bool _showReadBy = false; bool _reportLoading = false; bool _reportSending = false; bool _readByLoading = false; List<({int id, String title})>? _reasons; List? _readers; bool get _panelOpen => _showHistory || _showReport || _showReadBy; @override void initState() { super.initState(); if (_reactionsEnabled) { EmojiKeywordIndex.instance.ensureLoaded(); } _animController = AnimationController( vsync: this, duration: const Duration(milliseconds: 320), reverseDuration: const Duration(milliseconds: 220), ); _animation = CurvedAnimation( parent: _animController, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic, ); _expandController = AnimationController( vsync: this, duration: const Duration(milliseconds: 360), reverseDuration: const Duration(milliseconds: 260), ); _expandAnim = CurvedAnimation( parent: _expandController, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic, ); _expandController.addStatusListener(_onExpandStatus); _animController.forward(); } void _onExpandStatus(AnimationStatus status) { if (!mounted) return; if (status == AnimationStatus.completed) { if (!_reactionsPanelReady) setState(() => _reactionsPanelReady = true); } else if (status == AnimationStatus.dismissed) { if (_reactionsPanelReady) setState(() => _reactionsPanelReady = false); } } bool get _reactionsEnabled => widget.onReact != null && widget.quickReactions.isNotEmpty && widget.interaction != MessageActionsInteraction.click; @override void didChangeDependencies() { super.didChangeDependencies(); if (_initialized) return; _initialized = true; _actions = _buildActions(); _effectiveStyle = widget.interaction == MessageActionsInteraction.click ? MessageActionsStyle.list : widget.style; final screenSize = MediaQuery.sizeOf(context); if (_effectiveStyle == MessageActionsStyle.radial) { _computeRadialGeometry(screenSize); } else { _computeListGeometry(screenSize); } if (widget.interaction == MessageActionsInteraction.dragAndRelease) { widget.controller.addListener(_onControllerUpdate); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _onControllerUpdate(); }); } } void _computeRadialGeometry(Size screenSize) { final n = _actions.length; const reach = _radius + _btnSize / 2; final padding = MediaQuery.paddingOf(context); final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; final topMargin = padding.top + _hMargin; final bottomMargin = math.max(padding.bottom, keyboardInset) + _hMargin; final rect = widget.originRect; final spaceBelow = screenSize.height - bottomMargin - (rect.bottom + 16); final spaceAbove = (rect.top - 16) - topMargin; final belowFits = spaceBelow >= reach; final aboveFits = spaceAbove >= reach; final preferBelow = widget.tapPoint.dy < screenSize.height * 0.55; final _RadialSide side; if (belowFits && aboveFits) { side = preferBelow ? _RadialSide.below : _RadialSide.above; } else if (belowFits) { side = _RadialSide.below; } else if (aboveFits) { side = _RadialSide.above; } else { final spaceRight = screenSize.width - _hMargin - (rect.right + 16); final spaceLeft = (rect.left - 16) - _hMargin; final rightFits = spaceRight >= reach; final leftFits = spaceLeft >= reach; if (widget.isMe) { side = (leftFits || !rightFits) ? _RadialSide.left : _RadialSide.right; } else { side = (rightFits || !leftFits) ? _RadialSide.right : _RadialSide.left; } } final double base; double anchorX; double anchorY; switch (side) { case _RadialSide.below: base = math.pi * 0.5; anchorX = widget.tapPoint.dx; anchorY = rect.bottom + 16; case _RadialSide.above: base = -math.pi * 0.5; anchorX = widget.tapPoint.dx; anchorY = rect.top - 16; case _RadialSide.right: base = 0; anchorX = rect.right + 16; anchorY = widget.tapPoint.dy.clamp(rect.top, rect.bottom).toDouble(); case _RadialSide.left: base = math.pi; anchorX = rect.left - 16; anchorY = widget.tapPoint.dy.clamp(rect.top, rect.bottom).toDouble(); } _showBelow = side == _RadialSide.below || (side != _RadialSide.above && spaceBelow >= spaceAbove); final start = base - _arcSpan / 2; final step = n <= 1 ? 0.0 : _arcSpan / (n - 1); final offsets = []; double minDx = 0; double maxDx = 0; double minDy = 0; double maxDy = 0; for (int i = 0; i < n; i++) { final angle = start + step * i; final dx = math.cos(angle) * _radius; final dy = math.sin(angle) * _radius; offsets.add(Offset(dx, dy)); if (dx < minDx) minDx = dx; if (dx > maxDx) maxDx = dx; if (dy < minDy) minDy = dy; if (dy > maxDy) maxDy = dy; } final minX = _hMargin + _btnSize / 2; final maxX = screenSize.width - _hMargin - _btnSize / 2; final minY = topMargin + _btnSize / 2; final maxY = screenSize.height - bottomMargin - _btnSize / 2; if (anchorX + maxDx > maxX) anchorX = maxX - maxDx; if (anchorX + minDx < minX) anchorX = minX - minDx; if (anchorY + maxDy > maxY) anchorY = maxY - maxDy; if (anchorY + minDy < minY) anchorY = minY - minDy; _anchor = Offset(anchorX, anchorY); _buttonCenters = [for (final o in offsets) _anchor + o]; _buttonHitRects = [ for (final c in _buttonCenters) Rect.fromCenter( center: c, width: _hitRadius * 2, height: _hitRadius * 2, ), ]; } void _computeListGeometry(Size screenSize) { final n = _actions.length; const menuWidth = 220.0; const itemHeight = 42.0; const vPad = 6.0; final menuHeight = n * itemHeight + vPad * 2; final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; final bottomLimit = screenSize.height - keyboardInset; final maxMenuY = math.max(8.0, bottomLimit - menuHeight - 8.0); late double menuX; late double menuY; if (widget.interaction != MessageActionsInteraction.dragAndRelease) { final spaceBelow = bottomLimit - widget.tapPoint.dy - 8; _showBelow = spaceBelow >= menuHeight || widget.tapPoint.dy < menuHeight; final rawY = _showBelow ? widget.tapPoint.dy : widget.tapPoint.dy - menuHeight; menuY = rawY.clamp(8.0, maxMenuY).toDouble(); menuX = widget.tapPoint.dx .clamp(8.0, screenSize.width - menuWidth - 8.0) .toDouble(); } else { final spaceBelow = bottomLimit - widget.originRect.bottom - 24; final spaceAbove = widget.originRect.top - 24; _showBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove; final rawY = _showBelow ? widget.originRect.bottom + 10 : widget.originRect.top - 10 - menuHeight; menuY = rawY.clamp(8.0, maxMenuY).toDouble(); final rawX = widget.isMe ? widget.originRect.right - menuWidth : widget.originRect.left; menuX = rawX.clamp(8.0, screenSize.width - menuWidth - 8.0).toDouble(); } _menuRect = Rect.fromLTWH(menuX, menuY, menuWidth, menuHeight); _buttonHitRects = [ for (int i = 0; i < n; i++) Rect.fromLTWH( menuX, menuY + vPad + i * itemHeight, menuWidth, itemHeight, ), ]; } @override void dispose() { _animController.dispose(); _expandController.dispose(); widget.controller.removeListener(_onControllerUpdate); widget.snapshot?.dispose(); super.dispose(); } List<_Action> _buildActions() { final l10n = AppLocalizations.of(context)!; final copyText = widget.copyText; final canCopy = widget.allowCopy && copyText != null && copyText.isNotEmpty; return <_Action>[ if (widget.onReply != null) _Action(Symbols.reply, l10n.msgActionsReply, _reply), if (widget.onForward != null) _Action(Symbols.forward, l10n.msgActionsForward, _forward), if (canCopy) _Action(Symbols.content_copy, l10n.msgActionsCopy, _copy), if (widget.isMe && widget.onEdit != null) _Action(Symbols.edit, l10n.msgActionsEdit, _edit), if (widget.onPin != null) _Action( widget.isPinned ? Symbols.keep_off : Symbols.push_pin, widget.isPinned ? l10n.msgActionsUnpin : l10n.msgActionsPin, _pin, ), if (widget.onMarkUnread != null) _Action( Symbols.mark_chat_unread, l10n.msgActionsMarkUnread, _markUnread, ), if (widget.editHistory != null && widget.editHistory!.isNotEmpty) _Action(Symbols.history, l10n.msgActionsEditHistory, _showHistoryView), if (widget.loadReadBy != null) _Action(Symbols.visibility, l10n.msgActionsReadBy, _showReadByView), if (widget.onReport != null && widget.loadReportReasons != null) _Action( Symbols.flag, l10n.msgActionsReport, _showReportView, destructive: true, ), if (widget.allowDelete) _Action( Symbols.delete, l10n.msgActionsDelete, _delete, destructive: true, ), ]; } void _showHistoryView() { if (!mounted) return; setState(() => _showHistory = true); } Future _showReadByView() async { if (!mounted) return; setState(() { _showReadBy = true; _readByLoading = _readers == null; }); if (_readers != null) return; final loaded = await widget.loadReadBy?.call(); if (!mounted) return; setState(() { _readers = loaded ?? const []; _readByLoading = false; }); } Future _showReportView() async { if (!mounted) return; setState(() { _showReport = true; _reportLoading = _reasons == null; }); if (_reasons != null) return; final loaded = await widget.loadReportReasons?.call(); if (!mounted) return; setState(() { _reasons = loaded ?? const []; _reportLoading = false; }); } Future _submitReport(int reasonId) async { if (_reportSending) return; setState(() => _reportSending = true); final report = widget.onReport; final ok = report == null ? false : await report(reasonId); if (!mounted) return; if (ok) { await _close(); } else { setState(() => _reportSending = false); } } void _backToMenu() { if (!mounted) return; setState(() { _showHistory = false; _showReport = false; _showReadBy = false; }); } void _onControllerUpdate() { if (!mounted) return; final p = widget.controller.pointer; if (p != null) { final newHovered = _findButtonAt(p); if (newHovered != _hoveredIndex) { if (newHovered != -1) Haptics.selection(); setState(() => _hoveredIndex = newHovered); } } if (widget.controller.committed && !_committedFired) { _committedFired = true; _onCommit(); } } int _findButtonAt(Offset p) { for (int i = 0; i < _buttonHitRects.length; i++) { if (_buttonHitRects[i].contains(p)) return i; } return -1; } void _onCommit() { if (_hoveredIndex != -1 && widget.controller.movedSignificantly) { Haptics.medium(); _actions[_hoveredIndex].onTap(); } else if (widget.controller.movedSignificantly) { _close(); } } Future _close() async { if (!mounted || _closing) return; _closing = true; try { await _animController.reverse(); } catch (_) {} if (!mounted) return; widget.onDismiss(); } Future _copy() async { final text = widget.copyText; if (text != null && text.isNotEmpty) { await Clipboard.setData(ClipboardData(text: text)); if (!mounted) return; showCustomNotification( context, AppLocalizations.of(context)!.msgActionsCopied, ); } await _close(); } Future _delete() async { final onDelete = widget.onDelete; await _close(); onDelete?.call(); } Future _edit() async { final onEdit = widget.onEdit; await _close(); onEdit?.call(); } Future _reply() async { final onReply = widget.onReply; await _close(); onReply?.call(); } Future _forward() async { final onForward = widget.onForward; await _close(); onForward?.call(); } Future _markUnread() async { final onMarkUnread = widget.onMarkUnread; await _close(); onMarkUnread?.call(); } Future _pin() async { final onPin = widget.onPin; await _close(); onPin?.call(); } @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); final isClick = widget.interaction == MessageActionsInteraction.click; final showReactions = _reactionsEnabled && !isClick; return AnimatedBuilder( animation: Listenable.merge([_animation, _expandController]), builder: (ctx, _) { final t = _animation.value.clamp(0.0, 1.0); final e = showReactions ? _expandAnim.value.clamp(0.0, 1.0) : 0.0; final bubbleScale = 1.0 + 0.02 * t; final menuHidden = _panelOpen || _reactionsExpanded; return GestureDetector( onTap: _close, behavior: HitTestBehavior.opaque, child: Stack( children: [ if (!isClick) ...[ Positioned.fill( child: ColoredBox( color: Colors.black.withValues(alpha: 0.22 * t + 0.28 * e), ), ), if (widget.snapshot != null) Positioned( left: widget.originRect.left, top: widget.originRect.top, width: widget.originRect.width, height: widget.originRect.height, child: Opacity( opacity: 1.0 - 0.35 * e, child: Transform.scale( scale: bubbleScale, child: RawImage( image: widget.snapshot, width: widget.originRect.width, height: widget.originRect.height, fit: BoxFit.fill, ), ), ), ), ], Positioned.fill( child: IgnorePointer( ignoring: menuHidden, child: AnimatedOpacity( opacity: menuHidden ? 0.0 : 1.0, duration: const Duration(milliseconds: 150), curve: Curves.easeOut, child: Stack( children: [ if (_effectiveStyle == MessageActionsStyle.radial) ...[ ..._buildButtons(t), _buildLabelBanner(size, t), ] else _buildListMenu(t), ], ), ), ), ), Positioned.fill( child: IgnorePointer( ignoring: !_panelOpen, child: AnimatedOpacity( opacity: _panelOpen ? 1.0 : 0.0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, child: Stack( children: [ if (_showReport) _buildReportMenu() else if (_showHistory) _buildHistoryMenu() else if (_showReadBy) _buildReadByMenu(), ], ), ), ), ), if (showReactions) Positioned.fill( child: IgnorePointer( ignoring: _panelOpen, child: AnimatedOpacity( opacity: _panelOpen ? 0.0 : 1.0, duration: const Duration(milliseconds: 150), curve: Curves.easeOut, child: _buildReactionStrip(t, e), ), ), ), ], ), ); }, ); } void _toggleReactionsExpanded() { Haptics.tap(); setState(() => _reactionsExpanded = !_reactionsExpanded); if (_reactionsExpanded) { _expandController.forward(); } else { _expandController.reverse(); } } Future _onReactionPicked(String emoji) async { final cb = widget.onReact; Haptics.medium(); await _close(); cb?.call(emoji); } bool _isSelectedReaction(String emoji) { final sel = widget.selectedReaction; if (sel == null) return false; return EmojiKeywordIndex.normalize(sel) == EmojiKeywordIndex.normalize(emoji); } Rect _reactionAnchorRect() { if (_effectiveStyle == MessageActionsStyle.list && _menuRect != Rect.zero) { return _menuRect; } if (_buttonHitRects.isNotEmpty) { var box = _buttonHitRects.first; for (final r in _buttonHitRects.skip(1)) { box = box.expandToInclude(r); } return box; } return widget.originRect; } Widget _buildReactionStrip(double t, double e) { final cs = Theme.of(context).colorScheme; final size = MediaQuery.sizeOf(context); final padding = MediaQuery.paddingOf(context); final keyboardInset = MediaQuery.viewInsetsOf(context).bottom; final safeTop = padding.top + 8; final safeBottom = size.height - math.max(padding.bottom, keyboardInset) - 8; final quick = widget.quickReactions; const chevronCell = 38.0; const pillPad = 6.0; const pillHeight = 46.0; const gap = 10.0; const maxCell = 36.0; double pillWidth = pillPad * 2 + quick.length * maxCell + chevronCell; final maxPillWidth = size.width - 16; double cell = maxCell; if (pillWidth > maxPillWidth) { cell = ((maxPillWidth - pillPad * 2 - chevronCell) / quick.length).clamp( 28.0, maxCell, ); pillWidth = pillPad * 2 + quick.length * cell + chevronCell; } final anchor = _reactionAnchorRect(); double pillLeft = anchor.left.clamp( 8.0, math.max(8.0, size.width - 8 - pillWidth), ); final pillAbove = (anchor.top - safeTop) >= pillHeight + gap; double pillTop = pillAbove ? anchor.top - gap - pillHeight : anchor.bottom + gap; pillTop = pillTop.clamp( safeTop, math.max(safeTop, safeBottom - pillHeight), ); final collapsed = Rect.fromLTWH(pillLeft, pillTop, pillWidth, pillHeight); final panelWidth = math.min(size.width - 24, 300.0); const desiredHeight = 300.0; double panelLeft = collapsed.left.clamp( 8.0, math.max(8.0, size.width - 8 - panelWidth), ); double panelTop; double panelHeight; if (pillAbove) { panelTop = collapsed.top; panelHeight = math.min(desiredHeight, safeBottom - panelTop); } else { final panelBottom = collapsed.bottom; panelTop = math.max(safeTop, panelBottom - desiredHeight); panelHeight = panelBottom - panelTop; } final expanded = Rect.fromLTWH( panelLeft, panelTop, panelWidth, panelHeight, ); final morph = Rect.lerp(collapsed, expanded, e)!; final radius = ui.lerpDouble(pillHeight / 2, 20.0, e)!; final entryAlign = pillAbove ? Alignment.bottomLeft : Alignment.topLeft; return IgnorePointer( ignoring: t < 0.5, child: Opacity( opacity: t, child: Stack( children: [ if (e < 0.999) _buildCloudTail(collapsed, pillAbove, cs, t, e), Positioned.fromRect( rect: morph, child: Transform.scale( scale: 0.9 + 0.1 * t, alignment: entryAlign, child: _buildReactionSurface( cs, radius, e, cell, quick, expanded.size, pillAbove, ), ), ), ], ), ), ); } Widget _buildReactionSurface( ColorScheme cs, double radius, double e, double cell, List quick, Size expandedSize, bool pillAbove, ) { final borderRadius = BorderRadius.circular(radius); _pickerCache ??= RepaintBoundary( child: _ReactionEmojiPicker( onPick: _onReactionPicked, loadEmojis: widget.loadReactionEmojis, ), ); return DecoratedBox( decoration: BoxDecoration( color: cs.surfaceContainerHigh, borderRadius: borderRadius, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.4), blurRadius: 18, offset: const Offset(0, 8), ), ], ), child: ClipRRect( borderRadius: borderRadius, child: GestureDetector( onTap: () {}, behavior: HitTestBehavior.opaque, child: Stack( fit: StackFit.expand, children: [ if (_reactionsPanelReady) Positioned( left: 0, top: pillAbove ? 0 : null, bottom: pillAbove ? null : 0, width: expandedSize.width, height: expandedSize.height, child: TweenAnimationBuilder( tween: Tween(begin: 0.0, end: 1.0), duration: const Duration(milliseconds: 140), curve: Curves.easeOut, child: _pickerCache, builder: (_, value, child) => Opacity(opacity: value, child: child), ), ), if (!_reactionsPanelReady) Positioned( left: 0, right: 0, top: pillAbove ? 0 : null, bottom: pillAbove ? null : 0, height: 46, child: Opacity( opacity: (1.0 - e).clamp(0.0, 1.0), child: IgnorePointer( ignoring: e > 0.05, child: _buildQuickRow(cs, cell, quick), ), ), ), ], ), ), ), ); } Widget _buildQuickRow( ColorScheme cs, double cell, List quick, ) { return Center( child: Row( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(width: 6), for (final reaction in quick) _quickEmoji(cs, reaction, cell), _chevronButton(cs), const SizedBox(width: 4), ], ), ); } Widget _quickEmoji(ColorScheme cs, ReactionEmoji reaction, double cell) { final selected = _isSelectedReaction(reaction.emoji); return SizedBox( width: cell, height: cell, child: Material( color: selected ? cs.primary.withValues(alpha: 0.22) : Colors.transparent, shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), onTap: () => _onReactionPicked(reaction.emoji), child: Center( child: _ReactionGlyph(reaction: reaction, size: cell * 0.72), ), ), ), ); } Widget _chevronButton(ColorScheme cs) { return SizedBox( width: 40, height: 40, child: Material( color: cs.surfaceContainerHighest, shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), onTap: _toggleReactionsExpanded, child: Icon( Symbols.keyboard_arrow_down, color: cs.onSurfaceVariant, size: 24, ), ), ), ); } Widget _buildCloudTail( Rect pill, bool above, ColorScheme cs, double t, double e, ) { final fade = (t * (1.0 - e * 1.4)).clamp(0.0, 1.0); final baseX = pill.right - 22; final edgeY = above ? pill.bottom - 2 : pill.top + 2; final dir = above ? 1.0 : -1.0; final big = Offset(baseX, edgeY + dir * 6); final small = Offset(baseX + 8, edgeY + dir * 17); return IgnorePointer( child: Opacity( opacity: fade, child: Stack( children: [_tailCircle(big, 8.0, cs), _tailCircle(small, 5.0, cs)], ), ), ); } Widget _tailCircle(Offset c, double r, ColorScheme cs) { return Positioned( left: c.dx - r, top: c.dy - r, width: r * 2, height: r * 2, child: Container( decoration: BoxDecoration( color: cs.surfaceContainerHigh, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.28), blurRadius: 8, offset: const Offset(0, 3), ), ], ), ), ); } Widget _buildAnchoredPanel({ required String title, required Widget body, double width = 220.0, }) { final cs = Theme.of(context).colorScheme; final size = MediaQuery.sizeOf(context); final panelWidth = math.min(width, size.width - 16.0); double left; double top; if (_menuRect != Rect.zero) { left = _menuRect.left; top = _menuRect.top; } else { left = widget.isMe ? widget.originRect.right - panelWidth : widget.originRect.left; top = _showBelow ? widget.originRect.bottom + 10 : widget.originRect.top - 10; } final bottomLimit = size.height - MediaQuery.viewInsetsOf(context).bottom; left = left.clamp(8.0, math.max(8.0, size.width - panelWidth - 8.0)); top = top.clamp(8.0, math.max(8.0, bottomLimit - 160.0)); final maxHeight = math.min(size.height * 0.6, bottomLimit - top - 8.0); return Positioned( left: left, top: top, width: panelWidth, child: GestureDetector( onTap: () {}, child: Material( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(16), clipBehavior: Clip.antiAlias, elevation: 8, shadowColor: Colors.black.withValues(alpha: 0.4), child: ConstrainedBox( constraints: BoxConstraints(maxHeight: maxHeight), child: Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 44, child: Row( children: [ _panelBackButton(cs), Expanded( child: Text( title, style: TextStyle( color: cs.onSurface, fontSize: 14, fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: 12), ], ), ), Divider( height: 1, color: cs.outlineVariant.withValues(alpha: 0.4), ), Flexible(child: body), ], ), ), ), ), ); } Widget _panelBackButton(ColorScheme cs) => Material( color: Colors.transparent, shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), onTap: () { Haptics.tap(); _backToMenu(); }, child: Padding( padding: const EdgeInsets.all(10), child: Icon(Symbols.arrow_back, color: cs.onSurface, size: 20), ), ), ); Widget _buildHistoryMenu() { final cs = Theme.of(context).colorScheme; final history = widget.editHistory ?? const >[]; final rows = []; for (var i = 0; i < history.length; i++) { if (i > 0) rows.add(_historyDivider(cs)); rows.add( _historyRow( cs, history[i]['text'] as String?, history[i]['time'], current: false, ), ); } final currentTime = history.isNotEmpty ? history.last['time'] : null; if (rows.isNotEmpty) rows.add(_historyDivider(cs)); rows.add(_historyRow(cs, widget.messageText, currentTime, current: true)); return _buildAnchoredPanel( title: AppLocalizations.of(context)!.msgActionsEditHistory, body: SingleChildScrollView( child: Column(mainAxisSize: MainAxisSize.min, children: rows), ), ); } Widget _buildReadByMenu() { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final Widget body; if (_readByLoading) { body = const Padding( padding: EdgeInsets.symmetric(vertical: 28), child: Center(child: SmallSpinner(size: 24)), ); } else { final readers = _readers ?? const []; if (readers.isEmpty) { body = Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), child: Text( l10n.msgActionsReadByEmpty, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ); } else { body = SingleChildScrollView( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( mainAxisSize: MainAxisSize.min, children: [for (final reader in readers) _readerRow(cs, reader)], ), ); } } return _buildAnchoredPanel( title: l10n.msgActionsReadBy, body: body, width: 250, ); } Future _openReaderProfile(MessageReader reader) async { final onTap = widget.onReaderTap; if (onTap == null) return; Haptics.tap(); await _close(); onTap(reader.id); } Widget _readerRow(ColorScheme cs, MessageReader reader) { final reaction = reader.reaction; return Material( color: Colors.transparent, child: InkWell( onTap: widget.onReaderTap == null ? null : () => _openReaderProfile(reader), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), child: Row( children: [ QlyraAvatar( name: reader.name, imageUrl: reader.avatarUrl, size: 30, ), const SizedBox(width: 10), Expanded( child: Text( reader.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(color: cs.onSurface, fontSize: 14), ), ), if (reaction != null) ...[ const SizedBox(width: 8), _ReactionGlyph(reaction: reaction, size: 20), ], ], ), ), ), ); } Widget _buildReportMenu() { final cs = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; final Widget body; if (_reportLoading) { body = const Padding( padding: EdgeInsets.symmetric(vertical: 28), child: Center(child: SmallSpinner(size: 24)), ); } else { final reasons = _reasons ?? const <({int id, String title})>[]; if (reasons.isEmpty) { body = Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18), child: Text( l10n.msgActionsLoadReasonsFailed, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), ), ); } else { final rows = []; for (var i = 0; i < reasons.length; i++) { if (i > 0) rows.add(_historyDivider(cs)); rows.add(_reasonRow(cs, reasons[i])); } body = SingleChildScrollView( child: Column(mainAxisSize: MainAxisSize.min, children: rows), ); } } return _buildAnchoredPanel(title: l10n.msgActionsReport, body: body); } Widget _reasonRow(ColorScheme cs, ({int id, String title}) reason) => Material( color: Colors.transparent, child: InkWell( onTap: _reportSending ? null : () => _submitReport(reason.id), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), child: Text( reason.title, style: TextStyle(color: cs.onSurface, fontSize: 14), ), ), ), ); Widget _historyDivider(ColorScheme cs) => Divider(height: 1, color: cs.outlineVariant.withValues(alpha: 0.25)); Widget _historyRow( ColorScheme cs, String? text, dynamic time, { required bool current, }) { final l10n = AppLocalizations.of(context)!; final ms = time is int ? time : int.tryParse(time?.toString() ?? ''); final dateStr = ms != null ? formatDateTimeWords(DateTime.fromMillisecondsSinceEpoch(ms)) : ''; final label = current ? (dateStr.isEmpty ? l10n.msgActionsCurrentVersion : l10n.msgActionsCurrentVersionWithDate(dateStr)) : dateStr; return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 11), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( text == null || text.isEmpty ? l10n.msgActionsNoText : text, style: TextStyle( color: current ? cs.primary : cs.onSurface, fontSize: 15, height: 1.3, ), ), const SizedBox(height: 4), Text( label, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), ), ], ), ); } Widget _buildListMenu(double t) { final cs = Theme.of(context).colorScheme; final eased = Curves.easeOutCubic.transform(t); final scale = 0.88 + 0.12 * eased; final tapAnchored = widget.interaction != MessageActionsInteraction.dragAndRelease; return Positioned( left: _menuRect.left, top: _menuRect.top, width: _menuRect.width, height: _menuRect.height, child: Opacity( opacity: eased, child: Transform.scale( scale: scale, alignment: tapAnchored ? Alignment(-1.0, _showBelow ? -1.0 : 1.0) : Alignment(widget.isMe ? 1.0 : -1.0, _showBelow ? -1.0 : 1.0), child: Material( color: cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(16), clipBehavior: Clip.antiAlias, elevation: 8, shadowColor: Colors.black.withValues(alpha: 0.4), child: Column( mainAxisSize: MainAxisSize.min, children: [ const SizedBox(height: 6), for (int i = 0; i < _actions.length; i++) _ListMenuItem( action: _actions[i], highlighted: _hoveredIndex == i, onHoverChanged: tapAnchored ? (hovered) { if (hovered) { if (_hoveredIndex != i) { setState(() => _hoveredIndex = i); } } else if (_hoveredIndex == i) { setState(() => _hoveredIndex = -1); } } : null, ), const SizedBox(height: 6), ], ), ), ), ), ); } List _buildButtons(double t) { final n = _actions.length; return [ for (int i = 0; i < n; i++) Builder( builder: (_) { final delay = (i / n) * 0.25; final localT = ((t - delay) / (1.0 - delay)).clamp(0.0, 1.0); final eased = Curves.easeOutBack.transform(localT); final isHovered = _hoveredIndex == i; final hoverScale = isHovered ? 1.18 : 1.0; final entryScale = 0.4 + 0.6 * eased; final centerAtFull = _buttonCenters[i]; final centerAtT = _anchor + (centerAtFull - _anchor) * eased; return Positioned( left: centerAtT.dx - _btnSize / 2, top: centerAtT.dy - _btnSize / 2, width: _btnSize, height: _btnSize, child: Opacity( opacity: localT, child: Transform.scale( scale: entryScale, child: AnimatedScale( scale: hoverScale, duration: const Duration(milliseconds: 140), curve: Curves.easeOutCubic, child: _ActionButton( action: _actions[i], highlighted: isHovered, ), ), ), ), ); }, ), ]; } Widget _buildLabelBanner(Size size, double t) { final label = _hoveredIndex == -1 ? null : _actions[_hoveredIndex].label; final bottomInset = math.max( MediaQuery.paddingOf(context).bottom, MediaQuery.viewInsetsOf(context).bottom, ); return Positioned( left: 0, right: 0, bottom: bottomInset + 36, child: IgnorePointer( child: Center( child: AnimatedSwitcher( duration: const Duration(milliseconds: 140), transitionBuilder: (child, anim) => FadeTransition( opacity: anim, child: ScaleTransition( scale: Tween(begin: 0.85, end: 1.0).animate(anim), child: child, ), ), child: label == null ? const SizedBox(key: ValueKey('empty'), height: 0) : Container( key: ValueKey('label_$label'), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 9, ), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.72 * t), borderRadius: BorderRadius.circular(24), ), child: Text( label, style: const TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, ), ), ), ), ), ), ); } } class _Action { final IconData icon; final String label; final VoidCallback onTap; final bool destructive; const _Action(this.icon, this.label, this.onTap, {this.destructive = false}); } class _ReactionEmojiPicker extends StatefulWidget { final ValueChanged onPick; final Future> Function()? loadEmojis; const _ReactionEmojiPicker({required this.onPick, this.loadEmojis}); @override State<_ReactionEmojiPicker> createState() => _ReactionEmojiPickerState(); } class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> { final TextEditingController _searchCtrl = TextEditingController(); final FocusNode _searchFocus = FocusNode(); final ValueNotifier _scrolling = ValueNotifier(false); List _all = const []; List _results = const []; String _query = ''; bool _loaded = false; bool _onScrollNotification(ScrollNotification n) { if (n is ScrollStartNotification || n is ScrollUpdateNotification) { if (!_scrolling.value) _scrolling.value = true; } else if (n is ScrollEndNotification) { if (_scrolling.value) _scrolling.value = false; } return false; } @override void initState() { super.initState(); _load(); } Future _load() async { await EmojiKeywordIndex.instance.ensureLoaded(); final loader = widget.loadEmojis; final emojis = loader != null ? await loader() : EmojiKeywordIndex.instance.all .map((e) => ReactionEmoji(emoji: e)) .toList(); if (!mounted) return; setState(() { _all = emojis; _results = _all; _loaded = true; }); } void _onQueryChanged(String value) { final q = value.trim(); setState(() { _query = q; if (q.isEmpty) { _results = _all; } else { final matches = EmojiKeywordIndex.instance .search(q) .map(EmojiKeywordIndex.normalize) .toSet(); _results = _all .where( (e) => matches.contains(EmojiKeywordIndex.normalize(e.emoji)), ) .toList(); } }); } void _clearSearch() { _searchCtrl.clear(); _onQueryChanged(''); } @override void dispose() { _searchCtrl.dispose(); _searchFocus.dispose(); _scrolling.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return Material( type: MaterialType.transparency, child: Column( children: [ _buildSearchField(cs), Expanded( child: !_loaded ? const Center(child: SmallSpinner(size: 26)) : _results.isEmpty ? const SizedBox.shrink() : LottieScrollScope( isScrolling: _scrolling, child: NotificationListener( onNotification: _onScrollNotification, child: GridView.builder( padding: const EdgeInsets.fromLTRB(8, 2, 8, 10), keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, addAutomaticKeepAlives: false, gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: 48, mainAxisSpacing: 2, crossAxisSpacing: 2, ), itemCount: _results.length, itemBuilder: (context, i) { final reaction = _results[i]; return _EmojiCell( reaction: reaction, onTap: () => widget.onPick(reaction.emoji), ); }, ), ), ), ), ], ), ); } Widget _buildSearchField(ColorScheme cs) { return Padding( padding: const EdgeInsets.fromLTRB(10, 10, 10, 6), child: Container( height: 42, decoration: BoxDecoration( color: cs.surfaceContainerHighest, borderRadius: BorderRadius.circular(21), ), child: Row( children: [ const SizedBox(width: 12), Icon(Symbols.search, size: 22, color: cs.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: TextField( controller: _searchCtrl, focusNode: _searchFocus, onChanged: _onQueryChanged, textInputAction: TextInputAction.search, cursorColor: cs.primary, style: TextStyle(color: cs.onSurface, fontSize: 15), decoration: InputDecoration( isCollapsed: true, border: InputBorder.none, hintText: AppLocalizations.of(context)!.emojiSearchHint, hintStyle: TextStyle( color: cs.onSurfaceVariant, fontSize: 15, ), ), ), ), if (_query.isEmpty) const SizedBox(width: 12) else GestureDetector( behavior: HitTestBehavior.opaque, onTap: _clearSearch, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Icon( Symbols.close, size: 20, color: cs.onSurfaceVariant, ), ), ), ], ), ), ); } } class _EmojiCell extends StatelessWidget { final ReactionEmoji reaction; final VoidCallback onTap; const _EmojiCell({required this.reaction, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onTap, child: Center(child: _ReactionGlyph(reaction: reaction, size: 34)), ); } } class _ReactionGlyph extends StatelessWidget { final ReactionEmoji reaction; final double size; const _ReactionGlyph({required this.reaction, required this.size}); @override Widget build(BuildContext context) { final anim = reaction.animationUrl; final still = reaction.staticUrl; final hasAsset = (anim != null && anim.isNotEmpty) || (still != null && still.isNotEmpty); if (!hasAsset) { return Center( child: Text(reaction.emoji, style: TextStyle(fontSize: size * 0.9)), ); } return LottieImage( lottieUrl: anim, url: still, size: size, memCacheWidth: (size * 3).round(), ); } } class _ListMenuItem extends StatelessWidget { final _Action action; final bool highlighted; final ValueChanged? onHoverChanged; const _ListMenuItem({ required this.action, required this.highlighted, this.onHoverChanged, }); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final pillBg = action.destructive ? cs.error : cs.primary; final onPill = action.destructive ? cs.onError : cs.onPrimary; final restFg = action.destructive ? cs.error : cs.onSurface; final fg = highlighted ? onPill : restFg; final inner = SizedBox( height: 42, child: Material( color: Colors.transparent, child: InkWell( onTap: () { Haptics.tap(); action.onTap(); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), child: AnimatedContainer( duration: const Duration(milliseconds: 140), curve: Curves.easeOutCubic, decoration: BoxDecoration( color: highlighted ? pillBg : Colors.transparent, borderRadius: BorderRadius.circular(18), ), padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ Icon(action.icon, color: fg, size: 20), const SizedBox(width: 12), Expanded( child: Text( action.label, style: TextStyle( color: fg, fontSize: 14, fontWeight: highlighted ? FontWeight.w600 : FontWeight.w500, ), ), ), ], ), ), ), ), ), ); if (onHoverChanged == null) return inner; return MouseRegion( onEnter: (_) => onHoverChanged!(true), onExit: (_) => onHoverChanged!(false), child: inner, ); } } class _ActionButton extends StatelessWidget { final _Action action; final bool highlighted; const _ActionButton({required this.action, required this.highlighted}); @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; final bgColor = highlighted ? cs.primary : cs.surfaceContainerHighest; final iconColor = highlighted ? cs.onPrimary : cs.onSurface; return AnimatedContainer( duration: const Duration(milliseconds: 140), curve: Curves.easeOutCubic, decoration: BoxDecoration( color: bgColor, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.32), blurRadius: highlighted ? 14 : 8, offset: const Offset(0, 3), ), ], ), child: Material( color: Colors.transparent, shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), onTap: () { Haptics.tap(); action.onTap(); }, child: Center(child: Icon(action.icon, color: iconColor, size: 24)), ), ), ); } }