import 'dart:math'; import 'dart:ui'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'message_actions_bottom_sheet.dart'; import 'message_text.dart'; typedef AttachmentBuilder = Widget Function(BuildContext, Message, Attachment); /// The display behaviour of a widget enum DisplayWidget { /// Hides the widget replacing its space with a spacer hide, /// Hides the widget not replacing its space gone, /// Shows the widget normally show, } /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_widget.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_widget_paint.png) /// /// It shows a message with reactions, replies and user avatar. /// /// Usually you don't use this widget as it's the default message widget used by [MessageListView]. /// /// The widget components render the ui based on the first ancestor of type [StreamChatTheme]. /// Modify it to change the widget appearance. class MessageWidget extends StatefulWidget { /// Function called on mention tap final void Function(User) onMentionTap; /// The function called when tapping on replies final void Function(Message) onThreadTap; final Widget Function(BuildContext, Message) editMessageInputBuilder; final Widget Function(BuildContext, Message) textBuilder; /// Function called on long press final void Function(BuildContext, Message) onMessageActions; /// The message final Message message; /// The message theme final MessageTheme messageTheme; /// If true the widget will be mirrored final bool reverse; /// The shape of the message text final ShapeBorder shape; /// The shape of an attachment final ShapeBorder attachmentShape; /// The borderside of the message text final BorderSide borderSide; /// The borderside of an attachment final BorderSide attachmentBorderSide; /// The border radius of the message text final BorderRadiusGeometry borderRadiusGeometry; /// The border radius of an attachment final BorderRadiusGeometry attachmentBorderRadiusGeometry; /// The padding of the widget final EdgeInsetsGeometry padding; /// The internal padding of the message text final EdgeInsetsGeometry textPadding; /// The internal padding of an attachment final EdgeInsetsGeometry attachmentPadding; /// It controls the display behaviour of the user avatar final DisplayWidget showUserAvatar; /// It controls the display behaviour of the sending indicator final DisplayWidget showSendingIndicator; /// If true the widget will show the reactions final bool showReactions; final bool allRead; /// If true the widget will show the reply indicator final bool showReplyIndicator; /// The function called when tapping on UserAvatar final void Function(User) onUserAvatarTap; final List readList; /// If true show the users username next to the timestamp of the message final bool showUsername; final bool showTimestamp; final bool showDeleteMessage; final bool showEditMessage; final Map attachmentBuilders; MessageWidget({ Key key, @required this.message, @required this.messageTheme, this.reverse = false, this.shape, this.attachmentShape, this.borderSide, this.attachmentBorderSide, this.borderRadiusGeometry, this.attachmentBorderRadiusGeometry, this.onMentionTap, this.showUserAvatar = DisplayWidget.show, this.showSendingIndicator = DisplayWidget.show, this.showReplyIndicator = true, this.onThreadTap, this.showUsername = true, this.showTimestamp = true, this.showReactions = true, this.showDeleteMessage = true, this.showEditMessage = true, this.onUserAvatarTap, this.onMessageActions, this.editMessageInputBuilder, this.textBuilder, Map customAttachmentBuilders, this.readList, this.padding, this.textPadding = const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8.0, ), this.attachmentPadding = EdgeInsets.zero, this.allRead = false, }) : attachmentBuilders = { 'image': (context, message, attachment) { return ImageAttachment( attachment: attachment, message: message, messageTheme: messageTheme, size: Size( MediaQuery.of(context).size.width * 0.8, MediaQuery.of(context).size.height * 0.3, ), ); }, 'video': (context, message, attachment) { return VideoAttachment( attachment: attachment, messageTheme: messageTheme, size: Size( MediaQuery.of(context).size.width * 0.8, MediaQuery.of(context).size.height * 0.3, ), ); }, 'giphy': (context, message, attachment) { return GiphyAttachment( attachment: attachment, messageTheme: messageTheme, message: message, size: Size( MediaQuery.of(context).size.width * 0.8, MediaQuery.of(context).size.height * 0.3, ), ); }, 'file': (context, message, attachment) { return FileAttachment( attachment: attachment, size: Size( MediaQuery.of(context).size.width * 0.8, MediaQuery.of(context).size.height * 0.3, ), ); }, }..addAll(customAttachmentBuilders ?? {}), super(key: key); @override _MessageWidgetState createState() => _MessageWidgetState(); } class _MessageWidgetState extends State { final Map _reactionToEmoji = { 'love': 'โค๏ธ๏ธ', 'haha': '๐Ÿ˜‚', 'like': '๐Ÿ‘', 'sad': '๐Ÿ˜•', 'angry': '๐Ÿ˜ก', 'wow': '๐Ÿ˜ฒ', }; final GlobalKey _reactionPickerKey = GlobalKey(); double _reactionPadding = 0; @override Widget build(BuildContext context) { var leftPadding = widget.showUserAvatar != DisplayWidget.gone ? widget.messageTheme.avatarTheme.constraints.maxWidth + 16.0 : 6.0; return Portal( child: Padding( padding: widget.padding ?? EdgeInsets.all(8), child: Transform( alignment: Alignment.center, transform: Matrix4.rotationY(widget.reverse ? pi : 0), child: Container( alignment: Alignment.centerLeft, child: Container( constraints: BoxConstraints.loose( Size.fromWidth(MediaQuery.of(context).size.width * 0.8), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ IntrinsicWidth( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ if (widget.showUserAvatar == DisplayWidget.show) _buildUserAvatar(), SizedBox( width: 6, ), if (widget.showUserAvatar == DisplayWidget.hide) SizedBox( width: widget.messageTheme.avatarTheme .constraints.maxWidth + 8, ), Flexible( child: Padding( padding: widget.showReactions ? EdgeInsets.only( top: _reactionPadding, ) : EdgeInsets.zero, child: PortalEntry( portalAnchor: Alignment(0, 1), childAnchor: Alignment.topRight, portal: _buildReactionIndicator(context), child: (widget.message.isDeleted && widget.message.status != MessageSendingStatus .FAILED_DELETE) ? Transform( alignment: Alignment.center, transform: Matrix4.rotationY( widget.reverse ? pi : 0), child: DeletedMessage( messageTheme: widget.messageTheme, ), ) : Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ..._parseAttachments(context), if (widget.message.text .trim() .isNotEmpty) _buildTextBubble(context), ], ), ), ), ), ], ), if (widget.showReplyIndicator && widget.message.replyCount > 0) _buildReplyIndicator(leftPadding), ], ), ), if ((widget.message.createdAt != null && widget.showTimestamp) || widget.showUsername || widget.readList?.isNotEmpty == true) _buildBottomRow(leftPadding), ], ), ), ), ), ), ); } @override void didUpdateWidget(MessageWidget oldWidget) { super.didUpdateWidget(oldWidget); _updateReactionPadding(); } @override void initState() { super.initState(); _updateReactionPadding(); } void _updateReactionPadding() { WidgetsBinding.instance.addPostFrameCallback((timeStamp) { if (!mounted) { return; } if (_reactionPickerKey.currentContext != null && widget.message.reactionCounts != null && widget.message.reactionCounts.values .where((element) => element > 0) .isNotEmpty) { setState(() { _reactionPadding = _reactionPickerKey.currentContext.size.height; }); } else { setState(() { _reactionPadding = 0; }); } }); } Widget _buildReactionsTail(BuildContext context) { return AnimatedSwitcher( duration: Duration(milliseconds: 300), child: widget.message.reactionCounts?.isNotEmpty == true ? Transform.translate( offset: Offset(4, 0), child: CustomPaint( painter: ReactionBubblePainter( Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black, ), ), ) : SizedBox(), ); } Padding _buildBottomRow(double leftPadding) { return Padding( padding: EdgeInsets.only( left: leftPadding, top: 2, ), child: Row( mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Transform( alignment: Alignment.center, transform: Matrix4.rotationY(widget.reverse ? pi : 0), child: RichText( text: TextSpan( style: widget.messageTheme.createdAt, children: [ if (widget.showUsername) TextSpan( text: widget.message.user.name, style: TextStyle( fontWeight: FontWeight.bold, color: widget.messageTheme.createdAt.color .withOpacity(1)), ), if (widget.message.createdAt != null && widget.showTimestamp) TextSpan( text: Jiffy(widget.message.createdAt.toLocal()) .format(' HH:mm'), ), ], ), ), ), if (widget.showSendingIndicator == DisplayWidget.show) _buildSendingIndicator(), if (widget.readList?.isNotEmpty == true) SizedBox.fromSize( size: Size((widget.readList.length * 10.0) + 10, 17), child: Transform( alignment: Alignment.center, transform: Matrix4.rotationY(widget.reverse ? pi : 0), child: Padding( padding: const EdgeInsets.only(left: 4.0), child: _buildReadIndicator(), ), ), ), ], ), ); } Widget _buildReadIndicator() { var padding = 0.0; return Stack( children: widget.readList.map((e) { padding += 10.0; return Positioned( left: padding - 10, bottom: 0, top: 0, child: Material( color: Colors.white, shape: CircleBorder(), child: Padding( padding: const EdgeInsets.all(1.0), child: UserAvatar( user: e.user, constraints: BoxConstraints.loose(Size.fromRadius(16)), showOnlineStatus: false, ), ), ), ); }).toList(), ); } Widget _buildReactionIndicator(BuildContext context) { return AnimatedSwitcher( key: _reactionPickerKey, duration: Duration(milliseconds: 300), child: (widget.showReactions && widget.message.reactionCounts?.isNotEmpty == true && !widget.message.isDeleted) ? Container( child: GestureDetector( onTap: () => onLongPress(context), child: Container( width: MediaQuery.of(context).size.width * 0.3, padding: const EdgeInsets.only( bottom: 4.0, ), child: Column( crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Transform( transform: Matrix4.rotationY(widget.reverse ? pi : 0), alignment: Alignment.center, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black, borderRadius: BorderRadius.all(Radius.circular(14)), ), child: _buildReactionsText(context), ), ), _buildReactionsTail(context), ], ), ), ), ) : SizedBox(), ); } Text _buildReactionsText(BuildContext context) { return Text( widget.message.reactionCounts.keys.map((reactionType) { return _reactionToEmoji[reactionType] ?? '?'; }).join(' ') + ' ${widget.message.reactionCounts.values.fold(0, (t, v) => v + t).toString()}', style: TextStyle( color: Theme.of(context).brightness == Brightness.dark ? Colors.black : Colors.white, ), textAlign: TextAlign.justify, ); } void _showMessageBottomSheet(BuildContext context) { final channel = StreamChannel.of(context).channel; showModalBottomSheet( clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: Radius.circular(32), topRight: Radius.circular(32), ), ), context: context, builder: (context) { return StreamChannel( channel: channel, child: MessageActionsBottomSheet( showDeleteMessage: widget.showDeleteMessage, message: widget.message, editMessageInputBuilder: widget.editMessageInputBuilder, onThreadTap: widget.onThreadTap, showEditMessage: widget.showEditMessage, showReactions: widget.showReactions, showReply: widget.showReplyIndicator && widget.onThreadTap != null, ), ); }); } List _parseAttachments(BuildContext context) { return widget.message.attachments?.map((attachment) { final attachmentBuilder = widget.attachmentBuilders[attachment.type]; if (attachmentBuilder == null) { return SizedBox(); } return Padding( padding: EdgeInsets.only( bottom: 4, ), child: GestureDetector( onTap: () => retryMessage(context), onLongPress: () => onLongPress(context), child: Material( color: _getBackgroundColor(), clipBehavior: Clip.hardEdge, shape: widget.attachmentShape ?? widget.shape ?? ContinuousRectangleBorder( side: widget.attachmentBorderSide ?? widget.borderSide ?? BorderSide( color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withAlpha(24) : Colors.black.withAlpha(24), ), borderRadius: widget.attachmentBorderRadiusGeometry ?? widget.borderRadiusGeometry ?? BorderRadius.zero, ), child: Padding( padding: widget.attachmentPadding, child: ClipRRect( borderRadius: BorderRadius.circular(6), child: Transform( transform: Matrix4.rotationY(widget.reverse ? pi : 0), alignment: Alignment.center, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ getFailedMessageWidget( context, padding: const EdgeInsets.all(8.0), ), attachmentBuilder( context, widget.message, attachment, ), ], ), ), ), ), ), ), ); })?.toList() ?? []; } void onLongPress(BuildContext context) { if (widget.message.isEphemeral || widget.message.status == MessageSendingStatus.SENDING) { return; } if (widget.onMessageActions != null) { widget.onMessageActions(context, widget.message); } else { _showMessageBottomSheet(context); } return; } Widget _buildReplyIndicator(double leftPadding) { return Padding( padding: EdgeInsets.only( left: leftPadding, ), child: Transform( transform: Matrix4.rotationY(widget.reverse ? pi : 0), alignment: Alignment.center, child: ReplyIndicator( message: widget.message, reversed: widget.reverse, messageTheme: widget.messageTheme, onTap: widget.onThreadTap != null ? () { widget.onThreadTap(widget.message); } : null, ), ), ); } Widget _buildSendingIndicator() { return Padding( padding: const EdgeInsets.only(right: 4.0), child: Transform( transform: Matrix4.rotationY(widget.reverse ? pi : 0), alignment: Alignment.center, child: SendingIndicator( message: widget.message, allRead: widget.allRead, ), ), ); } Widget _buildUserAvatar() => Transform( transform: Matrix4.rotationY(widget.reverse ? pi : 0), alignment: Alignment.center, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: Transform.translate( offset: Offset( 0, widget.messageTheme.avatarTheme.constraints.maxHeight / 2), child: UserAvatar( user: widget.message.user, onTap: widget.onUserAvatarTap, constraints: widget.messageTheme.avatarTheme.constraints, showOnlineStatus: false, ), ), ), ); Widget getFailedMessageWidget( BuildContext context, { EdgeInsetsGeometry padding, }) { Widget failedWidget; if (widget.message.status == MessageSendingStatus.FAILED) { failedWidget = Text( 'MESSAGE FAILED ยท CLICK TO TRY AGAIN', style: widget.messageTheme.messageText.copyWith( color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(.5) : Colors.black.withOpacity(.5), fontSize: 11, ), ); } if (widget.message.status == MessageSendingStatus.FAILED_UPDATE) { failedWidget = Text( 'MESSAGE UPDATE FAILED ยท CLICK TO TRY AGAIN', style: widget.messageTheme.messageText.copyWith( color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(.5) : Colors.black.withOpacity(.5), fontSize: 11, ), ); } if (widget.message.status == MessageSendingStatus.FAILED_DELETE) { failedWidget = Text( 'MESSAGE DELETE FAILED ยท CLICK TO TRY AGAIN', style: widget.messageTheme.messageText.copyWith( color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(.5) : Colors.black.withOpacity(.5), fontSize: 11, ), ); } if (failedWidget != null) { return Padding( padding: padding ?? EdgeInsets.zero, child: failedWidget, ); } return SizedBox(); } Widget _buildTextBubble(BuildContext context) { return GestureDetector( onTap: () => retryMessage(context), onLongPress: () => onLongPress(context), child: Material( shape: widget.shape ?? ContinuousRectangleBorder( side: widget.borderSide ?? BorderSide( color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withAlpha(24) : Colors.black.withAlpha(24), ), borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, ), color: _getBackgroundColor(), child: Transform( transform: Matrix4.rotationY(widget.reverse ? pi : 0), alignment: Alignment.center, child: Padding( padding: widget.textPadding, child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ getFailedMessageWidget(context), _buildText(context), ], ), ), ), ), ); } Color _getBackgroundColor() { return (widget.message.status == MessageSendingStatus.FAILED || widget.message.status == MessageSendingStatus.FAILED_UPDATE || widget.message.status == MessageSendingStatus.FAILED_DELETE) ? Color(0xffd0021B).withOpacity(.1) : widget.messageTheme.messageBackgroundColor; } void retryMessage(BuildContext context) { final channel = StreamChannel.of(context).channel; if (widget.message.status == MessageSendingStatus.FAILED) { channel.sendMessage(widget.message); return; } if (widget.message.status == MessageSendingStatus.FAILED_UPDATE) { StreamChat.of(context).client.updateMessage( widget.message, channel.cid, ); return; } if (widget.message.status == MessageSendingStatus.FAILED_DELETE) { StreamChat.of(context).client.deleteMessage( widget.message, channel.cid, ); return; } } Widget _buildText(BuildContext context) { return widget.textBuilder != null ? widget.textBuilder(context, widget.message) : MessageText( message: widget.message, onMentionTap: widget.onMentionTap, messageTheme: widget.messageTheme, ); } } class ReactionBubblePainter extends CustomPainter { final Color color; ReactionBubblePainter(this.color); @override void paint(Canvas canvas, Size size) { final paint = Paint()..color = color; final path = Path(); path.lineTo(-2, -6); path.lineTo(0, 10); path.lineTo(10, -6); path.lineTo(-2, -6); canvas.drawPath(path, paint); } @override bool shouldRepaint(CustomPainter oldDelegate) { return true; } }