diff --git a/example/lib/main.dart b/example/lib/main.dart index ac895cfa..50a82270 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -67,7 +67,6 @@ void main() async { final client = Client( 's2dxdhpxd94g', logLevel: Level.INFO, - persistenceEnabled: false, showLocalNotification: Platform.isAndroid ? showLocalNotification : null, ); diff --git a/lib/src/message_input.dart b/lib/src/message_input.dart index bb60d891..e4de6915 100644 --- a/lib/src/message_input.dart +++ b/lib/src/message_input.dart @@ -17,6 +17,21 @@ import '../stream_chat_flutter.dart'; import 'stream_channel.dart'; typedef FileUploader = Future Function(File, Channel); +typedef AttachmentThumbnailBuilder = Widget Function( + BuildContext, + _SendingAttachment, +); + +enum ActionsLocation { + left, + right, +} + +enum DefaultAttachmentTypes { + image, + video, + file, +} /// Inactive state /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input.png) @@ -65,6 +80,7 @@ class MessageInput extends StatefulWidget { MessageInput({ Key key, this.onMessageSent, + this.preMessageSending, this.parentMessage, this.editMessage, this.maxHeight = 150, @@ -73,6 +89,10 @@ class MessageInput extends StatefulWidget { this.doImageUploadRequest, this.doFileUploadRequest, this.initialMessage, + this.textEditingController, + this.actions, + this.actionsLocation = ActionsLocation.left, + this.attachmentThumbnailBuilders, }) : super(key: key); /// Message to edit @@ -84,6 +104,10 @@ class MessageInput extends StatefulWidget { /// Function called after sending the message final void Function(Message) onMessageSent; + /// Function called right before sending the message + /// Use this to transform the message + final FutureOr Function(Message) preMessageSending; + /// Parent message in case of a thread final Message parentMessage; @@ -102,33 +126,48 @@ class MessageInput extends StatefulWidget { /// Override file upload request final FileUploader doFileUploadRequest; + /// The text controller of the TextField + final TextEditingController textEditingController; + + /// List of action widgets + final List actions; + + /// The location of the custom actions + final ActionsLocation actionsLocation; + + /// Map that defines a thumbnail builder for an attachment type + final Map attachmentThumbnailBuilders; + @override - _MessageInputState createState() => _MessageInputState( - doFileUploadRequest: doFileUploadRequest, - doImageUploadRequest: doImageUploadRequest, - ); + MessageInputState createState() => MessageInputState(); + + /// Use this method to get the current [StreamChatState] instance + static MessageInputState of(BuildContext context) { + MessageInputState messageInputState; + + messageInputState = context.findAncestorStateOfType(); + + if (messageInputState == null) { + throw Exception( + 'You must have a MessageInput widget as anchestor of your widget tree'); + } + + return messageInputState; + } } -class _MessageInputState extends State { +class MessageInputState extends State { final List<_SendingAttachment> _attachments = []; final _focusNode = FocusNode(); final List _mentionedUsers = []; - FileUploader doImageUploadRequest; - FileUploader doFileUploadRequest; - TextEditingController _textController; bool _inputEnabled = true; bool _messageIsPresent = false; bool _typingStarted = false; OverlayEntry _commandsOverlay, _mentionsOverlay; - _MessageInputState({ - this.doImageUploadRequest, - this.doFileUploadRequest, - }) { - doImageUploadRequest ??= _uploadImage; - doFileUploadRequest ??= _uploadFile; - } + /// The editing controller passed to the input TextField + TextEditingController textEditingController; @override Widget build(BuildContext context) { @@ -165,8 +204,12 @@ class _MessageInputState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!widget.disableAttachments) _buildAttachmentButton(), + if (widget.actionsLocation == ActionsLocation.left) + ...widget.actions ?? [], _buildTextInput(context), _animateSendButton(context), + if (widget.actionsLocation == ActionsLocation.right) + ...widget.actions ?? [], ], ); } @@ -194,10 +237,10 @@ class _MessageInputState extends State { minLines: null, maxLines: null, onSubmitted: (_) { - _sendMessage(context); + sendMessage(); }, keyboardType: widget.keyboardType, - controller: _textController, + controller: textEditingController, focusNode: _focusNode, onChanged: (s) { StreamChannel.of(context).channel.keyStroke(); @@ -216,10 +259,10 @@ class _MessageInputState extends State { Overlay.of(context).insert(_commandsOverlay); } - if (_textController.selection.isCollapsed && - (s[_textController.selection.start - 1] == '@' || - _textController.text - .substring(0, _textController.selection.start) + if (textEditingController.selection.isCollapsed && + (s[textEditingController.selection.start - 1] == '@' || + textEditingController.text + .substring(0, textEditingController.selection.start) .split(' ') .last .contains('@'))) { @@ -279,7 +322,7 @@ class _MessageInputState extends State { } OverlayEntry _buildCommandsOverlayEntry() { - final text = _textController.text; + final text = textEditingController.text; final commands = StreamChannel.of(context) .channel .config @@ -344,8 +387,8 @@ class _MessageInputState extends State { } OverlayEntry _buildMentionsOverlayEntry() { - final splits = _textController.text - .substring(0, _textController.value.selection.start) + final splits = textEditingController.text + .substring(0, textEditingController.value.selection.start) .split('@'); final query = splits.last.toLowerCase(); @@ -391,10 +434,10 @@ class _MessageInputState extends State { splits[splits.length - 1] = m.user.name; final rejoin = splits.join('@'); - _textController.value = TextEditingValue( + textEditingController.value = TextEditingValue( text: rejoin + - _textController.text - .substring(_textController.selection.start), + textEditingController.text.substring( + textEditingController.selection.start), selection: TextSelection.collapsed( offset: rejoin.length, ), @@ -413,7 +456,7 @@ class _MessageInputState extends State { } void _setCommand(Command c) { - _textController.value = TextEditingValue( + textEditingController.value = TextEditingValue( text: '/${c.name} ', selection: TextSelection.collapsed( offset: c.name.length + 2, @@ -452,34 +495,7 @@ class _MessageInputState extends State { width: 50, child: _buildAttachment(attachment), ), - Positioned( - height: 16, - width: 16, - top: 4, - right: 4, - child: RawMaterialButton( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - elevation: 0, - highlightElevation: 0, - focusElevation: 0, - disabledElevation: 0, - hoverElevation: 0, - onPressed: () { - setState(() { - _attachments.remove(attachment); - }); - }, - fillColor: Colors.white.withOpacity(.5), - child: Center( - child: Icon( - Icons.close, - size: 15, - ), - ), - ), - ), + _buildRemoveButton(attachment), attachment.uploaded ? SizedBox() : Positioned.fill( @@ -499,20 +515,62 @@ class _MessageInputState extends State { ); } + Positioned _buildRemoveButton(_SendingAttachment attachment) { + return Positioned( + height: 16, + width: 16, + top: 4, + right: 4, + child: RawMaterialButton( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + disabledElevation: 0, + hoverElevation: 0, + onPressed: () { + setState(() { + _attachments.remove(attachment); + }); + }, + fillColor: Colors.white.withOpacity(.5), + child: Center( + child: Icon( + Icons.close, + size: 15, + ), + ), + ), + ); + } + Widget _buildAttachment(_SendingAttachment attachment) { - switch (attachment.type) { - case FileType.image: + if (widget.attachmentThumbnailBuilders + ?.containsKey(attachment.attachment.type) == + true) { + return widget.attachmentThumbnailBuilders[attachment.attachment.type]( + context, + attachment, + ); + } + + switch (attachment.attachment.type) { + case 'image': + case 'giphy': return attachment.file != null ? Image.file( attachment.file, fit: BoxFit.cover, ) : Image.network( - attachment.url, + attachment.attachment.imageUrl ?? + attachment.attachment.thumbUrl, fit: BoxFit.cover, ); break; - case FileType.video: + case 'video': return Container( child: Icon(Icons.videocam), color: Colors.black26, @@ -535,7 +593,7 @@ class _MessageInputState extends State { color: Colors.transparent, child: IconButton( onPressed: () { - _showAttachmentModal(); + showAttachmentModal(); }, icon: Icon( Icons.add_circle_outline, @@ -544,7 +602,8 @@ class _MessageInputState extends State { ); } - void _showAttachmentModal() { + /// Show the attachment modal, making the user choose where to pick a media from + void showAttachmentModal() { if (_focusNode.hasFocus) { _focusNode.unfocus(); } @@ -575,7 +634,7 @@ class _MessageInputState extends State { leading: Icon(Icons.image), title: Text('Upload a photo'), onTap: () { - _pickFile(FileType.image, false); + pickFile(DefaultAttachmentTypes.image, false); Navigator.pop(context); }, ), @@ -583,7 +642,7 @@ class _MessageInputState extends State { leading: Icon(Icons.video_library), title: Text('Upload a video'), onTap: () { - _pickFile(FileType.video, false); + pickFile(DefaultAttachmentTypes.video, false); Navigator.pop(context); }, ), @@ -591,7 +650,7 @@ class _MessageInputState extends State { leading: Icon(Icons.camera_alt), title: Text('Photo from camera'), onTap: () { - _pickFile(FileType.image, true); + pickFile(DefaultAttachmentTypes.image, true); Navigator.pop(context); }, ), @@ -599,7 +658,7 @@ class _MessageInputState extends State { leading: Icon(Icons.videocam), title: Text('Video from camera'), onTap: () { - _pickFile(FileType.video, true); + pickFile(DefaultAttachmentTypes.video, true); Navigator.pop(context); }, ), @@ -607,7 +666,7 @@ class _MessageInputState extends State { leading: Icon(Icons.insert_drive_file), title: Text('Upload a file'), onTap: () { - _pickFile(FileType.any, false); + pickFile(DefaultAttachmentTypes.file, false); Navigator.pop(context); }, ), @@ -616,20 +675,50 @@ class _MessageInputState extends State { }); } - void _pickFile(FileType type, bool camera) async { + /// Add an attachment to the sending message + /// Use this to add custom type attachments + void addAttachment(Attachment attachment) { + setState(() { + _attachments.add(_SendingAttachment( + attachment: attachment, + uploaded: true, + )); + }); + } + + /// Pick a file from the device + /// If [camera] is true then the camera will open + void pickFile(DefaultAttachmentTypes fileType, [bool camera = false]) async { setState(() { _inputEnabled = false; }); File file; + String attachmentType; + + if (fileType == DefaultAttachmentTypes.image) { + attachmentType = 'image'; + } else if (fileType == DefaultAttachmentTypes.video) { + attachmentType = 'video'; + } else if (fileType == DefaultAttachmentTypes.file) { + attachmentType = 'file'; + } if (camera) { - if (type == FileType.image) { + if (fileType == DefaultAttachmentTypes.image) { file = await ImagePicker.pickImage(source: ImageSource.camera); - } else if (type == FileType.video) { + } else if (fileType == DefaultAttachmentTypes.video) { file = await ImagePicker.pickVideo(source: ImageSource.camera); } } else { + FileType type; + if (fileType == DefaultAttachmentTypes.image) { + type = FileType.image; + } else if (fileType == DefaultAttachmentTypes.video) { + type = FileType.video; + } else if (fileType == DefaultAttachmentTypes.file) { + type = FileType.any; + } file = await FilePicker.getFile(type: type); } @@ -645,16 +734,27 @@ class _MessageInputState extends State { final attachment = _SendingAttachment( file: file, - type: type, + attachment: Attachment( + localUri: file.uri, + type: attachmentType, + ), ); setState(() { _attachments.add(attachment); }); - final url = await _uploadAttachment(file, type, channel); + final url = await _uploadAttachment(file, fileType, channel); - attachment.url = url; + if (fileType == DefaultAttachmentTypes.image) { + attachment.attachment = attachment.attachment.copyWith( + imageUrl: url, + ); + } else { + attachment.attachment = attachment.attachment.copyWith( + assetUrl: url, + ); + } setState(() { attachment.uploaded = true; @@ -663,14 +763,22 @@ class _MessageInputState extends State { Future _uploadAttachment( File file, - FileType type, + DefaultAttachmentTypes type, Channel channel, ) async { String url; - if (type == FileType.image) { - url = await doImageUploadRequest(file, channel); + if (type == DefaultAttachmentTypes.image) { + if (widget.doImageUploadRequest != null) { + url = await widget.doImageUploadRequest(file, channel); + } else { + url = await _uploadImage(file, channel); + } } else { - url = await doFileUploadRequest(file, channel); + if (widget.doFileUploadRequest != null) { + url = await widget.doFileUploadRequest(file, channel); + } else { + url = await _uploadFile(file, channel); + } } return url; } @@ -714,7 +822,7 @@ class _MessageInputState extends State { child: IconButton( key: Key('sendButton'), onPressed: () { - _sendMessage(context); + sendMessage(); }, icon: Icon( Icons.send, @@ -725,15 +833,16 @@ class _MessageInputState extends State { ); } - void _sendMessage(BuildContext context) { - final text = _textController.text.trim(); + /// Sends the current message + void sendMessage() async { + final text = textEditingController.text.trim(); if (text.isEmpty && _attachments.isEmpty) { return; } final attachments = List<_SendingAttachment>.from(_attachments); - _textController.clear(); + textEditingController.clear(); _attachments.clear(); setState(() { @@ -754,22 +863,10 @@ class _MessageInputState extends State { message = widget.editMessage.copyWith( parentId: widget.parentMessage?.id, text: text, - attachments: widget.editMessage.attachments - .where((attachment) => attachment.type == 'giphy') - .toList() + - _getAttachments(attachments).toList(), + attachments: _getAttachments(attachments).toList(), mentionedUsers: _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), ); - - if (widget.editMessage.status == MessageSendingStatus.FAILED) { - sendingFuture = channel.sendMessage(message); - } - - sendingFuture = StreamChat.of(context).client.updateMessage( - message, - channel.cid, - ); } else { message = (widget.initialMessage ?? Message()).copyWith( parentId: widget.parentMessage?.id, @@ -778,35 +875,36 @@ class _MessageInputState extends State { mentionedUsers: _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), ); - sendingFuture = channel.sendMessage(message); } - sendingFuture.whenComplete(() { + if (widget.preMessageSending != null) { + message = await widget.preMessageSending(message); + } + + if (widget.editMessage == null || + widget.editMessage.status == MessageSendingStatus.FAILED) { + sendingFuture = channel.sendMessage(message); + } else { + sendingFuture = StreamChat.of(context).client.updateMessage( + message, + channel.cid, + ); + } + + return sendingFuture.whenComplete(() { if (widget.onMessageSent != null) { widget.onMessageSent(message); + } else { + if (widget.editMessage != null) { + Navigator.pop(context); + } } }); } Iterable _getAttachments(List<_SendingAttachment> attachments) { return attachments.map((attachment) { - String type; - switch (attachment.type) { - case FileType.image: - type = 'image'; - break; - case FileType.video: - type = 'video'; - break; - default: - type = 'file'; - } - return Attachment( - imageUrl: attachment.type == FileType.image ? attachment.url : null, - assetUrl: attachment.url, - type: type, - localUri: attachment.file.uri, - ); + return attachment.attachment; }); } @@ -821,7 +919,7 @@ class _MessageInputState extends State { _keyboardListener = KeyboardVisibility.onChange.listen((visible) { if (visible) { if (_commandsOverlay != null) { - if (_textController.text.startsWith('/')) { + if (textEditingController.text.startsWith('/')) { WidgetsBinding.instance.addPostFrameCallback((_) { _commandsOverlay = _buildCommandsOverlayEntry(); Overlay.of(context).insert(_commandsOverlay); @@ -830,7 +928,7 @@ class _MessageInputState extends State { } if (_mentionsOverlay != null) { - if (_textController.text.contains('@')) { + if (textEditingController.text.contains('@')) { WidgetsBinding.instance.addPostFrameCallback((_) { _mentionsOverlay = _buildCommandsOverlayEntry(); Overlay.of(context).insert(_mentionsOverlay); @@ -847,44 +945,24 @@ class _MessageInputState extends State { } }); - if (widget.editMessage != null) { - _parseExistingMessage(widget.editMessage); - } else if (widget.initialMessage != null) { - _parseExistingMessage(widget.initialMessage); - } else { - _textController = TextEditingController(); + textEditingController = + widget.textEditingController ?? TextEditingController(); + if (widget.editMessage != null || widget.initialMessage != null) { + _parseExistingMessage(widget.editMessage ?? widget.initialMessage); } } void _parseExistingMessage(Message message) { - _textController = TextEditingController(text: message.text); + textEditingController.text = message.text; _typingStarted = true; _messageIsPresent = true; message.attachments?.forEach((attachment) { - if (attachment.type == 'image') { - _attachments.add(_SendingAttachment( - type: FileType.image, - url: attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl ?? - attachment.ogScrapeUrl, - uploaded: true, - )); - } else if (attachment.type == 'video') { - _attachments.add(_SendingAttachment( - type: FileType.video, - url: attachment.assetUrl, - uploaded: true, - )); - } else if (attachment.type != 'giphy') { - _attachments.add(_SendingAttachment( - type: FileType.any, - url: attachment.assetUrl, - uploaded: true, - )); - } + _attachments.add(_SendingAttachment( + attachment: attachment, + uploaded: true, + )); }); } @@ -908,15 +986,13 @@ class _MessageInputState extends State { } class _SendingAttachment { - final File file; - final FileType type; - String url; + File file; + Attachment attachment; bool uploaded; _SendingAttachment({ - this.url, this.file, - this.type, + this.attachment, this.uploaded = false, }); } diff --git a/lib/src/message_list_view.dart b/lib/src/message_list_view.dart index 487dbc86..ca8deb25 100644 --- a/lib/src/message_list_view.dart +++ b/lib/src/message_list_view.dart @@ -73,6 +73,7 @@ class MessageListView extends StatefulWidget { this.attachmentBuilders, this.dateDividerBuilder, this.showAvatar = true, + this.editMessageInputBuilder, this.scrollPhysics = const AlwaysScrollableScrollPhysics(), }) : super(key: key); @@ -116,9 +117,14 @@ class MessageListView extends StatefulWidget { /// if true shows the user avatar final bool showAvatar; + + /// Builder used to build the message input to edit a message + final Widget Function(BuildContext, Message) editMessageInputBuilder; + /// The ScrollPhysics used by the ListView final ScrollPhysics scrollPhysics; + @override _MessageListViewState createState() => _MessageListViewState(); } @@ -182,6 +188,7 @@ class _MessageListViewState extends State { onMessageActions: widget.onMessageActions, attachmentBuilders: widget.attachmentBuilders, showAvatar: widget.showAvatar, + editMessageInputBuilder: widget.editMessageInputBuilder, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 32), @@ -248,6 +255,7 @@ class _MessageListViewState extends State { onMessageActions: widget.onMessageActions, attachmentBuilders: widget.attachmentBuilders, showAvatar: widget.showAvatar, + editMessageInputBuilder: widget.editMessageInputBuilder, ); } } @@ -342,6 +350,7 @@ class _MessageListViewState extends State { onMessageActions: widget.onMessageActions, attachmentBuilders: widget.attachmentBuilders, showAvatar: widget.showAvatar, + editMessageInputBuilder: widget.editMessageInputBuilder, ); } @@ -384,6 +393,7 @@ class _MessageListViewState extends State { onMessageActions: widget.onMessageActions, attachmentBuilders: widget.attachmentBuilders, showAvatar: widget.showAvatar, + editMessageInputBuilder: widget.editMessageInputBuilder, ); } diff --git a/lib/src/message_widget.dart b/lib/src/message_widget.dart index 49b8fc10..237a14fc 100644 --- a/lib/src/message_widget.dart +++ b/lib/src/message_widget.dart @@ -51,6 +51,7 @@ class MessageWidget extends StatefulWidget { this.showVideoFullScreen = true, this.attachmentBuilders, this.showAvatar = true, + this.editMessageInputBuilder, }) : super(key: key); /// Function called on mention tap @@ -89,6 +90,9 @@ class MessageWidget extends StatefulWidget { /// if true shows the user avatar final bool showAvatar; + /// Builder used to build the message input to edit a message + final Widget Function(BuildContext, Message) editMessageInputBuilder; + @override _MessageWidgetState createState() => _MessageWidgetState(); } @@ -712,10 +716,6 @@ class _MessageWidgetState extends State child: Icon( Icons.close, size: 15, - color: - Theme.of(context).brightness == Brightness.dark - ? Colors.white - : Colors.black, ), ), ), @@ -727,21 +727,23 @@ class _MessageWidgetState extends State padding: EdgeInsets.only( bottom: MediaQuery.of(context).viewInsets.bottom, ), - child: MessageInput( - editMessage: widget.message, - parentMessage: widget.isParent - ? StreamChannel.of(context) - .channel - .state - .messages - .firstWhere((message) => - message.id == widget.message.parentId) - : null, - onMessageSent: (_) { - FocusScope.of(context).unfocus(); - Navigator.pop(context); - }, - ), + child: widget.editMessageInputBuilder != null + ? widget.editMessageInputBuilder(context, widget.message) + : MessageInput( + editMessage: widget.message, + parentMessage: widget.isParent + ? StreamChannel.of(context) + .channel + .state + .messages + .firstWhere((message) => + message.id == widget.message.parentId) + : null, + onMessageSent: (_) { + FocusScope.of(context).unfocus(); + Navigator.pop(context); + }, + ), ), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index 7644af24..2b839d1b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,7 +20,7 @@ dependencies: file_picker: ^1.6.3+2 image_picker: ^0.6.5 flutter_keyboard_visibility: ^2.0.0 - stream_chat: ^0.2.0-alpha+7 + stream_chat: ^0.2.0-alpha+8 mime: ^0.9.6+3 visibility_detector: ^0.1.4 http_parser: ^3.1.4