From 0a6948cf326cd02e2e74d6241be85bb69a3254bb Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 16 Feb 2021 21:21:15 +0530 Subject: [PATCH] [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 | 151 +++--- .../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, 2561 insertions(+), 1328 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 87c12424..24e35a1f 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 76cd1b72..c4c87dcb 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 4b525661..45eb2032 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 534d9c08..a6c6a8b6 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 0d3d9ec3..cd2055e4 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 00000000..49949412 --- /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 8546fb9d..cc55aa71 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 db276d69..c45771c2 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 00000000..bffc24ae --- /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 00000000..d8085491 --- /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 00000000..36a42681 --- /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 ceff9652..3127bba3 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 e34859fb..dd09e8e8 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 218b16d3..2fa16ec0 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 1aec2aa0..1721e6f1 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 1d526a16..21a3cc14 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 2c5ce8a8..731ef080 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 00000000..42590831 --- /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 af5a1115..176bbbd3 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 00000000..19bf6a1a --- /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 00000000..0fd0593d --- /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 00000000..4cf55aed --- /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 0f89bc75..ed5a5750 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 00000000..d4b70dd6 --- /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 00000000..dc168bd2 --- /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 46b7b814..00000000 --- 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 244729a4..a010a500 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 8e9f566c..ab2e2055 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 2a36c1f5..00000000 --- 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 0997d137..af2b0994 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 f92f0ec1..00000000 --- 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 bee8f2ba..6da6a2e0 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; - _chewieController = ChewieController( - videoPlayerController: _videoPlayerController, - autoInitialize: true, - 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; ChewieController get chewieController => _chewieController; - void dispose() { - _videoPlayerController.dispose(); - _chewieController.dispose(); + 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: _autoInitialize, + showControls: _showControls, + aspectRatio: _videoPlayerController.value.aspectRatio, + ); + }); + } + + void addListener(VoidCallback listener) { + return _videoPlayerController.addListener(listener); + } + + void removeListener(VoidCallback listener) { + return _videoPlayerController.removeListener(listener); + } + + 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 4be1dc1d..3c4a7c73 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 4be47262..00000000 --- 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 7b0ed621..c509c469 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 3a290c60..39c2883a 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 e55e02df..00000000 --- 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 9214befb..73211014 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 4f995bf9..0a34e517 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)); - }); + _showErrorAlert( + 'The file is too large to upload. The file size limit is 20MB.', + ); } - - 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'); } + + 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 2929c2d7..6a753910 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 c44022bf..cc587786 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 d4a6702a..eed3d90b 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 8b0912eb..50741419 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 b0b3d0bb..fa5d9dc4 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 00000000..d0c7e454 --- /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 bfc0bda9..4b6b8ab2 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 107776bf..00000000 --- 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 00000000..30d06c55 --- /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 00000000..8932c68c --- /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 fa296075..6fc905b5 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 00000000..f850a61e --- /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 435fad27..beffbe43 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 f5cc5ed2..2586bea6 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 5ad92ddd..c8bda4e6 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,