diff --git a/packages/stream_chat/lib/src/core/models/reaction_group.dart b/packages/stream_chat/lib/src/core/models/reaction_group.dart index 4410500ac..480156de4 100644 --- a/packages/stream_chat/lib/src/core/models/reaction_group.dart +++ b/packages/stream_chat/lib/src/core/models/reaction_group.dart @@ -57,3 +57,26 @@ class ReactionGroup extends Equatable { lastReactionAt, ]; } + +/// A group of comparators for sorting [ReactionGroup]s. +final class ReactionSorting { + /// Sorts [ReactionGroup]s by the sum of their scores. + static int byScore(ReactionGroup a, ReactionGroup b) { + return a.sumScores.compareTo(b.sumScores); + } + + /// Sorts [ReactionGroup]s by the count of reactions. + static int byCount(ReactionGroup a, ReactionGroup b) { + return a.count.compareTo(b.count); + } + + /// Sorts [ReactionGroup]s by the date of their first reaction. + static int byFirstReactionAt(ReactionGroup a, ReactionGroup b) { + return a.firstReactionAt.compareTo(b.firstReactionAt); + } + + /// Sorts [ReactionGroup]s by the date of their last reaction. + static int byLastReactionAt(ReactionGroup a, ReactionGroup b) { + return a.lastReactionAt.compareTo(b.lastReactionAt); + } +} diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 0000c0e34..5cb08edf0 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -5,6 +5,7 @@ - `StreamReactionPicker` now requires reactions to be explicitly handled via `onReactionPicked`. *(Automatic handling is no longer supported.)* - `StreamMessageAction` is now generic `(StreamMessageAction)`, enhancing type safety. Individual onTap callbacks have been removed; actions are now handled centrally by widgets like `StreamMessageWidget.onCustomActionTap` or modals using action types. - `StreamMessageReactionsModal` no longer requires the `messageTheme` parameter. The theme now automatically derives from the `reverse` property. +- `StreamMessageWidget` no longer requires the `showReactionTail` parameter. The reaction picker tail is now always shown when the reaction picker is visible. For more details, please refer to the [migration guide](Unpublished). diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart index 5168626ed..e98c74fd2 100644 --- a/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_modal/message_actions_modal.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/reactions/reactions_align.dart'; +import 'package:stream_chat_flutter/src/reactions/picker/reaction_picker_bubble_overlay.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -71,56 +71,30 @@ class StreamMessageActionsModal extends StatelessWidget { Widget build(BuildContext context) { final theme = StreamChatTheme.of(context); - final Widget? reactionPicker = switch (showReactionPicker) { - false => null, - true => LayoutBuilder( - builder: (context, constraints) { - final orientation = MediaQuery.of(context).orientation; - final messageTheme = theme.getMessageTheme(reverse: reverse); - final messageFontSize = messageTheme.messageTextStyle?.fontSize; - - final alignment = message.calculateReactionPickerAlignment( - constraints: constraints, - fontSize: messageFontSize, - orientation: orientation, - reverse: reverse, - ); - - final onReactionPicked = switch (onActionTap) { - null => null, - final onActionTap => (reaction) { - return onActionTap.call( - SelectReaction(message: message, reaction: reaction), - ); - }, - }; - - return Align( - alignment: alignment, - child: reactionPickerBuilder(context, message, onReactionPicked), - ); - }, - ), - }; - final alignment = switch (reverse) { true => AlignmentDirectional.centerEnd, false => AlignmentDirectional.centerStart, }; + final onReactionPicked = switch (onActionTap) { + null => null, + final onActionTap => (reaction) => onActionTap( + SelectReaction(message: message, reaction: reaction), + ), + }; + return StreamMessageModal( + spacing: 4, alignment: alignment, - headerBuilder: (context) { - return Column( - spacing: 10, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: alignment.toColumnCrossAxisAlignment(), - children: [ - reactionPicker, - IgnorePointer(child: messageWidget), - ].nonNulls.toList(growable: false), - ); - }, + headerBuilder: (context) => ReactionPickerBubbleOverlay( + message: message, + reverse: reverse, + visible: showReactionPicker, + anchorOffset: const Offset(0, -8), + onReactionPicked: onReactionPicked, + reactionPickerBuilder: reactionPickerBuilder, + child: IgnorePointer(child: messageWidget), + ), contentBuilder: (context) { final actions = Column( mainAxisSize: MainAxisSize.min, diff --git a/packages/stream_chat_flutter/lib/src/message_modal/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_modal/message_reactions_modal.dart index 70278f3dc..8cdb69728 100644 --- a/packages/stream_chat_flutter/lib/src/message_modal/message_reactions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_modal/message_reactions_modal.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/reactions/reactions_align.dart'; +import 'package:stream_chat_flutter/src/reactions/picker/reaction_picker_bubble_overlay.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamMessageReactionsModal} @@ -64,58 +64,32 @@ class StreamMessageReactionsModal extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = StreamChatTheme.of(context); - final messageTheme = theme.getMessageTheme(reverse: reverse); - - final Widget? reactionPicker = switch (showReactionPicker) { - false => null, - true => LayoutBuilder( - builder: (context, constraints) { - final orientation = MediaQuery.of(context).orientation; - final messageFontSize = messageTheme.messageTextStyle?.fontSize; - - final alignment = message.calculateReactionPickerAlignment( - constraints: constraints, - fontSize: messageFontSize, - orientation: orientation, - reverse: reverse, - ); - - final onReactionPicked = switch (this.onReactionPicked) { - null => null, - final onPicked => (reaction) { - return onPicked.call( - SelectReaction(message: message, reaction: reaction), - ); - }, - }; - - return Align( - alignment: alignment, - child: reactionPickerBuilder(context, message, onReactionPicked), - ); - }, - ), - }; - final alignment = switch (reverse) { true => AlignmentDirectional.centerEnd, false => AlignmentDirectional.centerStart, }; + final onReactionPicked = switch (this.onReactionPicked) { + null => null, + final onPicked => (reaction) { + return onPicked.call( + SelectReaction(message: message, reaction: reaction), + ); + }, + }; + return StreamMessageModal( + spacing: 4, alignment: alignment, - headerBuilder: (context) { - return Column( - spacing: 10, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: alignment.toColumnCrossAxisAlignment(), - children: [ - reactionPicker, - IgnorePointer(child: messageWidget), - ].nonNulls.toList(growable: false), - ); - }, + headerBuilder: (context) => ReactionPickerBubbleOverlay( + message: message, + reverse: reverse, + visible: showReactionPicker, + anchorOffset: const Offset(0, -8), + onReactionPicked: onReactionPicked, + reactionPickerBuilder: reactionPickerBuilder, + child: IgnorePointer(child: messageWidget), + ), contentBuilder: (context) { final reactions = message.latestReactions; final hasReactions = reactions != null && reactions.isNotEmpty; diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart index 62ebaada6..b60db28f1 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart @@ -164,9 +164,9 @@ class _MessageCardState extends State { return Container( constraints: const BoxConstraints().copyWith(maxWidth: widthLimit), - margin: EdgeInsets.symmetric( - horizontal: (widget.isFailedState ? 12.0 : 0.0) + - (widget.showUserAvatar == DisplayWidget.gone ? 0 : 4.0), + margin: EdgeInsetsDirectional.only( + end: widget.reverse && widget.isFailedState ? 12.0 : 0.0, + start: !widget.reverse && widget.isFailedState ? 12.0 : 0.0, ), clipBehavior: Clip.hardEdge, decoration: ShapeDecoration( diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart index 7cc332018..a5408c404 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart @@ -6,6 +6,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart'; import 'package:stream_chat_flutter/src/message_widget/message_widget_content.dart'; +import 'package:stream_chat_flutter/src/misc/flexible_fractionally_sized_box.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// The display behaviour of a widget @@ -51,7 +52,6 @@ class StreamMessageWidget extends StatefulWidget { this.onReactionsTap, this.onReactionsHover, this.showReactionPicker = true, - this.showReactionTail, this.showUserAvatar = DisplayWidget.show, this.showSendingIndicator = true, this.showThreadReplyIndicator = false, @@ -258,12 +258,6 @@ class StreamMessageWidget extends StatefulWidget { /// {@endtemplate} final bool showReactionPicker; - /// {@template showReactionPickerTail} - /// Whether or not to show the reaction picker tail. - /// This is calculated internally in most cases and does not need to be set. - /// {@endtemplate} - final bool? showReactionTail; - /// {@template onShowMessage} /// Callback when show message is tapped /// {@endtemplate} @@ -438,7 +432,6 @@ class StreamMessageWidget extends StatefulWidget { void Function(String)? onLinkTap, bool? showReactionBrowser, bool? showReactionPicker, - bool? showReactionTail, List? readList, ShowMessageCallback? onShowMessage, bool? showUsername, @@ -508,7 +501,6 @@ class StreamMessageWidget extends StatefulWidget { onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap, onLinkTap: onLinkTap ?? this.onLinkTap, showReactionPicker: showReactionPicker ?? this.showReactionPicker, - showReactionTail: showReactionTail ?? this.showReactionTail, onShowMessage: onShowMessage ?? this.onShowMessage, showUsername: showUsername ?? this.showUsername, showTimestamp: showTimestamp ?? this.showTimestamp, @@ -704,76 +696,73 @@ class _StreamMessageWidgetState extends State child: MouseRegion(child: child), ); }, - child: Padding( - padding: widget.padding ?? const EdgeInsets.all(8), - child: FractionallySizedBox( - alignment: switch (widget.reverse) { - true => Alignment.centerRight, - false => Alignment.centerLeft, - }, - widthFactor: widget.widthFactor, - child: Builder(builder: (context) { - return MessageWidgetContent( - streamChatTheme: theme, - showUsername: showUsername, - showTimeStamp: showTimeStamp, - showEditedLabel: showEditedLabel, - showThreadReplyIndicator: showThreadReplyIndicator, - showSendingIndicator: showSendingIndicator, - showInChannel: showInChannel, - isGiphy: isGiphy, - isOnlyEmoji: isOnlyEmoji, - hasUrlAttachments: hasUrlAttachments, - messageTheme: widget.messageTheme, - reverse: widget.reverse, - message: widget.message, - hasNonUrlAttachments: hasNonUrlAttachments, - hasPoll: hasPoll, - hasQuotedMessage: hasQuotedMessage, - textPadding: widget.textPadding, - attachmentBuilders: widget.attachmentBuilders, - attachmentPadding: widget.attachmentPadding, - attachmentShape: widget.attachmentShape, - onAttachmentTap: widget.onAttachmentTap, - onReplyTap: widget.onReplyTap, - onThreadTap: widget.onThreadTap, - onShowMessage: widget.onShowMessage, - attachmentActionsModalBuilder: - widget.attachmentActionsModalBuilder, - avatarWidth: avatarWidth, - bottomRowPadding: bottomRowPadding, - isFailedState: isFailedState, - isPinned: isPinned, - messageWidget: widget, - showBottomRow: showBottomRow, - showPinHighlight: widget.showPinHighlight, - showReactionPickerTail: widget.showReactionTail == true, - showReactions: shouldShowReactions, - onReactionsTap: () { - final message = widget.message; - return switch (widget.onReactionsTap) { - final onReactionsTap? => onReactionsTap(message), - _ => _showMessageReactionsModal(context, message), - }; - }, - onReactionsHover: widget.onReactionsHover, - showUserAvatar: widget.showUserAvatar, - streamChat: streamChat, - translateUserAvatar: widget.translateUserAvatar, - shape: widget.shape, - borderSide: widget.borderSide, - borderRadiusGeometry: widget.borderRadiusGeometry, - textBuilder: widget.textBuilder, - quotedMessageBuilder: widget.quotedMessageBuilder, - onLinkTap: widget.onLinkTap, - onMentionTap: widget.onMentionTap, - onQuotedMessageTap: widget.onQuotedMessageTap, - bottomRowBuilderWithDefaultWidget: - widget.bottomRowBuilderWithDefaultWidget, - onUserAvatarTap: widget.onUserAvatarTap, - userAvatarBuilder: widget.userAvatarBuilder, - ); - }), + child: FlexibleFractionallySizedBox( + widthFactor: widget.widthFactor, + alignment: switch (widget.reverse) { + true => AlignmentDirectional.centerEnd, + false => AlignmentDirectional.centerStart, + }, + child: Padding( + padding: widget.padding ?? const EdgeInsets.all(8), + child: MessageWidgetContent( + streamChatTheme: theme, + showUsername: showUsername, + showTimeStamp: showTimeStamp, + showEditedLabel: showEditedLabel, + showThreadReplyIndicator: showThreadReplyIndicator, + showSendingIndicator: showSendingIndicator, + showInChannel: showInChannel, + isGiphy: isGiphy, + isOnlyEmoji: isOnlyEmoji, + hasUrlAttachments: hasUrlAttachments, + messageTheme: widget.messageTheme, + reverse: widget.reverse, + message: widget.message, + hasNonUrlAttachments: hasNonUrlAttachments, + hasPoll: hasPoll, + hasQuotedMessage: hasQuotedMessage, + textPadding: widget.textPadding, + attachmentBuilders: widget.attachmentBuilders, + attachmentPadding: widget.attachmentPadding, + attachmentShape: widget.attachmentShape, + onAttachmentTap: widget.onAttachmentTap, + onReplyTap: widget.onReplyTap, + onThreadTap: widget.onThreadTap, + onShowMessage: widget.onShowMessage, + attachmentActionsModalBuilder: + widget.attachmentActionsModalBuilder, + avatarWidth: avatarWidth, + bottomRowPadding: bottomRowPadding, + isFailedState: isFailedState, + isPinned: isPinned, + messageWidget: widget, + showBottomRow: showBottomRow, + showPinHighlight: widget.showPinHighlight, + showReactions: shouldShowReactions, + onReactionsTap: () { + final message = widget.message; + return switch (widget.onReactionsTap) { + final onReactionsTap? => onReactionsTap(message), + _ => _showMessageReactionsModal(context, message), + }; + }, + onReactionsHover: widget.onReactionsHover, + showUserAvatar: widget.showUserAvatar, + streamChat: streamChat, + translateUserAvatar: widget.translateUserAvatar, + shape: widget.shape, + borderSide: widget.borderSide, + borderRadiusGeometry: widget.borderRadiusGeometry, + textBuilder: widget.textBuilder, + quotedMessageBuilder: widget.quotedMessageBuilder, + onLinkTap: widget.onLinkTap, + onMentionTap: widget.onMentionTap, + onQuotedMessageTap: widget.onQuotedMessageTap, + bottomRowBuilderWithDefaultWidget: + widget.bottomRowBuilderWithDefaultWidget, + onUserAvatarTap: widget.onUserAvatarTap, + userAvatarBuilder: widget.userAvatarBuilder, + ), ), ), ), @@ -936,7 +925,6 @@ class _StreamMessageWidgetState extends State showSendingIndicator: false, padding: EdgeInsets.zero, showPinHighlight: false, - showReactionTail: showPicker, showUserAvatar: switch (widget.reverse) { true => DisplayWidget.gone, false => DisplayWidget.show, @@ -1055,7 +1043,6 @@ class _StreamMessageWidgetState extends State showSendingIndicator: false, padding: EdgeInsets.zero, showPinHighlight: false, - showReactionTail: showPicker, showUserAvatar: switch (widget.reverse) { true => DisplayWidget.gone, false => DisplayWidget.show, diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart index 41a46982d..dbf9b99ba 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter_portal/flutter_portal.dart'; import 'package:meta/meta.dart'; import 'package:stream_chat_flutter/src/reactions/desktop_reactions_builder.dart'; +import 'package:stream_chat_flutter/src/reactions/indicator/reaction_indicator_bubble_overlay.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Signature for the builder function that will be called when the message @@ -53,7 +53,6 @@ class MessageWidgetContent extends StatelessWidget { required this.onReplyTap, required this.attachmentActionsModalBuilder, required this.textPadding, - required this.showReactionPickerTail, required this.translateUserAvatar, required this.bottomRowPadding, required this.showInChannel, @@ -189,9 +188,6 @@ class MessageWidgetContent extends StatelessWidget { /// {@macro quotedMessageBuilder} final Widget Function(BuildContext, Message)? quotedMessageBuilder; - /// {@macro showReactionPickerTail} - final bool showReactionPickerTail; - /// {@macro translateUserAvatar} final bool translateUserAvatar; @@ -265,140 +261,58 @@ class MessageWidgetContent extends StatelessWidget { currentUser: streamChat.currentUser!, ), Row( - crossAxisAlignment: CrossAxisAlignment.end, + spacing: 8, mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, children: [ - if (!reverse && - showUserAvatar == DisplayWidget.show && - message.user != null) ...[ - UserAvatarTransform( - onUserAvatarTap: onUserAvatarTap, - userAvatarBuilder: userAvatarBuilder, - translateUserAvatar: translateUserAvatar, - messageTheme: messageTheme, - message: message, - ), - const SizedBox(width: 4), - ], - if (showUserAvatar == DisplayWidget.hide) - SizedBox(width: avatarWidth + 4), - Flexible( - child: PortalTarget( - visible: isMobileDevice && showReactions, - portalFollower: ReactionIndicator( - message: message, - messageTheme: messageTheme, - ownId: streamChat.currentUser!.id, + ...[ + Flexible( + child: ReactionIndicatorBubbleOverlay( reverse: reverse, + message: message, onTap: onReactionsTap, - ), - anchor: Aligned( - target: Alignment(reverse ? -1 : 1, -1), - follower: Alignment(reverse ? 1 : -1, 1), - offset: Offset(reverse ? 12 : -12, 42), - shiftToWithinBound: const AxisFlag(x: true), - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - Padding( - padding: showReactions - ? const EdgeInsets.only(top: 28) - : EdgeInsets.zero, - child: (message.isDeleted && !isFailedState) - ? Container( - margin: EdgeInsets.symmetric( - horizontal: showUserAvatar == - DisplayWidget.gone - ? 0 - : 4.0, - ), - child: StreamDeletedMessage( - borderRadiusGeometry: - borderRadiusGeometry, - borderSide: borderSide, - shape: shape, - messageTheme: messageTheme, - ), - ) - : MessageCard( - message: message, - isFailedState: isFailedState, - showUserAvatar: showUserAvatar, - messageTheme: messageTheme, - hasQuotedMessage: hasQuotedMessage, - hasUrlAttachments: hasUrlAttachments, - hasNonUrlAttachments: - hasNonUrlAttachments, - hasPoll: hasPoll, - isOnlyEmoji: isOnlyEmoji, - isGiphy: isGiphy, - attachmentBuilders: attachmentBuilders, - attachmentPadding: attachmentPadding, - attachmentShape: attachmentShape, - onAttachmentTap: onAttachmentTap, - onReplyTap: onReplyTap, - onShowMessage: onShowMessage, - attachmentActionsModalBuilder: - attachmentActionsModalBuilder, - textPadding: textPadding, - reverse: reverse, - onQuotedMessageTap: onQuotedMessageTap, - onMentionTap: onMentionTap, - onLinkTap: onLinkTap, - textBuilder: textBuilder, - quotedMessageBuilder: - quotedMessageBuilder, - borderRadiusGeometry: - borderRadiusGeometry, - borderSide: borderSide, - shape: shape, - ), - ), - // TODO: Make tail part of the Reaction Picker. - if (showReactionPickerTail) - PositionedDirectional( - end: reverse ? null : 4, - start: reverse ? 4 : null, - top: -8, - child: CustomPaint( - painter: ReactionBubblePainter( - streamChatTheme.colorTheme.barsBg, - Colors.transparent, - Colors.transparent, - tailCirclesSpace: 1, - flipTail: !reverse, - ), - ), - ), - ], + visible: isMobileDevice && showReactions, + anchorOffset: const Offset(0, 36), + childSizeDelta: switch (showUserAvatar) { + DisplayWidget.gone => Offset.zero, + // Size adjustment for the user avatar + _ => const Offset(40, 0), + }, + child: Padding( + padding: switch (showReactions) { + true => const EdgeInsets.only(top: 28), + false => EdgeInsets.zero, + }, + child: _buildMessageCard(context), + ), ), ), + ].addConditionally( + reverse: reverse, + condition: (_) => message.user != null, + switch (showUserAvatar) { + DisplayWidget.gone => null, + DisplayWidget.hide => SizedBox(width: avatarWidth), + DisplayWidget.show => UserAvatarTransform( + onUserAvatarTap: onUserAvatarTap, + userAvatarBuilder: userAvatarBuilder, + translateUserAvatar: translateUserAvatar, + messageTheme: messageTheme, + message: message, + ), + }, ), - if (reverse && - showUserAvatar == DisplayWidget.show && - message.user != null) ...[ - UserAvatarTransform( - onUserAvatarTap: onUserAvatarTap, - userAvatarBuilder: userAvatarBuilder, - translateUserAvatar: translateUserAvatar, - messageTheme: messageTheme, - message: message, - ), - const SizedBox(width: 4), - ], - if (showUserAvatar == DisplayWidget.hide) - SizedBox(width: avatarWidth + 4), ], ), if (isDesktopDeviceOrWeb && showReactions) ...[ Padding( - padding: showUserAvatar != DisplayWidget.gone - ? EdgeInsets.only( - left: avatarWidth + 4, - right: avatarWidth + 4, - ) - : EdgeInsets.zero, + padding: switch (showUserAvatar) { + DisplayWidget.gone => EdgeInsets.zero, + _ => EdgeInsets.only( + left: avatarWidth + 4, + right: avatarWidth + 4, + ) + }, child: DesktopReactionsBuilder( message: message, messageTheme: messageTheme, @@ -431,6 +345,47 @@ class MessageWidgetContent extends StatelessWidget { ); } + Widget _buildMessageCard(BuildContext context) { + if (message.isDeleted && !isFailedState) { + return StreamDeletedMessage( + borderRadiusGeometry: borderRadiusGeometry, + borderSide: borderSide, + shape: shape, + messageTheme: messageTheme, + ); + } + + return MessageCard( + message: message, + isFailedState: isFailedState, + showUserAvatar: showUserAvatar, + messageTheme: messageTheme, + hasQuotedMessage: hasQuotedMessage, + hasUrlAttachments: hasUrlAttachments, + hasNonUrlAttachments: hasNonUrlAttachments, + hasPoll: hasPoll, + isOnlyEmoji: isOnlyEmoji, + isGiphy: isGiphy, + attachmentBuilders: attachmentBuilders, + attachmentPadding: attachmentPadding, + attachmentShape: attachmentShape, + onAttachmentTap: onAttachmentTap, + onReplyTap: onReplyTap, + onShowMessage: onShowMessage, + attachmentActionsModalBuilder: attachmentActionsModalBuilder, + textPadding: textPadding, + reverse: reverse, + onQuotedMessageTap: onQuotedMessageTap, + onMentionTap: onMentionTap, + onLinkTap: onLinkTap, + textBuilder: textBuilder, + quotedMessageBuilder: quotedMessageBuilder, + borderRadiusGeometry: borderRadiusGeometry, + borderSide: borderSide, + shape: shape, + ); + } + Widget _buildBottomRow(BuildContext context) { final defaultWidget = BottomRow( onThreadTap: onThreadTap, @@ -463,3 +418,17 @@ class MessageWidgetContent extends StatelessWidget { return defaultWidget; } } + +extension on Iterable { + Iterable addConditionally( + T? item, { + required bool condition(T element), + bool reverse = false, + }) sync* { + for (final element in this) { + if (item != null && !reverse && condition(element)) yield item; + yield element; + if (item != null && reverse && condition(element)) yield item; + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/misc/flexible_fractionally_sized_box.dart b/packages/stream_chat_flutter/lib/src/misc/flexible_fractionally_sized_box.dart new file mode 100644 index 000000000..86da24000 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/flexible_fractionally_sized_box.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +/// A widget that sizes its child to a fraction of the total available space. +class FlexibleFractionallySizedBox extends StatelessWidget { + /// Creates a widget that sizes its child to a fraction of the total available + /// space. + /// + /// If non-null, the [widthFactor] and [heightFactor] arguments must be + /// non-negative. + const FlexibleFractionallySizedBox({ + super.key, + this.alignment = Alignment.center, + this.widthFactor, + this.heightFactor, + this.child, + }) : assert(widthFactor == null || widthFactor >= 0.0, ''), + assert(heightFactor == null || heightFactor >= 0.0, ''); + + /// The widget below this widget in the tree. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget? child; + + /// If non-null, the fraction of the incoming width given to the child. + /// + /// If non-null, the child is given a tight width constraint that is the max + /// incoming width constraint multiplied by this factor. + /// + /// If null, the incoming width constraints are passed to the child + /// unmodified. + final double? widthFactor; + + /// If non-null, the fraction of the incoming height given to the child. + /// + /// If non-null, the child is given a tight height constraint that is the max + /// incoming height constraint multiplied by this factor. + /// + /// If null, the incoming height constraints are passed to the child + /// unmodified. + final double? heightFactor; + + /// How to align the child. + /// + /// The x and y values of the alignment control the horizontal and vertical + /// alignment, respectively. An x value of -1.0 means that the left edge of + /// the child is aligned with the left edge of the parent whereas an x value + /// of 1.0 means that the right edge of the child is aligned with the right + /// edge of the parent. Other values interpolate (and extrapolate) linearly. + /// For example, a value of 0.0 means that the center of the child is aligned + /// with the center of the parent. + /// + /// Defaults to [Alignment.center]. + /// + /// See also: + /// + /// * [Alignment], a class with convenient constants typically used to + /// specify an [AlignmentGeometry]. + /// * [AlignmentDirectional], like [Alignment] for specifying alignments + /// relative to text direction. + final AlignmentGeometry alignment; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + var maxWidth = constraints.maxWidth; + if (widthFactor case final widthFactor?) { + final width = maxWidth * widthFactor; + maxWidth = width; + } + + var maxHeight = constraints.maxHeight; + if (heightFactor case final heightFactor?) { + final height = maxHeight * heightFactor; + maxHeight = height; + } + + return UnconstrainedBox( + alignment: alignment, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: maxWidth, + maxHeight: maxHeight, + ), + child: child, + ), + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/misc/size_change_listener.dart b/packages/stream_chat_flutter/lib/src/misc/size_change_listener.dart new file mode 100644 index 000000000..1e6a2adfc --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/size_change_listener.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +/// A widget that calls the callback when the layout dimensions of +/// its child change. +class SizeChangeListener extends SingleChildRenderObjectWidget { + /// Creates a new instance of [SizeChangeListener]. + const SizeChangeListener({ + super.key, + required this.onSizeChanged, + super.child, + }); + + /// The action to perform when the size of child widget changes. + final ValueChanged onSizeChanged; + + @override + RenderObject createRenderObject(BuildContext context) { + return _RenderSizeChangedWithCallback(onSizeChanged: onSizeChanged); + } +} + +class _RenderSizeChangedWithCallback extends RenderProxyBox { + _RenderSizeChangedWithCallback({ + required this.onSizeChanged, + }); + + final ValueChanged onSizeChanged; + Size? _oldSize; + + @override + void performLayout() { + super.performLayout(); + if (size != _oldSize) { + _oldSize = size; + WidgetsBinding.instance.addPostFrameCallback((_) { + // Call the callback with the new size + onSizeChanged.call(size); + }); + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator.dart b/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator.dart new file mode 100644 index 000000000..8f57179b2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator.dart @@ -0,0 +1,107 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/reactions/indicator/reaction_indicator_icon_list.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// {@template streamReactionIndicator} +/// A widget that displays a horizontal list of reaction icons that users have +/// reacted with on a message. +/// +/// This widget is typically used to show the reactions on a message in a +/// compact way, allowing users to see which reactions have been added +/// to a message without opening a full user reactions view. +/// {@endtemplate} +class StreamReactionIndicator extends StatelessWidget { + /// {@macro streamReactionIndicator} + const StreamReactionIndicator({ + super.key, + this.onTap, + required this.message, + this.backgroundColor, + this.padding = const EdgeInsets.all(8), + this.scrollable = true, + this.borderRadius = const BorderRadius.all(Radius.circular(26)), + this.reactionSorting = ReactionSorting.byFirstReactionAt, + }); + + /// Message to attach the reaction to. + final Message message; + + /// Callback triggered when the reaction indicator is tapped. + final VoidCallback? onTap; + + /// Background color for the reaction indicator. + final Color? backgroundColor; + + /// Padding around the reaction picker. + /// + /// Defaults to `EdgeInsets.all(8)`. + final EdgeInsets padding; + + /// Whether the reaction picker should be scrollable. + /// + /// Defaults to `true`. + final bool scrollable; + + /// Border radius for the reaction picker. + /// + /// Defaults to a circular border with a radius of 26. + final BorderRadius? borderRadius; + + /// Sorting strategy for the reaction. + /// + /// Defaults to sorting by the first reaction at. + final Comparator reactionSorting; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final config = StreamChatConfiguration.of(context); + final reactionIcons = config.reactionIcons; + + final ownReactions = {...?message.ownReactions?.map((it) => it.type)}; + final indicatorIcons = message.reactionGroups?.entries + .sortedByCompare((it) => it.value, reactionSorting) + .map((group) { + final reactionIcon = reactionIcons.firstWhere( + (it) => it.type == group.key, + orElse: () => const StreamReactionIcon.unknown(), + ); + + return ReactionIndicatorIcon( + type: reactionIcon.type, + builder: reactionIcon.builder, + isSelected: ownReactions.contains(reactionIcon.type), + ); + }); + + final isSingleIndicatorIcon = indicatorIcons?.length == 1; + final extraPadding = switch (isSingleIndicatorIcon) { + true => EdgeInsets.zero, + false => const EdgeInsets.symmetric(horizontal: 4), + }; + + final indicator = ReactionIndicatorIconList( + indicatorIcons: [...?indicatorIcons], + ); + + return Material( + borderRadius: borderRadius, + clipBehavior: Clip.antiAlias, + color: backgroundColor ?? theme.colorTheme.barsBg, + child: InkWell( + onTap: onTap, + child: Padding( + padding: padding.add(extraPadding), + child: switch (scrollable) { + true => SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: indicator, + ), + false => indicator, + }, + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator_bubble_overlay.dart b/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator_bubble_overlay.dart new file mode 100644 index 000000000..cb4ce0d46 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator_bubble_overlay.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/reactions/indicator/reaction_indicator.dart'; +import 'package:stream_chat_flutter/src/reactions/reaction_bubble_overlay.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// {@template reactionIndicatorBubbleOverlay} +/// A widget that displays a reaction indicator bubble overlay attached to a +/// [child] widget. Typically used to show the reactions for a [Message]. +/// +/// It positions the reaction indicator relative to the provided [child] widget, +/// using the given [anchorOffset] and [childSizeDelta] for fine-tuned placement +/// {@endtemplate} +class ReactionIndicatorBubbleOverlay extends StatelessWidget { + /// {@macro reactionIndicatorBubbleOverlay} + const ReactionIndicatorBubbleOverlay({ + super.key, + this.onTap, + required this.message, + required this.child, + this.visible = true, + this.reverse = false, + this.anchorOffset = Offset.zero, + this.childSizeDelta = Offset.zero, + }); + + /// Whether the overlay should be visible. + final bool visible; + + /// Whether to reverse the alignment of the overlay. + final bool reverse; + + /// The widget to which the overlay is anchored. + final Widget child; + + /// The message to display reactions for. + final Message message; + + /// Callback triggered when the reaction indicator is tapped. + final VoidCallback? onTap; + + /// The offset to apply to the anchor position. + final Offset anchorOffset; + + /// The additional size delta to apply to the child widget for positioning. + final Offset childSizeDelta; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final messageTheme = theme.getMessageTheme(reverse: reverse); + + return ReactionBubbleOverlay( + visible: visible, + childSizeDelta: childSizeDelta, + config: ReactionBubbleConfig( + fillColor: messageTheme.reactionsBackgroundColor, + borderColor: messageTheme.reactionsBorderColor, + maskColor: messageTheme.reactionsMaskColor, + ), + anchor: ReactionBubbleAnchor( + offset: anchorOffset, + follower: AlignmentDirectional.bottomCenter, + target: AlignmentDirectional(reverse ? -1 : 1, -1), + ), + reaction: StreamReactionIndicator( + onTap: onTap, + message: message, + backgroundColor: messageTheme.reactionsBackgroundColor, + ), + child: child, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator_icon_list.dart b/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator_icon_list.dart new file mode 100644 index 000000000..c01222fc7 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/reactions/indicator/reaction_indicator_icon_list.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/misc/reaction_icon.dart'; + +/// {@template reactionIndicatorIconBuilder} +/// Function signature for building a custom reaction icon widget. +/// +/// This is used to customize how each reaction icon is displayed in the +/// [ReactionIndicatorIconList]. +/// +/// Parameters: +/// - [context]: The build context. +/// - [icon]: The reaction icon data containing type and selection state. +/// {@endtemplate} +typedef ReactionIndicatorIconBuilder = Widget Function( + BuildContext context, + ReactionIndicatorIcon icon, +); + +/// {@template reactionIndicatorIconList} +/// A widget that displays a list of reactionIcons that users have reacted with +/// on a message. +/// +/// also see: +/// - [StreamReactionIndicator], which is a higher-level widget that uses this +/// widget to display a reaction indicator in a modal or inline. +/// {@endtemplate} +class ReactionIndicatorIconList extends StatelessWidget { + /// {@macro reactionIndicatorIconList} + const ReactionIndicatorIconList({ + super.key, + required this.indicatorIcons, + this.iconBuilder = _defaultIconBuilder, + }); + + /// The list of available reaction indicator icons. + final List indicatorIcons; + + /// The builder used to create the reaction indicator icons. + final ReactionIndicatorIconBuilder iconBuilder; + + static Widget _defaultIconBuilder( + BuildContext context, + ReactionIndicatorIcon icon, + ) { + return icon.build(context); + } + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 4, + runAlignment: WrapAlignment.center, + alignment: WrapAlignment.spaceAround, + crossAxisAlignment: WrapCrossAlignment.center, + children: [...indicatorIcons.map((icon) => iconBuilder(context, icon))], + ); + } +} + +/// {@template reactionIndicatorIcon} +/// A data class that represents a reaction icon within the reaction indicator. +/// +/// This class holds information about a specific reaction, such as its type, +/// whether it's currently selected by the user, and a builder function +/// to construct its visual representation. +/// {@endtemplate} +class ReactionIndicatorIcon { + /// {@macro reactionIndicatorIcon} + const ReactionIndicatorIcon({ + required this.type, + this.isSelected = false, + this.iconSize = 16, + required ReactionIconBuilder builder, + }) : _builder = builder; + + /// The unique identifier for the reaction type (e.g., "like", "love"). + final String type; + + /// A boolean indicating whether this reaction is currently selected by the + /// user. + final bool isSelected; + + /// The size of the reaction icon. + final double iconSize; + + /// Builds the actual widget for this reaction icon using the provided + /// [context], selection state, and icon size. + Widget build(BuildContext context) => _builder(context, isSelected, iconSize); + final ReactionIconBuilder _builder; +} diff --git a/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart b/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart index 75da2e4cc..06456e85c 100644 --- a/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart +++ b/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker.dart @@ -36,7 +36,7 @@ class StreamReactionPicker extends StatelessWidget { required this.message, required this.reactionIcons, this.onReactionPicked, - this.padding = const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + this.padding = const EdgeInsets.all(4), this.scrollable = true, this.borderRadius = const BorderRadius.all(Radius.circular(24)), }); @@ -85,7 +85,7 @@ class StreamReactionPicker extends StatelessWidget { /// Padding around the reaction picker. /// - /// Defaults to `EdgeInsets.symmetric(horizontal: 8, vertical: 4)`. + /// Defaults to `EdgeInsets.all(4)`. final EdgeInsets padding; /// Whether the reaction picker should be scrollable. @@ -108,12 +108,18 @@ class StreamReactionPicker extends StatelessWidget { onReactionPicked: onReactionPicked, ); + final isSinglePickerIcon = reactionIcons.length == 1; + final extraPadding = switch (isSinglePickerIcon) { + true => EdgeInsets.zero, + false => const EdgeInsets.symmetric(horizontal: 4), + }; + return Material( borderRadius: borderRadius, clipBehavior: Clip.antiAlias, color: theme.colorTheme.barsBg, child: Padding( - padding: padding, + padding: padding.add(extraPadding), child: switch (scrollable) { false => reactionPicker, true => SingleChildScrollView( diff --git a/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker_bubble_overlay.dart b/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker_bubble_overlay.dart new file mode 100644 index 000000000..a13cc5f3f --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker_bubble_overlay.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/reactions/picker/reaction_picker.dart'; +import 'package:stream_chat_flutter/src/reactions/picker/reaction_picker_icon_list.dart'; +import 'package:stream_chat_flutter/src/reactions/reaction_bubble_overlay.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// {@template reactionPickerBubbleOverlay} +/// A widget that displays a reaction picker bubble overlay attached to a +/// [child] widget. Typically used with the [MessageWidget] as the child. +/// +/// It positions the reaction picker relative to the provided [child] widget, +/// using the given [anchorOffset] and [childSizeDelta] for fine-tuned placement +/// {@endtemplate} +class ReactionPickerBubbleOverlay extends StatelessWidget { + /// {@macro reactionPickerBubbleOverlay} + const ReactionPickerBubbleOverlay({ + super.key, + required this.message, + required this.child, + this.onReactionPicked, + this.visible = true, + this.reverse = false, + this.anchorOffset = Offset.zero, + this.childSizeDelta = Offset.zero, + this.reactionPickerBuilder = StreamReactionPicker.builder, + }); + + /// Whether the overlay should be visible. + final bool visible; + + /// Whether to reverse the alignment of the overlay. + final bool reverse; + + /// The widget to which the overlay is anchored. + final Widget child; + + /// The message to attach the reaction to. + final Message message; + + /// Callback triggered when a reaction is picked. + final OnReactionPicked? onReactionPicked; + + /// Builder for the reaction picker widget. + final ReactionPickerBuilder reactionPickerBuilder; + + /// The offset to apply to the anchor position. + final Offset anchorOffset; + + /// The additional size delta to apply to the child widget for positioning. + final Offset childSizeDelta; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final colorTheme = theme.colorTheme; + + return ReactionBubbleOverlay( + visible: visible, + childSizeDelta: childSizeDelta, + config: ReactionBubbleConfig( + maskWidth: 0, + borderWidth: 0, + fillColor: colorTheme.barsBg, + ), + anchor: ReactionBubbleAnchor( + offset: anchorOffset, + follower: AlignmentDirectional.bottomCenter, + target: AlignmentDirectional(reverse ? -1 : 1, -1), + ), + reaction: reactionPickerBuilder.call(context, message, onReactionPicked), + child: child, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker_icon_list.dart b/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker_icon_list.dart index 17dc67764..124757aeb 100644 --- a/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker_icon_list.dart +++ b/packages/stream_chat_flutter/lib/src/reactions/picker/reaction_picker_icon_list.dart @@ -7,7 +7,7 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; /// {@template onReactionPressed} /// Callback called when a reaction icon is pressed. /// {@endtemplate} -typedef OnReactionPicked = void Function(Reaction reaction); +typedef OnReactionPicked = ValueSetter; /// {@template onReactionPickerIconPressed} /// Callback called when a reaction picker icon is pressed. @@ -28,7 +28,7 @@ typedef OnReactionPickerIconPressed = ValueSetter; typedef ReactionPickerIconBuilder = Widget Function( BuildContext context, ReactionPickerIcon icon, - OnReactionPickerIconPressed? onPressed, + VoidCallback? onPressed, ); /// {@template reactionPickerIconList} @@ -70,7 +70,7 @@ class ReactionPickerIconList extends StatefulWidget { static Widget _defaultIconBuilder( BuildContext context, ReactionPickerIcon icon, - OnReactionPickerIconPressed? onPressed, + VoidCallback? onPressed, ) { return ReactionIconButton( icon: icon, @@ -183,8 +183,8 @@ class _ReactionPickerIconListState extends State { ); final onPressed = switch (widget.onReactionPicked) { - final onPicked? => (type) { - final picked = reaction ?? Reaction(type: type); + final onPicked? => () { + final picked = reaction ?? Reaction(type: icon.type); return onPicked(picked); }, _ => null, @@ -222,8 +222,9 @@ class ReactionPickerIcon { const ReactionPickerIcon({ required this.type, this.isSelected = false, - required this.builder, - }); + this.iconSize = 24, + required ReactionIconBuilder builder, + }) : _builder = builder; /// The unique identifier for the reaction type (e.g., "like", "love"). final String type; @@ -232,9 +233,13 @@ class ReactionPickerIcon { /// user. final bool isSelected; - /// A builder function responsible for creating the widget that visually - /// represents this reaction icon. - final ReactionIconBuilder builder; + /// The size of the reaction icon. + final double iconSize; + + /// Builds the actual widget for this reaction icon using the provided + /// [context], selection state, and icon size. + Widget build(BuildContext context) => _builder(context, isSelected, iconSize); + final ReactionIconBuilder _builder; } /// {@template reactionIconButton} @@ -255,25 +260,20 @@ class ReactionIconButton extends StatelessWidget { final ReactionPickerIcon icon; /// Callback triggered when the reaction picker icon is pressed. - final OnReactionPickerIconPressed? onPressed; + final VoidCallback? onPressed; @override Widget build(BuildContext context) { return IconButton( - iconSize: 24, - icon: icon.builder(context, icon.isSelected, 24), + key: Key(icon.type), + iconSize: icon.iconSize, + onPressed: onPressed, + icon: icon.build(context), padding: const EdgeInsets.all(4), style: IconButton.styleFrom( tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size.square(24), + minimumSize: Size.square(icon.iconSize), ), - onPressed: switch (onPressed) { - final onPressed? => () { - final type = icon.type; - return onPressed(type); - }, - _ => null, - }, ); } } diff --git a/packages/stream_chat_flutter/lib/src/reactions/reaction_bubble_overlay.dart b/packages/stream_chat_flutter/lib/src/reactions/reaction_bubble_overlay.dart new file mode 100644 index 000000000..bd1b50034 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/reactions/reaction_bubble_overlay.dart @@ -0,0 +1,373 @@ +// ignore_for_file: cascade_invocations + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_portal/flutter_portal.dart'; +import 'package:stream_chat_flutter/src/misc/size_change_listener.dart'; + +/// Signature for building a custom ReactionBubble widget. +typedef ReactionBubbleBuilder = Widget Function( + BuildContext context, + ReactionBubbleConfig config, + Widget child, +); + +/// Defines the anchor settings for positioning a ReactionBubble relative to a +/// target widget. +class ReactionBubbleAnchor { + /// Creates an anchor with custom alignment and offset. + const ReactionBubbleAnchor({ + this.offset = Offset.zero, + required this.target, + required this.follower, + this.shiftToWithinBound = const AxisFlag(x: true), + }); + + /// Creates an anchor that positions the bubble at the top-end of the + /// target widget. + const ReactionBubbleAnchor.topEnd({ + this.offset = Offset.zero, + this.shiftToWithinBound = const AxisFlag(x: true), + }) : target = AlignmentDirectional.topEnd, + follower = AlignmentDirectional.bottomCenter; + + /// Creates an anchor that positions the bubble at the top-start of the + /// target widget. + const ReactionBubbleAnchor.topStart({ + this.offset = Offset.zero, + this.shiftToWithinBound = const AxisFlag(x: true), + }) : target = AlignmentDirectional.topStart, + follower = AlignmentDirectional.bottomCenter; + + /// Additional offset applied to the bubble position. + final Offset offset; + + /// Target alignment relative to the target widget. + final AlignmentDirectional target; + + /// Alignment of the bubble follower relative to the target alignment. + final AlignmentDirectional follower; + + /// Whether to shift the bubble within the visible screen bounds along each + /// axis if it exceeds the screen size. + final AxisFlag shiftToWithinBound; +} + +/// An overlay widget that displays a reaction bubble near a child widget. +class ReactionBubbleOverlay extends StatefulWidget { + /// Creates a new instance of [ReactionBubbleOverlay]. + const ReactionBubbleOverlay({ + super.key, + this.visible = true, + required this.child, + required this.reaction, + this.childSizeDelta = Offset.zero, + this.builder = _defaultBuilder, + this.config = const ReactionBubbleConfig(), + this.anchor = const ReactionBubbleAnchor.topEnd(), + }); + + /// The target child widget to anchor the reaction bubble to. + final Widget child; + + /// The reaction widget to display inside the bubble. + final Widget reaction; + + /// Whether the reaction bubble is visible. + final bool visible; + + /// Optional adjustment to the child's reported size. + final Offset childSizeDelta; + + /// The configuration used for rendering the reaction bubble. + final ReactionBubbleConfig config; + + /// The anchor configuration to control bubble positioning. + final ReactionBubbleAnchor anchor; + + /// The builder used to create the bubble appearance. + final ReactionBubbleBuilder builder; + + static Widget _defaultBuilder( + BuildContext context, + ReactionBubbleConfig config, + Widget child, + ) { + return RepaintBoundary( + child: CustomPaint( + painter: ReactionBubblePainter(config: config), + child: child, + ), + ); + } + + @override + State createState() => _ReactionBubbleOverlayState(); +} + +class _ReactionBubbleOverlayState extends State { + Size? _childSize; + + /// Calculates the alignment for the bubble tail relative to the bubble rect. + AlignmentGeometry _calculateTailAlignment({ + required Size childSize, + required Rect bubbleRect, + required Size availableSpace, + bool reverse = false, + }) { + final childEdgeX = switch (reverse) { + true => availableSpace.width - childSize.width, + false => childSize.width + }; + + final idealBubbleLeft = childEdgeX - (bubbleRect.width / 2); + final maxLeft = availableSpace.width - bubbleRect.width; + final actualBubbleLeft = idealBubbleLeft.clamp(0, math.max(0, maxLeft)); + final tailOffset = childEdgeX - actualBubbleLeft; + + if (tailOffset == 0) return AlignmentDirectional.bottomCenter; + return AlignmentDirectional((tailOffset * 2 / bubbleRect.width) - 1, 1); + } + + @override + Widget build(BuildContext context) { + final child = SizeChangeListener( + onSizeChanged: (size) => setState(() => _childSize = size), + child: widget.child, + ); + + final childSize = _childSize; + // If the child size is not available or the overlay should not be visible, + // return the child without any overlay. + if (childSize == null || !widget.visible) return child; + + final alignment = widget.anchor; + final direction = Directionality.maybeOf(context); + final targetAlignment = alignment.target.resolve(direction); + final followerAlignment = alignment.follower.resolve(direction); + final availableSpace = MediaQuery.sizeOf(context); + + final reverse = targetAlignment.x < 0; + final config = widget.config.copyWith( + flipTail: reverse, + tailAlignment: (bubbleRect) { + final alignment = _calculateTailAlignment( + reverse: reverse, + bubbleRect: bubbleRect, + availableSpace: availableSpace, + childSize: childSize + widget.childSizeDelta, + ); + + return alignment.resolve(direction); + }, + ); + + return PortalTarget( + anchor: Aligned( + target: targetAlignment, + follower: followerAlignment, + offset: widget.anchor.offset, + shiftToWithinBound: widget.anchor.shiftToWithinBound, + ), + portalFollower: widget.builder(context, config, widget.reaction), + child: child, + ); + } +} + +/// Defines the visual configuration of a ReactionBubble. +class ReactionBubbleConfig { + /// Creates a new instance of [ReactionBubbleConfig] with default values. + const ReactionBubbleConfig({ + this.flipTail = false, + this.fillColor, + this.maskColor, + this.borderColor, + this.maskWidth = 2.0, + this.borderWidth = 1.0, + this.bigTailCircleRadius = 4.0, + this.smallTailCircleRadius = 2.0, + this.tailAlignment = _defaultTailAlignment, + }); + + /// Whether to flip the tail horizontally. + final bool flipTail; + + /// Fill color of the bubble. + final Color? fillColor; + + /// Mask color of the bubble (used for visual masking). + final Color? maskColor; + + /// Border color of the bubble. + final Color? borderColor; + + /// Width of the mask stroke. + final double maskWidth; + + /// Width of the border stroke. + final double borderWidth; + + /// Radius of the larger circle at the bubble tail. + final double bigTailCircleRadius; + + /// Radius of the smaller circle at the bubble tail. + final double smallTailCircleRadius; + + /// Function that defines the alignment of the tail within the bubble rect. + final Alignment Function(Rect) tailAlignment; + + static Alignment _defaultTailAlignment(Rect rect) => Alignment.bottomCenter; + + /// The total height contribution of the bubble tail. + double get tailHeight => bigTailCircleRadius * 2 + smallTailCircleRadius * 2; + + /// Returns a copy of this config with optional overrides. + ReactionBubbleConfig copyWith({ + bool? flipTail, + Color? fillColor, + Color? maskColor, + Color? borderColor, + double? maskWidth, + double? borderWidth, + double? bigTailCircleRadius, + double? smallTailCircleRadius, + Alignment Function(Rect)? tailAlignment, + }) { + return ReactionBubbleConfig( + flipTail: flipTail ?? this.flipTail, + fillColor: fillColor ?? this.fillColor, + maskColor: maskColor ?? this.maskColor, + borderColor: borderColor ?? this.borderColor, + maskWidth: maskWidth ?? this.maskWidth, + borderWidth: borderWidth ?? this.borderWidth, + bigTailCircleRadius: bigTailCircleRadius ?? this.bigTailCircleRadius, + smallTailCircleRadius: + smallTailCircleRadius ?? this.smallTailCircleRadius, + tailAlignment: tailAlignment ?? this.tailAlignment, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is ReactionBubbleConfig && + runtimeType == other.runtimeType && + flipTail == other.flipTail && + fillColor == other.fillColor && + maskColor == other.maskColor && + borderColor == other.borderColor && + maskWidth == other.maskWidth && + borderWidth == other.borderWidth && + bigTailCircleRadius == other.bigTailCircleRadius && + smallTailCircleRadius == other.smallTailCircleRadius && + tailAlignment == other.tailAlignment; + } + + @override + int get hashCode => + flipTail.hashCode ^ + fillColor.hashCode ^ + maskColor.hashCode ^ + borderColor.hashCode ^ + maskWidth.hashCode ^ + borderWidth.hashCode ^ + bigTailCircleRadius.hashCode ^ + smallTailCircleRadius.hashCode ^ + tailAlignment.hashCode; +} + +/// A CustomPainter that draws a ReactionBubble based on a ReactionBubbleConfig. +class ReactionBubblePainter extends CustomPainter { + /// Creates a [ReactionBubblePainter] with the specified configuration. + ReactionBubblePainter({ + this.config = const ReactionBubbleConfig(), + }) : _fillPaint = Paint() + ..color = config.fillColor ?? Colors.white + ..style = PaintingStyle.fill, + _maskPaint = Paint() + ..color = config.maskColor ?? Colors.white + ..style = PaintingStyle.fill, + _borderPaint = Paint() + ..color = config.borderColor ?? Colors.black + ..style = PaintingStyle.stroke + ..strokeWidth = config.borderWidth; + + /// Configuration used to style the bubble. + final ReactionBubbleConfig config; + + final Paint _fillPaint; + final Paint _borderPaint; + final Paint _maskPaint; + + @override + void paint(Canvas canvas, Size size) { + final tailHeight = config.tailHeight; + final fullHeight = size.height + tailHeight; + final bubbleHeight = fullHeight - tailHeight; + final bubbleWidth = size.width; + + final bubbleRect = RRect.fromRectAndRadius( + Rect.fromLTRB(0, 0, bubbleWidth, bubbleHeight), + Radius.circular(bubbleHeight / 2), + ); + + final alignment = config.tailAlignment.call(bubbleRect.outerRect); + final bigTailCircleCenter = alignment.withinRect(bubbleRect.tallMiddleRect); + + final bigTailCircleRect = Rect.fromCircle( + center: bigTailCircleCenter, + radius: config.bigTailCircleRadius, + ); + + final smallTailCircleOffset = Offset( + config.flipTail ? bigTailCircleRect.right : bigTailCircleRect.left, + bigTailCircleRect.bottom + config.smallTailCircleRadius, + ); + + final smallTailCircleRect = Rect.fromCircle( + center: smallTailCircleOffset, + radius: config.smallTailCircleRadius, + ); + + final reactionBubbleMaskPath = _buildCombinedPath( + bubbleRect.inflate(config.maskWidth), + bigTailCircleRect.inflate(config.maskWidth), + smallTailCircleRect.inflate(config.maskWidth), + ); + + canvas.drawPath(reactionBubbleMaskPath, _maskPaint); + + final reactionBubblePath = _buildCombinedPath( + bubbleRect, + bigTailCircleRect, + smallTailCircleRect, + ); + + canvas.drawPath(reactionBubblePath, _borderPaint); + canvas.drawPath(reactionBubblePath, _fillPaint); + } + + /// Builds a combined path of the bubble and tail circles. + Path _buildCombinedPath( + RRect bubble, + Rect bigCircle, + Rect smallCircle, + ) { + final bubblePath = Path()..addRRect(bubble); + final bigTailPath = Path()..addOval(bigCircle); + final smallTailPath = Path()..addOval(smallCircle); + + return Path.combine( + PathOperation.union, + Path.combine(PathOperation.union, bubblePath, bigTailPath), + smallTailPath, + ); + } + + @override + bool shouldRepaint(covariant ReactionBubblePainter oldDelegate) { + return true; + } +} diff --git a/packages/stream_chat_flutter/lib/src/reactions/reaction_indicator.dart b/packages/stream_chat_flutter/lib/src/reactions/reaction_indicator.dart deleted file mode 100644 index 7bc904b95..000000000 --- a/packages/stream_chat_flutter/lib/src/reactions/reaction_indicator.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template reactionIndicator} -/// Indicates the reaction a [StreamMessageWidget] has. -/// -/// Used in [MessageWidgetContent]. -/// {@endtemplate} -class ReactionIndicator extends StatelessWidget { - /// {@macro reactionIndicator} - const ReactionIndicator({ - super.key, - required this.ownId, - required this.message, - required this.onTap, - required this.reverse, - required this.messageTheme, - }); - - /// The id of the current user. - final String ownId; - - /// {@macro message} - final Message message; - - /// The callback to perform when the widget is tapped or clicked. - final VoidCallback onTap; - - /// {@macro reverse} - final bool reverse; - - /// {@macro messageTheme} - final StreamMessageThemeData messageTheme; - - @override - Widget build(BuildContext context) { - final reactionsMap = {}; - for (final reaction in [...?message.latestReactions]) { - final reactionType = reaction.type; - final userId = reaction.user?.id; - - if (reactionsMap.containsKey(reactionType) && userId != ownId) continue; - reactionsMap[reactionType] = reaction; - } - - final reactionsList = reactionsMap.values.sorted((prev, curr) { - final prevUserId = prev.user?.id; - final currUserId = curr.user?.id; - - if (prevUserId == null && currUserId == null) return 0; - if (prevUserId == null) return 1; - if (currUserId == null) return -1; - - if (prevUserId == ownId) return 1; - return -1; - }); - - return GestureDetector( - onTap: onTap, - child: StreamReactionBubble( - key: ValueKey('${message.id}.reactions'), - reverse: reverse, - flipTail: reverse, - backgroundColor: - messageTheme.reactionsBackgroundColor ?? Colors.transparent, - borderColor: messageTheme.reactionsBorderColor ?? Colors.transparent, - maskColor: messageTheme.reactionsMaskColor ?? Colors.transparent, - reactions: reactionsList, - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/reactions/reactions_align.dart b/packages/stream_chat_flutter/lib/src/reactions/reactions_align.dart deleted file mode 100644 index 218ac3f86..000000000 --- a/packages/stream_chat_flutter/lib/src/reactions/reactions_align.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// Extension on [Message] that provides alignment calculations for reaction -/// pickers. -/// -/// This extension adds functionality to determine the optimal positioning of -/// reaction pickers based on message size and available screen space. -extension ReactionPickerAlignment on Message { - /// Calculates the alignment for the reaction picker based on the message size - /// and the available space. - /// - /// The [fontSize] is used to calculate the size of the message, if not - /// provided, the font size of 1 is used. - /// - /// The [orientation] is used to calculate the size of the message, if not - /// provided, the orientation of the device is used. - AlignmentGeometry calculateReactionPickerAlignment({ - required BoxConstraints constraints, - double? fontSize, - Orientation orientation = Orientation.portrait, - bool reverse = false, - }) { - final maxWidth = constraints.maxWidth; - - final roughSentenceSize = roughMessageSize(fontSize); - final hasAttachments = attachments.isNotEmpty; - final isReply = quotedMessageId != null; - final isAttachment = hasAttachments && !isReply; - - // divFactor is the percentage of the available space that the message - // takes. - // - // When the divFactor is bigger than 0.5 that means that the messages is - // bigger than 50% of the available space and the modal should have an - // offset in the direction that the message grows. When the divFactor is - // smaller than 0.5 then the offset should be to he side opposite of the - // message growth. - // - // In resume, when divFactor > 0.5 then result > 0, when divFactor < 0.5 - // then result < 0. - var divFactor = 0.5; - - // When in portrait, attachments normally take 75% of the screen, when in - // landscape, attachments normally take 50% of the screen. - if (isAttachment) { - divFactor = switch (orientation) { - Orientation.portrait => 0.75, - Orientation.landscape => 0.5, - }; - } else { - divFactor = roughSentenceSize == 0 ? 0.5 : (roughSentenceSize / maxWidth); - } - - final signal = reverse ? 1 : -1; - final result = signal * (1 - divFactor * 2.0); - - // Ensure reactions don't get pushed past the edge of the screen. - // - // This happens if divFactor is really big. When this happens, we can simply - // move the model all the way to the end of screen. - return AlignmentDirectional(result.clamp(-1, 1), 0); - } -} diff --git a/packages/stream_chat_flutter/lib/src/reactions/user_reactions.dart b/packages/stream_chat_flutter/lib/src/reactions/user_reactions.dart index 6871b843f..68065e8af 100644 --- a/packages/stream_chat_flutter/lib/src/reactions/user_reactions.dart +++ b/packages/stream_chat_flutter/lib/src/reactions/user_reactions.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/avatars/user_avatar.dart'; import 'package:stream_chat_flutter/src/misc/empty_widget.dart'; -import 'package:stream_chat_flutter/src/reactions/reaction_bubble.dart'; +import 'package:stream_chat_flutter/src/misc/reaction_icon.dart'; +import 'package:stream_chat_flutter/src/reactions/indicator/reaction_indicator_icon_list.dart'; +import 'package:stream_chat_flutter/src/reactions/reaction_bubble_overlay.dart'; +import 'package:stream_chat_flutter/src/stream_chat_configuration.dart'; import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/utils/extensions.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -95,7 +98,15 @@ class _UserReactionItem extends StatelessWidget { final isCurrentUserReaction = reactionUser.id == currentUser?.id; final theme = StreamChatTheme.of(context); - final messageTheme = theme.getMessageTheme(reverse: !isCurrentUserReaction); + final messageTheme = theme.getMessageTheme(reverse: isCurrentUserReaction); + + final config = StreamChatConfiguration.of(context); + final reactionIcons = config.reactionIcons; + + final reactionIcon = reactionIcons.firstWhere( + (it) => it.type == reaction.type, + orElse: () => const StreamReactionIcon.unknown(), + ); return Column( mainAxisSize: MainAxisSize.min, @@ -112,21 +123,33 @@ class _UserReactionItem extends StatelessWidget { constraints: const BoxConstraints.tightFor(height: 64, width: 64), ), PositionedDirectional( - bottom: 0, - start: isCurrentUserReaction ? 0 : null, + bottom: 8, end: isCurrentUserReaction ? null : 0, + start: isCurrentUserReaction ? 0 : null, child: IgnorePointer( - child: StreamReactionBubble( - reactions: [reaction], - reverse: isCurrentUserReaction, - flipTail: isCurrentUserReaction, - backgroundColor: messageTheme.reactionsBackgroundColor ?? - Colors.transparent, - borderColor: - messageTheme.reactionsBorderColor ?? Colors.transparent, - maskColor: - messageTheme.reactionsMaskColor ?? Colors.transparent, - tailCirclesSpacing: 1, + child: RepaintBoundary( + child: CustomPaint( + painter: ReactionBubblePainter( + config: ReactionBubbleConfig( + flipTail: isCurrentUserReaction, + fillColor: messageTheme.reactionsBackgroundColor, + borderColor: messageTheme.reactionsBorderColor, + maskColor: messageTheme.reactionsMaskColor, + ), + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: ReactionIndicatorIconList( + indicatorIcons: [ + ReactionIndicatorIcon( + type: reactionIcon.type, + isSelected: isCurrentUserReaction, + builder: reactionIcon.builder, + ), + ], + ), + ), + ), ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart index f1cb07953..2a1a22e02 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart @@ -677,8 +677,8 @@ class StreamChatThemeData { /// If [reverse] is true, it returns the [otherMessageTheme], otherwise it /// returns the [ownMessageTheme]. StreamMessageThemeData getMessageTheme({bool reverse = false}) { - if (reverse) return otherMessageTheme; - return ownMessageTheme; + if (reverse) return ownMessageTheme; + return otherMessageTheme; } /// Creates a copy of [StreamChatThemeData] with specified attributes diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 2b36105d1..8d581e796 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -106,7 +106,6 @@ export 'src/poll/stream_poll_text_field.dart'; export 'src/reactions/picker/reaction_picker.dart'; export 'src/reactions/picker/reaction_picker_icon_list.dart'; export 'src/reactions/reaction_bubble.dart'; -export 'src/reactions/reaction_indicator.dart'; export 'src/reactions/user_reactions.dart'; export 'src/scroll_view/channel_scroll_view/stream_channel_grid_tile.dart'; export 'src/scroll_view/channel_scroll_view/stream_channel_grid_view.dart'; diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_dark.png index 4c6b39c5f..42832c2e7 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_dark.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_light.png index 6799aede6..bd4c34d70 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_light.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_dark.png index e5eec7fd0..c9e02502f 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_dark.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_light.png index 98224204f..253c820e8 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_light.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png index 5c6bdddee..faf887e30 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png index e535ad401..ef7b39da9 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_reversed_with_reactions_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png index 44119c0a4..355c43d2b 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png index 643d5f879..1af962812 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_actions_modal_with_reactions_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png index f8e44836a..e172a54eb 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png index b497e9390..8a76e3d5f 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png index 29d2e83cc..2439b81f0 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png index 992656803..382c565a0 100644 Binary files a/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png and b/packages/stream_chat_flutter/test/src/message_modal/goldens/ci/stream_message_reactions_modal_reversed_light.png differ diff --git a/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart index 5ca7f3547..c035a9cb4 100644 --- a/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart +++ b/packages/stream_chat_flutter/test/src/message_modal/message_actions_modal_test.dart @@ -2,6 +2,7 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -141,21 +142,18 @@ void main() { final theme = StreamChatTheme.of(context); final messageTheme = theme.getMessageTheme(reverse: reverse); - return Align( - alignment: reverse ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - color: messageTheme.messageBackgroundColor, - ), - child: Text( - message.text ?? '', - style: messageTheme.messageTextStyle, - ), + return Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: messageTheme.messageBackgroundColor, + ), + child: Text( + message.text ?? '', + style: messageTheme.messageTextStyle, ), ); }, @@ -233,25 +231,27 @@ Widget _wrapWithMaterialApp( Brightness? brightness, List? reactionIcons, }) { - return MaterialApp( - debugShowCheckedModeBanner: false, - home: StreamChatConfiguration( - data: StreamChatConfigurationData(reactionIcons: reactionIcons), - child: StreamChatTheme( - data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: ColoredBox( - color: theme.colorTheme.overlay, - child: Padding( - padding: const EdgeInsets.all(8), - child: child, + return Portal( + child: MaterialApp( + debugShowCheckedModeBanner: false, + home: StreamChatConfiguration( + data: StreamChatConfigurationData(reactionIcons: reactionIcons), + child: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: ColoredBox( + color: theme.colorTheme.overlay, + child: Padding( + padding: const EdgeInsets.all(8), + child: child, + ), ), - ), - ); - }), + ); + }), + ), ), ), ); diff --git a/packages/stream_chat_flutter/test/src/message_modal/message_reactions_modal_test.dart b/packages/stream_chat_flutter/test/src/message_modal/message_reactions_modal_test.dart index ab4362c81..2b942ad0d 100644 --- a/packages/stream_chat_flutter/test/src/message_modal/message_reactions_modal_test.dart +++ b/packages/stream_chat_flutter/test/src/message_modal/message_reactions_modal_test.dart @@ -1,5 +1,6 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -182,21 +183,18 @@ void main() { final theme = StreamChatTheme.of(context); final messageTheme = theme.getMessageTheme(reverse: reverse); - return Align( - alignment: reverse ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(14), - color: messageTheme.messageBackgroundColor, - ), - child: Text( - message.text ?? '', - style: messageTheme.messageTextStyle, - ), + return Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: messageTheme.messageBackgroundColor, + ), + child: Text( + message.text ?? '', + style: messageTheme.messageTextStyle, ), ); }, @@ -246,29 +244,31 @@ Widget _wrapWithMaterialApp( Brightness? brightness, List? reactionIcons, }) { - return MaterialApp( - debugShowCheckedModeBanner: false, - home: StreamChat( - client: client, - // Mock the connectivity stream to always return wifi. - connectivityStream: Stream.value([ConnectivityResult.wifi]), - child: StreamChatConfiguration( - data: StreamChatConfigurationData(reactionIcons: reactionIcons), - child: StreamChatTheme( - data: StreamChatThemeData(brightness: brightness), - child: Builder(builder: (context) { - final theme = StreamChatTheme.of(context); - return Scaffold( - backgroundColor: theme.colorTheme.appBg, - body: ColoredBox( - color: theme.colorTheme.overlay, - child: Padding( - padding: const EdgeInsets.all(8), - child: child, + return Portal( + child: MaterialApp( + debugShowCheckedModeBanner: false, + home: StreamChat( + client: client, + // Mock the connectivity stream to always return wifi. + connectivityStream: Stream.value([ConnectivityResult.wifi]), + child: StreamChatConfiguration( + data: StreamChatConfigurationData(reactionIcons: reactionIcons), + child: StreamChatTheme( + data: StreamChatThemeData(brightness: brightness), + child: Builder(builder: (context) { + final theme = StreamChatTheme.of(context); + return Scaffold( + backgroundColor: theme.colorTheme.appBg, + body: ColoredBox( + color: theme.colorTheme.overlay, + child: Padding( + padding: const EdgeInsets.all(8), + child: child, + ), ), - ), - ); - }), + ); + }), + ), ), ), ), diff --git a/packages/stream_chat_flutter/test/src/reactions/reaction_picker_icon_list_test.dart b/packages/stream_chat_flutter/test/src/reactions/reaction_picker_icon_list_test.dart index 4dbb51949..0c86fc637 100644 --- a/packages/stream_chat_flutter/test/src/reactions/reaction_picker_icon_list_test.dart +++ b/packages/stream_chat_flutter/test/src/reactions/reaction_picker_icon_list_test.dart @@ -59,7 +59,7 @@ void main() { type: reactionIcons.first.type, builder: reactionIcons.first.builder, ), - onPressed: (type) {}, + onPressed: () {}, ), ), ); @@ -81,7 +81,7 @@ void main() { type: reactionIcons.first.type, builder: reactionIcons.first.builder, ), - onPressed: (type) { + onPressed: () { callbackTriggered = true; }, ), @@ -110,7 +110,7 @@ void main() { type: reactionIcons.first.type, builder: reactionIcons.first.builder, ), - onPressed: (type) {}, + onPressed: () {}, ), ), ); @@ -127,7 +127,7 @@ void main() { type: reactionIcons.first.type, builder: reactionIcons.first.builder, ), - onPressed: (type) {}, + onPressed: () {}, ), ), );