From 56d6b9643c57a644d3c55e4411315204b5358f73 Mon Sep 17 00:00:00 2001 From: Andre Rossi Date: Wed, 18 Oct 2023 13:45:27 -0300 Subject: [PATCH 1/2] feat: improve the performance of the image bubble --- .../ds_image_message_bubble.controller.dart | 105 ++++++++--- lib/src/services/ds_file.service.dart | 5 + .../utils/ds_directory_formatter.util.dart | 36 ++++ lib/src/widgets/chat/ds_carrousel.widget.dart | 2 +- .../chat/ds_image_message_bubble.widget.dart | 175 ++++++++++-------- lib/src/widgets/utils/ds_card.widget.dart | 3 + .../utils/ds_expanded_image.widget.dart | 76 ++++++-- pubspec.yaml | 3 +- 8 files changed, 288 insertions(+), 117 deletions(-) create mode 100644 lib/src/utils/ds_directory_formatter.util.dart diff --git a/lib/src/controllers/chat/ds_image_message_bubble.controller.dart b/lib/src/controllers/chat/ds_image_message_bubble.controller.dart index 2e497c86..deb7ab49 100644 --- a/lib/src/controllers/chat/ds_image_message_bubble.controller.dart +++ b/lib/src/controllers/chat/ds_image_message_bubble.controller.dart @@ -1,34 +1,95 @@ import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; -import 'package:flutter/material.dart'; +import 'package:blip_ds/blip_ds.dart'; +import 'package:blip_ds/src/utils/ds_directory_formatter.util.dart'; +import 'package:crypto/crypto.dart'; +import 'package:ffmpeg_kit_flutter_full_gpl/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_full_gpl/return_code.dart'; import 'package:get/get.dart'; -import '../../services/ds_auth.service.dart'; - class DSImageMessageBubbleController extends GetxController { - Future getImageInfo({ - required final String url, - final bool shouldAuthenticate = false, - }) async { - final Image img = Image.network( - url, - headers: shouldAuthenticate ? DSAuthService.httpHeaders : null, - ); + final maximumProgress = RxInt(0); + final downloadProgress = RxInt(0); + final localPath = RxnString(); + final isDownloading = RxBool(true); - final completer = Completer(); + final String url; + final String? mediaType; + final bool shouldAuthenticate; - final ImageStream imageStream = - img.image.resolve(const ImageConfiguration()); + DSImageMessageBubbleController( + this.url, { + this.mediaType, + this.shouldAuthenticate = false, + }) { + _downloadImage(); + } - imageStream.addListener( - ImageStreamListener( - (ImageInfo i, bool _) { - completer.complete(i); - }, - onError: (exception, stackTrace) => completer.completeError(exception), - ), + void _onReceiveProgress(final int currentProgress, final int maxProgres) { + downloadProgress.value = currentProgress; + maximumProgress.value = maxProgres; + } + + Future _downloadImage() async { + if (mediaType == null || !url.startsWith('http')) { + localPath.value = url; + return; + } + + final uri = Uri.parse(url); + + final fullPath = await DSDirectoryFormatter.getPath( + type: mediaType!, + fileName: md5.convert(utf8.encode(uri.path)).toString(), ); - return completer.future; + if (await File(fullPath).exists()) { + localPath.value = fullPath; + return; + } + + try { + final path = await DSFileService.download( + url, + fullPath.substring( + fullPath.lastIndexOf('/') + 1, + ), + onReceiveProgress: _onReceiveProgress, + httpHeaders: shouldAuthenticate ? DSAuthService.httpHeaders : null, + ); + + isDownloading.value = false; + + final success = await _compressImage( + input: path!, + output: fullPath, + ); + + if (success) { + if (await File(path).exists()) { + await File(path).delete(); + } + localPath.value = fullPath; + } else { + localPath.value = url; + } + } catch (_) { + localPath.value = url; + isDownloading.value = false; + } + } + + Future _compressImage({ + required String input, + required String output, + }) async { + final session = await FFmpegKit.execute( + '-hide_banner -y -i "$input" -vf scale=-2:720 "$output"'); + + final returnCode = await session.getReturnCode(); + + return ReturnCode.isSuccess(returnCode); } } diff --git a/lib/src/services/ds_file.service.dart b/lib/src/services/ds_file.service.dart index 640a2fd2..1952ca00 100644 --- a/lib/src/services/ds_file.service.dart +++ b/lib/src/services/ds_file.service.dart @@ -47,6 +47,7 @@ abstract class DSFileService { final String? path, final void Function(bool)? onDownloadStateChange, final Map? httpHeaders, + final Function(int, int)? onReceiveProgress, }) async { try { onDownloadStateChange?.call(true); @@ -63,6 +64,10 @@ abstract class DSFileService { options: Options( headers: httpHeaders, ), + onReceiveProgress: onReceiveProgress != null + ? (currentProgress, maximumProgress) => + onReceiveProgress(currentProgress, maximumProgress) + : null, ); if (response.statusCode == 200) return savedFilePath; diff --git a/lib/src/utils/ds_directory_formatter.util.dart b/lib/src/utils/ds_directory_formatter.util.dart new file mode 100644 index 00000000..16ae7070 --- /dev/null +++ b/lib/src/utils/ds_directory_formatter.util.dart @@ -0,0 +1,36 @@ +import 'dart:io'; + +import 'package:get/get.dart'; +import 'package:path_provider/path_provider.dart'; + +abstract class DSDirectoryFormatter { + static Future getPath({ + required final String type, + required final String fileName, + }) async { + final String? temporaryPath = Platform.isAndroid + ? (await getExternalCacheDirectories())?.first.path + : (await getApplicationCacheDirectory()).path; + final typeName = '${type.split('/').first.capitalizeFirst}'; + final prefix = fileName.contains(typeName.substring(0, 3).toUpperCase()) + ? '' + : '${typeName.substring(0, 3).toUpperCase()}-'; + final extension = type.split('/').last; + final path = + await _formatDirectory(typeName: typeName, directory: temporaryPath!); + final fullPath = '$path/$prefix$fileName.$extension'; + return fullPath; + } + + static Future _formatDirectory( + {required String typeName, required String directory}) async { + final formattedDirectory = '$directory/$typeName'; + final directoryExists = await Directory(formattedDirectory).exists(); + + if (!directoryExists) { + await Directory(formattedDirectory).create(recursive: true); + } + + return formattedDirectory; + } +} diff --git a/lib/src/widgets/chat/ds_carrousel.widget.dart b/lib/src/widgets/chat/ds_carrousel.widget.dart index d398b7fc..388563d1 100644 --- a/lib/src/widgets/chat/ds_carrousel.widget.dart +++ b/lib/src/widgets/chat/ds_carrousel.widget.dart @@ -7,7 +7,6 @@ import '../../models/ds_message_bubble_avatar_config.model.dart'; import '../../models/ds_message_bubble_style.model.dart'; import '../../utils/ds_utils.util.dart'; import '../utils/ds_card.widget.dart'; - import 'ds_image_message_bubble.widget.dart'; /// A Design System widget used to display multiple cards. @@ -95,6 +94,7 @@ class DSCarrousel extends StatelessWidget { onSelected: onSelected, onOpenLink: onOpenLink, style: style, + mediaType: header["value"]["type"], ), ), ); diff --git a/lib/src/widgets/chat/ds_image_message_bubble.widget.dart b/lib/src/widgets/chat/ds_image_message_bubble.widget.dart index 51759838..5c0c8053 100644 --- a/lib/src/widgets/chat/ds_image_message_bubble.widget.dart +++ b/lib/src/widgets/chat/ds_image_message_bubble.widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:get/get.dart'; import '../../controllers/chat/ds_image_message_bubble.controller.dart'; import '../../enums/ds_align.enum.dart'; @@ -6,7 +7,6 @@ import '../../enums/ds_border_radius.enum.dart'; import '../../models/ds_document_select.model.dart'; import '../../models/ds_message_bubble_style.model.dart'; import '../../themes/colors/ds_colors.theme.dart'; -import '../../utils/ds_utils.util.dart'; import '../texts/ds_caption_text.widget.dart'; import '../utils/ds_expanded_image.widget.dart'; import 'ds_document_select.widget.dart'; @@ -30,6 +30,9 @@ class DSImageMessageBubble extends StatefulWidget { this.onSelected, this.onOpenLink, this.shouldAuthenticate = false, + this.mediaType, + this.imageMaxHeight, + this.imageMinHeight, }) : style = style ?? DSMessageBubbleStyle(); final DSAlign align; @@ -46,6 +49,9 @@ class DSImageMessageBubble extends StatefulWidget { final void Function(String, Map)? onSelected; final void Function(Map)? onOpenLink; final bool shouldAuthenticate; + final String? mediaType; + final double? imageMaxHeight; + final double? imageMinHeight; @override State createState() => _DSImageMessageBubbleState(); @@ -58,7 +64,12 @@ class _DSImageMessageBubbleState extends State @override initState() { super.initState(); - _controller = DSImageMessageBubbleController(); + + _controller = DSImageMessageBubbleController( + widget.url, + mediaType: widget.mediaType, + shouldAuthenticate: widget.shouldAuthenticate, + ); } @override @@ -77,86 +88,100 @@ class _DSImageMessageBubbleState extends State padding: EdgeInsets.zero, hasSpacer: widget.hasSpacer, style: widget.style, - child: FutureBuilder( - future: _controller.getImageInfo( - url: widget.url, - shouldAuthenticate: widget.shouldAuthenticate, - ), - builder: (buildContext, snapshot) { - final isLoadingImage = !(snapshot.hasData || snapshot.hasError); - - final ImageInfo? data = - snapshot.hasData ? snapshot.data as ImageInfo : null; - - final width = - snapshot.hasData && data!.image.width > DSUtils.bubbleMinSize - ? data.image.width.toDouble() - : DSUtils.bubbleMinSize; - - return LayoutBuilder( - builder: (_, constraints) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DSExpandedImage( - appBarText: widget.appBarText, - appBarPhotoUri: widget.appBarPhotoUri, - url: widget.url, - width: width, - maxHeight: DSUtils.bubbleMaxSize, - align: widget.align, - style: widget.style, - isLoading: isLoadingImage, - shouldAuthenticate: widget.shouldAuthenticate, - ), - if ((widget.title?.isNotEmpty ?? false) || - (widget.text?.isNotEmpty ?? false)) - SizedBox( - width: width, - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.title?.isNotEmpty ?? false) - DSCaptionText( - widget.title!, - color: foregroundColor, - isSelectable: true, - ), - if ((widget.text?.isNotEmpty ?? false) && - (widget.title?.isNotEmpty ?? false)) - const SizedBox( - height: 6.0, - ), - if (widget.text?.isNotEmpty ?? false) - DSShowMoreText( - text: widget.text!, - maxWidth: constraints.maxWidth, - align: widget.align, - style: widget.style, - ) - ], - ), - ), - ), - if (widget.showSelect) - DSDocumentSelect( - align: widget.align, - options: widget.selectOptions, - onSelected: widget.onSelected, - onOpenLink: widget.onOpenLink, - style: widget.style, + child: LayoutBuilder( + builder: (_, constraints) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx( + () => _controller.localPath.value != null + ? DSExpandedImage( + appBarText: widget.appBarText, + appBarPhotoUri: widget.appBarPhotoUri, + url: _controller.localPath.value!, + maxHeight: widget.imageMaxHeight != null + ? widget.imageMaxHeight! + : widget.showSelect + ? 200.0 + : double.infinity, + minHeight: widget.imageMinHeight != null + ? widget.imageMinHeight! + : widget.showSelect + ? 200.0 + : 0.0, + align: widget.align, + style: widget.style, + isLoading: false, + shouldAuthenticate: widget.shouldAuthenticate, + ) + : _buildDownloadProgress(), + ), + if ((widget.title?.isNotEmpty ?? false) || + (widget.text?.isNotEmpty ?? false)) + SizedBox( + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.title?.isNotEmpty ?? false) + DSCaptionText( + widget.title!, + color: foregroundColor, + isSelectable: true, + ), + if ((widget.text?.isNotEmpty ?? false) && + (widget.title?.isNotEmpty ?? false)) + const SizedBox( + height: 6.0, + ), + if (widget.text?.isNotEmpty ?? false) + DSShowMoreText( + text: widget.text!, + maxWidth: constraints.maxWidth, + align: widget.align, + style: widget.style, + ) + ], ), - ], - ); - }, + ), + ), + if (widget.showSelect) + DSDocumentSelect( + align: widget.align, + options: widget.selectOptions, + onSelected: widget.onSelected, + onOpenLink: widget.onOpenLink, + style: widget.style, + ), + ], ); }, ), ); } + Widget _buildDownloadProgress() { + final foregroundColor = widget.style.isLightBubbleBackground(widget.align) + ? DSColors.neutralDarkCity + : DSColors.neutralLightSnow; + + final double percent = _controller.maximumProgress.value > 0 + ? _controller.downloadProgress.value / _controller.maximumProgress.value + : 0; + + return Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: CircularProgressIndicator( + color: foregroundColor, + backgroundColor: Colors.grey, + value: _controller.isDownloading.value ? percent : null, + ), + ), + ); + } + @override bool get wantKeepAlive => true; } diff --git a/lib/src/widgets/utils/ds_card.widget.dart b/lib/src/widgets/utils/ds_card.widget.dart index 6898ecb6..34079e63 100644 --- a/lib/src/widgets/utils/ds_card.widget.dart +++ b/lib/src/widgets/utils/ds_card.widget.dart @@ -166,6 +166,7 @@ class DSCard extends StatelessWidget { showSelect: true, onSelected: onSelected, onOpenLink: onOpenLink, + mediaType: documentSelectModel.header.mediaLink.type, ); } @@ -256,6 +257,8 @@ class DSCard extends StatelessWidget { borderRadius: borderRadius, style: style, shouldAuthenticate: shouldAuthenticate, + mediaType: media.type, + imageMaxHeight: 300.0, ); } else if (media.type.contains('video')) { return DSVideoMessageBubble( diff --git a/lib/src/widgets/utils/ds_expanded_image.widget.dart b/lib/src/widgets/utils/ds_expanded_image.widget.dart index 99e2851b..e411d8d4 100644 --- a/lib/src/widgets/utils/ds_expanded_image.widget.dart +++ b/lib/src/widgets/utils/ds_expanded_image.widget.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; @@ -10,6 +12,7 @@ class DSExpandedImage extends StatelessWidget { final String url; final BoxFit fit; final double width; + final double minHeight; final double maxHeight; final bool isLoading; final Uri? appBarPhotoUri; @@ -27,6 +30,7 @@ class DSExpandedImage extends StatelessWidget { this.fit = BoxFit.cover, this.width = double.infinity, this.maxHeight = double.infinity, + this.minHeight = 0.0, this.isLoading = false, this.appBarPhotoUri, this.shouldAuthenticate = false, @@ -49,21 +53,52 @@ class DSExpandedImage extends StatelessWidget { child: Container( constraints: BoxConstraints( maxHeight: maxHeight, + minHeight: minHeight, ), - child: DSCachedNetworkImageView( - fit: fit, - width: width, - url: url, - placeholder: (_, __) => _buildLoading(), - onError: () => _error.value = true, - align: align, - style: style, - shouldAuthenticate: shouldAuthenticate, - ), + child: url.startsWith('http') + ? DSCachedNetworkImageView( + fit: fit, + width: width, + url: url, + placeholder: (_, __) => _buildLoading(), + onError: () => _error.value = true, + align: align, + style: style, + shouldAuthenticate: shouldAuthenticate, + ) + : Image.file( + File(url), + width: width, + fit: fit, + cacheWidth: 360, + errorBuilder: (_, __, ___) => _defaultErrorWidget(), + ), ), ), ); + Widget _defaultErrorWidget() { + _error.value = true; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Icon( + DSIcons.file_image_broken_outline, + color: style.isLightBubbleBackground(align) + ? DSColors.neutralMediumElephant + : DSColors.neutralMediumCloud, + size: 75, + ), + ), + ), + ], + ); + } + Future _expandImage() => showGeneralDialog( context: Get.context!, barrierDismissible: false, @@ -99,14 +134,19 @@ class DSExpandedImage extends StatelessWidget { child: Container( padding: const EdgeInsets.all(8.0), child: PinchZoom( - child: DSCachedNetworkImageView( - url: url, - fit: BoxFit.contain, - placeholder: (context, _) => _buildLoading(), - align: align, - style: style, - shouldAuthenticate: shouldAuthenticate, - ), + child: url.startsWith('http') + ? DSCachedNetworkImageView( + url: url, + fit: BoxFit.contain, + placeholder: (context, _) => _buildLoading(), + align: align, + style: style, + shouldAuthenticate: shouldAuthenticate, + ) + : Image.file( + File(url), + fit: BoxFit.contain, + ), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 721f6849..b54c3e5c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: get: ^4.6.5 filesize: ^2.0.1 open_filex: ^4.3.2 - path_provider: ^2.0.11 + path_provider: ^2.1.1 dio: ^5.2.1+1 url_launcher: ^6.1.5 path: ^1.8.1 @@ -39,6 +39,7 @@ dependencies: dotted_border: ^2.0.0+3 map_launcher: ^2.5.0+1 mime: ^1.0.4 + crypto: ^3.0.2 dev_dependencies: flutter_test: From 00e67b6031398af3818f5cd086a851e238e14c3f Mon Sep 17 00:00:00 2001 From: Andre Rossi Date: Fri, 20 Oct 2023 09:04:00 -0300 Subject: [PATCH 2/2] feat: removed compress of the image --- .../ds_image_message_bubble.controller.dart | 42 ++++--------------- .../chat/ds_image_message_bubble.widget.dart | 2 +- 2 files changed, 8 insertions(+), 36 deletions(-) diff --git a/lib/src/controllers/chat/ds_image_message_bubble.controller.dart b/lib/src/controllers/chat/ds_image_message_bubble.controller.dart index deb7ab49..cd2cdf47 100644 --- a/lib/src/controllers/chat/ds_image_message_bubble.controller.dart +++ b/lib/src/controllers/chat/ds_image_message_bubble.controller.dart @@ -5,15 +5,12 @@ import 'dart:io'; import 'package:blip_ds/blip_ds.dart'; import 'package:blip_ds/src/utils/ds_directory_formatter.util.dart'; import 'package:crypto/crypto.dart'; -import 'package:ffmpeg_kit_flutter_full_gpl/ffmpeg_kit.dart'; -import 'package:ffmpeg_kit_flutter_full_gpl/return_code.dart'; import 'package:get/get.dart'; class DSImageMessageBubbleController extends GetxController { final maximumProgress = RxInt(0); final downloadProgress = RxInt(0); final localPath = RxnString(); - final isDownloading = RxBool(true); final String url; final String? mediaType; @@ -50,46 +47,21 @@ class DSImageMessageBubbleController extends GetxController { return; } + final fileName = fullPath.split('/').last; + final path = fullPath.substring(0, fullPath.lastIndexOf('/')); + try { - final path = await DSFileService.download( + final savedFilePath = await DSFileService.download( url, - fullPath.substring( - fullPath.lastIndexOf('/') + 1, - ), + fileName, + path: path, onReceiveProgress: _onReceiveProgress, httpHeaders: shouldAuthenticate ? DSAuthService.httpHeaders : null, ); - isDownloading.value = false; - - final success = await _compressImage( - input: path!, - output: fullPath, - ); - - if (success) { - if (await File(path).exists()) { - await File(path).delete(); - } - localPath.value = fullPath; - } else { - localPath.value = url; - } + localPath.value = savedFilePath; } catch (_) { localPath.value = url; - isDownloading.value = false; } } - - Future _compressImage({ - required String input, - required String output, - }) async { - final session = await FFmpegKit.execute( - '-hide_banner -y -i "$input" -vf scale=-2:720 "$output"'); - - final returnCode = await session.getReturnCode(); - - return ReturnCode.isSuccess(returnCode); - } } diff --git a/lib/src/widgets/chat/ds_image_message_bubble.widget.dart b/lib/src/widgets/chat/ds_image_message_bubble.widget.dart index 5c0c8053..92311f76 100644 --- a/lib/src/widgets/chat/ds_image_message_bubble.widget.dart +++ b/lib/src/widgets/chat/ds_image_message_bubble.widget.dart @@ -176,7 +176,7 @@ class _DSImageMessageBubbleState extends State child: CircularProgressIndicator( color: foregroundColor, backgroundColor: Colors.grey, - value: _controller.isDownloading.value ? percent : null, + value: percent, ), ), );