Skip to content

Commit

Permalink
feat: message grouping (#696)
Browse files Browse the repository at this point in the history
  • Loading branch information
demchenkoalex authored Jan 2, 2025
1 parent f811437 commit 11c0cc9
Show file tree
Hide file tree
Showing 15 changed files with 151 additions and 56 deletions.
8 changes: 4 additions & 4 deletions examples/flyer_chat/lib/api/api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,10 @@ class ApiState extends State<Api> {
children: [
Chat(
builders: Builders(
textMessageBuilder: (context, message) =>
FlyerChatTextMessage(message: message),
imageMessageBuilder: (context, message) =>
FlyerChatImageMessage(message: message),
textMessageBuilder: (context, message, index) =>
FlyerChatTextMessage(message: message, index: index),
imageMessageBuilder: (context, message, index) =>
FlyerChatImageMessage(message: message, index: index),
inputBuilder: (context) => ChatInput(
topWidget: InputActionBar(
buttons: [
Expand Down
8 changes: 4 additions & 4 deletions examples/flyer_chat/lib/gemini.dart
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@ class GeminiState extends State<Gemini> {
),
body: Chat(
builders: Builders(
textMessageBuilder: (context, message) =>
FlyerChatTextMessage(message: message),
imageMessageBuilder: (context, message) =>
FlyerChatImageMessage(message: message),
textMessageBuilder: (context, message, index) =>
FlyerChatTextMessage(message: message, index: index),
imageMessageBuilder: (context, message, index) =>
FlyerChatImageMessage(message: message, index: index),
inputBuilder: (context) => ChatInput(
topWidget: InputActionBar(
buttons: [
Expand Down
8 changes: 4 additions & 4 deletions examples/flyer_chat/lib/local.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ class LocalState extends State<Local> {
),
body: Chat(
builders: Builders(
textMessageBuilder: (context, message) =>
FlyerChatTextMessage(message: message),
imageMessageBuilder: (context, message) =>
FlyerChatImageMessage(message: message),
textMessageBuilder: (context, message, index) =>
FlyerChatTextMessage(message: message, index: index),
imageMessageBuilder: (context, message, index) =>
FlyerChatImageMessage(message: message, index: index),
inputBuilder: (context) => ChatInput(
topWidget: InputActionBar(
buttons: [
Expand Down
20 changes: 17 additions & 3 deletions packages/flutter_chat_core/lib/src/models/builders.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,31 @@ import 'message.dart';

part 'builders.freezed.dart';

typedef TextMessageBuilder = Widget Function(BuildContext, TextMessage);
typedef ImageMessageBuilder = Widget Function(BuildContext, ImageMessage);
typedef CustomMessageBuilder = Widget Function(BuildContext, CustomMessage);
typedef TextMessageBuilder = Widget Function(
BuildContext,
TextMessage,
int index,
);
typedef ImageMessageBuilder = Widget Function(
BuildContext,
ImageMessage,
int index,
);
typedef CustomMessageBuilder = Widget Function(
BuildContext,
CustomMessage,
int index,
);
typedef UnsupportedMessageBuilder = Widget Function(
BuildContext,
UnsupportedMessage,
int index,
);
typedef InputBuilder = Widget Function(BuildContext);
typedef ChatMessageBuilder = Widget Function(
BuildContext,
Message message,
int index,
Animation<double> animation,
Widget child,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ class ImageMessageTheme with _$ImageMessageTheme {
downloadProgressIndicatorColor: themeData.colorScheme.primary,
uploadProgressIndicatorColor: themeData.colorScheme.primary,
uploadOverlayColor:
themeData.colorScheme.surfaceContainerLow.withValues(alpha: 0.5),
// This API is deprecated in Dart ^3.6 and we support Dart ^3.3
// ignore: deprecated_member_use
themeData.colorScheme.surfaceContainerLow.withOpacity(0.5),
);
}

Expand Down
8 changes: 6 additions & 2 deletions packages/flutter_chat_core/lib/src/theme/input_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,15 @@ class InputTheme with _$InputTheme {
defaultChatFontFamily;

return InputTheme(
backgroundColor: themeData.colorScheme.surface.withValues(alpha: 0.8),
// This API is deprecated in Dart ^3.6 and we support Dart ^3.3
// ignore: deprecated_member_use
backgroundColor: themeData.colorScheme.surface.withOpacity(0.8),
textFieldColor: themeData.colorScheme.surfaceContainerHigh,
hintStyle: themeData.textTheme.bodyMedium?.copyWith(
fontFamily: family,
color: themeData.textTheme.bodyMedium?.color?.withValues(alpha: 0.6),
// This API is deprecated in Dart ^3.6 and we support Dart ^3.3
// ignore: deprecated_member_use
color: themeData.textTheme.bodyMedium?.color?.withOpacity(0.6),
),
textStyle: themeData.textTheme.bodyMedium?.copyWith(
fontFamily: family,
Expand Down
5 changes: 3 additions & 2 deletions packages/flutter_chat_core/lib/src/utils/typedefs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import '../models/message.dart';

typedef ChatItem = Widget Function(
BuildContext context,
Animation<double> animation,
Message message, {
Message message,
int index,
Animation<double> animation, {
bool? isRemoved,
});
8 changes: 5 additions & 3 deletions packages/flutter_chat_ui/lib/src/chat.dart
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,16 @@ class _ChatState extends State<Chat> with WidgetsBindingObserver {

Widget _buildItem(
BuildContext context,
Animation<double> animation,
Message message, {
Message message,
int index,
Animation<double> animation, {
bool? isRemoved,
}) {
return ChatMessageInternal(
key: ValueKey(message),
animation: animation,
message: message,
index: index,
animation: animation,
isRemoved: isRemoved,
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@ class ChatAnimatedList extends StatefulWidget {
this.removeAnimationDuration = const Duration(milliseconds: 250),
this.scrollToEndAnimationDuration = const Duration(milliseconds: 250),
this.scrollToBottomAppearanceDelay = const Duration(milliseconds: 250),
// default vertical padding between messages are 1, so we add 7 to make it 8
// for the first message
this.topPadding = 7,
this.topPadding = 8,
this.bottomPadding = 20,
this.topSliver,
this.bottomSliver,
Expand Down Expand Up @@ -127,11 +125,11 @@ class ChatAnimatedListState extends State<ChatAnimatedList>

_scrollToBottomController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
duration: const Duration(milliseconds: 250),
);
_scrollToBottomAnimation = CurvedAnimation(
parent: _scrollToBottomController,
curve: Curves.easeInOut,
curve: Curves.linearToEaseOut,
);
}

Expand Down Expand Up @@ -228,10 +226,12 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
) {
_sliverListViewContext ??= context;
final message = _chatController.messages[index];

return widget.itemBuilder(
context,
animation,
message,
index,
animation,
);
},
),
Expand Down Expand Up @@ -465,8 +465,9 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
position,
(context, animation) => widget.itemBuilder(
context,
animation,
data,
position,
animation,
isRemoved: true,
),
duration: widget.removeAnimationDuration,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ class ChatAnimatedListReversed extends StatefulWidget {
final double? bottomPadding;
final Widget? topSliver;
final Widget? bottomSliver;
final bool? handleSafeArea;
final ScrollViewKeyboardDismissBehavior? keyboardDismissBehavior;

const ChatAnimatedListReversed({
Expand All @@ -30,10 +31,11 @@ class ChatAnimatedListReversed extends StatefulWidget {
this.insertAnimationDuration = const Duration(milliseconds: 250),
this.removeAnimationDuration = const Duration(milliseconds: 250),
this.scrollToEndAnimationDuration = const Duration(milliseconds: 250),
this.topPadding,
this.topPadding = 8,
this.bottomPadding = 20,
this.topSliver,
this.bottomSliver,
this.handleSafeArea = true,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.onDrag,
});

Expand Down Expand Up @@ -117,6 +119,8 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed> {

@override
Widget build(BuildContext context) {
final bottomSafeArea = MediaQuery.of(context).padding.bottom;

return NotificationListener<Notification>(
onNotification: (notification) {
// Handle initial scroll to bottom so you see latest messages
Expand Down Expand Up @@ -155,7 +159,9 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed> {
builder: (context, heightNotifier, child) {
return SliverPadding(
padding: EdgeInsets.only(
bottom: heightNotifier.height + (widget.bottomPadding ?? 0),
bottom: heightNotifier.height +
(widget.bottomPadding ?? 0) +
(widget.handleSafeArea == true ? bottomSafeArea : 0),
),
);
},
Expand All @@ -170,12 +176,15 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed> {
Animation<double> animation,
) {
_sliverListViewContext ??= context;
final message = _chatController.messages[
max(_chatController.messages.length - 1 - index, 0)];
final currentIndex =
max(_chatController.messages.length - 1 - index, 0);
final message = _chatController.messages[currentIndex];

return widget.itemBuilder(
context,
animation,
message,
currentIndex,
animation,
);
},
),
Expand Down Expand Up @@ -218,8 +227,9 @@ class ChatAnimatedListReversedState extends State<ChatAnimatedListReversed> {
visualPosition,
(context, animation) => widget.itemBuilder(
context,
animation,
data,
visualPosition,
animation,
isRemoved: true,
),
duration: widget.removeAnimationDuration,
Expand Down
60 changes: 51 additions & 9 deletions packages/flutter_chat_ui/lib/src/chat_message/chat_message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import 'package:provider/provider.dart';
import '../utils/typedefs.dart';

class ChatMessage extends StatelessWidget {
static const EdgeInsetsGeometry _sentinelValue = EdgeInsets.zero;

final Message message;
final int index;
final Animation<double> animation;
final Widget child;
final Alignment sentMessageScaleAnimationAlignment;
Expand All @@ -14,11 +17,15 @@ class ChatMessage extends StatelessWidget {
final AlignmentGeometry receivedMessageAlignment;
final Alignment? scaleAnimationAlignment;
final AlignmentGeometry? alignment;
final EdgeInsetsGeometry padding;
final EdgeInsetsGeometry? padding;
final Duration? paddingChangeAnimationDuration;
final bool? isRemoved;
final int? messageGroupingTimeoutInSeconds;

const ChatMessage({
super.key,
required this.message,
required this.index,
required this.animation,
required this.child,
this.sentMessageScaleAnimationAlignment = Alignment.centerRight,
Expand All @@ -27,7 +34,10 @@ class ChatMessage extends StatelessWidget {
this.receivedMessageAlignment = AlignmentDirectional.centerStart,
this.scaleAnimationAlignment,
this.alignment,
this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 1),
this.padding = _sentinelValue,
this.paddingChangeAnimationDuration = const Duration(milliseconds: 250),
this.isRemoved,
this.messageGroupingTimeoutInSeconds = 300,
});

@override
Expand All @@ -40,6 +50,9 @@ class ChatMessage extends StatelessWidget {
curve: Curves.linearToEaseOut,
);

final resolvedPadding =
padding == _sentinelValue ? _calculateDefaultPadding(context) : padding;

return GestureDetector(
onTap: () => onMessageTap?.call(message),
child: FadeTransition(
Expand All @@ -57,17 +70,46 @@ class ChatMessage extends StatelessWidget {
(isSentByMe
? sentMessageAlignment
: receivedMessageAlignment),
child: Padding(
padding: message is TextMessage &&
(message as TextMessage).isOnlyEmoji == true
? EdgeInsets.zero
: padding,
child: child,
),
child: padding != null
? paddingChangeAnimationDuration != null
? AnimatedPadding(
padding: resolvedPadding!,
duration: paddingChangeAnimationDuration!,
curve: Curves.linearToEaseOut,
child: child,
)
: Padding(padding: resolvedPadding!, child: child)
: child,
),
),
),
),
);
}

EdgeInsetsGeometry _calculateDefaultPadding(BuildContext context) {
if (message is TextMessage &&
(message as TextMessage).isOnlyEmoji == true) {
return EdgeInsets.zero;
}

if (index == 0) {
return const EdgeInsets.symmetric(horizontal: 8);
}

try {
final chatController = context.read<ChatController>();
final previousMessage = chatController.messages[index - 1];

final isGrouped = previousMessage.author.id == message.author.id &&
message.createdAt.difference(previousMessage.createdAt).inSeconds <
(messageGroupingTimeoutInSeconds ?? 0);

return isGrouped || isRemoved == true
? const EdgeInsets.fromLTRB(8, 2, 8, 0)
: const EdgeInsets.fromLTRB(8, 12, 8, 0);
} catch (e) {
return const EdgeInsets.fromLTRB(8, 2, 8, 0);
}
}
}
Loading

0 comments on commit 11c0cc9

Please sign in to comment.