import 'dart:math'; import 'dart:ui'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.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/src/message_actions_modal.dart'; import 'package:stream_chat_flutter/src/message_reactions_modal.dart'; import 'package:stream_chat_flutter/src/reaction_bubble.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'image_group.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; /// The function called when tapping on a link final void Function(String) onLinkTap; 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.onLinkTap, 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 { @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: FractionallySizedBox( alignment: Alignment.centerLeft, widthFactor: 0.75, child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ 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: widget.message.reactionCounts ?.isNotEmpty == true ? 12 : 0, ) : EdgeInsets.zero, child: PortalEntry( portalAnchor: Alignment(-0.81, 0), 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, mainAxisSize: MainAxisSize.min, 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), ], ), ), ), ), ); } 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) { final otherReactions = widget.message.latestReactions ?.where( (element) => element.user.id != StreamChat.of(context).user.id) ?.toList() ?? []; var rowChildren = [ if (widget.message.ownReactions?.isNotEmpty == true) ...[ Transform.translate( offset: Offset( widget.reverse ? -6 : 0, 0, ), child: ReactionBubble( key: ValueKey('${widget.message.id}.own'), reverse: widget.reverse, backgroundColor: widget.messageTheme.ownReactionsBackgroundColor, borderColor: widget.messageTheme.ownReactionsBorderColor, reactions: widget.message.ownReactions, ), ), if (otherReactions.isEmpty) Container( width: 18, ), ], if (otherReactions?.isNotEmpty == true) ...[ if (widget.message.ownReactions?.isEmpty == true) Container( width: 18, ), Transform.translate( offset: Offset( widget.reverse ? 0 : -6, 0, ), child: ReactionBubble( key: ValueKey('${widget.message.id}.other'), reactions: otherReactions, reverse: widget.reverse, flipTail: true, backgroundColor: widget.messageTheme.otherReactionsBackgroundColor, borderColor: widget.messageTheme.otherReactionsBorderColor, ), ), ] ]; if (widget.reverse) { rowChildren = rowChildren.reversed.toList(); } return AnimatedSwitcher( duration: Duration(milliseconds: 300), child: (widget.showReactions && (widget.message.reactionCounts?.isNotEmpty == true || otherReactions.isNotEmpty) && !widget.message.isDeleted) ? Container( child: GestureDetector( onTap: () => _showMessageReactionsModalBottomSheet(context), child: FractionallySizedBox( widthFactor: 0.5, child: Row( mainAxisAlignment: MainAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: rowChildren, ), ), ), ) : SizedBox(), ); } void _showMessageActionModalBottomSheet(BuildContext context) { final channel = StreamChannel.of(context).channel; showDialog( context: context, builder: (context) { return StreamChannel( channel: channel, child: MessageActionsModal( messageTheme: widget.messageTheme, messageShape: widget.shape ?? _getDefaultShape(context), reverse: widget.reverse, showDeleteMessage: widget.showDeleteMessage, message: widget.message, editMessageInputBuilder: widget.editMessageInputBuilder, onThreadTap: widget.onThreadTap, showEditMessage: widget.showEditMessage, showReactions: widget.showReactions, showReply: widget.showReplyIndicator && widget.onThreadTap != null, ), ); }); } void _showMessageReactionsModalBottomSheet(BuildContext context) { final channel = StreamChannel.of(context).channel; showDialog( context: context, builder: (context) { return StreamChannel( channel: channel, child: MessageReactionsModal( onUserAvatarTap: widget.onUserAvatarTap, messageTheme: widget.messageTheme, messageShape: widget.shape ?? _getDefaultShape(context), reverse: widget.reverse, showDeleteMessage: widget.showDeleteMessage, message: widget.message, editMessageInputBuilder: widget.editMessageInputBuilder, onThreadTap: widget.onThreadTap, showEditMessage: widget.showEditMessage, showReactions: widget.showReactions, showReply: widget.showReplyIndicator && widget.onThreadTap != null, ), ); }); } ContinuousRectangleBorder _getDefaultShape(BuildContext context) { return 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, ); } List _parseAttachments(BuildContext context) { final images = widget.message.attachments ?.where((element) => element.type == 'image') ?.toList() ?? []; if (images.length > 1) { return [ wrapAttachmentWidget( context, Material( color: widget.messageTheme.messageBackgroundColor, child: ImageGroup( size: Size( MediaQuery.of(context).size.width * 0.8, MediaQuery.of(context).size.height * 0.3, ), images: images, message: widget.message, ), ), ), ]; } return widget.message.attachments?.map((attachment) { final attachmentBuilder = widget.attachmentBuilders[attachment.type]; if (attachmentBuilder == null) { return SizedBox(); } final attachmentWidget = attachmentBuilder( context, widget.message, attachment, ); return wrapAttachmentWidget(context, attachmentWidget); })?.toList() ?? []; } Padding wrapAttachmentWidget(BuildContext context, Widget attachmentWidget) { 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 ?? _getDefaultShape(context), 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), ), attachmentWidget, ], ), ), ), ), ), ), ); } void onLongPress(BuildContext context) { if (widget.message.isEphemeral || widget.message.status == MessageSendingStatus.SENDING) { return; } if (widget.onMessageActions != null) { widget.onMessageActions(context, widget.message); } else { _showMessageActionModalBottomSheet(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( onLinkTap: widget.onLinkTap, message: widget.message, onMentionTap: widget.onMentionTap, messageTheme: widget.messageTheme, ); } }