Files
Qlyra/lib/frontend/widgets/message_actions_overlay.dart
T

719 lines
21 KiB
Dart

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/haptics.dart';
import 'custom_notification.dart';
enum MessageActionsInteraction { dragAndRelease, click, tap }
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 MessageActionsController controller,
required MessageActionsStyle style,
required VoidCallback onDispose,
VoidCallback? onDelete,
VoidCallback? onEdit,
VoidCallback? onReply,
VoidCallback? onForward,
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,
controller: controller,
style: style,
interaction: interaction,
onDelete: onDelete,
onEdit: onEdit,
onReply: onReply,
onForward: onForward,
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 MessageActionsController controller;
final MessageActionsStyle style;
final MessageActionsInteraction interaction;
final VoidCallback onDismiss;
final VoidCallback? onDelete;
final VoidCallback? onEdit;
final VoidCallback? onReply;
final VoidCallback? onForward;
const _MessageActionsLayer({
required this.snapshot,
required this.originRect,
required this.tapPoint,
required this.isMe,
required this.messageText,
required this.controller,
required this.style,
required this.interaction,
required this.onDismiss,
this.onDelete,
this.onEdit,
this.onReply,
this.onForward,
});
@override
State<_MessageActionsLayer> createState() => _MessageActionsLayerState();
}
class _MessageActionsLayerState extends State<_MessageActionsLayer>
with SingleTickerProviderStateMixin {
late final AnimationController _animController;
late final Animation<double> _animation;
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<Offset> _buttonCenters = const [];
List<Rect> _buttonHitRects = const [];
Rect _menuRect = Rect.zero;
bool _initialized = false;
int _hoveredIndex = -1;
bool _committedFired = false;
@override
void initState() {
super.initState();
_animController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 320),
reverseDuration: const Duration(milliseconds: 220),
);
_animation = CurvedAnimation(
parent: _animController,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_animController.forward();
}
@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) {
_showBelow = widget.tapPoint.dy < screenSize.height * 0.55;
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 = <Offset>[];
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,
),
];
}
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;
late double menuX;
late double menuY;
if (widget.interaction != MessageActionsInteraction.dragAndRelease) {
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++)
Rect.fromLTWH(
menuX,
menuY + vPad + i * itemHeight,
menuWidth,
itemHeight,
),
];
}
@override
void dispose() {
_animController.dispose();
widget.controller.removeListener(_onControllerUpdate);
widget.snapshot?.dispose();
super.dispose();
}
List<_Action> _buildActions() {
final hasText = widget.messageText != null && widget.messageText!.isNotEmpty;
return <_Action>[
if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy),
if (widget.isMe && widget.onEdit != null)
_Action(Symbols.edit, 'Изменить', _edit),
if (widget.onReply != null)
_Action(Symbols.reply, 'Ответить', _reply),
if (widget.onForward != null)
_Action(Symbols.forward, 'Переслать', _forward),
_Action(
Symbols.delete,
'Удалить',
_delete,
destructive: true,
),
];
}
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<void> _close() async {
if (!mounted || _closing) return;
_closing = true;
try {
await _animController.reverse();
} catch (_) {}
if (!mounted) return;
widget.onDismiss();
}
Future<void> _copy() async {
final text = widget.messageText;
if (text != null && text.isNotEmpty) {
await Clipboard.setData(ClipboardData(text: text));
if (!mounted) return;
showCustomNotification(context, 'Скопировано');
}
await _close();
}
Future<void> _delete() async {
final onDelete = widget.onDelete;
await _close();
onDelete?.call();
}
Future<void> _edit() async {
final onEdit = widget.onEdit;
await _close();
onEdit?.call();
}
Future<void> _reply() async {
final onReply = widget.onReply;
await _close();
onReply?.call();
}
Future<void> _forward() async {
final onForward = widget.onForward;
await _close();
onForward?.call();
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final isClick = widget.interaction == MessageActionsInteraction.click;
return AnimatedBuilder(
animation: _animation,
builder: (ctx, _) {
final t = _animation.value.clamp(0.0, 1.0);
final blurSigma = 14.0 * t;
final bubbleScale = 1.0 + 0.05 * t;
return GestureDetector(
onTap: _close,
behavior: HitTestBehavior.opaque,
child: Stack(
children: [
if (!isClick) ...[
Positioned.fill(
child: BackdropFilter(
filter: ui.ImageFilter.blur(
sigmaX: blurSigma,
sigmaY: blurSigma,
),
child: ColoredBox(
color: Colors.black.withValues(alpha: 0.22 * t),
),
),
),
if (widget.snapshot != null)
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,
width: widget.originRect.width,
height: widget.originRect.height,
fit: BoxFit.fill,
),
),
),
],
if (_effectiveStyle == MessageActionsStyle.radial) ...[
..._buildButtons(t),
_buildLabelBanner(size, t),
] else
_buildListMenu(t),
],
),
);
},
);
}
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<Widget> _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 = MediaQuery.paddingOf(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<double>(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 _ListMenuItem extends StatelessWidget {
final _Action action;
final bool highlighted;
final ValueChanged<bool>? 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),
),
),
),
);
}
}