diff --git a/lib/pages/chat/chat_event_list.dart b/lib/pages/chat/chat_event_list.dart index 888e71270..2b2d45054 100644 --- a/lib/pages/chat/chat_event_list.dart +++ b/lib/pages/chat/chat_event_list.dart @@ -140,7 +140,7 @@ class ChatEventList extends StatelessWidget { resetAnimateIn: () { controller.animateInEventIndex = null; }, - onSwipe: () => controller.replyAction(replyTo: event), + onReply: () => controller.replyAction(replyTo: event), onInfoTab: controller.showEventInfo, onMention: () => controller.sendController.text += '${event.senderFromMemoryOrFallback.mention} ', diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart index 9ba3abbe7..27c4f4ce9 100644 --- a/lib/pages/chat/events/message.dart +++ b/lib/pages/chat/events/message.dart @@ -3,7 +3,6 @@ import 'package:flutter/services.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:matrix/matrix.dart'; -import 'package:swipe_to_action/swipe_to_action.dart'; import 'package:hermes/config/setting_keys.dart'; import 'package:hermes/l10n/l10n.dart'; @@ -16,6 +15,7 @@ import 'package:hermes/utils/string_color.dart'; import 'package:hermes/widgets/avatar.dart'; import 'package:hermes/widgets/matrix.dart'; import 'package:hermes/widgets/member_actions_popup_menu_button.dart'; +import 'package:hermes/utils/reply_swipe.dart'; import '../../../config/app_config.dart'; import 'message_content.dart'; import 'message_reactions.dart'; @@ -30,7 +30,7 @@ class Message extends StatelessWidget { final void Function(Event) onSelect; final void Function(Event) onInfoTab; final void Function(String) scrollToEventId; - final void Function() onSwipe; + final void Function() onReply; final void Function() onMention; final void Function() onEdit; final bool longPressSelect; @@ -55,7 +55,7 @@ class Message extends StatelessWidget { required this.onSelect, required this.onInfoTab, required this.scrollToEventId, - required this.onSwipe, + required this.onReply, this.selected = false, required this.onEdit, required this.singleSelected, @@ -195,18 +195,21 @@ class Message extends StatelessWidget { singleSelected && event.room.canSendDefaultMessages; return Center( - child: Swipeable( + child: ReplySwipe( key: ValueKey(event.eventId), - background: const Padding( - padding: EdgeInsets.symmetric(horizontal: 12.0), + backgroundBuilder: (context, direction, progress) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), child: Center( - child: Icon(Icons.check_outlined), + child: Opacity( + opacity: progress, + child: const Icon(Icons.check_outlined), + ), ), ), direction: AppSettings.swipeRightToLeftToReply.value - ? SwipeDirection.endToStart - : SwipeDirection.startToEnd, - onSwipe: (_) => onSwipe(), + ? ReplySwipeDirection.endToStart + : ReplySwipeDirection.startToEnd, + onReply: onReply, child: Container( constraints: const BoxConstraints( maxWidth: PantheonThemes.maxTimelineWidth, diff --git a/lib/utils/reply_swipe.dart b/lib/utils/reply_swipe.dart new file mode 100644 index 000000000..b9b932c47 --- /dev/null +++ b/lib/utils/reply_swipe.dart @@ -0,0 +1,306 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; + +enum ReplySwipeDirection { startToEnd, endToStart } + +typedef ReplyBackgroundBuilder = Widget Function( + BuildContext context, + ReplySwipeDirection direction, + double progress, // 0..1 +); + +/// Swipe-to-reply that animates the child, fires [onReply] once threshold +/// is crossed, then snaps back. Rejects early if initial motion is the +/// opposite direction so parents can win the arena. +class ReplySwipe extends StatefulWidget { + const ReplySwipe({ + super.key, + required this.child, + required this.onReply, + this.direction = ReplySwipeDirection.startToEnd, + this.thresholdPx = 56.0, + this.maxDragPx = 96.0, + this.hapticOnThreshold = true, + this.backgroundBuilder, + this.allowedPointerKinds, // optional filter (e.g., {touch, trackpad}) + }); + + final Widget child; + final VoidCallback onReply; + final ReplySwipeDirection direction; + final double thresholdPx; + final double maxDragPx; + final bool hapticOnThreshold; + final ReplyBackgroundBuilder? backgroundBuilder; + + /// Optional: restrict which input devices can trigger the drag (passes to + /// recognizer.supportedDevices). If null, uses Flutter's default. + final Set? allowedPointerKinds; + + @override + State createState() => _ReplySwipeState(); +} + +class _ReplySwipeState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 180), + ); + + double _dragX = 0.0; // >= 0 + bool _thresholdBuzzed = false; + + int _signFor(TextDirection textDir) { + final ltr = textDir == TextDirection.ltr; + final dir = widget.direction; + if (dir == ReplySwipeDirection.startToEnd) return ltr ? 1 : -1; + return ltr ? -1 : 1; + } + + void _setDragX(double v) { + final clamped = v.clamp(0.0, widget.maxDragPx).toDouble(); + if (clamped != _dragX) { + setState(() => _dragX = clamped); + final crossed = _dragX >= widget.thresholdPx; + if (widget.hapticOnThreshold && crossed && !_thresholdBuzzed) { + HapticFeedback.selectionClick(); + _thresholdBuzzed = true; + } + if (!crossed) _thresholdBuzzed = false; + } + } + + Future _snapBack() async { + final start = _dragX; + if (start == 0.0) return; + final anim = Tween(begin: start, end: 0.0).animate( + CurvedAnimation(parent: _ctrl, curve: Curves.easeOut), + ); + void listener() => setState(() => _dragX = anim.value); + _ctrl + ..value = 0.0 + ..addListener(listener); + try { + await _ctrl.forward(); + } finally { + _ctrl.removeListener(listener); + setState(() => _dragX = 0.0); + } + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final textDir = Directionality.of(context); + final allowedSign = _signFor(textDir); + final sign = allowedSign.toDouble(); + final progress = (_dragX / widget.maxDragPx).clamp(0.0, 1.0).toDouble(); + + return RawGestureDetector( + behavior: HitTestBehavior.deferToChild, + gestures: { + _DirectionalSwipeRecognizer: + GestureRecognizerFactoryWithHandlers<_DirectionalSwipeRecognizer>( + () => _DirectionalSwipeRecognizer( + allowedSign: allowedSign, + onAccepted: () {}, // hook if needed + ), + (rec) { + // Optional: restrict devices like Swipeable.allowedPointerKinds + rec.supportedDevices = widget.allowedPointerKinds; + rec.allowedSign = allowedSign; + + rec + ..onUpdate = (details) { + final delta = details.delta.dx * sign; + if (delta >= 0) { + _setDragX(_dragX + delta); + } else { + final next = _dragX + delta; + _setDragX(next >= 0 ? next : 0.0); + } + } + ..onEnd = (_) async { + final triggered = _dragX >= widget.thresholdPx; + if (triggered) widget.onReply(); + await _snapBack(); + }; + }, + ), + }, + child: Stack( + alignment: Alignment.center, + children: [ + if (widget.backgroundBuilder != null) + Positioned.fill( + child: widget.backgroundBuilder!( + context, + widget.direction, + progress, + ), + ) + else + _DefaultReplyBackground( + direction: widget.direction, + progress: progress, + ), + Transform.translate( + offset: Offset(sign * _dragX, 0.0), + child: widget.child, + ), + ], + ), + ); + } +} + +/// Custom recognizer that rejects early when initial movement is opposite +/// the allowed horizontal sign; accepts once slop is passed in allowed dir. +class _DirectionalSwipeRecognizer extends HorizontalDragGestureRecognizer { + _DirectionalSwipeRecognizer({required this.allowedSign, this.onAccepted}); + + /// The horizontal direction we treat as a valid swipe (+1 or -1). + int allowedSign; + + final VoidCallback? onAccepted; + + double _positiveExtent = 0.0; + double _negativeExtent = 0.0; + bool _accepted = false; + PointerDeviceKind? _pointerKind; + + void _resetState() { + _positiveExtent = 0.0; + _negativeExtent = 0.0; + _accepted = false; + _pointerKind = null; + } + + @override + void addAllowedPointer(PointerDownEvent event) { + _resetState(); + _pointerKind = event.kind; + super.addAllowedPointer(event); + } + + double _slopFor(PointerEvent event) { + final kind = _pointerKind ?? event.kind; + return computeHitSlop(kind, gestureSettings); + } + + void _accumulateDelta(double delta) { + if (delta > 0) { + _positiveExtent += delta; + } else if (delta < 0) { + _negativeExtent += -delta; + } + } + + bool _resolveForDirection(int direction, PointerEvent event) { + if (direction == allowedSign) { + _accepted = true; + resolve(GestureDisposition.accepted); + onAccepted?.call(); + return true; + } else if (direction == -allowedSign) { + resolve(GestureDisposition.rejected); + stopTrackingPointer(event.pointer); + _resetState(); + return true; + } + return false; + } + + @override + void handleEvent(PointerEvent event) { + if (!_accepted) { + if (event is PointerMoveEvent) { + _pointerKind ??= event.kind; + final deltaX = event.localDelta.dx; + if (deltaX != 0.0) { + _accumulateDelta(deltaX); + final slop = _slopFor(event); + if (_positiveExtent > slop) { + if (_resolveForDirection(1, event)) { + return; + } + } else if (_negativeExtent > slop) { + if (_resolveForDirection(-1, event)) { + return; + } + } + } + } else if (event is PointerPanZoomUpdateEvent) { + _pointerKind ??= event.kind; + final deltaX = event.panDelta.dx; + if (deltaX != 0.0) { + _accumulateDelta(deltaX); + final slop = _slopFor(event); + if (_positiveExtent > slop) { + if (_resolveForDirection(1, event)) { + return; + } + } else if (_negativeExtent > slop) { + if (_resolveForDirection(-1, event)) { + return; + } + } + } + } + } + super.handleEvent(event); + } + + @override + void didStopTrackingLastPointer(int pointer) { + _resetState(); + super.didStopTrackingLastPointer(pointer); + } +} + +/// Default background: Material reply icon that fades/slides in. +class _DefaultReplyBackground extends StatelessWidget { + const _DefaultReplyBackground({ + required this.direction, + required this.progress, + }); + + final ReplySwipeDirection direction; + final double progress; // 0..1 + + @override + Widget build(BuildContext context) { + final textDir = Directionality.of(context); + final isStartToEnd = direction == ReplySwipeDirection.startToEnd; + + final align = switch ((isStartToEnd, textDir)) { + (true, TextDirection.ltr) => Alignment.centerLeft, + (true, TextDirection.rtl) => Alignment.centerRight, + (false, TextDirection.ltr) => Alignment.centerRight, + (false, TextDirection.rtl) => Alignment.centerLeft, + }; + + final slideSign = align == Alignment.centerLeft ? -1.0 : 1.0; + + return IgnorePointer( + child: Container( + alignment: align, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Opacity( + opacity: progress, + child: Transform.translate( + offset: Offset((1 - progress) * 8.0 * slideSign, 0.0), + child: const Icon(Icons.reply, size: 20), + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index d20cae5c5..24c4cb145 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1844,14 +1844,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.0" - swipe_to_action: - dependency: "direct main" - description: - name: swipe_to_action - sha256: "05289a2bff6a0227deeff382afa1c46643d1f077e678d78f76440e153ea1ef7d" - url: "https://pub.dev" - source: hosted - version: "0.3.0" sync_http: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4b5806e48..cee317deb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -76,7 +76,6 @@ dependencies: slugify: ^2.0.0 sqflite_common_ffi: ^2.3.6 sqlcipher_flutter_libs: ^0.6.8 - swipe_to_action: ^0.3.0 tor_detector_web: ^1.1.0 unifiedpush: ^6.2.0 unifiedpush_ui: ^0.2.0