long-press на сообщении: scale-up с blur и радиальное меню действий

This commit is contained in:
klockky
2026-05-23 04:20:59 +00:00
parent ad3885cfb3
commit f85cad55ee
2 changed files with 314 additions and 2 deletions
+73 -2
View File
@@ -1,8 +1,10 @@
import 'dart:async';
import 'dart:io' show File;
import 'dart:ui' as ui;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:komet/backend/modules/chats.dart';
import 'package:komet/backend/modules/file_uploader.dart';
@@ -19,6 +21,7 @@ import '../../../core/utils/haptics.dart';
import '../../../core/config/app_cache_extent.dart';
import '../../../models/attachment.dart';
import '../../widgets/message_bubble.dart';
import '../../widgets/message_actions_overlay.dart';
import '../../widgets/attachment_panel.dart';
class _UploadStatus {
@@ -878,16 +881,22 @@ class _ChatScreenState extends State<ChatScreen>
overrideStatus: _effectiveStatus(message),
);
final pressable = _LongPressBubble(
message: message,
isMe: isMe,
child: bubble,
);
if (message.id == _lastSentId) {
return _SentMessageAnimation(
key: ValueKey('anim_${message.id}'),
onComplete: () {
if (mounted) setState(() => _lastSentId = null);
},
child: bubble,
child: pressable,
);
}
return bubble;
return pressable;
},
),
),
@@ -1678,6 +1687,68 @@ IconData _iconForFilename(String? name) {
}
}
class _LongPressBubble extends StatefulWidget {
final Widget child;
final CachedMessage message;
final bool isMe;
const _LongPressBubble({
required this.child,
required this.message,
required this.isMe,
});
@override
State<_LongPressBubble> createState() => _LongPressBubbleState();
}
class _LongPressBubbleState extends State<_LongPressBubble> {
final GlobalKey _boundaryKey = GlobalKey();
Offset _tapPoint = Offset.zero;
void _onLongPressStart(LongPressStartDetails details) {
_tapPoint = details.globalPosition;
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 dpr = MediaQuery.of(ctx).devicePixelRatio.clamp(1.0, 2.0);
final ui.Image snapshot;
try {
snapshot = renderObject.toImageSync(pixelRatio: dpr);
} catch (_) {
return;
}
Haptics.medium();
Navigator.of(ctx).push(
MessageActionsRoute(
snapshot: snapshot,
originRect: rect,
tapPoint: _tapPoint,
isMe: widget.isMe,
messageText: widget.message.text,
),
);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.deferToChild,
onLongPressStart: _onLongPressStart,
child: RepaintBoundary(
key: _boundaryKey,
child: widget.child,
),
);
}
}
class _SentMessageAnimation extends StatefulWidget {
final Widget child;
final VoidCallback onComplete;
@@ -0,0 +1,241 @@
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../core/utils/haptics.dart';
import 'custom_notification.dart';
class MessageActionsRoute extends PageRouteBuilder<void> {
MessageActionsRoute({
required ui.Image snapshot,
required Rect originRect,
required Offset tapPoint,
required bool isMe,
required String? messageText,
}) : super(
opaque: false,
barrierColor: Colors.transparent,
transitionDuration: const Duration(milliseconds: 320),
reverseTransitionDuration: const Duration(milliseconds: 220),
pageBuilder: (ctx, anim, secondaryAnim) {
return _MessageActionsLayer(
snapshot: snapshot,
originRect: originRect,
tapPoint: tapPoint,
isMe: isMe,
messageText: messageText,
animation: CurvedAnimation(
parent: anim,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
),
);
},
transitionsBuilder: (_, __, ___, child) => child,
);
}
class _MessageActionsLayer extends StatefulWidget {
final ui.Image snapshot;
final Rect originRect;
final Offset tapPoint;
final bool isMe;
final String? messageText;
final Animation<double> animation;
const _MessageActionsLayer({
required this.snapshot,
required this.originRect,
required this.tapPoint,
required this.isMe,
required this.messageText,
required this.animation,
});
@override
State<_MessageActionsLayer> createState() => _MessageActionsLayerState();
}
class _MessageActionsLayerState extends State<_MessageActionsLayer> {
@override
void dispose() {
widget.snapshot.dispose();
super.dispose();
}
Future<void> _close() async {
if (!mounted) return;
Navigator.of(context).maybePop();
}
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> _stub(String name) async {
if (!mounted) return;
showCustomNotification(context, '$name — пока в разработке');
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, _) {
final t = widget.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: [
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,
width: widget.originRect.width,
height: widget.originRect.height,
fit: BoxFit.fill,
),
),
),
..._buildRadialMenu(actions, anchor, showBelow, t, size),
],
),
);
},
);
}
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;
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 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);
return Positioned(
left: pos.dx - btnSize / 2,
top: pos.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]),
),
),
);
},
),
];
}
}
class _Action {
final IconData icon;
final String label;
final VoidCallback onTap;
const _Action(this.icon, this.label, this.onTap);
}
class _ActionButton extends StatelessWidget {
final _Action action;
const _ActionButton({required this.action});
@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,
child: Center(
child: Icon(action.icon, color: cs.onSurface, size: 24),
),
),
),
);
}
}