feat(ui): Added thumbnailSize, thumbnailResizeType, and thumbnailCropType params to StreamMessageWidget and StreamAttachmentPicker to customize the appearance of image thumbnails.
Signed-off-by: xsahil03x <[email protected]>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,43 +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'] == null ||
|
||||
imageUri.queryParameters['h'] == '*') &&
|
||||
(imageUri.queryParameters['w'] == null ||
|
||||
imageUri.queryParameters['w'] == '*') &&
|
||||
(imageUri.queryParameters['crop'] == null ||
|
||||
imageUri.queryParameters['crop'] == '*') &&
|
||||
(imageUri.queryParameters['resize'] == null ||
|
||||
imageUri.queryParameters['resize'] == '*')) {
|
||||
imageUri = imageUri.replace(queryParameters: {
|
||||
...imageUri.queryParameters,
|
||||
'h': '400', // TODO: Are these sizes optimal? Consider web/desktop
|
||||
'w': '400',
|
||||
'crop': 'center',
|
||||
'resize': 'clip',
|
||||
});
|
||||
} 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',
|
||||
@@ -115,9 +111,7 @@ class StreamImageAttachment extends StreamAttachmentWidget {
|
||||
child: image,
|
||||
);
|
||||
},
|
||||
imageUrl: imageUrl,
|
||||
errorWidget: (context, url, error) => AttachmentError(size: size),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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<T> 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;
|
||||
}
|
||||
|
||||
@@ -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<StreamFullScreenMedia>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _animationController;
|
||||
class _StreamFullScreenMediaState extends State<StreamFullScreenMedia> {
|
||||
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<bool>(true);
|
||||
|
||||
final _opacityTween = Tween<double>(begin: 1, end: 0);
|
||||
late final _opacityAnimation = _opacityTween.animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0, 0.6, curve: Curves.easeOut),
|
||||
),
|
||||
);
|
||||
|
||||
late final ValueNotifier<int> _currentPage = ValueNotifier(widget.startIndex);
|
||||
void switchDisplayingDetail() {
|
||||
_isDisplayingDetail.value = !_isDisplayingDetail.value;
|
||||
}
|
||||
|
||||
final videoPackages = <String, VideoPackage>{};
|
||||
|
||||
@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<StreamFullScreenMedia>
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Stack(
|
||||
children: [
|
||||
PageView.builder(
|
||||
body: ValueListenableBuilder<int>(
|
||||
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<bool>(
|
||||
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<bool>(
|
||||
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<StreamFullScreenMedia>
|
||||
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<bool>(
|
||||
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<StreamFullScreenMedia>
|
||||
);
|
||||
}
|
||||
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<StreamFullScreenMedia>
|
||||
}
|
||||
return const SizedBox();
|
||||
},
|
||||
itemCount: widget.mediaAttachmentPackages.length,
|
||||
),
|
||||
FadeTransition(
|
||||
opacity: _opacityAnimation,
|
||||
child: ValueListenableBuilder<int>(
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<StreamMediaListView> {
|
||||
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<StreamMediaListView> {
|
||||
}
|
||||
}
|
||||
|
||||
/// ImageProvider implementation
|
||||
/// ImageProvider implementation for [AssetEntity].
|
||||
class MediaThumbnailProvider extends ImageProvider<MediaThumbnailProvider> {
|
||||
/// 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<MediaThumbnailProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture<MediaThumbnailProvider>(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<ImageProvider>(
|
||||
'Thumbnail provider: $this \n Thumbnail key: $key',
|
||||
this,
|
||||
style: DiagnosticsTreeStyle.errorProperty,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<ui.Codec> _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<MediaThumbnailProvider> obtainKey(ImageConfiguration configuration) =>
|
||||
SynchronousFuture<MediaThumbnailProvider>(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,19 @@ 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 +491,9 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
List<StreamMessageAction>? 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 +554,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
|
||||
|
||||
+60
-5
@@ -43,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.
|
||||
@@ -77,6 +81,25 @@ class StreamAttachmentPicker extends StatefulWidget {
|
||||
/// The list of custom attachment types that can be picked.
|
||||
final List<CustomAttachmentType> 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({
|
||||
@@ -91,7 +114,11 @@ class StreamAttachmentPicker extends StatefulWidget {
|
||||
ValueChanged<bool>? onChangeInputState,
|
||||
ValueChanged<String>? onError,
|
||||
List<DefaultAttachmentTypes>? allowedAttachmentTypes,
|
||||
List<CustomAttachmentType>? customAttachmentTypes = const [],
|
||||
List<CustomAttachmentType>? customAttachmentTypes,
|
||||
ThumbnailSize? attachmentThumbnailSize,
|
||||
ThumbnailFormat? attachmentThumbnailFormat,
|
||||
int? attachmentThumbnailQuality,
|
||||
double? attachmentThumbnailScale,
|
||||
}) =>
|
||||
StreamAttachmentPicker(
|
||||
key: key ?? this.key,
|
||||
@@ -109,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
|
||||
@@ -360,6 +395,11 @@ class _StreamAttachmentPickerState extends State<StreamAttachmentPicker> {
|
||||
},
|
||||
allowedAttachmentTypes: widget.allowedAttachmentTypes,
|
||||
customAttachmentTypes: widget.customAttachmentTypes,
|
||||
mediaThumbnailSize: widget.attachmentThumbnailSize,
|
||||
mediaThumbnailFormat: widget.attachmentThumbnailFormat,
|
||||
mediaThumbnailQuality:
|
||||
widget.attachmentThumbnailQuality,
|
||||
mediaThumbnailScale: widget.attachmentThumbnailScale,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -374,9 +414,11 @@ class _StreamAttachmentPickerState extends State<StreamAttachmentPicker> {
|
||||
void _addAssetAttachment(AssetEntity medium) async {
|
||||
final mediaFile = await medium.originFile;
|
||||
if (mediaFile == null) return;
|
||||
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
|
||||
// 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;
|
||||
@@ -388,12 +430,13 @@ class _StreamAttachmentPickerState extends State<StreamAttachmentPicker> {
|
||||
updatedSize.width.floor(),
|
||||
updatedSize.height.floor(),
|
||||
),
|
||||
quality: 30, // TODO: investigate compressing all images
|
||||
quality: 70, // TODO: investigate compressing all images
|
||||
);
|
||||
cachedFile =
|
||||
final file =
|
||||
await File('${tempDir.path}/${mediaFile.path.split('/').last}')
|
||||
.create()
|
||||
..writeAsBytesSync(resizedImage!);
|
||||
.create();
|
||||
file.writeAsBytesSync(resizedImage!);
|
||||
cachedFile = file;
|
||||
} else {
|
||||
cachedFile = await mediaFile
|
||||
.copy('${tempDir.path}/${mediaFile.path.split('/').last}');
|
||||
@@ -457,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;
|
||||
@@ -468,6 +515,10 @@ class _PickerWidget extends StatefulWidget {
|
||||
final List<DefaultAttachmentTypes> allowedAttachmentTypes;
|
||||
final List<CustomAttachmentType> customAttachmentTypes;
|
||||
final MediaListViewController mediaListViewController;
|
||||
final ThumbnailSize mediaThumbnailSize;
|
||||
final ThumbnailFormat mediaThumbnailFormat;
|
||||
final int mediaThumbnailQuality;
|
||||
final double mediaThumbnailScale;
|
||||
|
||||
@override
|
||||
_PickerWidgetState createState() => _PickerWidgetState();
|
||||
@@ -522,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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:stream_chat_flutter/src/commands_overlay.dart';
|
||||
import 'package:stream_chat_flutter/src/emoji/emoji.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';
|
||||
|
||||
Reference in New Issue
Block a user