From 628542f99be2a5b4d81883ae4e61b764a2ec45f9 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Fri, 5 Feb 2021 14:57:17 +0530 Subject: [PATCH 01/19] [LLC, UI-KIT] Add support for custom attachment upload delegate Signed-off-by: Sahil Kumar --- packages/stream_chat/lib/src/api/channel.dart | 12 +- packages/stream_chat/lib/src/client.dart | 7 +- packages/stream_chat/lib/stream_chat.dart | 1 + .../lib/src/attachment_uploader.dart | 57 +++ .../lib/src/compress_video_service.dart | 3 +- .../lib/src/extension.dart | 20 +- .../lib/src/file_attachment.dart | 2 +- .../lib/src/message_input.dart | 480 ++++++++++-------- .../stream_chat_flutter/lib/src/utils.dart | 70 ++- packages/stream_chat_flutter/pubspec.yaml | 3 +- .../stream_chat_flutter_core/pubspec.yaml | 3 +- packages/stream_chat_persistence/pubspec.yaml | 3 +- 12 files changed, 418 insertions(+), 243 deletions(-) create mode 100644 packages/stream_chat_flutter/lib/src/attachment_uploader.dart diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index 7359c343f..4b525661c 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -223,19 +223,27 @@ class Channel { } /// Send a file to this channel - Future sendFile(MultipartFile file) async { + Future sendFile( + MultipartFile file, { + ProgressCallback onSendProgress, + }) async { final response = await _client.post( '$_channelURL/file', data: FormData.fromMap({'file': file}), + onSendProgress: onSendProgress, ); return _client.decode(response.data, SendFileResponse.fromJson); } /// Send an image to this channel - Future sendImage(MultipartFile file) async { + Future sendImage( + MultipartFile file, { + ProgressCallback onSendProgress, + }) async { final response = await _client.post( '$_channelURL/image', data: FormData.fromMap({'file': file}), + onSendProgress: onSendProgress, ); return _client.decode(response.data, SendImageResponse.fromJson); } diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart index 9b1ba4dba..0d3d9ec3d 100644 --- a/packages/stream_chat/lib/src/client.dart +++ b/packages/stream_chat/lib/src/client.dart @@ -794,9 +794,14 @@ class StreamChatClient { Future> post( String path, { dynamic data, + ProgressCallback onSendProgress, }) async { try { - final response = await httpClient.post(path, data: data); + final response = await httpClient.post( + path, + data: data, + onSendProgress: onSendProgress, + ); return response; } on DioError catch (error) { throw _parseError(error); diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 590175c4d..e34859fbd 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -2,6 +2,7 @@ library stream_chat; export 'package:dio/src/dio_error.dart'; export 'package:dio/src/multipart_file.dart'; +export 'package:dio/src/options.dart' show ProgressCallback; export 'package:logging/logging.dart' show Logger, Level; export './src/api/channel.dart'; diff --git a/packages/stream_chat_flutter/lib/src/attachment_uploader.dart b/packages/stream_chat_flutter/lib/src/attachment_uploader.dart new file mode 100644 index 000000000..76cd1b724 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment_uploader.dart @@ -0,0 +1,57 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; +import 'extension.dart'; + +abstract class AttachmentUploader { + Future uploadImage( + PlatformFile file, { + ProgressCallback onSendProgress, + }); + + Future uploadFile( + PlatformFile file, { + ProgressCallback onSendProgress, + }); +} + +class StreamAttachmentUploader implements AttachmentUploader { + final Channel _channel; + + const StreamAttachmentUploader(this._channel); + + @override + Future uploadImage( + PlatformFile file, { + ProgressCallback onSendProgress, + }) async { + final filename = file.path?.split('/')?.last; + final mimeType = filename.mimeType; + final res = await _channel.sendImage( + await MultipartFile.fromFile( + file.path, + filename: filename, + contentType: mimeType, + ), + onSendProgress: onSendProgress, + ); + return res.file; + } + + @override + Future uploadFile( + PlatformFile file, { + ProgressCallback onSendProgress, + }) async { + final filename = file.path?.split('/')?.last; + final mimeType = filename.mimeType; + final res = await _channel.sendFile( + await MultipartFile.fromFile( + file.path, + filename: filename, + contentType: mimeType, + ), + onSendProgress: onSendProgress, + ); + return res.file; + } +} diff --git a/packages/stream_chat_flutter/lib/src/compress_video_service.dart b/packages/stream_chat_flutter/lib/src/compress_video_service.dart index 4f7bf27d8..2a36c1f55 100644 --- a/packages/stream_chat_flutter/lib/src/compress_video_service.dart +++ b/packages/stream_chat_flutter/lib/src/compress_video_service.dart @@ -6,9 +6,10 @@ import 'package:video_compress/video_compress.dart'; class ICompressVideoService { static final ICompressVideoService instance = ICompressVideoService._(); final _lock = Lock(); + ICompressVideoService._(); - Future compressVideo(String path) async { + Future compress(String path) async { return _lock.synchronized(() { return VideoCompress.compressVideo( path, diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index 75a03925c..0997d1371 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -1,5 +1,7 @@ import 'package:characters/characters.dart'; import 'package:emojis/emoji.dart'; +import 'package:http_parser/http_parser.dart' as http_parser; +import 'package:mime/mime.dart'; final _emojis = Emoji.all(); @@ -10,15 +12,27 @@ extension StringExtension on String { return '${this[0].toUpperCase()}${substring(1)}'; } - // Emojis guidelines - // 1 to 3 emojis: big size with no text bubble. - // 4+ emojis or emojis+text: standard size with text bubble. + /// Returns whether the string contains only emoji's or not. + /// + /// Emojis guidelines + /// 1 to 3 emojis: big size with no text bubble. + /// 4+ emojis or emojis+text: standard size with text bubble. bool get isOnlyEmoji { final characters = trim().characters; if (characters.isEmpty) return false; if (characters.length > 3) return false; return characters.every((c) => _emojis.map((e) => e.char).contains(c)); } + + /// Returns the mime type from the passed file name. + http_parser.MediaType get mimeType { + if (this == null) return null; + if (toLowerCase().endsWith('heic')) { + return http_parser.MediaType.parse('image/heic'); + } else { + return http_parser.MediaType.parse(lookupMimeType(this)); + } + } } /// List extension diff --git a/packages/stream_chat_flutter/lib/src/file_attachment.dart b/packages/stream_chat_flutter/lib/src/file_attachment.dart index 209cdb99c..f92f0ec19 100644 --- a/packages/stream_chat_flutter/lib/src/file_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/file_attachment.dart @@ -92,7 +92,7 @@ class _FileAttachmentState extends State { ), SizedBox(height: 3.0), Text( - '${getSizeText(widget.attachment.extraData['file_size'])}', + '${filesize(widget.attachment.extraData['file_size'])}', style: StreamChatTheme.of(context) .textTheme .footnote diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 425bf5ea9..4f995bf93 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -9,9 +9,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:http_parser/http_parser.dart' as http_parser; import 'package:image_picker/image_picker.dart'; -import 'package:mime/mime.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:stream_chat_flutter/src/compress_video_service.dart'; import 'package:stream_chat_flutter/src/media_list_view.dart'; @@ -24,10 +22,10 @@ import 'package:substring_highlight/substring_highlight.dart'; import 'package:video_compress/video_compress.dart'; import '../stream_chat_flutter.dart'; +import 'attachment_uploader.dart'; import 'extension.dart'; import 'quoted_message_widget.dart'; -typedef FileUploader = Future Function(PlatformFile, Channel); typedef AttachmentThumbnailBuilder = Widget Function( BuildContext, _SendingAttachment, @@ -101,8 +99,7 @@ class MessageInput extends StatefulWidget { this.maxHeight = 150, this.keyboardType = TextInputType.multiline, this.disableAttachments = false, - this.doImageUploadRequest, - this.doFileUploadRequest, + this.attachmentUploader, this.initialMessage, this.textEditingController, this.actions, @@ -138,11 +135,8 @@ class MessageInput extends StatefulWidget { /// If true the attachments button will not be displayed final bool disableAttachments; - /// Override image upload request - final FileUploader doImageUploadRequest; - - /// Override file upload request - final FileUploader doFileUploadRequest; + /// A delegate to upload attachments + final AttachmentUploader attachmentUploader; /// The text controller of the TextField final TextEditingController textEditingController; @@ -184,7 +178,7 @@ class MessageInput extends StatefulWidget { } class MessageInputState extends State { - final List<_SendingAttachment> _attachments = []; + final _attachments = {}; final List _mentionedUsers = []; final _imagePicker = ImagePicker(); @@ -210,6 +204,40 @@ class MessageInputState extends State { bool get _hasQuotedMessage => widget.quotedMessage != null; + AttachmentUploader _attachmentUploader; + + @override + void initState() { + super.initState(); + _focusNode = widget.focusNode ?? FocusNode(); + _emojiNames = Emoji.all().map((e) => e.name); + + if (!kIsWeb) { + _keyboardListener = + _keyboardVisibilityController.onChange.listen((visible) { + if (_focusNode.hasFocus) { + _onChanged(context, textEditingController.text); + } + }); + } + + textEditingController = + widget.textEditingController ?? TextEditingController(); + if (widget.editMessage != null || widget.initialMessage != null) { + _parseExistingMessage(widget.editMessage ?? widget.initialMessage); + } + + textEditingController.addListener(() { + _onChanged(context, textEditingController.text); + }); + + _focusNode.addListener(() { + if (_focusNode.hasFocus) { + _openFilePickerSection = false; + } + }); + } + @override Widget build(BuildContext context) { Widget child = SafeArea( @@ -364,7 +392,7 @@ class MessageInputState extends State { padding: const EdgeInsets.all(8.0), child: AnimatedCrossFade( crossFadeState: ((_messageIsPresent || _attachments.isNotEmpty) && - _attachments.every((a) => a.uploaded == true)) + _attachments.values.every((a) => a.isUploaded == true)) ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: _buildSendButton(context), @@ -757,8 +785,9 @@ class MessageInputState extends State { } Widget _buildFilePickerSection() { - var _attachmentContainsFile = - _attachments.any((element) => element?.attachment?.type == 'file'); + final _attachmentContainsFile = _attachments.values.any((it) { + return it.attachmentType == 'file'; + }); Color _getIconColor(int index) { switch (index) { @@ -913,8 +942,9 @@ class MessageInputState extends State { } Widget _buildPickerSection() { - var _attachmentContainsFile = - _attachments.any((element) => element.attachment?.type == 'file'); + final _attachmentContainsFile = _attachments.values.any((it) { + return it.attachmentType == 'file'; + }); switch (_filePickerIndex) { case 0: @@ -949,16 +979,12 @@ class MessageInputState extends State { ); } return MediaListView( - selectedIds: _attachments.map((e) => e.id).toList(), + selectedIds: _attachments.keys.toList(), onSelect: (media) async { - if (!_attachments - .any((element) => element.id == media.id)) { + if (!_attachments.containsKey(media.id)) { _addAttachment(media); } else { - setState(() { - _attachments - .removeWhere((element) => element.id == media.id); - }); + setState(() => _attachments.remove(media.id)); } }, ); @@ -1019,14 +1045,9 @@ class MessageInputState extends State { } void _addAttachment(AssetEntity medium) async { - final attachment = _SendingAttachment( - id: medium.id, - ); + final attachmentId = medium.id; + _attachments[attachmentId] = _SendingAttachment(id: attachmentId); try { - setState(() { - _attachments.add(attachment); - }); - final mediaFile = await medium.originFile.timeout( Duration(seconds: 5), onTimeout: () => medium.originFile, @@ -1040,15 +1061,13 @@ class MessageInputState extends State { if (file.size > _kMaxAttachmentSize) { if (medium?.type == AssetType.video) { - final mediaInfo = await compressVideoService.compressVideo(file.path); + final mediaInfo = await compressVideoService.compress(file.path); if (mediaInfo.filesize / (1024 * 1024) > _kMaxAttachmentSize) { _showErrorAlert( 'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.', ); - setState(() { - _attachments.remove(attachment); - }); + _attachments.remove(attachmentId); return; } file = PlatformFile( @@ -1064,54 +1083,59 @@ class MessageInputState extends State { } } - final channel = StreamChannel.of(context).channel; setState(() { - attachment - ..file = file - ..attachment = Attachment( - localUri: file.path != null ? Uri.parse(file.path) : null, - type: medium?.type == AssetType.image ? 'image' : 'video', + _attachments.update(attachmentId, (it) { + return it.copyWith( + file: file, + attachment: Attachment( + localUri: file.path != null ? Uri.parse(file.path) : null, + type: medium?.type == AssetType.image ? 'image' : 'video', + ), ); + }); }); - final url = await _uploadAttachment( - file, - medium.type == AssetType.image - ? DefaultAttachmentTypes.image - : DefaultAttachmentTypes.video, - channel); - final fileType = medium.type == AssetType.image ? DefaultAttachmentTypes.image : DefaultAttachmentTypes.video; + final url = await _uploadAttachment( + file, + fileType, + onSendProgress: (sent, total) { + setState(() { + _attachments.update( + attachmentId, + (it) => it.copyWith(totalUploaded: sent, totalSize: total), + ); + }); + }, + ); + if (fileType == DefaultAttachmentTypes.image) { - attachment.attachment = attachment.attachment.copyWith( - imageUrl: url, - ); + _attachments.update(attachmentId, (it) { + return it.copyWith(attachment: it.attachment.copyWith(imageUrl: url)); + }); } else { - attachment.attachment = attachment.attachment.copyWith( - assetUrl: url, - ); + _attachments.update(attachmentId, (it) { + return it.copyWith(attachment: it.attachment.copyWith(assetUrl: url)); + }); } if (mounted) { setState(() { - attachment.uploaded = true; + // Marking as upload complete + _attachments.update( + attachmentId, + (it) => it.copyWith(totalUploaded: it.totalSize), + ); }); } } catch (e, s) { - setState(() { - _attachments.remove(attachment); - }); + setState(() => _attachments.remove(attachmentId)); print(e); print(s); - // ignore: deprecated_member_use - Scaffold.of(context).showSnackBar( - SnackBar( - content: Text('Error adding the attachment: $e'), - ), - ); + _showErrorAlert('Error adding the attachment: $e'); } } @@ -1536,9 +1560,15 @@ class MessageInputState extends State { Widget _buildAttachments() { if (_attachments.isEmpty) return Offstage(); + final fileAttachments = _attachments.values + .where((it) => it.attachmentType == 'file') + .toList(growable: false); + final remainingAttachments = _attachments.values + .where((it) => it.attachmentType != 'file') + .toList(growable: false); return Column( children: [ - if (_attachments.any((e) => e.attachment?.type == 'file')) + if (fileAttachments.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), child: LimitedBox( @@ -1546,8 +1576,7 @@ class MessageInputState extends State { child: ListView( reverse: true, shrinkWrap: true, - children: _attachments.reversed - .where((e) => e.attachment?.type == 'file') + children: fileAttachments.reversed .map( (e) => ClipRRect( borderRadius: BorderRadius.circular(10), @@ -1575,8 +1604,9 @@ class MessageInputState extends State { .white, ), ), - onTap: () => - setState(() => _attachments.remove(e)), + onTap: () { + setState(() => _attachments.remove(e.id)); + }, ), ), ), @@ -1586,15 +1616,14 @@ class MessageInputState extends State { ), ), ), - if (_attachments.any((e) => e.attachment?.type != 'file')) + if (remainingAttachments.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), child: LimitedBox( maxHeight: 104.0, child: ListView( scrollDirection: Axis.horizontal, - children: _attachments - .where((e) => e.attachment?.type != 'file') + children: remainingAttachments .map( (attachment) => ClipRRect( borderRadius: BorderRadius.circular(10), @@ -1610,16 +1639,15 @@ class MessageInputState extends State { ), ), _buildRemoveButton(attachment), - attachment.uploaded - ? SizedBox() - : Positioned.fill( - child: Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: CircularProgressIndicator(), - ), - ), + if (!attachment.isUploaded) + Positioned.fill( + child: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: CircularProgressIndicator(), ), + ), + ), ], ), ), @@ -1632,6 +1660,38 @@ class MessageInputState extends State { ); } + Widget _buildUploadProgressIndicator(int uploaded, int total) { + final theme = StreamChatTheme.of(context); + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorTheme.overlayDark.withOpacity(0.6), + ), + child: Padding( + padding: const EdgeInsets.only(top: 5, bottom: 5, right: 11, left: 5), + child: Row( + children: [ + SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(Color(0xffb2b2b2)), + ), + ), + SizedBox(width: 8), + Text( + '${filesize(uploaded)} / ${filesize(total)}', + style: theme.textTheme.footnote.copyWith( + color: theme.colorTheme.white, + ), + ), + ], + ), + ), + ); + } + Positioned _buildRemoveButton(_SendingAttachment attachment) { return Positioned( height: 24, @@ -1648,9 +1708,7 @@ class MessageInputState extends State { disabledElevation: 0, hoverElevation: 0, onPressed: () { - setState(() { - _attachments.remove(attachment); - }); + setState(() => _attachments.remove(attachment.id)); }, fillColor: StreamChatTheme.of(context).colorTheme.black.withOpacity(.5), child: Center( @@ -1665,9 +1723,9 @@ class MessageInputState extends State { Widget _buildAttachment(_SendingAttachment attachment) { if (widget.attachmentThumbnailBuilders - ?.containsKey(attachment.attachment?.type) == + ?.containsKey(attachment.attachmentType) == true) { - return widget.attachmentThumbnailBuilders[attachment.attachment?.type]( + return widget.attachmentThumbnailBuilders[attachment.attachmentType]( context, attachment, ); @@ -1677,7 +1735,7 @@ class MessageInputState extends State { return SizedBox(); } - switch (attachment.attachment?.type) { + switch (attachment.attachmentType) { case 'image': case 'giphy': return attachment.file != null @@ -1895,19 +1953,18 @@ class MessageInputState extends State { /// Use this to add custom type attachments void addAttachment(Attachment attachment) { setState(() { - _attachments.add(_SendingAttachment( + final _attachment = _SendingAttachment( attachment: attachment, - uploaded: true, - )); + totalUploaded: attachment.extraData['file_size'], + ); + _attachments[_attachment.id] = _attachment; }); } /// 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; - }); + setState(() => _inputEnabled = false); PlatformFile file; String attachmentType; @@ -1954,15 +2011,11 @@ class MessageInputState extends State { } } - setState(() { - _inputEnabled = true; - }); + setState(() => _inputEnabled = true); - if (file == null) { - return; - } + if (file == null) return; - final mimeType = _getMimeType(file.path.split('/').last); + final mimeType = file.path.split('/').last.mimeType; var extraDataMap = {}; @@ -1982,7 +2035,6 @@ class MessageInputState extends State { extraDataMap['file_size'] = file.size; } - final channel = StreamChannel.of(context).channel; final attachment = _SendingAttachment( file: file, attachment: Attachment( @@ -1992,14 +2044,13 @@ class MessageInputState extends State { title: file.name, ), ); + final attachmentId = attachment.id; - setState(() { - _attachments.add(attachment); - }); + setState(() => _attachments[attachmentId] = attachment); if (file.size / 1024 > _kMaxAttachmentSize) { if (attachmentType == 'video') { - final mediaInfo = await compressVideoService.compressVideo(file.path); + final mediaInfo = await compressVideoService.compress(file.path); file = PlatformFile( name: mediaInfo.title, size: (mediaInfo.filesize / 1024).ceil(), @@ -2007,98 +2058,74 @@ class MessageInputState extends State { path: mediaInfo.path, ); setState(() { - attachment.file = file; + _attachments.update(attachmentId, (it) => it.copyWith(file: file)); }); } else { - // ignore: deprecated_member_use _showErrorAlert( 'The file is too large to upload. The file size limit is 20MB.', ); - setState(() { - _attachments.remove(attachment); - }); + setState(() => _attachments.remove(attachmentId)); return; } } - final url = await _uploadAttachment(file, fileType, channel); - - if (fileType == DefaultAttachmentTypes.image) { - attachment.attachment = attachment.attachment.copyWith( - imageUrl: url, - ); - } else { - attachment.attachment = attachment.attachment.copyWith( - assetUrl: url, + try { + final url = await _uploadAttachment( + file, + fileType, + onSendProgress: (sent, total) { + setState(() { + _attachments.update( + attachmentId, + (it) => it.copyWith(totalUploaded: sent, totalSize: total), + ); + }); + }, ); - } - - setState(() { - attachment.uploaded = true; - }); - } - Future _uploadAttachment( - PlatformFile file, - DefaultAttachmentTypes type, - Channel channel, - ) async { - String url; - if (type == DefaultAttachmentTypes.image) { - if (widget.doImageUploadRequest != null) { - url = await widget.doImageUploadRequest(file, channel); - } else { - url = await _uploadImage(file, channel); - } - } else { - if (widget.doFileUploadRequest != null) { - url = await widget.doFileUploadRequest(file, channel); + if (fileType == DefaultAttachmentTypes.image) { + _attachments.update(attachmentId, (it) { + return it.copyWith(attachment: it.attachment.copyWith(imageUrl: url)); + }); } else { - url = await _uploadFile(file, channel); + _attachments.update(attachmentId, (it) { + return it.copyWith(attachment: it.attachment.copyWith(assetUrl: url)); + }); } - } - return url; - } - - Future _uploadImage(PlatformFile file, Channel channel) async { - final filename = file.path?.split('/')?.last; - final mimeType = _getMimeType(filename); - final bytes = file.bytes; - final res = await channel.sendImage( - MultipartFile.fromBytes( - bytes, - filename: filename, - contentType: mimeType, - ), - ); - return res.file; - } - http_parser.MediaType _getMimeType(String filename) { - http_parser.MediaType mimeType; - if (filename != null) { - if (filename.toLowerCase().endsWith('heic')) { - mimeType = http_parser.MediaType.parse('image/heic'); - } else { - mimeType = http_parser.MediaType.parse(lookupMimeType(filename)); + if (mounted) { + setState(() { + // Marking as upload complete + _attachments.update( + attachmentId, + (it) => it.copyWith(totalUploaded: it.totalSize), + ); + }); } + } catch (e, s) { + setState(() => _attachments.remove(attachmentId)); + print(e); + print(s); + _showErrorAlert('Error adding the attachment: $e'); } - - return mimeType; } - Future _uploadFile(PlatformFile file, Channel channel) async { - final filename = file.path?.split('/')?.last; - final mimeType = _getMimeType(filename); - final bytes = file.bytes; - final res = await channel.sendFile( - MultipartFile.fromBytes( - bytes, - filename: filename, - contentType: mimeType, - ), - ); - return res.file; + Future _uploadAttachment( + PlatformFile file, + DefaultAttachmentTypes type, { + ProgressCallback onSendProgress, + }) { + if (type == DefaultAttachmentTypes.image) { + return _attachmentUploader.uploadImage( + file, + onSendProgress: onSendProgress, + ); + } else { + return _attachmentUploader.uploadFile( + file, + onSendProgress: onSendProgress, + ); + } } Widget _buildIdleSendButton(BuildContext context) { @@ -2153,7 +2180,7 @@ class MessageInputState extends State { text = '/${_chosenCommand.name} ' + text; } - final attachments = List<_SendingAttachment>.from(_attachments); + final attachments = [..._attachments.values]; textEditingController.clear(); _attachments.clear(); @@ -2234,39 +2261,6 @@ class MessageInputState extends State { StreamSubscription _keyboardListener; - @override - void initState() { - super.initState(); - _focusNode = widget.focusNode ?? FocusNode(); - - _emojiNames = Emoji.all().map((e) => e.name); - - if (!kIsWeb) { - _keyboardListener = - _keyboardVisibilityController.onChange.listen((visible) { - if (_focusNode.hasFocus) { - _onChanged(context, textEditingController.text); - } - }); - } - - textEditingController = - widget.textEditingController ?? TextEditingController(); - if (widget.editMessage != null || widget.initialMessage != null) { - _parseExistingMessage(widget.editMessage ?? widget.initialMessage); - } - - textEditingController.addListener(() { - _onChanged(context, textEditingController.text); - }); - - _focusNode.addListener(() { - if (_focusNode.hasFocus) { - _openFilePickerSection = false; - } - }); - } - void _showErrorAlert(String description) { showModalBottomSheet( backgroundColor: StreamChatTheme.of(context).colorTheme.white, @@ -2344,10 +2338,11 @@ class MessageInputState extends State { _messageIsPresent = true; message.attachments?.forEach((attachment) { - _attachments.add(_SendingAttachment( + final _attachment = _SendingAttachment( attachment: attachment, - uploaded: true, - )); + totalUploaded: attachment.extraData['file_size'], + ); + _attachments[_attachment.id] = _attachment; }); } @@ -2368,22 +2363,61 @@ class MessageInputState extends State { FocusScope.of(context).requestFocus(_focusNode); _initialized = true; } + final channel = StreamChannel.of(context).channel; + if (_attachmentUploader == null) { + _attachmentUploader = + widget.attachmentUploader ?? StreamAttachmentUploader(channel); + } else if (_attachmentUploader is StreamAttachmentUploader) { + _attachmentUploader = StreamAttachmentUploader(channel); + } super.didChangeDependencies(); } } class _SendingAttachment { - PlatformFile file; - Attachment attachment; - bool uploaded; - String id; - _SendingAttachment({ + String id, this.file, this.attachment, - this.uploaded = false, - this.id, - }); + this.totalUploaded = 0, + int totalSize, + }) : id = id ?? shortHash(DateTime.now().millisecondsSinceEpoch), + attachmentType = attachment?.type, + totalSize = + totalSize ?? file?.size ?? attachment.extraData['file_size']; + + final String id; + final PlatformFile file; + final Attachment attachment; + final String attachmentType; + + final int totalUploaded; + final int totalSize; + + // Progress while the attachment is uploading to the server + // 0 -> 100 + double get uploadPercentage { + if (totalSize == null) return null; + return (totalUploaded / totalSize) * 100; + } + + bool get isUploaded => uploadPercentage == 100; + + _SendingAttachment copyWith({ + String id, + PlatformFile file, + Attachment attachment, + int totalUploaded, + int totalSize, + }) { + return _SendingAttachment( + id: id ?? this.id, + file: file ?? this.file, + attachment: attachment ?? this.attachment, + totalUploaded: totalUploaded ?? this.totalUploaded, + totalSize: totalSize ?? this.totalSize, + ); + } } /// Represents a 2-tuple, or pair. diff --git a/packages/stream_chat_flutter/lib/src/utils.dart b/packages/stream_chat_flutter/lib/src/utils.dart index f7eaf385a..bfc0bda94 100644 --- a/packages/stream_chat_flutter/lib/src/utils.dart +++ b/packages/stream_chat_flutter/lib/src/utils.dart @@ -214,18 +214,70 @@ String getWebsiteName(String hostName) { } } -/// -String getSizeText(int bytes) { - if (bytes == null) { - return 'Size N/A'; +/// A method returns a human readable string representing a file _size +String filesize(dynamic size, [int round = 2]) { + if (size == null) return 'Size N/A'; + + /** + * [size] can be passed as number or as string + * + * the optional parameter [round] specifies the number + * of digits after comma/point (default is 2) + */ + final divider = 1024; + int _size; + try { + _size = int.parse(size.toString()); + } catch (e) { + throw ArgumentError('Can not parse the size parameter: $e'); + } + + if (_size < divider) { + return '$_size B'; + } + + if (_size < divider * divider && _size % divider == 0) { + return '${(_size / divider).toStringAsFixed(0)} KB'; + } + + if (_size < divider * divider) { + return '${(_size / divider).toStringAsFixed(round)} KB'; + } + + if (_size < divider * divider * divider && _size % divider == 0) { + return '${(_size / (divider * divider)).toStringAsFixed(0)} MB'; + } + + if (_size < divider * divider * divider) { + return '${(_size / divider / divider).toStringAsFixed(round)} MB'; + } + + if (_size < divider * divider * divider * divider && _size % divider == 0) { + return '${(_size / (divider * divider * divider)).toStringAsFixed(0)} GB'; + } + + if (_size < divider * divider * divider * divider) { + return '${(_size / divider / divider / divider).toStringAsFixed(round)} GB'; + } + + if (_size < divider * divider * divider * divider * divider && + _size % divider == 0) { + num r = _size / divider / divider / divider / divider; + return '${r.toStringAsFixed(0)} TB'; + } + + if (_size < divider * divider * divider * divider * divider) { + num r = _size / divider / divider / divider / divider; + return '${r.toStringAsFixed(round)} TB'; } - if (bytes <= 1000) { - return '$bytes bytes'; - } else if (bytes <= 100000) { - return '${(bytes / 1000).toStringAsFixed(2)} KB'; + if (_size < divider * divider * divider * divider * divider * divider && + _size % divider == 0) { + num r = _size / divider / divider / divider / divider / divider; + return '${r.toStringAsFixed(0)} PB'; } else { - return '${(bytes / 1000000).toStringAsFixed(2)} MB'; + num r = _size / divider / divider / divider / divider / divider; + return '${r.toStringAsFixed(round)} PB'; } } diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 449a4e98e..435fad279 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -11,7 +11,8 @@ environment: dependencies: flutter: sdk: flutter - stream_chat_flutter_core: ^1.0.1-beta + stream_chat_flutter_core: + path: ../stream_chat_flutter_core flutter_app_badger: ^1.1.2 photo_view: ^0.10.3 rxdart: ^0.25.0 diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index 5d360a339..b60d5b34b 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -10,7 +10,8 @@ environment: flutter: ">=1.17.0" dependencies: - stream_chat: ^1.0.2-beta + stream_chat: + path: ../stream_chat flutter: sdk: flutter rxdart: ^0.25.0 diff --git a/packages/stream_chat_persistence/pubspec.yaml b/packages/stream_chat_persistence/pubspec.yaml index a38978849..d6ee16d1d 100644 --- a/packages/stream_chat_persistence/pubspec.yaml +++ b/packages/stream_chat_persistence/pubspec.yaml @@ -13,7 +13,8 @@ dependencies: path: ^1.7.0 path_provider: ^1.6.27 sqlite3_flutter_libs: ^0.3.0 - stream_chat: ^1.0.2-beta + stream_chat: + path: ../stream_chat dev_dependencies: test: ^1.15.7 From 0a6948cf326cd02e2e74d6241be85bb69a3254bb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 16 Feb 2021 21:21:15 +0530 Subject: [PATCH 02/19] [Async Attachment Upload] Initial implementation Signed-off-by: Sahil Kumar --- packages/stream_chat/analysis_options.yaml | 1 + .../lib/src/api}/attachment_uploader.dart | 46 +- packages/stream_chat/lib/src/api/channel.dart | 292 ++++++++++- .../stream_chat/lib/src/api/retry_queue.dart | 10 +- packages/stream_chat/lib/src/client.dart | 118 ++--- .../lib/src/extensions/string_extension.dart | 15 + .../lib/src/models/attachment.dart | 52 +- .../lib/src/models/attachment.g.dart | 16 +- .../lib/src/models/attachment_file.dart | 82 +++ .../src/models/attachment_file.freezed.dart | 486 ++++++++++++++++++ .../lib/src/models/attachment_file.g.dart | 54 ++ .../stream_chat/lib/src/models/message.dart | 5 +- packages/stream_chat/lib/stream_chat.dart | 3 + packages/stream_chat/pubspec.yaml | 2 + .../ios/Runner.xcodeproj/project.pbxproj | 68 +++ .../contents.xcworkspacedata | 3 + .../stream_chat_flutter/example/lib/main.dart | 16 +- .../lib/src/attachment/attachment.dart | 6 + .../{ => attachment}/attachment_title.dart | 4 +- .../attachment_upload_state_builder.dart | 197 +++++++ .../lib/src/attachment/attachment_widget.dart | 77 +++ .../lib/src/attachment/file_attachment.dart | 255 +++++++++ .../{ => attachment}/giphy_attachment.dart | 127 ++--- .../lib/src/attachment/image_attachment.dart | 139 +++++ .../lib/src/attachment/video_attachment.dart | 126 +++++ .../lib/src/attachment_error.dart | 38 -- .../lib/src/channel_file_display_screen.dart | 3 + .../lib/src/channel_media_display_screen.dart | 2 + .../lib/src/compress_video_service.dart | 22 - .../lib/src/extension.dart | 25 +- .../lib/src/file_attachment.dart | 207 -------- .../lib/src/full_screen_media.dart | 141 ++--- .../lib/src/image_actions_modal.dart | 7 +- .../lib/src/image_attachment.dart | 124 ----- .../lib/src/image_footer.dart | 13 +- .../lib/src/image_group.dart | 17 +- .../lib/src/media_utils.dart | 17 - .../lib/src/message_actions_modal.dart | 11 +- .../lib/src/message_input.dart | 483 +++++------------ .../lib/src/message_list_view.dart | 86 ++-- .../lib/src/message_reactions_modal.dart | 3 - .../lib/src/message_widget.dart | 90 ++-- .../lib/src/quoted_message_widget.dart | 19 +- .../lib/src/stream_svg_icon.dart | 12 + .../lib/src/upload_progress_indicator.dart | 62 +++ .../stream_chat_flutter/lib/src/utils.dart | 3 +- .../lib/src/video_attachment.dart | 125 ----- .../lib/src/video_service.dart | 62 +++ .../lib/src/video_thumbnail_image.dart | 87 ++++ .../lib/stream_chat_flutter.dart | 5 +- .../lib/svgs/icon_retry.svg | 5 + packages/stream_chat_flutter/pubspec.yaml | 1 + .../example/pubspec.yaml | 3 +- .../lib/src/mapper/message_mapper.dart | 6 +- 54 files changed, 2556 insertions(+), 1323 deletions(-) rename packages/{stream_chat_flutter/lib/src => stream_chat/lib/src/api}/attachment_uploader.dart (52%) create mode 100644 packages/stream_chat/lib/src/extensions/string_extension.dart create mode 100644 packages/stream_chat/lib/src/models/attachment_file.dart create mode 100644 packages/stream_chat/lib/src/models/attachment_file.freezed.dart create mode 100644 packages/stream_chat/lib/src/models/attachment_file.g.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/attachment.dart rename packages/stream_chat_flutter/lib/src/{ => attachment}/attachment_title.dart (96%) create mode 100644 packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart rename packages/stream_chat_flutter/lib/src/{ => attachment}/giphy_attachment.dart (83%) create mode 100644 packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart delete mode 100644 packages/stream_chat_flutter/lib/src/attachment_error.dart delete mode 100644 packages/stream_chat_flutter/lib/src/compress_video_service.dart delete mode 100644 packages/stream_chat_flutter/lib/src/file_attachment.dart delete mode 100644 packages/stream_chat_flutter/lib/src/image_attachment.dart delete mode 100644 packages/stream_chat_flutter/lib/src/media_utils.dart create mode 100644 packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart delete mode 100644 packages/stream_chat_flutter/lib/src/video_attachment.dart create mode 100644 packages/stream_chat_flutter/lib/src/video_service.dart create mode 100644 packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart create mode 100644 packages/stream_chat_flutter/lib/svgs/icon_retry.svg diff --git a/packages/stream_chat/analysis_options.yaml b/packages/stream_chat/analysis_options.yaml index 87c124245..24e35a1f0 100644 --- a/packages/stream_chat/analysis_options.yaml +++ b/packages/stream_chat/analysis_options.yaml @@ -3,6 +3,7 @@ include: package:pedantic/analysis_options.yaml analyzer: exclude: - lib/**/*.g.dart + - lib/**/*.freezed.dart - example/* - test/* diff --git a/packages/stream_chat_flutter/lib/src/attachment_uploader.dart b/packages/stream_chat/lib/src/api/attachment_uploader.dart similarity index 52% rename from packages/stream_chat_flutter/lib/src/attachment_uploader.dart rename to packages/stream_chat/lib/src/api/attachment_uploader.dart index 76cd1b724..c4c87dcb6 100644 --- a/packages/stream_chat_flutter/lib/src/attachment_uploader.dart +++ b/packages/stream_chat/lib/src/api/attachment_uploader.dart @@ -1,56 +1,80 @@ -import 'package:file_picker/file_picker.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'extension.dart'; +import 'package:dio/dio.dart'; +import 'package:stream_chat/src/models/attachment_file.dart'; +import '../client.dart'; +import '../extensions/string_extension.dart'; +/// abstract class AttachmentUploader { + /// Future uploadImage( - PlatformFile file, { + AttachmentFile file, + String channelId, + String channelType, { ProgressCallback onSendProgress, + CancelToken cancelToken, }); + /// Future uploadFile( - PlatformFile file, { + AttachmentFile file, + String channelId, + String channelType, { ProgressCallback onSendProgress, + CancelToken cancelToken, }); } +/// class StreamAttachmentUploader implements AttachmentUploader { - final Channel _channel; + final StreamChatClient _client; - const StreamAttachmentUploader(this._channel); + /// + const StreamAttachmentUploader(this._client); @override Future uploadImage( - PlatformFile file, { + AttachmentFile file, + String channelId, + String channelType, { ProgressCallback onSendProgress, + CancelToken cancelToken, }) async { final filename = file.path?.split('/')?.last; final mimeType = filename.mimeType; - final res = await _channel.sendImage( + final res = await _client.sendImage( await MultipartFile.fromFile( file.path, filename: filename, contentType: mimeType, ), + channelId, + channelType, onSendProgress: onSendProgress, + cancelToken: cancelToken, ); return res.file; } @override Future uploadFile( - PlatformFile file, { + AttachmentFile file, + String channelId, + String channelType, { ProgressCallback onSendProgress, + CancelToken cancelToken, }) async { final filename = file.path?.split('/')?.last; final mimeType = filename.mimeType; - final res = await _channel.sendFile( + final res = await _client.sendFile( await MultipartFile.fromFile( file.path, filename: filename, contentType: mimeType, ), + channelId, + channelType, onSendProgress: onSendProgress, + cancelToken: cancelToken, ); return res.file; } diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index 4b525661c..45eb20329 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -2,15 +2,16 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; +import 'package:pedantic/pedantic.dart' show unawaited; import 'package:dio/dio.dart'; import 'package:logging/logging.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/src/api/retry_queue.dart'; import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/models/attachment_file.dart'; import 'package:stream_chat/src/models/channel_state.dart'; import 'package:stream_chat/src/models/user.dart'; import 'package:stream_chat/stream_chat.dart'; -import 'package:uuid/uuid.dart'; import '../client.dart'; import '../models/event.dart'; @@ -171,19 +172,148 @@ class Channel { /// Call [watch] to initialize the client or instantiate it using [Channel.fromState] Future get initialized => _initializedCompleter.future; - /// Send a message to this channel + final _cancelableAttachmentUploadRequest = {}; + final _messageAttachmentsUploadCompleter = {}; + + /// Cancels [attachmentId] upload request. Throws exception if the request hasn't + /// even started yet, Already completed or Already cancelled. + /// + /// Optionally, provide a [reason] for the cancellation. + void cancelAttachmentUpload( + String attachmentId, { + String reason, + }) { + final cancelToken = _cancelableAttachmentUploadRequest[attachmentId]; + if (cancelToken == null) { + throw Exception( + "Upload request for this Attachment hasn't started yet or else Already completed", + ); + } + if (cancelToken.isCancelled) throw Exception('Already cancelled'); + cancelToken.cancel(reason); + } + + /// Retries the failed [attachmentId] upload request. + Future retryAttachmentUpload(String messageId, String attachmentId) { + return _uploadAttachments(messageId, [attachmentId]); + } + + Future _uploadAttachments( + String messageId, + Iterable attachmentIds, + ) { + var message = state.messages.firstWhere( + (it) => it.id == messageId, + orElse: () => null, + ); + + if (message == null) { + throw Exception('Error, Message not found'); + } + + final attachments = message.attachments.where((it) { + if (it.uploadState.isSuccess) return false; + return attachmentIds.contains(it.id); + }); + + if (attachments.isEmpty) { + client.logger.info('No attachments available to upload'); + if (message.attachments.every((it) => it.uploadState.isSuccess)) { + _messageAttachmentsUploadCompleter.remove(messageId)?.complete(message); + } + return Future.value(); + } + + client.logger.info('Found ${attachments.length} attachments'); + return Future.wait(attachments.map((it) { + client.logger.info('Uploading ${it.id} attachment...'); + + void updateAttachment(Attachment attachment) { + message = message.copyWith( + attachments: message.attachments.map((it) { + if (it.id != attachment.id) return it; + return attachment; + }).toList(growable: false)); + state?.addMessage(message); + } + + void onSendProgress(int sent, int total) { + updateAttachment(it.copyWith( + uploadState: UploadState.inProgress(uploaded: sent, total: total), + )); + } + + final isImage = it.type == 'image'; + final uploader = _client.attachmentUploader; + final cancelToken = CancelToken(); + Future future; + if (isImage) { + future = uploader.uploadImage(it.file, id, type, + onSendProgress: onSendProgress, cancelToken: cancelToken); + } else { + future = uploader.uploadFile(it.file, id, type, + onSendProgress: onSendProgress, cancelToken: cancelToken); + } + _cancelableAttachmentUploadRequest[it.id] = cancelToken; + return future.then((url) { + client.logger.info('Attachment ${it.id} uploaded successfully...'); + if (isImage) { + updateAttachment( + it.copyWith(imageUrl: url, uploadState: UploadState.success()), + ); + } else { + updateAttachment( + it.copyWith(assetUrl: url, uploadState: UploadState.success()), + ); + } + }).catchError((e, stk) { + updateAttachment( + it.copyWith(uploadState: UploadState.failed(error: e.toString())), + ); + }).whenComplete(() { + _cancelableAttachmentUploadRequest.remove(it.id); + }); + })).whenComplete(() { + if (message.attachments.every((it) => it.uploadState.isSuccess)) { + _messageAttachmentsUploadCompleter.remove(messageId)?.complete(message); + } + }); + } + + /// Send a [message] to this channel. Optionally pass a [attachmentUploader] + /// for custom attachments upload. + /// + /// Waits for a [_messageAttachmentsUploadCompleter] to complete + /// before actually sending the message. Future sendMessage(Message message) async { - final messageId = message.id ?? Uuid().v4(); + // Cancelling previous completer in case it's called again in the process + // Eg. Updating the message while the previous call is in progress. + _messageAttachmentsUploadCompleter + .remove(message.id) + ?.completeError('Message Cancelled'); + final quotedMessage = state?.messages?.firstWhere( (m) => m.id == message?.quotedMessageId, orElse: () => null, ); - final newMessage = message.copyWith( + message = message.copyWith( createdAt: message.createdAt ?? DateTime.now(), user: _client.state.user, - id: messageId, quotedMessage: quotedMessage, status: MessageSendingStatus.sending, + attachments: [ + ...message.attachments.map( + (it) { + if (it.uploadState.isSuccess) return it; + return it.copyWith( + uploadState: UploadState.inProgress( + uploaded: 0, + total: it.file?.size ?? it.extraData['file_size'], + ), + ); + }, + ) + ], ); if (message.parentId != null && message.id == null) { @@ -195,17 +325,24 @@ class Channel { )); } - state?.addMessage(newMessage); + state?.addMessage(message); try { + final attachmentsUploadCompleter = Completer(); + _messageAttachmentsUploadCompleter[message.id] = + attachmentsUploadCompleter; + + unawaited(_uploadAttachments( + message.id, + message.attachments.map((it) => it.id), + )); + + message = await attachmentsUploadCompleter.future; + final response = await _client.post( '$_channelURL/message', data: { - 'message': message - .copyWith( - id: messageId, - ) - .toJson() + 'message': message.toJson(), }, ); @@ -216,7 +353,104 @@ class Channel { return res; } catch (error) { if (error is DioError && error.type != DioErrorType.RESPONSE) { - state?.retryQueue?.add([newMessage]); + state?.retryQueue?.add([message]); + } + rethrow; + } + } + + /// Updates the [message] in this channel. Optionally pass a [attachmentUploader] + /// for custom attachments upload. + /// + /// Waits for a [_messageAttachmentsUploadCompleter] to complete + /// before actually updating the message. + Future updateMessage(Message message) async { + // Cancelling previous completer in case it's called again in the process + // Eg. Updating the message while the previous call is in progress. + _messageAttachmentsUploadCompleter + .remove(message.id) + ?.completeError('Message Cancelled'); + + message = message.copyWith( + status: MessageSendingStatus.updating, + updatedAt: message.updatedAt ?? DateTime.now(), + attachments: [ + ...message.attachments.map( + (it) { + if (it.uploadState.isSuccess) return it; + return it.copyWith( + uploadState: UploadState.inProgress( + uploaded: 0, + total: it.file?.size ?? it.extraData['file_size'], + ), + ); + }, + ) + ], + ); + + state?.addMessage(message); + + try { + final attachmentsUploadCompleter = Completer(); + _messageAttachmentsUploadCompleter[message.id] = + attachmentsUploadCompleter; + + unawaited(_uploadAttachments( + message.id, + message.attachments.map((it) => it.id), + )); + + message = await attachmentsUploadCompleter.future; + + final response = await _client.updateMessage(message); + state?.addMessage(response?.message?.copyWith( + ownReactions: message.ownReactions, + )); + return response; + } catch (error) { + if (error is DioError && error.type != DioErrorType.RESPONSE) { + state?.retryQueue?.add([message]); + } + rethrow; + } + } + + /// Deletes the [message] from the channel. + Future deleteMessage(Message message) async { + // Directly deleting the local messages which are not yet sent to server + if (message.status == MessageSendingStatus.sending || + message.status == MessageSendingStatus.failed) { + state.addMessage(message.copyWith( + type: 'deleted', + status: MessageSendingStatus.sent, + )); + + // Removing the attachments upload completer to stop the `sendMessage` + // waiting for attachments to complete. + _messageAttachmentsUploadCompleter + .remove(message.id) + ?.completeError(Exception('Message deleted')); + return EmptyResponse(); + } + + try { + message = message.copyWith( + type: 'deleted', + status: MessageSendingStatus.deleting, + deletedAt: message.deletedAt ?? DateTime.now(), + ); + + state?.addMessage(message); + + final response = await _client.deleteMessage(message); + + state?.addMessage(message.copyWith(status: MessageSendingStatus.sent)); + + return response; + } catch (error) { + if (error is DioError && error.type != DioErrorType.RESPONSE) { + state?.retryQueue?.add([message]); } rethrow; } @@ -226,26 +460,30 @@ class Channel { Future sendFile( MultipartFile file, { ProgressCallback onSendProgress, - }) async { - final response = await _client.post( - '$_channelURL/file', - data: FormData.fromMap({'file': file}), + CancelToken cancelToken, + }) { + return _client.sendFile( + file, + id, + type, onSendProgress: onSendProgress, + cancelToken: cancelToken, ); - return _client.decode(response.data, SendFileResponse.fromJson); } /// Send an image to this channel Future sendImage( - MultipartFile file, { + MultipartFile image, { ProgressCallback onSendProgress, - }) async { - final response = await _client.post( - '$_channelURL/image', - data: FormData.fromMap({'file': file}), + CancelToken cancelToken, + }) { + return _client.sendImage( + image, + id, + type, onSendProgress: onSendProgress, + cancelToken: cancelToken, ); - return _client.decode(response.data, SendImageResponse.fromJson); } /// Delete a file from this channel @@ -975,7 +1213,13 @@ class ChannelClientState { ?.getChannelThreads(_channel.cid) ?.then((threads) { _threads = threads; - retryFailedMessages(); + })?.then((_) { + _channel._client.chatPersistenceClient + ?.getChannelStateByCid(_channel.cid) + ?.then((state) { + updateChannelState(state); + retryFailedMessages(); + }); }); } diff --git a/packages/stream_chat/lib/src/api/retry_queue.dart b/packages/stream_chat/lib/src/api/retry_queue.dart index 534d9c080..a6c6a8b65 100644 --- a/packages/stream_chat/lib/src/api/retry_queue.dart +++ b/packages/stream_chat/lib/src/api/retry_queue.dart @@ -125,10 +125,7 @@ class RetryQueue { Future _sendMessage(Message message) async { if (message.status == MessageSendingStatus.failed_update || message.status == MessageSendingStatus.updating) { - await channel.client.updateMessage( - message, - channel.cid, - ); + await channel.updateMessage(message); } else if (message.status == MessageSendingStatus.failed || message.status == MessageSendingStatus.sending) { await channel.sendMessage( @@ -136,10 +133,7 @@ class RetryQueue { ); } else if (message.status == MessageSendingStatus.failed_delete || message.status == MessageSendingStatus.deleting) { - await channel.client.deleteMessage( - message, - channel.cid, - ); + await channel.client.deleteMessage(message); } } diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart index 0d3d9ec3d..cd2055e42 100644 --- a/packages/stream_chat/lib/src/client.dart +++ b/packages/stream_chat/lib/src/client.dart @@ -12,6 +12,7 @@ import 'package:stream_chat/src/models/own_user.dart'; import 'package:stream_chat/version.dart'; import 'package:uuid/uuid.dart'; +import 'api/attachment_uploader.dart'; import 'api/channel.dart'; import 'api/connection_status.dart'; import 'api/requests.dart'; @@ -78,6 +79,7 @@ class StreamChatClient { Duration receiveTimeout = const Duration(seconds: 6), Dio httpClient, RetryPolicy retryPolicy, + this.attachmentUploader, }) { _retryPolicy ??= RetryPolicy( retryTimeout: (StreamChatClient client, int attempt, ApiError error) => @@ -86,6 +88,8 @@ class StreamChatClient { attempt < 5, ); + attachmentUploader ??= StreamAttachmentUploader(this); + state = ClientState(this); _setupLogger(); @@ -97,6 +101,9 @@ class StreamChatClient { /// Chat persistence client ChatPersistenceClient chatPersistenceClient; + /// Attachment uploader + AttachmentUploader attachmentUploader; + /// Whether the chat persistence is available or not bool get persistenceEnabled => chatPersistenceClient != null; @@ -795,12 +802,14 @@ class StreamChatClient { String path, { dynamic data, ProgressCallback onSendProgress, + CancelToken cancelToken, }) async { try { final response = await httpClient.post( path, data: data, onSendProgress: onSendProgress, + cancelToken: cancelToken, ); return response; } on DioError catch (error) { @@ -1030,6 +1039,40 @@ class StreamChatClient { response.data, SearchMessagesResponse.fromJson); } + /// Send a [file] to the [channelId] of type [channelType] + Future sendFile( + MultipartFile file, + String channelId, + String channelType, { + ProgressCallback onSendProgress, + CancelToken cancelToken, + }) async { + final response = await post( + '/channels/$channelType/$channelId/file', + data: FormData.fromMap({'file': file}), + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); + return decode(response.data, SendFileResponse.fromJson); + } + + /// Send a [image] to the [channelId] of type [channelType] + Future sendImage( + MultipartFile image, + String channelId, + String channelType, { + ProgressCallback onSendProgress, + CancelToken cancelToken, + }) async { + final response = await post( + '/channels/$channelType/$channelId/image', + data: FormData.fromMap({'file': image}), + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ); + return decode(response.data, SendImageResponse.fromJson); + } + /// Add a device for Push Notifications. Future addDevice(String id, PushProvider pushProvider) async { final response = await post('/devices', data: { @@ -1202,77 +1245,18 @@ class StreamChatClient { } /// Update the given message - Future updateMessage( - Message message, [ - String cid, - ]) async { - message = message.copyWith( - status: MessageSendingStatus.updating, - updatedAt: message.updatedAt ?? DateTime.now(), + Future updateMessage(Message message) async { + final response = await post( + '/messages/${message.id}', + data: {'message': message}, ); - - final channel = state?.channels != null ? state?.channels[cid] : null; - channel?.state?.addMessage(message); - - return post('/messages/${message.id}', data: {'message': message}) - .then((res) { - final updateMessageResponse = decode( - res?.data, - UpdateMessageResponse.fromJson, - ); - - channel?.state?.addMessage(updateMessageResponse?.message?.copyWith( - ownReactions: message.ownReactions, - )); - - return updateMessageResponse; - }).catchError((error) { - if (error is DioError && - error.type != DioErrorType.RESPONSE && - state?.channels != null) { - channel?.state?.retryQueue?.add([message]); - } - throw error; - }); + return decode(response.data, UpdateMessageResponse.fromJson); } /// Deletes the given message - Future deleteMessage(Message message, [String cid]) async { - if (message.status == MessageSendingStatus.failed) { - state.channels[cid].state.addMessage(message.copyWith( - type: 'deleted', - status: MessageSendingStatus.sent, - )); - return EmptyResponse(); - } - - try { - message = message.copyWith( - type: 'deleted', - status: MessageSendingStatus.deleting, - deletedAt: message.deletedAt ?? DateTime.now(), - ); - - if (state?.channels != null) { - state.channels[cid]?.state?.addMessage(message); - } - - final response = await delete('/messages/${message.id}'); - - if (state?.channels != null) { - state.channels[cid]?.state - ?.addMessage(message.copyWith(status: MessageSendingStatus.sent)); - } - - return decode(response.data, EmptyResponse.fromJson); - } catch (error) { - if (error is DioError && - error.type != DioErrorType.RESPONSE && - state?.channels != null) { - state.channels[cid]?.state?.retryQueue?.add([message]); - } - rethrow; - } + Future deleteMessage(Message message) async { + final response = await delete('/messages/${message.id}'); + return decode(response.data, EmptyResponse.fromJson); } /// Get a message by id diff --git a/packages/stream_chat/lib/src/extensions/string_extension.dart b/packages/stream_chat/lib/src/extensions/string_extension.dart new file mode 100644 index 000000000..49949412d --- /dev/null +++ b/packages/stream_chat/lib/src/extensions/string_extension.dart @@ -0,0 +1,15 @@ +import 'package:http_parser/http_parser.dart' as http_parser; +import 'package:mime/mime.dart'; + +/// Useful extension functions for [String] +extension StringX on String { + /// Returns the mime type from the passed file name. + http_parser.MediaType get mimeType { + if (this == null) return null; + if (toLowerCase().endsWith('heic')) { + return http_parser.MediaType.parse('image/heic'); + } else { + return http_parser.MediaType.parse(lookupMimeType(this)); + } + } +} diff --git a/packages/stream_chat/lib/src/models/attachment.dart b/packages/stream_chat/lib/src/models/attachment.dart index 8546fb9dd..cc55aa712 100644 --- a/packages/stream_chat/lib/src/models/attachment.dart +++ b/packages/stream_chat/lib/src/models/attachment.dart @@ -1,6 +1,8 @@ // ignore_for_file: public_member_api_docs import 'package:json_annotation/json_annotation.dart'; +import 'package:stream_chat/src/models/attachment_file.dart'; +import 'package:uuid/uuid.dart'; import 'action.dart'; import 'serialization.dart'; @@ -52,10 +54,21 @@ class Attachment { final Uri localUri; + /// The file present inside this attachment. + final AttachmentFile file; + + /// The current upload state of the attachment + final UploadState uploadState; + /// Map of custom channel extraData @JsonKey(includeIfNull: false) final Map extraData; + /// The attachment ID. + /// + /// This is created locally for uniquely identifying a attachment. + final String id; + /// Known top level fields. /// Useful for [Serialization] methods. static const topLevelFields = [ @@ -79,11 +92,20 @@ class Attachment { 'actions', ]; + /// Known db specific top level fields. + /// Useful for [Serialization] methods. + static const dbSpecificTopLevelFields = [ + 'id', + 'upload_state', + 'file', + ]; + /// Constructor used for json serialization Attachment({ + String id, this.type, this.titleLink, - this.title, + String title, this.thumbUrl, this.text, this.pretext, @@ -100,8 +122,11 @@ class Attachment { this.assetUrl, this.actions, this.extraData, - this.localUri, - }); + this.file, + this.uploadState, + }) : id = id ?? Uuid().v4(), + title = title ?? file?.name, + localUri = file?.path != null ? Uri.parse(file.path) : null; /// Create a new instance from a json factory Attachment.fromJson(Map json) { @@ -111,9 +136,21 @@ class Attachment { /// Serialize to json Map toJson() => Serialization.moveFromExtraDataToRoot( - _$AttachmentToJson(this), topLevelFields); + _$AttachmentToJson(this), topLevelFields) + ..removeWhere((key, value) => dbSpecificTopLevelFields.contains(key)); + + /// Create a new instance from a db data + factory Attachment.fromData(Map json) { + return _$AttachmentFromJson(Serialization.moveToExtraDataFromRoot( + json, topLevelFields + dbSpecificTopLevelFields)); + } + + /// Serialize to db data + Map toData() => Serialization.moveFromExtraDataToRoot( + _$AttachmentToJson(this), topLevelFields + dbSpecificTopLevelFields); Attachment copyWith({ + String id, String type, String titleLink, String title, @@ -132,10 +169,12 @@ class Attachment { String authorIcon, String assetUrl, List actions, - Uri localUri, + AttachmentFile file, + UploadState uploadState, Map extraData, }) => Attachment( + id: id ?? this.id, type: type ?? this.type, titleLink: titleLink ?? this.titleLink, title: title ?? this.title, @@ -154,7 +193,8 @@ class Attachment { authorIcon: authorIcon ?? this.authorIcon, assetUrl: assetUrl ?? this.assetUrl, actions: actions ?? this.actions, - localUri: localUri ?? this.localUri, + file: file ?? this.file, + uploadState: uploadState ?? this.uploadState, extraData: extraData ?? this.extraData, ); diff --git a/packages/stream_chat/lib/src/models/attachment.g.dart b/packages/stream_chat/lib/src/models/attachment.g.dart index db276d69d..c45771c28 100644 --- a/packages/stream_chat/lib/src/models/attachment.g.dart +++ b/packages/stream_chat/lib/src/models/attachment.g.dart @@ -8,6 +8,7 @@ part of 'attachment.dart'; Attachment _$AttachmentFromJson(Map json) { return Attachment( + id: json['id'] as String, type: json['type'] as String, titleLink: json['title_link'] as String, title: json['title'] as String, @@ -35,9 +36,16 @@ Attachment _$AttachmentFromJson(Map json) { extraData: (json['extra_data'] as Map)?.map( (k, e) => MapEntry(k as String, e), ), - localUri: json['local_uri'] == null + file: json['file'] == null ? null - : Uri.parse(json['local_uri'] as String), + : AttachmentFile.fromJson((json['file'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), + uploadState: json['upload_state'] == null + ? null + : UploadState.fromJson((json['upload_state'] as Map)?.map( + (k, e) => MapEntry(k as String, e), + )), ); } @@ -68,7 +76,9 @@ Map _$AttachmentToJson(Attachment instance) { writeNotNull('author_icon', instance.authorIcon); writeNotNull('asset_url', instance.assetUrl); writeNotNull('actions', instance.actions?.map((e) => e?.toJson())?.toList()); - writeNotNull('local_uri', instance.localUri?.toString()); + writeNotNull('file', instance.file?.toJson()); + writeNotNull('upload_state', instance.uploadState?.toJson()); writeNotNull('extra_data', instance.extraData); + writeNotNull('id', instance.id); return val; } diff --git a/packages/stream_chat/lib/src/models/attachment_file.dart b/packages/stream_chat/lib/src/models/attachment_file.dart new file mode 100644 index 000000000..bffc24aec --- /dev/null +++ b/packages/stream_chat/lib/src/models/attachment_file.dart @@ -0,0 +1,82 @@ +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'attachment_file.freezed.dart'; + +part 'attachment_file.g.dart'; + +/// +@freezed +abstract class UploadState with _$UploadState { + /// + const factory UploadState.inProgress({int uploaded, int total}) = InProgress; + + /// + const factory UploadState.success() = Success; + + /// + const factory UploadState.failed({@required String error}) = Failed; + + /// Creates a new instance from a json + factory UploadState.fromJson(Map json) => + _$UploadStateFromJson(json); +} + +/// +extension UploadStateX on UploadState { + /// + bool get isInProgress => this is InProgress; + + /// + bool get isSuccess => this is Success; + + /// + bool get isFailed => this is Failed; +} + +Uint8List _fromString(String bytes) => Uint8List.fromList(bytes.codeUnits); + +String _toString(Uint8List bytes) => String.fromCharCodes(bytes); + +/// +@JsonSerializable() +class AttachmentFile { + /// + const AttachmentFile({ + this.path, + this.name, + this.bytes, + this.size, + }); + + /// The absolute path for a cached copy of this file. It can be used to create a + /// file instance with a descriptor for the given path. + /// ``` + /// final File myFile = File(platformFile.path); + /// ``` + final String path; + + /// File name including its extension. + final String name; + + /// Byte data for this file. Particularly useful if you want to manipulate its data + /// or easily upload to somewhere else. + @JsonKey(toJson: _toString, fromJson: _fromString) + final Uint8List bytes; + + /// The file size in bytes. + final int size; + + /// File extension for this file. + String get extension => name?.split('.')?.last; + + /// Create a new instance from a json + factory AttachmentFile.fromJson(Map json) { + return _$AttachmentFileFromJson(json); + } + + /// Serialize to json + Map toJson() => _$AttachmentFileToJson(this); +} diff --git a/packages/stream_chat/lib/src/models/attachment_file.freezed.dart b/packages/stream_chat/lib/src/models/attachment_file.freezed.dart new file mode 100644 index 000000000..d80854918 --- /dev/null +++ b/packages/stream_chat/lib/src/models/attachment_file.freezed.dart @@ -0,0 +1,486 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies + +part of 'attachment_file.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; +UploadState _$UploadStateFromJson(Map json) { + switch (json['runtimeType'] as String) { + case 'inProgress': + return InProgress.fromJson(json); + case 'success': + return Success.fromJson(json); + case 'failed': + return Failed.fromJson(json); + + default: + throw FallThroughError(); + } +} + +/// @nodoc +class _$UploadStateTearOff { + const _$UploadStateTearOff(); + +// ignore: unused_element + InProgress inProgress({int uploaded, int total}) { + return InProgress( + uploaded: uploaded, + total: total, + ); + } + +// ignore: unused_element + Success success() { + return const Success(); + } + +// ignore: unused_element + Failed failed({@required String error}) { + return Failed( + error: error, + ); + } + +// ignore: unused_element + UploadState fromJson(Map json) { + return UploadState.fromJson(json); + } +} + +/// @nodoc +// ignore: unused_element +const $UploadState = _$UploadStateTearOff(); + +/// @nodoc +mixin _$UploadState { + @optionalTypeArgs + TResult when({ + @required TResult inProgress(int uploaded, int total), + @required TResult success(), + @required TResult failed(String error), + }); + @optionalTypeArgs + TResult maybeWhen({ + TResult inProgress(int uploaded, int total), + TResult success(), + TResult failed(String error), + @required TResult orElse(), + }); + @optionalTypeArgs + TResult map({ + @required TResult inProgress(InProgress value), + @required TResult success(Success value), + @required TResult failed(Failed value), + }); + @optionalTypeArgs + TResult maybeMap({ + TResult inProgress(InProgress value), + TResult success(Success value), + TResult failed(Failed value), + @required TResult orElse(), + }); + Map toJson(); +} + +/// @nodoc +abstract class $UploadStateCopyWith<$Res> { + factory $UploadStateCopyWith( + UploadState value, $Res Function(UploadState) then) = + _$UploadStateCopyWithImpl<$Res>; +} + +/// @nodoc +class _$UploadStateCopyWithImpl<$Res> implements $UploadStateCopyWith<$Res> { + _$UploadStateCopyWithImpl(this._value, this._then); + + final UploadState _value; + // ignore: unused_field + final $Res Function(UploadState) _then; +} + +/// @nodoc +abstract class $InProgressCopyWith<$Res> { + factory $InProgressCopyWith( + InProgress value, $Res Function(InProgress) then) = + _$InProgressCopyWithImpl<$Res>; + $Res call({int uploaded, int total}); +} + +/// @nodoc +class _$InProgressCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res> + implements $InProgressCopyWith<$Res> { + _$InProgressCopyWithImpl(InProgress _value, $Res Function(InProgress) _then) + : super(_value, (v) => _then(v as InProgress)); + + @override + InProgress get _value => super._value as InProgress; + + @override + $Res call({ + Object uploaded = freezed, + Object total = freezed, + }) { + return _then(InProgress( + uploaded: uploaded == freezed ? _value.uploaded : uploaded as int, + total: total == freezed ? _value.total : total as int, + )); + } +} + +@JsonSerializable() + +/// @nodoc +class _$InProgress implements InProgress { + const _$InProgress({this.uploaded, this.total}); + + factory _$InProgress.fromJson(Map json) => + _$_$InProgressFromJson(json); + + @override + final int uploaded; + @override + final int total; + + @override + String toString() { + return 'UploadState.inProgress(uploaded: $uploaded, total: $total)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other is InProgress && + (identical(other.uploaded, uploaded) || + const DeepCollectionEquality() + .equals(other.uploaded, uploaded)) && + (identical(other.total, total) || + const DeepCollectionEquality().equals(other.total, total))); + } + + @override + int get hashCode => + runtimeType.hashCode ^ + const DeepCollectionEquality().hash(uploaded) ^ + const DeepCollectionEquality().hash(total); + + @JsonKey(ignore: true) + @override + $InProgressCopyWith get copyWith => + _$InProgressCopyWithImpl(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + @required TResult inProgress(int uploaded, int total), + @required TResult success(), + @required TResult failed(String error), + }) { + assert(inProgress != null); + assert(success != null); + assert(failed != null); + return inProgress(uploaded, total); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult inProgress(int uploaded, int total), + TResult success(), + TResult failed(String error), + @required TResult orElse(), + }) { + assert(orElse != null); + if (inProgress != null) { + return inProgress(uploaded, total); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + @required TResult inProgress(InProgress value), + @required TResult success(Success value), + @required TResult failed(Failed value), + }) { + assert(inProgress != null); + assert(success != null); + assert(failed != null); + return inProgress(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult inProgress(InProgress value), + TResult success(Success value), + TResult failed(Failed value), + @required TResult orElse(), + }) { + assert(orElse != null); + if (inProgress != null) { + return inProgress(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$_$InProgressToJson(this)..['runtimeType'] = 'inProgress'; + } +} + +abstract class InProgress implements UploadState { + const factory InProgress({int uploaded, int total}) = _$InProgress; + + factory InProgress.fromJson(Map json) = + _$InProgress.fromJson; + + int get uploaded; + int get total; + @JsonKey(ignore: true) + $InProgressCopyWith get copyWith; +} + +/// @nodoc +abstract class $SuccessCopyWith<$Res> { + factory $SuccessCopyWith(Success value, $Res Function(Success) then) = + _$SuccessCopyWithImpl<$Res>; +} + +/// @nodoc +class _$SuccessCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res> + implements $SuccessCopyWith<$Res> { + _$SuccessCopyWithImpl(Success _value, $Res Function(Success) _then) + : super(_value, (v) => _then(v as Success)); + + @override + Success get _value => super._value as Success; +} + +@JsonSerializable() + +/// @nodoc +class _$Success implements Success { + const _$Success(); + + factory _$Success.fromJson(Map json) => + _$_$SuccessFromJson(json); + + @override + String toString() { + return 'UploadState.success()'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || (other is Success); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + @required TResult inProgress(int uploaded, int total), + @required TResult success(), + @required TResult failed(String error), + }) { + assert(inProgress != null); + assert(success != null); + assert(failed != null); + return success(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult inProgress(int uploaded, int total), + TResult success(), + TResult failed(String error), + @required TResult orElse(), + }) { + assert(orElse != null); + if (success != null) { + return success(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + @required TResult inProgress(InProgress value), + @required TResult success(Success value), + @required TResult failed(Failed value), + }) { + assert(inProgress != null); + assert(success != null); + assert(failed != null); + return success(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult inProgress(InProgress value), + TResult success(Success value), + TResult failed(Failed value), + @required TResult orElse(), + }) { + assert(orElse != null); + if (success != null) { + return success(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$_$SuccessToJson(this)..['runtimeType'] = 'success'; + } +} + +abstract class Success implements UploadState { + const factory Success() = _$Success; + + factory Success.fromJson(Map json) = _$Success.fromJson; +} + +/// @nodoc +abstract class $FailedCopyWith<$Res> { + factory $FailedCopyWith(Failed value, $Res Function(Failed) then) = + _$FailedCopyWithImpl<$Res>; + $Res call({String error}); +} + +/// @nodoc +class _$FailedCopyWithImpl<$Res> extends _$UploadStateCopyWithImpl<$Res> + implements $FailedCopyWith<$Res> { + _$FailedCopyWithImpl(Failed _value, $Res Function(Failed) _then) + : super(_value, (v) => _then(v as Failed)); + + @override + Failed get _value => super._value as Failed; + + @override + $Res call({ + Object error = freezed, + }) { + return _then(Failed( + error: error == freezed ? _value.error : error as String, + )); + } +} + +@JsonSerializable() + +/// @nodoc +class _$Failed implements Failed { + const _$Failed({@required this.error}) : assert(error != null); + + factory _$Failed.fromJson(Map json) => + _$_$FailedFromJson(json); + + @override + final String error; + + @override + String toString() { + return 'UploadState.failed(error: $error)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other is Failed && + (identical(other.error, error) || + const DeepCollectionEquality().equals(other.error, error))); + } + + @override + int get hashCode => + runtimeType.hashCode ^ const DeepCollectionEquality().hash(error); + + @JsonKey(ignore: true) + @override + $FailedCopyWith get copyWith => + _$FailedCopyWithImpl(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + @required TResult inProgress(int uploaded, int total), + @required TResult success(), + @required TResult failed(String error), + }) { + assert(inProgress != null); + assert(success != null); + assert(failed != null); + return failed(error); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult inProgress(int uploaded, int total), + TResult success(), + TResult failed(String error), + @required TResult orElse(), + }) { + assert(orElse != null); + if (failed != null) { + return failed(error); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + @required TResult inProgress(InProgress value), + @required TResult success(Success value), + @required TResult failed(Failed value), + }) { + assert(inProgress != null); + assert(success != null); + assert(failed != null); + return failed(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult inProgress(InProgress value), + TResult success(Success value), + TResult failed(Failed value), + @required TResult orElse(), + }) { + assert(orElse != null); + if (failed != null) { + return failed(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$_$FailedToJson(this)..['runtimeType'] = 'failed'; + } +} + +abstract class Failed implements UploadState { + const factory Failed({@required String error}) = _$Failed; + + factory Failed.fromJson(Map json) = _$Failed.fromJson; + + String get error; + @JsonKey(ignore: true) + $FailedCopyWith get copyWith; +} diff --git a/packages/stream_chat/lib/src/models/attachment_file.g.dart b/packages/stream_chat/lib/src/models/attachment_file.g.dart new file mode 100644 index 000000000..36a426811 --- /dev/null +++ b/packages/stream_chat/lib/src/models/attachment_file.g.dart @@ -0,0 +1,54 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'attachment_file.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +AttachmentFile _$AttachmentFileFromJson(Map json) { + return AttachmentFile( + path: json['path'] as String, + name: json['name'] as String, + bytes: _fromString(json['bytes'] as String), + size: json['size'] as int, + ); +} + +Map _$AttachmentFileToJson(AttachmentFile instance) => + { + 'path': instance.path, + 'name': instance.name, + 'bytes': _toString(instance.bytes), + 'size': instance.size, + }; + +_$InProgress _$_$InProgressFromJson(Map json) { + return _$InProgress( + uploaded: json['uploaded'] as int, + total: json['total'] as int, + ); +} + +Map _$_$InProgressToJson(_$InProgress instance) => + { + 'uploaded': instance.uploaded, + 'total': instance.total, + }; + +_$Success _$_$SuccessFromJson(Map json) { + return _$Success(); +} + +Map _$_$SuccessToJson(_$Success instance) => + {}; + +_$Failed _$_$FailedFromJson(Map json) { + return _$Failed( + error: json['error'] as String, + ); +} + +Map _$_$FailedToJson(_$Failed instance) => { + 'error': instance.error, + }; diff --git a/packages/stream_chat/lib/src/models/message.dart b/packages/stream_chat/lib/src/models/message.dart index ceff96527..3127bba37 100644 --- a/packages/stream_chat/lib/src/models/message.dart +++ b/packages/stream_chat/lib/src/models/message.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; +import 'package:uuid/uuid.dart'; import 'attachment.dart'; import 'reaction.dart'; @@ -163,7 +164,7 @@ class Message { /// Constructor used for json serialization Message({ - this.id, + String id, this.text, this.type, this.attachments, @@ -187,7 +188,7 @@ class Message { this.extraData, this.deletedAt, this.status = MessageSendingStatus.sent, - }); + }) : id = id ?? Uuid().v4(); /// Create a new instance from a json factory Message.fromJson(Map json) => _$MessageFromJson( diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index e34859fbd..dd09e8e84 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -10,10 +10,12 @@ export './src/api/connection_status.dart'; export './src/api/requests.dart'; export './src/api/requests.dart'; export './src/api/responses.dart'; +export './src/api/attachment_uploader.dart' show AttachmentUploader; export './src/client.dart'; export './src/event_type.dart'; export './src/models/action.dart'; export './src/models/attachment.dart'; +export './src/models/attachment_file.dart'; export './src/models/channel_config.dart'; export './src/models/channel_model.dart'; export './src/models/channel_state.dart'; @@ -27,4 +29,5 @@ export './src/models/own_user.dart'; export './src/models/reaction.dart'; export './src/models/read.dart'; export './src/models/user.dart'; +export './src/extensions/string_extension.dart'; export './src/db/chat_persistence_client.dart'; diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index 218b16d3e..2fa16ec03 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -19,9 +19,11 @@ dependencies: collection: ^1.14.13 pedantic: ^1.9.2 meta: ^1.2.4 + freezed_annotation: ^0.12.0 dev_dependencies: build_runner: ^1.10.0 json_serializable: ^3.3.0 test: ^1.15.7 mockito: ^4.1.1 + freezed: ^0.12.7 diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj index 1aec2aa07..1721e6f10 100644 --- a/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/stream_chat_flutter/example/ios/Runner.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 1589B944F51366A883B3A7A5 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4AFDB673F1C9808CE4EC418F /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; @@ -31,7 +32,11 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 24CE22BB301621B9BF1A7A7C /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 4684439012E1DB1A82103E26 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 4AFDB673F1C9808CE4EC418F /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 59062C6EC2CCFE110AC70AB8 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -49,12 +54,24 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1589B944F51366A883B3A7A5 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 38CA51382F78F25FC59B6C81 /* Pods */ = { + isa = PBXGroup; + children = ( + 24CE22BB301621B9BF1A7A7C /* Pods-Runner.debug.xcconfig */, + 59062C6EC2CCFE110AC70AB8 /* Pods-Runner.release.xcconfig */, + 4684439012E1DB1A82103E26 /* Pods-Runner.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -72,6 +89,8 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, + 38CA51382F78F25FC59B6C81 /* Pods */, + E3E00C71A36D1ABA459667BF /* Frameworks */, ); sourceTree = ""; }; @@ -98,6 +117,14 @@ path = Runner; sourceTree = ""; }; + E3E00C71A36D1ABA459667BF /* Frameworks */ = { + isa = PBXGroup; + children = ( + 4AFDB673F1C9808CE4EC418F /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -105,12 +132,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 41D6CB24535AC541800DBB07 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 9DF0031578B883CBE79BDCC8 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -183,6 +212,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 41D6CB24535AC541800DBB07 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -197,6 +248,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + 9DF0031578B883CBE79BDCC8 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ diff --git a/packages/stream_chat_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/stream_chat_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a16e..21a3cc14c 100644 --- a/packages/stream_chat_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/packages/stream_chat_flutter/example/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index 2c5ce8a87..731ef0801 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -1,13 +1,21 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_persistence/stream_chat_persistence.dart'; + +final chatPersistentClient = StreamChatPersistenceClient( + logLevel: Level.INFO, + connectionMode: ConnectionMode.background, +); void main() async { + WidgetsFlutterBinding.ensureInitialized(); + /// Create a new instance of [StreamChatClient] passing the apikey obtained from your /// project dashboard. final client = StreamChatClient( 's2dxdhpxd94g', logLevel: Level.INFO, - ); + )..chatPersistenceClient = chatPersistentClient; /// Set the current user and connect the websocket. In a production scenario, this should be done using /// a backend to generate a user token using our server SDK. @@ -20,8 +28,7 @@ void main() async { final channel = client.channel('messaging', id: 'godevs'); - // ignore: unawaited_futures - channel.watch(); + await channel.watch(); runApp(MyApp(client, channel)); } @@ -49,6 +56,9 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( + theme: ThemeData.light(), + darkTheme: ThemeData.dark(), + themeMode: ThemeMode.system, builder: (context, widget) { return StreamChat( child: widget, diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment.dart new file mode 100644 index 000000000..425908315 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment.dart @@ -0,0 +1,6 @@ +export 'file_attachment.dart'; +export 'giphy_attachment.dart'; +export 'image_attachment.dart'; +export 'video_attachment.dart'; +export 'attachment_widget.dart' + show AttachmentError, AttachmentSource, AttachmentSourceX; diff --git a/packages/stream_chat_flutter/lib/src/attachment_title.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart similarity index 96% rename from packages/stream_chat_flutter/lib/src/attachment_title.dart rename to packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart index af5a11151..176bbbd3b 100644 --- a/packages/stream_chat_flutter/lib/src/attachment_title.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'stream_chat_theme.dart'; -import 'utils.dart'; +import '../stream_chat_theme.dart'; +import '../utils.dart'; class AttachmentTitle extends StatelessWidget { const AttachmentTitle({ diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart new file mode 100644 index 000000000..19bf6a1ab --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/upload_progress_indicator.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +typedef InProgressBuilder = Widget Function(BuildContext, int, int); +typedef FailedBuilder = Widget Function(BuildContext, String); + +class AttachmentUploadStateBuilder extends StatelessWidget { + final Message message; + final Attachment attachment; + final FailedBuilder failedBuilder; + final WidgetBuilder successBuilder; + final InProgressBuilder inProgressBuilder; + + const AttachmentUploadStateBuilder({ + Key key, + @required this.message, + @required this.attachment, + this.failedBuilder, + this.successBuilder, + this.inProgressBuilder, + }) : assert(message != null), + assert(attachment != null), + super(key: key); + + @override + Widget build(BuildContext context) { + if (attachment.uploadState == null) return Offstage(); + + final messageId = message.id; + final attachmentId = attachment.id; + + var inProgress = inProgressBuilder; + inProgress ??= (context, int sent, int total) { + return _InProgressState( + sent: sent, + total: total, + attachmentId: attachmentId, + ); + }; + + var failed = failedBuilder; + failed ??= (context, error) { + return _FailedState( + error: error, + messageId: messageId, + attachmentId: attachmentId, + ); + }; + + var success = successBuilder; + success ??= (context) => _SuccessState(); + + return attachment.uploadState.when( + inProgress: (sent, total) => inProgress(context, sent, total), + success: () => success(context), + failed: (error) => failed(context, error), + ); + } +} + +class _IconButton extends StatelessWidget { + final Widget icon; + final double iconSize; + final VoidCallback onPressed; + final Color fillColor; + + const _IconButton({ + Key key, + this.icon, + this.iconSize = 24.0, + this.onPressed, + this.fillColor, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: iconSize, + width: iconSize, + child: RawMaterialButton( + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + disabledElevation: 0, + hoverElevation: 0, + onPressed: onPressed, + fillColor: + fillColor ?? StreamChatTheme.of(context).colorTheme.overlayDark, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: icon, + ), + ); + } +} + +class _InProgressState extends StatelessWidget { + final int sent; + final int total; + final String attachmentId; + + const _InProgressState({ + Key key, + @required this.sent, + @required this.total, + @required this.attachmentId, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final channel = StreamChannel.of(context).channel; + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _IconButton( + icon: StreamSvgIcon.close( + color: StreamChatTheme.of(context).colorTheme.white, + ), + onPressed: () => channel.cancelAttachmentUpload(attachmentId), + ), + Center( + child: UploadProgressIndicator( + uploaded: sent, + total: total, + ), + ) + ], + ); + } +} + +class _FailedState extends StatelessWidget { + final String error; + final String messageId; + final String attachmentId; + + const _FailedState({ + Key key, + this.error, + @required this.messageId, + @required this.attachmentId, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final channel = StreamChannel.of(context).channel; + final theme = StreamChatTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _IconButton( + icon: StreamSvgIcon.retry( + color: theme.colorTheme.white, + ), + onPressed: () { + return channel.retryAttachmentUpload(messageId, attachmentId); + }, + ), + Center( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorTheme.overlayDark.withOpacity(0.6), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + child: Text( + 'UPLOAD ERROR', + style: theme.textTheme.footnote.copyWith( + color: theme.colorTheme.white, + ), + ), + ), + ), + ) + ], + ); + } +} + +class _SuccessState extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topRight, + child: CircleAvatar( + backgroundColor: StreamChatTheme.of(context).colorTheme.overlayDark, + maxRadius: 12.0, + child: StreamSvgIcon.check( + color: StreamChatTheme.of(context).colorTheme.white, + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart new file mode 100644 index 000000000..0fd0593dc --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import '../stream_chat_theme.dart'; + +enum AttachmentSource { + local, + network, +} + +extension AttachmentSourceX on AttachmentSource { + /// The [when] method is the equivalent to pattern matching. + /// Its prototype depends on the AttachmentSource defined. + T when({ + @required T Function() local, + @required T Function() network, + }) { + assert(() { + if (local == null || network == null) { + throw 'check for all possible cases'; + } + return true; + }()); + switch (this) { + case AttachmentSource.local: + return local(); + case AttachmentSource.network: + return network(); + } + } +} + +abstract class AttachmentWidget extends StatelessWidget { + final Size size; + final Message message; + final Attachment attachment; + final AttachmentSource _source; + + AttachmentSource get source => _source ?? attachment.file != null + ? AttachmentSource.local + : AttachmentSource.network; + + const AttachmentWidget({ + Key key, + @required this.message, + @required this.attachment, + this.size, + AttachmentSource source, + }) : _source = source, + super(key: key); +} + +class AttachmentError extends StatelessWidget { + final Size size; + + const AttachmentError({ + Key key, + this.size, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + width: size?.width, + height: size?.height, + color: StreamChatTheme.of(context).colorTheme.accentRed.withOpacity(.1), + child: Center( + child: Icon( + Icons.error_outline, + color: StreamChatTheme.of(context).colorTheme.black, + ), + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart new file mode 100644 index 000000000..4cf55aede --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart @@ -0,0 +1,255 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/utils.dart'; +import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import '../upload_progress_indicator.dart'; +import 'attachment_widget.dart'; + +class FileAttachment extends AttachmentWidget { + final Widget title; + final Widget trailing; + + const FileAttachment({ + Key key, + @required Message message, + @required Attachment attachment, + Size size, + this.title, + this.trailing, + }) : super(key: key, message: message, attachment: attachment, size: size); + + bool get isVideoAttachment => attachment.title?.mimeType?.type == 'video'; + + bool get isImageAttachment => attachment.title?.mimeType?.type == 'image'; + + @override + Widget build(BuildContext context) { + return Material( + child: Container( + width: size?.width ?? 100, + height: 56.0, + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: StreamChatTheme.of(context).colorTheme.greyWhisper, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + child: _getFileTypeImage(context), + height: 40.0, + width: 33.33, + margin: EdgeInsets.all(8.0), + ), + SizedBox(width: 8.0), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + attachment?.title ?? 'File', + style: StreamChatTheme.of(context).textTheme.bodyBold, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox(height: 3.0), + _buildSubtitle(context), + ], + ), + ), + SizedBox(width: 8.0), + _buildTrailing(context), + ], + ), + ), + ); + } + + ShapeBorder _getDefaultShape(BuildContext context) { + return RoundedRectangleBorder( + side: BorderSide(width: 0.0, color: Colors.transparent), + borderRadius: BorderRadius.circular(8), + ); + } + + Widget _getFileTypeImage(BuildContext context) { + if (isImageAttachment) { + return Material( + clipBehavior: Clip.antiAlias, + type: MaterialType.transparency, + shape: _getDefaultShape(context), + child: source.when( + local: () => Image.memory( + attachment.file.bytes, + fit: BoxFit.cover, + errorBuilder: (_, obj, trace) { + return getFileTypeImage(attachment.extraData['other']); + }, + ), + network: () => CachedNetworkImage( + imageUrl: attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl, + fit: BoxFit.cover, + errorWidget: (_, obj, trace) { + return getFileTypeImage(attachment.extraData['other']); + }, + progressIndicatorBuilder: (context, _, progress) { + return Center( + child: Container( + width: 20.0, + height: 20.0, + child: const CircularProgressIndicator(), + ), + ); + }, + ), + ), + ); + } + + if (isVideoAttachment) { + return Material( + clipBehavior: Clip.antiAlias, + type: MaterialType.transparency, + shape: _getDefaultShape(context), + child: source.when( + local: () => VideoThumbnailImage( + video: attachment.file.path, + placeholderBuilder: (_) { + return Center( + child: Container( + width: 20.0, + height: 20.0, + child: const CircularProgressIndicator(), + ), + ); + }, + ), + network: () => VideoThumbnailImage( + video: attachment.assetUrl, + placeholderBuilder: (_) { + return Center( + child: Container( + width: 20.0, + height: 20.0, + child: const CircularProgressIndicator(), + ), + ); + }, + ), + ), + ); + } + return getFileTypeImage(attachment.extraData['mime_type']); + } + + Widget _buildButton({ + Widget icon, + double iconSize = 24.0, + VoidCallback onPressed, + Color fillColor, + }) { + return Container( + height: iconSize, + width: iconSize, + child: RawMaterialButton( + elevation: 0, + highlightElevation: 0, + focusElevation: 0, + disabledElevation: 0, + hoverElevation: 0, + onPressed: onPressed, + fillColor: fillColor, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: icon, + ), + ); + } + + Widget _buildTrailing(BuildContext context) { + final theme = StreamChatTheme.of(context); + final channel = StreamChannel.of(context).channel; + final attachmentId = attachment.id; + var trailingWidget = trailing; + trailingWidget ??= attachment.uploadState?.when( + inProgress: (_, __) => Padding( + padding: const EdgeInsets.all(8.0), + child: _buildButton( + icon: StreamSvgIcon.close(color: theme.colorTheme.white), + fillColor: theme.colorTheme.overlayDark, + onPressed: () => channel.cancelAttachmentUpload(attachmentId), + ), + ), + success: () => Padding( + padding: const EdgeInsets.all(8.0), + child: CircleAvatar( + backgroundColor: theme.colorTheme.accentBlue, + maxRadius: 12.0, + child: StreamSvgIcon.check(color: theme.colorTheme.white), + ), + ), + failed: (_) => Padding( + padding: const EdgeInsets.all(8.0), + child: _buildButton( + icon: StreamSvgIcon.retry(color: theme.colorTheme.white), + fillColor: theme.colorTheme.overlayDark, + onPressed: () => channel.retryAttachmentUpload( + message?.id, + attachmentId, + ), + ), + ), + ) ?? + IconButton( + icon: StreamSvgIcon.cloudDownload(color: theme.colorTheme.black), + padding: const EdgeInsets.all(8), + visualDensity: VisualDensity.compact, + splashRadius: 16, + onPressed: () { + launchURL(context, attachment.assetUrl); + }, + ); + + return Material( + type: MaterialType.transparency, + child: trailingWidget, + ); + } + + Widget _buildSubtitle(BuildContext context) { + final theme = StreamChatTheme.of(context); + final size = attachment.file?.size ?? attachment.extraData['file_size']; + final textStyle = theme.textTheme.footnote.copyWith( + color: theme.colorTheme.grey, + ); + return attachment.uploadState?.when( + inProgress: (sent, total) { + return UploadProgressIndicator( + uploaded: sent, + total: total, + showBackground: false, + padding: EdgeInsets.zero, + textStyle: textStyle, + progressIndicatorColor: theme.colorTheme.accentBlue, + ); + }, + success: () { + return Text( + '${fileSize(size, 1)}/${fileSize(size, 1)}', + style: textStyle, + ); + }, + failed: (_) => Text('UPLOAD ERROR', style: textStyle), + ) ?? + Text('${fileSize(size)}', style: textStyle); + } +} diff --git a/packages/stream_chat_flutter/lib/src/giphy_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart similarity index 83% rename from packages/stream_chat_flutter/lib/src/giphy_attachment.dart rename to packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart index 0f89bc75d..ed5a5750c 100644 --- a/packages/stream_chat_flutter/lib/src/giphy_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart @@ -1,48 +1,42 @@ import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import '../stream_chat_flutter.dart'; -import 'attachment_error.dart'; -import 'full_screen_media.dart'; +import '../full_screen_media.dart'; +import '../stream_chat_theme.dart'; +import '../stream_svg_icon.dart'; +import 'attachment_widget.dart'; -class GiphyAttachment extends StatelessWidget { - final Attachment attachment; +class GiphyAttachment extends AttachmentWidget { final MessageTheme messageTheme; - final Message message; - final Size size; final ShowMessageCallback onShowMessage; final ValueChanged onReturnAction; const GiphyAttachment({ Key key, - this.attachment, + @required Message message, + @required Attachment attachment, + Size size, this.messageTheme, - this.message, - this.size, this.onShowMessage, this.onReturnAction, - }) : super(key: key); + }) : super(key: key, message: message, attachment: attachment, size: size); @override Widget build(BuildContext context) { - if (attachment.thumbUrl == null && - attachment.imageUrl == null && - attachment.assetUrl == null) { - return AttachmentError( - attachment: attachment, - ); + final imageUrl = + attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl; + if (imageUrl == null && source == AttachmentSource.network) { + return AttachmentError(); } - - return attachment.actions != null - ? _buildSendingAttachment(context) - : _buildSentAttachment(context); + if (attachment.actions != null) { + return _buildSendingAttachment(context, imageUrl); + } + return _buildSentAttachment(context, imageUrl); } - Widget _buildSendingAttachment(context) { + Widget _buildSendingAttachment(BuildContext context, String imageUrl) { final streamChannel = StreamChannel.of(context); - return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -67,9 +61,7 @@ class GiphyAttachment extends StatelessWidget { Padding( padding: const EdgeInsets.all(8.0), child: GestureDetector( - onTap: () async { - _onImageTap(context); - }, + onTap: () => _onImageTap(context), child: ClipRRect( borderRadius: BorderRadius.only( topLeft: Radius.circular(8), @@ -87,13 +79,10 @@ class GiphyAttachment extends StatelessWidget { ), ); }, - imageUrl: attachment.thumbUrl ?? - attachment.imageUrl ?? - attachment.assetUrl, - errorWidget: (context, url, error) => AttachmentError( - attachment: attachment, - size: size, - ), + imageUrl: imageUrl, + errorWidget: (context, url, error) { + return AttachmentError(size: size); + }, fit: BoxFit.cover, ), ), @@ -305,44 +294,38 @@ class GiphyAttachment extends StatelessWidget { } void _onImageTap(BuildContext context) async { - var res = await Navigator.push(context, MaterialPageRoute( - builder: (_) { - final channel = StreamChannel.of(context).channel; - - return StreamChannel( - channel: channel, - child: FullScreenMedia( - mediaAttachments: [ - attachment, - ], - userName: message.user.name, - sentAt: message.createdAt, - message: message, - onShowMessage: onShowMessage, - ), - ); - }, - )); - - if (res != null) { - onReturnAction(res); - } + final res = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) { + final channel = StreamChannel.of(context).channel; + return StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [attachment], + userName: message.user.name, + sentAt: message.createdAt, + message: message, + onShowMessage: onShowMessage, + ), + ); + }, + ), + ); + if (res != null) onReturnAction(res); } - Widget _buildSentAttachment(context) { + Widget _buildSentAttachment(BuildContext context, String imageUrl) { return Container( child: GestureDetector( onTap: () async { - var res = + final res = await Navigator.push(context, MaterialPageRoute(builder: (_) { - var channel = StreamChannel.of(context).channel; - + final channel = StreamChannel.of(context).channel; return StreamChannel( channel: channel, child: FullScreenMedia( - mediaAttachments: [ - attachment, - ], + mediaAttachments: [attachment], userName: message.user.name, sentAt: message.createdAt, message: message, @@ -350,10 +333,7 @@ class GiphyAttachment extends StatelessWidget { ), ); })); - - if (res != null) { - onReturnAction(res); - } + if (res != null) onReturnAction(res); }, child: Stack( children: [ @@ -369,13 +349,10 @@ class GiphyAttachment extends StatelessWidget { ), ); }, - imageUrl: attachment.thumbUrl ?? - attachment.imageUrl ?? - attachment.assetUrl, - errorWidget: (context, url, error) => AttachmentError( - attachment: attachment, - size: size, - ), + imageUrl: imageUrl, + errorWidget: (context, url, error) { + return AttachmentError(size: size); + }, fit: BoxFit.cover, ), Positioned( diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart new file mode 100644 index 000000000..d4b70dd64 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart @@ -0,0 +1,139 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/attachment/attachment_upload_state_builder.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import 'attachment_title.dart'; +import '../full_screen_media.dart'; +import '../stream_chat_theme.dart'; +import 'attachment_widget.dart'; + +class ImageAttachment extends AttachmentWidget { + final MessageTheme messageTheme; + final bool showTitle; + final ShowMessageCallback onShowMessage; + final ValueChanged onReturnAction; + final VoidCallback onAttachmentTap; + + const ImageAttachment({ + Key key, + @required Message message, + @required Attachment attachment, + Size size, + this.messageTheme, + this.showTitle = false, + this.onShowMessage, + this.onReturnAction, + this.onAttachmentTap, + }) : super(key: key, message: message, attachment: attachment, size: size); + + @override + Widget build(BuildContext context) { + return source.when( + local: () { + if (attachment.localUri == null) { + return AttachmentError(size: size); + } + return _buildImageAttachment( + context, + Image.memory( + attachment.file.bytes, + height: size?.height, + width: size?.width, + fit: BoxFit.cover, + errorBuilder: (context, _, __) { + return Image.asset( + 'images/placeholder.png', + package: 'stream_chat_flutter', + ); + }, + ), + ); + }, + network: () { + final imageUrl = + attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl; + if (imageUrl == null) { + return AttachmentError(size: size); + } + return _buildImageAttachment( + context, + CachedNetworkImage( + height: size?.height, + width: size?.width, + placeholder: (_, __) { + return Container( + width: size?.width, + height: size?.height, + child: Center( + child: CircularProgressIndicator(), + ), + ); + }, + imageUrl: imageUrl, + errorWidget: (context, url, error) { + return AttachmentError(size: size); + }, + fit: BoxFit.cover, + ), + ); + }, + ); + } + + Widget _buildImageAttachment(BuildContext context, Widget imageWidget) { + return ConstrainedBox( + constraints: BoxConstraints.loose(size), + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + GestureDetector( + onTap: onAttachmentTap ?? + () async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) { + final channel = StreamChannel.of(context).channel; + return StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [attachment], + userName: message.user.name, + sentAt: message.createdAt, + message: message, + onShowMessage: onShowMessage, + ), + ); + }, + ), + ); + if (result != null) onReturnAction(result); + }, + child: imageWidget, + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: AttachmentUploadStateBuilder( + message: message, + attachment: attachment, + ), + ), + ], + ), + ), + if (showTitle && attachment.title != null) + Material( + color: messageTheme.messageBackgroundColor, + child: AttachmentTitle( + messageTheme: messageTheme, + attachment: attachment, + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart new file mode 100644 index 000000000..dc168bd2c --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/full_screen_media.dart'; +import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +import 'attachment_title.dart'; +import 'attachment_upload_state_builder.dart'; +import 'attachment_widget.dart'; + +class VideoAttachment extends AttachmentWidget { + final MessageTheme messageTheme; + final ShowMessageCallback onShowMessage; + final ValueChanged onReturnAction; + + const VideoAttachment({ + Key key, + @required Message message, + @required Attachment attachment, + Size size, + this.messageTheme, + this.onShowMessage, + this.onReturnAction, + }) : super(key: key, message: message, attachment: attachment, size: size); + + @override + Widget build(BuildContext context) { + return source.when( + local: () { + if (attachment.file == null) { + return AttachmentError(size: size); + } + return _buildVideoAttachment( + context, + VideoThumbnailImage( + video: attachment.file.path, + height: size?.height, + width: size?.width, + fit: BoxFit.cover, + errorBuilder: (_, __) => AttachmentError(size: size), + ), + ); + }, + network: () { + if (attachment.assetUrl == null) { + return AttachmentError(size: size); + } + return _buildVideoAttachment( + context, + VideoThumbnailImage( + video: attachment.assetUrl, + height: size?.height, + width: size?.width, + fit: BoxFit.cover, + errorBuilder: (_, __) => AttachmentError(size: size), + ), + ); + }, + ); + } + + Widget _buildVideoAttachment(BuildContext context, Widget videoWidget) { + return ConstrainedBox( + constraints: BoxConstraints.loose(size), + child: Column( + children: [ + Expanded( + child: GestureDetector( + onTap: () async { + final channel = StreamChannel.of(context).channel; + final res = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => StreamChannel( + channel: channel, + child: FullScreenMedia( + mediaAttachments: [attachment], + userName: message.user.name, + sentAt: message.createdAt, + message: message, + onShowMessage: onShowMessage, + ), + ), + ), + ); + if (res != null) onReturnAction(res); + }, + child: Stack( + children: [ + Container( + height: size?.height, + width: size?.width, + child: videoWidget, + ), + Center( + child: Material( + shape: CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Icon(Icons.play_arrow), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: AttachmentUploadStateBuilder( + message: message, + attachment: attachment, + ), + ), + ], + ), + ), + ), + if (attachment.title != null) + Material( + color: messageTheme.messageBackgroundColor, + child: AttachmentTitle( + messageTheme: messageTheme, + attachment: attachment, + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment_error.dart b/packages/stream_chat_flutter/lib/src/attachment_error.dart deleted file mode 100644 index 46b7b814a..000000000 --- a/packages/stream_chat_flutter/lib/src/attachment_error.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import '../stream_chat_flutter.dart'; - -class AttachmentError extends StatelessWidget { - final Attachment attachment; - final Size size; - - const AttachmentError({ - Key key, - @required this.attachment, - this.size, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - if (attachment.localUri != null) { - return Image.file( - File(attachment.localUri.path), - ); - } - return Center( - child: Container( - width: size?.width, - height: size?.height ?? 200, - color: StreamChatTheme.of(context).colorTheme.accentRed.withOpacity(.1), - child: Center( - child: Icon( - Icons.error_outline, - color: StreamChatTheme.of(context).colorTheme.black, - ), - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart b/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart index 244729a42..a010a5003 100644 --- a/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart +++ b/packages/stream_chat_flutter/lib/src/channel_file_display_screen.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'attachment/attachment.dart'; + class ChannelFileDisplayScreen extends StatefulWidget { /// The sorting used for the channels matching the filters. /// Sorting is based on field and direction, multiple sorting options can be provided. @@ -164,6 +166,7 @@ class _ChannelFileDisplayScreenState extends State { child: Padding( padding: const EdgeInsets.all(8.0), child: FileAttachment( + message: media.values.toList()[position], attachment: media.keys.toList()[position], ), ), diff --git a/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart b/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart index 8e9f566c2..ab2e20550 100644 --- a/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart +++ b/packages/stream_chat_flutter/lib/src/channel_media_display_screen.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:video_player/video_player.dart'; +import 'attachment/attachment.dart'; + class ChannelMediaDisplayScreen extends StatefulWidget { /// The sorting used for the channels matching the filters. /// Sorting is based on field and direction, multiple sorting options can be provided. diff --git a/packages/stream_chat_flutter/lib/src/compress_video_service.dart b/packages/stream_chat_flutter/lib/src/compress_video_service.dart deleted file mode 100644 index 2a36c1f55..000000000 --- a/packages/stream_chat_flutter/lib/src/compress_video_service.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:async'; - -import 'package:synchronized/synchronized.dart'; -import 'package:video_compress/video_compress.dart'; - -class ICompressVideoService { - static final ICompressVideoService instance = ICompressVideoService._(); - final _lock = Lock(); - - ICompressVideoService._(); - - Future compress(String path) async { - return _lock.synchronized(() { - return VideoCompress.compressVideo( - path, - ); - }); - } -} - -ICompressVideoService get compressVideoService => - ICompressVideoService.instance; diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index 0997d1371..af2b0994a 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -1,7 +1,7 @@ import 'package:characters/characters.dart'; import 'package:emojis/emoji.dart'; -import 'package:http_parser/http_parser.dart' as http_parser; -import 'package:mime/mime.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; final _emojis = Emoji.all(); @@ -23,16 +23,6 @@ extension StringExtension on String { if (characters.length > 3) return false; return characters.every((c) => _emojis.map((e) => e.char).contains(c)); } - - /// Returns the mime type from the passed file name. - http_parser.MediaType get mimeType { - if (this == null) return null; - if (toLowerCase().endsWith('heic')) { - return http_parser.MediaType.parse('image/heic'); - } else { - return http_parser.MediaType.parse(lookupMimeType(this)); - } - } } /// List extension @@ -43,3 +33,14 @@ extension IterableX on Iterable { yield e; }).skip(1).toList(growable: false); } + +/// +extension PlatformFileX on PlatformFile { + /// + AttachmentFile get toAttachmentFile => AttachmentFile( + path: path, + name: name, + bytes: bytes, + size: size, + ); +} diff --git a/packages/stream_chat_flutter/lib/src/file_attachment.dart b/packages/stream_chat_flutter/lib/src/file_attachment.dart deleted file mode 100644 index f92f0ec19..000000000 --- a/packages/stream_chat_flutter/lib/src/file_attachment.dart +++ /dev/null @@ -1,207 +0,0 @@ -import 'dart:io'; - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; -import 'package:stream_chat_flutter/src/utils.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; -import 'package:video_compress/video_compress.dart'; -import 'package:video_player/video_player.dart'; - -import 'media_utils.dart'; - -enum FileAttachmentType { local, online } - -class FileAttachment extends StatefulWidget { - final Attachment attachment; - final Size size; - final Widget trailing; - final FileAttachmentType attachmentType; - final PlatformFile file; - - const FileAttachment({ - Key key, - @required this.attachment, - this.size, - this.trailing, - this.attachmentType = FileAttachmentType.online, - this.file, - }) : super(key: key); - - @override - _FileAttachmentState createState() => _FileAttachmentState(); -} - -class _FileAttachmentState extends State { - VideoPlayerController _controller; - Future _initializeVideoPlayerFuture; - - @override - void initState() { - super.initState(); - if (MediaUtils.getMimeType(widget.attachment.title)?.type == 'video') { - if (widget.attachmentType == FileAttachmentType.online) { - _controller = VideoPlayerController.network( - widget.attachment.assetUrl, - ); - } else { - _controller = VideoPlayerController.file( - File.fromRawPath(widget.file.bytes), - ); - } - - _initializeVideoPlayerFuture = _controller.initialize(); - } - } - - @override - Widget build(BuildContext context) { - return Material( - child: Container( - width: widget.size?.width ?? 100, - height: 56.0, - decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: StreamChatTheme.of(context).colorTheme.greyWhisper, - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - child: _getFileTypeImage(), - height: 40.0, - width: 33.33, - margin: EdgeInsets.all(8.0), - ), - SizedBox(width: 8.0), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.attachment?.title ?? 'File', - style: StreamChatTheme.of(context).textTheme.bodyBold, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - SizedBox(height: 3.0), - Text( - '${filesize(widget.attachment.extraData['file_size'])}', - style: StreamChatTheme.of(context) - .textTheme - .footnote - .copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(0.5)), - ), - ], - ), - ), - SizedBox(width: 8.0), - Material( - type: MaterialType.transparency, - child: widget.trailing ?? - IconButton( - icon: StreamSvgIcon.cloudDownload( - color: StreamChatTheme.of(context).colorTheme.black, - ), - padding: const EdgeInsets.all(8), - visualDensity: VisualDensity.compact, - splashRadius: 16, - onPressed: () { - launchURL(context, widget.attachment.assetUrl); - }, - ), - ), - ], - ), - ), - ); - } - - Widget _getFileTypeImage() { - if ((MediaUtils.getMimeType(widget.attachment.title)?.type == 'image')) { - switch (widget.attachmentType) { - case FileAttachmentType.local: - return Image.memory( - widget.file.bytes, - fit: BoxFit.cover, - errorBuilder: (_, obj, trace) { - return getFileTypeImage(widget.attachment.extraData['other']); - }, - ); - break; - case FileAttachmentType.online: - return CachedNetworkImage( - imageUrl: widget.attachment.imageUrl ?? - widget.attachment.assetUrl ?? - widget.attachment.thumbUrl, - fit: BoxFit.cover, - errorWidget: (_, obj, trace) { - return getFileTypeImage(widget.attachment.extraData['other']); - }, - progressIndicatorBuilder: (context, _, progress) { - return Center( - child: Container( - width: 20.0, - height: 20.0, - child: CircularProgressIndicator( - backgroundColor: - StreamChatTheme.of(context).colorTheme.accentBlue, - ), - ), - ); - }, - ); - break; - } - } - - if ((MediaUtils.getMimeType(widget.attachment.title)?.type == 'video')) { - switch (widget.attachmentType) { - case FileAttachmentType.local: - return FutureBuilder( - future: VideoCompress.getFileThumbnail(widget.file.path), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Image.asset( - 'images/placeholder.png', - package: 'stream_chat_flutter', - ); - } - - return Image.file( - snapshot.data, - fit: BoxFit.cover, - ); - }, - ); - break; - case FileAttachmentType.online: - return FutureBuilder( - future: _initializeVideoPlayerFuture, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done) { - return AspectRatio( - aspectRatio: _controller.value.aspectRatio, - child: VideoPlayer(_controller), - ); - } else { - return Center(child: CircularProgressIndicator()); - } - }, - ); - break; - } - } - return getFileTypeImage(widget.attachment.extraData['mime_type']); - } -} diff --git a/packages/stream_chat_flutter/lib/src/full_screen_media.dart b/packages/stream_chat_flutter/lib/src/full_screen_media.dart index bee8f2bad..6da6a2e06 100644 --- a/packages/stream_chat_flutter/lib/src/full_screen_media.dart +++ b/packages/stream_chat_flutter/lib/src/full_screen_media.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:cached_network_image/cached_network_image.dart'; import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; @@ -48,36 +51,34 @@ class _FullScreenMediaState extends State int _currentPage; - List videoPackages = []; + final videoPackages = {}; @override void initState() { super.initState(); - _controller = - AnimationController(vsync: this, duration: Duration(milliseconds: 300)); + _controller = AnimationController( + vsync: this, + duration: Duration(milliseconds: 300), + ); _pageController = PageController(initialPage: widget.startIndex); _currentPage = widget.startIndex; - widget.mediaAttachments - .where((element) => element.type == 'video') - .toList() - .forEach((element) { - videoPackages.add(VideoPackage( - context, - element, - () { - setState(() {}); - }, - showControls: true, - )); - }); + for (final attachment in widget.mediaAttachments) { + if (attachment.type != 'video') continue; + final package = VideoPackage(attachment, showControls: true); + videoPackages[attachment.id] = package; + } + initializePlayers(); + } + + Future initializePlayers() async { + await Future.wait(videoPackages.values.map( + (it) => it.initialize(), + )); + setState(() {}); } @override Widget build(BuildContext context) { - var videoAttachments = widget.mediaAttachments - .where((element) => element.type == 'video') - .toList(); - return Scaffold( resizeToAvoidBottomInset: false, body: Stack( @@ -92,14 +93,18 @@ class _FullScreenMediaState extends State _currentPage = val; }); }, - itemBuilder: (context, position) { - if (widget.mediaAttachments[position].type == 'image' || - widget.mediaAttachments[position].type == 'giphy') { + itemBuilder: (context, index) { + final attachment = widget.mediaAttachments[index]; + if (attachment.type == 'image' || + attachment.type == 'giphy') { + final imageUrl = attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl; return PhotoView( - imageProvider: CachedNetworkImageProvider( - widget.mediaAttachments[position].imageUrl ?? - widget.mediaAttachments[position].assetUrl ?? - widget.mediaAttachments[position].thumbUrl), + imageProvider: + imageUrl == null && attachment.localUri != null + ? Image.memory(attachment.file.bytes).image + : CachedNetworkImageProvider(imageUrl), maxScale: PhotoViewComputedScale.covered, minScale: PhotoViewComputedScale.contained, heroAttributes: PhotoViewHeroAttributes( @@ -125,12 +130,9 @@ class _FullScreenMediaState extends State } }, ); - } else if (widget.mediaAttachments[position].type == - 'video') { - var controllerPackage = videoPackages[videoAttachments - .indexOf(widget.mediaAttachments[position])]; - - if (!controllerPackage.initialised) { + } else if (attachment.type == 'video') { + final controller = videoPackages[attachment.id]; + if (!controller.initialized) { return Center( child: CircularProgressIndicator(), ); @@ -151,7 +153,7 @@ class _FullScreenMediaState extends State vertical: 50.0, ), child: Chewie( - controller: controllerPackage.chewieController, + controller: controller.chewieController, ), ), ); @@ -188,7 +190,6 @@ class _FullScreenMediaState extends State totalPages: widget.mediaAttachments.length, mediaAttachments: widget.mediaAttachments, message: widget.message, - videoPackages: videoPackages, mediaSelectedCallBack: (val) { setState(() { _currentPage = val; @@ -224,54 +225,58 @@ class _FullScreenMediaState extends State } @override - void dispose() { - videoPackages.forEach((element) { - element.dispose(); - }); + void dispose() async { + for (final package in videoPackages.values) { + await package.dispose(); + } super.dispose(); } } class VideoPackage { - VideoPlayerController _videoPlayerController; + final bool _showControls; + final bool _autoInitialize; + final VideoPlayerController _videoPlayerController; ChewieController _chewieController; - bool initialised = false; - VoidCallback onInit; - BuildContext context; - bool showControls; - /// - VideoPackage(this.context, Attachment attachment, this.onInit, - {this.showControls = false}) { - _videoPlayerController = VideoPlayerController.network(attachment.assetUrl); - _videoPlayerController.initialize().whenComplete(() { - initialised = true; + VideoPlayerController get videoPlayer => _videoPlayerController; + + ChewieController get chewieController => _chewieController; + + bool get initialized => _videoPlayerController.value.initialized; + + VideoPackage( + Attachment attachment, { + bool showControls = false, + bool autoInitialize = true, + }) : assert(attachment != null), + _showControls = showControls, + _autoInitialize = autoInitialize, + _videoPlayerController = attachment.localUri != null + ? VideoPlayerController.file(File.fromUri(attachment.localUri)) + : VideoPlayerController.network(attachment.assetUrl); + + Future initialize() { + return _videoPlayerController.initialize().then((_) { _chewieController = ChewieController( videoPlayerController: _videoPlayerController, - autoInitialize: true, - showControls: showControls, + autoInitialize: _autoInitialize, + showControls: _showControls, aspectRatio: _videoPlayerController.value.aspectRatio, ); - onInit(); }); - - VoidCallback errorListener; - errorListener = () { - if (_videoPlayerController.value.hasError) { - Navigator.pop(context); - launchURL(context, attachment.titleLink); - } - _videoPlayerController.removeListener(errorListener); - }; - _videoPlayerController.addListener(errorListener); } - VideoPlayerController get videoPlayer => _videoPlayerController; + void addListener(VoidCallback listener) { + return _videoPlayerController.addListener(listener); + } - ChewieController get chewieController => _chewieController; + void removeListener(VoidCallback listener) { + return _videoPlayerController.removeListener(listener); + } - void dispose() { - _videoPlayerController.dispose(); - _chewieController.dispose(); + Future dispose() { + _chewieController?.dispose(); + return _videoPlayerController?.dispose(); } } diff --git a/packages/stream_chat_flutter/lib/src/image_actions_modal.dart b/packages/stream_chat_flutter/lib/src/image_actions_modal.dart index 4be1dc1d6..3c4a7c73c 100644 --- a/packages/stream_chat_flutter/lib/src/image_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/image_actions_modal.dart @@ -105,10 +105,9 @@ class ImageActionsModal extends StatelessWidget { () { Navigator.pop(context); Navigator.pop(context); - StreamChat.of(context).client.deleteMessage( - message, - StreamChannel.of(context).channel.cid, - ); + StreamChannel.of(context) + .channel + .deleteMessage(message); }, color: StreamChatTheme.of(context).colorTheme.accentRed, ), diff --git a/packages/stream_chat_flutter/lib/src/image_attachment.dart b/packages/stream_chat_flutter/lib/src/image_attachment.dart deleted file mode 100644 index 4be472628..000000000 --- a/packages/stream_chat_flutter/lib/src/image_attachment.dart +++ /dev/null @@ -1,124 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; - -import '../stream_chat_flutter.dart'; -import 'attachment_error.dart'; -import 'attachment_title.dart'; -import 'full_screen_media.dart'; -import 'utils.dart'; - -class ImageAttachment extends StatelessWidget { - final Attachment attachment; - final Message message; - final MessageTheme messageTheme; - final Size size; - final bool showTitle; - final ShowMessageCallback onShowMessage; - final ValueChanged onReturnAction; - - const ImageAttachment({ - Key key, - @required this.attachment, - @required this.message, - @required this.size, - this.messageTheme, - this.showTitle = true, - this.onShowMessage, - this.onReturnAction, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - if (attachment.thumbUrl == null && - attachment.imageUrl == null && - attachment.assetUrl == null) { - return AttachmentError( - attachment: attachment, - ); - } - return ConstrainedBox( - constraints: BoxConstraints.loose(size), - child: Stack( - children: [ - Column( - children: [ - Expanded( - child: GestureDetector( - onTap: () async { - var result = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) { - final channel = StreamChannel.of(context).channel; - - return StreamChannel( - channel: channel, - child: FullScreenMedia( - mediaAttachments: [ - attachment, - ], - userName: message.user.name, - sentAt: message.createdAt, - message: message, - onShowMessage: onShowMessage, - ), - ); - }, - ), - ); - - if (result != null) { - onReturnAction(result); - } - }, - child: 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, - ), - ), - ), - if (showTitle && attachment.title != null) - Material( - color: messageTheme.messageBackgroundColor, - child: AttachmentTitle( - messageTheme: messageTheme, - attachment: attachment, - ), - ), - ], - ), - if (showTitle && - (attachment.titleLink != null || attachment.ogScrapeUrl != null)) - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => launchURL( - context, - attachment.titleLink ?? attachment.ogScrapeUrl, - ), - ), - ), - ), - ], - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/image_footer.dart b/packages/stream_chat_flutter/lib/src/image_footer.dart index 7b0ed6219..c509c4697 100644 --- a/packages/stream_chat_flutter/lib/src/image_footer.dart +++ b/packages/stream_chat_flutter/lib/src/image_footer.dart @@ -2,12 +2,12 @@ import 'dart:async'; import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:chewie/chewie.dart'; import 'package:dio/dio.dart'; import 'package:esys_flutter_share/esys_flutter_share.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; @@ -28,7 +28,6 @@ class ImageFooter extends StatefulWidget implements PreferredSizeWidget { final List mediaAttachments; final Message message; - final List videoPackages; final ValueChanged mediaSelectedCallBack; /// Creates a channel header @@ -41,7 +40,6 @@ class ImageFooter extends StatefulWidget implements PreferredSizeWidget { this.totalPages = 0, this.mediaAttachments, this.message, - this.videoPackages, this.mediaSelectedCallBack, }) : preferredSize = Size.fromHeight(kToolbarHeight), super(key: key); @@ -214,17 +212,14 @@ class _ImageFooterState extends State { itemBuilder: (context, index) { Widget media; final attachment = widget.mediaAttachments[index]; - if (attachment.type == 'video') { - var controllerPackage = widget.videoPackages[ - videoAttachments.indexOf(attachment)]; - media = InkWell( onTap: () => widget.mediaSelectedCallBack(index), child: FittedBox( fit: BoxFit.cover, - child: Chewie( - controller: controllerPackage.chewieController, + child: VideoThumbnailImage( + video: attachment.file?.path ?? + attachment.assetUrl, ), ), ); diff --git a/packages/stream_chat_flutter/lib/src/image_group.dart b/packages/stream_chat_flutter/lib/src/image_group.dart index 3a290c60f..39c2883a2 100644 --- a/packages/stream_chat_flutter/lib/src/image_group.dart +++ b/packages/stream_chat_flutter/lib/src/image_group.dart @@ -1,4 +1,3 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter/src/full_screen_media.dart'; @@ -9,12 +8,14 @@ class ImageGroup extends StatelessWidget { Key key, @required this.images, @required this.message, + @required this.messageTheme, @required this.size, this.onShowMessage, }) : super(key: key); final List images; final Message message; + final MessageTheme messageTheme; final Size size; final ShowMessageCallback onShowMessage; @@ -129,14 +130,12 @@ class ImageGroup extends StatelessWidget { } Widget _buildImage(BuildContext context, int index) { - return GestureDetector( - onTap: () => _onTap(context, index), - child: CachedNetworkImage( - imageUrl: images[index].imageUrl ?? - images[index].thumbUrl ?? - images[index].assetUrl, - fit: BoxFit.cover, - ), + return ImageAttachment( + attachment: images[index], + size: size, + message: message, + messageTheme: messageTheme, + onAttachmentTap: () => _onTap(context, index), ); } } diff --git a/packages/stream_chat_flutter/lib/src/media_utils.dart b/packages/stream_chat_flutter/lib/src/media_utils.dart deleted file mode 100644 index e55e02df8..000000000 --- a/packages/stream_chat_flutter/lib/src/media_utils.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:http_parser/http_parser.dart' as http_parser; -import 'package:mime/mime.dart'; - -class MediaUtils { - static http_parser.MediaType getMimeType(String filename) { - http_parser.MediaType mimeType; - if (filename != null) { - if (filename.toLowerCase().endsWith('heic')) { - mimeType = http_parser.MediaType.parse('image/heic'); - } else { - mimeType = http_parser.MediaType.parse(lookupMimeType(filename)); - } - } - - return mimeType; - } -} diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart index 9214befb5..732110142 100644 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart @@ -33,7 +33,6 @@ class MessageActionsModal extends StatefulWidget { final ShapeBorder messageShape; final ShapeBorder attachmentShape; final DisplayWidget showUserAvatar; - final Map videoPackages; const MessageActionsModal({ Key key, @@ -54,7 +53,6 @@ class MessageActionsModal extends StatefulWidget { this.messageShape, this.attachmentShape, this.reverse = false, - this.videoPackages, }) : super(key: key); @override @@ -182,7 +180,6 @@ class _MessageActionsModalState extends State { showSendingIndicator: false, shape: widget.messageShape, attachmentShape: widget.attachmentShape, - videoPackages: widget.videoPackages, ), ), SizedBox(height: 8), @@ -298,10 +295,7 @@ class _MessageActionsModalState extends State { if (answer) { try { Navigator.pop(context); - await StreamChat.of(context).client.deleteMessage( - widget.message, - StreamChannel.of(context).channel.cid, - ); + await StreamChannel.of(context).channel.deleteMessage(widget.message); } catch (err) { _showErrorAlert(); } @@ -570,10 +564,9 @@ class _MessageActionsModalState extends State { return InkWell( onTap: () { Navigator.pop(context); - final client = StreamChat.of(context).client; final channel = StreamChannel.of(context).channel; if (isUpdateFailed) { - client.updateMessage(widget.message, channel.cid); + channel.updateMessage(widget.message); } else { channel.sendMessage(widget.message); } diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 4f995bf93..0a34e517d 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -1,7 +1,7 @@ import 'dart:async'; -import 'dart:io'; import 'dart:math'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:emojis/emoji.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; @@ -11,7 +11,7 @@ import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:image_picker/image_picker.dart'; import 'package:photo_manager/photo_manager.dart'; -import 'package:stream_chat_flutter/src/compress_video_service.dart'; +import 'package:stream_chat_flutter/src/video_service.dart'; import 'package:stream_chat_flutter/src/media_list_view.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; @@ -19,16 +19,16 @@ import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/user_avatar.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:substring_highlight/substring_highlight.dart'; -import 'package:video_compress/video_compress.dart'; import '../stream_chat_flutter.dart'; -import 'attachment_uploader.dart'; +import 'attachment/attachment.dart'; import 'extension.dart'; import 'quoted_message_widget.dart'; +import 'video_thumbnail_image.dart'; typedef AttachmentThumbnailBuilder = Widget Function( BuildContext, - _SendingAttachment, + Attachment, ); enum ActionsLocation { @@ -44,7 +44,7 @@ enum DefaultAttachmentTypes { const _kMinMediaPickerSize = 360.0; -const _kMaxAttachmentSize = 20480; //20MB +const _kMaxAttachmentSize = 20971520; // 20MB in Bytes /// Inactive state /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input.png) @@ -99,7 +99,6 @@ class MessageInput extends StatefulWidget { this.maxHeight = 150, this.keyboardType = TextInputType.multiline, this.disableAttachments = false, - this.attachmentUploader, this.initialMessage, this.textEditingController, this.actions, @@ -135,9 +134,6 @@ class MessageInput extends StatefulWidget { /// If true the attachments button will not be displayed final bool disableAttachments; - /// A delegate to upload attachments - final AttachmentUploader attachmentUploader; - /// The text controller of the TextField final TextEditingController textEditingController; @@ -178,7 +174,7 @@ class MessageInput extends StatefulWidget { } class MessageInputState extends State { - final _attachments = {}; + final _attachments = {}; final List _mentionedUsers = []; final _imagePicker = ImagePicker(); @@ -204,8 +200,6 @@ class MessageInputState extends State { bool get _hasQuotedMessage => widget.quotedMessage != null; - AttachmentUploader _attachmentUploader; - @override void initState() { super.initState(); @@ -391,8 +385,7 @@ class MessageInputState extends State { return Padding( padding: const EdgeInsets.all(8.0), child: AnimatedCrossFade( - crossFadeState: ((_messageIsPresent || _attachments.isNotEmpty) && - _attachments.values.every((a) => a.isUploaded == true)) + crossFadeState: (_messageIsPresent || _attachments.isNotEmpty) ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: _buildSendButton(context), @@ -786,7 +779,7 @@ class MessageInputState extends State { Widget _buildFilePickerSection() { final _attachmentContainsFile = _attachments.values.any((it) { - return it.attachmentType == 'file'; + return it.type == 'file'; }); Color _getIconColor(int index) { @@ -943,7 +936,7 @@ class MessageInputState extends State { Widget _buildPickerSection() { final _attachmentContainsFile = _attachments.values.any((it) { - return it.attachmentType == 'file'; + return it.type == 'file'; }); switch (_filePickerIndex) { @@ -1045,98 +1038,47 @@ class MessageInputState extends State { } void _addAttachment(AssetEntity medium) async { - final attachmentId = medium.id; - _attachments[attachmentId] = _SendingAttachment(id: attachmentId); - try { - final mediaFile = await medium.originFile.timeout( - Duration(seconds: 5), - onTimeout: () => medium.originFile, - ); + final mediaFile = await medium.originFile.timeout( + Duration(seconds: 5), + onTimeout: () => medium.originFile, + ); - var file = PlatformFile( - path: mediaFile.path, - size: ((await mediaFile.length()) / 1024).ceil(), - bytes: mediaFile.readAsBytesSync(), - ); + var file = AttachmentFile( + path: mediaFile.path, + size: await mediaFile.length(), + bytes: mediaFile.readAsBytesSync(), + ); - if (file.size > _kMaxAttachmentSize) { - if (medium?.type == AssetType.video) { - final mediaInfo = await compressVideoService.compress(file.path); + if (file.size > _kMaxAttachmentSize) { + if (medium?.type == AssetType.video) { + final mediaInfo = await VideoService.compressVideo(file.path); - if (mediaInfo.filesize / (1024 * 1024) > _kMaxAttachmentSize) { - _showErrorAlert( - 'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.', - ); - _attachments.remove(attachmentId); - return; - } - file = PlatformFile( - name: file.name, - size: (mediaInfo.filesize / 1024).ceil(), - bytes: await mediaInfo.file.readAsBytes(), - path: mediaInfo.path, - ); - } else { + if (mediaInfo.filesize > _kMaxAttachmentSize) { _showErrorAlert( - 'The file is too large to upload. The file size limit is 20MB.', + 'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.', ); + return; } - } - - setState(() { - _attachments.update(attachmentId, (it) { - return it.copyWith( - file: file, - attachment: Attachment( - localUri: file.path != null ? Uri.parse(file.path) : null, - type: medium?.type == AssetType.image ? 'image' : 'video', - ), - ); - }); - }); - - final fileType = medium.type == AssetType.image - ? DefaultAttachmentTypes.image - : DefaultAttachmentTypes.video; - - final url = await _uploadAttachment( - file, - fileType, - onSendProgress: (sent, total) { - setState(() { - _attachments.update( - attachmentId, - (it) => it.copyWith(totalUploaded: sent, totalSize: total), - ); - }); - }, - ); - - if (fileType == DefaultAttachmentTypes.image) { - _attachments.update(attachmentId, (it) { - return it.copyWith(attachment: it.attachment.copyWith(imageUrl: url)); - }); + file = AttachmentFile( + name: file.name, + size: mediaInfo.filesize, + bytes: await mediaInfo.file.readAsBytes(), + path: mediaInfo.path, + ); } else { - _attachments.update(attachmentId, (it) { - return it.copyWith(attachment: it.attachment.copyWith(assetUrl: url)); - }); - } - - if (mounted) { - setState(() { - // Marking as upload complete - _attachments.update( - attachmentId, - (it) => it.copyWith(totalUploaded: it.totalSize), - ); - }); + _showErrorAlert( + 'The file is too large to upload. The file size limit is 20MB.', + ); } - } catch (e, s) { - setState(() => _attachments.remove(attachmentId)); - print(e); - print(s); - _showErrorAlert('Error adding the attachment: $e'); } + + setState(() { + _attachments[medium.id] = Attachment( + id: medium.id, + file: file, + type: medium.type == AssetType.image ? 'image' : 'video', + ); + }); } Widget _buildCommandIcon(String iconType) { @@ -1561,10 +1503,10 @@ class MessageInputState extends State { Widget _buildAttachments() { if (_attachments.isEmpty) return Offstage(); final fileAttachments = _attachments.values - .where((it) => it.attachmentType == 'file') + .where((it) => it.type == 'file') .toList(growable: false); final remainingAttachments = _attachments.values - .where((it) => it.attachmentType != 'file') + .where((it) => it.type != 'file') .toList(growable: false); return Column( children: [ @@ -1582,37 +1524,20 @@ class MessageInputState extends State { borderRadius: BorderRadius.circular(10), clipBehavior: Clip.antiAlias, child: FileAttachment( - attachment: e.attachment, - attachmentType: FileAttachmentType.local, - file: e.file, + message: null, + attachment: e, size: Size( MediaQuery.of(context).size.width * 0.65, 56.0, ), trailing: Padding( padding: const EdgeInsets.all(8.0), - child: InkWell( - child: CircleAvatar( - backgroundColor: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(0.6), - maxRadius: 12.0, - child: StreamSvgIcon.close( - color: StreamChatTheme.of(context) - .colorTheme - .white, - ), - ), - onTap: () { - setState(() => _attachments.remove(e.id)); - }, - ), + child: _buildRemoveButton(e), ), ), ), ) - .insertBetween(const SizedBox(width: 8)), + .insertBetween(const SizedBox(height: 8)), ), ), ), @@ -1638,16 +1563,11 @@ class MessageInputState extends State { child: _buildAttachment(attachment), ), ), - _buildRemoveButton(attachment), - if (!attachment.isUploaded) - Positioned.fill( - child: Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: CircularProgressIndicator(), - ), - ), - ), + Positioned( + top: 8, + right: 8, + child: _buildRemoveButton(attachment), + ), ], ), ), @@ -1660,44 +1580,10 @@ class MessageInputState extends State { ); } - Widget _buildUploadProgressIndicator(int uploaded, int total) { - final theme = StreamChatTheme.of(context); + Widget _buildRemoveButton(Attachment attachment) { return Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - color: theme.colorTheme.overlayDark.withOpacity(0.6), - ), - child: Padding( - padding: const EdgeInsets.only(top: 5, bottom: 5, right: 11, left: 5), - child: Row( - children: [ - SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator( - strokeWidth: 3, - valueColor: AlwaysStoppedAnimation(Color(0xffb2b2b2)), - ), - ), - SizedBox(width: 8), - Text( - '${filesize(uploaded)} / ${filesize(total)}', - style: theme.textTheme.footnote.copyWith( - color: theme.colorTheme.white, - ), - ), - ], - ), - ), - ); - } - - Positioned _buildRemoveButton(_SendingAttachment attachment) { - return Positioned( height: 24, width: 24, - top: 4, - right: 4, child: RawMaterialButton( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -1721,21 +1607,18 @@ class MessageInputState extends State { ); } - Widget _buildAttachment(_SendingAttachment attachment) { - if (widget.attachmentThumbnailBuilders - ?.containsKey(attachment.attachmentType) == + Widget _buildAttachment(Attachment attachment) { + if (attachment == null) return Offstage(); + + if (widget.attachmentThumbnailBuilders?.containsKey(attachment.type) == true) { - return widget.attachmentThumbnailBuilders[attachment.attachmentType]( + return widget.attachmentThumbnailBuilders[attachment.type]( context, attachment, ); } - if (attachment.attachment == null) { - return SizedBox(); - } - - switch (attachment.attachmentType) { + switch (attachment.type) { case 'image': case 'giphy': return attachment.file != null @@ -1749,32 +1632,33 @@ class MessageInputState extends State { ); }, ) - : Image.network( - attachment.attachment.imageUrl, + : CachedNetworkImage( + imageUrl: attachment.imageUrl ?? + attachment.assetUrl ?? + attachment.thumbUrl, fit: BoxFit.cover, + errorWidget: (_, obj, trace) { + return getFileTypeImage(attachment.extraData['other']); + }, + progressIndicatorBuilder: (context, _, progress) { + return Center( + child: Container( + width: 20.0, + height: 20.0, + child: const CircularProgressIndicator(), + ), + ); + }, ); - break; case 'video': return Stack( children: [ - Positioned.fill( - child: Container( - child: FutureBuilder( - future: VideoCompress.getFileThumbnail(attachment.file.path), - builder: (context, snapshot) { - if (!snapshot.hasData) { - return Image.asset( - 'images/placeholder.png', - package: 'stream_chat_flutter', - ); - } - - return Image.file( - snapshot.data, - fit: BoxFit.cover, - ); - }, - ), + Container( + height: 104, + width: 104, + child: VideoThumbnailImage( + video: attachment.file.path, + fit: BoxFit.cover, ), ), Positioned( @@ -1787,7 +1671,6 @@ class MessageInputState extends State { ), ], ); - break; default: return Container( child: Icon(Icons.insert_drive_file), @@ -1953,11 +1836,9 @@ class MessageInputState extends State { /// Use this to add custom type attachments void addAttachment(Attachment attachment) { setState(() { - final _attachment = _SendingAttachment( - attachment: attachment, - totalUploaded: attachment.extraData['file_size'], + _attachments[attachment.id] = attachment.copyWith( + uploadState: attachment.uploadState ?? UploadState.success(), ); - _attachments[_attachment.id] = _attachment; }); } @@ -1966,7 +1847,7 @@ class MessageInputState extends State { void pickFile(DefaultAttachmentTypes fileType, [bool camera = false]) async { setState(() => _inputEnabled = false); - PlatformFile file; + AttachmentFile file; String attachmentType; if (fileType == DefaultAttachmentTypes.image) { @@ -1988,8 +1869,8 @@ class MessageInputState extends State { return; } final bytes = await pickedFile.readAsBytes(); - file = PlatformFile( - size: (bytes.length / 1024).ceil(), + file = AttachmentFile( + size: bytes.length, path: pickedFile.path, bytes: bytes, ); @@ -2007,7 +1888,7 @@ class MessageInputState extends State { withData: true, ); if (res?.files?.isNotEmpty == true) { - file = res.files.single; + file = res.files.single.toAttachmentFile; } } @@ -2017,7 +1898,7 @@ class MessageInputState extends State { final mimeType = file.path.split('/').last.mimeType; - var extraDataMap = {}; + final extraDataMap = {}; if (camera) { if (mimeType.type == 'video' || mimeType.type == 'image') { @@ -2035,97 +1916,46 @@ class MessageInputState extends State { extraDataMap['file_size'] = file.size; } - final attachment = _SendingAttachment( + final attachment = Attachment( file: file, - attachment: Attachment( - localUri: file.path != null ? Uri.parse(file.path) : null, - type: attachmentType, - extraData: extraDataMap.isNotEmpty ? extraDataMap : null, - title: file.name, - ), + type: attachmentType, + extraData: extraDataMap.isNotEmpty ? extraDataMap : null, ); - final attachmentId = attachment.id; - setState(() => _attachments[attachmentId] = attachment); + _attachments[attachment.id] = attachment; - if (file.size / 1024 > _kMaxAttachmentSize) { - if (attachmentType == 'video') { - final mediaInfo = await compressVideoService.compress(file.path); - file = PlatformFile( - name: mediaInfo.title, - size: (mediaInfo.filesize / 1024).ceil(), + if (file.size > _kMaxAttachmentSize) { + if (attachmentType == 'Video') { + final mediaInfo = await VideoService.compressVideo(file.path); + + if (mediaInfo.filesize > _kMaxAttachmentSize) { + _showErrorAlert( + 'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.', + ); + _attachments.remove(attachment.id); + return; + } + file = AttachmentFile( + name: file.name, + size: mediaInfo.filesize, bytes: await mediaInfo.file.readAsBytes(), path: mediaInfo.path, ); - setState(() { - _attachments.update(attachmentId, (it) => it.copyWith(file: file)); - }); } else { _showErrorAlert( 'The file is too large to upload. The file size limit is 20MB.', ); - setState(() => _attachments.remove(attachmentId)); - return; } } - try { - final url = await _uploadAttachment( - file, - fileType, - onSendProgress: (sent, total) { - setState(() { - _attachments.update( - attachmentId, - (it) => it.copyWith(totalUploaded: sent, totalSize: total), - ); - }); - }, - ); - - if (fileType == DefaultAttachmentTypes.image) { - _attachments.update(attachmentId, (it) { - return it.copyWith(attachment: it.attachment.copyWith(imageUrl: url)); - }); - } else { - _attachments.update(attachmentId, (it) { - return it.copyWith(attachment: it.attachment.copyWith(assetUrl: url)); - }); - } - - if (mounted) { - setState(() { - // Marking as upload complete - _attachments.update( - attachmentId, - (it) => it.copyWith(totalUploaded: it.totalSize), - ); - }); - } - } catch (e, s) { - setState(() => _attachments.remove(attachmentId)); - print(e); - print(s); - _showErrorAlert('Error adding the attachment: $e'); - } - } - - Future _uploadAttachment( - PlatformFile file, - DefaultAttachmentTypes type, { - ProgressCallback onSendProgress, - }) { - if (type == DefaultAttachmentTypes.image) { - return _attachmentUploader.uploadImage( - file, - onSendProgress: onSendProgress, - ); - } else { - return _attachmentUploader.uploadFile( - file, - onSendProgress: onSendProgress, - ); - } + setState(() { + _attachments.update(attachment.id, (it) { + return it.copyWith( + file: file, + extraData: {...it.extraData}..update('file_size', (_) => file.size), + ); + }); + }); } Widget _buildIdleSendButton(BuildContext context) { @@ -2203,7 +2033,7 @@ class MessageInputState extends State { if (widget.editMessage != null) { message = widget.editMessage.copyWith( text: text, - attachments: _getAttachments(attachments).toList(), + attachments: attachments, mentionedUsers: _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), ); @@ -2211,7 +2041,7 @@ class MessageInputState extends State { message = (widget.initialMessage ?? Message()).copyWith( parentId: widget.parentMessage?.id, text: text, - attachments: _getAttachments(attachments).toList(), + attachments: attachments, mentionedUsers: _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), showInChannel: widget.parentMessage != null ? _sendAsDm : null, @@ -2237,13 +2067,11 @@ class MessageInputState extends State { _mentionedUsers.clear(); if (widget.editMessage == null || - widget.editMessage.status == MessageSendingStatus.failed) { + widget.editMessage.status == MessageSendingStatus.failed || + widget.editMessage.status == MessageSendingStatus.sending) { sendingFuture = channel.sendMessage(message); } else { - sendingFuture = StreamChat.of(context).client.updateMessage( - message, - channel.cid, - ); + sendingFuture = channel.updateMessage(message); } return sendingFuture.then((resp) { @@ -2253,12 +2081,6 @@ class MessageInputState extends State { }); } - Iterable _getAttachments(List<_SendingAttachment> attachments) { - return attachments.map((attachment) { - return attachment.attachment; - }); - } - StreamSubscription _keyboardListener; void _showErrorAlert(String description) { @@ -2334,16 +2156,12 @@ class MessageInputState extends State { void _parseExistingMessage(Message message) { textEditingController.text = message.text; - _messageIsPresent = true; - - message.attachments?.forEach((attachment) { - final _attachment = _SendingAttachment( - attachment: attachment, - totalUploaded: attachment.extraData['file_size'], + for (final attachment in message.attachments) { + _attachments[attachment.id] = attachment.copyWith( + uploadState: attachment.uploadState ?? UploadState.success(), ); - _attachments[_attachment.id] = _attachment; - }); + } } @override @@ -2363,63 +2181,10 @@ class MessageInputState extends State { FocusScope.of(context).requestFocus(_focusNode); _initialized = true; } - final channel = StreamChannel.of(context).channel; - if (_attachmentUploader == null) { - _attachmentUploader = - widget.attachmentUploader ?? StreamAttachmentUploader(channel); - } else if (_attachmentUploader is StreamAttachmentUploader) { - _attachmentUploader = StreamAttachmentUploader(channel); - } super.didChangeDependencies(); } } -class _SendingAttachment { - _SendingAttachment({ - String id, - this.file, - this.attachment, - this.totalUploaded = 0, - int totalSize, - }) : id = id ?? shortHash(DateTime.now().millisecondsSinceEpoch), - attachmentType = attachment?.type, - totalSize = - totalSize ?? file?.size ?? attachment.extraData['file_size']; - - final String id; - final PlatformFile file; - final Attachment attachment; - final String attachmentType; - - final int totalUploaded; - final int totalSize; - - // Progress while the attachment is uploading to the server - // 0 -> 100 - double get uploadPercentage { - if (totalSize == null) return null; - return (totalUploaded / totalSize) * 100; - } - - bool get isUploaded => uploadPercentage == 100; - - _SendingAttachment copyWith({ - String id, - PlatformFile file, - Attachment attachment, - int totalUploaded, - int totalSize, - }) { - return _SendingAttachment( - id: id ?? this.id, - file: file ?? this.file, - attachment: attachment ?? this.attachment, - totalUploaded: totalUploaded ?? this.totalUploaded, - totalSize: totalSize ?? this.totalSize, - ); - } -} - /// Represents a 2-tuple, or pair. class Tuple2 { /// Returns the first item of the tuple diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 2929c2d7c..6a753910f 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -239,46 +239,50 @@ class _MessageListViewState extends State { final MessageListController _messageListController = MessageListController(); - final Map videoPackages = {}; - @override Widget build(BuildContext context) { - return MessageListCore( - loadingBuilder: (context) { - return Center( - child: const CircularProgressIndicator(), - ); - }, - emptyBuilder: (context) { - return Center( - child: Text( - 'No chats here yet...', - style: StreamChatTheme.of(context).textTheme.footnote.copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(.5)), - ), - ); - }, - messageListBuilder: (context, list) { - return _buildListView(list); - }, - messageListController: _messageListController, - parentMessage: widget.parentMessage, - showScrollToBottom: widget.showScrollToBottom, - errorWidgetBuilder: (BuildContext context, Object error) { - return Center( - child: Text( - 'Something went wrong', - style: StreamChatTheme.of(context).textTheme.footnote.copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(.5)), - ), - ); + return WillPopScope( + onWillPop: () async { + print('Getting popped'); + return false; }, + child: MessageListCore( + loadingBuilder: (context) { + return Center( + child: const CircularProgressIndicator(), + ); + }, + emptyBuilder: (context) { + return Center( + child: Text( + 'No chats here yet...', + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5)), + ), + ); + }, + messageListBuilder: (context, list) { + return _buildListView(list); + }, + messageListController: _messageListController, + parentMessage: widget.parentMessage, + showScrollToBottom: widget.showScrollToBottom, + errorWidgetBuilder: (BuildContext context, Object error) { + return Center( + child: Text( + 'Something went wrong', + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5)), + ), + ); + }, + ), ); } @@ -778,7 +782,6 @@ class _MessageListViewState extends State { break; } }, - videoPackages: videoPackages, ); } @@ -939,10 +942,12 @@ class _MessageListViewState extends State { break; } }, - videoPackages: videoPackages, ); - if (!message.isDeleted && !message.isSystem && !message.isEphemeral) { + if (!message.isDeleted && + !message.isSystem && + !message.isEphemeral && + widget.onMessageSwiped != null) { child = Swipeable( onSwipeEnd: () { FocusScope.of(context).unfocus(); @@ -1056,7 +1061,6 @@ class _MessageListViewState extends State { streamChannel.reloadChannel(); } _messageNewListener?.cancel(); - videoPackages.values.forEach((e) => e.dispose()); super.dispose(); } } diff --git a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart index c44022bf0..cc587786d 100644 --- a/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_reactions_modal.dart @@ -23,7 +23,6 @@ class MessageReactionsModal extends StatelessWidget { final ShapeBorder messageShape; final ShapeBorder attachmentShape; final void Function(User) onUserAvatarTap; - final Map videoPackages; const MessageReactionsModal({ Key key, @@ -37,7 +36,6 @@ class MessageReactionsModal extends StatelessWidget { this.reverse = false, this.showUserAvatar = DisplayWidget.show, this.onUserAvatarTap, - this.videoPackages, }) : super(key: key); @override @@ -146,7 +144,6 @@ class MessageReactionsModal extends StatelessWidget { (message.status == MessageSendingStatus.sent || message.status == null), - videoPackages: videoPackages, ), ), if (message.latestReactions?.isNotEmpty == true) ...[ diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index d4a6702a3..eed3d90bd 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -14,6 +14,7 @@ import 'package:stream_chat_flutter/src/reaction_bubble.dart'; import 'package:stream_chat_flutter/src/url_attachment.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'attachment/attachment.dart'; import 'extension.dart'; import 'image_group.dart'; import 'message_text.dart'; @@ -142,9 +143,6 @@ class MessageWidget extends StatefulWidget { /// Function called when quotedMessage is tapped final OnQuotedMessageTap onQuotedMessageTap; - /// The cache for the video controllers of attachments IDed as message ID + attachment index - final Map videoPackages; - /// MessageWidget({ Key key, @@ -193,7 +191,6 @@ class MessageWidget extends StatefulWidget { this.attachmentPadding = EdgeInsets.zero, this.allRead = false, this.onQuotedMessageTap, - this.videoPackages, }) : attachmentBuilders = { 'image': (context, message, attachment) { return ImageAttachment( @@ -236,6 +233,7 @@ class MessageWidget extends StatefulWidget { }, 'file': (context, message, attachment) { return FileAttachment( + message: message, attachment: attachment, size: Size( MediaQuery.of(context).size.width * 0.8, @@ -250,7 +248,8 @@ class MessageWidget extends StatefulWidget { _MessageWidgetState createState() => _MessageWidgetState(); } -class _MessageWidgetState extends State { +class _MessageWidgetState extends State + with AutomaticKeepAliveClientMixin { bool get showThreadReplyIndicator => widget.showThreadReplyIndicator; bool get showSendingIndicator => widget.showSendingIndicator; @@ -298,8 +297,12 @@ class _MessageWidgetState extends State { showSendingIndicator || isDeleted; + @override + bool get wantKeepAlive => widget.message.attachments?.isNotEmpty == true; + @override Widget build(BuildContext context) { + super.build(context); final avatarWidth = widget.messageTheme.avatarTheme.constraints.maxWidth; var leftPadding = widget.showUserAvatar != DisplayWidget.gone ? avatarWidth + 8.5 : 0.5; @@ -744,7 +747,6 @@ class _MessageWidgetState extends State { !isFailedState && widget.onThreadTap != null, showFlagButton: widget.showFlagButton, - videoPackages: widget.videoPackages, ), ); }); @@ -773,7 +775,6 @@ class _MessageWidgetState extends State { editMessageInputBuilder: widget.editMessageInputBuilder, onThreadTap: widget.onThreadTap, showReactions: widget.showReactions, - videoPackages: widget.videoPackages, ), ); }); @@ -824,6 +825,7 @@ class _MessageWidgetState extends State { ), images: images, message: widget.message, + messageTheme: widget.messageTheme, onShowMessage: widget.onShowMessage, ), ), @@ -838,41 +840,6 @@ class _MessageWidgetState extends State { children: widget.message.attachments ?.where((element) => element.ogScrapeUrl == null) ?.map((attachment) { - if (attachment.type == 'video') { - VideoPackage package; - - if (widget.videoPackages == null) { - package = VideoPackage(context, attachment, () {}); - } else { - package = widget?.videoPackages[ - '${widget.message.id}${widget.message.attachments.indexOf(attachment)}'] ?? - VideoPackage(context, attachment, () {}); - } - - if (widget.videoPackages != null) { - widget.videoPackages[ - '${widget.message.id}${widget.message.attachments.indexOf(attachment)}'] = - package; - } - - return Transform( - transform: Matrix4.rotationY(widget.reverse ? pi : 0), - alignment: Alignment.center, - child: VideoAttachment( - attachment: attachment, - messageTheme: widget.messageTheme, - size: Size( - MediaQuery.of(context).size.width * 0.8, - MediaQuery.of(context).size.height * 0.3, - ), - message: widget.message, - onShowMessage: widget.onShowMessage, - onReturnAction: widget.onReturnAction, - videoPackage: package, - ), - ); - } - final attachmentBuilder = widget.attachmentBuilders[attachment.type]; @@ -885,7 +852,6 @@ class _MessageWidgetState extends State { return wrapAttachmentWidget( context, attachmentWidget, - attachment: attachment, ); })?.insertBetween(SizedBox( height: widget.attachmentPadding.vertical / 2, @@ -897,9 +863,8 @@ class _MessageWidgetState extends State { Widget wrapAttachmentWidget( BuildContext context, - Widget attachmentWidget, { - Attachment attachment, - }) { + Widget attachmentWidget, + ) { final attachmentShape = widget.attachmentShape ?? _getDefaultAttachmentShape(context); return Material( @@ -930,8 +895,29 @@ class _MessageWidgetState extends State { Widget _buildSendingIndicator() { final style = widget.messageTheme.createdAt; + final message = widget.message; + + if (hasNonUrlAttachments && + (message.status == MessageSendingStatus.sending || + message.status == MessageSendingStatus.updating)) { + final totalAttachments = message.attachments.length; + final uploadRemaining = message.attachments.where((it) { + return !it.uploadState.isSuccess; + }).length; + if (uploadRemaining == 0) { + return StreamSvgIcon.check( + size: style.fontSize, + color: IconTheme.of(context).color.withOpacity(0.5), + ); + } + return Text( + 'Uploading $uploadRemaining/$totalAttachments ...', + style: style, + ); + } + Widget child = SendingIndicator( - message: widget.message, + message: message, isMessageRead: isMessageRead, size: style.fontSize, ); @@ -1032,18 +1018,12 @@ class _MessageWidgetState extends State { return; } if (widget.message.status == MessageSendingStatus.failed_update) { - StreamChat.of(context).client.updateMessage( - widget.message, - channel.cid, - ); + channel.updateMessage(widget.message); return; } if (widget.message.status == MessageSendingStatus.failed_delete) { - StreamChat.of(context).client.deleteMessage( - widget.message, - channel.cid, - ); + channel.deleteMessage(widget.message); return; } } diff --git a/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart index 8b0912eb1..507414199 100644 --- a/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/quoted_message_widget.dart @@ -5,9 +5,8 @@ import 'package:flutter/material.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:video_player/video_player.dart'; -import 'attachment_error.dart'; +import 'attachment/attachment.dart'; import 'extension.dart'; -import 'image_attachment.dart'; import 'message_text.dart'; import 'stream_chat_theme.dart'; import 'user_avatar.dart'; @@ -200,10 +199,7 @@ class QuotedMessageWidget extends StatelessWidget { ), ); } - return AttachmentError( - attachment: attachment, - size: size, - ); + return AttachmentError(size: size); } Widget _parseAttachments(BuildContext context) { @@ -231,8 +227,8 @@ class QuotedMessageWidget extends StatelessWidget { transform: Matrix4.rotationY(reverse ? pi : 0), alignment: Alignment.center, child: Material( - clipBehavior: Clip.hardEdge, - color: Colors.transparent, + clipBehavior: Clip.antiAlias, + type: MaterialType.transparency, shape: attachment.type == 'file' ? null : _getDefaultShape(context), child: child, ), @@ -294,10 +290,9 @@ class QuotedMessageWidget extends StatelessWidget { }, imageUrl: attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl, - errorWidget: (context, url, error) => AttachmentError( - attachment: attachment, - size: size, - ), + errorWidget: (context, url, error) { + return AttachmentError(size: size); + }, fit: BoxFit.cover, ); }, diff --git a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart index b0b3d0bb8..fa5d9dc4a 100644 --- a/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart +++ b/packages/stream_chat_flutter/lib/src/stream_svg_icon.dart @@ -901,4 +901,16 @@ class StreamSvgIcon extends StatelessWidget { height: size, ); } + + factory StreamSvgIcon.retry({ + double size, + Color color, + }) { + return StreamSvgIcon( + assetName: 'icon_retry.svg', + color: color, + width: size, + height: size, + ); + } } diff --git a/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart b/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart new file mode 100644 index 000000000..d0c7e4545 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import 'stream_chat_theme.dart'; +import 'utils.dart'; + +class UploadProgressIndicator extends StatelessWidget { + final int uploaded; + final int total; + final Color progressIndicatorColor; + final EdgeInsetsGeometry padding; + final bool showBackground; + final TextStyle textStyle; + + const UploadProgressIndicator({ + Key key, + @required this.uploaded, + @required this.total, + this.progressIndicatorColor = const Color(0xffb2b2b2), + this.padding = const EdgeInsets.only(top: 5, bottom: 5, right: 11, left: 5), + this.showBackground = true, + this.textStyle, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + Widget child = Padding( + padding: padding, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator( + strokeWidth: 3, + valueColor: AlwaysStoppedAnimation(progressIndicatorColor), + ), + ), + SizedBox(width: 8), + Text( + '${fileSize(uploaded, 1)}/${fileSize(total, 1)}', + style: textStyle ?? + theme.textTheme.footnote.copyWith( + color: theme.colorTheme.white, + ), + ), + ], + ), + ); + if (showBackground) { + child = Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorTheme.overlayDark.withOpacity(0.6), + ), + child: child, + ); + } + return child; + } +} diff --git a/packages/stream_chat_flutter/lib/src/utils.dart b/packages/stream_chat_flutter/lib/src/utils.dart index bfc0bda94..4b6b8ab2a 100644 --- a/packages/stream_chat_flutter/lib/src/utils.dart +++ b/packages/stream_chat_flutter/lib/src/utils.dart @@ -4,6 +4,7 @@ import 'package:url_launcher/url_launcher.dart'; import '../stream_chat_flutter.dart'; import 'stream_svg_icon.dart'; +import 'dart:math'; Future launchURL(BuildContext context, String url) async { if (await canLaunch(url)) { @@ -215,7 +216,7 @@ String getWebsiteName(String hostName) { } /// A method returns a human readable string representing a file _size -String filesize(dynamic size, [int round = 2]) { +String fileSize(dynamic size, [int round = 2]) { if (size == null) return 'Size N/A'; /** diff --git a/packages/stream_chat_flutter/lib/src/video_attachment.dart b/packages/stream_chat_flutter/lib/src/video_attachment.dart deleted file mode 100644 index 107776bf3..000000000 --- a/packages/stream_chat_flutter/lib/src/video_attachment.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:chewie/chewie.dart'; -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/full_screen_media.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import 'attachment_title.dart'; - -class VideoAttachment extends StatefulWidget { - final Attachment attachment; - final MessageTheme messageTheme; - final Size size; - final Message message; - final ShowMessageCallback onShowMessage; - final ValueChanged onReturnAction; - final VideoPackage videoPackage; - - VideoAttachment({ - Key key, - @required this.attachment, - @required this.messageTheme, - this.videoPackage, - this.message, - this.size, - this.onShowMessage, - this.onReturnAction, - }) : super(key: key); - - @override - _VideoAttachmentState createState() => _VideoAttachmentState(); -} - -class _VideoAttachmentState extends State { - bool initialized = false; - - @override - void initState() { - super.initState(); - widget.videoPackage.onInit = () { - setState(() { - initialized = true; - }); - }; - } - - @override - Widget build(BuildContext context) { - if (!widget.videoPackage.initialised) { - return Container( - height: widget.size?.height ?? 100, - width: widget.size?.width ?? 100, - child: Center( - child: CircularProgressIndicator(), - ), - ); - } - - return GestureDetector( - onTap: () async { - final channel = StreamChannel.of(context).channel; - - var res = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => StreamChannel( - channel: channel, - child: FullScreenMedia( - mediaAttachments: [widget.attachment], - userName: widget.message.user.name, - sentAt: widget.message.createdAt, - message: widget.message, - onShowMessage: widget.onShowMessage, - ), - ), - ), - ); - - if (res != null) { - widget.onReturnAction(res); - } - }, - child: Container( - height: widget.size?.height, - width: widget.size?.width, - child: Flex( - direction: Axis.vertical, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: FittedBox( - fit: BoxFit.none, - child: Stack( - children: [ - Chewie( - controller: widget.videoPackage.chewieController, - ), - Positioned.fill( - child: Center( - child: Material( - shape: CircleBorder(), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Icon(Icons.play_arrow), - ), - ), - ), - ), - ], - ), - ), - ), - if (widget.attachment.title != null) - Material( - color: widget.messageTheme.messageBackgroundColor, - child: AttachmentTitle( - messageTheme: widget.messageTheme, - attachment: widget.attachment, - ), - ), - ], - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/video_service.dart b/packages/stream_chat_flutter/lib/src/video_service.dart new file mode 100644 index 000000000..30d06c556 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/video_service.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:synchronized/synchronized.dart'; +import 'package:video_compress/video_compress.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; +import 'package:meta/meta.dart'; + +class IVideoService { + static final IVideoService instance = IVideoService._(); + final _lock = Lock(); + + IVideoService._(); + + /// compress video from [path] + /// compress video from [path] return [Future] + /// + /// you can choose its quality by [quality], + /// determine whether to delete his source file by [deleteOrigin] + /// optional parameters [startTime] [duration] [includeAudio] [frameRate] + /// + /// ## example + /// ```dart + /// final info = await _flutterVideoCompress.compressVideo( + /// file.path, + /// deleteOrigin: true, + /// ); + /// debugPrint(info.toJson()); + /// ``` + Future compressVideo(String path) async { + return _lock.synchronized(() { + return VideoCompress.compressVideo( + path, + ); + }); + } + + /// Generates a thumbnail image data in memory as UInt8List, it can be easily used by Image.memory(...). + /// The video can be a local video file, or an URL repreents iOS or Android native supported video format. + /// Speicify the maximum height or width for the thumbnail or 0 for same resolution as the original video. + /// The lower quality value creates lower quality of the thumbnail image, but it gets ignored for PNG format. + Future generateVideoThumbnail({ + @required String video, + ImageFormat imageFormat = ImageFormat.PNG, + int maxHeight = 0, + int maxWidth = 0, + int timeMs = 0, + int quality = 10, + }) { + return VideoThumbnail.thumbnailData( + video: video, + imageFormat: imageFormat, + maxHeight: maxHeight, + maxWidth: maxWidth, + timeMs: timeMs, + quality: quality, + ); + } +} + +// ignore: non_constant_identifier_names +IVideoService get VideoService => IVideoService.instance; diff --git a/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart b/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart new file mode 100644 index 000000000..8932c68c5 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart @@ -0,0 +1,87 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; + +import 'stream_svg_icon.dart'; +import 'video_service.dart'; + +class VideoThumbnailImage extends StatefulWidget { + final String video; + final double width; + final double height; + final BoxFit fit; + final ImageFormat format; + final Widget Function(BuildContext, Object) errorBuilder; + final WidgetBuilder placeholderBuilder; + + const VideoThumbnailImage({ + Key key, + @required this.video, + this.width, + this.height, + this.fit, + this.format = ImageFormat.PNG, + this.errorBuilder, + this.placeholderBuilder, + }) : super(key: key); + + @override + _VideoThumbnailImageState createState() => _VideoThumbnailImageState(); +} + +class _VideoThumbnailImageState extends State { + Future thumbnailFuture; + + @override + void initState() { + thumbnailFuture = VideoService.generateVideoThumbnail( + video: widget.video, + imageFormat: widget.format, + ); + super.initState(); + } + + @override + void didUpdateWidget(covariant VideoThumbnailImage oldWidget) { + if (oldWidget.video != widget.video || oldWidget.format != widget.format) { + thumbnailFuture = VideoService.generateVideoThumbnail( + video: widget.video, + imageFormat: widget.format, + ); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: thumbnailFuture, + builder: (context, snapshot) { + if (snapshot.hasError) { + if (widget.errorBuilder != null) { + return widget.errorBuilder(context, snapshot.error); + } + return Center(child: StreamSvgIcon.error()); + } + if (!snapshot.hasData) { + if (widget.placeholderBuilder != null) { + return widget.placeholderBuilder(context); + } + return Image.asset( + 'images/placeholder.png', + package: 'stream_chat_flutter', + fit: widget.fit, + ); + } + final data = snapshot.data; + return Image.memory( + data, + fit: widget.fit, + height: widget.height, + width: widget.width, + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index fa2960750..6fc905b5f 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -7,12 +7,10 @@ export 'src/channel_name.dart'; export 'src/channel_preview.dart'; export 'src/date_divider.dart'; export 'src/deleted_message.dart'; -export 'src/file_attachment.dart'; +export 'src/attachment/attachment.dart'; export 'src/full_screen_media.dart'; export 'src/image_header.dart'; export 'src/image_footer.dart'; -export 'src/giphy_attachment.dart'; -export 'src/image_attachment.dart'; export 'src/message_input.dart'; export 'src/message_list_view.dart'; export 'src/message_text.dart'; @@ -31,7 +29,6 @@ export 'src/user_item.dart'; export 'src/user_list_view.dart'; export 'src/user_list_view.dart'; export 'src/utils.dart'; -export 'src/video_attachment.dart'; export 'src/message_search_item.dart'; export 'src/message_search_list_view.dart'; export 'src/unread_indicator.dart'; diff --git a/packages/stream_chat_flutter/lib/svgs/icon_retry.svg b/packages/stream_chat_flutter/lib/svgs/icon_retry.svg new file mode 100644 index 000000000..f850a61e1 --- /dev/null +++ b/packages/stream_chat_flutter/lib/svgs/icon_retry.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 435fad279..beffbe433 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: characters: ^1.0.0 dio: ^3.0.10 path_provider: ^1.6.27 + video_thumbnail: ^0.2.5+1 flutter: assets: diff --git a/packages/stream_chat_persistence/example/pubspec.yaml b/packages/stream_chat_persistence/example/pubspec.yaml index f5cc5ed2f..2586bea65 100644 --- a/packages/stream_chat_persistence/example/pubspec.yaml +++ b/packages/stream_chat_persistence/example/pubspec.yaml @@ -11,7 +11,8 @@ dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.0 - stream_chat: ^1.0.0-beta + stream_chat: + path: ../../stream_chat stream_chat_persistence: path: ../ diff --git a/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart index 5ad92ddd0..c8bda4e64 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/message_mapper.dart @@ -18,7 +18,7 @@ extension MessageEntityX on MessageEntity { ownReactions: ownReactions, attachments: attachments?.map((it) { final json = jsonDecode(it); - return Attachment.fromJson(json); + return Attachment.fromData(json); })?.toList(), createdAt: createdAt, extraData: extraData, @@ -47,7 +47,9 @@ extension MessageX on Message { MessageEntity toEntity({String cid}) { return MessageEntity( id: id, - attachments: attachments?.map((it) => jsonEncode(it))?.toList() ?? [], + attachments: attachments?.map((it) { + return jsonEncode(it.toData()); + })?.toList(), channelCid: cid, type: type, parentId: parentId, From 5c7cc9cc064e59a26a9c32f5ce59bbaf79ff3925 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 16 Feb 2021 21:28:40 +0530 Subject: [PATCH 03/19] [LLC] -> RetryQueue] Fix retry_queue delete message Signed-off-by: Sahil Kumar --- packages/stream_chat/lib/src/api/retry_queue.dart | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/stream_chat/lib/src/api/retry_queue.dart b/packages/stream_chat/lib/src/api/retry_queue.dart index a6c6a8b65..de3ca6106 100644 --- a/packages/stream_chat/lib/src/api/retry_queue.dart +++ b/packages/stream_chat/lib/src/api/retry_queue.dart @@ -128,12 +128,10 @@ class RetryQueue { await channel.updateMessage(message); } else if (message.status == MessageSendingStatus.failed || message.status == MessageSendingStatus.sending) { - await channel.sendMessage( - message, - ); + await channel.sendMessage(message); } else if (message.status == MessageSendingStatus.failed_delete || message.status == MessageSendingStatus.deleting) { - await channel.client.deleteMessage(message); + await channel.deleteMessage(message); } } From f9d74358bcc33b0e6b6c37c391da44d3f3ef1e89 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 16 Feb 2021 21:32:34 +0530 Subject: [PATCH 04/19] [UIKit -> MessageListView] Remove debug code Signed-off-by: Sahil Kumar --- .../lib/src/message_list_view.dart | 76 +++++++++---------- 1 file changed, 35 insertions(+), 41 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view.dart index 6a753910f..ed6aff9f5 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view.dart @@ -241,48 +241,42 @@ class _MessageListViewState extends State { @override Widget build(BuildContext context) { - return WillPopScope( - onWillPop: () async { - print('Getting popped'); - return false; + return MessageListCore( + loadingBuilder: (context) { + return Center( + child: const CircularProgressIndicator(), + ); + }, + emptyBuilder: (context) { + return Center( + child: Text( + 'No chats here yet...', + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5)), + ), + ); + }, + messageListBuilder: (context, list) { + return _buildListView(list); + }, + messageListController: _messageListController, + parentMessage: widget.parentMessage, + showScrollToBottom: widget.showScrollToBottom, + errorWidgetBuilder: (BuildContext context, Object error) { + return Center( + child: Text( + 'Something went wrong', + style: StreamChatTheme.of(context).textTheme.footnote.copyWith( + color: StreamChatTheme.of(context) + .colorTheme + .black + .withOpacity(.5)), + ), + ); }, - child: MessageListCore( - loadingBuilder: (context) { - return Center( - child: const CircularProgressIndicator(), - ); - }, - emptyBuilder: (context) { - return Center( - child: Text( - 'No chats here yet...', - style: StreamChatTheme.of(context).textTheme.footnote.copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(.5)), - ), - ); - }, - messageListBuilder: (context, list) { - return _buildListView(list); - }, - messageListController: _messageListController, - parentMessage: widget.parentMessage, - showScrollToBottom: widget.showScrollToBottom, - errorWidgetBuilder: (BuildContext context, Object error) { - return Center( - child: Text( - 'Something went wrong', - style: StreamChatTheme.of(context).textTheme.footnote.copyWith( - color: StreamChatTheme.of(context) - .colorTheme - .black - .withOpacity(.5)), - ), - ); - }, - ), ); } From 7e0454c7c43e5f29da2d6833bcd3e19927dedcb5 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 16 Feb 2021 21:44:27 +0530 Subject: [PATCH 05/19] [LLC] Fix tests Signed-off-by: Sahil Kumar --- packages/stream_chat/lib/src/api/channel.dart | 88 +++++++++---------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index bbe1d762a..1b802b4e4 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -301,19 +301,17 @@ class Channel { user: _client.state.user, quotedMessage: quotedMessage, status: MessageSendingStatus.sending, - attachments: [ - ...message.attachments.map( - (it) { - if (it.uploadState.isSuccess) return it; - return it.copyWith( - uploadState: UploadState.inProgress( - uploaded: 0, - total: it.file?.size ?? it.extraData['file_size'], - ), - ); - }, - ) - ], + attachments: message.attachments?.map( + (it) { + if (it.uploadState.isSuccess) return it; + return it.copyWith( + uploadState: UploadState.inProgress( + uploaded: 0, + total: it.file?.size ?? it.extraData['file_size'], + ), + ); + }, + )?.toList(), ); if (message.parentId != null && message.id == null) { @@ -328,16 +326,18 @@ class Channel { state?.addMessage(message); try { - final attachmentsUploadCompleter = Completer(); - _messageAttachmentsUploadCompleter[message.id] = - attachmentsUploadCompleter; - - unawaited(_uploadAttachments( - message.id, - message.attachments.map((it) => it.id), - )); + if (message.attachments?.isNotEmpty == true) { + final attachmentsUploadCompleter = Completer(); + _messageAttachmentsUploadCompleter[message.id] = + attachmentsUploadCompleter; + + unawaited(_uploadAttachments( + message.id, + message.attachments.map((it) => it.id), + )); - message = await attachmentsUploadCompleter.future; + message = await attachmentsUploadCompleter.future; + } final response = await _client.post( '$_channelURL/message', @@ -374,34 +374,34 @@ class Channel { message = message.copyWith( status: MessageSendingStatus.updating, updatedAt: message.updatedAt ?? DateTime.now(), - attachments: [ - ...message.attachments.map( - (it) { - if (it.uploadState.isSuccess) return it; - return it.copyWith( - uploadState: UploadState.inProgress( - uploaded: 0, - total: it.file?.size ?? it.extraData['file_size'], - ), - ); - }, - ) - ], + attachments: message.attachments?.map( + (it) { + if (it.uploadState.isSuccess) return it; + return it.copyWith( + uploadState: UploadState.inProgress( + uploaded: 0, + total: it.file?.size ?? it.extraData['file_size'], + ), + ); + }, + )?.toList(), ); state?.addMessage(message); try { - final attachmentsUploadCompleter = Completer(); - _messageAttachmentsUploadCompleter[message.id] = - attachmentsUploadCompleter; - - unawaited(_uploadAttachments( - message.id, - message.attachments.map((it) => it.id), - )); + if (message.attachments?.isNotEmpty == true) { + final attachmentsUploadCompleter = Completer(); + _messageAttachmentsUploadCompleter[message.id] = + attachmentsUploadCompleter; + + unawaited(_uploadAttachments( + message.id, + message.attachments.map((it) => it.id), + )); - message = await attachmentsUploadCompleter.future; + message = await attachmentsUploadCompleter.future; + } final response = await _client.updateMessage(message); state?.addMessage(response?.message?.copyWith( From beb54babb5e86dff1c6e53aed359dc1a144bcccb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 16 Feb 2021 21:58:30 +0530 Subject: [PATCH 06/19] [LLC] Add mime dependency Signed-off-by: Sahil Kumar --- packages/stream_chat/pubspec.yaml | 1 + packages/stream_chat_flutter/pubspec.yaml | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index 734826d3c..01731c0a2 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: collection: ^1.14.13 pedantic: ^1.9.2 meta: ^1.2.4 + mime: ^0.9.7 freezed_annotation: ^0.12.0 dev_dependencies: diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 7d0c7c02d..51557b3c9 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -30,7 +30,6 @@ dependencies: file_picker: ^2.1.5 image_picker: ^0.6.7+17 flutter_keyboard_visibility: ^4.0.2 - mime: ^0.9.7 video_compress: ^2.1.1 visibility_detector: ^0.1.5 http_parser: ^3.1.4 From 29447d14401dc805be69b0d98f313447ed9a4bcc Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Wed, 17 Feb 2021 09:19:26 +0100 Subject: [PATCH 07/19] fix analyze --- .../lib/src/attachment/attachment_widget.dart | 1 + packages/stream_chat_flutter/lib/src/image_footer.dart | 4 ---- packages/stream_chat_flutter/lib/src/utils.dart | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart index 0fd0593dc..bd58462da 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart @@ -11,6 +11,7 @@ enum AttachmentSource { extension AttachmentSourceX on AttachmentSource { /// The [when] method is the equivalent to pattern matching. /// Its prototype depends on the AttachmentSource defined. + // ignore: missing_return T when({ @required T Function() local, @required T Function() network, diff --git a/packages/stream_chat_flutter/lib/src/image_footer.dart b/packages/stream_chat_flutter/lib/src/image_footer.dart index c509c4697..7140e490e 100644 --- a/packages/stream_chat_flutter/lib/src/image_footer.dart +++ b/packages/stream_chat_flutter/lib/src/image_footer.dart @@ -142,10 +142,6 @@ class _ImageFooterState extends State { } void _showPhotosModal(context) { - var videoAttachments = widget.mediaAttachments - .where((element) => element.type == 'video') - .toList(); - showModalBottomSheet( context: context, barrierColor: StreamChatTheme.of(context).colorTheme.overlay, diff --git a/packages/stream_chat_flutter/lib/src/utils.dart b/packages/stream_chat_flutter/lib/src/utils.dart index 4b6b8ab2a..424f1880b 100644 --- a/packages/stream_chat_flutter/lib/src/utils.dart +++ b/packages/stream_chat_flutter/lib/src/utils.dart @@ -4,7 +4,6 @@ import 'package:url_launcher/url_launcher.dart'; import '../stream_chat_flutter.dart'; import 'stream_svg_icon.dart'; -import 'dart:math'; Future launchURL(BuildContext context, String url) async { if (await canLaunch(url)) { From 23d762eea7340a597673db4f8c6c8dc0f9e781b8 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 17 Feb 2021 14:23:07 +0530 Subject: [PATCH 08/19] [LLC] Fix upload attachments update method Signed-off-by: Sahil Kumar --- packages/stream_chat/lib/src/api/channel.dart | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index 1b802b4e4..8466a14a8 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -229,12 +229,13 @@ class Channel { client.logger.info('Uploading ${it.id} attachment...'); void updateAttachment(Attachment attachment) { - message = message.copyWith( - attachments: message.attachments.map((it) { - if (it.id != attachment.id) return it; - return attachment; - }).toList(growable: false)); - state?.addMessage(message); + final index = message.attachments.indexWhere((it) { + return it.id == attachment.id; + }); + if (index != -1) { + message.attachments[index] = attachment; + state?.addMessage(message); + } } void onSendProgress(int sent, int total) { From 8b9afbdc3a5dfaeadc60d6527dc3d33d7c894bd9 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Wed, 17 Feb 2021 09:28:50 +0100 Subject: [PATCH 09/19] update doc --- packages/stream_chat/lib/src/api/channel.dart | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index 8466a14a8..b9104e935 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -281,9 +281,7 @@ class Channel { }); } - /// Send a [message] to this channel. Optionally pass a [attachmentUploader] - /// for custom attachments upload. - /// + /// Send a [message] to this channel. /// Waits for a [_messageAttachmentsUploadCompleter] to complete /// before actually sending the message. Future sendMessage(Message message) async { @@ -360,9 +358,7 @@ class Channel { } } - /// Updates the [message] in this channel. Optionally pass a [attachmentUploader] - /// for custom attachments upload. - /// + /// Updates the [message] in this channel. /// Waits for a [_messageAttachmentsUploadCompleter] to complete /// before actually updating the message. Future updateMessage(Message message) async { From aa44d5840ce3c0c72818d5fd91d722f1833d5a8d Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 17 Feb 2021 17:55:13 +0530 Subject: [PATCH 10/19] [UIKit] Ui changes for upload_progress_indicator Signed-off-by: Sahil Kumar --- .../attachment_upload_state_builder.dart | 14 ++++-- .../lib/src/attachment/file_attachment.dart | 7 +-- .../lib/src/attachment/video_attachment.dart | 6 +-- .../lib/src/message_input.dart | 8 ++- .../lib/src/upload_progress_indicator.dart | 4 +- .../lib/src/video_thumbnail_image.dart | 49 ++++++++++--------- 6 files changed, 43 insertions(+), 45 deletions(-) diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart index 19bf6a1ab..ee932920c 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart @@ -113,13 +113,17 @@ class _InProgressState extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _IconButton( - icon: StreamSvgIcon.close( - color: StreamChatTheme.of(context).colorTheme.white, + Align( + alignment: Alignment.topRight, + child: _IconButton( + icon: StreamSvgIcon.close( + color: StreamChatTheme.of(context).colorTheme.white, + ), + onPressed: () => channel.cancelAttachmentUpload(attachmentId), ), - onPressed: () => channel.cancelAttachmentUpload(attachmentId), ), - Center( + Align( + alignment: Alignment.topRight, child: UploadProgressIndicator( uploaded: sent, total: total, diff --git a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart index 4cf55aede..d5c6b17ce 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart @@ -242,12 +242,7 @@ class FileAttachment extends AttachmentWidget { progressIndicatorColor: theme.colorTheme.accentBlue, ); }, - success: () { - return Text( - '${fileSize(size, 1)}/${fileSize(size, 1)}', - style: textStyle, - ); - }, + success: () => Text('${fileSize(size, 2)}', style: textStyle), failed: (_) => Text('UPLOAD ERROR', style: textStyle), ) ?? Text('${fileSize(size)}', style: textStyle); diff --git a/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart index dc168bd2c..b7a454560 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart @@ -86,11 +86,7 @@ class VideoAttachment extends AttachmentWidget { }, child: Stack( children: [ - Container( - height: size?.height, - width: size?.width, - child: videoWidget, - ), + videoWidget, Center( child: Material( shape: CircleBorder(), diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 0a34e517d..d24d51f74 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -1653,13 +1653,11 @@ class MessageInputState extends State { case 'video': return Stack( children: [ - Container( + VideoThumbnailImage( height: 104, width: 104, - child: VideoThumbnailImage( - video: attachment.file.path, - fit: BoxFit.cover, - ), + video: attachment.file?.path ?? attachment.assetUrl, + fit: BoxFit.cover, ), Positioned( left: 8, diff --git a/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart b/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart index d0c7e4545..53c121e2a 100644 --- a/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart +++ b/packages/stream_chat_flutter/lib/src/upload_progress_indicator.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'stream_chat_theme.dart'; -import 'utils.dart'; class UploadProgressIndicator extends StatelessWidget { final int uploaded; @@ -24,6 +23,7 @@ class UploadProgressIndicator extends StatelessWidget { @override Widget build(BuildContext context) { final theme = StreamChatTheme.of(context); + final _percentage = (uploaded / total) * 100; Widget child = Padding( padding: padding, child: Row( @@ -39,7 +39,7 @@ class UploadProgressIndicator extends StatelessWidget { ), SizedBox(width: 8), Text( - '${fileSize(uploaded, 1)}/${fileSize(total, 1)}', + '${_percentage.toInt()}%', style: textStyle ?? theme.textTheme.footnote.copyWith( color: theme.colorTheme.white, diff --git a/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart b/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart index 8932c68c5..70a300054 100644 --- a/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart +++ b/packages/stream_chat_flutter/lib/src/video_thumbnail_image.dart @@ -58,28 +58,33 @@ class _VideoThumbnailImageState extends State { return FutureBuilder( future: thumbnailFuture, builder: (context, snapshot) { - if (snapshot.hasError) { - if (widget.errorBuilder != null) { - return widget.errorBuilder(context, snapshot.error); - } - return Center(child: StreamSvgIcon.error()); - } - if (!snapshot.hasData) { - if (widget.placeholderBuilder != null) { - return widget.placeholderBuilder(context); - } - return Image.asset( - 'images/placeholder.png', - package: 'stream_chat_flutter', - fit: widget.fit, - ); - } - final data = snapshot.data; - return Image.memory( - data, - fit: widget.fit, - height: widget.height, - width: widget.width, + return AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + child: Builder( + key: ValueKey>(snapshot), + builder: (_) { + if (snapshot.hasError) { + return widget.errorBuilder?.call(context, snapshot.error) ?? + Center(child: StreamSvgIcon.error()); + } + if (!snapshot.hasData) { + return widget.placeholderBuilder?.call(context) ?? + Image.asset( + 'images/placeholder.png', + package: 'stream_chat_flutter', + fit: widget.fit, + height: widget.height, + width: widget.width, + ); + } + return Image.memory( + snapshot.data, + fit: widget.fit, + height: widget.height, + width: widget.width, + ); + }, + ), ); }, ); From 3ad22b16b360f12a160f357f7adbaf83b8107af6 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 17 Feb 2021 18:27:58 +0530 Subject: [PATCH 11/19] Add docs Signed-off-by: Sahil Kumar --- .../lib/src/api/attachment_uploader.dart | 14 ++++++++++--- .../lib/src/models/attachment_file.dart | 20 +++++++++---------- .../lib/src/extension.dart | 4 ++-- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/stream_chat/lib/src/api/attachment_uploader.dart b/packages/stream_chat/lib/src/api/attachment_uploader.dart index c4c87dcb6..a5d2d5760 100644 --- a/packages/stream_chat/lib/src/api/attachment_uploader.dart +++ b/packages/stream_chat/lib/src/api/attachment_uploader.dart @@ -3,9 +3,13 @@ import 'package:stream_chat/src/models/attachment_file.dart'; import '../client.dart'; import '../extensions/string_extension.dart'; -/// +/// Class responsible for uploading images and files from a given channel abstract class AttachmentUploader { + /// Uploads a image [file] to the given channel. + /// Returns image [file] URL once sent successfully. /// + /// Optionally, access upload progress using [onSendProgress] + /// and cancel the request using [cancelToken] Future uploadImage( AttachmentFile file, String channelId, @@ -14,7 +18,11 @@ abstract class AttachmentUploader { CancelToken cancelToken, }); + /// Uploads a [file] to the given channel. + /// Returns [file] URL once sent successfully. /// + /// Optionally, access upload progress using [onSendProgress] + /// and cancel the request using [cancelToken] Future uploadFile( AttachmentFile file, String channelId, @@ -24,11 +32,11 @@ abstract class AttachmentUploader { }); } -/// +/// Stream's default implementation of [AttachmentUploader] class StreamAttachmentUploader implements AttachmentUploader { final StreamChatClient _client; - /// + /// Creates a new [StreamAttachmentUploader] instance. const StreamAttachmentUploader(this._client); @override diff --git a/packages/stream_chat/lib/src/models/attachment_file.dart b/packages/stream_chat/lib/src/models/attachment_file.dart index bffc24aec..d7a1ba88e 100644 --- a/packages/stream_chat/lib/src/models/attachment_file.dart +++ b/packages/stream_chat/lib/src/models/attachment_file.dart @@ -7,16 +7,16 @@ part 'attachment_file.freezed.dart'; part 'attachment_file.g.dart'; -/// +/// Union class to hold various [UploadState] of a attachment. @freezed abstract class UploadState with _$UploadState { - /// + /// InProgress state of the union const factory UploadState.inProgress({int uploaded, int total}) = InProgress; - /// + /// Success state of the union const factory UploadState.success() = Success; - /// + /// Failed state of the union const factory UploadState.failed({@required String error}) = Failed; /// Creates a new instance from a json @@ -24,15 +24,15 @@ abstract class UploadState with _$UploadState { _$UploadStateFromJson(json); } -/// +/// Helper extension for UploadState extension UploadStateX on UploadState { - /// + /// Returns true if state is [InProgress] bool get isInProgress => this is InProgress; - /// + /// Returns true if state is [Success] bool get isSuccess => this is Success; - /// + /// Returns true if state is [Failed] bool get isFailed => this is Failed; } @@ -40,10 +40,10 @@ Uint8List _fromString(String bytes) => Uint8List.fromList(bytes.codeUnits); String _toString(Uint8List bytes) => String.fromCharCodes(bytes); -/// +/// The class that contains the information about an attachment file @JsonSerializable() class AttachmentFile { - /// + /// Creates a new [AttachmentFile] instance. const AttachmentFile({ this.path, this.name, diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index af2b0994a..f19d851b3 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -34,9 +34,9 @@ extension IterableX on Iterable { }).skip(1).toList(growable: false); } -/// +/// Useful extension for [PlatformFile] extension PlatformFileX on PlatformFile { - /// + /// Converts the [PlatformFile] into [AttachmentFile] AttachmentFile get toAttachmentFile => AttachmentFile( path: path, name: name, From 517c7e489d935ba791d8e1d5afdce379d92c0872 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 17 Feb 2021 20:13:20 +0530 Subject: [PATCH 12/19] [LLC -> Channel] Segregate sendMessage api call and state handling Signed-off-by: Sahil Kumar --- packages/stream_chat/lib/src/api/channel.dart | 15 +++------------ packages/stream_chat/lib/src/client.dart | 10 ++++++++++ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index b9104e935..24b08b3ff 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -338,18 +338,9 @@ class Channel { message = await attachmentsUploadCompleter.future; } - final response = await _client.post( - '$_channelURL/message', - data: { - 'message': message.toJson(), - }, - ); - - final res = _client.decode(response.data, SendMessageResponse.fromJson); - - state?.addMessage(res.message); - - return res; + final response = await _client.sendMessage(message, id, type); + state?.addMessage(response.message); + return response; } catch (error) { if (error is DioError && error.type != DioErrorType.RESPONSE) { state?.retryQueue?.add([message]); diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart index 81d3bc26c..5118d6b87 100644 --- a/packages/stream_chat/lib/src/client.dart +++ b/packages/stream_chat/lib/src/client.dart @@ -1246,6 +1246,16 @@ class StreamChatClient { return decode(response.data, EmptyResponse.fromJson); } + /// Sends the message to the given channel + Future sendMessage( + Message message, String channelId, String channelType) async { + final response = await post( + '/channels/$channelType/$channelId/message', + data: {'message': message}, + ); + return decode(response.data, SendMessageResponse.fromJson); + } + /// Update the given message Future updateMessage(Message message) async { final response = await post( From b29f673e913f3b76ad75f4199d689fc1f9dc2209 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 17 Feb 2021 20:14:01 +0530 Subject: [PATCH 13/19] [LLC] Refactor attachment_file_uploader usage in client and channel Signed-off-by: Sahil Kumar --- packages/stream_chat/lib/src/api/channel.dart | 21 ++-- ...der.dart => attachment_file_uploader.dart} | 67 ++++++------- packages/stream_chat/lib/src/client.dart | 29 +++--- packages/stream_chat/lib/stream_chat.dart | 2 +- .../test/src/api/channel_test.dart | 96 +++++++++++++++---- 5 files changed, 144 insertions(+), 71 deletions(-) rename packages/stream_chat/lib/src/{api/attachment_uploader.dart => attachment_file_uploader.dart} (51%) diff --git a/packages/stream_chat/lib/src/api/channel.dart b/packages/stream_chat/lib/src/api/channel.dart index 24b08b3ff..b63273afd 100644 --- a/packages/stream_chat/lib/src/api/channel.dart +++ b/packages/stream_chat/lib/src/api/channel.dart @@ -245,15 +245,20 @@ class Channel { } final isImage = it.type == 'image'; - final uploader = _client.attachmentUploader; final cancelToken = CancelToken(); Future future; if (isImage) { - future = uploader.uploadImage(it.file, id, type, - onSendProgress: onSendProgress, cancelToken: cancelToken); + future = sendImage( + it.file, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ).then((it) => it.file); } else { - future = uploader.uploadFile(it.file, id, type, - onSendProgress: onSendProgress, cancelToken: cancelToken); + future = sendFile( + it.file, + onSendProgress: onSendProgress, + cancelToken: cancelToken, + ).then((it) => it.file); } _cancelableAttachmentUploadRequest[it.id] = cancelToken; return future.then((url) { @@ -446,7 +451,7 @@ class Channel { /// Send a file to this channel Future sendFile( - MultipartFile file, { + AttachmentFile file, { ProgressCallback onSendProgress, CancelToken cancelToken, }) { @@ -461,12 +466,12 @@ class Channel { /// Send an image to this channel Future sendImage( - MultipartFile image, { + AttachmentFile file, { ProgressCallback onSendProgress, CancelToken cancelToken, }) { return _client.sendImage( - image, + file, id, type, onSendProgress: onSendProgress, diff --git a/packages/stream_chat/lib/src/api/attachment_uploader.dart b/packages/stream_chat/lib/src/attachment_file_uploader.dart similarity index 51% rename from packages/stream_chat/lib/src/api/attachment_uploader.dart rename to packages/stream_chat/lib/src/attachment_file_uploader.dart index a5d2d5760..8db8b168a 100644 --- a/packages/stream_chat/lib/src/api/attachment_uploader.dart +++ b/packages/stream_chat/lib/src/attachment_file_uploader.dart @@ -1,17 +1,18 @@ import 'package:dio/dio.dart'; +import 'package:stream_chat/src/api/responses.dart'; import 'package:stream_chat/src/models/attachment_file.dart'; -import '../client.dart'; -import '../extensions/string_extension.dart'; +import 'client.dart'; +import 'extensions/string_extension.dart'; -/// Class responsible for uploading images and files from a given channel -abstract class AttachmentUploader { - /// Uploads a image [file] to the given channel. - /// Returns image [file] URL once sent successfully. +/// Class responsible for uploading images and files to a given channel +abstract class AttachmentFileUploader { + /// Uploads a [image] to the given channel. + /// Returns [SendImageResponse] once sent successfully. /// /// Optionally, access upload progress using [onSendProgress] /// and cancel the request using [cancelToken] - Future uploadImage( - AttachmentFile file, + Future sendImage( + AttachmentFile image, String channelId, String channelType, { ProgressCallback onSendProgress, @@ -19,11 +20,11 @@ abstract class AttachmentUploader { }); /// Uploads a [file] to the given channel. - /// Returns [file] URL once sent successfully. + /// Returns [SendFileResponse] once sent successfully. /// /// Optionally, access upload progress using [onSendProgress] /// and cancel the request using [cancelToken] - Future uploadFile( + Future sendFile( AttachmentFile file, String channelId, String channelType, { @@ -32,15 +33,15 @@ abstract class AttachmentUploader { }); } -/// Stream's default implementation of [AttachmentUploader] -class StreamAttachmentUploader implements AttachmentUploader { +/// Stream's default implementation of [AttachmentFileUploader] +class StreamAttachmentUploader implements AttachmentFileUploader { final StreamChatClient _client; /// Creates a new [StreamAttachmentUploader] instance. const StreamAttachmentUploader(this._client); @override - Future uploadImage( + Future sendImage( AttachmentFile file, String channelId, String channelType, { @@ -49,22 +50,23 @@ class StreamAttachmentUploader implements AttachmentUploader { }) async { final filename = file.path?.split('/')?.last; final mimeType = filename.mimeType; - final res = await _client.sendImage( - await MultipartFile.fromFile( - file.path, - filename: filename, - contentType: mimeType, - ), - channelId, - channelType, + final response = await _client.post( + '/channels/$channelType/$channelId/image', + data: FormData.fromMap({ + 'file': await MultipartFile.fromFile( + file.path, + filename: filename, + contentType: mimeType, + ), + }), onSendProgress: onSendProgress, cancelToken: cancelToken, ); - return res.file; + return _client.decode(response.data, SendImageResponse.fromJson); } @override - Future uploadFile( + Future sendFile( AttachmentFile file, String channelId, String channelType, { @@ -73,17 +75,18 @@ class StreamAttachmentUploader implements AttachmentUploader { }) async { final filename = file.path?.split('/')?.last; final mimeType = filename.mimeType; - final res = await _client.sendFile( - await MultipartFile.fromFile( - file.path, - filename: filename, - contentType: mimeType, - ), - channelId, - channelType, + final response = await _client.post( + '/channels/$channelType/$channelId/file', + data: FormData.fromMap({ + 'file': await MultipartFile.fromFile( + file.path, + filename: filename, + contentType: mimeType, + ), + }), onSendProgress: onSendProgress, cancelToken: cancelToken, ); - return res.file; + return _client.decode(response.data, SendFileResponse.fromJson); } } diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart index 5118d6b87..6a513151c 100644 --- a/packages/stream_chat/lib/src/client.dart +++ b/packages/stream_chat/lib/src/client.dart @@ -8,11 +8,12 @@ import 'package:pedantic/pedantic.dart' show unawaited; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/src/api/retry_policy.dart'; import 'package:stream_chat/src/event_type.dart'; +import 'package:stream_chat/src/models/attachment_file.dart'; import 'package:stream_chat/src/models/own_user.dart'; import 'package:stream_chat/version.dart'; import 'package:uuid/uuid.dart'; -import 'api/attachment_uploader.dart'; +import 'attachment_file_uploader.dart'; import 'api/channel.dart'; import 'api/connection_status.dart'; import 'api/requests.dart'; @@ -102,7 +103,7 @@ class StreamChatClient { ChatPersistenceClient chatPersistenceClient; /// Attachment uploader - AttachmentUploader attachmentUploader; + AttachmentFileUploader attachmentUploader; /// Whether the chat persistence is available or not bool get persistenceEnabled => chatPersistenceClient != null; @@ -1043,36 +1044,36 @@ class StreamChatClient { /// Send a [file] to the [channelId] of type [channelType] Future sendFile( - MultipartFile file, + AttachmentFile file, String channelId, String channelType, { ProgressCallback onSendProgress, CancelToken cancelToken, - }) async { - final response = await post( - '/channels/$channelType/$channelId/file', - data: FormData.fromMap({'file': file}), + }) { + return attachmentUploader.sendFile( + file, + channelId, + channelType, onSendProgress: onSendProgress, cancelToken: cancelToken, ); - return decode(response.data, SendFileResponse.fromJson); } /// Send a [image] to the [channelId] of type [channelType] Future sendImage( - MultipartFile image, + AttachmentFile image, String channelId, String channelType, { ProgressCallback onSendProgress, CancelToken cancelToken, - }) async { - final response = await post( - '/channels/$channelType/$channelId/image', - data: FormData.fromMap({'file': image}), + }) { + return attachmentUploader.sendImage( + image, + channelId, + channelType, onSendProgress: onSendProgress, cancelToken: cancelToken, ); - return decode(response.data, SendImageResponse.fromJson); } /// Add a device for Push Notifications. diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index dd09e8e84..f0b3e60d4 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -10,7 +10,7 @@ export './src/api/connection_status.dart'; export './src/api/requests.dart'; export './src/api/requests.dart'; export './src/api/responses.dart'; -export './src/api/attachment_uploader.dart' show AttachmentUploader; +export './src/attachment_file_uploader.dart' show AttachmentFileUploader; export './src/client.dart'; export './src/event_type.dart'; export './src/models/action.dart'; diff --git a/packages/stream_chat/test/src/api/channel_test.dart b/packages/stream_chat/test/src/api/channel_test.dart index 32ac65c28..def7f4177 100644 --- a/packages/stream_chat/test/src/api/channel_test.dart +++ b/packages/stream_chat/test/src/api/channel_test.dart @@ -10,8 +10,12 @@ import 'package:stream_chat/src/models/reaction.dart'; import 'package:stream_chat/src/models/own_user.dart'; import 'package:test/test.dart'; +import 'package:stream_chat/stream_chat.dart'; + class MockDio extends Mock implements DioForNative {} +class MockAttachmentUploader extends Mock implements AttachmentFileUploader {} + class MockHttpClientAdapter extends Mock implements HttpClientAdapter {} void main() { @@ -44,6 +48,61 @@ void main() { mockDio.post('/channels/messaging/testid/message', data: { 'message': message.toJson(), })).called(1); + + // final imageFile = MultipartFile.fromString('value'); + // + // channelClient + // .sendImage(imageFile) + // .then((response) { + // final imageUrl = response.file; + // final attachment = Attachment( + // type = "image", + // imageUrl = imageUrl, + // ) + // val message = Message( + // attachments = mutableListOf(attachment), + // ) + // channelClient + // .sendMessage(message) + // .enqueue { + // /* ... */ + // } + // }) + // .catchError(onError); + + //final channelClient = client.channel("messaging", id:'general'); + // + // // Upload an image without detailed progress + // channelClient.sendImage(imageFile).enqueue { result-> + // if (result.isSuccess) { + // // Successful upload, you can now attach this image + // // to an message that you then send to a channel + // val imageUrl = result.data() + // val attachment = Attachment( + // type = "image", + // imageUrl = imageUrl, + // ) + // val message = Message( + // attachments = mutableListOf(attachment), + // ) + // channelClient.sendMessage(message).enqueue { /* ... */ } + // } + // } + // + // // Upload a file, monitoring for progress with a ProgressCallback + // channelClient.sendFile(anyOtherFile, object : ProgressCallback { + // override fun onSuccess(file: String) { + // val fileUrl = file + // } + // + // override fun onError(error: ChatError) { + // // Handle error + // } + // + // override fun onProgress(progress: Long) { + // // You can render the uploading progress here + // } + // }).enqueue() // No callback passed to enqueue, as we'll get notified above anyway }); test('markRead', () async { @@ -170,6 +229,11 @@ void main() { test('sendFile', () async { final mockDio = MockDio(); + final mockUploader = MockAttachmentUploader(); + + final file = AttachmentFile(path: 'filePath/fileName.pdf'); + final channelId = 'testId'; + final channelType = 'messaging'; when(mockDio.options).thenReturn(BaseOptions()); when(mockDio.interceptors).thenReturn(Interceptors()); @@ -178,23 +242,25 @@ void main() { 'api-key', httpClient: mockDio, tokenProvider: (_) async => '', + attachmentUploader: mockUploader, ); - final channelClient = client.channel('messaging', id: 'testid'); - final file = MultipartFile.fromString('file'); + final channelClient = client.channel(channelType, id: channelId); - when(mockDio.post('/channels/messaging/testid/file', - data: argThat(isA(), named: 'data'))) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + when(mockUploader.sendFile(file, channelId, channelType)) + .thenAnswer((_) async => SendFileResponse()); await channelClient.sendFile(file); - verify(mockDio.post('/channels/messaging/testid/file', - data: argThat(isA(), named: 'data'))) - .called(1); + verify(mockUploader.sendFile(file, channelId, channelType)).called(1); }); test('sendImage', () async { final mockDio = MockDio(); + final mockUploader = MockAttachmentUploader(); + + final image = AttachmentFile(path: 'imagePath/imageName.jpeg'); + final channelId = 'testId'; + final channelType = 'messaging'; when(mockDio.options).thenReturn(BaseOptions()); when(mockDio.interceptors).thenReturn(Interceptors()); @@ -203,18 +269,16 @@ void main() { 'api-key', httpClient: mockDio, tokenProvider: (_) async => '', + attachmentUploader: mockUploader, ); - final channelClient = client.channel('messaging', id: 'testid'); - final file = MultipartFile.fromString('file'); + final channelClient = client.channel(channelType, id: channelId); - when(mockDio.post('/channels/messaging/testid/image', - data: argThat(isA(), named: 'data'))) - .thenAnswer((_) async => Response(data: '{}', statusCode: 200)); + when(mockUploader.sendImage(image, channelId, channelType)) + .thenAnswer((_) async => SendImageResponse()); - await channelClient.sendImage(file); + await channelClient.sendImage(image); - verify(mockDio.post('/channels/messaging/testid/image', - data: argThat(isA(), named: 'data'))) + verify(mockUploader.sendImage(image, channelId, channelType)) .called(1); }); From 8766fabc7469115613d83ceebb5ec27f3c61a9da Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 17 Feb 2021 20:16:48 +0530 Subject: [PATCH 14/19] [LLC -> test] Remove dead code Signed-off-by: Sahil Kumar --- .../test/src/api/channel_test.dart | 58 +------------------ 1 file changed, 1 insertion(+), 57 deletions(-) diff --git a/packages/stream_chat/test/src/api/channel_test.dart b/packages/stream_chat/test/src/api/channel_test.dart index def7f4177..852a6d893 100644 --- a/packages/stream_chat/test/src/api/channel_test.dart +++ b/packages/stream_chat/test/src/api/channel_test.dart @@ -48,61 +48,6 @@ void main() { mockDio.post('/channels/messaging/testid/message', data: { 'message': message.toJson(), })).called(1); - - // final imageFile = MultipartFile.fromString('value'); - // - // channelClient - // .sendImage(imageFile) - // .then((response) { - // final imageUrl = response.file; - // final attachment = Attachment( - // type = "image", - // imageUrl = imageUrl, - // ) - // val message = Message( - // attachments = mutableListOf(attachment), - // ) - // channelClient - // .sendMessage(message) - // .enqueue { - // /* ... */ - // } - // }) - // .catchError(onError); - - //final channelClient = client.channel("messaging", id:'general'); - // - // // Upload an image without detailed progress - // channelClient.sendImage(imageFile).enqueue { result-> - // if (result.isSuccess) { - // // Successful upload, you can now attach this image - // // to an message that you then send to a channel - // val imageUrl = result.data() - // val attachment = Attachment( - // type = "image", - // imageUrl = imageUrl, - // ) - // val message = Message( - // attachments = mutableListOf(attachment), - // ) - // channelClient.sendMessage(message).enqueue { /* ... */ } - // } - // } - // - // // Upload a file, monitoring for progress with a ProgressCallback - // channelClient.sendFile(anyOtherFile, object : ProgressCallback { - // override fun onSuccess(file: String) { - // val fileUrl = file - // } - // - // override fun onError(error: ChatError) { - // // Handle error - // } - // - // override fun onProgress(progress: Long) { - // // You can render the uploading progress here - // } - // }).enqueue() // No callback passed to enqueue, as we'll get notified above anyway }); test('markRead', () async { @@ -278,8 +223,7 @@ void main() { await channelClient.sendImage(image); - verify(mockUploader.sendImage(image, channelId, channelType)) - .called(1); + verify(mockUploader.sendImage(image, channelId, channelType)).called(1); }); test('deleteFile', () async { From b7358354e36ab67b00f90102c9bff937d6cd9bfb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Thu, 18 Feb 2021 15:16:48 +0530 Subject: [PATCH 15/19] [LLC] Rename AttachmentUploader to AttachmentFileUploader Signed-off-by: Sahil Kumar --- .../stream_chat/lib/src/attachment_file_uploader.dart | 6 +++--- packages/stream_chat/lib/src/client.dart | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/stream_chat/lib/src/attachment_file_uploader.dart b/packages/stream_chat/lib/src/attachment_file_uploader.dart index 8db8b168a..8cb970eb1 100644 --- a/packages/stream_chat/lib/src/attachment_file_uploader.dart +++ b/packages/stream_chat/lib/src/attachment_file_uploader.dart @@ -34,11 +34,11 @@ abstract class AttachmentFileUploader { } /// Stream's default implementation of [AttachmentFileUploader] -class StreamAttachmentUploader implements AttachmentFileUploader { +class StreamAttachmentFileUploader implements AttachmentFileUploader { final StreamChatClient _client; - /// Creates a new [StreamAttachmentUploader] instance. - const StreamAttachmentUploader(this._client); + /// Creates a new [StreamAttachmentFileUploader] instance. + const StreamAttachmentFileUploader(this._client); @override Future sendImage( diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart index 6a513151c..6ee8609ee 100644 --- a/packages/stream_chat/lib/src/client.dart +++ b/packages/stream_chat/lib/src/client.dart @@ -80,7 +80,7 @@ class StreamChatClient { Duration receiveTimeout = const Duration(seconds: 6), Dio httpClient, RetryPolicy retryPolicy, - this.attachmentUploader, + this.attachmentFileUploader, }) { _retryPolicy ??= RetryPolicy( retryTimeout: (StreamChatClient client, int attempt, ApiError error) => @@ -89,7 +89,7 @@ class StreamChatClient { attempt < 5, ); - attachmentUploader ??= StreamAttachmentUploader(this); + attachmentFileUploader ??= StreamAttachmentFileUploader(this); state = ClientState(this); @@ -103,7 +103,7 @@ class StreamChatClient { ChatPersistenceClient chatPersistenceClient; /// Attachment uploader - AttachmentFileUploader attachmentUploader; + AttachmentFileUploader attachmentFileUploader; /// Whether the chat persistence is available or not bool get persistenceEnabled => chatPersistenceClient != null; @@ -1050,7 +1050,7 @@ class StreamChatClient { ProgressCallback onSendProgress, CancelToken cancelToken, }) { - return attachmentUploader.sendFile( + return attachmentFileUploader.sendFile( file, channelId, channelType, @@ -1067,7 +1067,7 @@ class StreamChatClient { ProgressCallback onSendProgress, CancelToken cancelToken, }) { - return attachmentUploader.sendImage( + return attachmentFileUploader.sendImage( image, channelId, channelType, From 183676bb3895fefa7e0986bd4b636d85d4c7908e Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 18 Feb 2021 11:45:50 +0100 Subject: [PATCH 16/19] fix test --- packages/stream_chat/lib/src/client.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stream_chat/lib/src/client.dart b/packages/stream_chat/lib/src/client.dart index 6ee8609ee..f898a3897 100644 --- a/packages/stream_chat/lib/src/client.dart +++ b/packages/stream_chat/lib/src/client.dart @@ -1252,7 +1252,7 @@ class StreamChatClient { Message message, String channelId, String channelType) async { final response = await post( '/channels/$channelType/$channelId/message', - data: {'message': message}, + data: {'message': message.toJson()}, ); return decode(response.data, SendMessageResponse.fromJson); } From b4e4acc40ec634952a5baac6ab6ed16820dbaace Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 18 Feb 2021 12:05:21 +0100 Subject: [PATCH 17/19] fix test --- packages/stream_chat/test/src/api/channel_test.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/stream_chat/test/src/api/channel_test.dart b/packages/stream_chat/test/src/api/channel_test.dart index 852a6d893..937113da9 100644 --- a/packages/stream_chat/test/src/api/channel_test.dart +++ b/packages/stream_chat/test/src/api/channel_test.dart @@ -187,7 +187,7 @@ void main() { 'api-key', httpClient: mockDio, tokenProvider: (_) async => '', - attachmentUploader: mockUploader, + attachmentFileUploader: mockUploader, ); final channelClient = client.channel(channelType, id: channelId); @@ -214,7 +214,7 @@ void main() { 'api-key', httpClient: mockDio, tokenProvider: (_) async => '', - attachmentUploader: mockUploader, + attachmentFileUploader: mockUploader, ); final channelClient = client.channel(channelType, id: channelId); From e6fcd9bfea98c323c9a952211f77dac92246a219 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 18 Feb 2021 12:50:19 +0100 Subject: [PATCH 18/19] version bp --- packages/stream_chat/CHANGELOG.md | 6 ++++++ packages/stream_chat/lib/version.dart | 2 +- packages/stream_chat/pubspec.yaml | 2 +- packages/stream_chat_flutter/CHANGELOG.md | 7 +++++++ packages/stream_chat_flutter/pubspec.yaml | 5 ++--- packages/stream_chat_flutter_core/CHANGELOG.md | 4 ++++ packages/stream_chat_flutter_core/pubspec.yaml | 5 ++--- packages/stream_chat_persistence/CHANGELOG.md | 4 ++++ packages/stream_chat_persistence/pubspec.yaml | 5 ++--- 9 files changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 0976da892..be8f634fe 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,9 @@ +## 1.1.0-beta + +- Fixed minor bugs +- Add support for custom attachment upload [docs here](https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart) +- Add support for asynchronous attachment upload + ## 1.0.3-beta - Fixed issue with disconnecting after connecting without awaiting the connection result diff --git a/packages/stream_chat/lib/version.dart b/packages/stream_chat/lib/version.dart index 904d76f2e..f3afe124c 100644 --- a/packages/stream_chat/lib/version.dart +++ b/packages/stream_chat/lib/version.dart @@ -2,4 +2,4 @@ import 'package:stream_chat/src/client.dart'; /// Current package version /// Used in [StreamChatClient] to build the `x-stream-client` header -const PACKAGE_VERSION = '1.0.3-beta'; +const PACKAGE_VERSION = '1.1.0-beta'; diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index 01731c0a2..b769aeef3 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat homepage: https://getstream.io/ description: The official Dart client for Stream Chat, a service for building chat applications. -version: 1.0.3-beta +version: 1.1.0-beta repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 014cca9b9..68fff28a3 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.1.0-beta + +- Update stream_chat_core dependency +- Expose common builders in ListView widgets +- Add support for asynchronous attachment upload while sending a message +- Fixed minor bugs + ## 1.0.2-beta - Update stream_chat_core dependency diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 51557b3c9..47fd51b93 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter. -version: 1.0.2-beta +version: 1.1.0-beta repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -11,8 +11,7 @@ environment: dependencies: flutter: sdk: flutter - stream_chat_flutter_core: - path: ../stream_chat_flutter_core + stream_chat_flutter_core: ^1.1.0-beta flutter_app_badger: ^1.1.2 photo_view: ^0.10.3 rxdart: ^0.25.0 diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index 1a07f043f..bb4d9b86e 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.1.0-beta + +* Update llc dependency + ## 1.0.2-beta * Update llc dependency diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index c143fba40..e60dff98c 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter_core homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter. -version: 1.0.2-beta +version: 1.1.0-beta repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -10,8 +10,7 @@ environment: flutter: ">=1.17.0" dependencies: - stream_chat: - path: ../stream_chat + stream_chat: ^1.1.0-beta flutter: sdk: flutter rxdart: ^0.25.0 diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 668220b11..619307532 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.1.0-beta + +* Update llc dependency + ## 1.0.2-beta * Update llc dependency diff --git a/packages/stream_chat_persistence/pubspec.yaml b/packages/stream_chat_persistence/pubspec.yaml index 6d2ee3617..327063c98 100644 --- a/packages/stream_chat_persistence/pubspec.yaml +++ b/packages/stream_chat_persistence/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_persistence homepage: https://github.com/GetStream/stream-chat-flutter description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter. -version: 1.0.2-beta +version: 1.1.0-beta repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues @@ -13,8 +13,7 @@ dependencies: path: ^1.7.0 path_provider: ^1.6.27 sqlite3_flutter_libs: ^0.3.0 - stream_chat: - path: ../stream_chat + stream_chat: ^1.1.0-beta dev_dependencies: test: ^1.15.7 From 29ac66228d981586a4b76c25b7cf9f9a7ab64e63 Mon Sep 17 00:00:00 2001 From: Salvatore Giordano Date: Thu, 18 Feb 2021 13:38:34 +0100 Subject: [PATCH 19/19] publish packages --- packages/stream_chat/pubspec.yaml | 1 + packages/stream_chat_flutter/pubspec.yaml | 1 + packages/stream_chat_persistence/example/pubspec.yaml | 3 +-- packages/stream_chat_persistence/pubspec.yaml | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index b769aeef3..8c1f86b3a 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -21,6 +21,7 @@ dependencies: meta: ^1.2.4 mime: ^0.9.7 freezed_annotation: ^0.12.0 + http_parser: ^3.1.4 dev_dependencies: build_runner: ^1.10.0 diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 47fd51b93..cd09c890c 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: video_compress: ^2.1.1 visibility_detector: ^0.1.5 http_parser: ^3.1.4 + meta: ^1.2.4 lottie: ^0.7.0+1 substring_highlight: ^0.1.2 flutter_slidable: ^0.5.7 diff --git a/packages/stream_chat_persistence/example/pubspec.yaml b/packages/stream_chat_persistence/example/pubspec.yaml index 2586bea65..4298e23a5 100644 --- a/packages/stream_chat_persistence/example/pubspec.yaml +++ b/packages/stream_chat_persistence/example/pubspec.yaml @@ -11,8 +11,7 @@ dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.0 - stream_chat: - path: ../../stream_chat + stream_chat: ^1.1.0-beta stream_chat_persistence: path: ../ diff --git a/packages/stream_chat_persistence/pubspec.yaml b/packages/stream_chat_persistence/pubspec.yaml index 327063c98..4057f10cf 100644 --- a/packages/stream_chat_persistence/pubspec.yaml +++ b/packages/stream_chat_persistence/pubspec.yaml @@ -12,7 +12,7 @@ dependencies: moor: ^3.4.0 path: ^1.7.0 path_provider: ^1.6.27 - sqlite3_flutter_libs: ^0.3.0 + sqlite3_flutter_libs: ^0.4.0+1 stream_chat: ^1.1.0-beta dev_dependencies: