drag-to-select на радиальном меню: подсветка под пальцем, лейбл, тактильный клик
This commit is contained in:
@@ -1704,10 +1704,16 @@ class _LongPressBubble extends StatefulWidget {
|
||||
|
||||
class _LongPressBubbleState extends State<_LongPressBubble> {
|
||||
final GlobalKey _boundaryKey = GlobalKey();
|
||||
Offset _tapPoint = Offset.zero;
|
||||
MessageActionsController? _controller;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.commit();
|
||||
_controller = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onLongPressStart(LongPressStartDetails details) {
|
||||
_tapPoint = details.globalPosition;
|
||||
final ctx = _boundaryKey.currentContext;
|
||||
if (ctx == null) return;
|
||||
final renderObject = ctx.findRenderObject();
|
||||
@@ -1726,15 +1732,35 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
|
||||
}
|
||||
|
||||
Haptics.medium();
|
||||
Navigator.of(ctx).push(
|
||||
|
||||
final controller = MessageActionsController();
|
||||
_controller = controller;
|
||||
|
||||
Navigator.of(ctx)
|
||||
.push(
|
||||
MessageActionsRoute(
|
||||
snapshot: snapshot,
|
||||
originRect: rect,
|
||||
tapPoint: _tapPoint,
|
||||
tapPoint: details.globalPosition,
|
||||
isMe: widget.isMe,
|
||||
messageText: widget.message.text,
|
||||
controller: controller,
|
||||
),
|
||||
);
|
||||
)
|
||||
.whenComplete(() {
|
||||
if (identical(_controller, controller)) {
|
||||
_controller = null;
|
||||
}
|
||||
controller.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
void _onLongPressMoveUpdate(LongPressMoveUpdateDetails details) {
|
||||
_controller?.updatePointer(details.globalPosition);
|
||||
}
|
||||
|
||||
void _onLongPressEnd(LongPressEndDetails details) {
|
||||
_controller?.commit();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1742,6 +1768,8 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.deferToChild,
|
||||
onLongPressStart: _onLongPressStart,
|
||||
onLongPressMoveUpdate: _onLongPressMoveUpdate,
|
||||
onLongPressEnd: _onLongPressEnd,
|
||||
child: RepaintBoundary(
|
||||
key: _boundaryKey,
|
||||
child: widget.child,
|
||||
|
||||
@@ -8,6 +8,28 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../core/utils/haptics.dart';
|
||||
import 'custom_notification.dart';
|
||||
|
||||
class MessageActionsController extends ChangeNotifier {
|
||||
Offset? pointer;
|
||||
Offset? initialPointer;
|
||||
bool committed = false;
|
||||
bool movedSignificantly = false;
|
||||
|
||||
void updatePointer(Offset p) {
|
||||
initialPointer ??= p;
|
||||
pointer = p;
|
||||
if (!movedSignificantly && (p - initialPointer!).distance > 18) {
|
||||
movedSignificantly = true;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void commit() {
|
||||
if (committed) return;
|
||||
committed = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
class MessageActionsRoute extends PageRouteBuilder<void> {
|
||||
MessageActionsRoute({
|
||||
required ui.Image snapshot,
|
||||
@@ -15,6 +37,7 @@ class MessageActionsRoute extends PageRouteBuilder<void> {
|
||||
required Offset tapPoint,
|
||||
required bool isMe,
|
||||
required String? messageText,
|
||||
required MessageActionsController controller,
|
||||
}) : super(
|
||||
opaque: false,
|
||||
barrierColor: Colors.transparent,
|
||||
@@ -27,6 +50,7 @@ class MessageActionsRoute extends PageRouteBuilder<void> {
|
||||
tapPoint: tapPoint,
|
||||
isMe: isMe,
|
||||
messageText: messageText,
|
||||
controller: controller,
|
||||
animation: CurvedAnimation(
|
||||
parent: anim,
|
||||
curve: Curves.easeOutCubic,
|
||||
@@ -44,6 +68,7 @@ class _MessageActionsLayer extends StatefulWidget {
|
||||
final Offset tapPoint;
|
||||
final bool isMe;
|
||||
final String? messageText;
|
||||
final MessageActionsController controller;
|
||||
final Animation<double> animation;
|
||||
|
||||
const _MessageActionsLayer({
|
||||
@@ -52,6 +77,7 @@ class _MessageActionsLayer extends StatefulWidget {
|
||||
required this.tapPoint,
|
||||
required this.isMe,
|
||||
required this.messageText,
|
||||
required this.controller,
|
||||
required this.animation,
|
||||
});
|
||||
|
||||
@@ -60,12 +86,112 @@ class _MessageActionsLayer extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _MessageActionsLayerState extends State<_MessageActionsLayer> {
|
||||
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;
|
||||
bool _showBelow = true;
|
||||
Offset _anchor = Offset.zero;
|
||||
List<Offset> _buttonCenters = const [];
|
||||
bool _initialized = false;
|
||||
|
||||
int _hoveredIndex = -1;
|
||||
bool _tapMode = false;
|
||||
bool _committedFired = false;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
_actions = _buildActions();
|
||||
final screenSize = MediaQuery.sizeOf(context);
|
||||
_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);
|
||||
widget.controller.addListener(_onControllerUpdate);
|
||||
}
|
||||
|
||||
@override
|
||||
void 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),
|
||||
_Action(Symbols.reply, 'Ответить', () => _stub('Ответ')),
|
||||
_Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')),
|
||||
_Action(Symbols.delete, 'Удалить', () => _stub('Удаление')),
|
||||
];
|
||||
}
|
||||
|
||||
List<Offset> _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 || _tapMode) 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 < _buttonCenters.length; i++) {
|
||||
if ((_buttonCenters[i] - p).distance <= _hitRadius) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
void _onCommit() {
|
||||
if (_hoveredIndex != -1) {
|
||||
Haptics.medium();
|
||||
_actions[_hoveredIndex].onTap();
|
||||
} else if (widget.controller.movedSignificantly) {
|
||||
_close();
|
||||
} else {
|
||||
setState(() => _tapMode = true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _close() async {
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).maybePop();
|
||||
@@ -87,25 +213,9 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> {
|
||||
await _close();
|
||||
}
|
||||
|
||||
List<_Action> _buildActions() {
|
||||
final hasText = widget.messageText != null && widget.messageText!.isNotEmpty;
|
||||
return <_Action>[
|
||||
if (hasText) _Action(Symbols.content_copy, 'Копировать', _copy),
|
||||
_Action(Symbols.reply, 'Ответить', () => _stub('Ответ')),
|
||||
_Action(Symbols.forward, 'Переслать', () => _stub('Пересылка')),
|
||||
_Action(Symbols.delete, 'Удалить', () => _stub('Удаление')),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final actions = _buildActions();
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final showBelow = widget.tapPoint.dy < size.height * 0.55;
|
||||
final anchor = showBelow
|
||||
? Offset(widget.tapPoint.dx, widget.originRect.bottom + 16)
|
||||
: Offset(widget.tapPoint.dx, widget.originRect.top - 16);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: widget.animation,
|
||||
builder: (ctx, _) {
|
||||
@@ -114,7 +224,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> {
|
||||
final bubbleScale = 1.0 + 0.05 * t;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: _close,
|
||||
onTap: _tapMode ? _close : null,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Stack(
|
||||
children: [
|
||||
@@ -144,7 +254,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> {
|
||||
),
|
||||
),
|
||||
),
|
||||
..._buildRadialMenu(actions, anchor, showBelow, t, size),
|
||||
..._buildButtons(t),
|
||||
_buildLabelBanner(size, t),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -152,49 +263,41 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> {
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildRadialMenu(
|
||||
List<_Action> actions,
|
||||
Offset anchor,
|
||||
bool below,
|
||||
double t,
|
||||
Size screenSize,
|
||||
) {
|
||||
final n = actions.length;
|
||||
if (n == 0) return const [];
|
||||
const radius = 92.0;
|
||||
const arcSpan = math.pi * 0.62;
|
||||
final baseAngle = below ? math.pi * 0.5 : -math.pi * 0.5;
|
||||
final startAngle = baseAngle - arcSpan / 2;
|
||||
final step = n == 1 ? 0.0 : arcSpan / (n - 1);
|
||||
const btnSize = 52.0;
|
||||
const margin = 8.0;
|
||||
|
||||
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 localT = ((t - delay) / (1.0 - delay)).clamp(0.0, 1.0);
|
||||
final eased = Curves.easeOutBack.transform(localT);
|
||||
final angle = startAngle + step * i;
|
||||
final rOffset =
|
||||
Offset(math.cos(angle), math.sin(angle)) * radius * eased;
|
||||
var pos = anchor + rOffset;
|
||||
final minX = margin + btnSize / 2;
|
||||
final maxX = screenSize.width - margin - btnSize / 2;
|
||||
if (pos.dx < minX) pos = Offset(minX, pos.dy);
|
||||
if (pos.dx > maxX) pos = Offset(maxX, pos.dy);
|
||||
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: pos.dx - btnSize / 2,
|
||||
top: pos.dy - btnSize / 2,
|
||||
width: btnSize,
|
||||
height: btnSize,
|
||||
left: centerAtT.dx - _btnSize / 2,
|
||||
top: centerAtT.dy - _btnSize / 2,
|
||||
width: _btnSize,
|
||||
height: _btnSize,
|
||||
child: Opacity(
|
||||
opacity: localT,
|
||||
child: Transform.scale(
|
||||
scale: 0.4 + 0.6 * eased,
|
||||
child: _ActionButton(action: actions[i]),
|
||||
scale: entryScale,
|
||||
child: AnimatedScale(
|
||||
scale: hoverScale,
|
||||
duration: const Duration(milliseconds: 140),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: _ActionButton(
|
||||
action: _actions[i],
|
||||
highlighted: isHovered,
|
||||
tapEnabled: _tapMode,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -202,6 +305,52 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> {
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -213,26 +362,46 @@ class _Action {
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
final _Action action;
|
||||
const _ActionButton({required this.action});
|
||||
final bool highlighted;
|
||||
final bool tapEnabled;
|
||||
const _ActionButton({
|
||||
required this.action,
|
||||
required this.highlighted,
|
||||
required this.tapEnabled,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
return Material(
|
||||
color: cs.surfaceContainerHighest,
|
||||
shape: const CircleBorder(),
|
||||
elevation: 6,
|
||||
shadowColor: Colors.black.withValues(alpha: 0.4),
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () {
|
||||
Haptics.tap();
|
||||
action.onTap();
|
||||
},
|
||||
child: Tooltip(
|
||||
message: action.label,
|
||||
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: tapEnabled
|
||||
? () {
|
||||
Haptics.tap();
|
||||
action.onTap();
|
||||
}
|
||||
: null,
|
||||
child: Center(
|
||||
child: Icon(action.icon, color: cs.onSurface, size: 24),
|
||||
child: Icon(action.icon, color: iconColor, size: 24),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user