1+ import 'dart:math' as math;
2+ import 'dart:ui' show lerpDouble;
3+
14import 'package:collection/collection.dart' ;
25import 'package:flutter/material.dart' ;
36import 'package:flutter/services.dart' ;
@@ -75,6 +78,7 @@ class StreamMessageWidget extends StatelessWidget {
7578 double ? spacing,
7679 Color ? backgroundColor,
7780 double widthFactor = 0.8 ,
81+ bool swipeToReply = false ,
7882 void Function (Message )? onMessageTap,
7983 void Function (Message )? onMessageLongPress,
8084 void Function (User )? onUserAvatarTap,
@@ -96,6 +100,7 @@ class StreamMessageWidget extends StatelessWidget {
96100 spacing: spacing,
97101 backgroundColor: backgroundColor,
98102 widthFactor: widthFactor,
103+ swipeToReply: swipeToReply,
99104 onMessageTap: onMessageTap,
100105 onMessageLongPress: onMessageLongPress,
101106 onUserAvatarTap: onUserAvatarTap,
@@ -149,6 +154,7 @@ class StreamMessageWidgetProps {
149154 this .spacing,
150155 this .backgroundColor,
151156 this .widthFactor = 0.8 ,
157+ this .swipeToReply = false ,
152158 this .onMessageTap,
153159 this .onMessageLongPress,
154160 this .onUserAvatarTap,
@@ -198,6 +204,19 @@ class StreamMessageWidgetProps {
198204 /// Values should be between 0.0 and 1.0. Defaults to 0.8 when not specified.
199205 final double widthFactor;
200206
207+ /// Whether swiping the message triggers a quoted-reply action.
208+ ///
209+ /// When true, the message can be swiped horizontally to initiate a reply.
210+ /// The swipe direction is determined automatically based on message
211+ /// alignment: end-to-start for the current user's messages and
212+ /// start-to-end for other users' messages. On completion, [onReplyTap] is
213+ /// invoked with the message.
214+ ///
215+ /// Swipe is disabled for deleted messages and messages in a failed state.
216+ ///
217+ /// Defaults to false.
218+ final bool swipeToReply;
219+
201220 /// Called when the message is tapped.
202221 ///
203222 /// If null, no tap gesture is registered on mobile. On desktop and web,
@@ -309,6 +328,7 @@ class StreamMessageWidgetProps {
309328 double ? spacing,
310329 Color ? backgroundColor,
311330 double ? widthFactor,
331+ bool ? swipeToReply,
312332 void Function (Message )? onMessageTap,
313333 void Function (Message )? onMessageLongPress,
314334 void Function (User )? onUserAvatarTap,
@@ -331,6 +351,7 @@ class StreamMessageWidgetProps {
331351 spacing: spacing ?? this .spacing,
332352 backgroundColor: backgroundColor ?? this .backgroundColor,
333353 widthFactor: widthFactor ?? this .widthFactor,
354+ swipeToReply: swipeToReply ?? this .swipeToReply,
334355 onMessageTap: onMessageTap ?? this .onMessageTap,
335356 onMessageLongPress: onMessageLongPress ?? this .onMessageLongPress,
336357 onUserAvatarTap: onUserAvatarTap ?? this .onUserAvatarTap,
@@ -436,7 +457,7 @@ class DefaultStreamMessage extends StatelessWidget {
436457 },
437458 );
438459
439- return Material (
460+ Widget result = Material (
440461 animateColor: true ,
441462 color: effectiveBackgroundColor,
442463 child: PlatformWidgetBuilder (
@@ -494,6 +515,16 @@ class DefaultStreamMessage extends StatelessWidget {
494515 ),
495516 ),
496517 );
518+
519+ if (props.swipeToReply && props.onReplyTap != null && ! message.isDeleted && ! message.state.isFailed) {
520+ result = _SwipeToReplyWrapper (
521+ message: message,
522+ onReplyTap: props.onReplyTap! ,
523+ child: result,
524+ );
525+ }
526+
527+ return result;
497528 }
498529
499530 // Builds the action list for a bounced (moderation-error) message.
@@ -855,6 +886,66 @@ extension on Poll {
855886 }
856887}
857888
889+ class _SwipeToReplyWrapper extends StatelessWidget {
890+ const _SwipeToReplyWrapper ({
891+ required this .message,
892+ required this .onReplyTap,
893+ required this .child,
894+ });
895+
896+ final Message message;
897+ final void Function (Message ) onReplyTap;
898+ final Widget child;
899+
900+ static const _swipeThreshold = 0.2 ;
901+
902+ @override
903+ Widget build (BuildContext context) {
904+ final alignment = StreamMessagePlacement .alignmentDirectionalOf (context);
905+ final isEnd = alignment == AlignmentDirectional .centerEnd;
906+
907+ return Swipeable (
908+ key: ValueKey ('swipe-${message .id }' ),
909+ direction: isEnd ? SwipeDirection .endToStart : SwipeDirection .startToEnd,
910+ swipeThreshold: _swipeThreshold,
911+ onSwiped: (_) => onReplyTap (message),
912+ backgroundBuilder: (context, details) {
913+ final progress = math.min (details.progress, _swipeThreshold) / _swipeThreshold;
914+
915+ var offset = Offset .lerp (const Offset (- 24 , 0 ), const Offset (12 , 0 ), progress)! ;
916+ if (isEnd) offset = Offset (- offset.dx, - offset.dy);
917+
918+ return Align (
919+ alignment: alignment,
920+ child: Transform .translate (
921+ offset: offset,
922+ child: Opacity (
923+ opacity: progress,
924+ child: SizedBox .square (
925+ dimension: 30 ,
926+ child: CustomPaint (
927+ painter: AnimatedCircleBorderPainter (
928+ progress: progress,
929+ color: context.streamColorScheme.borderDefault,
930+ ),
931+ child: Center (
932+ child: Icon (
933+ context.streamIcons.arrowShareLeft,
934+ size: lerpDouble (0 , 18 , progress),
935+ color: context.streamColorScheme.accentPrimary,
936+ ),
937+ ),
938+ ),
939+ ),
940+ ),
941+ ),
942+ );
943+ },
944+ child: child,
945+ );
946+ }
947+ }
948+
858949// Built-in fallback theme values for [DefaultStreamMessage].
859950//
860951// Used when neither the explicit props nor the ambient
0 commit comments