import 'dart:io'; import 'dart:math'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:chewie/chewie.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:jiffy/jiffy.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter/src/full_screen_image.dart'; import 'package:stream_chat_flutter/src/message_input.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart'; import 'package:stream_chat_flutter/src/reaction_picker.dart'; import 'package:stream_chat_flutter/src/stream_channel.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/user_avatar.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:video_player/video_player.dart'; import 'stream_chat.dart'; /// ![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 { /// Instantiate a new MessageWidget const MessageWidget({ Key key, @required this.previousMessage, @required this.message, @required this.nextMessage, this.onThreadTap, this.onMessageActions, this.isParent = false, this.onMentionTap, this.showOtherMessageUsername = false, this.showVideoFullScreen = true, }) : super(key: key); /// Function called on mention tap final void Function(User) onMentionTap; /// Function called on long press final Function(BuildContext, Message) onMessageActions; /// If true show the other users username next to the timestamp of the message final bool showOtherMessageUsername; /// This message final Message message; /// The previous message final Message previousMessage; /// The next message final Message nextMessage; /// The function called when tapping on replies final void Function(Message) onThreadTap; /// True if this is the parent of the thread being showed final bool isParent; /// True if the video player will allow fullscreen mode final bool showVideoFullScreen; @override _MessageWidgetState createState() => _MessageWidgetState(); } class _MessageWidgetState extends State with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { final Map _videoControllers = {}; final Map _chuwieControllers = {}; MessageTheme _messageTheme; StreamChatState _streamChat; StreamChannelState _streamChannel; bool _isMyMessage; String _currentUserId; String _messageUserId; String _previousUserId; String _nextUserId; bool _isLastUser; bool _isNextUser; @override Widget build(BuildContext context) { super.build(context); _messageTheme = _isMyMessage ? StreamChatTheme.of(context).ownMessageTheme : StreamChatTheme.of(context).otherMessageTheme; final alignment = _isMyMessage ? Alignment.centerRight : Alignment.centerLeft; var row = List.from([ Column( crossAxisAlignment: _isMyMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ (widget.message.isDeleted && widget.message.status != MessageSendingStatus.FAILED_DELETE) ? _buildDeletedMessage(alignment) : _buildBubble(context), if (_streamChannel.channel.config?.replies == true) _buildThreadIndicator(context), if (!_isNextUser) _buildTimestamp(alignment), ], ), _isNextUser ? Container( width: 40, ) : _buildUserAvatar(), ]); if (!_isMyMessage) { row = row.reversed.toList(); } return Container( padding: EdgeInsets.symmetric( horizontal: (_isMyMessage && widget.nextMessage == null) ? 0.0 : 10, ), margin: EdgeInsets.only( top: _isLastUser ? 5 : 24, bottom: widget.nextMessage == null ? 30 : 0, ), child: Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: _isMyMessage ? MainAxisAlignment.end : MainAxisAlignment.start, mainAxisSize: MainAxisSize.max, children: row, ), ); } Padding _buildUserAvatar() { return Padding( padding: EdgeInsets.only( left: _isMyMessage ? 8.0 : 0, right: _isMyMessage ? 0 : 8.0, ), child: Row( children: [ UserAvatar(user: widget.message.user), if (_isMyMessage && widget.nextMessage == null && (widget.message.status == MessageSendingStatus.SENT || widget.message.status == null)) Padding( padding: const EdgeInsets.symmetric( horizontal: 1.0, ), child: CircleAvatar( radius: 4, backgroundColor: StreamChatTheme.of(context).accentColor, child: Icon( Icons.done, color: Colors.white, size: 4, ), ), ), if (_isMyMessage && (widget.message.status == MessageSendingStatus.SENDING || widget.message.status == MessageSendingStatus.UPDATING)) Padding( padding: const EdgeInsets.symmetric( horizontal: 1.0, ), child: CircleAvatar( radius: 4, backgroundColor: Colors.grey, child: Icon( Icons.access_time, size: 4, color: Colors.white, ), ), ), if (_isMyMessage && (widget.message.status == MessageSendingStatus.FAILED || widget.message.status == MessageSendingStatus.FAILED_UPDATE || widget.message.status == MessageSendingStatus.FAILED_DELETE)) Padding( padding: const EdgeInsets.symmetric( horizontal: 1.0, ), child: CircleAvatar( radius: 4, backgroundColor: Color(0xffd0021B).withOpacity(.1), child: Icon( Icons.error_outline, size: 4, color: Colors.white, ), ), ), ], ), ); } @override void initState() { super.initState(); _streamChat = StreamChat.of(context); _streamChannel = StreamChannel.of(context); _currentUserId = _streamChat.client.state.user.id; _messageUserId = widget.message.user.id; _previousUserId = widget.previousMessage?.user?.id; _nextUserId = widget.nextMessage?.user?.id; _isLastUser = _previousUserId == _messageUserId; _isNextUser = _nextUserId == _messageUserId; _isMyMessage = _messageUserId == _currentUserId; } Align _buildDeletedMessage(Alignment alignment) { return Align( alignment: alignment, child: Padding( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 14, ), child: Text( 'This message was deleted...', style: _messageTheme.messageText.copyWith( fontStyle: FontStyle.italic, color: Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black, ), ), ), ); } Widget _buildThreadIndicator(BuildContext context) { var row = [ Text( 'Replies: ${widget.message.replyCount}', style: _messageTheme.replies, ), Transform( transform: Matrix4.rotationY(_isMyMessage ? 0 : pi), alignment: Alignment.center, child: Icon( Icons.subdirectory_arrow_left, color: Theme.of(context).brightness == Brightness.dark ? Colors.white12 : Colors.black12, ), ), ]; if (!_isMyMessage) { row = row.reversed.toList(); } return (widget.message.replyCount ?? 0) > 0 ? GestureDetector( onTap: () { if (widget.isParent) { return; } if (widget.onThreadTap != null) { widget.onThreadTap(widget.message); } }, child: Padding( padding: const EdgeInsets.symmetric(vertical: 2.0), child: Row( children: row, ), ), ) : SizedBox(); } Widget _buildBubble( BuildContext context, ) { var nOfAttachmentWidgets = 0; final column = List.from(widget.message.attachments?.map((attachment) { nOfAttachmentWidgets++; Widget attachmentWidget; if (attachment.type == 'video') { attachmentWidget = _buildVideo(attachment); } else if (attachment.type == 'image' || attachment.type == 'giphy') { attachmentWidget = _buildImage(attachment); } if (attachmentWidget != null) { return _buildAttachment( attachmentWidget, attachment, nOfAttachmentWidgets, context, ); } nOfAttachmentWidgets--; return SizedBox(); }) ?? []); if (widget.message.text.trim().isNotEmpty) { var text = widget.message.text; text = _replaceMentions(text); column.addAll( [ Column( crossAxisAlignment: _isMyMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (_streamChannel.channel.config?.reactions == true && nOfAttachmentWidgets == 0) Align( child: _buildReactions(), alignment: _isMyMessage ? Alignment.centerLeft : Alignment.centerRight, ), Stack( overflow: Overflow.visible, children: [ if (nOfAttachmentWidgets == 0 && _streamChannel.channel.config?.reactions == true) _buildReactionPaint(), _buildMessageText(nOfAttachmentWidgets, text, context), ], ), ], ), ], ); } if (_streamChannel.channel.config?.reactions == true && nOfAttachmentWidgets > 0) { column.insert( 0, Align( child: _buildReactions(), alignment: _isMyMessage ? Alignment.centerLeft : Alignment.centerRight, ), ); column[1] = Stack( overflow: Overflow.visible, children: [ Padding( padding: EdgeInsets.only( right: _isMyMessage ? 0.0 : 8.0, left: _isMyMessage ? 8.0 : 0.0, ), child: column[1], ), _buildReactionPaint(), ], ); } return GestureDetector( child: IntrinsicWidth( child: Column( children: column, crossAxisAlignment: _isMyMessage ? CrossAxisAlignment.end : CrossAxisAlignment.start, ), ), onTap: () { 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; } }, onLongPress: () { if (widget.message.isEphemeral || widget.message.status == MessageSendingStatus.SENDING) { return; } if (widget.onMessageActions != null) { widget.onMessageActions(context, widget.message); } else { _showMessageBottomSheet(context); } }, ); } Padding _buildAttachment( Widget attachmentWidget, Attachment attachment, int nOfAttachmentWidgets, BuildContext context, ) { final boxDecoration = _buildBoxDecoration(_isLastUser); return Padding( padding: const EdgeInsets.only(bottom: 2.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ ClipRRect( borderRadius: boxDecoration.borderRadius, child: Container( decoration: boxDecoration, constraints: BoxConstraints.loose( Size.fromWidth(MediaQuery.of(context).size.width * 0.7), ), child: Stack( children: [ Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ attachmentWidget, if (attachment.title != null) _buildAttachmentTitle(attachment), ], ), if (attachment.type == 'image' && attachment.titleLink != null) _buildPreviewInkwell(attachment), ], ), margin: EdgeInsets.only( top: nOfAttachmentWidgets > 1 ? 5 : 0, ), ), ), if (attachment.actions != null) _buildAttachmentActions(attachment, context), ], ), ); } Widget _buildSendingError(Widget child) { return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.message.status == MessageSendingStatus.FAILED) Text( 'MESSAGE FAILED · CLICK TO TRY AGAIN', style: _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) Text( 'MESSAGE UPDATE FAILED · CLICK TO TRY AGAIN', style: _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) Text( 'MESSAGE DELETE FAILED · CLICK TO TRY AGAIN', style: _messageTheme.messageText.copyWith( color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(.5) : Colors.black.withOpacity(.5), fontSize: 11, ), ), child, ], ); } Padding _buildMessageText( int nOfAttachmentWidgets, String text, BuildContext context, ) { return Padding( padding: EdgeInsets.only( right: _isMyMessage ? 0.0 : 8.0, left: _isMyMessage ? 8.0 : 0.0, ), child: Container( decoration: _buildBoxDecoration(_isLastUser || nOfAttachmentWidgets > 0), padding: EdgeInsets.all(10), constraints: BoxConstraints.loose( Size.fromWidth(MediaQuery.of(context).size.width * 0.7), ), child: _buildSendingError( MarkdownBody( data: text, onTapLink: (link) { if (link.startsWith('@')) { final mentionedUser = widget.message.mentionedUsers.firstWhere( (u) => '@${u.name.replaceAll(' ', '')}' == link, orElse: () => null, ); if (widget.onMentionTap != null) { widget.onMentionTap(mentionedUser); } else { print('tap on ${mentionedUser.name}'); } } else { _launchURL(link); } }, styleSheet: MarkdownStyleSheet.fromTheme( Theme.of(context).copyWith( textTheme: Theme.of(context).textTheme.apply( bodyColor: _messageTheme.messageText.color, decoration: _messageTheme.messageText.decoration, decorationColor: _messageTheme.messageText.decorationColor, decorationStyle: _messageTheme.messageText.decorationStyle, fontFamily: _messageTheme.messageText.fontFamily, ), ), ).copyWith( p: _messageTheme.messageText, ), ), ), ), ); } String _replaceMentions(String text) { widget.message.mentionedUsers?.forEach((u) { text = text.replaceAll( '@${u.name}', '[@${u.name}](@${u.name.replaceAll(' ', '')})'); }); return text; } Row _buildAttachmentActions(Attachment attachment, BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.end, children: attachment.actions?.map((action) { if (action.style == 'primary') { return Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: FlatButton( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Text('${action.text}'), color: action.style == 'primary' ? StreamChatTheme.of(context).accentColor : null, textColor: Colors.white, onPressed: () { _streamChannel.channel.sendAction(widget.message.id, { action.name: action.value, }); }, ), ); } return Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), child: OutlineButton( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Text('${action.text}'), color: StreamChatTheme.of(context).accentColor, onPressed: () { _streamChannel.channel.sendAction(widget.message.id, { action.name: action.value, }); }, ), ); })?.toList(), ); } Positioned _buildPreviewInkwell(Attachment attachment) { return Positioned.fill( child: Material( color: Colors.transparent, child: InkWell( onTap: () => _launchURL(attachment.titleLink), ), ), ); } GestureDetector _buildAttachmentTitle(Attachment attachment) { return GestureDetector( onTap: () { if (attachment.titleLink != null) { _launchURL(attachment.titleLink); } }, child: Container( constraints: BoxConstraints.loose( Size( MediaQuery.of(context).size.width * 0.7, 500, ), ), child: Padding( padding: const EdgeInsets.all(8.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( attachment.title, overflow: TextOverflow.ellipsis, style: _messageTheme.messageText.copyWith( color: StreamChatTheme.of(context).accentColor, fontWeight: FontWeight.bold, ), ), if (attachment.titleLink != null || attachment.ogScrapeUrl != null) Text( Uri.parse(attachment.titleLink ?? attachment.ogScrapeUrl) .authority .split('.') .reversed .take(2) .toList() .reversed .join('.'), overflow: TextOverflow.ellipsis, style: _messageTheme.createdAt, ), ], ), ), ), ); } Widget _buildReactionPaint() { return widget.message.reactionCounts?.isNotEmpty == true ? Positioned( left: _isMyMessage ? 8 : null, right: !_isMyMessage ? 8 : null, top: -6, child: CustomPaint( painter: _ReactionBubblePainter( Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black, ), ), ) : SizedBox(); } void _showMessageBottomSheet(BuildContext context) { if (!_streamChannel.channel.config.reactions && !_streamChannel.channel.config.replies) { return; } final theme = Theme.of(context); showModalBottomSheet( clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: Radius.circular(32), topRight: Radius.circular(32), ), ), context: context, builder: (_) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( color: Colors.black87, child: (_streamChannel.channel.config.reactions && widget.message.status != MessageSendingStatus.FAILED) ? ReactionPicker( channel: StreamChannel.of(context).channel, reactionToEmoji: reactionToEmoji, message: widget.message, ) : SizedBox(), ), _isMyMessage ? FlatButton( child: Padding( padding: const EdgeInsets.all(16.0), child: Text( 'Delete message', style: theme.textTheme.headline .copyWith(color: Colors.red), ), ), onPressed: () { Navigator.pop(context); StreamChat.of(context).client.deleteMessage( widget.message, _streamChannel.channel.cid, ); }, ) : SizedBox(), _isMyMessage ? FlatButton( child: Padding( padding: const EdgeInsets.all(16.0), child: Text( 'Edit message', style: theme.textTheme.headline, ), ), onPressed: () async { Navigator.pop(context); _showEditBottomSheet(context); }, ) : SizedBox(), (_streamChannel.channel.config.replies && widget.message.status != MessageSendingStatus.FAILED && widget.message.parentId == null && !widget.isParent) ? FlatButton( child: Padding( padding: const EdgeInsets.all(16.0), child: Text( 'Start a thread', style: theme.textTheme.headline, ), ), onPressed: () { Navigator.pop(context); widget.onThreadTap(widget.message); }, ) : SizedBox(), ], ), ); }); } void _showEditBottomSheet(BuildContext context) { showModalBottomSheet( context: context, elevation: 2, clipBehavior: Clip.hardEdge, isScrollControlled: true, shape: RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: Radius.circular(32), topRight: Radius.circular(32), ), ), builder: (context) { return StreamChannel( channel: _streamChannel.channel, child: Flex( direction: Axis.vertical, mainAxisAlignment: MainAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.only( top: 16.0, left: 16.0, right: 16.0, ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Edit message', style: Theme.of(context).textTheme.title, ), Container( height: 30, padding: const EdgeInsets.all(2.0), child: AspectRatio( aspectRatio: 1, child: RawMaterialButton( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4), ), elevation: 0, highlightElevation: 0, focusElevation: 0, disabledElevation: 0, hoverElevation: 0, onPressed: () { Navigator.of(context).pop(); }, fillColor: Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(.1) : Colors.black.withOpacity(.1), padding: EdgeInsets.all(4), child: Icon( Icons.close, size: 15, color: Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black, ), ), ), ), ], ), ), Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), child: MessageInput( editMessage: widget.message, parentMessage: widget.isParent ? StreamChannel.of(context) .channel .state .messages .firstWhere((message) => message.id == widget.message.parentId) : null, onMessageSent: (_) { FocusScope.of(context).unfocus(); Navigator.pop(context); }, ), ), ], ), ); }, ); } Widget _buildReactions() { return GestureDetector( onTap: () { if (widget.onMessageActions != null) { widget.onMessageActions(context, widget.message); } else { _showMessageBottomSheet(context); } }, child: Padding( padding: EdgeInsets.symmetric( vertical: widget.message.reactionCounts?.isNotEmpty == true ? 4.0 : 0, ), child: Container( padding: widget.message.reactionCounts?.isNotEmpty == true ? const EdgeInsets.all(8) : EdgeInsets.zero, decoration: BoxDecoration( color: Theme.of(context).brightness == Brightness.dark ? Colors.white : Colors.black, borderRadius: BorderRadius.all(Radius.circular(14))), child: AnimatedSwitcher( duration: Duration(milliseconds: 300), reverseDuration: Duration(milliseconds: 0), child: (widget.message.reactionCounts != null && widget.message.reactionCounts.isNotEmpty) ? _buildReactionRow() : SizedBox(), ), ), ), ); } Row _buildReactionRow() { return Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, children: [ ...widget.message.reactionCounts.keys.map((reactionType) { return Text( reactionToEmoji[reactionType] ?? '?', ); }), Padding( padding: const EdgeInsets.only(left: 4.0), child: Text( widget.message.reactionCounts.values .fold(0, (t, v) => v + t) .toString(), style: TextStyle( color: Theme.of(context).brightness == Brightness.dark ? Colors.black : Colors.white, ), ), ), ], ); } final Map reactionToEmoji = { 'love': '❤️️', 'haha': '😂', 'like': '👍', 'sad': '😕', 'angry': '😡', 'wow': '😲', }; Widget _buildImage( Attachment attachment, ) { if (attachment.thumbUrl == null && attachment.imageUrl == null && attachment.assetUrl == null) { return _buildErrorImage(attachment); } return Hero( tag: attachment.imageUrl ?? attachment.assetUrl ?? attachment.thumbUrl, child: CachedNetworkImage( imageBuilder: (context, provider) { return GestureDetector( child: Image(image: provider), onTap: () { Navigator.push(context, MaterialPageRoute(builder: (_) { return FullScreenImage( url: attachment.imageUrl ?? attachment.assetUrl ?? attachment.thumbUrl, ); })); }, ); }, placeholder: (_, __) { return Container( width: 200, height: 140, ); }, imageUrl: attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl, errorWidget: (context, url, error) => _buildErrorImage(attachment), fit: BoxFit.cover, ), ); } Widget _buildErrorImage(Attachment attachment) { if (attachment.localUri != null) { return Image.file( File(attachment.localUri.path), ); } return Center( child: Container( width: 200, height: 140, color: Color(0xffd0021B).withOpacity(.1), child: Center( child: Icon( Icons.error_outline, color: Colors.white, ), ), ), ); } Widget _buildErrorImage(Attachment attachment) { return Center( child: Container( width: 200, height: 140, color: Color(0xffd0021B).withOpacity(.1), child: Center( child: Icon( Icons.error_outline, color: Colors.white, ), ), ), ); } Widget _buildVideo( Attachment attachment, ) { VideoPlayerController videoController; if (_videoControllers.containsKey(attachment.assetUrl)) { videoController = _videoControllers[attachment.assetUrl]; } else { videoController = VideoPlayerController.network(attachment.assetUrl); _videoControllers[attachment.assetUrl] = videoController; } return FutureBuilder( future: videoController.value.initialized ? Future.value(true) : videoController.initialize(), builder: (_, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return Container( height: 100, width: 100, child: Center( child: CircularProgressIndicator(), ), ); } ChewieController chewieController; if (_chuwieControllers.containsKey(attachment.assetUrl)) { chewieController = _chuwieControllers[attachment.assetUrl]; } else { chewieController = ChewieController( allowFullScreen: widget.showVideoFullScreen, videoPlayerController: videoController, autoInitialize: false, aspectRatio: videoController.value.aspectRatio, errorBuilder: (_, e) { if (attachment.thumbUrl != null) { return Stack( children: [ Container( decoration: BoxDecoration( image: DecorationImage( fit: BoxFit.cover, image: CachedNetworkImageProvider( attachment.thumbUrl, ), ), ), ), if (attachment.titleLink != null) Material( color: Colors.transparent, child: InkWell( onTap: () => _launchURL(attachment.titleLink), ), ), ], ); } return _buildErrorImage(attachment); }); _chuwieControllers[attachment.assetUrl] = chewieController; } return Chewie( key: ValueKey( 'ATTACHMENT-${attachment.title}-${widget.message.id}'), controller: chewieController, ); }, ); } Future _launchURL(String url) async { if (await canLaunch(url)) { await launch(url); } else { Scaffold.of(context).showSnackBar( SnackBar( content: Text('Cannot launch the url'), ), ); } } @override void dispose() { _videoControllers.values.forEach((element) { element.dispose(); }); super.dispose(); } Widget _buildTimestamp(Alignment alignment) { return Padding( padding: const EdgeInsets.only(top: 5.0), child: RichText( text: TextSpan( style: _messageTheme.createdAt, children: [ if (!_isMyMessage && widget.showOtherMessageUsername) TextSpan( text: widget.message.user.name, style: TextStyle(fontWeight: FontWeight.bold), ), if (widget.message.createdAt != null) TextSpan( text: Jiffy(widget.message.createdAt.toLocal()).format(' HH:mm'), ), ], ), ), ); } BoxDecoration _buildBoxDecoration(bool rectBorders) { return BoxDecoration( border: _isMyMessage ? null : Border.all( color: Theme.of(context).brightness == Brightness.dark ? Colors.white.withAlpha(24) : Colors.black.withAlpha(24), ), borderRadius: BorderRadius.only( topLeft: Radius.circular((_isMyMessage || !rectBorders) ? 16 : 2), bottomLeft: Radius.circular(_isMyMessage ? 16 : 2), topRight: Radius.circular((_isMyMessage && rectBorders) ? 2 : 16), bottomRight: Radius.circular(_isMyMessage ? 2 : 16), ), color: (widget.message.status == MessageSendingStatus.FAILED || widget.message.status == MessageSendingStatus.FAILED_UPDATE || widget.message.status == MessageSendingStatus.FAILED_DELETE) ? Color(0xffd0021B).withOpacity(.1) : _messageTheme.messageBackgroundColor, ); } @override bool get wantKeepAlive { return widget.message.attachments?.isNotEmpty == true; } } 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.arcToPoint(Offset(-6, -6)); path.arcToPoint(Offset(0, 10)); path.arcToPoint(Offset(6, -6)); canvas.drawPath(path, paint); } @override bool shouldRepaint(CustomPainter oldDelegate) { return true; } }