diff --git a/example/ios/Flutter/.last_build_id b/example/ios/Flutter/.last_build_id index e72273c3..f57e1e1f 100644 --- a/example/ios/Flutter/.last_build_id +++ b/example/ios/Flutter/.last_build_id @@ -1 +1 @@ -13a41b8138c4868054e44b5158f3bdd6 \ No newline at end of file +c770358a10272b4ee1cdc2eb432445f6 \ No newline at end of file diff --git a/example/ios/fastlane/report.xml b/example/ios/fastlane/report.xml index 17688944..d6fdf2d7 100644 --- a/example/ios/fastlane/report.xml +++ b/example/ios/fastlane/report.xml @@ -5,27 +5,39 @@ - + - + - + - + - + + + + + + + + + + + + + diff --git a/example/lib/main.dart b/example/lib/main.dart index b004271b..6df1dafb 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -444,7 +444,7 @@ class ChannelPageArgs { }); } -class ChannelPage extends StatelessWidget { +class ChannelPage extends StatefulWidget { final int initialScrollIndex; final double initialAlignment; final bool highlightInitialMessage; @@ -456,6 +456,31 @@ class ChannelPage extends StatelessWidget { this.highlightInitialMessage = false, }) : super(key: key); + @override + _ChannelPageState createState() => _ChannelPageState(); +} + +class _ChannelPageState extends State { + Message _quotedMessage; + FocusNode _focusNode; + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + void _reply(Message message) { + setState(() => _quotedMessage = message); + _focusNode.requestFocus(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -507,9 +532,11 @@ class ChannelPage extends StatelessWidget { child: Stack( children: [ MessageListView( - initialScrollIndex: initialScrollIndex, - initialAlignment: initialAlignment, - highlightInitialMessage: highlightInitialMessage, + initialScrollIndex: widget.initialScrollIndex, + initialAlignment: widget.initialAlignment, + highlightInitialMessage: widget.highlightInitialMessage, + onMessageSwiped: _reply, + onReplyTap: _reply, threadBuilder: (_, parentMessage) { return ThreadPage( parent: parentMessage, @@ -557,7 +584,13 @@ class ChannelPage extends StatelessWidget { ], ), ), - MessageInput(), + MessageInput( + focusNode: _focusNode, + quotedMessage: _quotedMessage, + onQuotedMessageCleared: () { + setState(() => _quotedMessage = null); + }, + ), ], ), ); diff --git a/example/pubspec.yaml b/example/pubspec.yaml index dbaf61b5..4a0a755a 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,6 +1,6 @@ name: example description: A new Flutter project. -version: 1.0.108+111 +version: 1.0.110+113 environment: sdk: ">=2.2.2 <3.0.0" diff --git a/lib/src/extension.dart b/lib/src/extension.dart new file mode 100644 index 00000000..f49c574f --- /dev/null +++ b/lib/src/extension.dart @@ -0,0 +1,14 @@ +extension StringExtension on String { + String capitalize() { + return "${this[0].toUpperCase()}${this.substring(1)}"; + } +} + +/// List extension +extension ListX on List { + /// Insert any item inBetween the list items + List insertBetween(T item) => expand((e) sync* { + yield item; + yield e; + }).skip(1).toList(growable: false); +} diff --git a/lib/src/file_attachment.dart b/lib/src/file_attachment.dart index 92139530..ca51bf76 100644 --- a/lib/src/file_attachment.dart +++ b/lib/src/file_attachment.dart @@ -97,7 +97,7 @@ class _FileAttachmentState extends State { height: 3.0, ), Text( - '${_getSizeText(widget.attachment.extraData['file_size'])}', + '${getSizeText(widget.attachment.extraData['file_size'])}', style: StreamChatTheme.of(context).textTheme.body.copyWith( color: StreamChatTheme.of(context) .colorTheme @@ -122,37 +122,6 @@ class _FileAttachmentState extends State { ), ], ), - - // ListTile( - // dense: true, - // leading: Container( - // child: _getFileTypeImage(attachment.extraData['mime_type']), - // height: 40.0, - // width: 33.33, - // ), - // title: Text( - // attachment?.title ?? 'File', - // style: TextStyle( - // fontWeight: FontWeight.bold, - // ), - // maxLines: 3, - // ), - // subtitle: Text( - // '${attachment.extraData['file_size'] ?? 'N/A'} bytes', - // style: TextStyle( - // color: StreamChatTheme.of(context).colorTheme.black.withOpacity(0.5), - // ), - // ), - // trailing: trailing ?? - // IconButton( - // icon: StreamSvgIcon.cloud_download( - // color: StreamChatTheme.of(context).colorTheme.black, - // ), - // onPressed: () { - // launchURL(context, attachment.assetUrl); - // }, - // ), - // ), ), ); } @@ -226,76 +195,6 @@ class _FileAttachmentState extends State { break; } } - - switch (widget.attachment.extraData['mime_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; - } - } - - String _getSizeText(int bytes) { - if (bytes == null) { - return 'Size N/A'; - } - - if (bytes <= 1000) { - return '${bytes} bytes'; - } else if (bytes <= 100000) { - return '${(bytes / 1000).toStringAsFixed(2)} KB'; - } else { - return '${(bytes / 1000000).toStringAsFixed(2)} MB'; - } + return getFileTypeImage(widget.attachment.extraData['mime_type']); } } diff --git a/lib/src/lazy_load_scroll_view.dart b/lib/src/lazy_load_scroll_view.dart index 2bc82a55..bc1f4d1b 100644 --- a/lib/src/lazy_load_scroll_view.dart +++ b/lib/src/lazy_load_scroll_view.dart @@ -31,7 +31,7 @@ class LazyLoadScrollView extends StatefulWidget { final bool isLoading; /// Initiates a LazyLoadScrollView widget - LazyLoadScrollView({ + const LazyLoadScrollView({ Key key, @required this.child, this.onStartOfPage, diff --git a/lib/src/media_list_view.dart b/lib/src/media_list_view.dart index 9c3af69f..249348c4 100644 --- a/lib/src/media_list_view.dart +++ b/lib/src/media_list_view.dart @@ -29,6 +29,7 @@ class MediaListView extends StatefulWidget { this.selectedIds = const [], this.onSelect, }) : super(key: key); + @override _MediaListViewState createState() => _MediaListViewState(); } @@ -41,9 +42,7 @@ class _MediaListViewState extends State { @override Widget build(BuildContext context) { return LazyLoadScrollView( - onEndOfPage: () async { - return _getMedia(); - }, + onEndOfPage: () async => _getMedia(), child: GridView.builder( itemCount: _media.length, controller: _scrollController, diff --git a/lib/src/message_actions_modal.dart b/lib/src/message_actions_modal.dart index 3bdce507..6294ef6a 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; @@ -24,6 +25,7 @@ class MessageActionsModal extends StatelessWidget { final bool showEditMessage; final bool showResendMessage; final bool showReply; + final bool showThreadReply; final bool reverse; final ShapeBorder messageShape; final DisplayWidget showUserAvatar; @@ -35,10 +37,12 @@ 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.showResendMessage = true, + this.showThreadReply = true, this.showUserAvatar = DisplayWidget.show, this.editMessageInputBuilder, this.messageShape, @@ -51,17 +55,25 @@ class MessageActionsModal extends StatelessWidget { final user = StreamChat.of(context).user; final roughMaxSize = 2 * size.width / 3; + var messageTextLength = message.text.length; + if (message.quotedMessage != null) { + var quotedMessageLength = message.quotedMessage.text.length + 40; + if (message.quotedMessage.attachments?.isNotEmpty == true) { + quotedMessageLength += 40; + } + if (quotedMessageLength > messageTextLength) { + messageTextLength = quotedMessageLength; + } + } final roughSentenceSize = - message.text.length * messageTheme.messageText.fontSize * 1.2; + messageTextLength * messageTheme.messageText.fontSize * 1.2; final divFactor = message.attachments?.isNotEmpty == true ? 1 : (roughSentenceSize == 0 ? 1 : (roughSentenceSize / roughMaxSize)); return GestureDetector( behavior: HitTestBehavior.translucent, - onTap: () { - Navigator.pop(context); - }, + onTap: () => Navigator.pop(context), child: Stack( children: [ Positioned.fill( @@ -116,6 +128,7 @@ class MessageActionsModal extends StatelessWidget { showReactions: false, showUsername: false, showThreadReplyIndicator: false, + showReplyIndicator: false, showUserAvatar: showUserAvatar, showTimestamp: false, translateUserAvatar: false, @@ -165,6 +178,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 (showResendMessage) _buildResendMessage(context), if (showEditMessage) @@ -190,6 +209,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) { final isDeleteFailed = message.status == MessageSendingStatus.FAILED_DELETE; return ListTile( @@ -276,8 +313,8 @@ class MessageActionsModal extends StatelessWidget { backgroundColor: StreamChatTheme.of(context).colorTheme.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.only( - topLeft: Radius.circular(32), - topRight: Radius.circular(32), + topLeft: Radius.circular(16), + topRight: Radius.circular(16), ), ), builder: (context) { @@ -289,39 +326,26 @@ class MessageActionsModal extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Padding( - padding: const EdgeInsets.only( - top: 16.0, - left: 16.0, - right: 16.0, - ), + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - IconButton( - icon: StreamSvgIcon.edit( - size: 22, - color: - StreamChatTheme.of(context).primaryIconTheme.color, + Padding( + padding: const EdgeInsets.all(8.0), + child: StreamSvgIcon.edit( + color: StreamChatTheme.of(context) + .colorTheme + .greyGainsboro, ), - onPressed: () {}, ), Text( - 'Edit message', - style: Theme.of(context) - .textTheme - .headline6 - .copyWith(fontWeight: FontWeight.bold), + 'Edit Message', + style: TextStyle(fontWeight: FontWeight.bold), ), IconButton( - icon: Icon( - Icons.cancel_outlined, - size: 22, - color: - StreamChatTheme.of(context).primaryIconTheme.color, - ), - onPressed: () { - Navigator.of(context).pop(); - }, + visualDensity: VisualDensity.compact, + icon: StreamSvgIcon.close_small(), + onPressed: Navigator.of(context).pop, ), ], ), @@ -348,7 +372,7 @@ class MessageActionsModal extends StatelessWidget { ); } - Widget _buildReplyButton(BuildContext context) { + Widget _buildThreadReplyButton(BuildContext context) { return ListTile( title: Text( 'Thread reply', @@ -359,8 +383,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 b7416fc5..52d91d45 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,8 +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 'extension.dart'; import '../stream_chat_flutter.dart'; +import 'quoted_message_widget.dart'; import 'stream_channel.dart'; typedef FileUploader = Future Function(PlatformFile, Channel); @@ -107,6 +110,8 @@ class MessageInput extends StatefulWidget { this.actionsLocation = ActionsLocation.left, this.attachmentThumbnailBuilders, this.focusNode, + this.quotedMessage, + this.onQuotedMessageCleared, }) : super(key: key); /// Message to edit @@ -155,6 +160,12 @@ class MessageInput extends StatefulWidget { /// The focus node associated to the TextField final FocusNode focusNode; + /// + final Message quotedMessage; + + /// + final VoidCallback onQuotedMessageCleared; + @override MessageInputState createState() => MessageInputState(); @@ -166,7 +177,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; @@ -196,9 +207,11 @@ class MessageInputState extends State { /// The editing controller passed to the input TextField TextEditingController textEditingController; + bool get _hasQuotedMessage => widget.quotedMessage != null; + @override Widget build(BuildContext context) { - return SafeArea( + Widget child = SafeArea( child: GestureDetector( onPanUpdate: (details) { if (details.delta.dy > 0) { @@ -213,6 +226,32 @@ class MessageInputState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ + if (_hasQuotedMessage) + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: StreamSvgIcon.reply( + color: StreamChatTheme.of(context) + .colorTheme + .greyGainsboro, + ), + ), + Text( + 'Reply to Message', + style: TextStyle(fontWeight: FontWeight.bold), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: StreamSvgIcon.close_small(), + onPressed: widget.onQuotedMessageCleared, + ), + ], + ), + ), Padding( padding: const EdgeInsets.all(8.0), child: _buildTextField(context), @@ -227,6 +266,14 @@ class MessageInputState extends State { ), ), ); + if (widget.editMessage == null) { + child = Material( + color: StreamChatTheme.of(context).colorTheme.white, + elevation: 8, + child: child, + ); + } + return child; } Flex _buildTextField(BuildContext context) { @@ -328,17 +375,23 @@ class MessageInputState extends State { return AnimatedCrossFade( crossFadeState: _actionsShrunk ? CrossFadeState.showFirst : CrossFadeState.showSecond, - firstChild: InkWell( - onTap: () { - setState(() { - _actionsShrunk = false; - }); - }, - child: Padding( - padding: const EdgeInsets.all(8.0) + EdgeInsets.only(bottom: 3.0), - child: StreamSvgIcon.emptyCircleLeft( + firstChild: Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + child: IconButton( + onPressed: () { + setState(() { + _actionsShrunk = false; + }); + }, + icon: StreamSvgIcon.emptyCircleLeft( color: StreamChatTheme.of(context).colorTheme.accentBlue, ), + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + splashRadius: 24, ), ), secondChild: Row( @@ -370,7 +423,9 @@ class MessageInputState extends State { padding: _attachments.isEmpty ? null : EdgeInsets.all(6.0), child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildReplyToMessage(), _buildAttachments(), LimitedBox( maxHeight: widget.maxHeight, @@ -416,7 +471,7 @@ class MessageInputState extends State { .accentBlue, padding: EdgeInsets.zero, labelPadding: - EdgeInsets.symmetric(horizontal: 9.0), + EdgeInsets.symmetric(horizontal: 8.0), label: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, @@ -428,7 +483,7 @@ class MessageInputState extends State { size: 16.0, ), Text( - _chosenCommand?.name?.toUpperCase() ?? "", + _chosenCommand?.name?.toUpperCase() ?? '', style: StreamChatTheme.of(context) .textTheme .footnote @@ -443,23 +498,14 @@ class MessageInputState extends State { ) : null, suffixIcon: _commandEnabled - ? InkWell( - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8.0), - child: StreamSvgIcon.close_small(), - ), - onTap: () { - setState(() { - _commandEnabled = false; - }); + ? IconButton( + icon: StreamSvgIcon.close_small(), + splashRadius: 24, + onPressed: () { + setState(() => _commandEnabled = false); }, ) : null, - suffixIconConstraints: BoxConstraints( - maxHeight: 24.0, - maxWidth: 40.0, - ), ), textCapitalization: TextCapitalization.sentences, ), @@ -1307,6 +1353,25 @@ class MessageInputState extends State { _commandsOverlay = null; } + Widget _buildReplyToMessage() { + if (!_hasQuotedMessage) { + return Offstage(); + } + final containsUrl = widget.quotedMessage.attachments + ?.any((element) => element.ogScrapeUrl != null) == + true; + return Transform( + transform: Matrix4.rotationY(pi), + alignment: Alignment.center, + child: QuotedMessageWidget( + reverse: true, + showBorder: !containsUrl, + message: widget.quotedMessage, + messageTheme: StreamChatTheme.of(context).otherMessageTheme, + ), + ); + } + Widget _buildAttachments() { return _attachments.isEmpty ? Container() @@ -1520,55 +1585,62 @@ 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.symmetric(vertical: 12, horizontal: 8), + child: IconButton( + icon: StreamSvgIcon.lightning( color: _commandsOverlay != null ? StreamChatTheme.of(context).colorTheme.accentBlue : StreamChatTheme.of(context).colorTheme.grey, ), - ), - onTap: () async { - if (_openFilePickerSection) { - setState(() { - _animateContainer = false; - _openFilePickerSection = false; - _filePickerSize = _kMinMediaPickerSize; - }); - await Future.delayed(Duration(milliseconds: 300)); - } + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + splashRadius: 24, + onPressed: () async { + if (_openFilePickerSection) { + setState(() { + _animateContainer = false; + _openFilePickerSection = false; + _filePickerSize = _kMinMediaPickerSize; + }); + await Future.delayed(Duration(milliseconds: 300)); + } - if (_commandsOverlay == null) { - setState(() { - _commandsOverlay = _buildCommandsOverlayEntry(); - Overlay.of(context).insert(_commandsOverlay); - }); - } else { - setState(() { - _commandsOverlay?.remove(); - _commandsOverlay = null; - }); - } - }, + if (_commandsOverlay == null) { + setState(() { + _commandsOverlay = _buildCommandsOverlayEntry(); + Overlay.of(context).insert(_commandsOverlay); + }); + } else { + setState(() { + _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( - color: _openFilePickerSection - ? StreamChatTheme.of(context).colorTheme.accentBlue - : StreamChatTheme.of(context).colorTheme.grey, - ), + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + child: IconButton( + icon: StreamSvgIcon.attach( + color: _openFilePickerSection + ? StreamChatTheme.of(context).colorTheme.accentBlue + : StreamChatTheme.of(context).colorTheme.grey, ), - onTap: () async { + padding: const EdgeInsets.all(0), + constraints: BoxConstraints.tightFor( + height: 24, + width: 24, + ), + splashRadius: 24, + onPressed: () async { _emojiOverlay?.remove(); _emojiOverlay = null; _commandsOverlay?.remove(); @@ -1886,36 +1958,29 @@ class MessageInputState extends State { Widget _buildIdleSendButton(BuildContext context) { return Padding( - padding: const EdgeInsets.all(8.0) + EdgeInsets.only(bottom: 3.0), - child: Center( - child: InkWell( - onTap: () { - sendMessage(); - }, - child: StreamSvgIcon( - assetName: _getIdleSendIcon(), - color: StreamChatTheme.of(context).colorTheme.greyGainsboro, - height: 24.0, - width: 24.0, - ), - )), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + child: StreamSvgIcon( + assetName: _getIdleSendIcon(), + color: StreamChatTheme.of(context).colorTheme.greyGainsboro, + ), ); } Widget _buildSendButton(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.all(8.0) + EdgeInsets.only(bottom: 3.0), - child: InkWell( - onTap: () { - sendMessage(); - }, - child: StreamSvgIcon( - assetName: _getSendIcon(), - color: StreamChatTheme.of(context).colorTheme.accentBlue, - height: 24.0, - width: 24.0, - ), + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + 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).colorTheme.accentBlue, ), ), ); @@ -1954,6 +2019,9 @@ class MessageInputState extends State { textEditingController.clear(); _attachments.clear(); + if (widget.onQuotedMessageCleared != null) { + widget.onQuotedMessageCleared(); + } setState(() { _messageIsPresent = false; @@ -1985,6 +2053,12 @@ class MessageInputState extends State { ); } + if (widget.quotedMessage != null) { + message = message.copyWith( + quotedMessageId: widget.quotedMessage.id, + ); + } + if (widget.preMessageSending != null) { message = await widget.preMessageSending(message); } @@ -2023,7 +2097,6 @@ class MessageInputState extends State { @override void initState() { super.initState(); - _focusNode = widget.focusNode ?? FocusNode(); _emojiNames = Emoji.all().map((e) => e.name); @@ -2101,12 +2174,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 1bcce91f..68a1633b 100644 --- a/lib/src/message_list_view.dart +++ b/lib/src/message_list_view.dart @@ -16,6 +16,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, @@ -29,6 +30,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; @@ -109,12 +113,14 @@ class MessageListView extends StatefulWidget { this.parentMessage, this.threadBuilder, this.onThreadTap, + this.onReplyTap, this.dateDividerBuilder, - this.scrollPhysics = const AlwaysScrollableScrollPhysics(), + this.scrollPhysics = const ClampingScrollPhysics(), this.initialScrollIndex, this.initialAlignment, this.scrollController, this.itemPositionListener, + this.onMessageSwiped, this.highlightInitialMessage = false, this.onShowMessage, }) : super(key: key); @@ -158,6 +164,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; + /// If true the list will highlight the initialMessage if there is any. /// /// Also See [StreamChannel] @@ -398,7 +410,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, @@ -690,6 +702,7 @@ class _MessageListViewState extends State { return MessageWidget( showThreadReplyIndicator: false, showInChannelIndicator: false, + showReplyIndicator: false, message: message, reverse: isMyMessage, showUsername: !isMyMessage, @@ -748,6 +761,9 @@ class _MessageListViewState extends State { final allRead = readList.length >= (channel.memberCount ?? 0) - 1; + final isThreadMessage = + widget.parentMessage != null || message?.showInChannel == true; + Widget child = MessageWidget( key: ValueKey('MESSAGE-${message.id}'), message: message, @@ -759,6 +775,26 @@ class _MessageListViewState extends State { bottom: index == 0 ? 30 : (isNextUser ? 2 : 7), top: 3, ), + onQuotedMessageTap: (quotedMessageId) async { + final scrollToIndex = () { + final index = messages.indexWhere((m) => m.id == quotedMessageId); + _scrollController?.scrollTo( + index: index, + duration: const Duration(milliseconds: 350), + ); + }; + if (messages.map((e) => e.id).contains(quotedMessageId)) { + scrollToIndex(); + } else { + streamChannel.loadChannelAtMessage(quotedMessageId).then((_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (messages.map((e) => e.id).contains(quotedMessageId)) { + scrollToIndex(); + } + }); + }); + } + }, showInChannelIndicator: widget.parentMessage == null, showThreadReplyIndicator: widget.parentMessage == null, showUsername: !isMyMessage && !isNextUser, @@ -771,6 +807,7 @@ class _MessageListViewState extends State { 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), @@ -795,6 +832,16 @@ class _MessageListViewState extends State { onShowMessage: widget.onShowMessage, ); + if (!isThreadMessage) { + child = Swipeable( + onSwipeEnd: () => widget.onMessageSwiped(message), + backgroundIcon: StreamSvgIcon.reply( + color: StreamChatTheme.of(context).colorTheme.accentBlue, + ), + child: child, + ); + } + if (!initialMessageHighlightComplete && widget.highlightInitialMessage && _isInitialMessage(message.id)) { diff --git a/lib/src/message_widget.dart b/lib/src/message_widget.dart index 30f737c1..bc6646c5 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -11,13 +11,16 @@ 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/quoted_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); +typedef OnQuotedMessageTap = void Function(String); /// The display behaviour of a widget enum DisplayWidget { @@ -46,6 +49,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; @@ -102,6 +106,9 @@ class MessageWidget extends StatefulWidget { /// 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; + /// If true the widget will show the show in channel indicator final bool showInChannelIndicator; @@ -131,6 +138,9 @@ class MessageWidget extends StatefulWidget { /// Center user avatar with bottom of the message final bool translateUserAvatar; + /// Function called when quotedMessage is tapped + final OnQuotedMessageTap onQuotedMessageTap; + /// MessageWidget({ Key key, @@ -150,6 +160,8 @@ class MessageWidget extends StatefulWidget { this.showSendingIndicator = DisplayWidget.show, this.showThreadReplyIndicator = true, this.showInChannelIndicator = true, + this.showReplyIndicator = true, + this.onReplyTap, this.onThreadTap, this.showUsername = true, this.showTimestamp = true, @@ -172,6 +184,7 @@ class MessageWidget extends StatefulWidget { ), this.attachmentPadding = EdgeInsets.zero, this.allRead = false, + this.onQuotedMessageTap, }) : attachmentBuilders = { 'image': (context, message, attachment) { return ImageAttachment( @@ -239,6 +252,8 @@ class _MessageWidgetState extends State { bool get showInChannel => widget.showInChannelIndicator && widget.message?.showInChannel == true; + bool get _hasQuotedMessage => widget.message?.quotedMessage != null; + bool get isSendFailed => widget.message.status == MessageSendingStatus.FAILED; bool get isUpdateFailed => @@ -261,185 +276,201 @@ class _MessageWidgetState extends State { ? widget.messageTheme.avatarTheme.constraints.maxWidth + 14.5 : 6.0; + final isGiphy = + widget.message.attachments?.any((element) => element.type == 'giphy') == + true; + + final isOnlyEmoji = + widget.message.text.characters.every((c) => Emoji.byChar(c) != null); + final hasFiles = widget.message.attachments?.any((element) => element.type == 'file') == true; - return Portal( - child: GestureDetector( - onLongPress: () => onLongPress(context), - 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: [ - Stack( - clipBehavior: Clip.none, - alignment: AlignmentDirectional.bottomStart, - 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: PortalEntry( - portal: Container( - transform: - Matrix4.translationValues(-16, 2, 0), - child: _buildReactionIndicator(context), - constraints: - BoxConstraints(maxWidth: 22 * 6.0), + return Material( + type: MaterialType.transparency, + child: Portal( + child: InkWell( + onLongPress: widget.message.isDeleted && !isFailedState + ? null + : () => onLongPress(context), + 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: [ + Stack( + clipBehavior: Clip.none, + alignment: AlignmentDirectional.bottomStart, + 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, ), - portalAnchor: Alignment(-1.0, -1.0), - childAnchor: Alignment(1, -1.0), - child: Stack( - clipBehavior: Clip.none, - children: [ - Padding( - padding: widget.showReactions - ? EdgeInsets.only( - top: widget - .message - .reactionCounts - ?.isNotEmpty == - true - ? 18 - : 0, - ) - : EdgeInsets.zero, - child: (widget.message.isDeleted && - !isFailedState) - ? Transform( - alignment: Alignment.center, - transform: Matrix4.rotationY( - widget.reverse ? pi : 0), - child: DeletedMessage( - reverse: widget.reverse, - borderRadiusGeometry: widget - .borderRadiusGeometry, - borderSide: widget.borderSide, - shape: widget.shape, - messageTheme: - widget.messageTheme, - ), - ) - : Material( - clipBehavior: Clip.antiAlias, - shape: widget.shape ?? - RoundedRectangleBorder( - side: isOnlyEmoji - ? BorderSide.none - : widget.borderSide ?? - BorderSide( - color: Theme.of(context) - .brightness == - Brightness - .dark - ? StreamChatTheme.of( - context) - .colorTheme - .white - .withAlpha( - 24) - : StreamChatTheme.of( - context) - .colorTheme - .black - .withAlpha( - 24), - ), - borderRadius: widget - .borderRadiusGeometry ?? - BorderRadius.zero, - ), - color: _getBackgroundColor(), - child: Padding( - padding: EdgeInsets.all( - hasFiles ? 2.0 : 0.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment - .start, - mainAxisSize: - MainAxisSize.min, - children: [ - ..._parseAttachments( - context), - if (widget.message.text - .trim() - .isNotEmpty && - !isGiphy) - _buildTextBubble( + Flexible( + child: PortalEntry( + portal: Container( + transform: + Matrix4.translationValues(-16, 2, 0), + child: _buildReactionIndicator(context), + constraints: + BoxConstraints(maxWidth: 22 * 6.0), + ), + portalAnchor: Alignment(-1.0, -1.0), + childAnchor: Alignment(1, -1.0), + child: Stack( + clipBehavior: Clip.none, + children: [ + Padding( + padding: widget.showReactions + ? EdgeInsets.only( + top: widget + .message + .reactionCounts + ?.isNotEmpty == + true + ? 18 + : 0, + ) + : EdgeInsets.zero, + child: (widget.message.isDeleted && + !isFailedState) + ? Transform( + alignment: Alignment.center, + transform: Matrix4.rotationY( + widget.reverse ? pi : 0), + child: DeletedMessage( + reverse: widget.reverse, + borderRadiusGeometry: widget + .borderRadiusGeometry, + borderSide: + widget.borderSide, + shape: widget.shape, + messageTheme: + widget.messageTheme, + ), + ) + : Card( + clipBehavior: Clip.antiAlias, + elevation: 0.0, + shape: widget.shape ?? + RoundedRectangleBorder( + side: isOnlyEmoji + ? BorderSide.none + : widget.borderSide ?? + BorderSide( + color: Theme.of(context) + .brightness == + Brightness + .dark + ? StreamChatTheme.of( + context) + .colorTheme + .white + .withAlpha( + 24) + : StreamChatTheme.of( + context) + .colorTheme + .black + .withAlpha( + 24), + ), + borderRadius: widget + .borderRadiusGeometry ?? + BorderRadius.zero, + ), + color: _getBackgroundColor(), + child: Padding( + padding: EdgeInsets.all( + hasFiles ? 2.0 : 0.0), + child: Column( + crossAxisAlignment: + CrossAxisAlignment + .start, + mainAxisSize: + MainAxisSize.min, + children: [ + if (_hasQuotedMessage) + _buildQuotedMessage(), + ..._parseAttachments( context), - ], + if (widget.message.text + .trim() + .isNotEmpty && + !isGiphy) + _buildTextBubble( + context), + ], + ), ), ), - ), - ), - if (widget.showReactionPickerIndicator) - Positioned( - right: 0, - top: -6, - child: Transform( - transform: Matrix4.rotationY( - widget.reverse ? pi : 0), - child: CustomPaint( - painter: ReactionBubblePainter( - widget.messageTheme - .reactionsBackgroundColor, - widget.messageTheme - .reactionsBorderColor, + ), + if (widget.showReactionPickerIndicator) + Positioned( + right: 0, + top: -6, + child: Transform( + transform: Matrix4.rotationY( + widget.reverse ? pi : 0), + child: CustomPaint( + painter: ReactionBubblePainter( + widget.messageTheme + .reactionsBackgroundColor, + widget.messageTheme + .reactionsBorderColor, + ), ), ), ), - ), - ], + ], + ), ), ), - ), - ], - ), - if (showBottomRow) SizedBox(height: 20.0), - ], - ), - if (showBottomRow) _buildBottomRow(leftPadding), - if (isFailedState) - Positioned( - left: widget.reverse ? -3 : null, - right: widget.reverse ? null : -9, - bottom: showBottomRow ? 20 : 0, - child: Container( - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, + ], ), - child: StreamSvgIcon.error(size: 20), - ), + if (showBottomRow) SizedBox(height: 20.0), + ], ), - ], - ), - ], + if (showBottomRow) _buildBottomRow(leftPadding), + if (isFailedState) + Positioned( + left: widget.reverse ? -3 : null, + right: widget.reverse ? null : -9, + bottom: showBottomRow ? 20 : 0, + child: Container( + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: StreamSvgIcon.error(size: 20), + ), + ), + ], + ), + ], + ), ), ), ), @@ -448,6 +479,23 @@ class _MessageWidgetState extends State { ); } + Widget _buildQuotedMessage() { + final isMyMessage = + widget.message.user.id == StreamChat.of(context).user.id; + return QuotedMessageWidget( + onTap: () { + if (widget.onQuotedMessageTap != null) { + widget.onQuotedMessageTap(widget.message.quotedMessageId); + } + }, + message: widget.message.quotedMessage, + messageTheme: isMyMessage + ? StreamChatTheme.of(context).otherMessageTheme + : StreamChatTheme.of(context).ownMessageTheme, + reverse: widget.reverse, + ); + } + Widget _buildBottomRow(double leftPadding) { final deleted = widget.message.isDeleted; var children = []; @@ -572,7 +620,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( @@ -637,7 +685,8 @@ class _MessageWidgetState extends State { showDeleteMessage: widget.showDeleteMessage, message: widget.message, editMessageInputBuilder: widget.editMessageInputBuilder, - onThreadTap: widget.onThreadTap, + onReplyTap: widget.onReplyTap, + onThreadReplyTap: widget.onThreadTap, showResendMessage: widget.showResendMessage && (isSendFailed || isUpdateFailed), showCopyMessage: !isFailedState && @@ -648,9 +697,11 @@ class _MessageWidgetState extends State { ?.any((element) => element.type == 'giphy') != true, showReactions: widget.showReactions, - showReply: widget.showThreadReplyIndicator && + showReply: widget.showReplyIndicator && !isFailedState && - widget.onThreadTap != null, + widget.onReplyTap != null, + showThreadReply: + widget.showThreadReplyIndicator && widget.onThreadTap != null, ), ); }); @@ -835,7 +886,7 @@ class _MessageWidgetState extends State { ); Widget _buildTextBubble(BuildContext context) { - Widget child = Transform( + return Transform( transform: Matrix4.rotationY(widget.reverse ? pi : 0), alignment: Alignment.center, child: Column( @@ -859,13 +910,13 @@ class _MessageWidgetState extends State { ), ), if (widget.message.attachments - ?.any((element) => element.ogScrapeUrl != null) == - true) + ?.any((element) => element.ogScrapeUrl != null) == + true && + !_hasQuotedMessage) _buildUrlAttachment(), ], ), ); - return child; } bool get isOnlyEmoji => @@ -873,6 +924,10 @@ class _MessageWidgetState extends State { widget.message.text.characters.every((c) => Emoji.byChar(c) != null); Color _getBackgroundColor() { + if (_hasQuotedMessage) { + return widget.messageTheme.messageBackgroundColor; + } + if (widget.message.attachments ?.any((element) => element.ogScrapeUrl != null) == true) { @@ -912,47 +967,6 @@ 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; - } - } } class _ThreadReplyPainter extends CustomPainter { diff --git a/lib/src/quoted_message_widget.dart b/lib/src/quoted_message_widget.dart new file mode 100644 index 00000000..7c5dd512 --- /dev/null +++ b/lib/src/quoted_message_widget.dart @@ -0,0 +1,321 @@ +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 'package:video_player/video_player.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'; +import 'extension.dart'; + +typedef QuotedMessageAttachmentThumbnailBuilder = Widget Function( + BuildContext, + Attachment, +); + +class _VideoAttachmentThumbnail extends StatefulWidget { + final Size size; + final Attachment attachment; + + const _VideoAttachmentThumbnail({ + Key key, + @required this.attachment, + this.size = const Size(32, 32), + }) : super(key: key); + + @override + _VideoAttachmentThumbnailState createState() => + _VideoAttachmentThumbnailState(); +} + +class _VideoAttachmentThumbnailState extends State<_VideoAttachmentThumbnail> { + VideoPlayerController _controller; + + @override + void initState() { + super.initState(); + _controller = VideoPlayerController.network(widget.attachment.assetUrl) + ..initialize().then((_) { + setState(() {}); //when your thumbnail will show. + }); + } + + @override + void dispose() { + super.dispose(); + _controller.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: widget.size.height, + width: widget.size.width, + child: _controller.value.initialized + ? VideoPlayer(_controller) + : CircularProgressIndicator()); + } +} + +/// +class QuotedMessageWidget 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; + + /// Map that defines a thumbnail builder for an attachment type + final Map + attachmentThumbnailBuilders; + + final GestureTapCallback onTap; + + /// + QuotedMessageWidget({ + Key key, + @required this.message, + @required this.messageTheme, + this.reverse = false, + this.showBorder = false, + this.textLimit = 170, + this.attachmentThumbnailBuilders, + this.onTap, + }) : 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 InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.only(top: 8, bottom: 6, right: 4, left: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: _buildMessage(context)), + SizedBox(width: 4), + _buildUserAvatar(), + ], + ), + ), + ); + } + + Widget _buildMessage(BuildContext context) { + final isOnlyEmoji = + message.text.characters.every((c) => Emoji.byChar(c) != null); + var msg = _hasAttachments && !_containsText + ? message.copyWith(text: message.attachments.last?.title ?? '') + : message; + if (msg.text.length > textLimit) { + msg = msg.copyWith(text: '${msg.text.substring(0, textLimit - 3)}...'); + } + + final children = [ + if (_hasAttachments) _parseAttachments(context), + if (msg.text.isNotEmpty) + Flexible( + child: 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, + ), + ), + ), + ].insertBetween(const SizedBox(width: 8)); + + return Container( + decoration: BoxDecoration( + color: _getBackgroundColor(context), + border: showBorder + ? Border.all( + color: StreamChatTheme.of(context).colorTheme.greyGainsboro, + ) + : null, + borderRadius: BorderRadius.only( + topRight: Radius.circular(12), + topLeft: Radius.circular(12), + bottomLeft: Radius.circular(12), + ), + ), + padding: const EdgeInsets.all(8), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: + reverse ? MainAxisAlignment.end : MainAxisAlignment.start, + children: reverse ? children.reversed.toList() : children, + ), + ); + } + + 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 { + QuotedMessageAttachmentThumbnailBuilder attachmentBuilder; + attachment = message.attachments.last; + if (attachmentThumbnailBuilders?.containsKey(attachment?.type) == true) { + attachmentBuilder = attachmentThumbnailBuilders[attachment?.type]; + } + attachmentBuilder = _defaultAttachmentBuilder[attachment?.type]; + if (attachmentBuilder == null) { + child = Offstage(); + } + child = attachmentBuilder(context, attachment); + } + child = AbsorbPointer(child: child); + return Transform( + transform: Matrix4.rotationY(reverse ? pi : 0), + alignment: Alignment.center, + child: 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: StreamChatTheme.of(context).colorTheme.greyWhisper, + ), + 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, + ), + ), + ); + } + + Map + get _defaultAttachmentBuilder { + return { + 'image': (_, attachment) { + return ImageAttachment( + attachment: attachment, + message: message, + messageTheme: messageTheme, + size: Size(32, 32), + ); + }, + 'video': (_, attachment) { + return _VideoAttachmentThumbnail( + key: ValueKey(attachment.assetUrl), + attachment: attachment, + ); + }, + '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']), + ); + }, + }; + } + + Color _getBackgroundColor(BuildContext context) { + if (_containsScrapeUrl) { + return StreamChatTheme.of(context).colorTheme.blueAlice; + } + return messageTheme.messageBackgroundColor; + } +} diff --git a/lib/src/stream_svg_icon.dart b/lib/src/stream_svg_icon.dart index a8c66957..e7ef46c3 100644 --- a/lib/src/stream_svg_icon.dart +++ b/lib/src/stream_svg_icon.dart @@ -10,9 +10,9 @@ class StreamSvgIcon extends StatelessWidget { const StreamSvgIcon({ this.assetName, - this.width, - this.height, this.color, + this.width = 24, + this.height = 24, }); @override @@ -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..8280bf24 --- /dev/null +++ b/lib/src/swipeable.dart @@ -0,0 +1,163 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'stream_chat_theme.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) { + final delta = details.primaryDelta; + _dragExtent += delta; + + if (_dragExtent.isNegative) return; + + 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( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: StreamChatTheme.of(context) + .colorTheme + .greyGainsboro, + ), + ), + child: widget.backgroundIcon, + ), + ), + ], + ), + ), + SlideTransition( + position: _moveAnimation, + child: widget.child, + ), + ], + ), + ); + } +} diff --git a/lib/src/utils.dart b/lib/src/utils.dart index 32681d6e..f573b77b 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'; + import '../stream_chat_flutter.dart'; Future launchURL(BuildContext context, String url) async { @@ -97,11 +99,119 @@ Future showConfirmationDialog( String getRandomPicUrl(User user) => 'https://getstream.io/random_png/?id=${user.id}&name=${user.name}'; -/// List extension -extension ListX on List { - /// Insert any item inBetween the list items - List insertBetween(T item) => expand((e) sync* { - yield item; - yield e; - }).skip(1).toList(growable: false); +/// 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; + } +} + +/// +String getSizeText(int bytes) { + if (bytes == null) { + return 'Size N/A'; + } + + if (bytes <= 1000) { + return '${bytes} bytes'; + } else if (bytes <= 100000) { + return '${(bytes / 1000).toStringAsFixed(2)} KB'; + } else { + return '${(bytes / 1000000).toStringAsFixed(2)} MB'; + } +} + +/// +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/pubspec.yaml b/pubspec.yaml index db646293..9fa13141 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -28,7 +28,7 @@ dependencies: file_picker: ^2.0.12 image_picker: ^0.6.7+2 flutter_keyboard_visibility: ^3.3.0 - stream_chat: ^0.2.21+2 + stream_chat: ^0.2.22 mime: ^0.9.6+3 video_compress: ^2.1.1 visibility_detector: ^0.1.5 diff --git a/test/src/message_action_modal_test.dart b/test/src/message_action_modal_test.dart index 5b0c1678..0dcd8d87 100644 --- a/test/src/message_action_modal_test.dart +++ b/test/src/message_action_modal_test.dart @@ -71,6 +71,7 @@ void main() { showCopyMessage: false, showDeleteMessage: false, showReply: false, + showThreadReply: false, message: Message( text: 'test', user: User( @@ -87,6 +88,7 @@ void main() { await tester.pump(Duration(milliseconds: 1000)); expect(find.byKey(Key('MessageWidget')), findsOneWidget); + expect(find.text('Reply'), findsNothing); expect(find.text('Thread reply'), findsNothing); expect(find.text('Edit message'), findsNothing); expect(find.text('Delete message'), findsNothing); diff --git a/test/src/message_reaction_modal_test.dart b/test/src/message_reaction_modal_test.dart index d1c2a49c..c023b20d 100644 --- a/test/src/message_reaction_modal_test.dart +++ b/test/src/message_reaction_modal_test.dart @@ -74,7 +74,7 @@ void main() { ), latestReactions: [ Reaction( - type: 'thumbs_up', + type: 'like', user: User(id: testUserId), ), Reaction( diff --git a/test/src/reaction_bubble_test.dart b/test/src/reaction_bubble_test.dart index 6518dc94..c0bc8cd1 100644 --- a/test/src/reaction_bubble_test.dart +++ b/test/src/reaction_bubble_test.dart @@ -31,7 +31,7 @@ void main() { ); testWidgets( - 'it should show a thumb up', + 'it should show a like', (WidgetTester tester) async { final client = MockClient(); final clientState = MockClientState(); @@ -50,7 +50,7 @@ void main() { child: ReactionBubble( reactions: [ Reaction( - type: 'thumbs_up', + type: 'like', user: User(id: 'test'), ), ], @@ -86,7 +86,7 @@ void main() { child: ReactionBubble( reactions: [ Reaction( - type: 'thumbs_up', + type: 'like', user: User(id: 'test'), ), Reaction(