From ed7a3b4c001bcbd199324b778bfed0b2711e8d3f Mon Sep 17 00:00:00 2001 From: klockky Date: Mon, 25 May 2026 18:15:26 +0300 Subject: [PATCH] =?UTF-8?q?=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D1=87=D0=BD?= =?UTF-8?q?=D0=BE=D0=B5=20=D0=BC=D0=B5=D0=BD=D1=8E=20=D0=B4=D0=B5=D0=B9?= =?UTF-8?q?=D1=81=D1=82=D0=B2=D0=B8=D0=B9=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BD=D0=B5=D0=B5;=20=D0=BF=D0=BA=D0=BC=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=BA=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D0=B5=D1=82=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA;=20?= =?UTF-8?q?=D1=80=D0=B0=D0=B4=D0=B8=D0=B0=D0=BB=20=D0=BD=D0=B5=20=D1=81?= =?UTF-8?q?=D1=8A=D0=B5=D0=B7=D0=B6=D0=B0=D0=B5=D1=82=20=D1=83=20=D0=BA?= =?UTF-8?q?=D1=80=D0=B0=D1=91=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/frontend/screens/chats/chat_screen.dart | 32 +++ .../widgets/message_actions_overlay.dart | 233 +++++++++++------- 2 files changed, 181 insertions(+), 84 deletions(-) diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index 96884ff..711deea 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -1756,6 +1756,37 @@ class _LongPressBubbleState extends State<_LongPressBubble> { ); } + void _onSecondaryTapDown(TapDownDetails details) { + if (_controller != null) return; + final ctx = _boundaryKey.currentContext; + if (ctx == null) return; + final renderObject = ctx.findRenderObject(); + if (renderObject is! RenderRepaintBoundary) return; + + final origin = renderObject.localToGlobal(Offset.zero); + final rect = origin & renderObject.size; + + final controller = MessageActionsController(); + _controller = controller; + + showMessageActions( + context: ctx, + originRect: rect, + tapPoint: details.globalPosition, + isMe: widget.isMe, + messageText: widget.message.text, + controller: controller, + style: MessageActionsStyle.list, + interaction: MessageActionsInteraction.click, + onDispose: () { + if (identical(_controller, controller)) { + _controller = null; + } + controller.dispose(); + }, + ); + } + @override Widget build(BuildContext context) { return Listener( @@ -1769,6 +1800,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { onLongPressMoveUpdate: (d) => _controller?.updatePointer(d.globalPosition), onLongPressEnd: (_) => _controller?.commit(), + onSecondaryTapDown: _onSecondaryTapDown, child: RepaintBoundary( key: _boundaryKey, child: widget.child, diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 67d2a07..873e0d8 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -10,6 +10,8 @@ import '../../core/config/app_message_actions_style.dart'; import '../../core/utils/haptics.dart'; import 'custom_notification.dart'; +enum MessageActionsInteraction { dragAndRelease, click } + class MessageActionsController extends ChangeNotifier { Offset? pointer; Offset? initialPointer; @@ -63,7 +65,7 @@ class MessageActionsController extends ChangeNotifier { void showMessageActions({ required BuildContext context, - required ui.Image snapshot, + ui.Image? snapshot, required Rect originRect, required Offset tapPoint, required bool isMe, @@ -71,6 +73,7 @@ void showMessageActions({ required MessageActionsController controller, required MessageActionsStyle style, required VoidCallback onDispose, + MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease, }) { final overlay = Overlay.of(context, rootOverlay: true); late OverlayEntry entry; @@ -83,6 +86,7 @@ void showMessageActions({ messageText: messageText, controller: controller, style: style, + interaction: interaction, onDismiss: () { if (entry.mounted) entry.remove(); onDispose(); @@ -93,13 +97,14 @@ void showMessageActions({ } class _MessageActionsLayer extends StatefulWidget { - final ui.Image snapshot; + final ui.Image? snapshot; final Rect originRect; final Offset tapPoint; final bool isMe; final String? messageText; final MessageActionsController controller; final MessageActionsStyle style; + final MessageActionsInteraction interaction; final VoidCallback onDismiss; const _MessageActionsLayer({ @@ -110,6 +115,7 @@ class _MessageActionsLayer extends StatefulWidget { required this.messageText, required this.controller, required this.style, + required this.interaction, required this.onDismiss, }); @@ -129,6 +135,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> static const double _hMargin = 8.0; late List<_Action> _actions; + late MessageActionsStyle _effectiveStyle; bool _showBelow = true; Offset _anchor = Offset.zero; List _buttonCenters = const []; @@ -161,46 +168,95 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> if (_initialized) return; _initialized = true; _actions = _buildActions(); + _effectiveStyle = widget.interaction == MessageActionsInteraction.click + ? MessageActionsStyle.list + : widget.style; final screenSize = MediaQuery.sizeOf(context); - if (widget.style == MessageActionsStyle.radial) { + if (_effectiveStyle == MessageActionsStyle.radial) { _computeRadialGeometry(screenSize); } else { _computeListGeometry(screenSize); } - widget.controller.addListener(_onControllerUpdate); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _onControllerUpdate(); - }); + if (widget.interaction == MessageActionsInteraction.dragAndRelease) { + widget.controller.addListener(_onControllerUpdate); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _onControllerUpdate(); + }); + } } void _computeRadialGeometry(Size screenSize) { _showBelow = widget.tapPoint.dy < screenSize.height * 0.55; - _anchor = _showBelow - ? Offset(widget.tapPoint.dx, widget.originRect.bottom + 16) - : Offset(widget.tapPoint.dx, widget.originRect.top - 16); - _buttonCenters = _computeButtonCenters(screenSize); + final anchorY = _showBelow + ? widget.originRect.bottom + 16 + : widget.originRect.top - 16; + + final n = _actions.length; + final base = _showBelow ? math.pi * 0.5 : -math.pi * 0.5; + final start = base - _arcSpan / 2; + final step = n <= 1 ? 0.0 : _arcSpan / (n - 1); + + final offsets = []; + double minDx = 0; + double maxDx = 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; + } + + final minX = _hMargin + _btnSize / 2; + final maxX = screenSize.width - _hMargin - _btnSize / 2; + + double anchorX = widget.tapPoint.dx; + if (anchorX + maxDx > maxX) anchorX = maxX - maxDx; + if (anchorX + minDx < minX) anchorX = minX - minDx; + + _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), + Rect.fromCenter( + center: c, + width: _hitRadius * 2, + height: _hitRadius * 2, + ), ]; } void _computeListGeometry(Size screenSize) { final n = _actions.length; - const menuWidth = 244.0; - const itemHeight = 48.0; - const vPad = 8.0; + const menuWidth = 220.0; + const itemHeight = 42.0; + const vPad = 6.0; final menuHeight = n * itemHeight + vPad * 2; - final spaceBelow = screenSize.height - widget.originRect.bottom - 24; - final spaceAbove = widget.originRect.top - 24; - _showBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove; - final menuY = _showBelow - ? widget.originRect.bottom + 10 - : widget.originRect.top - 10 - menuHeight; - final rawX = widget.isMe - ? widget.originRect.right - menuWidth - : widget.originRect.left; - final menuX = rawX.clamp(8.0, screenSize.width - menuWidth - 8.0).toDouble(); + late double menuX; + late double menuY; + if (widget.interaction == MessageActionsInteraction.click) { + final spaceBelow = screenSize.height - 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, screenSize.height - menuHeight - 8.0).toDouble(); + menuX = widget.tapPoint.dx + .clamp(8.0, screenSize.width - menuWidth - 8.0) + .toDouble(); + } else { + final spaceBelow = screenSize.height - widget.originRect.bottom - 24; + final spaceAbove = widget.originRect.top - 24; + _showBelow = spaceBelow >= menuHeight || spaceBelow >= spaceAbove; + menuY = _showBelow + ? widget.originRect.bottom + 10 + : widget.originRect.top - 10 - menuHeight; + 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++) @@ -217,7 +273,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> void dispose() { _animController.dispose(); widget.controller.removeListener(_onControllerUpdate); - widget.snapshot.dispose(); + widget.snapshot?.dispose(); super.dispose(); } @@ -236,26 +292,6 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> ]; } - List _computeButtonCenters(Size screenSize) { - final n = _actions.length; - if (n == 0) return const []; - final base = _showBelow ? math.pi * 0.5 : -math.pi * 0.5; - final start = base - _arcSpan / 2; - final step = n == 1 ? 0.0 : _arcSpan / (n - 1); - final minX = _hMargin + _btnSize / 2; - final maxX = screenSize.width - _hMargin - _btnSize / 2; - return [ - for (int i = 0; i < n; i++) - () { - final angle = start + step * i; - var p = _anchor + Offset(math.cos(angle), math.sin(angle)) * _radius; - if (p.dx < minX) p = Offset(minX, p.dy); - if (p.dx > maxX) p = Offset(maxX, p.dy); - return p; - }(), - ]; - } - void _onControllerUpdate() { if (!mounted) return; @@ -319,6 +355,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> @override Widget build(BuildContext context) { final size = MediaQuery.sizeOf(context); + final isClick = widget.interaction == MessageActionsInteraction.click; return AnimatedBuilder( animation: _animation, builder: (ctx, _) { @@ -331,33 +368,36 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> behavior: HitTestBehavior.opaque, child: Stack( children: [ - Positioned.fill( - child: BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: blurSigma, - sigmaY: blurSigma, - ), - child: ColoredBox( - color: Colors.black.withValues(alpha: 0.22 * t), + if (!isClick) ...[ + Positioned.fill( + child: BackdropFilter( + filter: ui.ImageFilter.blur( + sigmaX: blurSigma, + sigmaY: blurSigma, + ), + child: ColoredBox( + color: Colors.black.withValues(alpha: 0.22 * t), + ), ), ), - ), - Positioned( - left: widget.originRect.left, - top: widget.originRect.top, - width: widget.originRect.width, - height: widget.originRect.height, - child: Transform.scale( - scale: bubbleScale, - child: RawImage( - image: widget.snapshot, + if (widget.snapshot != null) + Positioned( + left: widget.originRect.left, + top: widget.originRect.top, width: widget.originRect.width, height: widget.originRect.height, - fit: BoxFit.fill, + child: Transform.scale( + scale: bubbleScale, + child: RawImage( + image: widget.snapshot, + width: widget.originRect.width, + height: widget.originRect.height, + fit: BoxFit.fill, + ), + ), ), - ), - ), - if (widget.style == MessageActionsStyle.radial) ...[ + ], + if (_effectiveStyle == MessageActionsStyle.radial) ...[ ..._buildButtons(t), _buildLabelBanner(size, t), ] else @@ -373,6 +413,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> final cs = Theme.of(context).colorScheme; final eased = Curves.easeOutCubic.transform(t); final scale = 0.88 + 0.12 * eased; + final isClick = widget.interaction == MessageActionsInteraction.click; return Positioned( left: _menuRect.left, top: _menuRect.top, @@ -382,26 +423,39 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> opacity: eased, child: Transform.scale( scale: scale, - alignment: Alignment( - widget.isMe ? 1.0 : -1.0, - _showBelow ? -1.0 : 1.0, - ), + alignment: isClick + ? 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(18), + 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: 8), + const SizedBox(height: 6), for (int i = 0; i < _actions.length; i++) _ListMenuItem( action: _actions[i], highlighted: _hoveredIndex == i, + onHoverChanged: isClick + ? (hovered) { + if (hovered) { + if (_hoveredIndex != i) { + setState(() => _hoveredIndex = i); + } + } else if (_hoveredIndex == i) { + setState(() => _hoveredIndex = -1); + } + } + : null, ), - const SizedBox(height: 8), + const SizedBox(height: 6), ], ), ), @@ -515,7 +569,12 @@ class _Action { class _ListMenuItem extends StatelessWidget { final _Action action; final bool highlighted; - const _ListMenuItem({required this.action, required this.highlighted}); + final ValueChanged? onHoverChanged; + const _ListMenuItem({ + required this.action, + required this.highlighted, + this.onHoverChanged, + }); @override Widget build(BuildContext context) { @@ -524,8 +583,8 @@ class _ListMenuItem extends StatelessWidget { final onPill = action.destructive ? cs.onError : cs.onPrimary; final restFg = action.destructive ? cs.error : cs.onSurface; final fg = highlighted ? onPill : restFg; - return SizedBox( - height: 48, + final inner = SizedBox( + height: 42, child: Material( color: Colors.transparent, child: InkWell( @@ -534,25 +593,25 @@ class _ListMenuItem extends StatelessWidget { action.onTap(); }, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + 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(20), + borderRadius: BorderRadius.circular(18), ), - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ - Icon(action.icon, color: fg, size: 22), - const SizedBox(width: 14), + Icon(action.icon, color: fg, size: 20), + const SizedBox(width: 12), Expanded( child: Text( action.label, style: TextStyle( color: fg, - fontSize: 15, + fontSize: 14, fontWeight: highlighted ? FontWeight.w600 : FontWeight.w500, ), @@ -565,6 +624,12 @@ class _ListMenuItem extends StatelessWidget { ), ), ); + if (onHoverChanged == null) return inner; + return MouseRegion( + onEnter: (_) => onHoverChanged!(true), + onExit: (_) => onHoverChanged!(false), + child: inner, + ); } }