Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 2 additions & 70 deletions packages/stream_chat_flutter/example/lib/main.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
// ignore_for_file: public_member_api_docs

import 'dart:async';
import 'dart:math' as math;
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:responsive_builder/responsive_builder.dart';
Expand Down Expand Up @@ -254,74 +252,8 @@ class _ChannelPageState extends State<ChannelPage> {
Expanded(
child: StreamMessageListView(
threadBuilder: (_, parent) => ThreadPage(parent: parent!),
messageBuilder: (context, message, defaultProps) {
// The threshold after which the message is considered
// swiped.
const threshold = 0.2;

final currentUser = StreamChat.of(context).currentUser;
final isMyMessage = message.user?.id == currentUser?.id;

// The direction in which the message can be swiped.
final swipeDirection = isMyMessage ? SwipeDirection.endToStart : SwipeDirection.startToEnd;

return Swipeable(
key: ValueKey(message.id),
direction: swipeDirection,
swipeThreshold: threshold,
onSwiped: (details) => reply(message),
backgroundBuilder: (context, details) {
// The alignment of the swipe action.
final alignment = isMyMessage ? Alignment.centerRight : Alignment.centerLeft;

// The progress of the swipe action.
final progress = math.min(details.progress, threshold) / threshold;

// The offset for the reply icon.
var offset = Offset.lerp(
const Offset(-24, 0),
const Offset(12, 0),
progress,
)!;

// If the message is mine, we need to flip the offset.
if (isMyMessage) {
offset = Offset(-offset.dx, -offset.dy);
}

final _streamTheme = StreamChatTheme.of(context);

return Align(
alignment: alignment,
child: Transform.translate(
offset: offset,
child: Opacity(
opacity: progress,
child: SizedBox.square(
dimension: 30,
child: CustomPaint(
painter: AnimatedCircleBorderPainter(
progress: progress,
color: _streamTheme.colorTheme.borders,
),
child: Center(
child: Icon(
context.streamIcons.arrowShareLeft,
size: lerpDouble(0, 18, progress),
color: _streamTheme.colorTheme.accentPrimary,
),
),
),
),
),
),
);
},
child: DefaultStreamMessage(
props: defaultProps.copyWith(onReplyTap: reply),
),
);
},
onReplyTap: reply,
swipeToReply: true,
),
),
StreamMessageInput(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ class StreamMessageListView extends StatefulWidget {
this.onThreadTap,
this.onEditMessageTap,
this.onReplyTap,
this.swipeToReply = false,
this.onUserAvatarTap,
this.onReactionsTap,
this.onQuotedMessageTap,
Expand Down Expand Up @@ -223,6 +224,14 @@ class StreamMessageListView extends StatefulWidget {
/// Forwarded to each [StreamMessageWidget] in the list.
final void Function(Message)? onReplyTap;

/// Whether swiping a message triggers a quoted-reply action.
///
/// Forwarded to each [StreamMessageWidget] in the list via
/// [StreamMessageWidgetProps.swipeToReply].
///
/// Defaults to false.
final bool swipeToReply;

/// Called when a user avatar is tapped.
///
/// Forwarded to each [StreamMessageWidget] in the list.
Expand Down Expand Up @@ -1056,6 +1065,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
) {
final parentMessageProps = StreamMessageWidgetProps(
message: message,
swipeToReply: widget.swipeToReply,
onThreadTap: _onThreadTap,
onMessageTap: widget.onMessageTap,
onMessageLongPress: widget.onMessageLongPress,
Expand Down Expand Up @@ -1200,6 +1210,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {

final messageWidgetProps = StreamMessageWidgetProps(
message: message,
swipeToReply: widget.swipeToReply,
onThreadTap: _onThreadTap,
onMessageTap: widget.onMessageTap,
onMessageLongPress: widget.onMessageLongPress,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import 'dart:math' as math;
import 'dart:ui' show lerpDouble;

import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
Expand Down Expand Up @@ -75,6 +78,7 @@ class StreamMessageWidget extends StatelessWidget {
double? spacing,
Color? backgroundColor,
double widthFactor = 0.8,
bool swipeToReply = false,
void Function(Message)? onMessageTap,
void Function(Message)? onMessageLongPress,
void Function(User)? onUserAvatarTap,
Expand All @@ -96,6 +100,7 @@ class StreamMessageWidget extends StatelessWidget {
spacing: spacing,
backgroundColor: backgroundColor,
widthFactor: widthFactor,
swipeToReply: swipeToReply,
onMessageTap: onMessageTap,
onMessageLongPress: onMessageLongPress,
onUserAvatarTap: onUserAvatarTap,
Expand Down Expand Up @@ -149,6 +154,7 @@ class StreamMessageWidgetProps {
this.spacing,
this.backgroundColor,
this.widthFactor = 0.8,
this.swipeToReply = false,
this.onMessageTap,
this.onMessageLongPress,
this.onUserAvatarTap,
Expand Down Expand Up @@ -198,6 +204,19 @@ class StreamMessageWidgetProps {
/// Values should be between 0.0 and 1.0. Defaults to 0.8 when not specified.
final double widthFactor;

/// Whether swiping the message triggers a quoted-reply action.
///
/// When true, the message can be swiped from left to right to initiate a
/// reply. The swipe direction and reply icon position are always
/// start-to-end (left to right in LTR layouts), regardless of whether the
/// message belongs to the current user or another participant.
/// On completion, [onReplyTap] is invoked with the message.
///
/// Swipe is disabled for deleted messages and messages in a failed state.
///
/// Defaults to false.
final bool swipeToReply;

/// Called when the message is tapped.
///
/// If null, no tap gesture is registered on mobile. On desktop and web,
Expand Down Expand Up @@ -309,6 +328,7 @@ class StreamMessageWidgetProps {
double? spacing,
Color? backgroundColor,
double? widthFactor,
bool? swipeToReply,
void Function(Message)? onMessageTap,
void Function(Message)? onMessageLongPress,
void Function(User)? onUserAvatarTap,
Expand All @@ -331,6 +351,7 @@ class StreamMessageWidgetProps {
spacing: spacing ?? this.spacing,
backgroundColor: backgroundColor ?? this.backgroundColor,
widthFactor: widthFactor ?? this.widthFactor,
swipeToReply: swipeToReply ?? this.swipeToReply,
onMessageTap: onMessageTap ?? this.onMessageTap,
onMessageLongPress: onMessageLongPress ?? this.onMessageLongPress,
onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap,
Expand Down Expand Up @@ -436,7 +457,7 @@ class DefaultStreamMessage extends StatelessWidget {
},
);

return Material(
Widget result = Material(
animateColor: true,
color: effectiveBackgroundColor,
child: PlatformWidgetBuilder(
Expand Down Expand Up @@ -494,6 +515,16 @@ class DefaultStreamMessage extends StatelessWidget {
),
),
);

if (props.swipeToReply && props.onReplyTap != null && !message.isDeleted && !message.state.isFailed) {
result = _SwipeToReplyWrapper(
message: message,
onReplyTap: props.onReplyTap!,
child: result,
);
}

return result;
}

// Builds the action list for a bounced (moderation-error) message.
Expand Down Expand Up @@ -855,6 +886,60 @@ extension on Poll {
}
}

class _SwipeToReplyWrapper extends StatelessWidget {
const _SwipeToReplyWrapper({
required this.message,
required this.onReplyTap,
required this.child,
});

final Message message;
final void Function(Message) onReplyTap;
final Widget child;

static const _swipeThreshold = 0.2;

@override
Widget build(BuildContext context) {
return Swipeable(
key: ValueKey('swipe-${message.id}'),
direction: SwipeDirection.startToEnd,
swipeThreshold: _swipeThreshold,
onSwiped: (_) => onReplyTap(message),
backgroundBuilder: (context, details) {
final progress = math.min(details.progress, _swipeThreshold) / _swipeThreshold;
final offset = Offset.lerp(const Offset(-24, 0), const Offset(12, 0), progress)!;

return Align(
alignment: AlignmentDirectional.centerStart,
child: Transform.translate(
offset: offset,
child: Opacity(
opacity: progress,
child: SizedBox.square(
dimension: 32,
child: CustomPaint(
painter: AnimatedCircleBorderPainter(
progress: progress,
color: context.streamColorScheme.borderDefault,
),
child: Center(
child: Icon(
context.streamIcons.arrowShareLeft,
size: lerpDouble(0, 20, progress),
),
),
),
),
),
),
);
},
child: child,
);
}
}

// Built-in fallback theme values for [DefaultStreamMessage].
//
// Used when neither the explicit props nor the ambient
Expand Down
60 changes: 1 addition & 59 deletions sample_app/lib/pages/channel_page.dart
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
// ignore_for_file: deprecated_member_use, avoid_redundant_argument_values

import 'dart:math' as math;
import 'dart:ui';

import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
Expand Down Expand Up @@ -106,8 +103,8 @@ class _ChannelPageState extends State<ChannelPage> {
highlightInitialMessage: widget.highlightInitialMessage,
onEditMessageTap: _editMessage,
onReplyTap: _reply,
swipeToReply: true,
messageFilter: defaultFilter,
messageBuilder: _messageBuilder,
threadBuilder: (_, parentMessage) {
return ThreadPage(parent: parentMessage!);
},
Expand Down Expand Up @@ -201,61 +198,6 @@ class _ChannelPageState extends State<ChannelPage> {
return channel.sendStaticLocation(location: result.coordinates);
}

Widget _messageBuilder(
BuildContext context,
Message message,
StreamMessageWidgetProps defaultProps,
) {
final defaultWidget = StreamMessageWidget.fromProps(props: defaultProps);

if (message.isDeleted || message.state.isFailed) return defaultWidget;

final alignment = StreamMessagePlacement.alignmentDirectionalOf(context);
final isEnd = alignment == AlignmentDirectional.centerEnd;

const threshold = 0.2;

return Swipeable(
key: ValueKey(message.id),
direction: isEnd ? SwipeDirection.endToStart : SwipeDirection.startToEnd,
swipeThreshold: threshold,
onSwiped: (_) => _reply(message),
backgroundBuilder: (context, details) {
final progress = math.min(details.progress, threshold) / threshold;

var offset = Offset.lerp(const Offset(-24, 0), const Offset(12, 0), progress)!;
if (isEnd) offset = Offset(-offset.dx, -offset.dy);

return Align(
alignment: alignment,
child: Transform.translate(
offset: offset,
child: Opacity(
opacity: progress,
child: SizedBox.square(
dimension: 30,
child: CustomPaint(
painter: AnimatedCircleBorderPainter(
progress: progress,
color: context.streamColorScheme.borderDefault,
),
child: Center(
child: Icon(
context.streamIcons.arrowShareLeft,
size: lerpDouble(0, 18, progress),
color: context.streamColorScheme.accentPrimary,
),
),
),
),
),
),
);
},
child: defaultWidget,
);
}

bool defaultFilter(Message m) {
final currentUser = StreamChat.of(context).currentUser;
final isMyMessage = m.user?.id == currentUser?.id;
Expand Down
1 change: 1 addition & 0 deletions sample_app/lib/pages/thread_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class _ThreadPageState extends State<ThreadPage> {
initialScrollIndex: widget.initialScrollIndex,
initialAlignment: widget.initialAlignment,
onReplyTap: _reply,
swipeToReply: true,
messageFilter: defaultFilter,
showScrollToBottom: false,
highlightInitialMessage: true,
Expand Down
Loading