Skip to content

Commit 76ce90a

Browse files
committed
feat(ui): Add reactionIndicatorBuilder for custom reaction indicators
This commit introduces a `reactionIndicatorBuilder` to `StreamMessageWidget`, allowing developers to customize the appearance of reaction indicators. A primary use case is displaying reaction counts next to emojis, similar to the web/desktop experience. Key changes: - Added `reactionIndicatorBuilder` to `StreamMessageWidget` for building custom reaction indicators. - Added `reactionIconBuilder` to `StreamReactionIndicator` to allow customization of individual reaction icons within the indicator, such as adding a count. - Refactored `ReactionPickerIconList` to be more generic by removing the `message` dependency and renaming `onReactionPicked` to `onIconPicked`. - Added tests for the new `StreamReactionIndicator` and `ReactionIndicatorIconList` widgets.
1 parent 30da524 commit 76ce90a

33 files changed

+766
-160
lines changed

packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ class StreamMessageWidget extends StatefulWidget {
100100
this.imageAttachmentThumbnailCropType = 'center',
101101
this.attachmentActionsModalBuilder,
102102
this.reactionPickerBuilder = StreamReactionPicker.builder,
103+
this.reactionIndicatorBuilder = StreamReactionIndicator.builder,
103104
});
104105

105106
/// {@template onMentionTap}
@@ -386,6 +387,9 @@ class StreamMessageWidget extends StatefulWidget {
386387
/// {@macro reactionPickerBuilder}
387388
final ReactionPickerBuilder reactionPickerBuilder;
388389

390+
/// {@macro reactionIndicatorBuilder}
391+
final ReactionIndicatorBuilder reactionIndicatorBuilder;
392+
389393
/// Size of the image attachment thumbnail.
390394
final Size imageAttachmentThumbnailSize;
391395

@@ -469,6 +473,7 @@ class StreamMessageWidget extends StatefulWidget {
469473
String? imageAttachmentThumbnailCropType,
470474
AttachmentActionsBuilder? attachmentActionsModalBuilder,
471475
ReactionPickerBuilder? reactionPickerBuilder,
476+
ReactionIndicatorBuilder? reactionIndicatorBuilder,
472477
}) {
473478
return StreamMessageWidget(
474479
key: key ?? this.key,
@@ -545,6 +550,8 @@ class StreamMessageWidget extends StatefulWidget {
545550
attachmentActionsModalBuilder ?? this.attachmentActionsModalBuilder,
546551
reactionPickerBuilder:
547552
reactionPickerBuilder ?? this.reactionPickerBuilder,
553+
reactionIndicatorBuilder:
554+
reactionIndicatorBuilder ?? this.reactionIndicatorBuilder,
548555
);
549556
}
550557

@@ -770,6 +777,7 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
770777
widget.bottomRowBuilderWithDefaultWidget,
771778
onUserAvatarTap: widget.onUserAvatarTap,
772779
userAvatarBuilder: widget.userAvatarBuilder,
780+
reactionIndicatorBuilder: widget.reactionIndicatorBuilder,
773781
),
774782
),
775783
),

packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class MessageWidgetContent extends StatelessWidget {
6363
required this.showEditedLabel,
6464
required this.messageWidget,
6565
required this.onThreadTap,
66+
required this.reactionIndicatorBuilder,
6667
this.onUserAvatarTap,
6768
this.borderRadiusGeometry,
6869
this.borderSide,
@@ -224,6 +225,9 @@ class MessageWidgetContent extends StatelessWidget {
224225
/// {@macro userAvatarBuilder}
225226
final Widget Function(BuildContext, User)? userAvatarBuilder;
226227

228+
/// {@macro reactionIndicatorBuilder}
229+
final ReactionIndicatorBuilder reactionIndicatorBuilder;
230+
227231
@override
228232
Widget build(BuildContext context) {
229233
return Column(
@@ -273,6 +277,7 @@ class MessageWidgetContent extends StatelessWidget {
273277
onTap: onReactionsTap,
274278
visible: isMobileDevice && showReactions,
275279
anchorOffset: const Offset(0, 36),
280+
reactionIndicatorBuilder: reactionIndicatorBuilder,
276281
childSizeDelta: switch (showUserAvatar) {
277282
DisplayWidget.gone => Offset.zero,
278283
// Size adjustment for the user avatar

packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator.dart

Lines changed: 88 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,25 @@
11
import 'package:collection/collection.dart';
22
import 'package:flutter/material.dart';
3-
import 'package:stream_chat_flutter/src/reactions/indicator/reaction_indicator_icon_list.dart';
43
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
54

5+
/// {@template reactionIndicatorBuilder}
6+
/// Signature for a function that builds a custom reaction indicator widget.
7+
///
8+
/// This allows users to customize how reactions are displayed on messages,
9+
/// including showing reaction counts alongside emojis.
10+
///
11+
/// Parameters:
12+
/// - [context]: The build context.
13+
/// - [message]: The message containing the reactions to display.
14+
/// - [onTap]: An optional callback triggered when the reaction indicator
15+
/// is tapped.
16+
/// {@endtemplate}
17+
typedef ReactionIndicatorBuilder = Widget Function(
18+
BuildContext context,
19+
Message message,
20+
VoidCallback? onTap,
21+
);
22+
623
/// {@template streamReactionIndicator}
724
/// A widget that displays a horizontal list of reaction icons that users have
825
/// reacted with on a message.
@@ -17,33 +34,71 @@ class StreamReactionIndicator extends StatelessWidget {
1734
super.key,
1835
this.onTap,
1936
required this.message,
37+
required this.reactionIcons,
38+
this.reactionIconBuilder,
2039
this.backgroundColor,
2140
this.padding = const EdgeInsets.all(8),
2241
this.scrollable = true,
2342
this.borderRadius = const BorderRadius.all(Radius.circular(26)),
2443
this.reactionSorting = ReactionSorting.byFirstReactionAt,
2544
});
2645

27-
/// Message to attach the reaction to.
28-
final Message message;
46+
/// Creates a [StreamReactionIndicator] using the default reaction icons
47+
/// provided by the [StreamChatConfiguration].
48+
///
49+
/// This is the recommended way to create a reaction indicator
50+
/// as it ensures that the icons are consistent with the rest of the app.
51+
///
52+
/// The [onTap] callback is optional and can be used to handle
53+
/// when the reaction indicator is tapped.
54+
factory StreamReactionIndicator.builder(
55+
BuildContext context,
56+
Message message,
57+
VoidCallback? onTap,
58+
) {
59+
final config = StreamChatConfiguration.of(context);
60+
final reactionIcons = config.reactionIcons;
61+
62+
final currentUser = StreamChat.maybeOf(context)?.currentUser;
63+
final isMyMessage = message.user?.id == currentUser?.id;
64+
65+
final theme = StreamChatTheme.of(context);
66+
final messageTheme = theme.getMessageTheme(reverse: isMyMessage);
67+
68+
return StreamReactionIndicator(
69+
onTap: onTap,
70+
message: message,
71+
reactionIcons: reactionIcons,
72+
backgroundColor: messageTheme.reactionsBackgroundColor,
73+
);
74+
}
2975

3076
/// Callback triggered when the reaction indicator is tapped.
3177
final VoidCallback? onTap;
3278

79+
/// Message to attach the reaction to.
80+
final Message message;
81+
82+
/// The list of available reaction icons.
83+
final List<StreamReactionIcon> reactionIcons;
84+
85+
/// Optional custom builder for reaction indicator icons.
86+
final ReactionIndicatorIconBuilder? reactionIconBuilder;
87+
3388
/// Background color for the reaction indicator.
3489
final Color? backgroundColor;
3590

36-
/// Padding around the reaction picker.
91+
/// Padding around the reaction indicator.
3792
///
3893
/// Defaults to `EdgeInsets.all(8)`.
3994
final EdgeInsets padding;
4095

41-
/// Whether the reaction picker should be scrollable.
96+
/// Whether the reaction indicator should be scrollable.
4297
///
4398
/// Defaults to `true`.
4499
final bool scrollable;
45100

46-
/// Border radius for the reaction picker.
101+
/// Border radius for the reaction indicator.
47102
///
48103
/// Defaults to a circular border with a radius of 26.
49104
final BorderRadius? borderRadius;
@@ -56,35 +111,40 @@ class StreamReactionIndicator extends StatelessWidget {
56111
@override
57112
Widget build(BuildContext context) {
58113
final theme = StreamChatTheme.of(context);
59-
final config = StreamChatConfiguration.of(context);
60-
final reactionIcons = config.reactionIcons;
61114

62115
final ownReactions = {...?message.ownReactions?.map((it) => it.type)};
63-
final indicatorIcons = message.reactionGroups?.entries
64-
.sortedByCompare((it) => it.value, reactionSorting)
65-
.map((group) {
66-
final reactionIcon = reactionIcons.firstWhere(
67-
(it) => it.type == group.key,
68-
orElse: () => const StreamReactionIcon.unknown(),
69-
);
70-
71-
return ReactionIndicatorIcon(
72-
type: reactionIcon.type,
73-
builder: reactionIcon.builder,
74-
isSelected: ownReactions.contains(reactionIcon.type),
75-
);
76-
});
116+
final reactionIcons = {for (final it in this.reactionIcons) it.type: it};
117+
118+
final sortedReactionGroups = message.reactionGroups?.entries
119+
.sortedByCompare((it) => it.value, reactionSorting);
120+
121+
final indicatorIcons = sortedReactionGroups?.map(
122+
(group) {
123+
final reactionType = group.key;
124+
final reactionIcon = switch (reactionIcons[reactionType]) {
125+
final icon? => icon,
126+
_ => const StreamReactionIcon.unknown(),
127+
};
128+
129+
return ReactionIndicatorIcon(
130+
type: reactionType,
131+
builder: reactionIcon.builder,
132+
isSelected: ownReactions.contains(reactionType),
133+
);
134+
},
135+
);
136+
137+
final reactionIndicator = ReactionIndicatorIconList(
138+
iconBuilder: reactionIconBuilder,
139+
indicatorIcons: [...?indicatorIcons],
140+
);
77141

78142
final isSingleIndicatorIcon = indicatorIcons?.length == 1;
79143
final extraPadding = switch (isSingleIndicatorIcon) {
80144
true => EdgeInsets.zero,
81145
false => const EdgeInsets.symmetric(horizontal: 4),
82146
};
83147

84-
final indicator = ReactionIndicatorIconList(
85-
indicatorIcons: [...?indicatorIcons],
86-
);
87-
88148
return Material(
89149
borderRadius: borderRadius,
90150
clipBehavior: Clip.antiAlias,
@@ -94,11 +154,11 @@ class StreamReactionIndicator extends StatelessWidget {
94154
child: Padding(
95155
padding: padding.add(extraPadding),
96156
child: switch (scrollable) {
157+
false => reactionIndicator,
97158
true => SingleChildScrollView(
98159
scrollDirection: Axis.horizontal,
99-
child: indicator,
160+
child: reactionIndicator,
100161
),
101-
false => indicator,
102162
},
103163
),
104164
),

packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator_bubble_overlay.dart

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class ReactionIndicatorBubbleOverlay extends StatelessWidget {
2222
this.reverse = false,
2323
this.anchorOffset = Offset.zero,
2424
this.childSizeDelta = Offset.zero,
25+
this.reactionIndicatorBuilder = StreamReactionIndicator.builder,
2526
});
2627

2728
/// Whether the overlay should be visible.
@@ -45,6 +46,9 @@ class ReactionIndicatorBubbleOverlay extends StatelessWidget {
4546
/// The additional size delta to apply to the child widget for positioning.
4647
final Offset childSizeDelta;
4748

49+
/// Builder for the reaction indicator widget.
50+
final ReactionIndicatorBuilder reactionIndicatorBuilder;
51+
4852
@override
4953
Widget build(BuildContext context) {
5054
final theme = StreamChatTheme.of(context);
@@ -63,11 +67,7 @@ class ReactionIndicatorBubbleOverlay extends StatelessWidget {
6367
follower: AlignmentDirectional.bottomCenter,
6468
target: AlignmentDirectional(reverse ? -1 : 1, -1),
6569
),
66-
reaction: StreamReactionIndicator(
67-
onTap: onTap,
68-
message: message,
69-
backgroundColor: messageTheme.reactionsBackgroundColor,
70-
),
70+
reaction: reactionIndicatorBuilder.call(context, message, onTap),
7171
child: child,
7272
);
7373
}

packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator_icon_list.dart

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ typedef ReactionIndicatorIconBuilder = Widget Function(
2020
/// A widget that displays a list of reactionIcons that users have reacted with
2121
/// on a message.
2222
///
23-
/// also see:
23+
/// See also:
2424
/// - [StreamReactionIndicator], which is a higher-level widget that uses this
2525
/// widget to display a reaction indicator in a modal or inline.
2626
/// {@endtemplate}
@@ -29,8 +29,8 @@ class ReactionIndicatorIconList extends StatelessWidget {
2929
const ReactionIndicatorIconList({
3030
super.key,
3131
required this.indicatorIcons,
32-
this.iconBuilder = _defaultIconBuilder,
33-
});
32+
ReactionIndicatorIconBuilder? iconBuilder,
33+
}) : iconBuilder = iconBuilder ?? _defaultIconBuilder;
3434

3535
/// The list of available reaction indicator icons.
3636
final List<ReactionIndicatorIcon> indicatorIcons;

packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import 'package:flutter/material.dart';
22
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
33

4+
/// {@template onReactionPicked}
5+
/// Callback called when a reaction is picked.
6+
/// {@endtemplate}
7+
typedef OnReactionPicked = ValueSetter<Reaction>;
8+
49
/// {@template reactionPickerBuilder}
510
/// Function signature for building a custom reaction picker widget.
611
///
@@ -33,9 +38,11 @@ class StreamReactionPicker extends StatelessWidget {
3338
/// {@macro streamReactionPicker}
3439
const StreamReactionPicker({
3540
super.key,
41+
this.onReactionPicked,
3642
required this.message,
3743
required this.reactionIcons,
38-
this.onReactionPicked,
44+
this.reactionIconBuilder,
45+
this.backgroundColor,
3946
this.padding = const EdgeInsets.all(4),
4047
this.scrollable = true,
4148
this.borderRadius = const BorderRadius.all(Radius.circular(24)),
@@ -83,6 +90,12 @@ class StreamReactionPicker extends StatelessWidget {
8390
/// {@macro onReactionPressed}
8491
final OnReactionPicked? onReactionPicked;
8592

93+
/// Optional custom builder for reaction picker icons.
94+
final ReactionPickerIconBuilder? reactionIconBuilder;
95+
96+
/// Background color for the reaction picker.
97+
final Color? backgroundColor;
98+
8699
/// Padding around the reaction picker.
87100
///
88101
/// Defaults to `EdgeInsets.all(4)`.
@@ -102,10 +115,31 @@ class StreamReactionPicker extends StatelessWidget {
102115
Widget build(BuildContext context) {
103116
final theme = StreamChatTheme.of(context);
104117

118+
final ownReactions = [...?message.ownReactions];
119+
final ownReactionsMap = {for (final it in ownReactions) it.type: it};
120+
121+
final indicatorIcons = reactionIcons.map(
122+
(reactionIcon) {
123+
final reactionType = reactionIcon.type;
124+
125+
return ReactionPickerIcon(
126+
type: reactionType,
127+
builder: reactionIcon.builder,
128+
// If the reaction is present in ownReactions, it is selected.
129+
isSelected: ownReactionsMap[reactionType] != null,
130+
);
131+
},
132+
);
133+
105134
final reactionPicker = ReactionPickerIconList(
106-
message: message,
107-
reactionIcons: reactionIcons,
108-
onReactionPicked: onReactionPicked,
135+
iconBuilder: reactionIconBuilder,
136+
reactionIcons: [...indicatorIcons],
137+
onIconPicked: (reactionIcon) {
138+
final reactionType = reactionIcon.type;
139+
final reaction = ownReactionsMap[reactionType];
140+
141+
return onReactionPicked?.call(reaction ?? Reaction(type: reactionType));
142+
},
109143
);
110144

111145
final isSinglePickerIcon = reactionIcons.length == 1;
@@ -117,7 +151,7 @@ class StreamReactionPicker extends StatelessWidget {
117151
return Material(
118152
borderRadius: borderRadius,
119153
clipBehavior: Clip.antiAlias,
120-
color: theme.colorTheme.barsBg,
154+
color: backgroundColor ?? theme.colorTheme.barsBg,
121155
child: Padding(
122156
padding: padding.add(extraPadding),
123157
child: switch (scrollable) {

packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker_bubble_overlay.dart

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import 'package:flutter/material.dart';
22
import 'package:stream_chat_flutter/src/reactions/picker/reaction_picker.dart';
3-
import 'package:stream_chat_flutter/src/reactions/picker/reaction_picker_icon_list.dart';
43
import 'package:stream_chat_flutter/src/reactions/reaction_bubble_overlay.dart';
54
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
65
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';

0 commit comments

Comments
 (0)