From 83278382fbc08249151d2d0a4c5a3d9e7111e8da Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:01:09 +0200 Subject: [PATCH 01/36] Create package --- packages/flyer_chat_reactions/LICENSE | 21 +++++++++++++++++ .../analysis_options.yaml | 1 + .../lib/flyer_chat_reactions.dart | 0 packages/flyer_chat_reactions/pubspec.yaml | 23 +++++++++++++++++++ 4 files changed, 45 insertions(+) create mode 100644 packages/flyer_chat_reactions/LICENSE create mode 100644 packages/flyer_chat_reactions/analysis_options.yaml create mode 100644 packages/flyer_chat_reactions/lib/flyer_chat_reactions.dart create mode 100644 packages/flyer_chat_reactions/pubspec.yaml diff --git a/packages/flyer_chat_reactions/LICENSE b/packages/flyer_chat_reactions/LICENSE new file mode 100644 index 000000000..5a17b5947 --- /dev/null +++ b/packages/flyer_chat_reactions/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Oleksandr Demchenko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/flyer_chat_reactions/analysis_options.yaml b/packages/flyer_chat_reactions/analysis_options.yaml new file mode 100644 index 000000000..f04c6cf0f --- /dev/null +++ b/packages/flyer_chat_reactions/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml diff --git a/packages/flyer_chat_reactions/lib/flyer_chat_reactions.dart b/packages/flyer_chat_reactions/lib/flyer_chat_reactions.dart new file mode 100644 index 000000000..e69de29bb diff --git a/packages/flyer_chat_reactions/pubspec.yaml b/packages/flyer_chat_reactions/pubspec.yaml new file mode 100644 index 000000000..9b9b6319a --- /dev/null +++ b/packages/flyer_chat_reactions/pubspec.yaml @@ -0,0 +1,23 @@ +name: flyer_chat_reactions +version: 0.0.12 +description: > + Reactions package for Flutter chat apps, complementing flutter_chat_ui. #chat #ui +homepage: https://flyer.chat +repository: https://github.com/flyerhq/flutter_chat_ui + +environment: + sdk: ">=3.7.0 <4.0.0" + flutter: ">=3.29.0" + +dependencies: + animate_do: ^4.2.0 + flutter: + sdk: flutter + flutter_chat_core: ^2.7.0 + flutter_chat_ui: ^2.7.0 + provider: ^6.1.5 + +dev_dependencies: + flutter_lints: ^6.0.0 + flutter_test: + sdk: flutter From 664493f0c7fa0f2cfe526915df15bb6e43e95269 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:02:05 +0200 Subject: [PATCH 02/36] Create ChatTheme extension --- .../lib/src/helpers/chat_theme_extensions.dart | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/flyer_chat_reactions/lib/src/helpers/chat_theme_extensions.dart diff --git a/packages/flyer_chat_reactions/lib/src/helpers/chat_theme_extensions.dart b/packages/flyer_chat_reactions/lib/src/helpers/chat_theme_extensions.dart new file mode 100644 index 000000000..3184e34bd --- /dev/null +++ b/packages/flyer_chat_reactions/lib/src/helpers/chat_theme_extensions.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; + +extension ReactionsTheme on ChatTheme { + Color get reactionBackgroundColor => colors.surfaceContainer; + Color get reactionReactedBackgroundColor => colors.surfaceContainerHighest; + Color get reactionBorderColor => colors.surface; + + Color get reactionCountTextColor => colors.onSurface; + TextStyle get reactionEmojiTextStyle => typography.bodyMedium; + TextStyle get reactionCountTextStyle => + typography.bodySmall.copyWith(fontWeight: FontWeight.bold); + TextStyle get reactionSurplusTextStyle => + typography.bodySmall.copyWith(fontWeight: FontWeight.bold); +} From d40608be4645cd435b11f95f4820f9d2bfb60549 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:19:39 +0200 Subject: [PATCH 03/36] Typedef message's reactions --- .../lib/src/models/message.dart | 18 +++-- .../lib/src/models/message.freezed.dart | 80 +++++++++---------- .../lib/src/utils/typedef.dart | 3 + 3 files changed, 53 insertions(+), 48 deletions(-) create mode 100644 packages/flyer_chat_reactions/lib/src/utils/typedef.dart diff --git a/packages/flutter_chat_core/lib/src/models/message.dart b/packages/flutter_chat_core/lib/src/models/message.dart index 79bce33c9..ecd5d158d 100644 --- a/packages/flutter_chat_core/lib/src/models/message.dart +++ b/packages/flutter_chat_core/lib/src/models/message.dart @@ -8,6 +8,8 @@ import 'link_preview_data.dart'; part 'message.freezed.dart'; part 'message.g.dart'; +typedef MessageReactions = Map>; + /// Base class for all message types. /// /// Uses a sealed class hierarchy with Freezed for immutability and union types. @@ -50,7 +52,7 @@ sealed class Message with _$Message { @EpochDateTimeConverter() DateTime? editedAt, /// Map of reaction keys to lists of user IDs who reacted. - Map>? reactions, + MessageReactions? reactions, /// Indicates if the message is pinned. bool? pinned, @@ -152,7 +154,7 @@ sealed class Message with _$Message { @EpochDateTimeConverter() DateTime? updatedAt, /// Map of reaction keys to lists of user IDs who reacted. - Map>? reactions, + MessageReactions? reactions, /// Indicates if the message is pinned. bool? pinned, @@ -222,7 +224,7 @@ sealed class Message with _$Message { @EpochDateTimeConverter() DateTime? updatedAt, /// Map of reaction keys to lists of user IDs who reacted. - Map>? reactions, + MessageReactions? reactions, /// Indicates if the message is pinned. bool? pinned, @@ -280,7 +282,7 @@ sealed class Message with _$Message { @EpochDateTimeConverter() DateTime? updatedAt, /// Map of reaction keys to lists of user IDs who reacted. - Map>? reactions, + MessageReactions? reactions, /// Indicates if the message is pinned. bool? pinned, @@ -344,7 +346,7 @@ sealed class Message with _$Message { @EpochDateTimeConverter() DateTime? updatedAt, /// Map of reaction keys to lists of user IDs who reacted. - Map>? reactions, + MessageReactions? reactions, /// Indicates if the message is pinned. bool? pinned, @@ -405,7 +407,7 @@ sealed class Message with _$Message { @EpochDateTimeConverter() DateTime? updatedAt, /// Map of reaction keys to lists of user IDs who reacted. - Map>? reactions, + MessageReactions? reactions, /// Indicates if the message is pinned. bool? pinned, @@ -454,7 +456,7 @@ sealed class Message with _$Message { @EpochDateTimeConverter() DateTime? updatedAt, /// Map of reaction keys to lists of user IDs who reacted. - Map>? reactions, + MessageReactions? reactions, /// Indicates if the message is pinned. bool? pinned, @@ -501,7 +503,7 @@ sealed class Message with _$Message { @EpochDateTimeConverter() DateTime? updatedAt, /// Map of reaction keys to lists of user IDs who reacted. - Map>? reactions, + MessageReactions? reactions, /// Indicates if the message is pinned. bool? pinned, diff --git a/packages/flutter_chat_core/lib/src/models/message.freezed.dart b/packages/flutter_chat_core/lib/src/models/message.freezed.dart index c360092a4..b8bcacad4 100644 --- a/packages/flutter_chat_core/lib/src/models/message.freezed.dart +++ b/packages/flutter_chat_core/lib/src/models/message.freezed.dart @@ -152,7 +152,7 @@ as MessageStatus?, @JsonSerializable() class TextMessage extends Message { - const TextMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, @EpochDateTimeConverter() this.editedAt, final Map>? reactions, this.pinned, final Map? metadata, this.status, required this.text, this.linkPreviewData, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'text',super._(); + const TextMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, @EpochDateTimeConverter() this.editedAt, final MessageReactions? reactions, this.pinned, final Map? metadata, this.status, required this.text, this.linkPreviewData, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'text',super._(); factory TextMessage.fromJson(Map json) => _$TextMessageFromJson(json); /// Unique identifier for the message. @@ -178,9 +178,9 @@ class TextMessage extends Message { /// Timestamp when the message was last edited. @EpochDateTimeConverter() final DateTime? editedAt; /// Map of reaction keys to lists of user IDs who reacted. - final Map>? _reactions; + final MessageReactions? _reactions; /// Map of reaction keys to lists of user IDs who reacted. -@override Map>? get reactions { +@override MessageReactions? get reactions { final value = _reactions; if (value == null) return null; if (_reactions is EqualUnmodifiableMapView) return _reactions; @@ -246,7 +246,7 @@ abstract mixin class $TextMessageCopyWith<$Res> implements $MessageCopyWith<$Res factory $TextMessageCopyWith(TextMessage value, $Res Function(TextMessage) _then) = _$TextMessageCopyWithImpl; @override @useResult $Res call({ - MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt,@EpochDateTimeConverter() DateTime? editedAt, Map>? reactions, bool? pinned, Map? metadata, MessageStatus? status, String text, LinkPreviewData? linkPreviewData + MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt,@EpochDateTimeConverter() DateTime? editedAt, MessageReactions? reactions, bool? pinned, Map? metadata, MessageStatus? status, String text, LinkPreviewData? linkPreviewData }); @@ -277,7 +277,7 @@ as DateTime?,seenAt: freezed == seenAt ? _self.seenAt : seenAt // ignore: cast_n as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime?,editedAt: freezed == editedAt ? _self.editedAt : editedAt // ignore: cast_nullable_to_non_nullable as DateTime?,reactions: freezed == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable -as Map>?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable +as MessageReactions?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable as bool?,metadata: freezed == metadata ? _self._metadata : metadata // ignore: cast_nullable_to_non_nullable as Map?,status: freezed == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as MessageStatus?,text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable @@ -440,7 +440,7 @@ as String, @JsonSerializable() class ImageMessage extends Message { - const ImageMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final Map>? reactions, this.pinned, final Map? metadata, this.status, required this.source, this.text, this.thumbhash, this.blurhash, this.width, this.height, this.size, this.hasOverlay, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'image',super._(); + const ImageMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final MessageReactions? reactions, this.pinned, final Map? metadata, this.status, required this.source, this.text, this.thumbhash, this.blurhash, this.width, this.height, this.size, this.hasOverlay, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'image',super._(); factory ImageMessage.fromJson(Map json) => _$ImageMessageFromJson(json); /// Unique identifier for the message. @@ -464,9 +464,9 @@ class ImageMessage extends Message { /// Timestamp when the message was last updated. @override@EpochDateTimeConverter() final DateTime? updatedAt; /// Map of reaction keys to lists of user IDs who reacted. - final Map>? _reactions; + final MessageReactions? _reactions; /// Map of reaction keys to lists of user IDs who reacted. -@override Map>? get reactions { +@override MessageReactions? get reactions { final value = _reactions; if (value == null) return null; if (_reactions is EqualUnmodifiableMapView) return _reactions; @@ -544,7 +544,7 @@ abstract mixin class $ImageMessageCopyWith<$Res> implements $MessageCopyWith<$Re factory $ImageMessageCopyWith(ImageMessage value, $Res Function(ImageMessage) _then) = _$ImageMessageCopyWithImpl; @override @useResult $Res call({ - MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, Map>? reactions, bool? pinned, Map? metadata, MessageStatus? status, String source, String? text, String? thumbhash, String? blurhash, double? width, double? height, int? size, bool? hasOverlay + MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, MessageReactions? reactions, bool? pinned, Map? metadata, MessageStatus? status, String source, String? text, String? thumbhash, String? blurhash, double? width, double? height, int? size, bool? hasOverlay }); @@ -574,7 +574,7 @@ as DateTime?,deliveredAt: freezed == deliveredAt ? _self.deliveredAt : delivered as DateTime?,seenAt: freezed == seenAt ? _self.seenAt : seenAt // ignore: cast_nullable_to_non_nullable as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime?,reactions: freezed == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable -as Map>?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable +as MessageReactions?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable as bool?,metadata: freezed == metadata ? _self._metadata : metadata // ignore: cast_nullable_to_non_nullable as Map?,status: freezed == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as MessageStatus?,source: null == source ? _self.source : source // ignore: cast_nullable_to_non_nullable @@ -596,7 +596,7 @@ as bool?, @JsonSerializable() class FileMessage extends Message { - const FileMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final Map>? reactions, this.pinned, final Map? metadata, this.status, required this.source, required this.name, this.size, this.mimeType, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'file',super._(); + const FileMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final MessageReactions? reactions, this.pinned, final Map? metadata, this.status, required this.source, required this.name, this.size, this.mimeType, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'file',super._(); factory FileMessage.fromJson(Map json) => _$FileMessageFromJson(json); /// Unique identifier for the message. @@ -620,9 +620,9 @@ class FileMessage extends Message { /// Timestamp when the message was last updated. @override@EpochDateTimeConverter() final DateTime? updatedAt; /// Map of reaction keys to lists of user IDs who reacted. - final Map>? _reactions; + final MessageReactions? _reactions; /// Map of reaction keys to lists of user IDs who reacted. -@override Map>? get reactions { +@override MessageReactions? get reactions { final value = _reactions; if (value == null) return null; if (_reactions is EqualUnmodifiableMapView) return _reactions; @@ -692,7 +692,7 @@ abstract mixin class $FileMessageCopyWith<$Res> implements $MessageCopyWith<$Res factory $FileMessageCopyWith(FileMessage value, $Res Function(FileMessage) _then) = _$FileMessageCopyWithImpl; @override @useResult $Res call({ - MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, Map>? reactions, bool? pinned, Map? metadata, MessageStatus? status, String source, String name, int? size, String? mimeType + MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, MessageReactions? reactions, bool? pinned, Map? metadata, MessageStatus? status, String source, String name, int? size, String? mimeType }); @@ -722,7 +722,7 @@ as DateTime?,deliveredAt: freezed == deliveredAt ? _self.deliveredAt : delivered as DateTime?,seenAt: freezed == seenAt ? _self.seenAt : seenAt // ignore: cast_nullable_to_non_nullable as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime?,reactions: freezed == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable -as Map>?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable +as MessageReactions?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable as bool?,metadata: freezed == metadata ? _self._metadata : metadata // ignore: cast_nullable_to_non_nullable as Map?,status: freezed == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as MessageStatus?,source: null == source ? _self.source : source // ignore: cast_nullable_to_non_nullable @@ -740,7 +740,7 @@ as String?, @JsonSerializable() class VideoMessage extends Message { - const VideoMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final Map>? reactions, this.pinned, final Map? metadata, this.status, required this.source, this.text, this.name, this.size, this.width, this.height, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'video',super._(); + const VideoMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final MessageReactions? reactions, this.pinned, final Map? metadata, this.status, required this.source, this.text, this.name, this.size, this.width, this.height, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'video',super._(); factory VideoMessage.fromJson(Map json) => _$VideoMessageFromJson(json); /// Unique identifier for the message. @@ -764,9 +764,9 @@ class VideoMessage extends Message { /// Timestamp when the message was last updated. @override@EpochDateTimeConverter() final DateTime? updatedAt; /// Map of reaction keys to lists of user IDs who reacted. - final Map>? _reactions; + final MessageReactions? _reactions; /// Map of reaction keys to lists of user IDs who reacted. -@override Map>? get reactions { +@override MessageReactions? get reactions { final value = _reactions; if (value == null) return null; if (_reactions is EqualUnmodifiableMapView) return _reactions; @@ -840,7 +840,7 @@ abstract mixin class $VideoMessageCopyWith<$Res> implements $MessageCopyWith<$Re factory $VideoMessageCopyWith(VideoMessage value, $Res Function(VideoMessage) _then) = _$VideoMessageCopyWithImpl; @override @useResult $Res call({ - MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, Map>? reactions, bool? pinned, Map? metadata, MessageStatus? status, String source, String? text, String? name, int? size, double? width, double? height + MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, MessageReactions? reactions, bool? pinned, Map? metadata, MessageStatus? status, String source, String? text, String? name, int? size, double? width, double? height }); @@ -870,7 +870,7 @@ as DateTime?,deliveredAt: freezed == deliveredAt ? _self.deliveredAt : delivered as DateTime?,seenAt: freezed == seenAt ? _self.seenAt : seenAt // ignore: cast_nullable_to_non_nullable as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime?,reactions: freezed == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable -as Map>?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable +as MessageReactions?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable as bool?,metadata: freezed == metadata ? _self._metadata : metadata // ignore: cast_nullable_to_non_nullable as Map?,status: freezed == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as MessageStatus?,source: null == source ? _self.source : source // ignore: cast_nullable_to_non_nullable @@ -890,7 +890,7 @@ as double?, @JsonSerializable() class AudioMessage extends Message { - const AudioMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final Map>? reactions, this.pinned, final Map? metadata, this.status, required this.source, @DurationConverter() required this.duration, this.text, this.size, final List? waveform, final String? $type}): _reactions = reactions,_metadata = metadata,_waveform = waveform,$type = $type ?? 'audio',super._(); + const AudioMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final MessageReactions? reactions, this.pinned, final Map? metadata, this.status, required this.source, @DurationConverter() required this.duration, this.text, this.size, final List? waveform, final String? $type}): _reactions = reactions,_metadata = metadata,_waveform = waveform,$type = $type ?? 'audio',super._(); factory AudioMessage.fromJson(Map json) => _$AudioMessageFromJson(json); /// Unique identifier for the message. @@ -914,9 +914,9 @@ class AudioMessage extends Message { /// Timestamp when the message was last updated. @override@EpochDateTimeConverter() final DateTime? updatedAt; /// Map of reaction keys to lists of user IDs who reacted. - final Map>? _reactions; + final MessageReactions? _reactions; /// Map of reaction keys to lists of user IDs who reacted. -@override Map>? get reactions { +@override MessageReactions? get reactions { final value = _reactions; if (value == null) return null; if (_reactions is EqualUnmodifiableMapView) return _reactions; @@ -997,7 +997,7 @@ abstract mixin class $AudioMessageCopyWith<$Res> implements $MessageCopyWith<$Re factory $AudioMessageCopyWith(AudioMessage value, $Res Function(AudioMessage) _then) = _$AudioMessageCopyWithImpl; @override @useResult $Res call({ - MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, Map>? reactions, bool? pinned, Map? metadata, MessageStatus? status, String source,@DurationConverter() Duration duration, String? text, int? size, List? waveform + MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, MessageReactions? reactions, bool? pinned, Map? metadata, MessageStatus? status, String source,@DurationConverter() Duration duration, String? text, int? size, List? waveform }); @@ -1027,7 +1027,7 @@ as DateTime?,deliveredAt: freezed == deliveredAt ? _self.deliveredAt : delivered as DateTime?,seenAt: freezed == seenAt ? _self.seenAt : seenAt // ignore: cast_nullable_to_non_nullable as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime?,reactions: freezed == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable -as Map>?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable +as MessageReactions?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable as bool?,metadata: freezed == metadata ? _self._metadata : metadata // ignore: cast_nullable_to_non_nullable as Map?,status: freezed == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as MessageStatus?,source: null == source ? _self.source : source // ignore: cast_nullable_to_non_nullable @@ -1046,7 +1046,7 @@ as List?, @JsonSerializable() class SystemMessage extends Message { - const SystemMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final Map>? reactions, this.pinned, final Map? metadata, this.status, required this.text, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'system',super._(); + const SystemMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final MessageReactions? reactions, this.pinned, final Map? metadata, this.status, required this.text, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'system',super._(); factory SystemMessage.fromJson(Map json) => _$SystemMessageFromJson(json); /// Unique identifier for the message. @@ -1070,9 +1070,9 @@ class SystemMessage extends Message { /// Timestamp when the message was last updated. @override@EpochDateTimeConverter() final DateTime? updatedAt; /// Map of reaction keys to lists of user IDs who reacted. - final Map>? _reactions; + final MessageReactions? _reactions; /// Map of reaction keys to lists of user IDs who reacted. -@override Map>? get reactions { +@override MessageReactions? get reactions { final value = _reactions; if (value == null) return null; if (_reactions is EqualUnmodifiableMapView) return _reactions; @@ -1136,7 +1136,7 @@ abstract mixin class $SystemMessageCopyWith<$Res> implements $MessageCopyWith<$R factory $SystemMessageCopyWith(SystemMessage value, $Res Function(SystemMessage) _then) = _$SystemMessageCopyWithImpl; @override @useResult $Res call({ - MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, Map>? reactions, bool? pinned, Map? metadata, MessageStatus? status, String text + MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, MessageReactions? reactions, bool? pinned, Map? metadata, MessageStatus? status, String text }); @@ -1166,7 +1166,7 @@ as DateTime?,deliveredAt: freezed == deliveredAt ? _self.deliveredAt : delivered as DateTime?,seenAt: freezed == seenAt ? _self.seenAt : seenAt // ignore: cast_nullable_to_non_nullable as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime?,reactions: freezed == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable -as Map>?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable +as MessageReactions?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable as bool?,metadata: freezed == metadata ? _self._metadata : metadata // ignore: cast_nullable_to_non_nullable as Map?,status: freezed == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as MessageStatus?,text: null == text ? _self.text : text // ignore: cast_nullable_to_non_nullable @@ -1181,7 +1181,7 @@ as String, @JsonSerializable() class CustomMessage extends Message { - const CustomMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final Map>? reactions, this.pinned, final Map? metadata, this.status, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'custom',super._(); + const CustomMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final MessageReactions? reactions, this.pinned, final Map? metadata, this.status, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'custom',super._(); factory CustomMessage.fromJson(Map json) => _$CustomMessageFromJson(json); /// Unique identifier for the message. @@ -1205,9 +1205,9 @@ class CustomMessage extends Message { /// Timestamp when the message was last updated. @override@EpochDateTimeConverter() final DateTime? updatedAt; /// Map of reaction keys to lists of user IDs who reacted. - final Map>? _reactions; + final MessageReactions? _reactions; /// Map of reaction keys to lists of user IDs who reacted. -@override Map>? get reactions { +@override MessageReactions? get reactions { final value = _reactions; if (value == null) return null; if (_reactions is EqualUnmodifiableMapView) return _reactions; @@ -1269,7 +1269,7 @@ abstract mixin class $CustomMessageCopyWith<$Res> implements $MessageCopyWith<$R factory $CustomMessageCopyWith(CustomMessage value, $Res Function(CustomMessage) _then) = _$CustomMessageCopyWithImpl; @override @useResult $Res call({ - MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, Map>? reactions, bool? pinned, Map? metadata, MessageStatus? status + MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, MessageReactions? reactions, bool? pinned, Map? metadata, MessageStatus? status }); @@ -1299,7 +1299,7 @@ as DateTime?,deliveredAt: freezed == deliveredAt ? _self.deliveredAt : delivered as DateTime?,seenAt: freezed == seenAt ? _self.seenAt : seenAt // ignore: cast_nullable_to_non_nullable as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime?,reactions: freezed == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable -as Map>?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable +as MessageReactions?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable as bool?,metadata: freezed == metadata ? _self._metadata : metadata // ignore: cast_nullable_to_non_nullable as Map?,status: freezed == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as MessageStatus?, @@ -1313,7 +1313,7 @@ as MessageStatus?, @JsonSerializable() class UnsupportedMessage extends Message { - const UnsupportedMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final Map>? reactions, this.pinned, final Map? metadata, this.status, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'unsupported',super._(); + const UnsupportedMessage({required this.id, required this.authorId, this.replyToMessageId, @EpochDateTimeConverter() this.createdAt, @EpochDateTimeConverter() this.deletedAt, @EpochDateTimeConverter() this.failedAt, @EpochDateTimeConverter() this.sentAt, @EpochDateTimeConverter() this.deliveredAt, @EpochDateTimeConverter() this.seenAt, @EpochDateTimeConverter() this.updatedAt, final MessageReactions? reactions, this.pinned, final Map? metadata, this.status, final String? $type}): _reactions = reactions,_metadata = metadata,$type = $type ?? 'unsupported',super._(); factory UnsupportedMessage.fromJson(Map json) => _$UnsupportedMessageFromJson(json); /// Unique identifier for the message. @@ -1337,9 +1337,9 @@ class UnsupportedMessage extends Message { /// Timestamp when the message was last updated. @override@EpochDateTimeConverter() final DateTime? updatedAt; /// Map of reaction keys to lists of user IDs who reacted. - final Map>? _reactions; + final MessageReactions? _reactions; /// Map of reaction keys to lists of user IDs who reacted. -@override Map>? get reactions { +@override MessageReactions? get reactions { final value = _reactions; if (value == null) return null; if (_reactions is EqualUnmodifiableMapView) return _reactions; @@ -1401,7 +1401,7 @@ abstract mixin class $UnsupportedMessageCopyWith<$Res> implements $MessageCopyWi factory $UnsupportedMessageCopyWith(UnsupportedMessage value, $Res Function(UnsupportedMessage) _then) = _$UnsupportedMessageCopyWithImpl; @override @useResult $Res call({ - MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, Map>? reactions, bool? pinned, Map? metadata, MessageStatus? status + MessageID id, UserID authorId, MessageID? replyToMessageId,@EpochDateTimeConverter() DateTime? createdAt,@EpochDateTimeConverter() DateTime? deletedAt,@EpochDateTimeConverter() DateTime? failedAt,@EpochDateTimeConverter() DateTime? sentAt,@EpochDateTimeConverter() DateTime? deliveredAt,@EpochDateTimeConverter() DateTime? seenAt,@EpochDateTimeConverter() DateTime? updatedAt, MessageReactions? reactions, bool? pinned, Map? metadata, MessageStatus? status }); @@ -1431,7 +1431,7 @@ as DateTime?,deliveredAt: freezed == deliveredAt ? _self.deliveredAt : delivered as DateTime?,seenAt: freezed == seenAt ? _self.seenAt : seenAt // ignore: cast_nullable_to_non_nullable as DateTime?,updatedAt: freezed == updatedAt ? _self.updatedAt : updatedAt // ignore: cast_nullable_to_non_nullable as DateTime?,reactions: freezed == reactions ? _self._reactions : reactions // ignore: cast_nullable_to_non_nullable -as Map>?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable +as MessageReactions?,pinned: freezed == pinned ? _self.pinned : pinned // ignore: cast_nullable_to_non_nullable as bool?,metadata: freezed == metadata ? _self._metadata : metadata // ignore: cast_nullable_to_non_nullable as Map?,status: freezed == status ? _self.status : status // ignore: cast_nullable_to_non_nullable as MessageStatus?, diff --git a/packages/flyer_chat_reactions/lib/src/utils/typedef.dart b/packages/flyer_chat_reactions/lib/src/utils/typedef.dart new file mode 100644 index 000000000..468314b12 --- /dev/null +++ b/packages/flyer_chat_reactions/lib/src/utils/typedef.dart @@ -0,0 +1,3 @@ +/// Callback signature for when a reaction is tapped. +typedef OnReactionTapCallback = void Function(String reaction); +typedef OnReactionLongPressCallback = void Function(String reaction); From c32ad1dd0f83878f387b8dbecde98b898aeead18 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:03:33 +0200 Subject: [PATCH 04/36] Create Reaction model --- .../lib/src/models/reaction.dart | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 packages/flyer_chat_reactions/lib/src/models/reaction.dart diff --git a/packages/flyer_chat_reactions/lib/src/models/reaction.dart b/packages/flyer_chat_reactions/lib/src/models/reaction.dart new file mode 100644 index 000000000..55ade8dac --- /dev/null +++ b/packages/flyer_chat_reactions/lib/src/models/reaction.dart @@ -0,0 +1,60 @@ +import 'package:flutter_chat_core/flutter_chat_core.dart'; + +class Reaction { + final String emoji; + final bool isReactedByUser; + final int count; + final List userIds; + + Reaction({ + required this.emoji, + required this.count, + required this.isReactedByUser, + required this.userIds, + }); + + @override + String toString() { + return 'Reaction(emoji: $emoji, count: $count, isReactedByUser: $isReactedByUser, userIds: $userIds)'; + } +} + +/// Converts a map of reactions to a list of [Reaction] objects +/// +/// [reactions] is a map [MessageReactions] where keys are emoji strings and values are lists of user IDs +/// [currentUserId] is used to determine if the current user has reacted +List reactionsFromMessageReactions({ + required MessageReactions? reactions, + required String currentUserId, +}) { + if (reactions == null) { + return []; + } + return reactions.entries.map((entry) { + final emoji = entry.key; + final users = entry.value; + return Reaction( + emoji: emoji, + count: users.length, + isReactedByUser: users.contains(currentUserId), + userIds: users, + ); + }).toList(); +} + +/// Get the list of reactions that the user has reacted to +/// +/// [reactions] is a map [MessageReactions] where keys are emoji strings and values are lists of user IDs +/// [currentUserId] is used to determine if the current user has reacted +List getUserReactions( + Map>? reactions, + String currentUserId, +) { + if (reactions == null) { + return []; + } + return reactions.entries + .where((entry) => entry.value.contains(currentUserId)) + .map((entry) => entry.key) + .toList(); +} From 1f23225bbbe4dde00534c75387111d2c2029ba3c Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:03:41 +0200 Subject: [PATCH 05/36] Create MenuItem model --- .../lib/src/models/menu_item.dart | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 packages/flyer_chat_reactions/lib/src/models/menu_item.dart diff --git a/packages/flyer_chat_reactions/lib/src/models/menu_item.dart b/packages/flyer_chat_reactions/lib/src/models/menu_item.dart new file mode 100644 index 000000000..4edcebb6d --- /dev/null +++ b/packages/flyer_chat_reactions/lib/src/models/menu_item.dart @@ -0,0 +1,16 @@ +import 'package:flutter/widgets.dart'; + +class MenuItem { + final String title; + final IconData icon; + final bool isDestructive; + final Function()? onTap; + + // constructor + const MenuItem({ + required this.title, + required this.icon, + this.isDestructive = false, + this.onTap, + }); +} From 5264e310eefdcc34ddb292f61cf4816e6c8d376c Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:04:10 +0200 Subject: [PATCH 06/36] Create ReactionTile (little bubble for one reaction under the message) --- .../lib/src/widgets/reaction_tile.dart | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart b/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart new file mode 100644 index 000000000..7d505e67d --- /dev/null +++ b/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart @@ -0,0 +1,277 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:provider/provider.dart'; + +import '../helpers/chat_theme_extensions.dart'; + +/// A widget that displays a reaction with an emoji and optional count. +/// +/// Used to show individual reactions in the chat interface, supporting both +/// single emoji reactions, reactions with counts or a text (for surplus) + +class ReactionTile extends StatefulWidget { + /// The emoji to display as the reaction. + final String? emoji; + + /// The count of reactions for this emoji. + /// If null, the count is not shown. + /// If 0, the tile is not shown. + final int? count; + + /// The text to display, after the count text or alone. + /// Typically used for surplus reactions. + final String? extraText; + + /// Whether this reaction was added by the current user. + /// Affects the visual styling of the tile. + final bool reactedByUser; + + /// Callback triggered when the reaction tile is tapped. + /// Used to handle reaction selection/deselection. + final VoidCallback? onTap; + + /// Callback triggered when the reaction tile is long pressed. + /// Used to handle additional actions like showing a menu or details. + final VoidCallback? onLongPress; + + /// Background color for the reaction tile when not reacted by the user. + final Color? backgroundColor; + + /// Background color for the reaction tile when reacted by the user. + final Color? reactedBackgroundColor; + + /// Color of the border around the reaction tile. + final Color? borderColor; + + /// Text style for the count text and extra text. + final TextStyle? countTextStyle; + + /// Text style for the surplus text. + final TextStyle? extraTextStyle; + + /// Text style for the emoji. + final TextStyle? emojiTextStyle; + + /// Fixed width for the reaction tile. + /// If null, the tile will size itself based on its content and constraints. + final double? width; + + /// Fixed height for the reaction tile. + /// If null, uses the default height. + final double? height; + + /// Remove/Add on tap or not. + /// If true, the reaction will be removed locally when tapped. + final bool removeOrAddLocallyOnTap; + + /// Creates a reaction tile widget. + const ReactionTile({ + super.key, + this.emoji, + this.count, + this.extraText, + this.reactedByUser = false, + this.onTap, + this.onLongPress, + this.backgroundColor, + this.reactedBackgroundColor, + this.borderColor, + this.countTextStyle, + this.emojiTextStyle, + this.extraTextStyle, + this.width, + this.height, + this.removeOrAddLocallyOnTap = false, + }); + + @override + State createState() => _ReactionTileState(); +} + +class _ReactionTileState extends State { + late bool _isTapped; + late int? _count; + + @override + void initState() { + super.initState(); + _count = widget.count; + _isTapped = widget.reactedByUser; + } + + @override + Widget build(BuildContext context) { + // Used to shrink on state update + if (_count == 0) { + return const SizedBox.shrink(); + } + final theme = context.read(); + final countString = + _count != null + ? ReactionTileCountTextHelper.getCountString(_count!) + : null; + return GestureDetector( + onTap: () { + if (widget.count != null && widget.removeOrAddLocallyOnTap) { + setState(() { + _count = _count! + (_isTapped ? -1 : 1); + _isTapped = !_isTapped; + }); + } + widget.onTap?.call(); + }, + onLongPress: widget.onLongPress, + child: Container( + padding: EdgeInsets.symmetric( + horizontal: ReactionTileConstants.horizontalPadding, + ), + height: widget.height ?? ReactionTileConstants.height, + width: widget.width, + decoration: BoxDecoration( + color: + _isTapped + ? widget.reactedBackgroundColor + : widget.backgroundColor, + borderRadius: BorderRadius.circular(16), + border: + widget.borderColor != null + ? Border.all(color: widget.borderColor!, width: 1) + : null, + ), + child: ColoredBox( + color: Colors.transparent, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.emoji != null) + /// Emoji alignement issue https://github.com/flutter/flutter/issues/119623 + Padding( + padding: EdgeInsets.fromLTRB(2.5, 0, 0, 1.5), + child: Text( + widget.emoji!, + style: ReactionTileStyleResolver.resolveEmojiTextStyle( + provided: widget.emojiTextStyle, + theme: theme, + ), + ), + ), + if (widget.emoji != null && countString != null) + SizedBox(width: ReactionTileConstants.textElementsSpacing), + if (countString != null) + Text( + countString, + style: ReactionTileStyleResolver.resolveCountTextStyle( + provided: widget.countTextStyle, + theme: theme, + ), + ), + if ((countString != null || widget.emoji != null) && + widget.extraText != null) + SizedBox(width: ReactionTileConstants.textElementsSpacing), + if (widget.extraText != null) + Text( + widget.extraText!, + style: ReactionTileStyleResolver.resolveExtraTextStyle( + provided: widget.extraTextStyle, + theme: theme, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class ReactionTileConstants { + static const double textElementsSpacing = 2; + static const double minimumWidth = 40; + static const double horizontalPadding = 8; + static const double height = 24; +} + +class ReactionTileCountTextHelper { + static String? getCountString(int count) { + return count > 1 ? count.toString() : null; + } +} + +class ReactionTileSizeHelper { + /// Calculate the prefered size for the [ReactionTile] + /// + /// This is used to calculate the size of the [ReactionTile] + /// and the number of reactions that can fit in the available width. + /// + + static Size calculatePrefferedSize({ + required TextStyle emojiStyle, + required TextStyle countTextStyle, + required TextStyle extraTextStyle, + String? emoji, + String? countText, + String? extraText, + }) { + final hasEmoji = emoji != null && emoji.isNotEmpty; + final hasText = countText != null && countText.isNotEmpty; + final hasExtraText = extraText != null && extraText.isNotEmpty; + if (!hasEmoji && !hasText && !hasExtraText) { + return Size.square(0); + } + var width = 0.0; + + if (hasEmoji) { + width += emojiStyle.fontSize ?? 12; + width += 2.5; // See emoji alignement + } + if (hasText) { + if (width > 0) { + width += ReactionTileConstants.textElementsSpacing; + } + width += countText.length * (countTextStyle.fontSize ?? 12); + } + if (hasExtraText) { + if (width > 0) { + width += ReactionTileConstants.textElementsSpacing; + } + width += extraText.length * (extraTextStyle.fontSize ?? 12); + } + width += ReactionTileConstants.horizontalPadding * 2; + + return Size( + width.clamp(ReactionTileConstants.minimumWidth, double.infinity), + ReactionTileConstants.height, + ); + } +} + +class ReactionTileStyleResolver { + static TextStyle resolveEmojiTextStyle({ + TextStyle? provided, + required ChatTheme theme, + }) { + if (provided != null) return provided; + return theme.reactionEmojiTextStyle; + } + + static TextStyle resolveCountTextStyle({ + TextStyle? provided, + required ChatTheme theme, + }) { + if (provided != null) return provided; + return theme.reactionCountTextStyle; + } + + static TextStyle resolveExtraTextStyle({ + TextStyle? provided, + required ChatTheme theme, + }) { + if (provided != null) return provided; + return theme.reactionSurplusTextStyle; + } +} From af2f921a19bc93b4ec22551e1a5ceaf478436da3 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:06:47 +0200 Subject: [PATCH 07/36] Create the ReactionList (bottomSheet with all the reactions on a message) --- .../lib/src/widgets/reactions_list.dart | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 packages/flyer_chat_reactions/lib/src/widgets/reactions_list.dart diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reactions_list.dart b/packages/flyer_chat_reactions/lib/src/widgets/reactions_list.dart new file mode 100644 index 000000000..b997c57a7 --- /dev/null +++ b/packages/flyer_chat_reactions/lib/src/widgets/reactions_list.dart @@ -0,0 +1,333 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:provider/provider.dart'; + +import '../models/reaction.dart'; + +/// Theme values for [ReactionsList]. +typedef _LocalTheme = + ({ + Color backgroundColor, + Color selectedFilterChipColor, + Color unselectedFilterChipColor, + TextStyle filterChipTextStyle, + TextStyle listEmojiTextStyle, + TextStyle listCountTextStyle, + TextStyle listUsernamesTextStyle, + }); + +/// A widget that displays a list of users and their reactions in a bottom sheet. +/// +/// Used to show who reacted with which emoji, typically shown when long-pressing +/// a reaction tile. +class ReactionsList extends StatefulWidget { + /// The list of reactions to display. + final List reactions; + + /// The config for the reactions list. + final ReactionListStyleConfig styleConfig; + + /// Creates a widget that displays a list of users and their reactions. + const ReactionsList({ + super.key, + required this.reactions, + this.styleConfig = const ReactionListStyleConfig(), + }); + + @override + State createState() => _ReactionsListState(); +} + +class _ReactionsListState extends State { + String? selectedEmoji; + late Future> _userNamesFuture; + + @override + void initState() { + super.initState(); + _initUserNamesFuture(); + } + + @override + void didUpdateWidget(covariant ReactionsList oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.reactions != widget.reactions) { + _initUserNamesFuture(); + } + } + + void _initUserNamesFuture() { + final resolveUser = context.read(); + final userCache = context.read(); + _userNamesFuture = _resolveUserNames(context, resolveUser, userCache); + } + + Future> _resolveUserNames( + BuildContext context, + ResolveUserCallback resolveUser, + UserCache userCache, + ) async { + final userMap = {}; + for (final reaction in widget.reactions) { + for (final userId in reaction.userIds) { + if (!userMap.containsKey(userId)) { + final resolvedUser = await userCache.getOrResolve( + userId, + resolveUser, + ); + userMap[userId] = resolvedUser?.name ?? userId; + } + } + } + return userMap; + } + + @override + Widget build(BuildContext context) { + final theme = context.select( + (ChatTheme t) => ( + backgroundColor: widget.styleConfig.backgroundColor ?? t.colors.surface, + selectedFilterChipColor: + widget.styleConfig.filterChipsSelectedColor ?? + t.colors.onPrimary.withValues(alpha: 0.2), + unselectedFilterChipColor: + widget.styleConfig.filterChipsUnselectedColor ?? + t.colors.onSurface.withValues(alpha: 0.2), + filterChipTextStyle: + widget.styleConfig.filterChipsTextStyles ?? t.typography.bodyMedium, + listEmojiTextStyle: + widget.styleConfig.listEmojiTextStyle ?? t.typography.bodyMedium, + listCountTextStyle: + widget.styleConfig.listCountTextStyle ?? t.typography.bodyMedium, + listUsernamesTextStyle: + widget.styleConfig.listUsernamesTextStyle ?? + t.typography.bodyMedium, + ), + ); + + final filteredReactions = + selectedEmoji == null + ? widget.reactions + : widget.reactions.where((r) => r.emoji == selectedEmoji).toList(); + + return Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: theme.backgroundColor, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(28), + topRight: Radius.circular(28), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Filter chips + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + _buildChip( + label: Text( + '${widget.styleConfig.allFilterChipLabel} • ${widget.reactions.length}', + ), + theme: theme, + selected: selectedEmoji == null, + onSelected: (selected) { + setState(() { + selectedEmoji = null; + }); + }, + ), + const SizedBox(width: 8), + ...widget.reactions.map( + (reaction) => Padding( + padding: const EdgeInsets.only(right: 8), + child: _buildChip( + label: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(reaction.emoji), + const SizedBox(width: 4), + Text('${reaction.count}'), + ], + ), + theme: theme, + selected: selectedEmoji == reaction.emoji, + onSelected: (selected) { + setState(() { + selectedEmoji = selected ? reaction.emoji : null; + }); + }, + ), + ), + ), + ], + ), + ), + // Reactions list + Flexible( + child: FutureBuilder>( + future: _userNamesFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ), + ); + } + + final userMap = snapshot.data ?? {}; + + return SizedBox( + height: MediaQuery.of(context).size.height * 0.4, + child: ListView.builder( + shrinkWrap: true, + itemCount: filteredReactions.length, + itemBuilder: (context, index) { + final reaction = filteredReactions[index]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Text( + reaction.emoji, + style: widget.styleConfig.listEmojiTextStyle, + ), + const SizedBox(width: 8), + Text( + '${reaction.count}', + style: widget.styleConfig.listCountTextStyle, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 16.0, + right: 16.0, + bottom: 4, + ), + child: Text( + reaction.userIds + .map((userId) => userMap[userId] ?? userId) + .join(', '), + style: theme.listUsernamesTextStyle, + ), + ), + if (index < filteredReactions.length - 1) + Divider( + height: 1, + color: theme.unselectedFilterChipColor.withValues( + alpha: 0.2, + ), + ), + ], + ); + }, + ), + ); + }, + ), + ), + ], + ), + ); + } + + Widget _buildChip({ + required Widget label, + required _LocalTheme theme, + void Function(bool)? onSelected, + required bool selected, + }) { + return FilterChip( + label: label, + showCheckmark: false, + selectedColor: theme.selectedFilterChipColor, + backgroundColor: theme.backgroundColor, + shadowColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + side: BorderSide.none, + onSelected: onSelected, + selected: selected, + ); + } +} + +/// Shows a bottom sheet with the list of reactions. +/// Must be called with a context from the Chat for providers to be available. +Future showReactionsList({ + required BuildContext context, + required List reactions, + ReactionListStyleConfig? styleConfig, +}) { + final providers = ChatProviders.from(context); + return showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: + (context) => MultiProvider( + providers: providers, + child: ReactionsList( + reactions: reactions, + styleConfig: styleConfig ?? const ReactionListStyleConfig(), + ), + ), + ); +} + +class ReactionListStyleConfig { + /// Label for All filter chips. Default is 'All' + final String allFilterChipLabel; + + /// The style for the user ID text. + final TextStyle? listUsernamesTextStyle; + + /// The style for the filter chips text. + final TextStyle? filterChipsTextStyles; + + /// The background color for selected filter chips. + final Color? filterChipsSelectedColor; + + /// The background color for unselected filter chips. + final Color? filterChipsUnselectedColor; + + /// The style for the header title text. + final TextStyle? headerTitleTextStyle; + + /// The style for the header count text. + final TextStyle? headerCountTextStyle; + + /// Font size for the emoji in reaction tiles. + final TextStyle? listEmojiTextStyle; + + /// The style for the reaction count text. + final TextStyle? listCountTextStyle; + + /// Background color for the bottom sheet. + final Color? backgroundColor; + + const ReactionListStyleConfig({ + this.allFilterChipLabel = 'All', + this.listUsernamesTextStyle, + this.filterChipsTextStyles, + this.filterChipsSelectedColor, + this.filterChipsUnselectedColor, + this.headerTitleTextStyle, + this.headerCountTextStyle, + this.listEmojiTextStyle, + this.listCountTextStyle, + this.backgroundColor, + }); +} From e0b343c49fdf04c79d7379c7aae93893d4a1333c Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:04:44 +0200 Subject: [PATCH 08/36] Create FlyerChatReactRow (adaptative row for all reactions) --- .../src/widgets/flyer_chat_reactions_row.dart | 255 ++++++++++++++++++ 1 file changed, 255 insertions(+) create mode 100644 packages/flyer_chat_reactions/lib/src/widgets/flyer_chat_reactions_row.dart diff --git a/packages/flyer_chat_reactions/lib/src/widgets/flyer_chat_reactions_row.dart b/packages/flyer_chat_reactions/lib/src/widgets/flyer_chat_reactions_row.dart new file mode 100644 index 000000000..95f92453a --- /dev/null +++ b/packages/flyer_chat_reactions/lib/src/widgets/flyer_chat_reactions_row.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:provider/provider.dart'; + +import '../helpers/chat_theme_extensions.dart'; +import '../models/reaction.dart'; +import '../utils/typedef.dart'; +import 'reaction_tile.dart'; + +/// A widget that displays a row of reaction tiles with emojis and counts. +/// +/// Handles layout and overflow of reactions, showing a surplus count when +/// there are more reactions than can fit in the available space. +class FlyerChatReactionsRow extends StatefulWidget { + /// The reactions to display, mapped by emoji. + final List reactions; + + /// Callback for when a reaction is tapped. + final OnReactionTapCallback? onReactionTap; + + /// Callback for when a reaction is long pressed. + final OnReactionLongPressCallback? onReactionLongPress; + + /// Callback when the surplus is tapped. + final VoidCallback? onSurplusReactionTap; + + /// Font size for the emoji in reaction tiles. + final TextStyle? emojiTextStyle; + + /// Text style for the count text in reaction tiles. + /// Note that we use a FittedBox so if the text is too long, it will be scaled down. + final TextStyle? countTextStyle; + + /// Text style for the surplus text in reaction tiles. + /// Note that we use a FittedBox so if the text is too long, it will be scaled down. + final TextStyle? surplusTextStyle; + + /// Space between reaction tiles. + /// Defaults to 2. + final double spacing; + + /// Inside padding for each [ReactionTile]. + /// Defaults to EdgeInsets.zero. + final EdgeInsets reactionTilePadding; + + /// Color of the border around reaction tiles. + /// If null, uses the default theme color. + final Color? borderColor; + + /// Background color for reaction tiles when not reacted by the user. + /// If null, uses the default theme color. + final Color? reactionBackgroundColor; + + /// Background color for reaction tiles when reacted by the user. + /// If null, uses the default theme color. + final Color? reactionReactedBackgroundColor; + + /// Alignment of the reactions row + final MainAxisAlignment alignment; + + /// Remove/Add on tap or not. + /// If true, the reaction will be removed locally when tapped. + final bool removeOrAddLocallyOnTap; + + /// Creates a widget that displays a row of reaction tiles. + const FlyerChatReactionsRow({ + super.key, + required this.reactions, + this.onReactionTap, + this.onReactionLongPress, + this.onSurplusReactionTap, + this.emojiTextStyle, + this.countTextStyle, + this.surplusTextStyle, + this.spacing = 2, + this.reactionTilePadding = const EdgeInsets.symmetric(horizontal: 4), + this.borderColor, + this.reactionBackgroundColor, + this.reactionReactedBackgroundColor, + this.alignment = MainAxisAlignment.start, + this.removeOrAddLocallyOnTap = false, + }); + + @override + State createState() => _FlyerChatReactionsRowState(); +} + +class _FlyerChatReactionsRowState extends State { + /// List of calculated sizes for each reaction tile. + final reactionsSizes = []; + + /// Calculates how many reactions can fit in the available width. + /// + /// Also updates [reactionsSizes] with the + /// calculated sizes for each visible reaction. + /// + /// Returns the number of reactions that can be displayed. + int calculateSizesAndMaxCapacity({ + required List reactions, + required double stackWidth, + required TextStyle emojiTextStyle, + required TextStyle countTextStyle, + required TextStyle extraTextStyle, + }) { + reactionsSizes.clear(); + double usedWidth = 0; + var visibleCount = 0; + final widgetCount = reactions.length; + + for (var i = 0; i < widgetCount; i++) { + final nextSize = ReactionTileSizeHelper.calculatePrefferedSize( + emojiStyle: emojiTextStyle, + countTextStyle: countTextStyle, + extraTextStyle: extraTextStyle, + emoji: reactions[i].emoji, + countText: ReactionTileCountTextHelper.getCountString( + reactions[i].count, + ), + ); + final spaceNeeded = + usedWidth + nextSize.width + (visibleCount > 0 ? widget.spacing : 0); + if (spaceNeeded < stackWidth) { + usedWidth = spaceNeeded; + visibleCount++; + reactionsSizes.add(nextSize); + } else { + break; + } + } + return visibleCount; + } + + @override + Widget build(BuildContext context) { + if (widget.reactions.isEmpty) { + return const SizedBox.shrink(); + } + final theme = context.read(); + final emojiTextStyle = ReactionTileStyleResolver.resolveEmojiTextStyle( + provided: widget.emojiTextStyle, + theme: theme, + ); + final countTextStyle = ReactionTileStyleResolver.resolveCountTextStyle( + provided: widget.countTextStyle, + theme: theme, + ); + final extraTextStyle = ReactionTileStyleResolver.resolveExtraTextStyle( + provided: widget.surplusTextStyle, + theme: theme, + ); + + final reactedBackgroundColor = + widget.reactionReactedBackgroundColor ?? + theme.reactionReactedBackgroundColor; + final backgroundColor = + widget.reactionBackgroundColor ?? theme.reactionBackgroundColor; + + return LayoutBuilder( + builder: (context, BoxConstraints constraints) { + final isNotEnoughSpace = + constraints.maxWidth <= 0 || constraints.maxHeight <= 0; + if (isNotEnoughSpace) { + return const SizedBox.shrink(); + } + + final stackWidth = constraints.maxWidth; + var maxCapacity = calculateSizesAndMaxCapacity( + reactions: widget.reactions, + stackWidth: stackWidth, + emojiTextStyle: emojiTextStyle, + countTextStyle: countTextStyle, + extraTextStyle: extraTextStyle, + ); + var visibleItemsCount = reactionsSizes.length; + var hiddenCount = widget.reactions.length - maxCapacity; + final souldDisplaySurplus = hiddenCount > 0; + + Size? surplusWidgetSize; + if (souldDisplaySurplus) { + surplusWidgetSize = ReactionTileSizeHelper.calculatePrefferedSize( + emojiStyle: emojiTextStyle, + countTextStyle: countTextStyle, + extraTextStyle: extraTextStyle, + extraText: '+$hiddenCount', + ); + maxCapacity = calculateSizesAndMaxCapacity( + reactions: widget.reactions, + stackWidth: stackWidth - surplusWidgetSize.width - widget.spacing, + emojiTextStyle: emojiTextStyle, + countTextStyle: countTextStyle, + extraTextStyle: extraTextStyle, + ); + + visibleItemsCount = reactionsSizes.length; + hiddenCount = widget.reactions.length - visibleItemsCount; + } + + final children = []; + + for (var i = 0; i < visibleItemsCount; i++) { + children.add( + ReactionTile( + key: ValueKey(widget.reactions[i].emoji), + width: reactionsSizes[i].width, + emoji: widget.reactions[i].emoji, + count: widget.reactions[i].count, + countTextStyle: countTextStyle, + emojiTextStyle: emojiTextStyle, + borderColor: theme.reactionBorderColor, + backgroundColor: backgroundColor, + reactedBackgroundColor: reactedBackgroundColor, + reactedByUser: widget.reactions[i].isReactedByUser, + onTap: () { + widget.onReactionTap?.call(widget.reactions[i].emoji); + }, + onLongPress: () { + widget.onReactionLongPress?.call(widget.reactions[i].emoji); + }, + removeOrAddLocallyOnTap: widget.removeOrAddLocallyOnTap, + ), + ); + } + + if (surplusWidgetSize != null) { + children.add( + ReactionTile( + key: const ValueKey('surplus'), + width: surplusWidgetSize.width, + extraText: '+$hiddenCount', + backgroundColor: backgroundColor, + reactedBackgroundColor: backgroundColor, + extraTextStyle: extraTextStyle, + borderColor: theme.reactionBorderColor, + onTap: () { + widget.onSurplusReactionTap?.call(); + }, + onLongPress: widget.onSurplusReactionTap, + ), + ); + } + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + mainAxisAlignment: widget.alignment, + mainAxisSize: MainAxisSize.max, + children: children, + ), + ], + ); + }, + ); + } +} From 2035c3d3e36969038086cbe66e860202169cb747 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:05:08 +0200 Subject: [PATCH 09/36] Add isSentByme to the tap callbacks --- examples/flyer_chat/lib/api/api.dart | 1 + examples/flyer_chat/lib/local.dart | 1 + .../lib/src/chat_message/chat_message.dart | 32 ++++++++++++------- .../lib/src/utils/typedefs.dart | 9 +++++- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/examples/flyer_chat/lib/api/api.dart b/examples/flyer_chat/lib/api/api.dart index d6175bbcb..2e2b271e0 100644 --- a/examples/flyer_chat/lib/api/api.dart +++ b/examples/flyer_chat/lib/api/api.dart @@ -294,6 +294,7 @@ class ApiState extends State { Message item, { int? index, TapUpDetails? details, + required bool isSentByMe, }) async { await _chatController.removeMessage(item); diff --git a/examples/flyer_chat/lib/local.dart b/examples/flyer_chat/lib/local.dart index 11a893f9d..04af240d2 100644 --- a/examples/flyer_chat/lib/local.dart +++ b/examples/flyer_chat/lib/local.dart @@ -272,6 +272,7 @@ class LocalState extends State { Message message, { int? index, LongPressStartDetails? details, + required bool isSentByMe, }) async { // Skip showing menu for system messages if (message.authorId == 'system' || details == null) return; diff --git a/packages/flutter_chat_ui/lib/src/chat_message/chat_message.dart b/packages/flutter_chat_ui/lib/src/chat_message/chat_message.dart index 0c28bbd48..df82a2a0e 100644 --- a/packages/flutter_chat_ui/lib/src/chat_message/chat_message.dart +++ b/packages/flutter_chat_ui/lib/src/chat_message/chat_message.dart @@ -147,22 +147,32 @@ class ChatMessage extends StatelessWidget { ), ), GestureDetector( - onTapUp: - (details) => onMessageTap?.call( - context, - message, - index: index, - details: details, - ), + onTapUp: (details) { + onMessageTap?.call( + context, + message, + index: index, + details: details, + isSentByMe: isSentByMe, + ); + }, onDoubleTap: - () => onMessageDoubleTap?.call(context, message, index: index), - onLongPressStart: - (details) => onMessageLongPress?.call( + () => onMessageDoubleTap?.call( context, message, index: index, - details: details, + isSentByMe: isSentByMe, ), + onLongPress: () { + onMessageLongPress?.call( + context, + message, + index: index, + details: LongPressStartDetails(), + isSentByMe: isSentByMe, + ); + return; + }, child: FadeTransition( opacity: curvedAnimation, child: SizeTransition( diff --git a/packages/flutter_chat_ui/lib/src/utils/typedefs.dart b/packages/flutter_chat_ui/lib/src/utils/typedefs.dart index f7b645e40..902418fa0 100644 --- a/packages/flutter_chat_ui/lib/src/utils/typedefs.dart +++ b/packages/flutter_chat_ui/lib/src/utils/typedefs.dart @@ -10,13 +10,19 @@ typedef OnMessageTapCallback = Message message, { int index, TapUpDetails details, + required bool isSentByMe, }); /// Callback signature for when a message is double tapped. /// [context] is the BuildContext from the widget tree where the tap occurs. /// Provides the tapped [message], its [index] typedef OnMessageDoubleTapCallback = - void Function(BuildContext context, Message message, {int index}); + void Function( + BuildContext context, + Message message, { + int index, + required bool isSentByMe, + }); /// Callback signature for when a message is long-pressed. /// [context] is the BuildContext from the widget tree where the long press occurs. @@ -27,6 +33,7 @@ typedef OnMessageLongPressCallback = Message message, { int index, LongPressStartDetails details, + required bool isSentByMe, }); /// Callback signature for when the user attempts to send a message. From ba4a7826a2ee3b39fb757d2eeb08fd3615aa3c86 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:05:50 +0200 Subject: [PATCH 10/36] Create the ChatProvider class that allow to easily pass all chat provider to another context --- .../flutter_chat_ui/lib/flutter_chat_ui.dart | 1 + packages/flutter_chat_ui/lib/src/chat.dart | 2 + .../lib/src/utils/chat_providers.dart | 64 +++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 packages/flutter_chat_ui/lib/src/utils/chat_providers.dart diff --git a/packages/flutter_chat_ui/lib/flutter_chat_ui.dart b/packages/flutter_chat_ui/lib/flutter_chat_ui.dart index 9dcc05e59..fc8e894b1 100644 --- a/packages/flutter_chat_ui/lib/flutter_chat_ui.dart +++ b/packages/flutter_chat_ui/lib/flutter_chat_ui.dart @@ -13,6 +13,7 @@ export 'src/load_more.dart'; export 'src/scroll_to_bottom.dart'; export 'src/simple_text_message.dart'; export 'src/username.dart'; +export 'src/utils/chat_providers.dart'; export 'src/utils/composer_height_notifier.dart'; export 'src/utils/load_more_notifier.dart'; export 'src/utils/typedefs.dart'; diff --git a/packages/flutter_chat_ui/lib/src/chat.dart b/packages/flutter_chat_ui/lib/src/chat.dart index a30a005e1..2b1f5f22a 100644 --- a/packages/flutter_chat_ui/lib/src/chat.dart +++ b/packages/flutter_chat_ui/lib/src/chat.dart @@ -134,6 +134,8 @@ class _ChatState extends State with WidgetsBindingObserver { @override Widget build(BuildContext context) { + /// IMPORTANT: Keep this list in sync with the MultiProvider helper in [ChatProviders]]. + return MultiProvider( providers: [ Provider.value(value: widget.currentUserId), diff --git a/packages/flutter_chat_ui/lib/src/utils/chat_providers.dart b/packages/flutter_chat_ui/lib/src/utils/chat_providers.dart new file mode 100644 index 000000000..0de982118 --- /dev/null +++ b/packages/flutter_chat_ui/lib/src/utils/chat_providers.dart @@ -0,0 +1,64 @@ +import 'package:cross_cache/cross_cache.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:provider/provider.dart'; +import 'package:provider/single_child_widget.dart'; + +import '../chat.dart'; +import 'composer_height_notifier.dart'; +import 'load_more_notifier.dart'; +import 'typedefs.dart'; + +/// A utility class to re-expose the current Chat-related providers +/// for use in dialogs, custom routes, or overlays. +/// +/// This ensures that context-dependent widgets (e.g., using `context.watch`) +/// work properly in a new widget tree. +/// +/// +/// IMPORTANT: Keep this list in sync with the main MultiProvider in [Chat]]. + +class ChatProviders { + /// Recreates the list of `Provider`s from the current [context], + /// so you can rewrap a new widget subtree (e.g. in a Hero route or dialog). + static List from(BuildContext context) => [ + Provider.value(value: mustRead(context)), + Provider.value(value: mustRead(context)), + Provider.value(value: mustRead(context)), + Provider.value(value: mustRead(context)), + Provider.value(value: mustRead(context)), + Provider.value(value: mustRead(context)), + ChangeNotifierProvider.value(value: mustRead(context)), + Provider.value(value: mustRead(context)), + + // Optional callbacks use context.read() directly: + Provider.value(value: context.read()), + Provider.value(value: context.read()), + Provider.value(value: context.read()), + Provider.value(value: context.read()), + + ChangeNotifierProvider.value( + value: mustRead(context), + ), + ChangeNotifierProvider.value(value: mustRead(context)), + ]; +} + +/// Safely reads a provider from the given [context]. +/// Throws a clear error if the provider is missing, +/// instead of silently crashing at runtime. +T mustRead(BuildContext context) { + try { + return context.read(); + } catch (e, stack) { + throw FlutterError.fromParts([ + ErrorSummary('Missing provider for type $T'), + ErrorDescription( + 'ChatProviders.from(context) tried to read a $T, but it was not found in the widget tree.', + ), + ErrorHint('Ensure this provider is available in the current context.'), + DiagnosticsStackTrace('Stack trace', stack), + ]); + } +} From 21ffdb9cd901cb9f859b4018e801cf7933028e66 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:07:35 +0200 Subject: [PATCH 11/36] Expose the method to build the "inner" message widget --- .../flutter_chat_ui/lib/flutter_chat_ui.dart | 1 + .../chat_message_build_helpers.dart | 107 ++++++++++++++++++ .../chat_message/chat_message_internal.dart | 106 +---------------- 3 files changed, 110 insertions(+), 104 deletions(-) create mode 100644 packages/flutter_chat_ui/lib/src/chat_message/chat_message_build_helpers.dart diff --git a/packages/flutter_chat_ui/lib/flutter_chat_ui.dart b/packages/flutter_chat_ui/lib/flutter_chat_ui.dart index fc8e894b1..db5c60a85 100644 --- a/packages/flutter_chat_ui/lib/flutter_chat_ui.dart +++ b/packages/flutter_chat_ui/lib/flutter_chat_ui.dart @@ -6,6 +6,7 @@ export 'src/chat.dart'; export 'src/chat_animated_list/chat_animated_list.dart'; export 'src/chat_animated_list/chat_animated_list_reversed.dart'; export 'src/chat_message/chat_message.dart'; +export 'src/chat_message/chat_message_build_helpers.dart'; export 'src/composer.dart'; export 'src/empty_chat_list.dart'; export 'src/is_typing.dart'; diff --git a/packages/flutter_chat_ui/lib/src/chat_message/chat_message_build_helpers.dart b/packages/flutter_chat_ui/lib/src/chat_message/chat_message_build_helpers.dart new file mode 100644 index 000000000..57e28c15f --- /dev/null +++ b/packages/flutter_chat_ui/lib/src/chat_message/chat_message_build_helpers.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; + +import '../simple_text_message.dart'; + +Widget buildMessageContent( + BuildContext context, + Builders builders, + Message message, + int index, { + required bool isSentByMe, + MessageGroupStatus? groupStatus, + }) { + switch (message) { + case TextMessage(): + return builders.textMessageBuilder?.call( + context, + message, + index, + isSentByMe: isSentByMe, + groupStatus: groupStatus, + ) ?? + SimpleTextMessage(message: message, index: index); + case TextStreamMessage(): + return builders.textStreamMessageBuilder?.call( + context, + message, + index, + isSentByMe: isSentByMe, + groupStatus: groupStatus, + ) ?? + const SizedBox.shrink(); + case ImageMessage(): + final result = + builders.imageMessageBuilder?.call( + context, + message, + index, + isSentByMe: isSentByMe, + groupStatus: groupStatus, + ) ?? + const SizedBox.shrink(); + assert( + !(result is SizedBox && result.width == 0 && result.height == 0), + 'You are trying to display an image message but you have not provided an imageMessageBuilder. ' + 'Use builders parameter of Chat widget to provide an image message widget. ' + 'If you want to use default image message widget, install flyer_chat_image_message package and use FlyerChatImageMessage widget.', + ); + return result; + case FileMessage(): + return builders.fileMessageBuilder?.call( + context, + message, + index, + isSentByMe: isSentByMe, + groupStatus: groupStatus, + ) ?? + const SizedBox.shrink(); + case VideoMessage(): + return builders.videoMessageBuilder?.call( + context, + message, + index, + isSentByMe: isSentByMe, + groupStatus: groupStatus, + ) ?? + const SizedBox.shrink(); + case AudioMessage(): + return builders.audioMessageBuilder?.call( + context, + message, + index, + isSentByMe: isSentByMe, + groupStatus: groupStatus, + ) ?? + const SizedBox.shrink(); + case SystemMessage(): + return builders.systemMessageBuilder?.call( + context, + message, + index, + isSentByMe: isSentByMe, + groupStatus: groupStatus, + ) ?? + const SizedBox.shrink(); + case CustomMessage(): + return builders.customMessageBuilder?.call( + context, + message, + index, + isSentByMe: isSentByMe, + groupStatus: groupStatus, + ) ?? + const SizedBox.shrink(); + case UnsupportedMessage(): + return builders.unsupportedMessageBuilder?.call( + context, + message, + index, + isSentByMe: isSentByMe, + groupStatus: groupStatus, + ) ?? + const Text( + 'This message is not supported. Please update your app.', + ); + } + } \ No newline at end of file diff --git a/packages/flutter_chat_ui/lib/src/chat_message/chat_message_internal.dart b/packages/flutter_chat_ui/lib/src/chat_message/chat_message_internal.dart index dea1b7a1c..0809912a5 100644 --- a/packages/flutter_chat_ui/lib/src/chat_message/chat_message_internal.dart +++ b/packages/flutter_chat_ui/lib/src/chat_message/chat_message_internal.dart @@ -6,6 +6,7 @@ import 'package:provider/provider.dart'; import '../simple_text_message.dart'; import 'chat_message.dart'; +import 'chat_message_build_helpers.dart'; /// Internal widget responsible for building and updating a single chat message item. /// @@ -99,7 +100,7 @@ class _ChatMessageInternalState extends State { final groupStatus = _resolveGroupStatus(context); - final child = _buildMessage( + final child = buildMessageContent( context, builders, _updatedMessage, @@ -182,109 +183,6 @@ class _ChatMessageInternalState extends State { return null; } } - - Widget _buildMessage( - BuildContext context, - Builders builders, - Message message, - int index, { - required bool isSentByMe, - MessageGroupStatus? groupStatus, - }) { - switch (message) { - case TextMessage(): - return builders.textMessageBuilder?.call( - context, - message, - index, - isSentByMe: isSentByMe, - groupStatus: groupStatus, - ) ?? - SimpleTextMessage(message: message, index: index); - case TextStreamMessage(): - return builders.textStreamMessageBuilder?.call( - context, - message, - index, - isSentByMe: isSentByMe, - groupStatus: groupStatus, - ) ?? - const SizedBox.shrink(); - case ImageMessage(): - final result = - builders.imageMessageBuilder?.call( - context, - message, - index, - isSentByMe: isSentByMe, - groupStatus: groupStatus, - ) ?? - const SizedBox.shrink(); - assert( - !(result is SizedBox && result.width == 0 && result.height == 0), - 'You are trying to display an image message but you have not provided an imageMessageBuilder. ' - 'Use builders parameter of Chat widget to provide an image message widget. ' - 'If you want to use default image message widget, install flyer_chat_image_message package and use FlyerChatImageMessage widget.', - ); - return result; - case FileMessage(): - return builders.fileMessageBuilder?.call( - context, - message, - index, - isSentByMe: isSentByMe, - groupStatus: groupStatus, - ) ?? - const SizedBox.shrink(); - case VideoMessage(): - return builders.videoMessageBuilder?.call( - context, - message, - index, - isSentByMe: isSentByMe, - groupStatus: groupStatus, - ) ?? - const SizedBox.shrink(); - case AudioMessage(): - return builders.audioMessageBuilder?.call( - context, - message, - index, - isSentByMe: isSentByMe, - groupStatus: groupStatus, - ) ?? - const SizedBox.shrink(); - case SystemMessage(): - return builders.systemMessageBuilder?.call( - context, - message, - index, - isSentByMe: isSentByMe, - groupStatus: groupStatus, - ) ?? - const SizedBox.shrink(); - case CustomMessage(): - return builders.customMessageBuilder?.call( - context, - message, - index, - isSentByMe: isSentByMe, - groupStatus: groupStatus, - ) ?? - const SizedBox.shrink(); - case UnsupportedMessage(): - return builders.unsupportedMessageBuilder?.call( - context, - message, - index, - isSentByMe: isSentByMe, - groupStatus: groupStatus, - ) ?? - const Text( - 'This message is not supported. Please update your app.', - ); - } - } } /// Determines if two messages should be grouped together based on the grouping mode. From 0647867c0590ad6bd81816c4beb7c45dbdfc1407 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:08:13 +0200 Subject: [PATCH 12/36] Create the ReactionDialog --- .../lib/src/models/default_data.dart | 5 + .../lib/src/widgets/reactions_dialog.dart | 413 ++++++++++++++++++ 2 files changed, 418 insertions(+) create mode 100644 packages/flyer_chat_reactions/lib/src/models/default_data.dart create mode 100644 packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart diff --git a/packages/flyer_chat_reactions/lib/src/models/default_data.dart b/packages/flyer_chat_reactions/lib/src/models/default_data.dart new file mode 100644 index 000000000..fa13ed644 --- /dev/null +++ b/packages/flyer_chat_reactions/lib/src/models/default_data.dart @@ -0,0 +1,5 @@ +class DefaultData { + // default list of five reactions to be displayed from emojis and a plus icon at the end + // the plus icon will be used to add more reactions + static const List reactions = ['👍', '❤️', '😂', '😮', '😢', '😠']; +} diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart new file mode 100644 index 000000000..5bfc4740f --- /dev/null +++ b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart @@ -0,0 +1,413 @@ +import 'dart:ui'; +import 'package:animate_do/animate_do.dart' show FadeInLeft, Pulse; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; +import 'package:flutter_chat_ui/flutter_chat_ui.dart' + show ChatProviders, buildMessageContent; +import 'package:provider/provider.dart'; + +import '../models/default_data.dart'; +import '../models/menu_item.dart'; +import '../utils/typedef.dart'; + +//// Theme values for [ReactionsDialogWidget]. +typedef _LocalTheme = + ({ + Color onSurface, + Color surface, + Color primary, + BorderRadiusGeometry shape, + }); + +class ReactionsDialogWidget extends StatefulWidget { + const ReactionsDialogWidget({ + super.key, + required this.messageWidget, + required this.onReactionTap, + this.moreReactionsWidget, + this.onMoreReactionsTap, + this.menuItems, + this.reactions, + this.userReactions, + this.widgetAlignment, + this.menuItemsWidthRatio, + this.menuItemBackgroundColor, + this.menuItemDestructiveColor, + this.menuItemDividerColor, + this.reactionsPickerBackgroundColor, + this.reactionsPickerReactedBackgroundColor, + this.menuItemTapAnimationDuration, + this.reactionTapAnimationDuration, + this.reactionPickerFadeLeftAnimationDuration, + }); + + /// The message widget to be displayed in the dialog + final Widget messageWidget; + + /// The callback function to be called when a reaction is tapped + final OnReactionTapCallback onReactionTap; + + /// More Reactions Widget + final Widget? moreReactionsWidget; + + /// The callback function to be called when the "more" reactions widget is tapped + /// If not provided the widget will not be displayed + final VoidCallback? onMoreReactionsTap; + + /// The list of menu items to be displayed in the context menu + final List? menuItems; + + /// The list of default reactions to be displayed + final List? reactions; + + /// The list of user reactions to be displayed + /// This allow user to remove them from here + final List? userReactions; + + /// The alignment of the widget + /// Only left right is taken into account + final Alignment? widgetAlignment; + + /// The width ratio of the menu items + final double? menuItemsWidthRatio; + + /// Animation duration when a menu item is selected + final Duration? menuItemTapAnimationDuration; + + /// The background color for menu items + final Color? menuItemBackgroundColor; + + /// Destructive color for menu items + final Color? menuItemDestructiveColor; + + /// The divider color for menu items + final Color? menuItemDividerColor; + + /// The background color for reactions picker + final Color? reactionsPickerBackgroundColor; + + /// The color for the reactions reacted by the user + final Color? reactionsPickerReactedBackgroundColor; + + /// Animation duration when a reaction is selected + final Duration? reactionTapAnimationDuration; + + /// Animation duration to display the reactions row + final Duration? reactionPickerFadeLeftAnimationDuration; + + @override + State createState() => _ReactionsDialogWidgetState(); +} + +class _ReactionsDialogWidgetState extends State { + bool reactionClicked = false; + int? clickedReactionIndex; + int? clickedContextMenuIndex; + + @override + Widget build(BuildContext context) { + final theme = context.select( + (ChatTheme t) => ( + onSurface: t.colors.onSurface, + surface: t.colors.surface, + primary: t.colors.primary, + shape: t.shape, + ), + ); + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), + child: Padding( + padding: const EdgeInsets.only(right: 20.0, left: 20.0), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + buildReactionsPicker(context, theme), + const SizedBox(height: 10), + buildMessage(), + const SizedBox(height: 10), + buildMenuItems(context, theme), + ], + ), + ), + ); + } + + Align buildMenuItems(BuildContext context, _LocalTheme theme) { + final destructiveColor = widget.menuItemDestructiveColor ?? Colors.red; + return Align( + alignment: widget.widgetAlignment ?? Alignment.centerRight, + child: Material( + color: Colors.transparent, + child: Container( + /// TODO: maybe use pixels, for desktop? + width: + MediaQuery.of(context).size.width * + (widget.menuItemsWidthRatio ?? 0.45), + decoration: BoxDecoration( + color: widget.menuItemBackgroundColor ?? theme.surface, + borderRadius: theme.shape, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.min, + children: [ + for (var item in widget.menuItems ?? const []) + Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: InkWell( + onTap: () { + setState(() { + clickedContextMenuIndex = widget.menuItems?.indexOf( + item, + ); + }); + + Future.delayed( + widget.menuItemTapAnimationDuration ?? + const Duration(milliseconds: 200), + ).whenComplete(() { + if (context.mounted) { + Navigator.of(context).pop(); + } + item.onTap?.call(); + }); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + item.title, + style: TextStyle( + color: + item.isDestructive + ? destructiveColor + : theme.onSurface, + ), + ), + Pulse( + infinite: false, + duration: + widget.menuItemTapAnimationDuration ?? + const Duration(milliseconds: 200), + animate: + clickedContextMenuIndex == + widget.menuItems?.indexOf(item), + child: Icon( + item.icon, + color: + item.isDestructive + ? destructiveColor + : theme.onSurface, + ), + ), + ], + ), + ), + ), + if (widget.menuItems?.last != item) + Divider( + color: widget.menuItemDividerColor ?? Colors.white, + thickness: 0.5, + height: 0.5, + ), + ], + ), + ], + ), + ), + ), + ); + } + + Align buildMessage() { + return Align( + alignment: widget.widgetAlignment ?? Alignment.centerRight, + child: widget.messageWidget, + ); + } + + Align buildReactionsPicker(BuildContext context, _LocalTheme theme) { + // Merge default reactions with user reactions, removing duplicates + final allReactions = + { + ...(widget.reactions ?? DefaultData.reactions), + ...(widget.userReactions ?? const []), + }.toList(); + + final reactionTapAnimationDuration = + widget.reactionTapAnimationDuration ?? + const Duration(milliseconds: 200); + return Align( + alignment: widget.widgetAlignment ?? Alignment.centerRight, + child: Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: widget.reactionsPickerBackgroundColor ?? theme.surface, + borderRadius: theme.shape, + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < allReactions.length; i++) + FadeInLeft( + from: 0 + (i * 20).toDouble(), + duration: + widget.reactionPickerFadeLeftAnimationDuration ?? + const Duration(milliseconds: 200), + delay: Duration.zero, + child: InkWell( + child: Container( + margin: const EdgeInsets.only(right: 2), + padding: const EdgeInsets.fromLTRB(4.0, 2.0, 4.0, 2), + decoration: BoxDecoration( + color: + (widget.userReactions ?? const []).contains( + allReactions[i], + ) + ? widget.reactionsPickerReactedBackgroundColor ?? + theme.onSurface.withValues(alpha: 0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Pulse( + infinite: false, + duration: reactionTapAnimationDuration, + animate: reactionClicked && clickedReactionIndex == i, + child: Text( + allReactions[i], + style: TextStyle(fontSize: 22), + ), + ), + ), + onTap: () { + setState(() { + reactionClicked = true; + clickedReactionIndex = i; + }); + Future.delayed( + reactionTapAnimationDuration, + ).whenComplete(() { + if (context.mounted) { + Navigator.of(context).pop(); + } + widget.onReactionTap(allReactions[i]); + }); + }, + ), + ), + if (widget.onMoreReactionsTap != null) + FadeInLeft( + from: 0 + (allReactions.length * 20).toDouble(), + duration: + widget.reactionPickerFadeLeftAnimationDuration ?? + const Duration(milliseconds: 200), + delay: Duration.zero, + child: InkWell( + onTap: () { + if (context.mounted) { + Navigator.of(context).pop(); + } + widget.onMoreReactionsTap?.call(); + }, + child: + widget.moreReactionsWidget ?? + Padding( + padding: const EdgeInsets.fromLTRB( + 4.0, + 2.0, + 4.0, + 2, + ), + child: Icon( + Icons.more_horiz_rounded, + color: theme.onSurface, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Method to display the reactions dialog for a message +/// Refer to [ReactionsDialogWidget] for the available parameters +/// +void showReactionsDialog( + BuildContext context, + Message message, { + required bool isSentByMe, + required Function(String) onReactionTap, + VoidCallback? onMoreReactionsTap, + List? menuItems, + List? reactions, + List? userReactions, + Alignment? widgetAlignment, + double? menuItemsWidthRatio, + Color? menuItemBackgroundColor, + Color? menuItemDestructiveColor, + Color? menuItemDividerColor, + Color? reactionsPickerBackgroundColor, + Color? reactionsPickerReactedBackgroundColor, + Duration? menuItemTapAnimationDuration, + Duration? reactionTapAnimationDuration, + Duration? reactionPickerFadeLeftAnimationDuration, + Widget? moreReactionsWidget, +}) { + final providers = ChatProviders.from(context); + + final widget = buildMessageContent( + context, + context.read(), + message, + 0, + isSentByMe: isSentByMe, + ); + + showDialog( + context: context, + useSafeArea: true, + useRootNavigator: false, + builder: + (context) => MultiProvider( + providers: providers, + child: ReactionsDialogWidget( + messageWidget: widget, + widgetAlignment: + widgetAlignment ?? + (isSentByMe ? Alignment.centerRight : Alignment.centerLeft), + onReactionTap: (reaction) { + onReactionTap(reaction); + }, + onMoreReactionsTap: onMoreReactionsTap, + menuItems: menuItems, + reactions: reactions, + userReactions: userReactions, + menuItemsWidthRatio: menuItemsWidthRatio, + menuItemBackgroundColor: menuItemBackgroundColor, + menuItemDestructiveColor: menuItemDestructiveColor, + menuItemDividerColor: menuItemDividerColor, + reactionsPickerBackgroundColor: reactionsPickerBackgroundColor, + reactionsPickerReactedBackgroundColor: + reactionsPickerReactedBackgroundColor, + menuItemTapAnimationDuration: menuItemTapAnimationDuration, + reactionTapAnimationDuration: reactionTapAnimationDuration, + reactionPickerFadeLeftAnimationDuration: + reactionPickerFadeLeftAnimationDuration, + moreReactionsWidget: moreReactionsWidget, + ), + ), + ); +} From fe4bbe5afb9b7612d1ee91ff69c2f5fc4a66481d Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:08:49 +0200 Subject: [PATCH 13/36] Add a ReactionsBuilder to Builders --- .../lib/src/models/builders.dart | 7 ++++ .../lib/src/models/builders.freezed.dart | 33 +++++++++++-------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/packages/flutter_chat_core/lib/src/models/builders.dart b/packages/flutter_chat_core/lib/src/models/builders.dart index a490a028d..91f5eef20 100644 --- a/packages/flutter_chat_core/lib/src/models/builders.dart +++ b/packages/flutter_chat_core/lib/src/models/builders.dart @@ -134,6 +134,10 @@ typedef EmptyChatListBuilder = Widget Function(BuildContext); typedef LinkPreviewBuilder = Widget? Function(BuildContext, TextMessage, bool isSendByMe); +/// Signature for building the reactions widget. +typedef ReactionsBuilder = + Widget? Function(BuildContext, Message, bool isSentByMe); + /// A collection of builder functions used to customize the UI components /// of the chat interface. @Freezed(fromJson: false, toJson: false) @@ -190,6 +194,9 @@ abstract class Builders with _$Builders { /// Custom builder for the link preview widget. LinkPreviewBuilder? linkPreviewBuilder, + + /// Custom builder for the reactions widget. + ReactionsBuilder? reactionsBuilder, }) = _Builders; const Builders._(); diff --git a/packages/flutter_chat_core/lib/src/models/builders.freezed.dart b/packages/flutter_chat_core/lib/src/models/builders.freezed.dart index 78c1c7c36..1890af0ae 100644 --- a/packages/flutter_chat_core/lib/src/models/builders.freezed.dart +++ b/packages/flutter_chat_core/lib/src/models/builders.freezed.dart @@ -31,7 +31,8 @@ mixin _$Builders { ScrollToBottomBuilder? get scrollToBottomBuilder;/// Custom builder for the load more indicator. LoadMoreBuilder? get loadMoreBuilder;/// Custom builder for the empty chat list. EmptyChatListBuilder? get emptyChatListBuilder;/// Custom builder for the link preview widget. - LinkPreviewBuilder? get linkPreviewBuilder; + LinkPreviewBuilder? get linkPreviewBuilder;/// Custom builder for the reactions widget. + ReactionsBuilder? get reactionsBuilder; /// Create a copy of Builders /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -42,16 +43,16 @@ $BuildersCopyWith get copyWith => _$BuildersCopyWithImpl(thi @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is Builders&&(identical(other.textMessageBuilder, textMessageBuilder) || other.textMessageBuilder == textMessageBuilder)&&(identical(other.textStreamMessageBuilder, textStreamMessageBuilder) || other.textStreamMessageBuilder == textStreamMessageBuilder)&&(identical(other.imageMessageBuilder, imageMessageBuilder) || other.imageMessageBuilder == imageMessageBuilder)&&(identical(other.fileMessageBuilder, fileMessageBuilder) || other.fileMessageBuilder == fileMessageBuilder)&&(identical(other.videoMessageBuilder, videoMessageBuilder) || other.videoMessageBuilder == videoMessageBuilder)&&(identical(other.audioMessageBuilder, audioMessageBuilder) || other.audioMessageBuilder == audioMessageBuilder)&&(identical(other.systemMessageBuilder, systemMessageBuilder) || other.systemMessageBuilder == systemMessageBuilder)&&(identical(other.customMessageBuilder, customMessageBuilder) || other.customMessageBuilder == customMessageBuilder)&&(identical(other.unsupportedMessageBuilder, unsupportedMessageBuilder) || other.unsupportedMessageBuilder == unsupportedMessageBuilder)&&(identical(other.composerBuilder, composerBuilder) || other.composerBuilder == composerBuilder)&&(identical(other.chatMessageBuilder, chatMessageBuilder) || other.chatMessageBuilder == chatMessageBuilder)&&(identical(other.chatAnimatedListBuilder, chatAnimatedListBuilder) || other.chatAnimatedListBuilder == chatAnimatedListBuilder)&&(identical(other.scrollToBottomBuilder, scrollToBottomBuilder) || other.scrollToBottomBuilder == scrollToBottomBuilder)&&(identical(other.loadMoreBuilder, loadMoreBuilder) || other.loadMoreBuilder == loadMoreBuilder)&&(identical(other.emptyChatListBuilder, emptyChatListBuilder) || other.emptyChatListBuilder == emptyChatListBuilder)&&(identical(other.linkPreviewBuilder, linkPreviewBuilder) || other.linkPreviewBuilder == linkPreviewBuilder)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is Builders&&(identical(other.textMessageBuilder, textMessageBuilder) || other.textMessageBuilder == textMessageBuilder)&&(identical(other.textStreamMessageBuilder, textStreamMessageBuilder) || other.textStreamMessageBuilder == textStreamMessageBuilder)&&(identical(other.imageMessageBuilder, imageMessageBuilder) || other.imageMessageBuilder == imageMessageBuilder)&&(identical(other.fileMessageBuilder, fileMessageBuilder) || other.fileMessageBuilder == fileMessageBuilder)&&(identical(other.videoMessageBuilder, videoMessageBuilder) || other.videoMessageBuilder == videoMessageBuilder)&&(identical(other.audioMessageBuilder, audioMessageBuilder) || other.audioMessageBuilder == audioMessageBuilder)&&(identical(other.systemMessageBuilder, systemMessageBuilder) || other.systemMessageBuilder == systemMessageBuilder)&&(identical(other.customMessageBuilder, customMessageBuilder) || other.customMessageBuilder == customMessageBuilder)&&(identical(other.unsupportedMessageBuilder, unsupportedMessageBuilder) || other.unsupportedMessageBuilder == unsupportedMessageBuilder)&&(identical(other.composerBuilder, composerBuilder) || other.composerBuilder == composerBuilder)&&(identical(other.chatMessageBuilder, chatMessageBuilder) || other.chatMessageBuilder == chatMessageBuilder)&&(identical(other.chatAnimatedListBuilder, chatAnimatedListBuilder) || other.chatAnimatedListBuilder == chatAnimatedListBuilder)&&(identical(other.scrollToBottomBuilder, scrollToBottomBuilder) || other.scrollToBottomBuilder == scrollToBottomBuilder)&&(identical(other.loadMoreBuilder, loadMoreBuilder) || other.loadMoreBuilder == loadMoreBuilder)&&(identical(other.emptyChatListBuilder, emptyChatListBuilder) || other.emptyChatListBuilder == emptyChatListBuilder)&&(identical(other.linkPreviewBuilder, linkPreviewBuilder) || other.linkPreviewBuilder == linkPreviewBuilder)&&(identical(other.reactionsBuilder, reactionsBuilder) || other.reactionsBuilder == reactionsBuilder)); } @override -int get hashCode => Object.hash(runtimeType,textMessageBuilder,textStreamMessageBuilder,imageMessageBuilder,fileMessageBuilder,videoMessageBuilder,audioMessageBuilder,systemMessageBuilder,customMessageBuilder,unsupportedMessageBuilder,composerBuilder,chatMessageBuilder,chatAnimatedListBuilder,scrollToBottomBuilder,loadMoreBuilder,emptyChatListBuilder,linkPreviewBuilder); +int get hashCode => Object.hash(runtimeType,textMessageBuilder,textStreamMessageBuilder,imageMessageBuilder,fileMessageBuilder,videoMessageBuilder,audioMessageBuilder,systemMessageBuilder,customMessageBuilder,unsupportedMessageBuilder,composerBuilder,chatMessageBuilder,chatAnimatedListBuilder,scrollToBottomBuilder,loadMoreBuilder,emptyChatListBuilder,linkPreviewBuilder,reactionsBuilder); @override String toString() { - return 'Builders(textMessageBuilder: $textMessageBuilder, textStreamMessageBuilder: $textStreamMessageBuilder, imageMessageBuilder: $imageMessageBuilder, fileMessageBuilder: $fileMessageBuilder, videoMessageBuilder: $videoMessageBuilder, audioMessageBuilder: $audioMessageBuilder, systemMessageBuilder: $systemMessageBuilder, customMessageBuilder: $customMessageBuilder, unsupportedMessageBuilder: $unsupportedMessageBuilder, composerBuilder: $composerBuilder, chatMessageBuilder: $chatMessageBuilder, chatAnimatedListBuilder: $chatAnimatedListBuilder, scrollToBottomBuilder: $scrollToBottomBuilder, loadMoreBuilder: $loadMoreBuilder, emptyChatListBuilder: $emptyChatListBuilder, linkPreviewBuilder: $linkPreviewBuilder)'; + return 'Builders(textMessageBuilder: $textMessageBuilder, textStreamMessageBuilder: $textStreamMessageBuilder, imageMessageBuilder: $imageMessageBuilder, fileMessageBuilder: $fileMessageBuilder, videoMessageBuilder: $videoMessageBuilder, audioMessageBuilder: $audioMessageBuilder, systemMessageBuilder: $systemMessageBuilder, customMessageBuilder: $customMessageBuilder, unsupportedMessageBuilder: $unsupportedMessageBuilder, composerBuilder: $composerBuilder, chatMessageBuilder: $chatMessageBuilder, chatAnimatedListBuilder: $chatAnimatedListBuilder, scrollToBottomBuilder: $scrollToBottomBuilder, loadMoreBuilder: $loadMoreBuilder, emptyChatListBuilder: $emptyChatListBuilder, linkPreviewBuilder: $linkPreviewBuilder, reactionsBuilder: $reactionsBuilder)'; } @@ -62,7 +63,7 @@ abstract mixin class $BuildersCopyWith<$Res> { factory $BuildersCopyWith(Builders value, $Res Function(Builders) _then) = _$BuildersCopyWithImpl; @useResult $Res call({ - TextMessageBuilder? textMessageBuilder, TextStreamMessageBuilder? textStreamMessageBuilder, ImageMessageBuilder? imageMessageBuilder, FileMessageBuilder? fileMessageBuilder, VideoMessageBuilder? videoMessageBuilder, AudioMessageBuilder? audioMessageBuilder, SystemMessageBuilder? systemMessageBuilder, CustomMessageBuilder? customMessageBuilder, UnsupportedMessageBuilder? unsupportedMessageBuilder, ComposerBuilder? composerBuilder, ChatMessageBuilder? chatMessageBuilder, ChatAnimatedListBuilder? chatAnimatedListBuilder, ScrollToBottomBuilder? scrollToBottomBuilder, LoadMoreBuilder? loadMoreBuilder, EmptyChatListBuilder? emptyChatListBuilder, LinkPreviewBuilder? linkPreviewBuilder + TextMessageBuilder? textMessageBuilder, TextStreamMessageBuilder? textStreamMessageBuilder, ImageMessageBuilder? imageMessageBuilder, FileMessageBuilder? fileMessageBuilder, VideoMessageBuilder? videoMessageBuilder, AudioMessageBuilder? audioMessageBuilder, SystemMessageBuilder? systemMessageBuilder, CustomMessageBuilder? customMessageBuilder, UnsupportedMessageBuilder? unsupportedMessageBuilder, ComposerBuilder? composerBuilder, ChatMessageBuilder? chatMessageBuilder, ChatAnimatedListBuilder? chatAnimatedListBuilder, ScrollToBottomBuilder? scrollToBottomBuilder, LoadMoreBuilder? loadMoreBuilder, EmptyChatListBuilder? emptyChatListBuilder, LinkPreviewBuilder? linkPreviewBuilder, ReactionsBuilder? reactionsBuilder }); @@ -79,7 +80,7 @@ class _$BuildersCopyWithImpl<$Res> /// Create a copy of Builders /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? textMessageBuilder = freezed,Object? textStreamMessageBuilder = freezed,Object? imageMessageBuilder = freezed,Object? fileMessageBuilder = freezed,Object? videoMessageBuilder = freezed,Object? audioMessageBuilder = freezed,Object? systemMessageBuilder = freezed,Object? customMessageBuilder = freezed,Object? unsupportedMessageBuilder = freezed,Object? composerBuilder = freezed,Object? chatMessageBuilder = freezed,Object? chatAnimatedListBuilder = freezed,Object? scrollToBottomBuilder = freezed,Object? loadMoreBuilder = freezed,Object? emptyChatListBuilder = freezed,Object? linkPreviewBuilder = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? textMessageBuilder = freezed,Object? textStreamMessageBuilder = freezed,Object? imageMessageBuilder = freezed,Object? fileMessageBuilder = freezed,Object? videoMessageBuilder = freezed,Object? audioMessageBuilder = freezed,Object? systemMessageBuilder = freezed,Object? customMessageBuilder = freezed,Object? unsupportedMessageBuilder = freezed,Object? composerBuilder = freezed,Object? chatMessageBuilder = freezed,Object? chatAnimatedListBuilder = freezed,Object? scrollToBottomBuilder = freezed,Object? loadMoreBuilder = freezed,Object? emptyChatListBuilder = freezed,Object? linkPreviewBuilder = freezed,Object? reactionsBuilder = freezed,}) { return _then(_self.copyWith( textMessageBuilder: freezed == textMessageBuilder ? _self.textMessageBuilder : textMessageBuilder // ignore: cast_nullable_to_non_nullable as TextMessageBuilder?,textStreamMessageBuilder: freezed == textStreamMessageBuilder ? _self.textStreamMessageBuilder : textStreamMessageBuilder // ignore: cast_nullable_to_non_nullable @@ -97,7 +98,8 @@ as ChatAnimatedListBuilder?,scrollToBottomBuilder: freezed == scrollToBottomBuil as ScrollToBottomBuilder?,loadMoreBuilder: freezed == loadMoreBuilder ? _self.loadMoreBuilder : loadMoreBuilder // ignore: cast_nullable_to_non_nullable as LoadMoreBuilder?,emptyChatListBuilder: freezed == emptyChatListBuilder ? _self.emptyChatListBuilder : emptyChatListBuilder // ignore: cast_nullable_to_non_nullable as EmptyChatListBuilder?,linkPreviewBuilder: freezed == linkPreviewBuilder ? _self.linkPreviewBuilder : linkPreviewBuilder // ignore: cast_nullable_to_non_nullable -as LinkPreviewBuilder?, +as LinkPreviewBuilder?,reactionsBuilder: freezed == reactionsBuilder ? _self.reactionsBuilder : reactionsBuilder // ignore: cast_nullable_to_non_nullable +as ReactionsBuilder?, )); } @@ -108,7 +110,7 @@ as LinkPreviewBuilder?, class _Builders extends Builders { - const _Builders({this.textMessageBuilder, this.textStreamMessageBuilder, this.imageMessageBuilder, this.fileMessageBuilder, this.videoMessageBuilder, this.audioMessageBuilder, this.systemMessageBuilder, this.customMessageBuilder, this.unsupportedMessageBuilder, this.composerBuilder, this.chatMessageBuilder, this.chatAnimatedListBuilder, this.scrollToBottomBuilder, this.loadMoreBuilder, this.emptyChatListBuilder, this.linkPreviewBuilder}): super._(); + const _Builders({this.textMessageBuilder, this.textStreamMessageBuilder, this.imageMessageBuilder, this.fileMessageBuilder, this.videoMessageBuilder, this.audioMessageBuilder, this.systemMessageBuilder, this.customMessageBuilder, this.unsupportedMessageBuilder, this.composerBuilder, this.chatMessageBuilder, this.chatAnimatedListBuilder, this.scrollToBottomBuilder, this.loadMoreBuilder, this.emptyChatListBuilder, this.linkPreviewBuilder, this.reactionsBuilder}): super._(); /// Custom builder for text messages. @@ -143,6 +145,8 @@ class _Builders extends Builders { @override final EmptyChatListBuilder? emptyChatListBuilder; /// Custom builder for the link preview widget. @override final LinkPreviewBuilder? linkPreviewBuilder; +/// Custom builder for the reactions widget. +@override final ReactionsBuilder? reactionsBuilder; /// Create a copy of Builders /// with the given fields replaced by the non-null parameter values. @@ -154,16 +158,16 @@ _$BuildersCopyWith<_Builders> get copyWith => __$BuildersCopyWithImpl<_Builders> @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _Builders&&(identical(other.textMessageBuilder, textMessageBuilder) || other.textMessageBuilder == textMessageBuilder)&&(identical(other.textStreamMessageBuilder, textStreamMessageBuilder) || other.textStreamMessageBuilder == textStreamMessageBuilder)&&(identical(other.imageMessageBuilder, imageMessageBuilder) || other.imageMessageBuilder == imageMessageBuilder)&&(identical(other.fileMessageBuilder, fileMessageBuilder) || other.fileMessageBuilder == fileMessageBuilder)&&(identical(other.videoMessageBuilder, videoMessageBuilder) || other.videoMessageBuilder == videoMessageBuilder)&&(identical(other.audioMessageBuilder, audioMessageBuilder) || other.audioMessageBuilder == audioMessageBuilder)&&(identical(other.systemMessageBuilder, systemMessageBuilder) || other.systemMessageBuilder == systemMessageBuilder)&&(identical(other.customMessageBuilder, customMessageBuilder) || other.customMessageBuilder == customMessageBuilder)&&(identical(other.unsupportedMessageBuilder, unsupportedMessageBuilder) || other.unsupportedMessageBuilder == unsupportedMessageBuilder)&&(identical(other.composerBuilder, composerBuilder) || other.composerBuilder == composerBuilder)&&(identical(other.chatMessageBuilder, chatMessageBuilder) || other.chatMessageBuilder == chatMessageBuilder)&&(identical(other.chatAnimatedListBuilder, chatAnimatedListBuilder) || other.chatAnimatedListBuilder == chatAnimatedListBuilder)&&(identical(other.scrollToBottomBuilder, scrollToBottomBuilder) || other.scrollToBottomBuilder == scrollToBottomBuilder)&&(identical(other.loadMoreBuilder, loadMoreBuilder) || other.loadMoreBuilder == loadMoreBuilder)&&(identical(other.emptyChatListBuilder, emptyChatListBuilder) || other.emptyChatListBuilder == emptyChatListBuilder)&&(identical(other.linkPreviewBuilder, linkPreviewBuilder) || other.linkPreviewBuilder == linkPreviewBuilder)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _Builders&&(identical(other.textMessageBuilder, textMessageBuilder) || other.textMessageBuilder == textMessageBuilder)&&(identical(other.textStreamMessageBuilder, textStreamMessageBuilder) || other.textStreamMessageBuilder == textStreamMessageBuilder)&&(identical(other.imageMessageBuilder, imageMessageBuilder) || other.imageMessageBuilder == imageMessageBuilder)&&(identical(other.fileMessageBuilder, fileMessageBuilder) || other.fileMessageBuilder == fileMessageBuilder)&&(identical(other.videoMessageBuilder, videoMessageBuilder) || other.videoMessageBuilder == videoMessageBuilder)&&(identical(other.audioMessageBuilder, audioMessageBuilder) || other.audioMessageBuilder == audioMessageBuilder)&&(identical(other.systemMessageBuilder, systemMessageBuilder) || other.systemMessageBuilder == systemMessageBuilder)&&(identical(other.customMessageBuilder, customMessageBuilder) || other.customMessageBuilder == customMessageBuilder)&&(identical(other.unsupportedMessageBuilder, unsupportedMessageBuilder) || other.unsupportedMessageBuilder == unsupportedMessageBuilder)&&(identical(other.composerBuilder, composerBuilder) || other.composerBuilder == composerBuilder)&&(identical(other.chatMessageBuilder, chatMessageBuilder) || other.chatMessageBuilder == chatMessageBuilder)&&(identical(other.chatAnimatedListBuilder, chatAnimatedListBuilder) || other.chatAnimatedListBuilder == chatAnimatedListBuilder)&&(identical(other.scrollToBottomBuilder, scrollToBottomBuilder) || other.scrollToBottomBuilder == scrollToBottomBuilder)&&(identical(other.loadMoreBuilder, loadMoreBuilder) || other.loadMoreBuilder == loadMoreBuilder)&&(identical(other.emptyChatListBuilder, emptyChatListBuilder) || other.emptyChatListBuilder == emptyChatListBuilder)&&(identical(other.linkPreviewBuilder, linkPreviewBuilder) || other.linkPreviewBuilder == linkPreviewBuilder)&&(identical(other.reactionsBuilder, reactionsBuilder) || other.reactionsBuilder == reactionsBuilder)); } @override -int get hashCode => Object.hash(runtimeType,textMessageBuilder,textStreamMessageBuilder,imageMessageBuilder,fileMessageBuilder,videoMessageBuilder,audioMessageBuilder,systemMessageBuilder,customMessageBuilder,unsupportedMessageBuilder,composerBuilder,chatMessageBuilder,chatAnimatedListBuilder,scrollToBottomBuilder,loadMoreBuilder,emptyChatListBuilder,linkPreviewBuilder); +int get hashCode => Object.hash(runtimeType,textMessageBuilder,textStreamMessageBuilder,imageMessageBuilder,fileMessageBuilder,videoMessageBuilder,audioMessageBuilder,systemMessageBuilder,customMessageBuilder,unsupportedMessageBuilder,composerBuilder,chatMessageBuilder,chatAnimatedListBuilder,scrollToBottomBuilder,loadMoreBuilder,emptyChatListBuilder,linkPreviewBuilder,reactionsBuilder); @override String toString() { - return 'Builders(textMessageBuilder: $textMessageBuilder, textStreamMessageBuilder: $textStreamMessageBuilder, imageMessageBuilder: $imageMessageBuilder, fileMessageBuilder: $fileMessageBuilder, videoMessageBuilder: $videoMessageBuilder, audioMessageBuilder: $audioMessageBuilder, systemMessageBuilder: $systemMessageBuilder, customMessageBuilder: $customMessageBuilder, unsupportedMessageBuilder: $unsupportedMessageBuilder, composerBuilder: $composerBuilder, chatMessageBuilder: $chatMessageBuilder, chatAnimatedListBuilder: $chatAnimatedListBuilder, scrollToBottomBuilder: $scrollToBottomBuilder, loadMoreBuilder: $loadMoreBuilder, emptyChatListBuilder: $emptyChatListBuilder, linkPreviewBuilder: $linkPreviewBuilder)'; + return 'Builders(textMessageBuilder: $textMessageBuilder, textStreamMessageBuilder: $textStreamMessageBuilder, imageMessageBuilder: $imageMessageBuilder, fileMessageBuilder: $fileMessageBuilder, videoMessageBuilder: $videoMessageBuilder, audioMessageBuilder: $audioMessageBuilder, systemMessageBuilder: $systemMessageBuilder, customMessageBuilder: $customMessageBuilder, unsupportedMessageBuilder: $unsupportedMessageBuilder, composerBuilder: $composerBuilder, chatMessageBuilder: $chatMessageBuilder, chatAnimatedListBuilder: $chatAnimatedListBuilder, scrollToBottomBuilder: $scrollToBottomBuilder, loadMoreBuilder: $loadMoreBuilder, emptyChatListBuilder: $emptyChatListBuilder, linkPreviewBuilder: $linkPreviewBuilder, reactionsBuilder: $reactionsBuilder)'; } @@ -174,7 +178,7 @@ abstract mixin class _$BuildersCopyWith<$Res> implements $BuildersCopyWith<$Res> factory _$BuildersCopyWith(_Builders value, $Res Function(_Builders) _then) = __$BuildersCopyWithImpl; @override @useResult $Res call({ - TextMessageBuilder? textMessageBuilder, TextStreamMessageBuilder? textStreamMessageBuilder, ImageMessageBuilder? imageMessageBuilder, FileMessageBuilder? fileMessageBuilder, VideoMessageBuilder? videoMessageBuilder, AudioMessageBuilder? audioMessageBuilder, SystemMessageBuilder? systemMessageBuilder, CustomMessageBuilder? customMessageBuilder, UnsupportedMessageBuilder? unsupportedMessageBuilder, ComposerBuilder? composerBuilder, ChatMessageBuilder? chatMessageBuilder, ChatAnimatedListBuilder? chatAnimatedListBuilder, ScrollToBottomBuilder? scrollToBottomBuilder, LoadMoreBuilder? loadMoreBuilder, EmptyChatListBuilder? emptyChatListBuilder, LinkPreviewBuilder? linkPreviewBuilder + TextMessageBuilder? textMessageBuilder, TextStreamMessageBuilder? textStreamMessageBuilder, ImageMessageBuilder? imageMessageBuilder, FileMessageBuilder? fileMessageBuilder, VideoMessageBuilder? videoMessageBuilder, AudioMessageBuilder? audioMessageBuilder, SystemMessageBuilder? systemMessageBuilder, CustomMessageBuilder? customMessageBuilder, UnsupportedMessageBuilder? unsupportedMessageBuilder, ComposerBuilder? composerBuilder, ChatMessageBuilder? chatMessageBuilder, ChatAnimatedListBuilder? chatAnimatedListBuilder, ScrollToBottomBuilder? scrollToBottomBuilder, LoadMoreBuilder? loadMoreBuilder, EmptyChatListBuilder? emptyChatListBuilder, LinkPreviewBuilder? linkPreviewBuilder, ReactionsBuilder? reactionsBuilder }); @@ -191,7 +195,7 @@ class __$BuildersCopyWithImpl<$Res> /// Create a copy of Builders /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? textMessageBuilder = freezed,Object? textStreamMessageBuilder = freezed,Object? imageMessageBuilder = freezed,Object? fileMessageBuilder = freezed,Object? videoMessageBuilder = freezed,Object? audioMessageBuilder = freezed,Object? systemMessageBuilder = freezed,Object? customMessageBuilder = freezed,Object? unsupportedMessageBuilder = freezed,Object? composerBuilder = freezed,Object? chatMessageBuilder = freezed,Object? chatAnimatedListBuilder = freezed,Object? scrollToBottomBuilder = freezed,Object? loadMoreBuilder = freezed,Object? emptyChatListBuilder = freezed,Object? linkPreviewBuilder = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? textMessageBuilder = freezed,Object? textStreamMessageBuilder = freezed,Object? imageMessageBuilder = freezed,Object? fileMessageBuilder = freezed,Object? videoMessageBuilder = freezed,Object? audioMessageBuilder = freezed,Object? systemMessageBuilder = freezed,Object? customMessageBuilder = freezed,Object? unsupportedMessageBuilder = freezed,Object? composerBuilder = freezed,Object? chatMessageBuilder = freezed,Object? chatAnimatedListBuilder = freezed,Object? scrollToBottomBuilder = freezed,Object? loadMoreBuilder = freezed,Object? emptyChatListBuilder = freezed,Object? linkPreviewBuilder = freezed,Object? reactionsBuilder = freezed,}) { return _then(_Builders( textMessageBuilder: freezed == textMessageBuilder ? _self.textMessageBuilder : textMessageBuilder // ignore: cast_nullable_to_non_nullable as TextMessageBuilder?,textStreamMessageBuilder: freezed == textStreamMessageBuilder ? _self.textStreamMessageBuilder : textStreamMessageBuilder // ignore: cast_nullable_to_non_nullable @@ -209,7 +213,8 @@ as ChatAnimatedListBuilder?,scrollToBottomBuilder: freezed == scrollToBottomBuil as ScrollToBottomBuilder?,loadMoreBuilder: freezed == loadMoreBuilder ? _self.loadMoreBuilder : loadMoreBuilder // ignore: cast_nullable_to_non_nullable as LoadMoreBuilder?,emptyChatListBuilder: freezed == emptyChatListBuilder ? _self.emptyChatListBuilder : emptyChatListBuilder // ignore: cast_nullable_to_non_nullable as EmptyChatListBuilder?,linkPreviewBuilder: freezed == linkPreviewBuilder ? _self.linkPreviewBuilder : linkPreviewBuilder // ignore: cast_nullable_to_non_nullable -as LinkPreviewBuilder?, +as LinkPreviewBuilder?,reactionsBuilder: freezed == reactionsBuilder ? _self.reactionsBuilder : reactionsBuilder // ignore: cast_nullable_to_non_nullable +as ReactionsBuilder?, )); } From b414f0f85415602766e7e3b672f9552e54304c45 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:09:07 +0200 Subject: [PATCH 14/36] Add surfaceContainerHighest to ChatTheme --- .../lib/src/theme/chat_theme.dart | 7 +++++ .../lib/src/theme/chat_theme.freezed.dart | 29 +++++++++++-------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/packages/flutter_chat_core/lib/src/theme/chat_theme.dart b/packages/flutter_chat_core/lib/src/theme/chat_theme.dart index 9cfcab45f..196901065 100644 --- a/packages/flutter_chat_core/lib/src/theme/chat_theme.dart +++ b/packages/flutter_chat_core/lib/src/theme/chat_theme.dart @@ -85,6 +85,9 @@ abstract class ChatColors with _$ChatColors { /// A slightly lighter/darker variant of [surfaceContainer]. required Color surfaceContainerHigh, + + /// The highest/most elevated container surface. + required Color surfaceContainerHighest, }) = _ChatColors; const ChatColors._(); @@ -98,6 +101,7 @@ abstract class ChatColors with _$ChatColors { surfaceContainerLow: Color(0xfffafafa), surfaceContainer: Color(0xfff5f5f5), surfaceContainerHigh: Color(0xffeeeeee), + surfaceContainerHighest: Color(0xfff0f0f0), ); /// Default dark color palette. @@ -109,6 +113,7 @@ abstract class ChatColors with _$ChatColors { surfaceContainerLow: Color(0xff121212), surfaceContainer: Color(0xff1c1c1c), surfaceContainerHigh: Color(0xff242424), + surfaceContainerHighest: Color(0xff2c2c2c), ); /// Creates [ChatColors] from a Material [ThemeData]. @@ -120,6 +125,7 @@ abstract class ChatColors with _$ChatColors { surfaceContainerLow: themeData.colorScheme.surfaceContainerLow, surfaceContainer: themeData.colorScheme.surfaceContainer, surfaceContainerHigh: themeData.colorScheme.surfaceContainerHigh, + surfaceContainerHighest: themeData.colorScheme.surfaceContainerHigh, ); /// Merges this color scheme with another [ChatColors]. @@ -135,6 +141,7 @@ abstract class ChatColors with _$ChatColors { surfaceContainerLow: other.surfaceContainerLow, surfaceContainer: other.surfaceContainer, surfaceContainerHigh: other.surfaceContainerHigh, + surfaceContainerHighest: other.surfaceContainerHighest, ); } } diff --git a/packages/flutter_chat_core/lib/src/theme/chat_theme.freezed.dart b/packages/flutter_chat_core/lib/src/theme/chat_theme.freezed.dart index 87c219960..a67d129a0 100644 --- a/packages/flutter_chat_core/lib/src/theme/chat_theme.freezed.dart +++ b/packages/flutter_chat_core/lib/src/theme/chat_theme.freezed.dart @@ -197,7 +197,8 @@ mixin _$ChatColors { Color get onSurface;/// Background color for elements like received messages. Color get surfaceContainer;/// A slightly lighter/darker variant of [surfaceContainer]. Color get surfaceContainerLow;/// A slightly lighter/darker variant of [surfaceContainer]. - Color get surfaceContainerHigh; + Color get surfaceContainerHigh;/// The highest/most elevated container surface. + Color get surfaceContainerHighest; /// Create a copy of ChatColors /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -208,16 +209,16 @@ $ChatColorsCopyWith get copyWith => _$ChatColorsCopyWithImpl Object.hash(runtimeType,primary,onPrimary,surface,onSurface,surfaceContainer,surfaceContainerLow,surfaceContainerHigh); +int get hashCode => Object.hash(runtimeType,primary,onPrimary,surface,onSurface,surfaceContainer,surfaceContainerLow,surfaceContainerHigh,surfaceContainerHighest); @override String toString() { - return 'ChatColors(primary: $primary, onPrimary: $onPrimary, surface: $surface, onSurface: $onSurface, surfaceContainer: $surfaceContainer, surfaceContainerLow: $surfaceContainerLow, surfaceContainerHigh: $surfaceContainerHigh)'; + return 'ChatColors(primary: $primary, onPrimary: $onPrimary, surface: $surface, onSurface: $onSurface, surfaceContainer: $surfaceContainer, surfaceContainerLow: $surfaceContainerLow, surfaceContainerHigh: $surfaceContainerHigh, surfaceContainerHighest: $surfaceContainerHighest)'; } @@ -228,7 +229,7 @@ abstract mixin class $ChatColorsCopyWith<$Res> { factory $ChatColorsCopyWith(ChatColors value, $Res Function(ChatColors) _then) = _$ChatColorsCopyWithImpl; @useResult $Res call({ - Color primary, Color onPrimary, Color surface, Color onSurface, Color surfaceContainer, Color surfaceContainerLow, Color surfaceContainerHigh + Color primary, Color onPrimary, Color surface, Color onSurface, Color surfaceContainer, Color surfaceContainerLow, Color surfaceContainerHigh, Color surfaceContainerHighest }); @@ -245,7 +246,7 @@ class _$ChatColorsCopyWithImpl<$Res> /// Create a copy of ChatColors /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? primary = null,Object? onPrimary = null,Object? surface = null,Object? onSurface = null,Object? surfaceContainer = null,Object? surfaceContainerLow = null,Object? surfaceContainerHigh = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? primary = null,Object? onPrimary = null,Object? surface = null,Object? onSurface = null,Object? surfaceContainer = null,Object? surfaceContainerLow = null,Object? surfaceContainerHigh = null,Object? surfaceContainerHighest = null,}) { return _then(_self.copyWith( primary: null == primary ? _self.primary : primary // ignore: cast_nullable_to_non_nullable as Color,onPrimary: null == onPrimary ? _self.onPrimary : onPrimary // ignore: cast_nullable_to_non_nullable @@ -254,6 +255,7 @@ as Color,onSurface: null == onSurface ? _self.onSurface : onSurface // ignore: c as Color,surfaceContainer: null == surfaceContainer ? _self.surfaceContainer : surfaceContainer // ignore: cast_nullable_to_non_nullable as Color,surfaceContainerLow: null == surfaceContainerLow ? _self.surfaceContainerLow : surfaceContainerLow // ignore: cast_nullable_to_non_nullable as Color,surfaceContainerHigh: null == surfaceContainerHigh ? _self.surfaceContainerHigh : surfaceContainerHigh // ignore: cast_nullable_to_non_nullable +as Color,surfaceContainerHighest: null == surfaceContainerHighest ? _self.surfaceContainerHighest : surfaceContainerHighest // ignore: cast_nullable_to_non_nullable as Color, )); } @@ -265,7 +267,7 @@ as Color, class _ChatColors extends ChatColors { - const _ChatColors({required this.primary, required this.onPrimary, required this.surface, required this.onSurface, required this.surfaceContainer, required this.surfaceContainerLow, required this.surfaceContainerHigh}): super._(); + const _ChatColors({required this.primary, required this.onPrimary, required this.surface, required this.onSurface, required this.surfaceContainer, required this.surfaceContainerLow, required this.surfaceContainerHigh, required this.surfaceContainerHighest}): super._(); /// Primary color, often used for sent messages and accents. @@ -282,6 +284,8 @@ class _ChatColors extends ChatColors { @override final Color surfaceContainerLow; /// A slightly lighter/darker variant of [surfaceContainer]. @override final Color surfaceContainerHigh; +/// The highest/most elevated container surface. +@override final Color surfaceContainerHighest; /// Create a copy of ChatColors /// with the given fields replaced by the non-null parameter values. @@ -293,16 +297,16 @@ _$ChatColorsCopyWith<_ChatColors> get copyWith => __$ChatColorsCopyWithImpl<_Cha @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatColors&&(identical(other.primary, primary) || other.primary == primary)&&(identical(other.onPrimary, onPrimary) || other.onPrimary == onPrimary)&&(identical(other.surface, surface) || other.surface == surface)&&(identical(other.onSurface, onSurface) || other.onSurface == onSurface)&&(identical(other.surfaceContainer, surfaceContainer) || other.surfaceContainer == surfaceContainer)&&(identical(other.surfaceContainerLow, surfaceContainerLow) || other.surfaceContainerLow == surfaceContainerLow)&&(identical(other.surfaceContainerHigh, surfaceContainerHigh) || other.surfaceContainerHigh == surfaceContainerHigh)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatColors&&(identical(other.primary, primary) || other.primary == primary)&&(identical(other.onPrimary, onPrimary) || other.onPrimary == onPrimary)&&(identical(other.surface, surface) || other.surface == surface)&&(identical(other.onSurface, onSurface) || other.onSurface == onSurface)&&(identical(other.surfaceContainer, surfaceContainer) || other.surfaceContainer == surfaceContainer)&&(identical(other.surfaceContainerLow, surfaceContainerLow) || other.surfaceContainerLow == surfaceContainerLow)&&(identical(other.surfaceContainerHigh, surfaceContainerHigh) || other.surfaceContainerHigh == surfaceContainerHigh)&&(identical(other.surfaceContainerHighest, surfaceContainerHighest) || other.surfaceContainerHighest == surfaceContainerHighest)); } @override -int get hashCode => Object.hash(runtimeType,primary,onPrimary,surface,onSurface,surfaceContainer,surfaceContainerLow,surfaceContainerHigh); +int get hashCode => Object.hash(runtimeType,primary,onPrimary,surface,onSurface,surfaceContainer,surfaceContainerLow,surfaceContainerHigh,surfaceContainerHighest); @override String toString() { - return 'ChatColors(primary: $primary, onPrimary: $onPrimary, surface: $surface, onSurface: $onSurface, surfaceContainer: $surfaceContainer, surfaceContainerLow: $surfaceContainerLow, surfaceContainerHigh: $surfaceContainerHigh)'; + return 'ChatColors(primary: $primary, onPrimary: $onPrimary, surface: $surface, onSurface: $onSurface, surfaceContainer: $surfaceContainer, surfaceContainerLow: $surfaceContainerLow, surfaceContainerHigh: $surfaceContainerHigh, surfaceContainerHighest: $surfaceContainerHighest)'; } @@ -313,7 +317,7 @@ abstract mixin class _$ChatColorsCopyWith<$Res> implements $ChatColorsCopyWith<$ factory _$ChatColorsCopyWith(_ChatColors value, $Res Function(_ChatColors) _then) = __$ChatColorsCopyWithImpl; @override @useResult $Res call({ - Color primary, Color onPrimary, Color surface, Color onSurface, Color surfaceContainer, Color surfaceContainerLow, Color surfaceContainerHigh + Color primary, Color onPrimary, Color surface, Color onSurface, Color surfaceContainer, Color surfaceContainerLow, Color surfaceContainerHigh, Color surfaceContainerHighest }); @@ -330,7 +334,7 @@ class __$ChatColorsCopyWithImpl<$Res> /// Create a copy of ChatColors /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? primary = null,Object? onPrimary = null,Object? surface = null,Object? onSurface = null,Object? surfaceContainer = null,Object? surfaceContainerLow = null,Object? surfaceContainerHigh = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? primary = null,Object? onPrimary = null,Object? surface = null,Object? onSurface = null,Object? surfaceContainer = null,Object? surfaceContainerLow = null,Object? surfaceContainerHigh = null,Object? surfaceContainerHighest = null,}) { return _then(_ChatColors( primary: null == primary ? _self.primary : primary // ignore: cast_nullable_to_non_nullable as Color,onPrimary: null == onPrimary ? _self.onPrimary : onPrimary // ignore: cast_nullable_to_non_nullable @@ -339,6 +343,7 @@ as Color,onSurface: null == onSurface ? _self.onSurface : onSurface // ignore: c as Color,surfaceContainer: null == surfaceContainer ? _self.surfaceContainer : surfaceContainer // ignore: cast_nullable_to_non_nullable as Color,surfaceContainerLow: null == surfaceContainerLow ? _self.surfaceContainerLow : surfaceContainerLow // ignore: cast_nullable_to_non_nullable as Color,surfaceContainerHigh: null == surfaceContainerHigh ? _self.surfaceContainerHigh : surfaceContainerHigh // ignore: cast_nullable_to_non_nullable +as Color,surfaceContainerHighest: null == surfaceContainerHighest ? _self.surfaceContainerHighest : surfaceContainerHighest // ignore: cast_nullable_to_non_nullable as Color, )); } From f77ea7804a125ebaef8376ef0a7f20f3620faf21 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:23:14 +0200 Subject: [PATCH 15/36] Add the reactions (from builder) in the ChatMessage Widget --- .../lib/src/chat_message/chat_message.dart | 73 +++++++++++++------ 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/packages/flutter_chat_ui/lib/src/chat_message/chat_message.dart b/packages/flutter_chat_ui/lib/src/chat_message/chat_message.dart index df82a2a0e..514c0e3c6 100644 --- a/packages/flutter_chat_ui/lib/src/chat_message/chat_message.dart +++ b/packages/flutter_chat_ui/lib/src/chat_message/chat_message.dart @@ -134,6 +134,10 @@ class ChatMessage extends StatelessWidget { final resolvedPadding = padding ?? _resolveDefaultPadding(context); + final reactionsBuilder = context.read()?.reactionsBuilder; + Widget? reactionsWidget; + reactionsWidget = reactionsBuilder?.call(context, message, isSentByMe); + final Widget messageWidget = Column( mainAxisSize: MainAxisSize.min, children: [ @@ -190,7 +194,10 @@ class ChatMessage extends StatelessWidget { (isSentByMe ? sentMessageAlignment : receivedMessageAlignment), - child: _buildMessage(isSentByMe: isSentByMe), + child: _buildMessage( + isSentByMe: isSentByMe, + reactionsWidget: reactionsWidget, + ), ), ), ), @@ -213,27 +220,49 @@ class ChatMessage extends StatelessWidget { return messageWidget; } - Widget _buildMessage({required bool isSentByMe}) => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - isSentByMe - ? sentMessageColumnAlignment - : receivedMessageColumnAlignment, - children: [ - if (topWidget != null) topWidget!, - Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: - isSentByMe ? sentMessageRowAlignment : receivedMessageRowAlignment, - children: [ - if (leadingWidget != null) leadingWidget!, - Flexible(child: child), - if (trailingWidget != null) trailingWidget!, - ], - ), - if (bottomWidget != null) bottomWidget!, - ], - ); + Widget _buildMessage({required bool isSentByMe, Widget? reactionsWidget}) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + isSentByMe + ? sentMessageColumnAlignment + : receivedMessageColumnAlignment, + children: [ + if (topWidget != null) topWidget!, + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: + isSentByMe + ? sentMessageRowAlignment + : receivedMessageRowAlignment, + children: [ + if (leadingWidget != null) leadingWidget!, + Flexible( + child: + reactionsWidget != null + ? Stack( + children: [ + // TODO Find better way to add height for the reactions widget + // TODO: maybe we could set a width to allow at least some space for the reactions widget + // We message is really short ? + Column(children: [child, SizedBox(height: 16)]), + Positioned( + bottom: 0, + left: 8, + right: 8, + child: reactionsWidget, + ), + ], + ) + : child, + ), + if (trailingWidget != null) trailingWidget!, + ], + ), + if (bottomWidget != null) bottomWidget!, + ], + ); + } EdgeInsetsGeometry _resolveDefaultPadding(BuildContext context) { if (index == 0) { From baf51377c55a90551e5fb70272c05ea7636d040d Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:24:07 +0200 Subject: [PATCH 16/36] Expose class and widgets --- packages/flyer_chat_reactions/lib/flyer_chat_reactions.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/flyer_chat_reactions/lib/flyer_chat_reactions.dart b/packages/flyer_chat_reactions/lib/flyer_chat_reactions.dart index e69de29bb..355d42a90 100644 --- a/packages/flyer_chat_reactions/lib/flyer_chat_reactions.dart +++ b/packages/flyer_chat_reactions/lib/flyer_chat_reactions.dart @@ -0,0 +1,6 @@ +export 'src/models/menu_item.dart'; +export 'src/models/reaction.dart'; +export 'src/widgets/flyer_chat_reactions_row.dart'; +export 'src/widgets/reaction_tile.dart'; +export 'src/widgets/reactions_dialog.dart'; +export 'src/widgets/reactions_list.dart'; From f0787768daf6e97659dc7cdd1d64b5930d88b771 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Thu, 17 Jul 2025 16:25:32 +0200 Subject: [PATCH 17/36] Add example --- examples/flyer_chat/ios/Podfile.lock | 21 +++- examples/flyer_chat/lib/create_message.dart | 21 ++++ examples/flyer_chat/lib/local.dart | 106 +++++++++++++++++--- examples/flyer_chat/pubspec.yaml | 5 +- 4 files changed, 133 insertions(+), 20 deletions(-) diff --git a/examples/flyer_chat/ios/Podfile.lock b/examples/flyer_chat/ios/Podfile.lock index 81472501a..35a65cfd2 100644 --- a/examples/flyer_chat/ios/Podfile.lock +++ b/examples/flyer_chat/ios/Podfile.lock @@ -30,6 +30,8 @@ PODS: - DKPhotoGallery/Resource (0.0.19): - SDWebImage - SwiftyGif + - emoji_picker_flutter (0.0.1): + - Flutter - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter @@ -43,20 +45,25 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS - - SDWebImage (5.21.0): - - SDWebImage/Core (= 5.21.0) - - SDWebImage/Core (5.21.0) + - SDWebImage (5.21.1): + - SDWebImage/Core (= 5.21.1) + - SDWebImage/Core (5.21.1) + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS - SwiftyGif (5.4.5) - url_launcher_ios (0.0.1): - Flutter DEPENDENCIES: + - emoji_picker_flutter (from `.symlinks/plugins/emoji_picker_flutter/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - integration_test (from `.symlinks/plugins/integration_test/ios`) - isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) SPEC REPOS: @@ -67,6 +74,8 @@ SPEC REPOS: - SwiftyGif EXTERNAL SOURCES: + emoji_picker_flutter: + :path: ".symlinks/plugins/emoji_picker_flutter/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" Flutter: @@ -79,19 +88,23 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/isar_flutter_libs/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + emoji_picker_flutter: ece213fc274bdddefb77d502d33080dc54e616cc file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e isar_flutter_libs: 9fc2cfb928c539e1b76c481ba5d143d556d94920 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 - SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868 + SDWebImage: f29024626962457f3470184232766516dee8dfea + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d diff --git a/examples/flyer_chat/lib/create_message.dart b/examples/flyer_chat/lib/create_message.dart index f56ee2f36..07369b570 100644 --- a/examples/flyer_chat/lib/create_message.dart +++ b/examples/flyer_chat/lib/create_message.dart @@ -25,6 +25,13 @@ Future createMessage( sentAt: localOnly == true ? DateTime.now().toUtc() : null, text: text ?? lorem(paragraphs: 1, words: Random().nextInt(30) + 1), metadata: isOnlyEmoji(text ?? '') ? {'isOnlyEmoji': true} : null, + reactions: { + '👍': [authorId, 'someOtherId'], + '👎': ['someOtherId'], + '👏': [authorId], + '👌': [authorId], + '👊': [authorId], + }, ); } else { final orientation = ['portrait', 'square', 'wide'][Random().nextInt(3)]; @@ -61,6 +68,13 @@ Future createMessage( source: response.data['img'], thumbhash: response.data['thumbhash'], blurhash: response.data['blurhash'], + reactions: { + '👍': [authorId, 'someOtherId'], + '👎': ['someOtherId'], + '👏': [authorId], + '👌': [authorId], + '👊': [authorId], + }, ); } else { message = FileMessage( @@ -71,6 +85,13 @@ Future createMessage( sentAt: localOnly == true ? DateTime.now().toUtc() : null, source: response.data['img'], size: 1000000, + reactions: { + '👍': [authorId, 'someOtherId'], + '👎': ['someOtherId'], + '👏': [authorId], + '👌': [authorId], + '👊': [authorId], + }, ); } } diff --git a/examples/flyer_chat/lib/local.dart b/examples/flyer_chat/lib/local.dart index 04af240d2..5268ca304 100644 --- a/examples/flyer_chat/lib/local.dart +++ b/examples/flyer_chat/lib/local.dart @@ -1,6 +1,7 @@ import 'dart:math'; import 'package:dio/dio.dart'; +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -10,10 +11,10 @@ import 'package:flutter_chat_ui/flutter_chat_ui.dart'; import 'package:flutter_link_previewer/flutter_link_previewer.dart'; import 'package:flyer_chat_file_message/flyer_chat_file_message.dart'; import 'package:flyer_chat_image_message/flyer_chat_image_message.dart'; +import 'package:flyer_chat_reactions/flyer_chat_reactions.dart'; import 'package:flyer_chat_system_message/flyer_chat_system_message.dart'; import 'package:flyer_chat_text_message/flyer_chat_text_message.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:pull_down_button/pull_down_button.dart'; import 'package:uuid/uuid.dart'; import 'create_message.dart'; @@ -233,6 +234,30 @@ class LocalState extends State { child: child, ); }, + reactionsBuilder: (context, message, isSentByMe) { + final reactions = reactionsFromMessageReactions( + reactions: message.reactions, + currentUserId: _currentUser.id, + ); + return FlyerChatReactionsRow( + reactions: reactions, + alignment: isSentByMe + ? MainAxisAlignment.start + : MainAxisAlignment.end, + + /// Open List on tap (WhatsApp Style) + // onReactionTap: (reaction) => + // showReactionsList(context: context, reactions: reactions), + /// Or react on tap (Slack Style) + onReactionTap: (reaction) => + _handleReactionTap(message, reaction), + removeOrAddLocallyOnTap: true, + onReactionLongPress: (reaction) => + showReactionsList(context: context, reactions: reactions), + onSurplusReactionTap: () => + showReactionsList(context: context, reactions: reactions), + ); + }, ), chatController: _chatController, currentUserId: _currentUser.id, @@ -274,29 +299,83 @@ class LocalState extends State { LongPressStartDetails? details, required bool isSentByMe, }) async { - // Skip showing menu for system messages - if (message.authorId == 'system' || details == null) return; + showReactionsDialog( + context, + message, + isSentByMe: isSentByMe, + // reactions: ['📌'], // The default reactions to propose in the dialog + userReactions: getUserReactions(message.reactions, _currentUser.id), + onReactionTap: (reaction) => _handleReactionTap(message, reaction), + onMoreReactionsTap: () async { + // Use whichever emoji picker you want + final picked = await _showEmojiPicker(); + if (picked != null) { + _handleReactionTap(message, picked); + } + }, + menuItems: _getMenuItems(message), + ); + } + + Future _showEmojiPicker() { + return showModalBottomSheet( + context: context, + useSafeArea: true, + builder: (context) => EmojiPicker( + onEmojiSelected: (Category? category, Emoji emoji) { + Navigator.of(context).pop(emoji.emoji); + }, + config: Config( + height: 250, + checkPlatformCompatibility: false, + viewOrderConfig: const ViewOrderConfig(), + skinToneConfig: const SkinToneConfig(), + categoryViewConfig: const CategoryViewConfig(), + bottomActionBarConfig: const BottomActionBarConfig(enabled: false), + searchViewConfig: const SearchViewConfig(), + ), + ), + ); + } - // Calculate position for the menu - final position = details.globalPosition; + void _handleReactionTap(Message message, String reaction) { + debugPrint('reaction Tapped: $reaction'); + // Maybe the lib could expose if it's a removal or at least helpers methods + final reactions = Map>.from(message.reactions ?? {}); + final userId = _currentUser.id; - // Create a Rect for the menu position (small area around tap point) - final menuRect = Rect.fromCenter( - center: position, - width: 0, // Width and height of 0 means show exactly at the point - height: 0, + final users = List.from(reactions[reaction] ?? []); + if (users.contains(userId)) { + users.remove(userId); + if (users.isEmpty) { + reactions.remove(reaction); // Remove the key if no users left + } else { + reactions[reaction] = users; + } + } else { + users.add(userId); + reactions[reaction] = users; + } + + _chatController.updateMessage( + message, + message.copyWith(reactions: reactions), ); + } + + List _getMenuItems(Message message) { + if (message.authorId == 'system') return []; final items = [ if (message is TextMessage) - PullDownMenuItem( + MenuItem( title: 'Copy', icon: CupertinoIcons.doc_on_doc, onTap: () { _copyMessage(message); }, ), - PullDownMenuItem( + MenuItem( title: 'Delete', icon: CupertinoIcons.delete, isDestructive: true, @@ -305,8 +384,7 @@ class LocalState extends State { }, ), ]; - - await showPullDownMenu(context: context, position: menuRect, items: items); + return items; } void _copyMessage(TextMessage message) async { diff --git a/examples/flyer_chat/pubspec.yaml b/examples/flyer_chat/pubspec.yaml index 0ac6e3ca9..3b27f4a65 100644 --- a/examples/flyer_chat/pubspec.yaml +++ b/examples/flyer_chat/pubspec.yaml @@ -2,7 +2,7 @@ name: flyer_chat description: "A new Flutter project." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev +publish_to: "none" # Remove this line if you wish to publish to pub.dev # The following defines the version and build number for your application. # A version number is three numbers separated by dots, like 1.2.43 @@ -33,6 +33,7 @@ dependencies: cross_cache: ^1.0.4 cupertino_icons: ^1.0.8 dio: ^5.8.0+1 + emoji_picker_flutter: ^4.3.0 file_picker: ^10.2.0 flutter: sdk: flutter @@ -43,6 +44,7 @@ dependencies: flutter_lorem: ^2.0.0 flyer_chat_file_message: ^2.3.1 flyer_chat_image_message: ^2.2.1 + flyer_chat_reactions: ^0.0.12 flyer_chat_system_message: ^2.1.13 flyer_chat_text_message: ^2.5.1 flyer_chat_text_stream_message: ^2.2.6 @@ -77,7 +79,6 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. From a45415fbeeb5e54cd13cbfa6e089b1398d857e6e Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 18 Jul 2025 14:40:03 +0200 Subject: [PATCH 18/36] List: fix total count + filter out reactions at 0 --- .../lib/src/widgets/reactions_list.dart | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reactions_list.dart b/packages/flyer_chat_reactions/lib/src/widgets/reactions_list.dart index b997c57a7..94086c954 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reactions_list.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reactions_list.dart @@ -105,11 +105,17 @@ class _ReactionsListState extends State { t.typography.bodyMedium, ), ); + final validReactions = widget.reactions.where((r) => r.count > 0).toList(); final filteredReactions = selectedEmoji == null - ? widget.reactions - : widget.reactions.where((r) => r.emoji == selectedEmoji).toList(); + ? validReactions + : validReactions.where((r) => r.emoji == selectedEmoji).toList(); + + final totalReactionCount = widget.reactions.fold( + 0, + (sum, r) => sum + r.count, + ); return Container( clipBehavior: Clip.hardEdge, @@ -131,7 +137,7 @@ class _ReactionsListState extends State { children: [ _buildChip( label: Text( - '${widget.styleConfig.allFilterChipLabel} • ${widget.reactions.length}', + '${widget.styleConfig.allFilterChipLabel} • $totalReactionCount', ), theme: theme, selected: selectedEmoji == null, @@ -142,7 +148,7 @@ class _ReactionsListState extends State { }, ), const SizedBox(width: 8), - ...widget.reactions.map( + ...validReactions.map( (reaction) => Padding( padding: const EdgeInsets.only(right: 8), child: _buildChip( From 0f60fdfc9f405765a64d56f80b69be46a21ec4f3 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 18 Jul 2025 14:59:55 +0200 Subject: [PATCH 19/36] Fix Tile count not updating when Reaction row is updated (maybe we should pass a randomValueKey when instancing instead?) --- .../lib/src/widgets/reaction_tile.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart b/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart index 7d505e67d..0e0e18e4e 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart @@ -99,6 +99,16 @@ class _ReactionTileState extends State { _isTapped = widget.reactedByUser; } + @override + void didUpdateWidget(ReactionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.count != widget.count) { + setState(() { + _count = widget.count; + }); + } + } + @override Widget build(BuildContext context) { // Used to shrink on state update From 929f0148d49ca55ec0298a802bded0be79dbae70 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 18 Jul 2025 15:15:26 +0200 Subject: [PATCH 20/36] Filter count 0 reactions from FlyerChatReactionsRow --- .../src/widgets/flyer_chat_reactions_row.dart | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/flyer_chat_reactions/lib/src/widgets/flyer_chat_reactions_row.dart b/packages/flyer_chat_reactions/lib/src/widgets/flyer_chat_reactions_row.dart index 95f92453a..25ff1eb76 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/flyer_chat_reactions_row.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/flyer_chat_reactions_row.dart @@ -132,7 +132,8 @@ class _FlyerChatReactionsRowState extends State { @override Widget build(BuildContext context) { - if (widget.reactions.isEmpty) { + final validReactions = widget.reactions.where((r) => r.count > 0).toList(); + if (validReactions.isEmpty) { return const SizedBox.shrink(); } final theme = context.read(); @@ -165,14 +166,14 @@ class _FlyerChatReactionsRowState extends State { final stackWidth = constraints.maxWidth; var maxCapacity = calculateSizesAndMaxCapacity( - reactions: widget.reactions, + reactions: validReactions, stackWidth: stackWidth, emojiTextStyle: emojiTextStyle, countTextStyle: countTextStyle, extraTextStyle: extraTextStyle, ); var visibleItemsCount = reactionsSizes.length; - var hiddenCount = widget.reactions.length - maxCapacity; + var hiddenCount = validReactions.length - maxCapacity; final souldDisplaySurplus = hiddenCount > 0; Size? surplusWidgetSize; @@ -184,7 +185,7 @@ class _FlyerChatReactionsRowState extends State { extraText: '+$hiddenCount', ); maxCapacity = calculateSizesAndMaxCapacity( - reactions: widget.reactions, + reactions: validReactions, stackWidth: stackWidth - surplusWidgetSize.width - widget.spacing, emojiTextStyle: emojiTextStyle, countTextStyle: countTextStyle, @@ -192,7 +193,7 @@ class _FlyerChatReactionsRowState extends State { ); visibleItemsCount = reactionsSizes.length; - hiddenCount = widget.reactions.length - visibleItemsCount; + hiddenCount = validReactions.length - visibleItemsCount; } final children = []; @@ -200,21 +201,21 @@ class _FlyerChatReactionsRowState extends State { for (var i = 0; i < visibleItemsCount; i++) { children.add( ReactionTile( - key: ValueKey(widget.reactions[i].emoji), + key: ValueKey(validReactions[i].emoji), width: reactionsSizes[i].width, - emoji: widget.reactions[i].emoji, - count: widget.reactions[i].count, + emoji: validReactions[i].emoji, + count: validReactions[i].count, countTextStyle: countTextStyle, emojiTextStyle: emojiTextStyle, borderColor: theme.reactionBorderColor, backgroundColor: backgroundColor, reactedBackgroundColor: reactedBackgroundColor, - reactedByUser: widget.reactions[i].isReactedByUser, + reactedByUser: validReactions[i].isReactedByUser, onTap: () { - widget.onReactionTap?.call(widget.reactions[i].emoji); + widget.onReactionTap?.call(validReactions[i].emoji); }, onLongPress: () { - widget.onReactionLongPress?.call(widget.reactions[i].emoji); + widget.onReactionLongPress?.call(validReactions[i].emoji); }, removeOrAddLocallyOnTap: widget.removeOrAddLocallyOnTap, ), From 85e9956def30aced52fea7e419669ec3ad4d0423 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 18 Jul 2025 15:24:32 +0200 Subject: [PATCH 21/36] Also reset Tile tapped state on widget Update --- .../flyer_chat_reactions/lib/src/widgets/reaction_tile.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart b/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart index 0e0e18e4e..ec5324524 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart @@ -102,9 +102,11 @@ class _ReactionTileState extends State { @override void didUpdateWidget(ReactionTile oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.count != widget.count) { + if (oldWidget.count != widget.count || + oldWidget.reactedByUser != widget.reactedByUser) { setState(() { _count = widget.count; + _isTapped = widget.reactedByUser; }); } } From f7af945699a317e46179de7756027e732101bafe Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Fri, 18 Jul 2025 15:28:34 +0200 Subject: [PATCH 22/36] Lighter color for surfaceContainerHighest in dark theme --- packages/flutter_chat_core/lib/src/theme/chat_theme.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter_chat_core/lib/src/theme/chat_theme.dart b/packages/flutter_chat_core/lib/src/theme/chat_theme.dart index 196901065..fe9fcf232 100644 --- a/packages/flutter_chat_core/lib/src/theme/chat_theme.dart +++ b/packages/flutter_chat_core/lib/src/theme/chat_theme.dart @@ -113,7 +113,7 @@ abstract class ChatColors with _$ChatColors { surfaceContainerLow: Color(0xff121212), surfaceContainer: Color(0xff1c1c1c), surfaceContainerHigh: Color(0xff242424), - surfaceContainerHighest: Color(0xff2c2c2c), + surfaceContainerHighest: Color(0xff444444), ); /// Creates [ChatColors] from a Material [ThemeData]. From 2ee1b185ae29a7a5d29bd53ab6955952a0f99416 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Wed, 23 Jul 2025 10:29:25 +0200 Subject: [PATCH 23/36] Lighter colors for elevated elements --- .../lib/src/widgets/reactions_dialog.dart | 9 +++++---- .../lib/src/widgets/reactions_list.dart | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart index 5bfc4740f..64cc7418c 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart @@ -14,7 +14,7 @@ import '../utils/typedef.dart'; typedef _LocalTheme = ({ Color onSurface, - Color surface, + Color surfaceContainer, Color primary, BorderRadiusGeometry shape, }); @@ -109,7 +109,7 @@ class _ReactionsDialogWidgetState extends State { final theme = context.select( (ChatTheme t) => ( onSurface: t.colors.onSurface, - surface: t.colors.surface, + surfaceContainer: t.colors.surfaceContainerHigh, primary: t.colors.primary, shape: t.shape, ), @@ -146,7 +146,7 @@ class _ReactionsDialogWidgetState extends State { MediaQuery.of(context).size.width * (widget.menuItemsWidthRatio ?? 0.45), decoration: BoxDecoration( - color: widget.menuItemBackgroundColor ?? theme.surface, + color: widget.menuItemBackgroundColor ?? theme.surfaceContainer, borderRadius: theme.shape, ), child: Column( @@ -248,7 +248,8 @@ class _ReactionsDialogWidgetState extends State { child: Container( padding: const EdgeInsets.all(5), decoration: BoxDecoration( - color: widget.reactionsPickerBackgroundColor ?? theme.surface, + color: + widget.reactionsPickerBackgroundColor ?? theme.surfaceContainer, borderRadius: theme.shape, ), child: SingleChildScrollView( diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reactions_list.dart b/packages/flyer_chat_reactions/lib/src/widgets/reactions_list.dart index 94086c954..7ce609593 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reactions_list.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reactions_list.dart @@ -87,7 +87,8 @@ class _ReactionsListState extends State { Widget build(BuildContext context) { final theme = context.select( (ChatTheme t) => ( - backgroundColor: widget.styleConfig.backgroundColor ?? t.colors.surface, + backgroundColor: + widget.styleConfig.backgroundColor ?? t.colors.surfaceContainerHigh, selectedFilterChipColor: widget.styleConfig.filterChipsSelectedColor ?? t.colors.onPrimary.withValues(alpha: 0.2), From 78ceecc4df276d8fda7ce469af520b0d5d489a51 Mon Sep 17 00:00:00 2001 From: Alex Demchenko Date: Sat, 26 Jul 2025 16:59:38 +0200 Subject: [PATCH 24/36] get dependencies --- .../linux/flutter/generated_plugin_registrant.cc | 4 ++++ .../linux/flutter/generated_plugins.cmake | 1 + .../macos/Flutter/GeneratedPluginRegistrant.swift | 4 ++++ examples/flyer_chat/macos/Podfile.lock | 13 +++++++++++++ .../windows/flutter/generated_plugin_registrant.cc | 3 +++ .../windows/flutter/generated_plugins.cmake | 1 + .../lib/src/chat_message/chat_message_internal.dart | 1 - 7 files changed, 26 insertions(+), 1 deletion(-) diff --git a/examples/flyer_chat/linux/flutter/generated_plugin_registrant.cc b/examples/flyer_chat/linux/flutter/generated_plugin_registrant.cc index 31124ea32..9ee1a82e9 100644 --- a/examples/flyer_chat/linux/flutter/generated_plugin_registrant.cc +++ b/examples/flyer_chat/linux/flutter/generated_plugin_registrant.cc @@ -6,11 +6,15 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) emoji_picker_flutter_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "EmojiPickerFlutterPlugin"); + emoji_picker_flutter_plugin_register_with_registrar(emoji_picker_flutter_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); diff --git a/examples/flyer_chat/linux/flutter/generated_plugins.cmake b/examples/flyer_chat/linux/flutter/generated_plugins.cmake index 00d762d49..fcacb86fe 100644 --- a/examples/flyer_chat/linux/flutter/generated_plugins.cmake +++ b/examples/flyer_chat/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + emoji_picker_flutter file_selector_linux isar_flutter_libs url_launcher_linux diff --git a/examples/flyer_chat/macos/Flutter/GeneratedPluginRegistrant.swift b/examples/flyer_chat/macos/Flutter/GeneratedPluginRegistrant.swift index d48ecf7cf..d3828c3e9 100644 --- a/examples/flyer_chat/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/examples/flyer_chat/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,16 +5,20 @@ import FlutterMacOS import Foundation +import emoji_picker_flutter import file_picker import file_selector_macos import isar_flutter_libs import path_provider_foundation +import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + EmojiPickerFlutterPlugin.register(with: registry.registrar(forPlugin: "EmojiPickerFlutterPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/examples/flyer_chat/macos/Podfile.lock b/examples/flyer_chat/macos/Podfile.lock index 9da77816f..7f4faab38 100644 --- a/examples/flyer_chat/macos/Podfile.lock +++ b/examples/flyer_chat/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - emoji_picker_flutter (0.0.1): + - FlutterMacOS - file_picker (0.0.1): - FlutterMacOS - file_selector_macos (0.0.1): @@ -9,18 +11,25 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS - url_launcher_macos (0.0.1): - FlutterMacOS DEPENDENCIES: + - emoji_picker_flutter (from `Flutter/ephemeral/.symlinks/plugins/emoji_picker_flutter/macos`) - file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - isar_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/isar_flutter_libs/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) EXTERNAL SOURCES: + emoji_picker_flutter: + :path: Flutter/ephemeral/.symlinks/plugins/emoji_picker_flutter/macos file_picker: :path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos file_selector_macos: @@ -31,15 +40,19 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/isar_flutter_libs/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: + emoji_picker_flutter: 51ca408e289d84d1e460016b2a28721ec754fcf7 file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 isar_flutter_libs: a65381780401f81ad6bf3f2e7cd0de5698fb98c4 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 url_launcher_macos: 0fba8ddabfc33ce0a9afe7c5fef5aab3d8d2d673 PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82 diff --git a/examples/flyer_chat/windows/flutter/generated_plugin_registrant.cc b/examples/flyer_chat/windows/flutter/generated_plugin_registrant.cc index f380d6e46..5df4cee79 100644 --- a/examples/flyer_chat/windows/flutter/generated_plugin_registrant.cc +++ b/examples/flyer_chat/windows/flutter/generated_plugin_registrant.cc @@ -6,11 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + EmojiPickerFlutterPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("EmojiPickerFlutterPluginCApi")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); IsarFlutterLibsPluginRegisterWithRegistrar( diff --git a/examples/flyer_chat/windows/flutter/generated_plugins.cmake b/examples/flyer_chat/windows/flutter/generated_plugins.cmake index 383a7fda4..0f17407c9 100644 --- a/examples/flyer_chat/windows/flutter/generated_plugins.cmake +++ b/examples/flyer_chat/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + emoji_picker_flutter file_selector_windows isar_flutter_libs url_launcher_windows diff --git a/packages/flutter_chat_ui/lib/src/chat_message/chat_message_internal.dart b/packages/flutter_chat_ui/lib/src/chat_message/chat_message_internal.dart index 0809912a5..e55d6f94d 100644 --- a/packages/flutter_chat_ui/lib/src/chat_message/chat_message_internal.dart +++ b/packages/flutter_chat_ui/lib/src/chat_message/chat_message_internal.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:provider/provider.dart'; -import '../simple_text_message.dart'; import 'chat_message.dart'; import 'chat_message_build_helpers.dart'; From a1c90ffe30606c7e62b851d628ef6368ccbe377b Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 29 Jul 2025 23:07:52 +0200 Subject: [PATCH 25/36] Simplify widget alignment --- .../lib/src/widgets/reactions_dialog.dart | 343 +++++++++--------- 1 file changed, 163 insertions(+), 180 deletions(-) diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart index 64cc7418c..1ea3a077a 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart @@ -29,7 +29,7 @@ class ReactionsDialogWidget extends StatefulWidget { this.menuItems, this.reactions, this.userReactions, - this.widgetAlignment, + this.widgetAlignment = CrossAxisAlignment.end, this.menuItemsWidthRatio, this.menuItemBackgroundColor, this.menuItemDestructiveColor, @@ -64,9 +64,8 @@ class ReactionsDialogWidget extends StatefulWidget { /// This allow user to remove them from here final List? userReactions; - /// The alignment of the widget - /// Only left right is taken into account - final Alignment? widgetAlignment; + /// The horizontal alignment of the widget + final CrossAxisAlignment widgetAlignment; /// The width ratio of the menu items final double? menuItemsWidthRatio; @@ -120,12 +119,12 @@ class _ReactionsDialogWidgetState extends State { padding: const EdgeInsets.only(right: 20.0, left: 20.0), child: Column( mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.stretch, + crossAxisAlignment: widget.widgetAlignment, mainAxisAlignment: MainAxisAlignment.center, children: [ buildReactionsPicker(context, theme), const SizedBox(height: 10), - buildMessage(), + widget.messageWidget, const SizedBox(height: 10), buildMenuItems(context, theme), ], @@ -134,103 +133,93 @@ class _ReactionsDialogWidgetState extends State { ); } - Align buildMenuItems(BuildContext context, _LocalTheme theme) { + Widget buildMenuItems(BuildContext context, _LocalTheme theme) { final destructiveColor = widget.menuItemDestructiveColor ?? Colors.red; - return Align( - alignment: widget.widgetAlignment ?? Alignment.centerRight, - child: Material( - color: Colors.transparent, - child: Container( - /// TODO: maybe use pixels, for desktop? - width: - MediaQuery.of(context).size.width * - (widget.menuItemsWidthRatio ?? 0.45), - decoration: BoxDecoration( - color: widget.menuItemBackgroundColor ?? theme.surfaceContainer, - borderRadius: theme.shape, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - mainAxisSize: MainAxisSize.min, - children: [ - for (var item in widget.menuItems ?? const []) - Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: InkWell( - onTap: () { - setState(() { - clickedContextMenuIndex = widget.menuItems?.indexOf( - item, - ); - }); + return Material( + color: Colors.transparent, + child: Container( + /// TODO: maybe use pixels, for desktop? + width: + MediaQuery.of(context).size.width * + (widget.menuItemsWidthRatio ?? 0.45), + decoration: BoxDecoration( + color: widget.menuItemBackgroundColor ?? theme.surfaceContainer, + borderRadius: theme.shape, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + mainAxisSize: MainAxisSize.min, + children: [ + for (var item in widget.menuItems ?? const []) + Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: InkWell( + onTap: () { + setState(() { + clickedContextMenuIndex = widget.menuItems?.indexOf( + item, + ); + }); - Future.delayed( - widget.menuItemTapAnimationDuration ?? - const Duration(milliseconds: 200), - ).whenComplete(() { - if (context.mounted) { - Navigator.of(context).pop(); - } - item.onTap?.call(); - }); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - item.title, - style: TextStyle( - color: - item.isDestructive - ? destructiveColor - : theme.onSurface, - ), + Future.delayed( + widget.menuItemTapAnimationDuration ?? + const Duration(milliseconds: 200), + ).whenComplete(() { + if (context.mounted) { + Navigator.of(context).pop(); + } + item.onTap?.call(); + }); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + item.title, + style: TextStyle( + color: + item.isDestructive + ? destructiveColor + : theme.onSurface, ), - Pulse( - infinite: false, - duration: - widget.menuItemTapAnimationDuration ?? - const Duration(milliseconds: 200), - animate: - clickedContextMenuIndex == - widget.menuItems?.indexOf(item), - child: Icon( - item.icon, - color: - item.isDestructive - ? destructiveColor - : theme.onSurface, - ), + ), + Pulse( + infinite: false, + duration: + widget.menuItemTapAnimationDuration ?? + const Duration(milliseconds: 200), + animate: + clickedContextMenuIndex == + widget.menuItems?.indexOf(item), + child: Icon( + item.icon, + color: + item.isDestructive + ? destructiveColor + : theme.onSurface, ), - ], - ), + ), + ], ), ), - if (widget.menuItems?.last != item) - Divider( - color: widget.menuItemDividerColor ?? Colors.white, - thickness: 0.5, - height: 0.5, - ), - ], - ), - ], - ), + ), + if (widget.menuItems?.last != item) + Divider( + color: widget.menuItemDividerColor ?? Colors.white, + thickness: 0.5, + height: 0.5, + ), + ], + ), + ], ), ), ); } - Align buildMessage() { - return Align( - alignment: widget.widgetAlignment ?? Alignment.centerRight, - child: widget.messageWidget, - ); - } - - Align buildReactionsPicker(BuildContext context, _LocalTheme theme) { + Widget buildReactionsPicker(BuildContext context, _LocalTheme theme) { // Merge default reactions with user reactions, removing duplicates final allReactions = { @@ -241,101 +230,93 @@ class _ReactionsDialogWidgetState extends State { final reactionTapAnimationDuration = widget.reactionTapAnimationDuration ?? const Duration(milliseconds: 200); - return Align( - alignment: widget.widgetAlignment ?? Alignment.centerRight, - child: Material( - color: Colors.transparent, - child: Container( - padding: const EdgeInsets.all(5), - decoration: BoxDecoration( - color: - widget.reactionsPickerBackgroundColor ?? theme.surfaceContainer, - borderRadius: theme.shape, - ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - for (var i = 0; i < allReactions.length; i++) - FadeInLeft( - from: 0 + (i * 20).toDouble(), - duration: - widget.reactionPickerFadeLeftAnimationDuration ?? - const Duration(milliseconds: 200), - delay: Duration.zero, - child: InkWell( - child: Container( - margin: const EdgeInsets.only(right: 2), - padding: const EdgeInsets.fromLTRB(4.0, 2.0, 4.0, 2), - decoration: BoxDecoration( - color: - (widget.userReactions ?? const []).contains( - allReactions[i], - ) - ? widget.reactionsPickerReactedBackgroundColor ?? - theme.onSurface.withValues(alpha: 0.2) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: Pulse( - infinite: false, - duration: reactionTapAnimationDuration, - animate: reactionClicked && clickedReactionIndex == i, - child: Text( - allReactions[i], - style: TextStyle(fontSize: 22), - ), + return Material( + color: Colors.transparent, + child: Container( + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: + widget.reactionsPickerBackgroundColor ?? theme.surfaceContainer, + borderRadius: theme.shape, + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < allReactions.length; i++) + FadeInLeft( + from: 0 + (i * 20).toDouble(), + duration: + widget.reactionPickerFadeLeftAnimationDuration ?? + const Duration(milliseconds: 200), + delay: Duration.zero, + child: InkWell( + child: Container( + margin: const EdgeInsets.only(right: 2), + padding: const EdgeInsets.fromLTRB(4.0, 2.0, 4.0, 2), + decoration: BoxDecoration( + color: + (widget.userReactions ?? const []).contains( + allReactions[i], + ) + ? widget.reactionsPickerReactedBackgroundColor ?? + theme.onSurface.withValues(alpha: 0.2) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Pulse( + infinite: false, + duration: reactionTapAnimationDuration, + animate: reactionClicked && clickedReactionIndex == i, + child: Text( + allReactions[i], + style: TextStyle(fontSize: 22), ), ), - onTap: () { - setState(() { - reactionClicked = true; - clickedReactionIndex = i; - }); - Future.delayed( - reactionTapAnimationDuration, - ).whenComplete(() { + ), + onTap: () { + setState(() { + reactionClicked = true; + clickedReactionIndex = i; + }); + Future.delayed(reactionTapAnimationDuration).whenComplete( + () { if (context.mounted) { Navigator.of(context).pop(); } widget.onReactionTap(allReactions[i]); - }); - }, - ), + }, + ); + }, ), - if (widget.onMoreReactionsTap != null) - FadeInLeft( - from: 0 + (allReactions.length * 20).toDouble(), - duration: - widget.reactionPickerFadeLeftAnimationDuration ?? - const Duration(milliseconds: 200), - delay: Duration.zero, - child: InkWell( - onTap: () { - if (context.mounted) { - Navigator.of(context).pop(); - } - widget.onMoreReactionsTap?.call(); - }, - child: - widget.moreReactionsWidget ?? - Padding( - padding: const EdgeInsets.fromLTRB( - 4.0, - 2.0, - 4.0, - 2, - ), - child: Icon( - Icons.more_horiz_rounded, - color: theme.onSurface, - ), + ), + if (widget.onMoreReactionsTap != null) + FadeInLeft( + from: 0 + (allReactions.length * 20).toDouble(), + duration: + widget.reactionPickerFadeLeftAnimationDuration ?? + const Duration(milliseconds: 200), + delay: Duration.zero, + child: InkWell( + onTap: () { + if (context.mounted) { + Navigator.of(context).pop(); + } + widget.onMoreReactionsTap?.call(); + }, + child: + widget.moreReactionsWidget ?? + Padding( + padding: const EdgeInsets.fromLTRB(4.0, 2.0, 4.0, 2), + child: Icon( + Icons.more_horiz_rounded, + color: theme.onSurface, ), - ), + ), ), - ], - ), + ), + ], ), ), ), @@ -355,7 +336,7 @@ void showReactionsDialog( List? menuItems, List? reactions, List? userReactions, - Alignment? widgetAlignment, + CrossAxisAlignment? widgetAlignment, double? menuItemsWidthRatio, Color? menuItemBackgroundColor, Color? menuItemDestructiveColor, @@ -388,7 +369,9 @@ void showReactionsDialog( messageWidget: widget, widgetAlignment: widgetAlignment ?? - (isSentByMe ? Alignment.centerRight : Alignment.centerLeft), + (isSentByMe + ? CrossAxisAlignment.end + : CrossAxisAlignment.start), onReactionTap: (reaction) { onReactionTap(reaction); }, From 9627ac2af737effc7d55e060c65b0511ed6bcf87 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 29 Jul 2025 23:11:47 +0200 Subject: [PATCH 26/36] Use pull_down_button --- examples/flyer_chat/lib/local.dart | 9 +- .../lib/flyer_chat_reactions.dart | 1 - .../lib/src/models/menu_item.dart | 16 --- .../lib/src/widgets/reactions_dialog.dart | 129 ++---------------- packages/flyer_chat_reactions/pubspec.yaml | 1 + 5 files changed, 15 insertions(+), 141 deletions(-) delete mode 100644 packages/flyer_chat_reactions/lib/src/models/menu_item.dart diff --git a/examples/flyer_chat/lib/local.dart b/examples/flyer_chat/lib/local.dart index 5268ca304..f8f2b0a67 100644 --- a/examples/flyer_chat/lib/local.dart +++ b/examples/flyer_chat/lib/local.dart @@ -15,6 +15,7 @@ import 'package:flyer_chat_reactions/flyer_chat_reactions.dart'; import 'package:flyer_chat_system_message/flyer_chat_system_message.dart'; import 'package:flyer_chat_text_message/flyer_chat_text_message.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:pull_down_button/pull_down_button.dart'; import 'package:uuid/uuid.dart'; import 'create_message.dart'; @@ -363,24 +364,26 @@ class LocalState extends State { ); } - List _getMenuItems(Message message) { + List _getMenuItems(Message message) { if (message.authorId == 'system') return []; final items = [ if (message is TextMessage) - MenuItem( + PullDownMenuItem( title: 'Copy', icon: CupertinoIcons.doc_on_doc, onTap: () { _copyMessage(message); + Navigator.of(context).pop(); }, ), - MenuItem( + PullDownMenuItem( title: 'Delete', icon: CupertinoIcons.delete, isDestructive: true, onTap: () { _removeItem(message); + Navigator.of(context).pop(); }, ), ]; diff --git a/packages/flyer_chat_reactions/lib/flyer_chat_reactions.dart b/packages/flyer_chat_reactions/lib/flyer_chat_reactions.dart index 355d42a90..b72bad667 100644 --- a/packages/flyer_chat_reactions/lib/flyer_chat_reactions.dart +++ b/packages/flyer_chat_reactions/lib/flyer_chat_reactions.dart @@ -1,4 +1,3 @@ -export 'src/models/menu_item.dart'; export 'src/models/reaction.dart'; export 'src/widgets/flyer_chat_reactions_row.dart'; export 'src/widgets/reaction_tile.dart'; diff --git a/packages/flyer_chat_reactions/lib/src/models/menu_item.dart b/packages/flyer_chat_reactions/lib/src/models/menu_item.dart deleted file mode 100644 index 4edcebb6d..000000000 --- a/packages/flyer_chat_reactions/lib/src/models/menu_item.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:flutter/widgets.dart'; - -class MenuItem { - final String title; - final IconData icon; - final bool isDestructive; - final Function()? onTap; - - // constructor - const MenuItem({ - required this.title, - required this.icon, - this.isDestructive = false, - this.onTap, - }); -} diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart index 1ea3a077a..9c7541d80 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart @@ -5,9 +5,10 @@ import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:flutter_chat_ui/flutter_chat_ui.dart' show ChatProviders, buildMessageContent; import 'package:provider/provider.dart'; +import 'package:pull_down_button/pull_down_button.dart' + show PullDownMenuEntry, PullDownMenu; import '../models/default_data.dart'; -import '../models/menu_item.dart'; import '../utils/typedef.dart'; //// Theme values for [ReactionsDialogWidget]. @@ -30,13 +31,8 @@ class ReactionsDialogWidget extends StatefulWidget { this.reactions, this.userReactions, this.widgetAlignment = CrossAxisAlignment.end, - this.menuItemsWidthRatio, - this.menuItemBackgroundColor, - this.menuItemDestructiveColor, - this.menuItemDividerColor, this.reactionsPickerBackgroundColor, this.reactionsPickerReactedBackgroundColor, - this.menuItemTapAnimationDuration, this.reactionTapAnimationDuration, this.reactionPickerFadeLeftAnimationDuration, }); @@ -55,7 +51,7 @@ class ReactionsDialogWidget extends StatefulWidget { final VoidCallback? onMoreReactionsTap; /// The list of menu items to be displayed in the context menu - final List? menuItems; + final List? menuItems; /// The list of default reactions to be displayed final List? reactions; @@ -67,21 +63,6 @@ class ReactionsDialogWidget extends StatefulWidget { /// The horizontal alignment of the widget final CrossAxisAlignment widgetAlignment; - /// The width ratio of the menu items - final double? menuItemsWidthRatio; - - /// Animation duration when a menu item is selected - final Duration? menuItemTapAnimationDuration; - - /// The background color for menu items - final Color? menuItemBackgroundColor; - - /// Destructive color for menu items - final Color? menuItemDestructiveColor; - - /// The divider color for menu items - final Color? menuItemDividerColor; - /// The background color for reactions picker final Color? reactionsPickerBackgroundColor; @@ -125,94 +106,10 @@ class _ReactionsDialogWidgetState extends State { buildReactionsPicker(context, theme), const SizedBox(height: 10), widget.messageWidget, - const SizedBox(height: 10), - buildMenuItems(context, theme), - ], - ), - ), - ); - } - - Widget buildMenuItems(BuildContext context, _LocalTheme theme) { - final destructiveColor = widget.menuItemDestructiveColor ?? Colors.red; - return Material( - color: Colors.transparent, - child: Container( - /// TODO: maybe use pixels, for desktop? - width: - MediaQuery.of(context).size.width * - (widget.menuItemsWidthRatio ?? 0.45), - decoration: BoxDecoration( - color: widget.menuItemBackgroundColor ?? theme.surfaceContainer, - borderRadius: theme.shape, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - mainAxisSize: MainAxisSize.min, - children: [ - for (var item in widget.menuItems ?? const []) - Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: InkWell( - onTap: () { - setState(() { - clickedContextMenuIndex = widget.menuItems?.indexOf( - item, - ); - }); - - Future.delayed( - widget.menuItemTapAnimationDuration ?? - const Duration(milliseconds: 200), - ).whenComplete(() { - if (context.mounted) { - Navigator.of(context).pop(); - } - item.onTap?.call(); - }); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - item.title, - style: TextStyle( - color: - item.isDestructive - ? destructiveColor - : theme.onSurface, - ), - ), - Pulse( - infinite: false, - duration: - widget.menuItemTapAnimationDuration ?? - const Duration(milliseconds: 200), - animate: - clickedContextMenuIndex == - widget.menuItems?.indexOf(item), - child: Icon( - item.icon, - color: - item.isDestructive - ? destructiveColor - : theme.onSurface, - ), - ), - ], - ), - ), - ), - if (widget.menuItems?.last != item) - Divider( - color: widget.menuItemDividerColor ?? Colors.white, - thickness: 0.5, - height: 0.5, - ), - ], - ), + if (widget.menuItems != null && widget.menuItems!.isNotEmpty) ...[ + const SizedBox(height: 10), + PullDownMenu(items: widget.menuItems!), + ], ], ), ), @@ -333,17 +230,12 @@ void showReactionsDialog( required bool isSentByMe, required Function(String) onReactionTap, VoidCallback? onMoreReactionsTap, - List? menuItems, + List? menuItems, List? reactions, List? userReactions, CrossAxisAlignment? widgetAlignment, - double? menuItemsWidthRatio, - Color? menuItemBackgroundColor, - Color? menuItemDestructiveColor, - Color? menuItemDividerColor, Color? reactionsPickerBackgroundColor, Color? reactionsPickerReactedBackgroundColor, - Duration? menuItemTapAnimationDuration, Duration? reactionTapAnimationDuration, Duration? reactionPickerFadeLeftAnimationDuration, Widget? moreReactionsWidget, @@ -379,14 +271,9 @@ void showReactionsDialog( menuItems: menuItems, reactions: reactions, userReactions: userReactions, - menuItemsWidthRatio: menuItemsWidthRatio, - menuItemBackgroundColor: menuItemBackgroundColor, - menuItemDestructiveColor: menuItemDestructiveColor, - menuItemDividerColor: menuItemDividerColor, reactionsPickerBackgroundColor: reactionsPickerBackgroundColor, reactionsPickerReactedBackgroundColor: reactionsPickerReactedBackgroundColor, - menuItemTapAnimationDuration: menuItemTapAnimationDuration, reactionTapAnimationDuration: reactionTapAnimationDuration, reactionPickerFadeLeftAnimationDuration: reactionPickerFadeLeftAnimationDuration, diff --git a/packages/flyer_chat_reactions/pubspec.yaml b/packages/flyer_chat_reactions/pubspec.yaml index 9b9b6319a..bd1344277 100644 --- a/packages/flyer_chat_reactions/pubspec.yaml +++ b/packages/flyer_chat_reactions/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: flutter_chat_core: ^2.7.0 flutter_chat_ui: ^2.7.0 provider: ^6.1.5 + pull_down_button: ^0.10.2 dev_dependencies: flutter_lints: ^6.0.0 From e70d0655d34ede91a291381ad3a42cd9a93a8930 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 29 Jul 2025 23:19:26 +0200 Subject: [PATCH 27/36] CursorBot comments fix --- .../lib/src/theme/chat_theme.dart | 2 +- .../lib/src/chat_message/chat_message.dart | 4 ++-- .../lib/src/widgets/flyer_chat_reactions_row.dart | 4 ++-- .../lib/src/widgets/reaction_tile.dart | 14 ++++++++++---- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/packages/flutter_chat_core/lib/src/theme/chat_theme.dart b/packages/flutter_chat_core/lib/src/theme/chat_theme.dart index fe9fcf232..ae4561d66 100644 --- a/packages/flutter_chat_core/lib/src/theme/chat_theme.dart +++ b/packages/flutter_chat_core/lib/src/theme/chat_theme.dart @@ -125,7 +125,7 @@ abstract class ChatColors with _$ChatColors { surfaceContainerLow: themeData.colorScheme.surfaceContainerLow, surfaceContainer: themeData.colorScheme.surfaceContainer, surfaceContainerHigh: themeData.colorScheme.surfaceContainerHigh, - surfaceContainerHighest: themeData.colorScheme.surfaceContainerHigh, + surfaceContainerHighest: themeData.colorScheme.surfaceContainerHighest, ); /// Merges this color scheme with another [ChatColors]. diff --git a/packages/flutter_chat_ui/lib/src/chat_message/chat_message.dart b/packages/flutter_chat_ui/lib/src/chat_message/chat_message.dart index 514c0e3c6..29a450cbd 100644 --- a/packages/flutter_chat_ui/lib/src/chat_message/chat_message.dart +++ b/packages/flutter_chat_ui/lib/src/chat_message/chat_message.dart @@ -167,12 +167,12 @@ class ChatMessage extends StatelessWidget { index: index, isSentByMe: isSentByMe, ), - onLongPress: () { + onLongPressStart: (details) { onMessageLongPress?.call( context, message, index: index, - details: LongPressStartDetails(), + details: details, isSentByMe: isSentByMe, ); return; diff --git a/packages/flyer_chat_reactions/lib/src/widgets/flyer_chat_reactions_row.dart b/packages/flyer_chat_reactions/lib/src/widgets/flyer_chat_reactions_row.dart index 25ff1eb76..8f2a793f9 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/flyer_chat_reactions_row.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/flyer_chat_reactions_row.dart @@ -108,7 +108,7 @@ class _FlyerChatReactionsRowState extends State { final widgetCount = reactions.length; for (var i = 0; i < widgetCount; i++) { - final nextSize = ReactionTileSizeHelper.calculatePrefferedSize( + final nextSize = ReactionTileSizeHelper.calculatePreferredSize( emojiStyle: emojiTextStyle, countTextStyle: countTextStyle, extraTextStyle: extraTextStyle, @@ -178,7 +178,7 @@ class _FlyerChatReactionsRowState extends State { Size? surplusWidgetSize; if (souldDisplaySurplus) { - surplusWidgetSize = ReactionTileSizeHelper.calculatePrefferedSize( + surplusWidgetSize = ReactionTileSizeHelper.calculatePreferredSize( emojiStyle: emojiTextStyle, countTextStyle: countTextStyle, extraTextStyle: extraTextStyle, diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart b/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart index ec5324524..dd91ceb02 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reaction_tile.dart @@ -160,9 +160,9 @@ class _ReactionTileState extends State { mainAxisSize: MainAxisSize.min, children: [ if (widget.emoji != null) - /// Emoji alignement issue https://github.com/flutter/flutter/issues/119623 + /// Emoji alignment issue https://github.com/flutter/flutter/issues/119623 Padding( - padding: EdgeInsets.fromLTRB(2.5, 0, 0, 1.5), + padding: ReactionTileConstants.emojiAlignmentPadding, child: Text( widget.emoji!, style: ReactionTileStyleResolver.resolveEmojiTextStyle( @@ -206,6 +206,12 @@ class ReactionTileConstants { static const double minimumWidth = 40; static const double horizontalPadding = 8; static const double height = 24; + static const EdgeInsets emojiAlignmentPadding = EdgeInsets.fromLTRB( + 2.5, + 0, + 0, + 1.5, + ); } class ReactionTileCountTextHelper { @@ -221,7 +227,7 @@ class ReactionTileSizeHelper { /// and the number of reactions that can fit in the available width. /// - static Size calculatePrefferedSize({ + static Size calculatePreferredSize({ required TextStyle emojiStyle, required TextStyle countTextStyle, required TextStyle extraTextStyle, @@ -239,7 +245,7 @@ class ReactionTileSizeHelper { if (hasEmoji) { width += emojiStyle.fontSize ?? 12; - width += 2.5; // See emoji alignement + width += 2.5; // See emoji alignment } if (hasText) { if (width > 0) { From 13d8a0372e3fe05dd80db4d2de26dd943a5b2570 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 29 Jul 2025 23:19:34 +0200 Subject: [PATCH 28/36] Fix comment --- packages/flyer_chat_reactions/lib/src/models/default_data.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/flyer_chat_reactions/lib/src/models/default_data.dart b/packages/flyer_chat_reactions/lib/src/models/default_data.dart index fa13ed644..0076d48ce 100644 --- a/packages/flyer_chat_reactions/lib/src/models/default_data.dart +++ b/packages/flyer_chat_reactions/lib/src/models/default_data.dart @@ -1,5 +1,4 @@ class DefaultData { - // default list of five reactions to be displayed from emojis and a plus icon at the end - // the plus icon will be used to add more reactions + // / Default list of reactions to be displayed from emojis static const List reactions = ['👍', '❤️', '😂', '😮', '😢', '😠']; } From 8b0c2e7c324d553006a850dea086d1ce8568044a Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Wed, 30 Jul 2025 09:10:59 +0200 Subject: [PATCH 29/36] Add HoverFloatEffect --- .../lib/src/utils/hover_float_effect.dart | 79 +++++++++++++++++++ .../lib/src/widgets/reactions_dialog.dart | 13 ++- 2 files changed, 90 insertions(+), 2 deletions(-) create mode 100644 packages/flyer_chat_reactions/lib/src/utils/hover_float_effect.dart diff --git a/packages/flyer_chat_reactions/lib/src/utils/hover_float_effect.dart b/packages/flyer_chat_reactions/lib/src/utils/hover_float_effect.dart new file mode 100644 index 000000000..bfb89d544 --- /dev/null +++ b/packages/flyer_chat_reactions/lib/src/utils/hover_float_effect.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; + +/// A widget that applies a floating hover effect to its child. +/// +/// When the mouse hovers over this widget, it creates a subtle 3D-like effect +/// by translating and scaling the child based on mouse position. +/// +/// This effect is only applied on desktop platforms (Windows, macOS, Linux) and web. +/// On mobile platforms, the child widget is returned without any hover effects. +class HoverFloatEffect extends StatefulWidget { + /// The widget to apply the hover effect to. + final Widget child; + + /// The scale factor applied to the child when hovering. + /// A value of 1.0 means no scaling, 1.05 means 5% larger. + /// Defaults to 1.05 for a subtle zoom effect. + final double zoomScale; + + /// The translation distance in pixels for the hover effect. + /// Controls how far the widget moves from its center position. + /// Defaults to 20 pixels for a subtle floating effect. + final double translationDistance; + + const HoverFloatEffect({ + super.key, + required this.child, + this.zoomScale = 1.05, + this.translationDistance = 20, + }); + + @override + State createState() => _HoverFloatEffectState(); +} + +class _HoverFloatEffectState extends State { + Offset _offset = Offset.zero; + + /// Returns true if the current platform supports mouse hover effects. + bool get _supportsHover => + kIsWeb || + defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.linux; + + @override + Widget build(BuildContext context) { + // On mobile platforms, just return the child without hover effects + if (!_supportsHover) { + return widget.child; + } + + final size = MediaQuery.of(context).size; + return MouseRegion( + onHover: (event) { + // Calculate normalized position (-0.5 to 0.5) and apply translation distance + final dx = + (event.position.dx / size.width - 0.5) * widget.translationDistance; + final dy = + (event.position.dy / size.height - 0.5) * + widget.translationDistance; + setState(() => _offset = Offset(dx, dy)); + }, + onExit: (_) => setState(() => _offset = Offset.zero), + child: TweenAnimationBuilder( + tween: Tween(begin: Offset.zero, end: _offset), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + builder: (context, offset, child) { + return Transform.translate( + offset: offset, + child: Transform.scale(scale: widget.zoomScale, child: child), + ); + }, + child: widget.child, + ), + ); + } +} diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart index 9c7541d80..6e6f3cf85 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart @@ -9,6 +9,7 @@ import 'package:pull_down_button/pull_down_button.dart' show PullDownMenuEntry, PullDownMenu; import '../models/default_data.dart'; +import '../utils/hover_float_effect.dart'; import '../utils/typedef.dart'; //// Theme values for [ReactionsDialogWidget]. @@ -35,6 +36,7 @@ class ReactionsDialogWidget extends StatefulWidget { this.reactionsPickerReactedBackgroundColor, this.reactionTapAnimationDuration, this.reactionPickerFadeLeftAnimationDuration, + this.activateHoverFloatEffect = true, }); /// The message widget to be displayed in the dialog @@ -75,6 +77,9 @@ class ReactionsDialogWidget extends StatefulWidget { /// Animation duration to display the reactions row final Duration? reactionPickerFadeLeftAnimationDuration; + /// Whether to activate the hover float effect + final bool activateHoverFloatEffect; + @override State createState() => _ReactionsDialogWidgetState(); } @@ -97,7 +102,7 @@ class _ReactionsDialogWidgetState extends State { return BackdropFilter( filter: ImageFilter.blur(sigmaX: 5, sigmaY: 5), child: Padding( - padding: const EdgeInsets.only(right: 20.0, left: 20.0), + padding: const EdgeInsets.only(right: 32.0, left: 32.0), child: Column( mainAxisSize: MainAxisSize.max, crossAxisAlignment: widget.widgetAlignment, @@ -105,7 +110,9 @@ class _ReactionsDialogWidgetState extends State { children: [ buildReactionsPicker(context, theme), const SizedBox(height: 10), - widget.messageWidget, + widget.activateHoverFloatEffect + ? HoverFloatEffect(child: widget.messageWidget) + : widget.messageWidget, if (widget.menuItems != null && widget.menuItems!.isNotEmpty) ...[ const SizedBox(height: 10), PullDownMenu(items: widget.menuItems!), @@ -239,6 +246,7 @@ void showReactionsDialog( Duration? reactionTapAnimationDuration, Duration? reactionPickerFadeLeftAnimationDuration, Widget? moreReactionsWidget, + bool activateHoverFloatEffect = true, }) { final providers = ChatProviders.from(context); @@ -278,6 +286,7 @@ void showReactionsDialog( reactionPickerFadeLeftAnimationDuration: reactionPickerFadeLeftAnimationDuration, moreReactionsWidget: moreReactionsWidget, + activateHoverFloatEffect: activateHoverFloatEffect, ), ), ); From d6fea1fd0b8cef9fb3abbddf976dcf9baf22c016 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Wed, 30 Jul 2025 11:46:37 +0200 Subject: [PATCH 30/36] Rename alignment parameter --- .../lib/src/widgets/reactions_dialog.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart index 6e6f3cf85..f01e86bdb 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart @@ -31,7 +31,7 @@ class ReactionsDialogWidget extends StatefulWidget { this.menuItems, this.reactions, this.userReactions, - this.widgetAlignment = CrossAxisAlignment.end, + this.alignment = CrossAxisAlignment.end, this.reactionsPickerBackgroundColor, this.reactionsPickerReactedBackgroundColor, this.reactionTapAnimationDuration, @@ -63,7 +63,7 @@ class ReactionsDialogWidget extends StatefulWidget { final List? userReactions; /// The horizontal alignment of the widget - final CrossAxisAlignment widgetAlignment; + final CrossAxisAlignment alignment; /// The background color for reactions picker final Color? reactionsPickerBackgroundColor; @@ -105,7 +105,7 @@ class _ReactionsDialogWidgetState extends State { padding: const EdgeInsets.only(right: 32.0, left: 32.0), child: Column( mainAxisSize: MainAxisSize.max, - crossAxisAlignment: widget.widgetAlignment, + crossAxisAlignment: widget.alignment, mainAxisAlignment: MainAxisAlignment.center, children: [ buildReactionsPicker(context, theme), @@ -267,7 +267,7 @@ void showReactionsDialog( providers: providers, child: ReactionsDialogWidget( messageWidget: widget, - widgetAlignment: + alignment: widgetAlignment ?? (isSentByMe ? CrossAxisAlignment.end From ce0d231c7e7a9216c4fb7d667358979d804088cb Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Wed, 30 Jul 2025 11:48:44 +0200 Subject: [PATCH 31/36] Fix type for onReactionTap in showDialog --- .../lib/src/widgets/reactions_dialog.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart index f01e86bdb..ac65b4c8f 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart @@ -235,7 +235,7 @@ void showReactionsDialog( BuildContext context, Message message, { required bool isSentByMe, - required Function(String) onReactionTap, + required OnReactionTapCallback onReactionTap, VoidCallback? onMoreReactionsTap, List? menuItems, List? reactions, @@ -272,9 +272,7 @@ void showReactionsDialog( (isSentByMe ? CrossAxisAlignment.end : CrossAxisAlignment.start), - onReactionTap: (reaction) { - onReactionTap(reaction); - }, + onReactionTap: onReactionTap, onMoreReactionsTap: onMoreReactionsTap, menuItems: menuItems, reactions: reactions, From f5921d858ba1fa53adc31b1948b072c908f4a3f7 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Wed, 30 Jul 2025 12:37:43 +0200 Subject: [PATCH 32/36] Dialog ) Use a builder for the more reaction widget --- .../flyer_chat_reactions/lib/src/utils/typedef.dart | 4 ++++ .../lib/src/widgets/reactions_dialog.dart | 12 ++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/flyer_chat_reactions/lib/src/utils/typedef.dart b/packages/flyer_chat_reactions/lib/src/utils/typedef.dart index 468314b12..0054e51cd 100644 --- a/packages/flyer_chat_reactions/lib/src/utils/typedef.dart +++ b/packages/flyer_chat_reactions/lib/src/utils/typedef.dart @@ -1,3 +1,7 @@ +import 'package:flutter/material.dart'; + /// Callback signature for when a reaction is tapped. typedef OnReactionTapCallback = void Function(String reaction); typedef OnReactionLongPressCallback = void Function(String reaction); +typedef ReactionsDialogMoreReactionsWidgetBuilder = + Widget Function(BuildContext context); diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart index ac65b4c8f..edd21851a 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart @@ -26,7 +26,7 @@ class ReactionsDialogWidget extends StatefulWidget { super.key, required this.messageWidget, required this.onReactionTap, - this.moreReactionsWidget, + this.moreReactionsWidgetBuilder, this.onMoreReactionsTap, this.menuItems, this.reactions, @@ -45,8 +45,8 @@ class ReactionsDialogWidget extends StatefulWidget { /// The callback function to be called when a reaction is tapped final OnReactionTapCallback onReactionTap; - /// More Reactions Widget - final Widget? moreReactionsWidget; + /// More Reactions Widget builder + final ReactionsDialogMoreReactionsWidgetBuilder? moreReactionsWidgetBuilder; /// The callback function to be called when the "more" reactions widget is tapped /// If not provided the widget will not be displayed @@ -210,7 +210,7 @@ class _ReactionsDialogWidgetState extends State { widget.onMoreReactionsTap?.call(); }, child: - widget.moreReactionsWidget ?? + widget.moreReactionsWidgetBuilder?.call(context) ?? Padding( padding: const EdgeInsets.fromLTRB(4.0, 2.0, 4.0, 2), child: Icon( @@ -245,7 +245,7 @@ void showReactionsDialog( Color? reactionsPickerReactedBackgroundColor, Duration? reactionTapAnimationDuration, Duration? reactionPickerFadeLeftAnimationDuration, - Widget? moreReactionsWidget, + ReactionsDialogMoreReactionsWidgetBuilder? moreReactionsWidgetBuilder, bool activateHoverFloatEffect = true, }) { final providers = ChatProviders.from(context); @@ -283,7 +283,7 @@ void showReactionsDialog( reactionTapAnimationDuration: reactionTapAnimationDuration, reactionPickerFadeLeftAnimationDuration: reactionPickerFadeLeftAnimationDuration, - moreReactionsWidget: moreReactionsWidget, + moreReactionsWidgetBuilder: moreReactionsWidgetBuilder, activateHoverFloatEffect: activateHoverFloatEffect, ), ), From 65f282ae0cdef9e5468bfce9e609066adbf436b2 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Wed, 30 Jul 2025 12:43:53 +0200 Subject: [PATCH 33/36] Also rename alignment in the show method + specify it's horizontal in case we expose the vertical one someday --- .../lib/src/widgets/reactions_dialog.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart index edd21851a..26b4545d2 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart @@ -31,7 +31,7 @@ class ReactionsDialogWidget extends StatefulWidget { this.menuItems, this.reactions, this.userReactions, - this.alignment = CrossAxisAlignment.end, + this.horizontalAlignment = CrossAxisAlignment.end, this.reactionsPickerBackgroundColor, this.reactionsPickerReactedBackgroundColor, this.reactionTapAnimationDuration, @@ -63,7 +63,7 @@ class ReactionsDialogWidget extends StatefulWidget { final List? userReactions; /// The horizontal alignment of the widget - final CrossAxisAlignment alignment; + final CrossAxisAlignment horizontalAlignment; /// The background color for reactions picker final Color? reactionsPickerBackgroundColor; @@ -105,7 +105,7 @@ class _ReactionsDialogWidgetState extends State { padding: const EdgeInsets.only(right: 32.0, left: 32.0), child: Column( mainAxisSize: MainAxisSize.max, - crossAxisAlignment: widget.alignment, + crossAxisAlignment: widget.horizontalAlignment, mainAxisAlignment: MainAxisAlignment.center, children: [ buildReactionsPicker(context, theme), @@ -240,7 +240,7 @@ void showReactionsDialog( List? menuItems, List? reactions, List? userReactions, - CrossAxisAlignment? widgetAlignment, + CrossAxisAlignment? horizontalAlignment, Color? reactionsPickerBackgroundColor, Color? reactionsPickerReactedBackgroundColor, Duration? reactionTapAnimationDuration, @@ -267,8 +267,8 @@ void showReactionsDialog( providers: providers, child: ReactionsDialogWidget( messageWidget: widget, - alignment: - widgetAlignment ?? + horizontalAlignment: + horizontalAlignment ?? (isSentByMe ? CrossAxisAlignment.end : CrossAxisAlignment.start), From ec5bff7de73ee96649ef824ac1784c052dfb1106 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Mon, 11 Aug 2025 14:28:42 +0200 Subject: [PATCH 34/36] Use a builder for context menu to give user flexibility --- examples/flyer_chat/lib/local.dart | 8 ++++---- .../lib/src/utils/typedef.dart | 3 +++ .../lib/src/widgets/reactions_dialog.dart | 14 ++++++-------- packages/flyer_chat_reactions/pubspec.yaml | 1 - 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/examples/flyer_chat/lib/local.dart b/examples/flyer_chat/lib/local.dart index f8f2b0a67..3a397ad28 100644 --- a/examples/flyer_chat/lib/local.dart +++ b/examples/flyer_chat/lib/local.dart @@ -314,7 +314,7 @@ class LocalState extends State { _handleReactionTap(message, picked); } }, - menuItems: _getMenuItems(message), + bottomWidgetBuilder: (context) => _buildContextualMenu(message), ); } @@ -364,8 +364,8 @@ class LocalState extends State { ); } - List _getMenuItems(Message message) { - if (message.authorId == 'system') return []; + Widget _buildContextualMenu(Message message) { + if (message.authorId == 'system') return SizedBox.shrink(); final items = [ if (message is TextMessage) @@ -387,7 +387,7 @@ class LocalState extends State { }, ), ]; - return items; + return PullDownMenu(items: items); } void _copyMessage(TextMessage message) async { diff --git a/packages/flyer_chat_reactions/lib/src/utils/typedef.dart b/packages/flyer_chat_reactions/lib/src/utils/typedef.dart index 0054e51cd..f940d9462 100644 --- a/packages/flyer_chat_reactions/lib/src/utils/typedef.dart +++ b/packages/flyer_chat_reactions/lib/src/utils/typedef.dart @@ -5,3 +5,6 @@ typedef OnReactionTapCallback = void Function(String reaction); typedef OnReactionLongPressCallback = void Function(String reaction); typedef ReactionsDialogMoreReactionsWidgetBuilder = Widget Function(BuildContext context); + +typedef ReactionsDialogBottomWidgetBuilder = + Widget Function(BuildContext context); diff --git a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart index 26b4545d2..42635baa4 100644 --- a/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart +++ b/packages/flyer_chat_reactions/lib/src/widgets/reactions_dialog.dart @@ -5,8 +5,6 @@ import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:flutter_chat_ui/flutter_chat_ui.dart' show ChatProviders, buildMessageContent; import 'package:provider/provider.dart'; -import 'package:pull_down_button/pull_down_button.dart' - show PullDownMenuEntry, PullDownMenu; import '../models/default_data.dart'; import '../utils/hover_float_effect.dart'; @@ -28,7 +26,6 @@ class ReactionsDialogWidget extends StatefulWidget { required this.onReactionTap, this.moreReactionsWidgetBuilder, this.onMoreReactionsTap, - this.menuItems, this.reactions, this.userReactions, this.horizontalAlignment = CrossAxisAlignment.end, @@ -37,6 +34,7 @@ class ReactionsDialogWidget extends StatefulWidget { this.reactionTapAnimationDuration, this.reactionPickerFadeLeftAnimationDuration, this.activateHoverFloatEffect = true, + this.bottomWidgetBuilder, }); /// The message widget to be displayed in the dialog @@ -53,7 +51,7 @@ class ReactionsDialogWidget extends StatefulWidget { final VoidCallback? onMoreReactionsTap; /// The list of menu items to be displayed in the context menu - final List? menuItems; + final ReactionsDialogBottomWidgetBuilder? bottomWidgetBuilder; /// The list of default reactions to be displayed final List? reactions; @@ -113,9 +111,9 @@ class _ReactionsDialogWidgetState extends State { widget.activateHoverFloatEffect ? HoverFloatEffect(child: widget.messageWidget) : widget.messageWidget, - if (widget.menuItems != null && widget.menuItems!.isNotEmpty) ...[ + if (widget.bottomWidgetBuilder != null) ...[ const SizedBox(height: 10), - PullDownMenu(items: widget.menuItems!), + widget.bottomWidgetBuilder!(context), ], ], ), @@ -237,7 +235,6 @@ void showReactionsDialog( required bool isSentByMe, required OnReactionTapCallback onReactionTap, VoidCallback? onMoreReactionsTap, - List? menuItems, List? reactions, List? userReactions, CrossAxisAlignment? horizontalAlignment, @@ -246,6 +243,7 @@ void showReactionsDialog( Duration? reactionTapAnimationDuration, Duration? reactionPickerFadeLeftAnimationDuration, ReactionsDialogMoreReactionsWidgetBuilder? moreReactionsWidgetBuilder, + ReactionsDialogBottomWidgetBuilder? bottomWidgetBuilder, bool activateHoverFloatEffect = true, }) { final providers = ChatProviders.from(context); @@ -274,7 +272,7 @@ void showReactionsDialog( : CrossAxisAlignment.start), onReactionTap: onReactionTap, onMoreReactionsTap: onMoreReactionsTap, - menuItems: menuItems, + bottomWidgetBuilder: bottomWidgetBuilder, reactions: reactions, userReactions: userReactions, reactionsPickerBackgroundColor: reactionsPickerBackgroundColor, diff --git a/packages/flyer_chat_reactions/pubspec.yaml b/packages/flyer_chat_reactions/pubspec.yaml index bd1344277..9b9b6319a 100644 --- a/packages/flyer_chat_reactions/pubspec.yaml +++ b/packages/flyer_chat_reactions/pubspec.yaml @@ -16,7 +16,6 @@ dependencies: flutter_chat_core: ^2.7.0 flutter_chat_ui: ^2.7.0 provider: ^6.1.5 - pull_down_button: ^0.10.2 dev_dependencies: flutter_lints: ^6.0.0 From 16d61bbef26630e1ca14648a68fdadefc8468fcb Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Mon, 11 Aug 2025 14:29:21 +0200 Subject: [PATCH 35/36] Disable long press on system messages --- examples/flyer_chat/lib/local.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/flyer_chat/lib/local.dart b/examples/flyer_chat/lib/local.dart index 3a397ad28..1abc726ee 100644 --- a/examples/flyer_chat/lib/local.dart +++ b/examples/flyer_chat/lib/local.dart @@ -300,6 +300,7 @@ class LocalState extends State { LongPressStartDetails? details, required bool isSentByMe, }) async { + if (message.authorId == 'system') return; showReactionsDialog( context, message, From 94925f88b9138cb12761abf5593725d8602f4595 Mon Sep 17 00:00:00 2001 From: Nicolas Braun Date: Tue, 26 Aug 2025 08:16:26 +0200 Subject: [PATCH 36/36] Fix import order --- .../flyer_chat_reactions/lib/src/utils/hover_float_effect.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flyer_chat_reactions/lib/src/utils/hover_float_effect.dart b/packages/flyer_chat_reactions/lib/src/utils/hover_float_effect.dart index bfb89d544..32d154e70 100644 --- a/packages/flyer_chat_reactions/lib/src/utils/hover_float_effect.dart +++ b/packages/flyer_chat_reactions/lib/src/utils/hover_float_effect.dart @@ -1,5 +1,5 @@ -import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; /// A widget that applies a floating hover effect to its child. ///