diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index be7ffa2b..4482e3ac 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -4,6 +4,45 @@ - [[#882]](https://github.com/GetStream/stream-chat-flutter/issues/882) Lots of unhandled exceptions when network is off or spotty. +- Fixes an error where Stream CDN images were not being resized in the message list view. + +🚀 Improved + +- Automatically resize images that are above a specific pixel count to ensure resizing works: + getstream.io/chat/docs/go-golang/file_uploads/#image-resizing + +✅ Added + +- Added `thumbnailSize`, `thumbnailResizeType`, and `thumbnailCropType` params + to `StreamMessageWidget` and `StreamAttachmentPicker` to customize the appearance of image + thumbnails. + + ```dart + StreamMessageInput( + focusNode: _focusNode, + messageInputController: _messageInputController, + attachmentsPickerBuilder: (_, __, picker) { + return picker.copyWith( + attachmentThumbnailSize: ..., + attachmentThumbnailFormat: ..., + attachmentThumbnailQuality: ..., + attachmentThumbnailScale: ..., + ); + }, + ), + ``` + + ```dart + StreamMessageListView( + messageBuilder: (context, details, messages, defaultMessage) { + return defaultMessage.copyWith( + imageAttachmentThumbnailSize: ..., + imageAttachmentThumbnailCropType: ..., + imageAttachmentThumbnailResizeType: ..., + ); + }, + ), + ``` ## 4.4.1 diff --git a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart index 3d0c3314..2feeb0c2 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart @@ -100,6 +100,8 @@ class StreamFileAttachment extends StreamAttachmentWidget { borderRadius: BorderRadius.circular(8), ); + // TODO: Improve image memory. This is using the full image instead of a + // smaller version (thumbnail) Widget _getFileTypeImage(BuildContext context) { if (isImageAttachment) { return Material( diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart index c022ee48..8d0d3f37 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/attachment/attachment_title.dart'; import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@macro image_attachment} @@ -24,6 +25,9 @@ class StreamImageAttachment extends StreamAttachmentWidget { this.onShowMessage, this.onReturnAction, this.onAttachmentTap, + this.imageThumbnailSize = const Size(400, 400), + this.imageThumbnailResizeType = 'crop', + this.imageThumbnailCropType = 'center', }); /// [StreamMessageThemeData] for showing image title @@ -41,6 +45,19 @@ class StreamImageAttachment extends StreamAttachmentWidget { /// Callback when attachment is tapped final VoidCallback? onAttachmentTap; + /// Size of the attachment image thumbnail. + final Size imageThumbnailSize; + + /// Resize type of the image attachment thumbnail. + /// + /// Defaults to [crop] + final String /*clip|crop|scale|fill*/ imageThumbnailResizeType; + + /// Crop type of the image attachment thumbnail. + /// + /// Defaults to [center] + final String /*center|top|bottom|left|right*/ imageThumbnailCropType; + @override Widget build(BuildContext context) => source.when( local: () { @@ -65,39 +82,22 @@ class StreamImageAttachment extends StreamAttachmentWidget { var imageUrl = attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl; - if (imageUrl == null) { - return AttachmentError(size: size); - } + if (imageUrl == null) return AttachmentError(size: size); - var imageUri = Uri.parse(imageUrl); - if (imageUri.host.endsWith('stream-io-cdn.com') && - imageUri.queryParameters['h'] == '*' && - imageUri.queryParameters['w'] == '*' && - imageUri.queryParameters['crop'] == '*' && - imageUri.queryParameters['resize'] == '*') { - imageUri = imageUri.replace(queryParameters: { - ...imageUri.queryParameters, - 'h': '400', - 'w': '400', - 'crop': 'center', - 'resize': 'crop', - }); - } else if (imageUri.host.endsWith('stream-cloud-uploads.imgix.net')) { - imageUri = imageUri.replace(queryParameters: { - ...imageUri.queryParameters, - 'height': '400', - 'width': '400', - 'fit': 'crop', - }); - } - imageUrl = imageUri.toString(); + imageUrl = imageUrl.getResizedImageUrl( + width: imageThumbnailSize.width, + height: imageThumbnailSize.height, + resize: imageThumbnailResizeType, + crop: imageThumbnailCropType, + ); return _buildImageAttachment( context, CachedNetworkImage( - cacheKey: imageUri.replace(queryParameters: {}).toString(), + imageUrl: imageUrl, height: size?.height, width: size?.width, + fit: BoxFit.cover, placeholder: (context, __) { final image = Image.asset( 'images/placeholder.png', @@ -111,9 +111,7 @@ class StreamImageAttachment extends StreamAttachmentWidget { child: image, ); }, - imageUrl: imageUrl, errorWidget: (context, url, error) => AttachmentError(size: size), - fit: BoxFit.cover, ), ); }, diff --git a/packages/stream_chat_flutter/lib/src/extension.dart b/packages/stream_chat_flutter/lib/src/extension.dart index 5ad30039..c9566b0d 100644 --- a/packages/stream_chat_flutter/lib/src/extension.dart +++ b/packages/stream_chat_flutter/lib/src/extension.dart @@ -31,6 +31,46 @@ extension StringExtension on String { /// Levenshtein distance between this and [t]. int levenshteinDistance(String t) => levenshtein(this, t); + + /// Returns a resized imageUrl with the given [width], [height], [resize] + /// and [crop] if it is from Stream CDN or Dashboard. + /// + /// Read more at https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart#image-resizing + String getResizedImageUrl({ + // TODO: Are these sizes optimal? Consider web/desktop + double width = 400, + double height = 400, + String /*clip|crop|scale|fill*/ resize = 'scale', + String /*center|top|bottom|left|right*/ crop = 'center', + }) { + final uri = Uri.parse(this); + final host = uri.host; + + final fromStreamCDN = host.endsWith('stream-io-cdn.com'); + final fromStreamDashboard = host.endsWith('stream-cloud-uploads.imgix.net'); + + if (!fromStreamCDN && !fromStreamDashboard) return this; + + final queryParameters = {...uri.queryParameters}; + + if (fromStreamCDN) { + if (queryParameters['h'].isNullOrMatches('*') && + queryParameters['w'].isNullOrMatches('*') && + queryParameters['crop'].isNullOrMatches('*') && + queryParameters['resize'].isNullOrMatches('*')) { + queryParameters['h'] = height.floor().toString(); + queryParameters['w'] = width.floor().toString(); + queryParameters['crop'] = crop; + queryParameters['resize'] = resize; + } + } else if (fromStreamDashboard) { + queryParameters['height'] = height.floor().toString(); + queryParameters['width'] = width.floor().toString(); + queryParameters['fit'] = crop; + } + + return uri.replace(queryParameters: queryParameters).toString(); + } } /// List extension @@ -281,3 +321,10 @@ extension UriX on Uri { return Uri.parse('http://${toString()}'); } } + +/// Extensions on generic type [T] +extension TypeX on T? { + /// Returns true if the value is null or matches the given [value] + /// otherwise returns false. + bool isNullOrMatches(T value) => this == null || this == value; +} 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 cb54c464..55120de0 100644 --- a/packages/stream_chat_flutter/lib/src/full_screen_media.dart +++ b/packages/stream_chat_flutter/lib/src/full_screen_media.dart @@ -5,6 +5,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:chewie/chewie.dart'; import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; +import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:video_player/video_player.dart'; @@ -64,36 +65,21 @@ class StreamFullScreenMedia extends StatefulWidget { _StreamFullScreenMediaState createState() => _StreamFullScreenMediaState(); } -class _StreamFullScreenMediaState extends State - with SingleTickerProviderStateMixin { - late final AnimationController _animationController; +class _StreamFullScreenMediaState extends State { late final PageController _pageController; - late final _curvedAnimation = CurvedAnimation( - parent: _animationController, - curve: Curves.easeOut, - reverseCurve: Curves.easeIn, - ); + late final _currentPage = ValueNotifier(widget.startIndex); + late final _isDisplayingDetail = ValueNotifier(true); - final _opacityTween = Tween(begin: 1, end: 0); - late final _opacityAnimation = _opacityTween.animate( - CurvedAnimation( - parent: _animationController, - curve: const Interval(0, 0.6, curve: Curves.easeOut), - ), - ); - - late final ValueNotifier _currentPage = ValueNotifier(widget.startIndex); + void switchDisplayingDetail() { + _isDisplayingDetail.value = !_isDisplayingDetail.value; + } final videoPackages = {}; @override void initState() { super.initState(); - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 300), - ); _pageController = PageController(initialPage: widget.startIndex); for (var i = 0; i < widget.mediaAttachmentPackages.length; i++) { final attachment = widget.mediaAttachmentPackages[i].attachment; @@ -127,26 +113,102 @@ class _StreamFullScreenMediaState extends State @override Widget build(BuildContext context) => Scaffold( resizeToAvoidBottomInset: false, - body: Stack( - children: [ - PageView.builder( + body: ValueListenableBuilder( + valueListenable: _currentPage, + builder: (context, currentPage, child) { + final _currentAttachmentPackage = + widget.mediaAttachmentPackages[currentPage]; + final _currentMessage = _currentAttachmentPackage.message; + final _currentAttachment = _currentAttachmentPackage.attachment; + return Stack( + children: [ + Positioned.fill(child: child!), + ValueListenableBuilder( + valueListenable: _isDisplayingDetail, + builder: (context, isDisplayingDetail, child) { + final mediaQuery = MediaQuery.of(context); + final topPadding = mediaQuery.padding.top; + return AnimatedPositionedDirectional( + duration: kThemeAnimationDuration, + curve: Curves.easeInOut, + top: isDisplayingDetail + ? 0 + : -(topPadding + kToolbarHeight), + start: 0, + end: 0, + height: topPadding + kToolbarHeight, + child: StreamGalleryHeader( + userName: widget.userName, + sentAt: context.translations.sentAtText( + date: _currentAttachmentPackage.message.createdAt, + time: _currentAttachmentPackage.message.createdAt, + ), + onBackPressed: Navigator.of(context).pop, + message: _currentMessage, + attachment: _currentAttachment, + onShowMessage: () { + widget.onShowMessage?.call( + _currentMessage, + StreamChannel.of(context).channel, + ); + }, + attachmentActionsModalBuilder: + widget.attachmentActionsModalBuilder, + ), + ); + }, + ), + if (!_currentMessage.isEphemeral) + ValueListenableBuilder( + valueListenable: _isDisplayingDetail, + builder: (context, isDisplayingDetail, child) { + final mediaQuery = MediaQuery.of(context); + final bottomPadding = mediaQuery.padding.bottom; + return AnimatedPositionedDirectional( + duration: kThemeAnimationDuration, + curve: Curves.easeInOut, + bottom: isDisplayingDetail + ? 0 + : -(bottomPadding + kToolbarHeight), + start: 0, + end: 0, + height: bottomPadding + kToolbarHeight, + child: StreamGalleryFooter( + currentPage: currentPage, + totalPages: widget.mediaAttachmentPackages.length, + mediaAttachmentPackages: + widget.mediaAttachmentPackages, + mediaSelectedCallBack: (val) { + _currentPage.value = val; + _pageController.animateToPage( + val, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + Navigator.pop(context); + }, + ), + ); + }, + ), + ], + ); + }, + child: InkWell( + onTap: switchDisplayingDetail, + child: PageView.builder( controller: _pageController, + itemCount: widget.mediaAttachmentPackages.length, onPageChanged: (val) { _currentPage.value = val; - - if (videoPackages.isEmpty) { - return; - } - + if (videoPackages.isEmpty) return; final currentAttachment = widget.mediaAttachmentPackages[val].attachment; - for (final e in videoPackages.values) { if (e._attachment != currentAttachment) { e._chewieController?.pause(); } } - if (widget.autoplayVideos && currentAttachment.type == 'video') { final controller = videoPackages[currentAttachment.id]!; @@ -161,33 +223,44 @@ class _StreamFullScreenMediaState extends State final imageUrl = attachment.imageUrl ?? attachment.assetUrl ?? attachment.thumbUrl; - return AnimatedBuilder( - animation: _curvedAnimation, - builder: (context, child) => PhotoView( - loadingBuilder: (context, image) => const Offstage(), - imageProvider: (imageUrl == null && - attachment.localUri != null && - attachment.file?.bytes != null) - ? Image.memory(attachment.file!.bytes!).image - : CachedNetworkImageProvider(imageUrl!), - maxScale: PhotoViewComputedScale.covered, - minScale: PhotoViewComputedScale.contained, - heroAttributes: PhotoViewHeroAttributes( - tag: widget.mediaAttachmentPackages, + return ValueListenableBuilder( + valueListenable: _isDisplayingDetail, + builder: (context, isDisplayingDetail, _) => + AnimatedContainer( + color: isDisplayingDetail + ? StreamChannelHeaderTheme.of(context).color + : Colors.black, + duration: kThemeAnimationDuration, + child: PhotoView( + imageProvider: (imageUrl == null && + attachment.localUri != null && + attachment.file?.bytes != null) + ? Image.memory(attachment.file!.bytes!).image + : CachedNetworkImageProvider(imageUrl!), + errorBuilder: (_, __, ___) => const AttachmentError(), + loadingBuilder: (context, _) { + final image = Image.asset( + 'images/placeholder.png', + fit: BoxFit.cover, + package: 'stream_chat_flutter', + ); + final colorTheme = + StreamChatTheme.of(context).colorTheme; + return Shimmer.fromColors( + baseColor: colorTheme.disabled, + highlightColor: colorTheme.inputBg, + child: image, + ); + }, + maxScale: PhotoViewComputedScale.covered, + minScale: PhotoViewComputedScale.contained, + heroAttributes: PhotoViewHeroAttributes( + tag: widget.mediaAttachmentPackages, + ), + backgroundDecoration: const BoxDecoration( + color: Colors.transparent, + ), ), - backgroundDecoration: BoxDecoration( - color: ColorTween( - begin: StreamChannelHeaderTheme.of(context).color, - end: Colors.black, - ).lerp(_curvedAnimation.value), - ), - onTapUp: (a, b, c) { - if (_animationController.isCompleted) { - _animationController.reverse(); - } else { - _animationController.forward(); - } - }, ), ); } else if (attachment.type == 'video') { @@ -198,17 +271,9 @@ class _StreamFullScreenMediaState extends State ); } return InkWell( - onTap: () { - if (_animationController.isCompleted) { - _animationController.reverse(); - } else { - _animationController.forward(); - } - }, + onTap: switchDisplayingDetail, child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 50, - ), + padding: const EdgeInsets.symmetric(vertical: 50), child: Chewie( controller: controller.chewieController!, ), @@ -217,76 +282,16 @@ class _StreamFullScreenMediaState extends State } return const SizedBox(); }, - itemCount: widget.mediaAttachmentPackages.length, ), - FadeTransition( - opacity: _opacityAnimation, - child: ValueListenableBuilder( - valueListenable: _currentPage, - builder: (context, value, child) { - final _currentAttachmentPackage = - widget.mediaAttachmentPackages[value]; - final _currentMessage = _currentAttachmentPackage.message; - final _currentAttachment = - _currentAttachmentPackage.attachment; - return Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - StreamGalleryHeader( - userName: widget.userName, - sentAt: context.translations.sentAtText( - date: widget - .mediaAttachmentPackages[_currentPage.value] - .message - .createdAt, - time: widget - .mediaAttachmentPackages[_currentPage.value] - .message - .createdAt, - ), - onBackPressed: () { - Navigator.of(context).pop(); - }, - message: _currentMessage, - attachment: _currentAttachment, - onShowMessage: () { - widget.onShowMessage?.call( - _currentMessage, - StreamChannel.of(context).channel, - ); - }, - attachmentActionsModalBuilder: - widget.attachmentActionsModalBuilder, - ), - if (!_currentMessage.isEphemeral) - StreamGalleryFooter( - currentPage: value, - totalPages: widget.mediaAttachmentPackages.length, - mediaAttachmentPackages: - widget.mediaAttachmentPackages, - mediaSelectedCallBack: (val) { - _currentPage.value = val; - _pageController.animateToPage( - val, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - Navigator.pop(context); - }, - ), - ], - ); - }, - ), - ), - ], + ), ), ); @override void dispose() { - _animationController.dispose(); + _currentPage.dispose(); _pageController.dispose(); + _isDisplayingDetail.dispose(); for (final package in videoPackages.values) { package.dispose(); } diff --git a/packages/stream_chat_flutter/lib/src/image_group.dart b/packages/stream_chat_flutter/lib/src/image_group.dart index cb8f6edd..16cb7352 100644 --- a/packages/stream_chat_flutter/lib/src/image_group.dart +++ b/packages/stream_chat_flutter/lib/src/image_group.dart @@ -19,6 +19,9 @@ class StreamImageGroup extends StatelessWidget { this.onReturnAction, this.onShowMessage, this.onAttachmentTap, + this.imageThumbnailSize = const Size(400, 400), + this.imageThumbnailResizeType = 'crop', + this.imageThumbnailCropType = 'center', }); /// List of attachments to show @@ -36,12 +39,25 @@ class StreamImageGroup extends StatelessWidget { /// [StreamMessageThemeData] to apply to message final StreamMessageThemeData messageTheme; - /// Size of iamges + /// Size of images final Size size; /// Callback for when show message is tapped final ShowMessageCallback? onShowMessage; + /// Size of the attachment image thumbnail. + final Size imageThumbnailSize; + + /// Resize type of the image attachment thumbnail. + /// + /// Defaults to [crop] + final String /*clip|crop|scale|fill*/ imageThumbnailResizeType; + + /// Crop type of the image attachment thumbnail. + /// + /// Defaults to [center] + final String /*center|top|bottom|left|right*/ imageThumbnailCropType; + @override Widget build(BuildContext context) => ConstrainedBox( constraints: BoxConstraints.loose(size), @@ -153,5 +169,8 @@ class StreamImageGroup extends StatelessWidget { message: message, messageTheme: messageTheme, onAttachmentTap: () => _onTap(context, index), + imageThumbnailSize: imageThumbnailSize, + imageThumbnailResizeType: imageThumbnailResizeType, + imageThumbnailCropType: imageThumbnailCropType, ); } diff --git a/packages/stream_chat_flutter/lib/src/media_list_view.dart b/packages/stream_chat_flutter/lib/src/media_list_view.dart index 34b512c4..7dce7864 100644 --- a/packages/stream_chat_flutter/lib/src/media_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/media_list_view.dart @@ -22,6 +22,10 @@ class StreamMediaListView extends StatefulWidget { this.selectedIds = const [], this.onSelect, this.controller, + this.thumbnailSize = const ThumbnailSize(400, 400), + this.thumbnailFormat = ThumbnailFormat.jpeg, + this.thumbnailQuality = 100, + this.thumbnailScale = 1, }); /// Stores the media selected @@ -33,6 +37,21 @@ class StreamMediaListView extends StatefulWidget { /// Controller that handles MediaListView final MediaListViewController? controller; + /// The thumbnail size. + final ThumbnailSize thumbnailSize; + + /// {@macro photo_manager.ThumbnailFormat} + final ThumbnailFormat thumbnailFormat; + + /// The quality value for the thumbnail. + /// + /// Valid from 1 to 100. + /// Defaults to 100. + final int thumbnailQuality; + + /// Scale of the image. + final double thumbnailScale; + @override _StreamMediaListViewState createState() => _StreamMediaListViewState(); } @@ -75,14 +94,18 @@ class _StreamMediaListViewState extends State { AspectRatio( aspectRatio: 1, child: FadeInImage( + image: MediaThumbnailProvider( + media: media, + size: widget.thumbnailSize, + format: widget.thumbnailFormat, + quality: widget.thumbnailQuality, + scale: widget.thumbnailScale, + ), fadeInDuration: const Duration(milliseconds: 300), placeholder: const AssetImage( 'images/placeholder.png', package: 'stream_chat_flutter', ), - image: MediaThumbnailProvider( - media: media, - ), fit: BoxFit.cover, ), ), @@ -195,55 +218,95 @@ class _StreamMediaListViewState extends State { } } -/// ImageProvider implementation +/// ImageProvider implementation for [AssetEntity]. class MediaThumbnailProvider extends ImageProvider { /// Constructor for creating a [MediaThumbnailProvider] const MediaThumbnailProvider({ required this.media, + // TODO: Are these sizes optimal? Consider web/desktop + this.size = const ThumbnailSize(400, 400), + this.format = ThumbnailFormat.jpeg, + this.quality = 100, + this.scale = 2, }); - /// Media to load + /// Media to get the thumbnail from. final AssetEntity media; + /// The thumbnail size. + final ThumbnailSize size; + + /// {@macro photo_manager.ThumbnailFormat} + final ThumbnailFormat format; + + /// The quality value for the thumbnail. + /// + /// Valid from 1 to 100. + /// Defaults to 100. + final int quality; + + /// Scale of the image. + final double scale; + + @override + Future obtainKey(ImageConfiguration configuration) { + return SynchronousFuture(this); + } + @override ImageStreamCompleter load( MediaThumbnailProvider key, DecoderCallback decode, - ) => - MultiFrameImageStreamCompleter( - codec: _loadAsync(key, decode), - scale: 1, - informationCollector: () sync* { - yield ErrorDescription('Id: ${media.id}'); - }, - ); + ) { + return MultiFrameImageStreamCompleter( + codec: _loadAsync(key, decode), + scale: key.scale, + informationCollector: () sync* { + yield DiagnosticsProperty( + 'Thumbnail provider: $this \n Thumbnail key: $key', + this, + style: DiagnosticsTreeStyle.errorProperty, + ); + }, + ); + } Future _loadAsync( MediaThumbnailProvider key, DecoderCallback decode, ) async { - assert(key == this, 'Checks MediaThumbnailProvider'); - final bytes = await media.thumbnailData; - + assert(key == this, '$key is not $this'); + final bytes = await media.thumbnailDataWithSize( + size, + format: format, + quality: quality, + ); return decode(bytes!); } - @override - Future obtainKey(ImageConfiguration configuration) => - SynchronousFuture(this); - @override bool operator ==(dynamic other) { - if (other.runtimeType != runtimeType) return false; - final MediaThumbnailProvider typedOther = other; - return media.id == typedOther.media.id; + if (other is MediaThumbnailProvider) { + return media == other.media && + size == other.size && + format == other.format && + quality == other.quality && + scale == other.scale; + } + return false; } @override - int get hashCode => media.id.hashCode; + int get hashCode => hashValues(media, size, format, quality, scale); @override - String toString() => '$runtimeType("${media.id}")'; + String toString() => '$runtimeType(' + 'media: $media, ' + 'size: $size, ' + 'format: $format, ' + 'quality: $quality, ' + 'scale: $scale' + ')'; } extension on Duration { @@ -252,7 +315,6 @@ extension on Duration { if (s.startsWith('00:')) { return s.replaceFirst('00:', ''); } - return s; } } diff --git a/packages/stream_chat_flutter/lib/src/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget.dart index b247c91e..71d0ab2c 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget.dart @@ -105,6 +105,9 @@ class StreamMessageWidget extends StatefulWidget { this.customActions = const [], this.onAttachmentTap, this.usernameBuilder, + this.imageAttachmentThumbnailSize = const Size(400, 400), + this.imageAttachmentThumbnailResizeType = 'crop', + this.imageAttachmentThumbnailCropType = 'center', }) : attachmentBuilders = { 'image': (context, message, attachments) { final border = RoundedRectangleBorder( @@ -130,6 +133,10 @@ class StreamMessageWidget extends StatefulWidget { onShowMessage: onShowMessage, onReturnAction: onReturnAction, onAttachmentTap: onAttachmentTap, + imageThumbnailSize: imageAttachmentThumbnailSize, + imageThumbnailResizeType: + imageAttachmentThumbnailResizeType, + imageThumbnailCropType: imageAttachmentThumbnailCropType, ), ), border, @@ -155,6 +162,9 @@ class StreamMessageWidget extends StatefulWidget { onAttachmentTap.call(message, attachments[0]); } : null, + imageThumbnailSize: imageAttachmentThumbnailSize, + imageThumbnailResizeType: imageAttachmentThumbnailResizeType, + imageThumbnailCropType: imageAttachmentThumbnailCropType, ), border, reverse, @@ -413,6 +423,20 @@ class StreamMessageWidget extends StatefulWidget { /// Customize onTap on attachment final void Function(Message message, Attachment attachment)? onAttachmentTap; + /// Size of the image attachment thumbnail. + final Size imageAttachmentThumbnailSize; + + /// Resize type of the image attachment thumbnail. + /// + /// Defaults to [crop] + final String /*clip|crop|scale|fill*/ imageAttachmentThumbnailResizeType; + + /// Crop type of the image attachment thumbnail. + /// + /// Defaults to [center] + final String /*center|top|bottom|left|right*/ + imageAttachmentThumbnailCropType; + /// Creates a copy of [StreamMessageWidget] with /// specified attributes overridden. StreamMessageWidget copyWith({ @@ -468,6 +492,9 @@ class StreamMessageWidget extends StatefulWidget { List? customActions, void Function(Message message, Attachment attachment)? onAttachmentTap, Widget Function(BuildContext, User)? userAvatarBuilder, + Size? imageAttachmentThumbnailSize, + String? imageAttachmentThumbnailResizeType, + String? imageAttachmentThumbnailCropType, }) => StreamMessageWidget( key: key ?? this.key, @@ -528,6 +555,13 @@ class StreamMessageWidget extends StatefulWidget { customActions: customActions ?? this.customActions, onAttachmentTap: onAttachmentTap ?? this.onAttachmentTap, userAvatarBuilder: userAvatarBuilder ?? this.userAvatarBuilder, + imageAttachmentThumbnailSize: + imageAttachmentThumbnailSize ?? this.imageAttachmentThumbnailSize, + imageAttachmentThumbnailResizeType: + imageAttachmentThumbnailResizeType ?? + this.imageAttachmentThumbnailResizeType, + imageAttachmentThumbnailCropType: imageAttachmentThumbnailCropType ?? + this.imageAttachmentThumbnailCropType, ); @override diff --git a/packages/stream_chat_flutter/lib/src/v4/message_input/stream_attachment_picker.dart b/packages/stream_chat_flutter/lib/src/v4/message_input/stream_attachment_picker.dart index 2473cf0a..8c1e14f8 100644 --- a/packages/stream_chat_flutter/lib/src/v4/message_input/stream_attachment_picker.dart +++ b/packages/stream_chat_flutter/lib/src/v4/message_input/stream_attachment_picker.dart @@ -1,4 +1,6 @@ import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -41,6 +43,10 @@ class StreamAttachmentPicker extends StatefulWidget { DefaultAttachmentTypes.video, ], this.customAttachmentTypes = const [], + this.attachmentThumbnailSize = const ThumbnailSize(400, 400), + this.attachmentThumbnailFormat = ThumbnailFormat.jpeg, + this.attachmentThumbnailQuality = 100, + this.attachmentThumbnailScale = 1, }); /// True if the picker is open. @@ -75,6 +81,25 @@ class StreamAttachmentPicker extends StatefulWidget { /// The list of custom attachment types that can be picked. final List customAttachmentTypes; + /// Size of the attachment thumbnails. + /// + /// Defaults to (400, 400). + final ThumbnailSize attachmentThumbnailSize; + + /// Format of the attachment thumbnails. + /// + /// Defaults to [ThumbnailFormat.jpeg]. + final ThumbnailFormat attachmentThumbnailFormat; + + /// The quality value for the attachment thumbnails. + /// + /// Valid from 1 to 100. + /// Defaults to 100. + final int attachmentThumbnailQuality; + + /// The scale to apply on the [attachmentThumbnailSize]. + final double attachmentThumbnailScale; + /// Used to create a new copy of [StreamAttachmentPicker] with modified /// properties. StreamAttachmentPicker copyWith({ @@ -89,7 +114,11 @@ class StreamAttachmentPicker extends StatefulWidget { ValueChanged? onChangeInputState, ValueChanged? onError, List? allowedAttachmentTypes, - List? customAttachmentTypes = const [], + List? customAttachmentTypes, + ThumbnailSize? attachmentThumbnailSize, + ThumbnailFormat? attachmentThumbnailFormat, + int? attachmentThumbnailQuality, + double? attachmentThumbnailScale, }) => StreamAttachmentPicker( key: key ?? this.key, @@ -107,6 +136,14 @@ class StreamAttachmentPicker extends StatefulWidget { allowedAttachmentTypes ?? this.allowedAttachmentTypes, customAttachmentTypes: customAttachmentTypes ?? this.customAttachmentTypes, + attachmentThumbnailSize: + attachmentThumbnailSize ?? this.attachmentThumbnailSize, + attachmentThumbnailFormat: + attachmentThumbnailFormat ?? this.attachmentThumbnailFormat, + attachmentThumbnailQuality: + attachmentThumbnailQuality ?? this.attachmentThumbnailQuality, + attachmentThumbnailScale: + attachmentThumbnailScale ?? this.attachmentThumbnailScale, ); @override @@ -358,6 +395,11 @@ class _StreamAttachmentPickerState extends State { }, allowedAttachmentTypes: widget.allowedAttachmentTypes, customAttachmentTypes: widget.customAttachmentTypes, + mediaThumbnailSize: widget.attachmentThumbnailSize, + mediaThumbnailFormat: widget.attachmentThumbnailFormat, + mediaThumbnailQuality: + widget.attachmentThumbnailQuality, + mediaThumbnailScale: widget.attachmentThumbnailScale, ), ), ), @@ -371,13 +413,34 @@ class _StreamAttachmentPickerState extends State { void _addAssetAttachment(AssetEntity medium) async { final mediaFile = await medium.originFile; - if (mediaFile == null) return; final tempDir = await getTemporaryDirectory(); - final cachedFile = await mediaFile - .copy('${tempDir.path}/${mediaFile.path.split('/').last}'); + // TODO: Confirm that this max resolution is final + // Taken from https://getstream.io/chat/docs/flutter-dart/file_uploads/?language=dart#image-resizing + const maxCDNImageResolution = 16800000; + final imageResolution = medium.width * medium.height; + File? cachedFile; + if (imageResolution > maxCDNImageResolution) { + final aspect = imageResolution / maxCDNImageResolution; + final updatedSize = medium.size / (math.sqrt(aspect)); + final resizedImage = await medium.thumbnailDataWithSize( + ThumbnailSize( + updatedSize.width.floor(), + updatedSize.height.floor(), + ), + quality: 70, // TODO: investigate compressing all images + ); + final file = + await File('${tempDir.path}/${mediaFile.path.split('/').last}') + .create(); + file.writeAsBytesSync(resizedImage!); + cachedFile = file; + } else { + cachedFile = await mediaFile + .copy('${tempDir.path}/${mediaFile.path.split('/').last}'); + } final file = AttachmentFile( path: cachedFile.path, @@ -437,6 +500,10 @@ class _PickerWidget extends StatefulWidget { required this.allowedAttachmentTypes, required this.customAttachmentTypes, required this.mediaListViewController, + this.mediaThumbnailSize = const ThumbnailSize(400, 400), + this.mediaThumbnailFormat = ThumbnailFormat.jpeg, + this.mediaThumbnailQuality = 100, + this.mediaThumbnailScale = 1, }); final int filePickerIndex; @@ -448,6 +515,10 @@ class _PickerWidget extends StatefulWidget { final List allowedAttachmentTypes; final List customAttachmentTypes; final MediaListViewController mediaListViewController; + final ThumbnailSize mediaThumbnailSize; + final ThumbnailFormat mediaThumbnailFormat; + final int mediaThumbnailQuality; + final double mediaThumbnailScale; @override _PickerWidgetState createState() => _PickerWidgetState(); @@ -502,6 +573,10 @@ class _PickerWidgetState extends State<_PickerWidget> { selectedIds: widget.selectedMedias, onSelect: widget.onMediaSelected, controller: widget.mediaListViewController, + thumbnailSize: widget.mediaThumbnailSize, + thumbnailFormat: widget.mediaThumbnailFormat, + thumbnailQuality: widget.mediaThumbnailQuality, + thumbnailScale: widget.mediaThumbnailScale, ); } diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 39023c85..091f8ae4 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -1,4 +1,6 @@ export 'package:jiffy/jiffy.dart'; +export 'package:photo_manager/photo_manager.dart' + show ThumbnailSize, ThumbnailFormat; export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; export 'src/attachment/attachment.dart'; @@ -22,6 +24,7 @@ export 'src/info_tile.dart'; export 'src/localization/stream_chat_localizations.dart'; export 'src/localization/translations.dart' show DefaultTranslations; export 'src/message_action.dart'; + // ignore: deprecated_member_use_from_same_package export 'src/message_input.dart' show MessageInput, MessageInputState; export 'src/message_list_view.dart'; @@ -49,6 +52,7 @@ export 'src/user_item.dart'; export 'src/user_list_view.dart'; export 'src/user_mention_tile.dart'; export 'src/utils.dart'; + // v4 export 'src/v4/message_input/countdown_button.dart'; export 'src/v4/message_input/stream_attachment_picker.dart';