From 4ee84745bfe1dca5cfe935b02a5f084d386ab1bf Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 12 Jun 2026 08:02:31 +0000 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20(=D1=83=20=D1=81=D0=B5=D0=B1=D1=8F/=D1=83=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=D1=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/messages.dart | 22 ++ lib/frontend/screens/chats/chat_screen.dart | 225 +++++++++++++++++- .../widgets/message_actions_overlay.dart | 12 +- 3 files changed, 246 insertions(+), 13 deletions(-) diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart index 1d8bab5..12ced01 100644 --- a/lib/backend/modules/messages.dart +++ b/lib/backend/modules/messages.dart @@ -467,6 +467,28 @@ class MessagesModule { return ''; } + Future deleteMessages( + int chatId, + List messageIds, { + bool forEveryone = false, + }) async { + final ids = messageIds + .map((id) => int.tryParse(id)) + .whereType() + .toList(); + if (ids.isEmpty) return false; + + final payload = { + 'messageIds': ids, + 'chatId': chatId, + 'forMe': !forEveryone, + 'itemType': 'REGULAR', + }; + + final response = await _api.sendRequest(Opcode.msgDelete, payload); + return response.isOk; + } + Future requestTranscription( int chatId, int messageId, diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index ebf09ec..7a19311 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -156,6 +156,7 @@ class _ChatScreenState extends State with TickerProviderStateMixin { bool _historyKickedOff = false; List _messages = []; final ValueNotifier _messagesRev = ValueNotifier(0); + final Set _deletingIds = {}; List? _combinedItemsCache; int? _combinedItemsKey; bool _floatingDateScheduled = false; @@ -622,6 +623,123 @@ class _ChatScreenState extends State with TickerProviderStateMixin { _messagesRev.value++; } + Future _confirmDeleteMessage(CachedMessage message, bool isMe) async { + final isLocalOnly = message.id.startsWith('temp_'); + final canForEveryone = isMe && !isLocalOnly; + + if (isLocalOnly) { + _startDeleteAnimation(message.id); + return; + } + + final forEveryone = await _showDeleteMessageDialog(canForEveryone); + if (forEveryone == null || !mounted) return; + + final ok = await messagesModule.deleteMessages( + widget.chatId, + [message.id], + forEveryone: forEveryone, + ); + if (!mounted) return; + if (!ok) { + Haptics.error(); + showCustomNotification(context, 'Не удалось удалить сообщение'); + return; + } + _startDeleteAnimation(message.id); + } + + void _startDeleteAnimation(String messageId) { + if (!_deletingIds.add(messageId)) return; + Haptics.tap(); + _bumpMessages(); + } + + Future _finalizeDelete(String messageId) async { + if (!mounted) return; + _deletingIds.remove(messageId); + final idx = _messages.indexWhere((m) => m.id == messageId); + if (idx != -1) { + _messages.removeAt(idx); + _reactionNotifiers.remove(messageId)?.dispose(); + } + _bumpMessages(); + try { + await AppDatabase.deleteMessage(_myId, widget.chatId, messageId); + } catch (_) {} + } + + Future _showDeleteMessageDialog(bool canForEveryone) { + final cs = Theme.of(context).colorScheme; + var alsoForEveryone = canForEveryone; + return showDialog( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setLocalState) { + return AlertDialog( + backgroundColor: cs.surfaceContainerHigh, + title: const Text('Удалить сообщение'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Вы точно хотите удалить это сообщение?', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 15), + ), + if (canForEveryone) ...[ + const SizedBox(height: 16), + InkWell( + onTap: () => setLocalState( + () => alsoForEveryone = !alsoForEveryone, + ), + borderRadius: BorderRadius.circular(8), + child: Row( + children: [ + Checkbox( + value: alsoForEveryone, + onChanged: (v) => setLocalState( + () => alsoForEveryone = v ?? false, + ), + ), + Expanded( + child: Text( + 'Также удалить для ${widget.name}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: cs.onSurface, + fontSize: 15, + ), + ), + ), + ], + ), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () => Navigator.pop( + ctx, + canForEveryone && alsoForEveryone, + ), + child: Text('Удалить', style: TextStyle(color: cs.error)), + ), + ], + ); + }, + ); + }, + ); + } + void _onMessageEvent(MessageEvent event) { if (!mounted) return; switch (event) { @@ -1539,21 +1657,31 @@ class _ChatScreenState extends State with TickerProviderStateMixin { final pressable = _LongPressBubble( message: message, isMe: isMe, + onDelete: () => _confirmDeleteMessage(message, isMe), child: bubble, ); - final Widget child = message.id == _lastSentId - ? _SentMessageAnimation( - key: ValueKey('anim_${message.id}'), - onComplete: () { - if (mounted) { - _lastSentId = null; - _bumpMessages(); - } - }, - child: pressable, - ) - : pressable; + final Widget child; + if (_deletingIds.contains(message.id)) { + child = _DeletingMessageAnimation( + key: ValueKey('del_${message.id}'), + onComplete: () => _finalizeDelete(message.id), + child: IgnorePointer(child: pressable), + ); + } else if (message.id == _lastSentId) { + child = _SentMessageAnimation( + key: ValueKey('anim_${message.id}'), + onComplete: () { + if (mounted) { + _lastSentId = null; + _bumpMessages(); + } + }, + child: pressable, + ); + } else { + child = pressable; + } final builtItem = RepaintBoundary( key: ValueKey('msg_${message.id}'), @@ -2699,11 +2827,13 @@ class _LongPressBubble extends StatefulWidget { final Widget child; final CachedMessage message; final bool isMe; + final VoidCallback onDelete; const _LongPressBubble({ required this.child, required this.message, required this.isMe, + required this.onDelete, }); @override @@ -2754,6 +2884,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { messageText: widget.message.text, controller: controller, style: AppMessageActionsStyle.current.value, + onDelete: widget.onDelete, onDispose: () { if (identical(_controller, controller)) { _controller = null; @@ -2785,6 +2916,7 @@ class _LongPressBubbleState extends State<_LongPressBubble> { controller: controller, style: MessageActionsStyle.list, interaction: MessageActionsInteraction.click, + onDelete: widget.onDelete, onDispose: () { if (identical(_controller, controller)) { _controller = null; @@ -2814,6 +2946,75 @@ class _LongPressBubbleState extends State<_LongPressBubble> { } } +class _DeletingMessageAnimation extends StatefulWidget { + final Widget child; + final VoidCallback onComplete; + + const _DeletingMessageAnimation({ + super.key, + required this.child, + required this.onComplete, + }); + + @override + State<_DeletingMessageAnimation> createState() => + _DeletingMessageAnimationState(); +} + +class _DeletingMessageAnimationState extends State<_DeletingMessageAnimation> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl; + late final Animation _opacity; + late final Animation _scale; + late final Animation _collapse; + bool _fired = false; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + ); + _opacity = Tween(begin: 1, end: 0).animate( + CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6)), + ); + _scale = Tween(begin: 1, end: 0.82).animate( + CurvedAnimation(parent: _ctrl, curve: const Interval(0.0, 0.6)), + ); + _collapse = Tween(begin: 1, end: 0).animate( + CurvedAnimation(parent: _ctrl, curve: const Interval(0.35, 1.0, curve: Curves.easeInOut)), + ); + _ctrl.forward().whenComplete(() { + if (_fired) return; + _fired = true; + widget.onComplete(); + }); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizeTransition( + sizeFactor: _collapse, + alignment: Alignment.center, + child: FadeTransition( + opacity: _opacity, + child: ScaleTransition( + scale: _scale, + alignment: Alignment.center, + child: widget.child, + ), + ), + ); + } +} + class _SentMessageAnimation extends StatefulWidget { final Widget child; final VoidCallback onComplete; diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 873e0d8..d22a7c0 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -73,6 +73,7 @@ void showMessageActions({ required MessageActionsController controller, required MessageActionsStyle style, required VoidCallback onDispose, + VoidCallback? onDelete, MessageActionsInteraction interaction = MessageActionsInteraction.dragAndRelease, }) { final overlay = Overlay.of(context, rootOverlay: true); @@ -87,6 +88,7 @@ void showMessageActions({ controller: controller, style: style, interaction: interaction, + onDelete: onDelete, onDismiss: () { if (entry.mounted) entry.remove(); onDispose(); @@ -106,6 +108,7 @@ class _MessageActionsLayer extends StatefulWidget { final MessageActionsStyle style; final MessageActionsInteraction interaction; final VoidCallback onDismiss; + final VoidCallback? onDelete; const _MessageActionsLayer({ required this.snapshot, @@ -117,6 +120,7 @@ class _MessageActionsLayer extends StatefulWidget { required this.style, required this.interaction, required this.onDismiss, + this.onDelete, }); @override @@ -286,7 +290,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> _Action( Symbols.delete, 'Удалить', - () => _stub('Удаление'), + _delete, destructive: true, ), ]; @@ -346,6 +350,12 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> await _close(); } + Future _delete() async { + final onDelete = widget.onDelete; + await _close(); + onDelete?.call(); + } + Future _stub(String name) async { if (!mounted) return; showCustomNotification(context, '$name — пока в разработке');