diff --git a/example/lib/main.dart b/example/lib/main.dart index 4b529e23..ab002a62 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -415,10 +415,31 @@ class ChannelQuerySearchResultPage extends StatelessWidget { } } -class ChannelPage extends StatelessWidget { - const ChannelPage({ - Key key, - }) : super(key: key); +class ChannelPage extends StatefulWidget { + @override + _ChannelPageState createState() => _ChannelPageState(); +} + +class _ChannelPageState extends State { + Message _replyMessage; + FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + void _reply(Message message) { + setState(() => _replyMessage = message); + _focusNode.requestFocus(); + } @override Widget build(BuildContext context) { @@ -433,6 +454,8 @@ class ChannelPage extends StatelessWidget { child: Stack( children: [ MessageListView( + onMessageSwiped: _reply, + onReplyTap: _reply, threadBuilder: (_, parentMessage) { return ThreadPage( parent: parentMessage, @@ -458,7 +481,10 @@ class ChannelPage extends StatelessWidget { ], ), ), - MessageInput(), + MessageInput( + focusNode: _focusNode, + replyMessage: _replyMessage, + ), ], ), ); diff --git a/lib/src/extension.dart b/lib/src/extension.dart new file mode 100644 index 00000000..d0b18fe8 --- /dev/null +++ b/lib/src/extension.dart @@ -0,0 +1,5 @@ +extension StringExtension on String { + String capitalize() { + return "${this[0].toUpperCase()}${this.substring(1)}"; + } +} diff --git a/lib/src/file_attachment.dart b/lib/src/file_attachment.dart index fff13bbe..599b71f6 100644 --- a/lib/src/file_attachment.dart +++ b/lib/src/file_attachment.dart @@ -32,7 +32,7 @@ class FileAttachment extends StatelessWidget { child: Row( children: [ Container( - child: _getFileTypeImage(attachment.extraData['mime_type']), + child: getFileTypeImage(attachment.extraData['mime_type']), height: 40.0, width: 33.33, margin: EdgeInsets.all(8.0), @@ -116,63 +116,4 @@ class FileAttachment extends StatelessWidget { ), ); } - - StreamSvgIcon _getFileTypeImage(String type) { - switch (type) { - case '7z': - return StreamSvgIcon.filetype_7z(); - break; - case 'csv': - return StreamSvgIcon.filetype_csv(); - break; - case 'doc': - return StreamSvgIcon.filetype_doc(); - break; - case 'docx': - return StreamSvgIcon.filetype_docx(); - break; - case 'html': - return StreamSvgIcon.filetype_html(); - break; - case 'md': - return StreamSvgIcon.filetype_md(); - break; - case 'odt': - return StreamSvgIcon.filetype_odt(); - break; - case 'pdf': - return StreamSvgIcon.filetype_pdf(); - break; - case 'ppt': - return StreamSvgIcon.filetype_ppt(); - break; - case 'pptx': - return StreamSvgIcon.filetype_pptx(); - break; - case 'rar': - return StreamSvgIcon.filetype_rar(); - break; - case 'rtf': - return StreamSvgIcon.filetype_rtf(); - break; - case 'tar': - return StreamSvgIcon.filetype_tar(); - break; - case 'txt': - return StreamSvgIcon.filetype_txt(); - break; - case 'xls': - return StreamSvgIcon.filetype_xls(); - break; - case 'xlsx': - return StreamSvgIcon.filetype_xlsx(); - break; - case 'zip': - return StreamSvgIcon.filetype_zip(); - break; - default: - return StreamSvgIcon.filetype_Generic(); - break; - } - } } diff --git a/lib/src/message_actions_modal.dart b/lib/src/message_actions_modal.dart index 1967cd2b..e8966197 100644 --- a/lib/src/message_actions_modal.dart +++ b/lib/src/message_actions_modal.dart @@ -15,7 +15,8 @@ import 'stream_chat_theme.dart'; class MessageActionsModal extends StatelessWidget { final Widget Function(BuildContext, Message) editMessageInputBuilder; - final void Function(Message) onThreadTap; + final void Function(Message) onThreadReplyTap; + final void Function(Message) onReplyTap; final Message message; final MessageTheme messageTheme; final bool showReactions; @@ -23,6 +24,7 @@ class MessageActionsModal extends StatelessWidget { final bool showCopyMessage; final bool showEditMessage; final bool showReply; + final bool showThreadReply; final bool reverse; final ShapeBorder messageShape; final DisplayWidget showUserAvatar; @@ -34,9 +36,11 @@ class MessageActionsModal extends StatelessWidget { this.showReactions = true, this.showDeleteMessage = true, this.showEditMessage = true, - this.onThreadTap, + this.onReplyTap, + this.onThreadReplyTap, this.showCopyMessage = true, this.showReply = true, + this.showThreadReply = true, this.showUserAvatar = DisplayWidget.show, this.editMessageInputBuilder, this.messageShape, @@ -114,6 +118,7 @@ class MessageActionsModal extends StatelessWidget { showReactions: false, showUsername: false, showReplyIndicator: false, + showThreadReplyIndicator: false, showUserAvatar: showUserAvatar, showTimestamp: false, translateUserAvatar: false, @@ -158,6 +163,12 @@ class MessageActionsModal extends StatelessWidget { message.status == null) && message.parentId == null) _buildReplyButton(context), + if (showThreadReply && + (message.status == + MessageSendingStatus.SENT || + message.status == null) && + message.parentId == null) + _buildThreadReplyButton(context), if (showEditMessage) _buildEditMessage(context), if (showDeleteMessage) @@ -181,6 +192,24 @@ class MessageActionsModal extends StatelessWidget { ); } + Widget _buildReplyButton(BuildContext context) { + return ListTile( + title: Text( + 'Reply', + style: Theme.of(context).textTheme.headline6, + ), + leading: StreamSvgIcon.reply( + color: StreamChatTheme.of(context).primaryIconTheme.color, + ), + onTap: () { + Navigator.pop(context); + if (onReplyTap != null) { + onReplyTap(message); + } + }, + ); + } + Widget _buildDeleteButton(BuildContext context) { return ListTile( title: Text( @@ -313,7 +342,7 @@ class MessageActionsModal extends StatelessWidget { ); } - Widget _buildReplyButton(BuildContext context) { + Widget _buildThreadReplyButton(BuildContext context) { return ListTile( title: Text( 'Thread reply', @@ -324,8 +353,8 @@ class MessageActionsModal extends StatelessWidget { ), onTap: () { Navigator.pop(context); - if (onThreadTap != null) { - onThreadTap(message); + if (onThreadReplyTap != null) { + onThreadReplyTap(message); } }, ); diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index 4e30afe1..18b6d014 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'package:emojis/emoji.dart'; import 'package:file_picker/file_picker.dart'; @@ -21,9 +22,10 @@ import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/user_avatar.dart'; import 'package:substring_highlight/substring_highlight.dart'; import 'package:video_compress/video_compress.dart'; -import 'package:photo_manager/photo_manager.dart'; +import 'extension.dart'; import '../stream_chat_flutter.dart'; +import 'reply_message_widget.dart'; import 'stream_channel.dart'; typedef FileUploader = Future Function(PlatformFile, Channel); @@ -108,6 +110,7 @@ class MessageInput extends StatefulWidget { this.actionsLocation = ActionsLocation.left, this.attachmentThumbnailBuilders, this.focusNode, + this.replyMessage, }) : super(key: key); /// Message to edit @@ -156,6 +159,9 @@ class MessageInput extends StatefulWidget { /// The focus node associated to the TextField final FocusNode focusNode; + /// + final Message replyMessage; + @override MessageInputState createState() => MessageInputState(); @@ -167,7 +173,7 @@ class MessageInput extends StatefulWidget { if (messageInputState == null) { throw Exception( - 'You must have a MessageInput widget as anchestor of your widget tree'); + 'You must have a MessageInput widget as ancestor of your widget tree'); } return messageInputState; @@ -197,9 +203,13 @@ class MessageInputState extends State { /// The editing controller passed to the input TextField TextEditingController textEditingController; + Message _replyMessage; + + bool get _hasReplyMessage => _replyMessage != null; + @override Widget build(BuildContext context) { - return SafeArea( + Widget child = SafeArea( child: GestureDetector( onPanUpdate: (details) { if (details.delta.dy > 0) { @@ -214,8 +224,31 @@ class MessageInputState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ + if (_hasReplyMessage) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: StreamSvgIcon.reply( + color: Colors.black.withOpacity(0.2), + ), + ), + Text( + 'Reply to Message', + style: TextStyle(fontWeight: FontWeight.bold), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: StreamSvgIcon.close_small(), + onPressed: () { + setState(() => _replyMessage = null); + }, + ), + ], + ), Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.symmetric(vertical: 8.0), child: _buildTextField(context), ), if (widget.parentMessage != null) @@ -228,6 +261,14 @@ class MessageInputState extends State { ), ), ); + if (widget.editMessage == null) { + child = Material( + color: Colors.white, + elevation: 8, + child: child, + ); + } + return child; } Flex _buildTextField(BuildContext context) { @@ -332,6 +373,9 @@ class MessageInputState extends State { _actionsShrunk = false; }); }, + visualDensity: VisualDensity.compact, + splashRadius: 24, + padding: const EdgeInsets.all(0), icon: StreamSvgIcon.emptyCircleLeft( color: StreamChatTheme.of(context).accentColor, ), @@ -356,12 +400,13 @@ class MessageInputState extends State { decoration: BoxDecoration( borderRadius: BorderRadius.circular(20.0), border: Border.all( - color: Colors.grey, + color: Colors.black.withOpacity(0.16), ), ), child: Column( mainAxisSize: MainAxisSize.min, children: [ + _buildRepyToMessage(), _buildAttachments(), LimitedBox( maxHeight: widget.maxHeight, @@ -436,6 +481,7 @@ class MessageInputState extends State { } Timer _debounce; + void _onChanged(BuildContext context, String s) { if (_debounce?.isActive == true) _debounce.cancel(); _debounce = Timer( @@ -799,7 +845,7 @@ class MessageInputState extends State { Widget _buildPickerSection() { var _attachmentContainsFile = - _attachments.any((element) => element.attachment.type == 'file'); + _attachments.any((element) => element.attachment?.type == 'file'); switch (_filePickerIndex) { case 0: @@ -1232,6 +1278,25 @@ class MessageInputState extends State { _commandsOverlay = null; } + Widget _buildRepyToMessage() { + if (!_hasReplyMessage) { + return Offstage(); + } + final containsUrl = _replyMessage.attachments + ?.any((element) => element.ogScrapeUrl != null) == + true; + return Transform( + transform: Matrix4.rotationY(pi), + alignment: Alignment.center, + child: ReplyMessageWidget( + reverse: true, + showBorder: !containsUrl, + message: _replyMessage, + messageTheme: StreamChatTheme.of(context).otherMessageTheme, + ), + ); + } + Widget _buildAttachments() { return _attachments.isEmpty ? Container() @@ -1437,57 +1502,68 @@ class MessageInputState extends State { } Widget _buildCommandButton() { - return InkWell( - child: Padding( - padding: - const EdgeInsets.only(left: 4.0, right: 8.0, top: 8.0, bottom: 8.0), - child: StreamSvgIcon.lightning( + return Padding( + padding: const EdgeInsets.all(8.0), + child: IconButton( + icon: StreamSvgIcon.lightning( color: Color(0xFF000000).withAlpha(128), ), + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + splashRadius: 24, + onPressed: () { + if (_commandsOverlay == null) { + _commandsOverlay = _buildCommandsOverlayEntry(); + Overlay.of(context).insert(_commandsOverlay); + } else { + _commandsOverlay?.remove(); + _commandsOverlay = null; + } + }, ), - onTap: () { - if (_commandsOverlay == null) { - _commandsOverlay = _buildCommandsOverlayEntry(); - Overlay.of(context).insert(_commandsOverlay); - } else { - _commandsOverlay?.remove(); - _commandsOverlay = null; - } - }, ); } Widget _buildAttachmentButton() { var padding = widget.editMessage == null ? 4.0 : 8.0; return Center( - child: InkWell( - child: Padding( - padding: - EdgeInsets.only(left: 8.0, right: padding, top: 8.0, bottom: 8.0), - child: StreamSvgIcon.attach( + child: Padding( + padding: + EdgeInsets.only(left: 8.0, right: padding, top: 8.0, bottom: 8.0), + child: IconButton( + icon: StreamSvgIcon.attach( color: _openFilePickerSection ? StreamChatTheme.of(context).accentColor : Color(0xFF000000).withAlpha(128), ), - ), - onTap: () async { - _emojiOverlay?.remove(); - _emojiOverlay = null; - _commandsOverlay?.remove(); - _commandsOverlay = null; - _mentionsOverlay?.remove(); - _mentionsOverlay = null; + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + splashRadius: 24, + onPressed: () async { + _emojiOverlay?.remove(); + _emojiOverlay = null; + _commandsOverlay?.remove(); + _commandsOverlay = null; + _mentionsOverlay?.remove(); + _mentionsOverlay = null; - if (_openFilePickerSection) { - setState(() { - _animateContainer = true; - _openFilePickerSection = false; - _filePickerSize = _kMinMediaPickerSize; - }); - } else { - showAttachmentModal(); - } - }, + if (_openFilePickerSection) { + setState(() { + _animateContainer = true; + _openFilePickerSection = false; + _filePickerSize = _kMinMediaPickerSize; + }); + } else { + showAttachmentModal(); + } + }, + ), ), ); } @@ -1786,15 +1862,11 @@ class MessageInputState extends State { return Padding( padding: const EdgeInsets.all(8.0), child: Center( - child: InkWell( - onTap: () { - sendMessage(); - }, child: StreamSvgIcon( assetName: _getIdleSendIcon(), color: Colors.grey, ), - )), + ), ); } @@ -1802,11 +1874,16 @@ class MessageInputState extends State { return Center( child: Padding( padding: const EdgeInsets.all(8.0), - child: InkWell( - onTap: () { - sendMessage(); - }, - child: StreamSvgIcon( + child: IconButton( + onPressed: sendMessage, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(0), + splashRadius: 24, + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + icon: StreamSvgIcon( assetName: _getSendIcon(), color: StreamChatTheme.of(context).accentColor, ), @@ -1918,6 +1995,8 @@ class MessageInputState extends State { void initState() { super.initState(); + _replyMessage = widget.replyMessage; + _focusNode = widget.focusNode ?? FocusNode(); _emojiNames = Emoji.all().map((e) => e.name); @@ -1970,6 +2049,7 @@ class MessageInputState extends State { } bool _initialized = false; + @override void didChangeDependencies() { if (widget.editMessage != null && !_initialized) { @@ -1978,6 +2058,14 @@ class MessageInputState extends State { } super.didChangeDependencies(); } + + @override + void didUpdateWidget(MessageInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.replyMessage?.id != _replyMessage?.id) { + _replyMessage = widget.replyMessage; + } + } } class _SendingAttachment { @@ -1994,12 +2082,6 @@ class _SendingAttachment { }); } -extension StringExtension on String { - String capitalize() { - return "${this[0].toUpperCase()}${this.substring(1)}"; - } -} - /// Represents a 2-tuple, or pair. class Tuple2 { /// Returns the first item of the tuple diff --git a/lib/src/message_list_view.dart b/lib/src/message_list_view.dart index 430e0b85..2881496e 100644 --- a/lib/src/message_list_view.dart +++ b/lib/src/message_list_view.dart @@ -13,6 +13,7 @@ import 'package:visibility_detector/visibility_detector.dart'; import '../stream_chat_flutter.dart'; import 'date_divider.dart'; import 'stream_channel.dart'; +import 'swipeable.dart'; typedef MessageBuilder = Widget Function( BuildContext, @@ -26,6 +27,9 @@ typedef ParentMessageBuilder = Widget Function( typedef ThreadBuilder = Widget Function(BuildContext context, Message parent); typedef ThreadTapCallback = void Function(Message, Widget); +typedef OnMessageSwiped = void Function(Message); +typedef ReplyTapCallback = void Function(Message); + class MessageDetails { /// True if the message belongs to the current user bool isMyMessage; @@ -106,12 +110,14 @@ class MessageListView extends StatefulWidget { this.parentMessage, this.threadBuilder, this.onThreadTap, + this.onReplyTap, this.dateDividerBuilder, this.scrollPhysics = const AlwaysScrollableScrollPhysics(), this.initialScrollIndex = 0, this.initialAlignment = 0, this.scrollController, this.itemPositionListener, + this.onMessageSwiped, }) : super(key: key); /// Function used to build a custom message widget @@ -153,6 +159,12 @@ class MessageListView extends StatefulWidget { /// The ScrollPhysics used by the ListView final ScrollPhysics scrollPhysics; + /// Called when message item gets swiped + final OnMessageSwiped onMessageSwiped; + + /// + final ReplyTapCallback onReplyTap; + @override _MessageListViewState createState() => _MessageListViewState(); } @@ -273,7 +285,7 @@ class _MessageListViewState extends State { if (widget.messageBuilder != null) { messageWidget = Builder( key: ValueKey('MESSAGE-${message.id}'), - builder: (_) => widget.messageBuilder( + builder: (context) => widget.messageBuilder( context, MessageDetails( context, @@ -540,6 +552,7 @@ class _MessageListViewState extends State { return MessageWidget( showReplyIndicator: false, + showThreadReplyIndicator: false, message: message, reverse: isMyMessage, showUsername: !isMyMessage, @@ -597,47 +610,54 @@ class _MessageListViewState extends State { final allRead = readList.length >= (channel.memberCount ?? 0) - 1; - return MessageWidget( - key: ValueKey('MESSAGE-${message.id}'), - message: message, - reverse: isMyMessage, - showReactions: !message.isDeleted, - padding: EdgeInsets.only( - left: 8.0, - right: 8.0, - bottom: index == 0 ? 30 : (isNextUser ? 5 : 10), + return Swipeable( + onSwipeEnd: () => widget.onMessageSwiped(message), + backgroundIcon: StreamSvgIcon.reply( + color: StreamChatTheme.of(context).accentColor, ), - showUsername: !isMyMessage && !isNextUser, - showSendingIndicator: isMyMessage && - (index == 0 || message.status != MessageSendingStatus.SENT) - ? DisplayWidget.show - : DisplayWidget.hide, - showTimestamp: !isNextUser || readList?.isNotEmpty == true, - showEditMessage: isMyMessage, - showDeleteMessage: isMyMessage, - borderSide: isMyMessage ? BorderSide.none : null, - onThreadTap: _onThreadTap, - attachmentBorderRadiusGeometry: BorderRadius.only( - topLeft: Radius.circular(16), - bottomLeft: Radius.circular(!isNextUser ? 0 : 16), - topRight: Radius.circular(16), - bottomRight: Radius.circular(16), + child: MessageWidget( + key: ValueKey('MESSAGE-${message.id}'), + message: message, + reverse: isMyMessage, + showReactions: !message.isDeleted, + padding: EdgeInsets.only( + left: 8.0, + right: 8.0, + bottom: index == 0 ? 30 : (isNextUser ? 5 : 10), + ), + showUsername: !isMyMessage && !isNextUser, + showSendingIndicator: isMyMessage && + (index == 0 || message.status != MessageSendingStatus.SENT) + ? DisplayWidget.show + : DisplayWidget.hide, + showTimestamp: !isNextUser || readList?.isNotEmpty == true, + showEditMessage: isMyMessage, + showDeleteMessage: isMyMessage, + borderSide: isMyMessage ? BorderSide.none : null, + onThreadTap: _onThreadTap, + onReplyTap: widget.onReplyTap, + attachmentBorderRadiusGeometry: BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(!isNextUser ? 0 : 16), + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + attachmentPadding: const EdgeInsets.all(2), + borderRadiusGeometry: BorderRadius.only( + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(!isNextUser ? 0 : 16), + topRight: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + showUserAvatar: isMyMessage + ? DisplayWidget.gone + : (isNextUser ? DisplayWidget.hide : DisplayWidget.show), + messageTheme: isMyMessage + ? StreamChatTheme.of(context).ownMessageTheme + : StreamChatTheme.of(context).otherMessageTheme, + readList: readList, + allRead: allRead, ), - attachmentPadding: const EdgeInsets.all(2), - borderRadiusGeometry: BorderRadius.only( - topLeft: Radius.circular(16), - bottomLeft: Radius.circular(!isNextUser ? 0 : 16), - topRight: Radius.circular(16), - bottomRight: Radius.circular(16), - ), - showUserAvatar: isMyMessage - ? DisplayWidget.gone - : (isNextUser ? DisplayWidget.hide : DisplayWidget.show), - messageTheme: isMyMessage - ? StreamChatTheme.of(context).ownMessageTheme - : StreamChatTheme.of(context).otherMessageTheme, - readList: readList, - allRead: allRead, ); } diff --git a/lib/src/message_reactions_modal.dart b/lib/src/message_reactions_modal.dart index db5d8710..e5043e62 100644 --- a/lib/src/message_reactions_modal.dart +++ b/lib/src/message_reactions_modal.dart @@ -105,7 +105,7 @@ class MessageReactionsModal extends StatelessWidget { showReactions: false, showUsername: false, showUserAvatar: showUserAvatar, - showReplyIndicator: false, + showThreadReplyIndicator: false, showTimestamp: false, translateUserAvatar: false, showSendingIndicator: DisplayWidget.gone, diff --git a/lib/src/message_widget.dart b/lib/src/message_widget.dart index e5fe146f..27fa4df4 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -11,11 +11,13 @@ 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/src/reply_message_widget.dart'; import 'package:stream_chat_flutter/src/url_attachment.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'image_group.dart'; import 'message_text.dart'; +import 'extension.dart'; typedef AttachmentBuilder = Widget Function(BuildContext, Message, Attachment); @@ -46,6 +48,7 @@ class MessageWidget extends StatefulWidget { /// The function called when tapping on replies final void Function(Message) onThreadTap; + final void Function(Message) onReplyTap; final Widget Function(BuildContext, Message) editMessageInputBuilder; final Widget Function(BuildContext, Message) textBuilder; @@ -55,6 +58,9 @@ class MessageWidget extends StatefulWidget { /// The message final Message message; + /// The replyMessage + final Message replyMessage; + /// The message theme final MessageTheme messageTheme; @@ -99,6 +105,9 @@ class MessageWidget extends StatefulWidget { final bool allRead; + /// If true the widget will show the thread reply indicator + final bool showThreadReplyIndicator; + /// If true the widget will show the reply indicator final bool showReplyIndicator; @@ -127,6 +136,7 @@ class MessageWidget extends StatefulWidget { Key key, @required this.message, @required this.messageTheme, + this.replyMessage, this.reverse = false, this.translateUserAvatar = true, this.shape, @@ -140,6 +150,8 @@ class MessageWidget extends StatefulWidget { this.showUserAvatar = DisplayWidget.show, this.showSendingIndicator = DisplayWidget.show, this.showReplyIndicator = true, + this.showThreadReplyIndicator = true, + this.onReplyTap, this.onThreadTap, this.showUsername = true, this.showTimestamp = true, @@ -213,6 +225,8 @@ class MessageWidget extends StatefulWidget { } class _MessageWidgetState extends State { + bool get _hasReplyMessage => widget.replyMessage != null; + @override Widget build(BuildContext context) { var leftPadding = widget.showUserAvatar != DisplayWidget.gone @@ -230,6 +244,9 @@ class _MessageWidgetState extends State { widget.message.attachments?.any((element) => element.type == 'file') == true; + final isMyMessage = + widget.message.user.id == StreamChat.of(context).user.id; + return Portal( child: Padding( padding: widget.padding ?? EdgeInsets.all(8), @@ -306,7 +323,8 @@ class _MessageWidgetState extends State { clipBehavior: Clip.antiAlias, shape: widget.shape ?? RoundedRectangleBorder( - side: isOnlyEmoji + side: isOnlyEmoji && + !_hasReplyMessage ? BorderSide.none : widget.borderSide ?? BorderSide( @@ -333,6 +351,19 @@ class _MessageWidgetState extends State { CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ + if (_hasReplyMessage) + ReplyMessageWidget( + message: + widget.replyMessage, + messageTheme: isMyMessage + ? StreamChatTheme.of( + context) + .otherMessageTheme + : StreamChatTheme.of( + context) + .ownMessageTheme, + reverse: widget.reverse, + ), ..._parseAttachments(context), if (widget.message.text .trim() @@ -367,7 +398,7 @@ class _MessageWidgetState extends State { ), ], ), - if (widget.showReplyIndicator && + if (widget.showThreadReplyIndicator && widget.message.replyCount > 0) _buildReplyIndicator(leftPadding), ], @@ -393,7 +424,7 @@ class _MessageWidgetState extends State { var splitList = host.split('.'); var hostName = splitList.length == 3 ? splitList[1] : splitList[0]; var hostDisplayName = urlAttachment.authorName?.capitalize() ?? - _getWebsiteName(hostName.toLowerCase()) ?? + getWebsiteName(hostName.toLowerCase()) ?? hostName.capitalize(); return UrlAttachment( @@ -562,14 +593,16 @@ class _MessageWidgetState extends State { showDeleteMessage: widget.showDeleteMessage, message: widget.message, editMessageInputBuilder: widget.editMessageInputBuilder, - onThreadTap: widget.onThreadTap, + onReplyTap: widget.onReplyTap, + onThreadReplyTap: widget.onThreadTap, showEditMessage: widget.showEditMessage && widget.message.attachments ?.any((element) => element.type == 'giphy') != true, showReactions: widget.showReactions, - showReply: - widget.showReplyIndicator && widget.onThreadTap != null, + showReply: widget.showReplyIndicator, + showThreadReply: + widget.showThreadReplyIndicator && widget.onThreadTap != null, ), ); }); @@ -878,6 +911,10 @@ class _MessageWidgetState extends State { final isOnlyEmoji = widget.message.text.characters.every((c) => Emoji.byChar(c) != null); + if (_hasReplyMessage) { + return widget.messageTheme.messageBackgroundColor; + } + if ((widget.message.status == MessageSendingStatus.FAILED || widget.message.status == MessageSendingStatus.FAILED_UPDATE || widget.message.status == MessageSendingStatus.FAILED_DELETE)) { @@ -918,45 +955,4 @@ class _MessageWidgetState extends State { return; } } - - String _getWebsiteName(String hostName) { - switch (hostName) { - case 'reddit': - return 'Reddit'; - case 'youtube': - return 'Youtube'; - case 'wikipedia': - return 'Wikipedia'; - case 'twitter': - return 'Twitter'; - case 'facebook': - return 'Facebook'; - case 'amazon': - return 'Amazon'; - case 'yelp': - return 'Yelp'; - case 'imdb': - return 'IMDB'; - case 'pinterest': - return 'Pinterest'; - case 'tripadvisor': - return 'TripAdvisor'; - case 'instagram': - return 'Instagram'; - case 'walmart': - return 'Walmart'; - case 'craigslist': - return 'Craigslist'; - case 'ebay': - return 'eBay'; - case 'linkedin': - return 'LinkedIn'; - case 'google': - return 'Google'; - case 'apple': - return 'Apple'; - default: - return null; - } - } } diff --git a/lib/src/reply_message_widget.dart b/lib/src/reply_message_widget.dart new file mode 100644 index 00000000..9ae6f23e --- /dev/null +++ b/lib/src/reply_message_widget.dart @@ -0,0 +1,264 @@ +import 'dart:math'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:emojis/emoji.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat/stream_chat.dart'; + +import 'attachment_error.dart'; +import 'image_attachment.dart'; +import 'message_text.dart'; +import 'stream_chat_theme.dart'; +import 'user_avatar.dart'; +import 'utils.dart'; + +/// +class ReplyMessageWidget extends StatelessWidget { + /// The message + final Message message; + + /// The message theme + final MessageTheme messageTheme; + + /// If true the widget will be mirrored + final bool reverse; + + /// If true the message will show a grey border + final bool showBorder; + + /// limit of the text message shown + final int textLimit; + + final Map _attachmentBuilders; + + /// + ReplyMessageWidget({ + Key key, + @required this.message, + @required this.messageTheme, + this.reverse = false, + this.showBorder = false, + this.textLimit = 170, + }) : _attachmentBuilders = { + 'image': (attachment) { + return ImageAttachment( + attachment: attachment, + message: message, + messageTheme: messageTheme, + size: Size(32, 32), + ); + }, + 'video': (attachment) { + final size = Size(32, 32); + if (attachment.thumbUrl != null) { + return Container( + height: size.height, + width: size.width, + decoration: BoxDecoration( + image: DecorationImage( + fit: BoxFit.cover, + image: CachedNetworkImageProvider( + attachment.thumbUrl, + ), + ), + ), + ); + } + return AttachmentError( + attachment: attachment, + size: size, + ); + }, + 'giphy': (attachment) { + final size = Size(32, 32); + return CachedNetworkImage( + height: size?.height, + width: size?.width, + placeholder: (_, __) { + return Container( + width: size?.width, + height: size?.height, + child: Center( + child: CircularProgressIndicator(), + ), + ); + }, + imageUrl: attachment.thumbUrl ?? + attachment.imageUrl ?? + attachment.assetUrl, + errorWidget: (context, url, error) => AttachmentError( + attachment: attachment, + size: size, + ), + fit: BoxFit.cover, + ); + }, + 'file': (attachment) { + return Container( + height: 32, + width: 32, + child: getFileTypeImage(attachment.extraData['mime_type']), + ); + }, + }, + super(key: key); + + bool get _hasAttachments => message.attachments?.isNotEmpty == true; + + bool get _containsScrapeUrl => + message.attachments?.any((element) => element.ogScrapeUrl != null) == + true; + + bool get _containsText => message?.text?.isNotEmpty == true; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8, bottom: 6, right: 4, left: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded(child: _buildMessage(context)), + SizedBox(width: 4), + _buildUserAvatar(), + ], + ), + ); + } + + Widget _buildMessage(BuildContext context) { + final children = [ + if (_hasAttachments) ...[ + _parseAttachments(context), + SizedBox(width: 8), + ], + Expanded(child: _buildTextMessage()), + ]; + return Container( + constraints: BoxConstraints( + minHeight: 48.0, + ), + decoration: BoxDecoration( + color: _getBackgroundColor(), + border: showBorder ? Border.all(color: Colors.black.withOpacity(0.08)) : null, + borderRadius: BorderRadius.only( + topRight: Radius.circular(12), + topLeft: Radius.circular(12), + bottomLeft: Radius.circular(12), + ), + ), + padding: const EdgeInsets.all(8), + child: Row( + mainAxisAlignment: + reverse ? MainAxisAlignment.end : MainAxisAlignment.start, + children: reverse ? children.reversed.toList() : children, + ), + ); + } + + Widget _buildTextMessage() { + final isOnlyEmoji = + message.text.characters.every((c) => Emoji.byChar(c) != null); + var msg = _hasAttachments && !_containsText + ? message.copyWith(text: message.attachments.last?.title ?? 'File') + : message; + if (msg.text.length > textLimit) { + msg = msg.copyWith(text: '${msg.text.substring(0, textLimit - 3)}...'); + } + return Transform( + transform: Matrix4.rotationY(reverse ? pi : 0), + alignment: Alignment.center, + child: MessageText( + message: msg, + messageTheme: isOnlyEmoji && _containsText + ? messageTheme.copyWith( + messageText: messageTheme.messageText.copyWith( + fontSize: 24, + )) + : messageTheme, + ), + ); + } + + Widget _buildUrlAttachment(Attachment attachment) { + final size = Size(32, 32); + if (attachment.thumbUrl != null) { + return Container( + height: size.height, + width: size.width, + decoration: BoxDecoration( + image: DecorationImage( + fit: BoxFit.cover, + image: CachedNetworkImageProvider( + attachment.imageUrl, + ), + ), + ), + ); + } + return AttachmentError( + attachment: attachment, + size: size, + ); + } + + Widget _parseAttachments(BuildContext context) { + Widget child; + Attachment attachment; + if (_containsScrapeUrl) { + attachment = message.attachments.firstWhere( + (element) => element.ogScrapeUrl != null, + ); + child = _buildUrlAttachment(attachment); + } else { + attachment = message.attachments.last; + final attachmentBuilder = _attachmentBuilders[attachment.type]; + if (attachmentBuilder == null) { + child = Offstage(); + } + child = attachmentBuilder(attachment); + } + return Material( + clipBehavior: Clip.hardEdge, + color: Colors.transparent, + shape: attachment.type == 'file' ? null : _getDefaultShape(context), + child: child, + ); + } + + ShapeBorder _getDefaultShape(BuildContext context) { + return RoundedRectangleBorder( + side: BorderSide( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.white.withAlpha(24) + : Colors.black.withAlpha(24), + ), + borderRadius: BorderRadius.circular(8), + ); + } + + Widget _buildUserAvatar() { + return Transform( + transform: Matrix4.rotationY(reverse ? pi : 0), + alignment: Alignment.center, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: UserAvatar( + user: message.user, + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + showOnlineStatus: false, + ), + ), + ); + } + + Color _getBackgroundColor() { + if (_containsScrapeUrl) { + return Color(0xFFE9F2FF); + } + return messageTheme.messageBackgroundColor; + } +} diff --git a/lib/src/stream_svg_icon.dart b/lib/src/stream_svg_icon.dart index 42fed46e..607f7208 100644 --- a/lib/src/stream_svg_icon.dart +++ b/lib/src/stream_svg_icon.dart @@ -326,6 +326,18 @@ class StreamSvgIcon extends StatelessWidget { ); } + factory StreamSvgIcon.reply({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'Icon_curve_line_left_up_big.svg', + color: color, + width: size, + height: size, + ); + } + factory StreamSvgIcon.edit({ double size, Color color, diff --git a/lib/src/swipeable.dart b/lib/src/swipeable.dart new file mode 100644 index 00000000..bdaf6993 --- /dev/null +++ b/lib/src/swipeable.dart @@ -0,0 +1,162 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +/// +class Swipeable extends StatefulWidget { + final Widget child; + final Widget backgroundIcon; + final VoidCallback onSwipeStart; + final VoidCallback onSwipeCancel; + final VoidCallback onSwipeEnd; + final double threshold; + + /// + const Swipeable({ + @required this.child, + @required this.backgroundIcon, + this.onSwipeStart, + this.onSwipeCancel, + this.onSwipeEnd, + this.threshold = 82.0, + }); + + @override + State createState() => _SwipeableState(); +} + +class _SwipeableState extends State with TickerProviderStateMixin { + double _dragExtent = 0.0; + AnimationController _moveController; + AnimationController _iconMoveController; + Animation _moveAnimation; + Animation _iconTransitionAnimation; + Animation _iconFadeAnimation; + bool _pastThreshold = false; + + final _animationDuration = const Duration(milliseconds: 200); + + @override + void initState() { + super.initState(); + _moveController = + AnimationController(duration: _animationDuration, vsync: this); + _iconMoveController = + AnimationController(duration: _animationDuration, vsync: this); + _moveAnimation = Tween(begin: Offset.zero, end: Offset(1.0, 0.0)) + .animate(_moveController); + _iconTransitionAnimation = + Tween(begin: Offset(-0.1, 0.0), end: Offset(0.4, 0.0)) + .animate(_moveController); + _iconFadeAnimation = + Tween(begin: 0.7, end: 1.0).animate(_iconMoveController); + + final controllerValue = 0.0; + _moveController.animateTo(controllerValue); + _iconMoveController.animateTo(controllerValue); + } + + @override + void dispose() { + _moveController.dispose(); + _iconMoveController.dispose(); + super.dispose(); + } + + void _handleDragStart(DragStartDetails details) { + if (widget.onSwipeStart != null) { + widget.onSwipeStart(); + } + } + + void _handleDragUpdate(DragUpdateDetails details) { + print(_moveAnimation.value.dx); + + final delta = details.primaryDelta; + if (delta.isNegative) return; + + _dragExtent += delta; + + var movePastThresholdPixels = widget.threshold; + var newPos = _dragExtent.abs() / context.size.width; + + if (_dragExtent.abs() > movePastThresholdPixels) { + // how many "thresholds" past the threshold we are. 1 = the threshold 2 + // = two thresholds. + var n = _dragExtent.abs() / movePastThresholdPixels; + + // Take the number of thresholds past the threshold, and reduce this + // number + var reducedThreshold = math.pow(n, 0.3); + + var adjustedPixelPos = movePastThresholdPixels * reducedThreshold; + newPos = adjustedPixelPos / context.size.width; + + if (_dragExtent > 0 && !_pastThreshold) { + _iconMoveController.value = 1; + _pastThreshold = true; + } + } else { + // Send a cancel event if the user has swiped back underneath the + // threshold + if (_pastThreshold && widget.onSwipeCancel != null) { + widget.onSwipeCancel(); + } + _pastThreshold = false; + } + if (!_pastThreshold || newPos < _moveController.value) { + _iconMoveController.value = newPos; + } + _moveController.value = newPos; + } + + void _handleDragEnd(DragEndDetails details) { + _moveController.animateTo(0.0, duration: _animationDuration); + _iconMoveController.animateTo(0.0, duration: _animationDuration); + _dragExtent = 0.0; + if (_pastThreshold && widget.onSwipeEnd != null) { + widget.onSwipeEnd(); + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + behavior: HitTestBehavior.opaque, + child: Stack( + alignment: Alignment.center, + fit: StackFit.passthrough, + children: [ + SlideTransition( + position: _iconTransitionAnimation, + child: Row( + children: [ + FadeTransition( + opacity: _iconFadeAnimation, + child: Container( + margin: const EdgeInsets.all(8), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: Colors.black.withOpacity(0.08), + ), + ), + child: widget.backgroundIcon, + ), + ), + ], + ), + ), + SlideTransition( + position: _moveAnimation, + child: widget.child, + ), + ], + ), + ); + } +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 20e3ce1f..aad972a4 100644 --- a/lib/src/utils.dart +++ b/lib/src/utils.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'stream_svg_icon.dart'; + Future launchURL(BuildContext context, String url) async { if (await canLaunch(url)) { await launch(url); @@ -47,3 +49,105 @@ Future showConfirmationDialog( /// Get random png with initials String getRandomPicUrl(User user) => 'https://getstream.io/random_png/?id=${user.id}&name=${user.name}'; + +/// Get websiteName from [hostName] +String getWebsiteName(String hostName) { + switch (hostName) { + case 'reddit': + return 'Reddit'; + case 'youtube': + return 'Youtube'; + case 'wikipedia': + return 'Wikipedia'; + case 'twitter': + return 'Twitter'; + case 'facebook': + return 'Facebook'; + case 'amazon': + return 'Amazon'; + case 'yelp': + return 'Yelp'; + case 'imdb': + return 'IMDB'; + case 'pinterest': + return 'Pinterest'; + case 'tripadvisor': + return 'TripAdvisor'; + case 'instagram': + return 'Instagram'; + case 'walmart': + return 'Walmart'; + case 'craigslist': + return 'Craigslist'; + case 'ebay': + return 'eBay'; + case 'linkedin': + return 'LinkedIn'; + case 'google': + return 'Google'; + case 'apple': + return 'Apple'; + default: + return null; + } +} + +/// +StreamSvgIcon getFileTypeImage(String type) { + switch (type) { + case '7z': + return StreamSvgIcon.filetype_7z(); + break; + case 'csv': + return StreamSvgIcon.filetype_csv(); + break; + case 'doc': + return StreamSvgIcon.filetype_doc(); + break; + case 'docx': + return StreamSvgIcon.filetype_docx(); + break; + case 'html': + return StreamSvgIcon.filetype_html(); + break; + case 'md': + return StreamSvgIcon.filetype_md(); + break; + case 'odt': + return StreamSvgIcon.filetype_odt(); + break; + case 'pdf': + return StreamSvgIcon.filetype_pdf(); + break; + case 'ppt': + return StreamSvgIcon.filetype_ppt(); + break; + case 'pptx': + return StreamSvgIcon.filetype_pptx(); + break; + case 'rar': + return StreamSvgIcon.filetype_rar(); + break; + case 'rtf': + return StreamSvgIcon.filetype_rtf(); + break; + case 'tar': + return StreamSvgIcon.filetype_tar(); + break; + case 'txt': + return StreamSvgIcon.filetype_txt(); + break; + case 'xls': + return StreamSvgIcon.filetype_xls(); + break; + case 'xlsx': + return StreamSvgIcon.filetype_xlsx(); + break; + case 'zip': + return StreamSvgIcon.filetype_zip(); + break; + default: + return StreamSvgIcon.filetype_Generic(); + break; + } +}