feat(chat): выделение сообщений

This commit is contained in:
Jganenokk
2026-06-22 17:48:17 +07:00
parent 31dc6b619c
commit a902a587ba
3 changed files with 233 additions and 84 deletions
+225 -74
View File
@@ -191,6 +191,11 @@ class _ChatScreenState extends State<ChatScreen>
String? _lastMarkedId; String? _lastMarkedId;
final ValueNotifier<int> _otherUnread = ValueNotifier(0); final ValueNotifier<int> _otherUnread = ValueNotifier(0);
final ValueNotifier<Set<String>> _selectedIds = ValueNotifier(const {});
late final AnimationController _selectionAnim;
bool get _selectionMode => _selectedIds.value.isNotEmpty;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -213,6 +218,11 @@ class _ChatScreenState extends State<ChatScreen>
vsync: this, vsync: this,
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
); );
_selectionAnim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
reverseDuration: const Duration(milliseconds: 200),
);
AppCommands.current.addListener(_updateCommandPanel); AppCommands.current.addListener(_updateCommandPanel);
_pushSub = api.pushStream _pushSub = api.pushStream
.where( .where(
@@ -596,6 +606,8 @@ class _ChatScreenState extends State<ChatScreen>
_attachAnim.dispose(); _attachAnim.dispose();
AppCommands.current.removeListener(_updateCommandPanel); AppCommands.current.removeListener(_updateCommandPanel);
_commandAnim.dispose(); _commandAnim.dispose();
_selectionAnim.dispose();
_selectedIds.dispose();
_commandMatches.dispose(); _commandMatches.dispose();
_messageController.dispose(); _messageController.dispose();
_messageFocusNode.dispose(); _messageFocusNode.dispose();
@@ -868,6 +880,38 @@ class _ChatScreenState extends State<ChatScreen>
_messagesRev.value++; _messagesRev.value++;
} }
void _enterSelection(CachedMessage message) {
if (message.isControl) return;
Haptics.medium();
if (_selectedIds.value.contains(message.id)) return;
_selectedIds.value = {..._selectedIds.value, message.id};
_syncSelectionAnim();
}
void _toggleSelection(CachedMessage message) {
if (message.isControl) return;
final next = Set<String>.from(_selectedIds.value);
if (!next.remove(message.id)) next.add(message.id);
Haptics.selection();
_selectedIds.value = next;
_syncSelectionAnim();
}
void _clearSelection() {
if (_selectedIds.value.isEmpty) return;
_selectedIds.value = const {};
_syncSelectionAnim();
}
void _syncSelectionAnim() {
if (_selectedIds.value.isEmpty) {
_selectionAnim.reverse();
} else if (_selectionAnim.status != AnimationStatus.forward &&
_selectionAnim.value < 1) {
_selectionAnim.forward();
}
}
bool _canEditMessage(CachedMessage message) { bool _canEditMessage(CachedMessage message) {
if (message.senderId != _myId) return false; if (message.senderId != _myId) return false;
if (message.id.startsWith('temp_')) return false; if (message.id.startsWith('temp_')) return false;
@@ -2068,7 +2112,16 @@ class _ChatScreenState extends State<ChatScreen>
final bottomInset = _keyboardReserve > 0 final bottomInset = _keyboardReserve > 0
? math.max(mq.viewInsets.bottom, _keyboardReserve) ? math.max(mq.viewInsets.bottom, _keyboardReserve)
: mq.viewInsets.bottom; : mq.viewInsets.bottom;
return MediaQuery( return ValueListenableBuilder<Set<String>>(
valueListenable: _selectedIds,
builder: (context, selected, child) => PopScope(
canPop: selected.isEmpty,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _clearSelection();
},
child: child!,
),
child: MediaQuery(
data: mq.copyWith( data: mq.copyWith(
viewInsets: mq.viewInsets.copyWith(bottom: bottomInset), viewInsets: mq.viewInsets.copyWith(bottom: bottomInset),
), ),
@@ -2288,42 +2341,68 @@ class _ChatScreenState extends State<ChatScreen>
), ),
), ),
AnimatedBuilder( AnimatedBuilder(
animation: _attachAnim, animation: _selectionAnim,
builder: (context, _) { builder: (context, child) {
if (_attachAnim.value == 0) final t = Curves.easeOut.transform(
return const SizedBox.shrink(); _selectionAnim.value.clamp(0.0, 1.0),
final curve = );
_attachAnim.status == AnimationStatus.reverse if (t == 0) return child!;
? Curves.easeIn if (t == 1) return const SizedBox.shrink();
: Curves.easeOut; return ClipRect(
final t = curve.transform(_attachAnim.value); child: Align(
return Padding( alignment: Alignment.topCenter,
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), heightFactor: 1 - t,
child: ClipRect( child: Transform.translate(
child: Align( offset: Offset(0, 48 * t),
alignment: Alignment.bottomCenter, child: Opacity(opacity: 1 - t, child: child),
heightFactor: t,
child: Opacity(
opacity: t,
child: AttachmentPanel(
onClose: () =>
_showAttachmentPanel.value = false,
onPickFile: _pickAndUploadFile,
onSendById: _sendFileById,
),
),
), ),
), ),
); );
}, },
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _attachAnim,
builder: (context, _) {
if (_attachAnim.value == 0)
return const SizedBox.shrink();
final curve =
_attachAnim.status == AnimationStatus.reverse
? Curves.easeIn
: Curves.easeOut;
final t = curve.transform(_attachAnim.value);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: ClipRect(
child: Align(
alignment: Alignment.bottomCenter,
heightFactor: t,
child: Opacity(
opacity: t,
child: AttachmentPanel(
onClose: () =>
_showAttachmentPanel.value = false,
onPickFile: _pickAndUploadFile,
onSendById: _sendFileById,
),
),
),
),
);
},
),
_buildInputArea(context),
],
),
), ),
_buildInputArea(context),
], ],
), ),
), ),
), ),
), ),
), ),
),
); );
} }
@@ -2395,9 +2474,14 @@ class _ChatScreenState extends State<ChatScreen>
uploadProgress: _photoProgressFor(message), uploadProgress: _photoProgressFor(message),
); );
final pressable = _LongPressBubble( final pressable = _SelectableMessageRow(
message: message, message: message,
isMe: isMe, isMe: isMe,
selectedIds: _selectedIds,
selectionAnim: _selectionAnim,
isSelectionActive: () => _selectionMode,
onToggleSelection: () => _toggleSelection(message),
onEnterSelection: () => _enterSelection(message),
onDelete: () => _confirmDeleteMessage(message, isMe), onDelete: () => _confirmDeleteMessage(message, isMe),
onEdit: _canEditMessage(message) onEdit: _canEditMessage(message)
? () => _startEditMessage(message) ? () => _startEditMessage(message)
@@ -3784,37 +3868,42 @@ IconData _iconForFilename(String? name) {
} }
} }
class _LongPressBubble extends StatefulWidget { class _SelectableMessageRow extends StatefulWidget {
final Widget child; final Widget child;
final CachedMessage message; final CachedMessage message;
final bool isMe; final bool isMe;
final ValueListenable<Set<String>> selectedIds;
final Animation<double> selectionAnim;
final bool Function() isSelectionActive;
final VoidCallback onToggleSelection;
final VoidCallback onEnterSelection;
final VoidCallback onDelete; final VoidCallback onDelete;
final VoidCallback? onEdit; final VoidCallback? onEdit;
const _LongPressBubble({ const _SelectableMessageRow({
required this.child, required this.child,
required this.message, required this.message,
required this.isMe, required this.isMe,
required this.selectedIds,
required this.selectionAnim,
required this.isSelectionActive,
required this.onToggleSelection,
required this.onEnterSelection,
required this.onDelete, required this.onDelete,
this.onEdit, this.onEdit,
}); });
@override @override
State<_LongPressBubble> createState() => _LongPressBubbleState(); State<_SelectableMessageRow> createState() => _SelectableMessageRowState();
} }
class _LongPressBubbleState extends State<_LongPressBubble> { class _SelectableMessageRowState extends State<_SelectableMessageRow> {
static const double _gutterWidth = 40;
final GlobalKey _boundaryKey = GlobalKey(); final GlobalKey _boundaryKey = GlobalKey();
MessageActionsController? _controller; Offset? _lastTapDown;
@override void _openMenu() {
void dispose() {
_controller?.commit();
_controller = null;
super.dispose();
}
void _onLongPressStart(LongPressStartDetails details) {
final ctx = _boundaryKey.currentContext; final ctx = _boundaryKey.currentContext;
if (ctx == null) return; if (ctx == null) return;
final renderObject = ctx.findRenderObject(); final renderObject = ctx.findRenderObject();
@@ -3832,34 +3921,26 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
return; return;
} }
Haptics.medium(); Haptics.tap();
final controller = MessageActionsController(); final controller = MessageActionsController();
controller.attach(details.globalPosition);
_controller = controller;
showMessageActions( showMessageActions(
context: ctx, context: ctx,
snapshot: snapshot, snapshot: snapshot,
originRect: rect, originRect: rect,
tapPoint: details.globalPosition, tapPoint: _lastTapDown ?? rect.center,
isMe: widget.isMe, isMe: widget.isMe,
messageText: widget.message.text, messageText: widget.message.text,
controller: controller, controller: controller,
style: AppMessageActionsStyle.current.value, style: AppMessageActionsStyle.current.value,
interaction: MessageActionsInteraction.tap,
onDelete: widget.onDelete, onDelete: widget.onDelete,
onEdit: widget.onEdit, onEdit: widget.onEdit,
onDispose: () { onDispose: controller.dispose,
if (identical(_controller, controller)) {
_controller = null;
}
controller.dispose();
},
); );
} }
void _onSecondaryTapDown(TapDownDetails details) { void _onSecondaryTapDown(TapDownDetails details) {
if (_controller != null) return;
final ctx = _boundaryKey.currentContext; final ctx = _boundaryKey.currentContext;
if (ctx == null) return; if (ctx == null) return;
final renderObject = ctx.findRenderObject(); final renderObject = ctx.findRenderObject();
@@ -3869,8 +3950,6 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
final rect = origin & renderObject.size; final rect = origin & renderObject.size;
final controller = MessageActionsController(); final controller = MessageActionsController();
_controller = controller;
showMessageActions( showMessageActions(
context: ctx, context: ctx,
originRect: rect, originRect: rect,
@@ -3882,31 +3961,103 @@ class _LongPressBubbleState extends State<_LongPressBubble> {
interaction: MessageActionsInteraction.click, interaction: MessageActionsInteraction.click,
onDelete: widget.onDelete, onDelete: widget.onDelete,
onEdit: widget.onEdit, onEdit: widget.onEdit,
onDispose: () { onDispose: controller.dispose,
if (identical(_controller, controller)) { );
_controller = null; }
}
controller.dispose(); void _handleTap() {
}, if (widget.isSelectionActive()) {
widget.onToggleSelection();
} else {
_openMenu();
}
}
void _handleLongPress() {
if (widget.isSelectionActive()) {
widget.onToggleSelection();
} else {
widget.onEnterSelection();
}
}
Widget _buildCheckCircle(bool selected, ColorScheme cs) {
return AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOut,
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: selected ? cs.primary : Colors.transparent,
border: Border.all(
color: selected
? cs.primary
: cs.onSurfaceVariant.withValues(alpha: 0.6),
width: 2,
),
),
child: selected
? Icon(Symbols.check, size: 16, weight: 700, color: cs.onPrimary)
: null,
); );
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Listener( if (widget.message.isControl) return widget.child;
behavior: HitTestBehavior.deferToChild, final cs = Theme.of(context).colorScheme;
onPointerMove: (event) => _controller?.updatePointer(event.position),
onPointerUp: (event) => _controller?.commit(), return AnimatedBuilder(
onPointerCancel: (event) => _controller?.commit(), animation: widget.selectionAnim,
child: GestureDetector( builder: (context, _) {
behavior: HitTestBehavior.deferToChild, final t = Curves.easeOut.transform(
onLongPressStart: _onLongPressStart, widget.selectionAnim.value.clamp(0.0, 1.0),
onLongPressMoveUpdate: (d) => );
_controller?.updatePointer(d.globalPosition), return ValueListenableBuilder<Set<String>>(
onLongPressEnd: (_) => _controller?.commit(), valueListenable: widget.selectedIds,
onSecondaryTapDown: _onSecondaryTapDown, builder: (context, selected, _) {
child: RepaintBoundary(key: _boundaryKey, child: widget.child), final isSelected = selected.contains(widget.message.id);
), final active = selected.isNotEmpty;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (d) => _lastTapDown = d.globalPosition,
onTap: _handleTap,
onLongPress: _handleLongPress,
onSecondaryTapDown: active ? null : _onSecondaryTapDown,
child: ColoredBox(
color: isSelected
? cs.primary.withValues(alpha: 0.10)
: Colors.transparent,
child: Stack(
children: [
RepaintBoundary(
key: _boundaryKey,
child: IgnorePointer(
ignoring: active,
child: Padding(
padding: EdgeInsets.only(left: _gutterWidth * t),
child: widget.child,
),
),
),
if (t > 0)
Positioned(
left: 8,
bottom: 10,
child: Opacity(
opacity: t,
child: _buildCheckCircle(isSelected, cs),
),
),
],
),
),
);
},
);
},
); );
} }
} }
@@ -10,7 +10,7 @@ import '../../core/config/app_message_actions_style.dart';
import '../../core/utils/haptics.dart'; import '../../core/utils/haptics.dart';
import 'custom_notification.dart'; import 'custom_notification.dart';
enum MessageActionsInteraction { dragAndRelease, click } enum MessageActionsInteraction { dragAndRelease, click, tap }
class MessageActionsController extends ChangeNotifier { class MessageActionsController extends ChangeNotifier {
Offset? pointer; Offset? pointer;
@@ -243,7 +243,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
final menuHeight = n * itemHeight + vPad * 2; final menuHeight = n * itemHeight + vPad * 2;
late double menuX; late double menuX;
late double menuY; late double menuY;
if (widget.interaction == MessageActionsInteraction.click) { if (widget.interaction != MessageActionsInteraction.dragAndRelease) {
final spaceBelow = screenSize.height - widget.tapPoint.dy - 8; final spaceBelow = screenSize.height - widget.tapPoint.dy - 8;
_showBelow = spaceBelow >= menuHeight || widget.tapPoint.dy < menuHeight; _showBelow = spaceBelow >= menuHeight || widget.tapPoint.dy < menuHeight;
final rawY = _showBelow final rawY = _showBelow
@@ -435,7 +435,8 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final eased = Curves.easeOutCubic.transform(t); final eased = Curves.easeOutCubic.transform(t);
final scale = 0.88 + 0.12 * eased; final scale = 0.88 + 0.12 * eased;
final isClick = widget.interaction == MessageActionsInteraction.click; final tapAnchored =
widget.interaction != MessageActionsInteraction.dragAndRelease;
return Positioned( return Positioned(
left: _menuRect.left, left: _menuRect.left,
top: _menuRect.top, top: _menuRect.top,
@@ -445,7 +446,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
opacity: eased, opacity: eased,
child: Transform.scale( child: Transform.scale(
scale: scale, scale: scale,
alignment: isClick alignment: tapAnchored
? Alignment(-1.0, _showBelow ? -1.0 : 1.0) ? Alignment(-1.0, _showBelow ? -1.0 : 1.0)
: Alignment( : Alignment(
widget.isMe ? 1.0 : -1.0, widget.isMe ? 1.0 : -1.0,
@@ -465,7 +466,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer>
_ListMenuItem( _ListMenuItem(
action: _actions[i], action: _actions[i],
highlighted: _hoveredIndex == i, highlighted: _hoveredIndex == i,
onHoverChanged: isClick onHoverChanged: tapAnchored
? (hovered) { ? (hovered) {
if (hovered) { if (hovered) {
if (_hoveredIndex != i) { if (_hoveredIndex != i) {
+2 -5
View File
@@ -370,9 +370,7 @@ class MessageBubble extends StatelessWidget {
final reactionsUnder = _reactionsUnderBubble(contentType); final reactionsUnder = _reactionsUnderBubble(contentType);
final reactionsInside = contentType != MessageType.text && !reactionsUnder; final reactionsInside = contentType != MessageType.text && !reactionsUnder;
return GestureDetector( return Padding(
onTap: Haptics.tap,
child: Padding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: 12, left: 12,
right: 12, right: 12,
@@ -435,8 +433,7 @@ class MessageBubble extends StatelessWidget {
], ],
), ),
), ),
), );
);
} }
Map? _resolveReactionInfo() { Map? _resolveReactionInfo() {