refactor!: initial attachments refactor.

Signed-off-by: xsahil03x <[email protected]>
This commit is contained in:
Sahil Kumar
2023-07-28 21:07:02 +05:30
committed by xsahil03x
parent eb6f0852e8
commit f246de55a1
61 changed files with 3628 additions and 1712 deletions
@@ -1,6 +1,5 @@
export 'attachment_error.dart';
export 'attachment_upload_state_builder.dart';
export 'attachment_widget.dart' show AttachmentSource;
export 'file_attachment.dart';
export 'giphy_attachment.dart';
export 'image_attachment.dart';
@@ -198,6 +198,7 @@ class _FailedState extends StatelessWidget {
children: [
_IconButton(
icon: StreamSvgIcon.retry(
size: 14,
color: theme.colorTheme.barsBg,
),
onPressed: () {
@@ -217,6 +218,7 @@ class _FailedState extends StatelessWidget {
),
child: Text(
context.translations.uploadErrorLabel,
textAlign: TextAlign.center,
style: theme.textTheme.footnote.copyWith(
color: theme.colorTheme.barsBg,
),
@@ -1,57 +0,0 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Enum for identifying type of attachment
enum AttachmentSource {
/// Attachment is attached
local,
/// Attachment is uploaded
network;
/// The [when] method is the equivalent to pattern matching.
/// Its prototype depends on the AttachmentSource defined.
T when<T>({
required T Function() local,
required T Function() network,
}) {
switch (this) {
case AttachmentSource.local:
return local();
case AttachmentSource.network:
return network();
}
}
}
/// {@template streamAttachmentWidget}
/// Abstract class for deriving attachment types
/// {@endtemplate}
abstract class StreamAttachmentWidget extends StatelessWidget {
/// {@macro streamAttachmentWidget}
const StreamAttachmentWidget({
super.key,
required this.message,
required this.attachment,
this.constraints,
AttachmentSource? source,
}) : _source = source;
/// Contraints of attachments
final BoxConstraints? constraints;
final AttachmentSource? _source;
/// The message that [attachment] is associated with
final Message message;
/// The [Attachment] to display
final Attachment attachment;
/// Getter for source of attachment
AttachmentSource get source =>
_source ??
(attachment.file != null
? AttachmentSource.local
: AttachmentSource.network);
}
@@ -0,0 +1,62 @@
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:stream_chat_flutter/src/attachment/builder/attachment_widget_builder.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// {@template attachmentWidgetCatalog}
/// A widget catalog which determines which attachment widget should be build
/// for a given [Message] and [Attachment] based on the list of [builders].
///
/// This is used by the [MessageWidget] to build the widget for the
/// [Message.attachments]. If you want to customize the widget used to show
/// attachments, you can use this to add your own attachment builder.
/// {@endtemplate}
///
/// See also:
///
/// * [StreamAttachmentWidgetBuilder], which is used to build a widget for a
/// given [Message] and [Attachment].
/// * [MessageWidget] which uses the [AttachmentWidgetCatalog] to build the
/// widget for the [Message.attachments].
class AttachmentWidgetCatalog {
/// {@macro attachmentWidgetCatalog}
const AttachmentWidgetCatalog({required this.builders});
/// The list of builders to use to build the widget.
///
/// The order of the builders is important. The first builder that can handle
/// the message and attachments will be used to build the widget.
final List<StreamAttachmentWidgetBuilder> builders;
/// Builds a widget for the given [message] and [attachments].
///
/// It iterates through the list of builders and uses the first builder
/// that can handle the message and attachments.
///
/// Throws an [Exception] if no builder is found for the message.
Widget build(BuildContext context, Message message) {
assert(!message.isDeleted, 'Cannot build attachment for deleted message');
assert(
message.attachments.isNotEmpty,
'Cannot build attachment for message without attachments',
);
// The list of attachments to build the widget for.
final attachments = message.attachments.grouped;
for (final builder in builders) {
if (builder.canHandle(message, attachments)) {
return builder.build(context, message, attachments);
}
}
throw Exception('No builder found for $message and $attachments');
}
}
extension on List<Attachment> {
/// Groups the attachments by their type.
Map<String, List<Attachment>> get grouped {
return groupBy(this, (attachment) => attachment.type!);
}
}
@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/attachment/file_attachment.dart';
import 'package:stream_chat_flutter/src/attachment/gallery_attachment.dart';
import 'package:stream_chat_flutter/src/attachment/giphy_attachment.dart';
import 'package:stream_chat_flutter/src/attachment/image_attachment.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/giphy_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/video_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/attachment/url_attachment.dart';
import 'package:stream_chat_flutter/src/attachment/video_attachment.dart';
import 'package:stream_chat_flutter/src/stream_chat.dart';
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/utils/utils.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import '../attachment_upload_state_builder.dart';
part 'fallback_attachment_builder.dart';
part 'file_attachment_builder.dart';
part 'gallery_attachment_builder.dart';
part 'giphy_attachment_builder.dart';
part 'image_attachment_builder.dart';
part 'mixed_attachment_builder.dart';
part 'url_attachment_builder.dart';
part 'video_attachment_builder.dart';
/// {@template streamAttachmentWidgetTapCallback}
/// Signature for a function that's called when the user taps on an attachment.
/// {@endtemplate}
typedef StreamAttachmentWidgetTapCallback = void Function(
Message message,
Attachment attachment,
);
/// {@template attachmentWidgetBuilder}
/// A builder which is used to build a widget for a given [Message] and
/// [Attachment]'s. This can also be used to show custom attachments.
/// {@endtemplate}
///
/// See also:
///
/// * [AttachmentWidgetBuilderManager], which is used to manage a list of
/// [StreamAttachmentWidgetBuilder]'s.
abstract class StreamAttachmentWidgetBuilder {
/// {@macro attachmentWidgetBuilder}
const StreamAttachmentWidgetBuilder();
/// The default list of builders used by the [AttachmentWidgetCatalog].
///
/// This list contains the following builders in order:
/// * [MixedAttachmentBuilder]
/// * [GalleryAttachmentBuilder]
/// * [GiphyAttachmentBuilder]
/// * [FileAttachmentBuilder]
/// * [ImageAttachmentBuilder]
/// * [VideoAttachmentBuilder]
/// * [UrlAttachmentBuilder]
/// * [FallbackAttachmentBuilder]
///
/// You can use this list as a starting point for your own list of builders.
///
/// Example:
///
/// ```dart
/// final myBuilders = [
/// ...StreamAttachmentWidgetBuilder.defaultBuilders,
/// MyCustomAttachmentBuilder(),
/// MyOtherCustomAttachmentBuilder(),
/// ...
/// ];
/// ```
///
/// **Note**: The order of the builders in the list is important. The first
/// builder that returns `true` from [canHandle] will be used to build the
/// widget.
static List<StreamAttachmentWidgetBuilder> defaultBuilders({
required Message message,
StreamAttachmentWidgetTapCallback? onAttachmentTap,
}) {
return [
// Handles a mix of image, gif, video, and file attachments.
MixedAttachmentBuilder(
onAttachmentTap: onAttachmentTap,
),
// Handles a mix of image, gif, and video attachments.
GalleryAttachmentBuilder(
onAttachmentTap: onAttachmentTap,
),
// Handles file attachments.
FileAttachmentBuilder(
onAttachmentTap: onAttachmentTap,
),
// Handles giphy attachments.
GiphyAttachmentBuilder(
onAttachmentTap: onAttachmentTap,
),
// Handles image attachments.
ImageAttachmentBuilder(
onAttachmentTap: onAttachmentTap,
),
// Handles video attachments.
VideoAttachmentBuilder(
onAttachmentTap: onAttachmentTap,
),
// We don't handle URL attachments if the message is a reply.
if (message.quotedMessage == null)
UrlAttachmentBuilder(
onAttachmentTap: onAttachmentTap,
),
// Fallback builder should always be the last builder in the list.
const FallbackAttachmentBuilder(),
];
}
/// Determines whether this builder can handle the given [message] and
/// [attachments]. If this returns `true`, [build] will be called.
/// Otherwise, the next builder in the list will be called.
bool canHandle(Message message, Map<String, List<Attachment>> attachments);
/// Builds a widget for the given [message] and [attachments].
/// This will only be called if [canHandle] returns `true`.
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
);
/// Asserts that this builder can handle the given [message] and
/// [attachments].
///
/// This is used to ensure that the [defaultBuilders] are used correctly.
///
/// **Note**: This method is only called in debug mode.
bool debugAssertCanHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(() {
if (!canHandle(message, attachments)) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'A $runtimeType was used to build a attachment for a message, but '
'it cant handle the message.',
),
ErrorDescription(
'The builders in the list must be checked in order. Check the '
'documentation for $runtimeType for more details.',
),
]);
}
return true;
}(), '');
return true;
}
}
@@ -0,0 +1,33 @@
part of 'attachment_widget_builder.dart';
/// {@template fallbackAttachmentBuilder}
/// A widget builder for when no other builder can handle the attachments.
///
/// Saves you from getting an error when you have an attachment type that is not
/// supported by the SDK.
/// {@endtemplate}
class FallbackAttachmentBuilder extends StreamAttachmentWidgetBuilder {
/// {@macro fallbackAttachmentBuilder}
const FallbackAttachmentBuilder();
@override
bool canHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
// Always returns True because this builder will be used as a fallback when
// no other builder can handle the attachments.
return true;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
// Returns an empty widget because this builder will be used as a fallback
// when no other builder can handle the attachments.
return const SizedBox.shrink();
}
}
@@ -0,0 +1,87 @@
part of 'attachment_widget_builder.dart';
/// {@template fileAttachmentBuilder}
/// A widget builder for [AttachmentType.file] attachment type.
/// {@endtemplate}
class FileAttachmentBuilder extends StreamAttachmentWidgetBuilder {
/// {@macro fileAttachmentBuilder}
const FileAttachmentBuilder({
this.shape,
this.backgroundColor,
this.constraints = const BoxConstraints(),
this.padding = const EdgeInsets.all(4),
this.onAttachmentTap,
});
/// The shape of the file attachment.
final ShapeBorder? shape;
/// The background color of the file attachment.
final Color? backgroundColor;
/// The constraints to apply to the file attachment widget.
final BoxConstraints constraints;
/// The padding to apply to the file attachment widget.
final EdgeInsetsGeometry padding;
/// The callback to call when the attachment is tapped.
final StreamAttachmentWidgetTapCallback? onAttachmentTap;
@override
bool canHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
final files = attachments[AttachmentType.file];
return files != null && files.isNotEmpty;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(debugAssertCanHandle(message, attachments), '');
final files = attachments[AttachmentType.file]!;
Widget _buildFileAttachment(Attachment file) {
VoidCallback? onTap;
if (onAttachmentTap != null) {
onTap = () => onAttachmentTap!(message, file);
}
return InkWell(
onTap: onTap,
child: StreamFileAttachment(
file: file,
message: message,
shape: shape,
constraints: constraints,
backgroundColor: backgroundColor,
),
);
}
Widget child;
if (files.length == 1) {
child = _buildFileAttachment(files.first);
} else {
child = Column(
children: <Widget>[
for (final file in files) _buildFileAttachment(file),
].insertBetween(
// Add a small vertical padding between each attachment.
SizedBox(height: padding.vertical / 2),
),
);
}
return Padding(
padding: padding,
child: child,
);
}
}
@@ -0,0 +1,152 @@
part of 'attachment_widget_builder.dart';
const _kDefaultGalleryConstraints = BoxConstraints.tightFor(
width: 256,
height: 195,
);
/// {@template galleryAttachmentBuilder}
/// A widget builder for [AttachmentType.image], [AttachmentType.video] and
/// [AttachmentType.giphy] attachment types.
///
/// This builder will render a [StreamGalleryAttachment] widget when the message
/// has more than one image or video or giphy attachment.
/// {@endtemplate}
class GalleryAttachmentBuilder extends StreamAttachmentWidgetBuilder {
/// {@macro galleryAttachmentBuilder}
const GalleryAttachmentBuilder({
this.shape,
this.padding = const EdgeInsets.all(2),
this.spacing = 2,
this.runSpacing = 2,
this.constraints = _kDefaultGalleryConstraints,
this.onAttachmentTap,
});
/// The shape of the gallery attachment.
final ShapeBorder? shape;
/// The constraints to apply to the gallery attachment widget.
final BoxConstraints constraints;
/// The padding to apply to the gallery attachment widget.
final EdgeInsetsGeometry padding;
/// How much space to place between children in a run in the main axis.
///
/// For example, if [spacing] is 10.0, the children will be spaced at least
/// 10.0 logical pixels apart in the main axis.
///
/// Defaults to 2.0.
final double spacing;
/// How much space to place between the runs themselves in the cross axis.
///
/// For example, if [runSpacing] is 10.0, the runs will be spaced at least
/// 10.0 logical pixels apart in the cross axis.
///
/// Defaults to 2.0.
final double runSpacing;
/// The callback to call when the attachment is tapped.
final StreamAttachmentWidgetTapCallback? onAttachmentTap;
@override
bool canHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
final images = attachments[AttachmentType.image];
if (images != null && images.length > 1) return true;
final videos = attachments[AttachmentType.video];
if (videos != null && videos.length > 1) return true;
final giphys = attachments[AttachmentType.giphy];
if (giphys != null && giphys.length > 1) return true;
if (images != null && videos != null) return true;
if (images != null && giphys != null) return true;
if (videos != null && giphys != null) return true;
return false;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(debugAssertCanHandle(message, attachments), '');
final galleryAttachments = [...attachments.values.expand((it) => it)];
return Padding(
padding: padding,
child: StreamGalleryAttachment(
shape: shape,
message: message,
spacing: spacing,
runSpacing: runSpacing,
constraints: constraints,
attachments: galleryAttachments,
itemBuilder: (context, index) {
final attachment = galleryAttachments[index];
final attachmentType = attachment.type;
final isImage = attachmentType == AttachmentType.image;
final isVideo = attachmentType == AttachmentType.video;
final isGiphy = attachmentType == AttachmentType.giphy;
assert(
isImage || isVideo || isGiphy,
'Attachment type should be image, video or giphy',
);
VoidCallback? onTap;
if (onAttachmentTap != null) {
onTap = () => onAttachmentTap!(message, attachment);
}
return InkWell(
onTap: onTap,
child: Stack(
alignment: Alignment.center,
children: [
if (isImage)
StreamImageAttachmentThumbnail(
image: attachment,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
)
else if (isVideo)
StreamVideoAttachmentThumbnail(
video: attachment,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
)
else if (isGiphy)
StreamGiphyAttachmentThumbnail(
giphy: attachment,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
Padding(
padding: const EdgeInsets.all(8),
child: StreamAttachmentUploadStateBuilder(
message: message,
attachment: attachment,
),
),
],
),
);
},
),
);
}
}
@@ -0,0 +1,73 @@
part of 'attachment_widget_builder.dart';
const _kDefaultGiphyConstraints = BoxConstraints(
minWidth: 170,
maxWidth: 256,
minHeight: 100,
maxHeight: 300,
);
/// {@template giphyAttachmentBuilder}
/// A widget builder for [AttachmentType.giphy] attachment type.
///
/// This builder is used when a message contains only a single giphy attachment.
/// {@endtemplate}
class GiphyAttachmentBuilder extends StreamAttachmentWidgetBuilder {
/// {@macro giphyAttachmentBuilder}
const GiphyAttachmentBuilder({
this.shape,
this.padding = const EdgeInsets.all(2),
this.constraints = _kDefaultGiphyConstraints,
this.onAttachmentTap,
});
/// The shape of the giphy attachment.
final ShapeBorder? shape;
/// The constraints to apply to the giphy attachment widget.
final BoxConstraints constraints;
/// The padding to apply to the giphy attachment widget.
final EdgeInsetsGeometry padding;
/// The callback to call when the attachment is tapped.
final StreamAttachmentWidgetTapCallback? onAttachmentTap;
@override
bool canHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
final giphyAttachments = attachments[AttachmentType.giphy];
return giphyAttachments != null && giphyAttachments.length == 1;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(debugAssertCanHandle(message, attachments), '');
final giphy = attachments[AttachmentType.giphy]!.first;
VoidCallback? onTap;
if (onAttachmentTap != null) {
onTap = () => onAttachmentTap!(message, giphy);
}
return Padding(
padding: padding,
child: InkWell(
onTap: onTap,
child: StreamGiphyAttachment(
message: message,
constraints: constraints,
giphy: giphy,
shape: shape,
),
),
);
}
}
@@ -0,0 +1,72 @@
part of 'attachment_widget_builder.dart';
const _kDefaultImageConstraints = BoxConstraints(
minWidth: 170,
maxWidth: 256,
minHeight: 100,
maxHeight: 300,
);
/// {@template imageAttachmentBuilder}
/// A widget builder for [AttachmentType.image] attachment type.
///
/// This builder is used when a message contains only a single image attachment.
/// {@endtemplate}
class ImageAttachmentBuilder extends StreamAttachmentWidgetBuilder {
/// {@macro imageAttachmentBuilder}
const ImageAttachmentBuilder({
this.shape,
this.padding = const EdgeInsets.all(2),
this.constraints = _kDefaultImageConstraints,
this.onAttachmentTap,
});
/// The shape of the image attachment.
final ShapeBorder? shape;
/// The constraints to apply to the image attachment widget.
final BoxConstraints constraints;
/// The padding to apply to the image attachment widget.
final EdgeInsetsGeometry padding;
/// The callback to call when the attachment is tapped.
final StreamAttachmentWidgetTapCallback? onAttachmentTap;
@override
bool canHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
final images = attachments[AttachmentType.image];
return images != null && images.length == 1;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(debugAssertCanHandle(message, attachments), '');
final image = attachments[AttachmentType.image]!.first;
VoidCallback? onTap;
if (onAttachmentTap != null) {
onTap = () => onAttachmentTap!(message, image);
}
return Padding(
padding: padding,
child: InkWell(
onTap: onTap,
child: StreamImageAttachment(
message: message,
constraints: constraints,
image: image,
),
),
);
}
}
@@ -0,0 +1,118 @@
part of 'attachment_widget_builder.dart';
/// {@template mixedAttachmentBuilder}
/// A widget builder for Mixed attachment type.
///
/// This builder is used when a message contains both image/video/giphy and file
/// attachments.
///
/// This builder will render first image/video/giphy attachment and then render
/// the file attachments.
/// {@endtemplate}
class MixedAttachmentBuilder extends StreamAttachmentWidgetBuilder {
/// {@macro mixedAttachmentBuilder}
MixedAttachmentBuilder({
this.shape,
this.padding = const EdgeInsets.all(4),
this.onAttachmentTap,
}) : _imageAttachmentBuilder = ImageAttachmentBuilder(
onAttachmentTap: onAttachmentTap,
padding: EdgeInsets.symmetric(horizontal: padding.horizontal),
),
_videoAttachmentBuilder = VideoAttachmentBuilder(
onAttachmentTap: onAttachmentTap,
padding: EdgeInsets.symmetric(horizontal: padding.horizontal),
),
_giphyAttachmentBuilder = GiphyAttachmentBuilder(
onAttachmentTap: onAttachmentTap,
padding: EdgeInsets.symmetric(horizontal: padding.horizontal),
),
_galleryAttachmentBuilder = GalleryAttachmentBuilder(
onAttachmentTap: onAttachmentTap,
padding: EdgeInsets.symmetric(horizontal: padding.horizontal),
),
_fileAttachmentBuilder = FileAttachmentBuilder(
onAttachmentTap: onAttachmentTap,
padding: EdgeInsets.symmetric(horizontal: padding.horizontal),
);
/// The shape of the gallery attachment.
final ShapeBorder? shape;
/// The padding to apply to the gallery attachment widget.
final EdgeInsetsGeometry padding;
/// The callback to call when the attachment is tapped.
final StreamAttachmentWidgetTapCallback? onAttachmentTap;
late final StreamAttachmentWidgetBuilder _imageAttachmentBuilder;
late final StreamAttachmentWidgetBuilder _videoAttachmentBuilder;
late final StreamAttachmentWidgetBuilder _giphyAttachmentBuilder;
late final StreamAttachmentWidgetBuilder _galleryAttachmentBuilder;
late final StreamAttachmentWidgetBuilder _fileAttachmentBuilder;
@override
bool canHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
final containsImage = attachments.keys.contains(AttachmentType.image);
final containsVideo = attachments.keys.contains(AttachmentType.video);
final containsGiphy = attachments.keys.contains(AttachmentType.giphy);
final containsFile = attachments.keys.contains(AttachmentType.file);
final containsMedia = containsImage || containsVideo || containsGiphy;
return containsMedia && containsFile;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(debugAssertCanHandle(message, attachments), '');
final files = attachments[AttachmentType.file];
final images = attachments[AttachmentType.image];
final videos = attachments[AttachmentType.video];
final giphys = attachments[AttachmentType.giphy];
final shouldBuildGallery = [...?images, ...?videos, ...?giphys].length > 1;
return Padding(
padding: padding,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (files != null)
for (final file in files)
_fileAttachmentBuilder.build(context, message, {
AttachmentType.file: [file],
}),
if (shouldBuildGallery)
_galleryAttachmentBuilder.build(context, message, {
if (images != null) AttachmentType.image: images,
if (videos != null) AttachmentType.video: videos,
if (giphys != null) AttachmentType.giphy: giphys,
})
else if (images != null && images.length == 1)
_imageAttachmentBuilder.build(context, message, {
AttachmentType.image: images,
})
else if (videos != null && videos.length == 1)
_videoAttachmentBuilder.build(context, message, {
AttachmentType.video: videos,
})
else if (giphys != null && giphys.length == 1)
_giphyAttachmentBuilder.build(context, message, {
AttachmentType.giphy: giphys,
}),
].insertBetween(
SizedBox(height: padding.vertical / 2),
),
),
);
}
}
@@ -0,0 +1,104 @@
part of 'attachment_widget_builder.dart';
const _kDefaultUrlAttachmentConstraints = BoxConstraints(maxWidth: 290);
/// {@template urlAttachmentBuilder}
/// A widget builder for url attachment type.
///
/// This is used to show url attachments with a preview. e.g. youtube, twitter,
/// etc.
/// {@endtemplate}
class UrlAttachmentBuilder extends StreamAttachmentWidgetBuilder {
/// {@macro urlAttachmentBuilder}
const UrlAttachmentBuilder({
this.shape,
this.padding = const EdgeInsets.all(8),
this.constraints = _kDefaultUrlAttachmentConstraints,
this.onAttachmentTap,
});
/// The shape of the url attachment.
final ShapeBorder? shape;
/// The constraints to apply to the url attachment widget.
final BoxConstraints constraints;
/// The padding to apply to the url attachment widget.
final EdgeInsetsGeometry padding;
/// The callback to call when the attachment is tapped.
final StreamAttachmentWidgetTapCallback? onAttachmentTap;
@override
bool canHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
final urls = attachments[AttachmentType.urlPreview];
return urls != null && urls.isNotEmpty;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(debugAssertCanHandle(message, attachments), '');
final urlPreviews = attachments[AttachmentType.urlPreview]!;
final client = StreamChat.of(context).client;
final isMyMessage = message.user?.id == client.state.currentUser?.id;
final streamChatTheme = StreamChatTheme.of(context);
final messageTheme = isMyMessage
? streamChatTheme.ownMessageTheme
: streamChatTheme.otherMessageTheme;
Widget _buildUrlPreview(Attachment urlPreview) {
VoidCallback? onTap;
if (onAttachmentTap != null) {
onTap = () => onAttachmentTap!(message, urlPreview);
}
final host = Uri.parse(urlPreview.titleLink!).host;
final splitList = host.split('.');
final hostName = splitList.length == 3 ? splitList[1] : splitList[0];
final hostDisplayName = urlPreview.authorName?.capitalize() ??
getWebsiteName(hostName.toLowerCase()) ??
hostName.capitalize();
return InkWell(
onTap: onTap,
child: StreamUrlAttachment(
message: message,
urlAttachment: urlPreview,
hostDisplayName: hostDisplayName,
messageTheme: messageTheme,
constraints: constraints,
shape: shape,
),
);
}
Widget child;
if (urlPreviews.length == 1) {
child = _buildUrlPreview(urlPreviews.first);
} else {
child = Column(
children: <Widget>[
for (final urlPreview in urlPreviews) _buildUrlPreview(urlPreview),
].insertBetween(
// Add a small vertical padding between each attachment.
SizedBox(height: padding.vertical / 2),
),
);
}
return Padding(
padding: padding,
child: child,
);
}
}
@@ -0,0 +1,72 @@
part of 'attachment_widget_builder.dart';
const _kDefaultVideoConstraints = BoxConstraints.tightFor(
width: 256,
height: 195,
);
/// {@template videoAttachmentBuilder}
/// A widget builder for [AttachmentType.video] attachment type.
///
/// This builder is used when a message contains only a single video attachment.
/// {@endtemplate}
class VideoAttachmentBuilder extends StreamAttachmentWidgetBuilder {
/// {@macro videoAttachmentBuilder}
const VideoAttachmentBuilder({
this.shape,
this.padding = const EdgeInsets.all(2),
this.constraints = _kDefaultVideoConstraints,
this.onAttachmentTap,
});
/// The shape of the video attachment.
final ShapeBorder? shape;
/// The constraints to apply to the video attachment widget.
final BoxConstraints constraints;
/// The padding to apply to the video attachment widget.
final EdgeInsetsGeometry padding;
/// The callback to call when the attachment is tapped.
final StreamAttachmentWidgetTapCallback? onAttachmentTap;
@override
bool canHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
final videos = attachments[AttachmentType.video];
if (videos != null && videos.length == 1) return true;
return false;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(debugAssertCanHandle(message, attachments), '');
final video = attachments[AttachmentType.video]!.first;
VoidCallback? onTap;
if (onAttachmentTap != null) {
onTap = () => onAttachmentTap!(message, video);
}
return Padding(
padding: padding,
child: InkWell(
onTap: onTap,
child: StreamVideoAttachment(
message: message,
constraints: constraints,
video: video,
),
),
);
}
}
@@ -1,13 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart';
import 'package:stream_chat_flutter/src/attachment/handler/stream_attachment_handler.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/file_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/indicators/upload_progress_indicator.dart';
import 'package:stream_chat_flutter/src/misc/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/utils/utils.dart';
import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// {@template streamFileAttachment}
@@ -15,209 +12,143 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
///
/// Used in [MessageWidget].
/// {@endtemplate}
class StreamFileAttachment extends StreamAttachmentWidget {
class StreamFileAttachment extends StatelessWidget {
/// {@macro streamFileAttachment}
const StreamFileAttachment({
super.key,
required super.message,
required super.attachment,
super.constraints,
required this.message,
required this.file,
this.title,
this.trailing,
this.onAttachmentTap,
this.shape,
this.backgroundColor,
this.constraints = const BoxConstraints(),
});
/// Title for the attachment
/// The [Message] that the file is attached to.
final Message message;
/// The [Attachment] object containing the file information.
final Attachment file;
/// The shape of the attachment.
///
/// Defaults to [RoundedRectangleBorder] with a radius of 12.
final ShapeBorder? shape;
/// The background color of the attachment.
///
/// Defaults to [StreamChatTheme.colorTheme.barsBg].
final Color? backgroundColor;
/// The constraints to use when displaying the file.
final BoxConstraints constraints;
/// Widget for displaying the title of the attachment.
/// (usually the file name)
final Widget? title;
/// Widget for displaying at the end of the attachment
/// Widget for displaying at the end of the attachment.
/// (such as a download button)
final Widget? trailing;
/// {@macro onAttachmentTap}
final OnAttachmentTap? onAttachmentTap;
/// Checks if the attachment is a video
bool get isVideoAttachment => attachment.title?.mimeType?.type == 'video';
/// Checks if the attachment is an image
bool get isImageAttachment => attachment.title?.mimeType?.type == 'image';
@override
Widget build(BuildContext context) {
final colorTheme = StreamChatTheme.of(context).colorTheme;
return Material(
child: GestureDetector(
onTap: onAttachmentTap,
child: Container(
constraints: constraints ?? const BoxConstraints.tightFor(width: 100),
height: 56,
decoration: BoxDecoration(
color: colorTheme.barsBg,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: colorTheme.borders,
final chatTheme = StreamChatTheme.of(context);
final textTheme = chatTheme.textTheme;
final colorTheme = chatTheme.colorTheme;
final backgroundColor = this.backgroundColor ?? colorTheme.barsBg;
final shape = this.shape ??
RoundedRectangleBorder(
side: BorderSide(
color: colorTheme.borders,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(12),
);
return Container(
constraints: constraints,
clipBehavior: Clip.hardEdge,
decoration: ShapeDecoration(
shape: shape,
color: backgroundColor,
),
child: Row(
children: [
Container(
width: 34,
height: 40,
margin: const EdgeInsets.all(8),
child: _FileTypeImage(file: file),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.title ?? context.translations.fileText,
maxLines: 1,
style: textTheme.bodyBold,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
_FileAttachmentSubtitle(attachment: file),
],
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 40,
width: 33.33,
margin: const EdgeInsets.all(8),
child: _FileTypeImage(
isImageAttachment: isImageAttachment,
isVideoAttachment: isVideoAttachment,
source: source,
attachment: attachment,
const SizedBox(width: 8),
Material(
type: MaterialType.transparency,
child: trailing ??
_Trailing(
attachment: file,
message: message,
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
attachment.title ?? context.translations.fileText,
style: StreamChatTheme.of(context).textTheme.bodyBold,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
_FileAttachmentSubtitle(attachment: attachment),
],
),
),
const SizedBox(width: 8),
Material(
type: MaterialType.transparency,
child: trailing ??
_Trailing(
attachment: attachment,
message: message,
),
),
],
),
),
],
),
);
}
}
class _FileTypeImage extends StatelessWidget {
const _FileTypeImage({
required this.isImageAttachment,
required this.isVideoAttachment,
required this.source,
required this.attachment,
});
const _FileTypeImage({required this.file});
final bool isImageAttachment;
final bool isVideoAttachment;
final AttachmentSource source;
final Attachment attachment;
ShapeBorder _getDefaultShape(BuildContext context) {
return RoundedRectangleBorder(
side: const BorderSide(width: 0, color: Colors.transparent),
borderRadius: BorderRadius.circular(8),
);
}
final Attachment file;
// TODO: Improve image memory.
// This is using the full image instead of a smaller version (thumbnail)
@override
Widget build(BuildContext context) {
if (isImageAttachment) {
return Material(
clipBehavior: Clip.hardEdge,
type: MaterialType.transparency,
shape: _getDefaultShape(context),
child: source.when(
local: () {
if (attachment.file?.bytes == null) {
return getFileTypeImage(
attachment.extraData['mime_type'] as String?,
);
}
return Image.memory(
attachment.file!.bytes!,
fit: BoxFit.cover,
errorBuilder: (_, obj, trace) => getFileTypeImage(
attachment.extraData['mime_type'] as String?,
),
);
},
network: () {
if ((attachment.imageUrl ??
attachment.assetUrl ??
attachment.thumbUrl) ==
null) {
return getFileTypeImage(
attachment.extraData['mime_type'] as String?,
);
}
return CachedNetworkImage(
imageUrl: attachment.imageUrl ??
attachment.assetUrl ??
attachment.thumbUrl!,
fit: BoxFit.cover,
errorWidget: (_, obj, trace) => getFileTypeImage(
attachment.extraData['mime_type'] as String?,
),
placeholder: (_, __) {
final image = Image.asset(
'images/placeholder.png',
fit: BoxFit.cover,
package: 'stream_chat_flutter',
);
Widget child = StreamFileAttachmentThumbnail(
file: file,
width: double.infinity,
height: double.infinity,
// fit: BoxFit.cover,
);
final colorTheme = StreamChatTheme.of(context).colorTheme;
return Shimmer.fromColors(
baseColor: colorTheme.disabled,
highlightColor: colorTheme.inputBg,
child: image,
);
},
);
},
final mimeType = file.title?.mimeType?.type;
final isImage = mimeType == 'image';
final isVideo = mimeType == 'video';
if (isImage || isVideo) {
final colorTheme = StreamChatTheme.of(context).colorTheme;
child = Container(
clipBehavior: Clip.hardEdge,
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: BorderSide(color: colorTheme.borders),
borderRadius: BorderRadius.circular(8),
),
),
child: child,
);
}
if (isVideoAttachment) {
return Material(
clipBehavior: Clip.hardEdge,
type: MaterialType.transparency,
shape: _getDefaultShape(context),
child: source.when(
local: () => StreamVideoThumbnailImage(
video: attachment.file!.path,
placeholderBuilder: (_) => const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(),
),
),
),
network: () => StreamVideoThumbnailImage(
video: attachment.assetUrl,
placeholderBuilder: (_) => const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(),
),
),
),
),
);
}
return getFileTypeImage(attachment.extraData['mime_type'] as String?);
return child;
}
}
@@ -346,7 +277,6 @@ class _FileAttachmentSubtitle extends StatelessWidget {
uploaded: sent,
total: total,
showBackground: false,
padding: EdgeInsets.zero,
textStyle: textStyle,
progressIndicatorColor: theme.colorTheme.accentPrimary,
),
@@ -0,0 +1,261 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/giphy_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/video_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/misc/flex_grid.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamGalleryAttachment}
/// Constructs a gallery of images, videos, and gifs from a list of attachments.
///
/// This widget uses a [FlexGrid] to display the attachments in a grid format.
/// The grid will automatically resize based on the size of the attachment.
/// {@endtemplate}
///
/// See also:
///
/// * [StreamImageAttachmentThumbnail], which is used to display the image
/// thumbnails.
/// * [StreamVideoAttachmentThumbnail], which is used to display the video
/// thumbnails.
/// * [StreamGiphyAttachmentThumbnail], which is used to display the gif
/// thumbnails.
class StreamGalleryAttachment extends StatelessWidget {
/// {@macro streamGalleryAttachment}
const StreamGalleryAttachment({
super.key,
required this.attachments,
required this.message,
this.shape,
this.constraints = const BoxConstraints(),
this.spacing = 2.0,
this.runSpacing = 2.0,
required this.itemBuilder,
});
/// List of attachments to show
final List<Attachment> attachments;
/// The [Message] that the images are attached to
final Message message;
/// The shape of the attachment.
///
/// Defaults to [RoundedRectangleBorder] with a radius of 14.
final ShapeBorder? shape;
/// The constraints of the [attachments]
final BoxConstraints constraints;
/// How much space to place between children in a run in the main axis.
///
/// For example, if [spacing] is 10.0, the children will be spaced at least
/// 10.0 logical pixels apart in the main axis.
///
/// Defaults to 2.0.
final double spacing;
/// How much space to place between the runs themselves in the cross axis.
///
/// For example, if [runSpacing] is 10.0, the runs will be spaced at least
/// 10.0 logical pixels apart in the cross axis.
///
/// Defaults to 2.0.
final double runSpacing;
/// Item builder for the gallery.
final IndexedWidgetBuilder itemBuilder;
@override
Widget build(BuildContext context) {
assert(
attachments.length >= 2,
'Gallery should have at least 2 attachments, found ${attachments.length}',
);
final chatTheme = StreamChatTheme.of(context);
final colorTheme = chatTheme.colorTheme;
final shape = this.shape ??
RoundedRectangleBorder(
side: BorderSide(
color: colorTheme.borders,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(14),
);
return Container(
constraints: constraints,
clipBehavior: Clip.hardEdge,
decoration: ShapeDecoration(shape: shape),
// Added a builder just for the sake of calculating the image count
// and building the appropriate layout based on the image count.
child: Builder(
builder: (context) {
final attachmentCount = attachments.length;
if (attachmentCount == 2) {
return _buildForTwo(context, attachments);
}
if (attachmentCount == 3) {
return _buildForThree(context, attachments);
}
return _buildForFourOrMore(context, attachments);
},
),
);
}
Widget _buildForTwo(BuildContext context, List<Attachment> attachments) {
final aspectRatio1 = attachments[0].originalSize?.aspectRatio;
final aspectRatio2 = attachments[1].originalSize?.aspectRatio;
// check if one image is landscape and other is portrait or vice versa
final isLandscape1 = aspectRatio1 != null && aspectRatio1 > 1;
final isLandscape2 = aspectRatio2 != null && aspectRatio2 > 1;
// Both the images are landscape.
if (isLandscape1 && isLandscape2) {
// ----------
// | |
// ----------
// | |
// ----------
return FlexGrid(
pattern: const [
[1],
[1],
],
children: [
itemBuilder(context, 0),
itemBuilder(context, 1),
],
);
}
// Both the images are portrait.
if (!isLandscape1 && !isLandscape2) {
// -----------
// | | |
// | | |
// | | |
// -----------
return FlexGrid(
pattern: const [
[1, 1],
],
children: [
itemBuilder(context, 0),
itemBuilder(context, 1),
],
);
}
// Layout on the basis of isLandscape1.
// 1. True
// -----------
// | | |
// | | |
// | | |
// -----------
//
// 2. False
// -----------
// | | |
// | | |
// | | |
// -----------
return FlexGrid(
pattern: [
if (isLandscape1) [2, 1] else [1, 2],
],
children: [
itemBuilder(context, 0),
itemBuilder(context, 1),
],
);
}
Widget _buildForThree(BuildContext context, List<Attachment> attachments) {
final aspectRatio1 = attachments[0].originalSize?.aspectRatio;
final isLandscape1 = aspectRatio1 != null && aspectRatio1 > 1;
// We layout on the basis of isLandscape1.
// 1. True
// -----------
// | |
// | |
// |---------|
// | | |
// | | |
// -----------
//
// 2. False
// -----------
// | | |
// | | |
// | |----|
// | | |
// | | |
// -----------
return FlexGrid(
pattern: const [
[1],
[1, 1],
],
reverse: !isLandscape1,
children: [
itemBuilder(context, 0),
itemBuilder(context, 1),
itemBuilder(context, 2),
],
);
}
Widget _buildForFourOrMore(
BuildContext context, List<Attachment> attachments) {
final pattern = <List<int>>[];
final children = <Widget>[];
for (var i = 0; i < attachments.length; i++) {
if (i.isEven) {
pattern.add([1]);
} else {
pattern.last.add(1);
}
children.add(itemBuilder(context, i));
}
// -----------
// | | |
// | | |
// ------------
// | | |
// | | |
// ------------
return FlexGrid(
pattern: pattern,
maxChildren: 4,
children: children,
overlayBuilder: (context, remaining) {
return IgnorePointer(
child: ColoredBox(
color: Colors.black38,
child: Center(
child: Text(
'+$remaining',
style: const TextStyle(
fontSize: 26,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
);
},
);
}
}
@@ -1,346 +1,134 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/giphy_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/misc/giphy_chip.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamGiphyAttachment}
/// Shows a GIF attachment in a [StreamMessageWidget].
/// {@endtemplate}
class StreamGiphyAttachment extends StreamAttachmentWidget {
class StreamGiphyAttachment extends StatelessWidget {
/// {@macro streamGiphyAttachment}
const StreamGiphyAttachment({
super.key,
required super.message,
required super.attachment,
super.constraints,
this.onShowMessage,
this.onReplyMessage,
this.onAttachmentTap,
this.attachmentActionsModalBuilder,
required this.message,
required this.giphy,
this.type = GiphyInfoType.fixedHeightDownsampled,
this.shape,
this.constraints = const BoxConstraints(),
});
/// {@macro showMessageCallback}
final ShowMessageCallback? onShowMessage;
/// The [Message] that the giphy is attached to.
final Message message;
/// {@macro replyMessageCallback}
final ReplyMessageCallback? onReplyMessage;
/// The [Attachment] object containing the giphy information.
final Attachment giphy;
/// {@macro onAttachmentTap}
final OnAttachmentTap? onAttachmentTap;
/// The type of giphy to display.
///
/// Defaults to [GiphyInfoType.fixedHeight].
final GiphyInfoType type;
/// {@macro attachmentActionsBuilder}
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
/// The shape of the attachment.
///
/// Defaults to [RoundedRectangleBorder] with a radius of 14.
final ShapeBorder? shape;
/// The constraints to use when displaying the giphy.
final BoxConstraints constraints;
@override
Widget build(BuildContext context) {
final imageUrl =
attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl;
if (imageUrl == null) {
return const AttachmentError();
}
if (attachment.actions != null && attachment.actions!.isNotEmpty) {
return _buildSendingAttachment(context, imageUrl);
}
return _buildSentAttachment(context, imageUrl);
}
BoxFit? fit;
final giphyInfo = giphy.giphyInfo(type);
Widget _buildSendingAttachment(BuildContext context, String imageUrl) {
final streamChannel = StreamChannel.of(context);
return ConstrainedBox(
constraints: constraints?.copyWith(
maxHeight: double.infinity,
) ??
const BoxConstraints.expand(),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Card(
color: StreamChatTheme.of(context).colorTheme.barsBg,
elevation: 2,
clipBehavior: Clip.hardEdge,
margin: EdgeInsets.zero,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topRight: Radius.circular(16),
topLeft: Radius.circular(16),
bottomLeft: Radius.circular(16),
Size? giphySize;
if (giphyInfo != null) {
giphySize = Size(giphyInfo.width, giphyInfo.height);
}
// If attachment size is available, we will tighten the constraints max
// size to the attachment size.
var constraints = this.constraints;
if (giphySize != null) {
constraints = constraints.tightenMaxSize(giphySize);
} else {
// For backward compatibility, we will fill the available space if the
// attachment size is not available.
fit = BoxFit.cover;
}
final chatTheme = StreamChatTheme.of(context);
final colorTheme = chatTheme.colorTheme;
final shape = this.shape ??
RoundedRectangleBorder(
side: BorderSide(
color: colorTheme.borders,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(14),
);
return Hero(
tag: giphy.id,
child: Container(
constraints: constraints,
clipBehavior: Clip.hardEdge,
decoration: ShapeDecoration(shape: shape),
child: AspectRatio(
aspectRatio: giphySize?.aspectRatio ?? 1,
child: Stack(
alignment: Alignment.center,
children: [
StreamGiphyAttachmentThumbnail(
type: type,
giphy: giphy,
fit: fit,
width: double.infinity,
height: double.infinity,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (giphy.uploadState.isSuccess)
const Positioned(
bottom: 8,
left: 8,
child: GiphyChip(),
)
else
Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
StreamSvgIcon.giphyIcon(),
const SizedBox(width: 8),
Text(
context.translations.giphyLabel,
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(width: 8),
if (attachment.title != null)
Flexible(
child: Text(
attachment.title!,
style: TextStyle(
color: StreamChatTheme.of(context)
.colorTheme
.textHighEmphasis
.withOpacity(0.5),
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
child: StreamAttachmentUploadStateBuilder(
message: message,
attachment: giphy,
),
),
Padding(
padding: const EdgeInsets.all(2),
child: GestureDetector(
onTap: () {
if (onAttachmentTap != null) {
onAttachmentTap?.call();
} else {
_onImageTap(context);
}
},
child: CachedNetworkImage(
height: constraints?.maxHeight,
width: constraints?.maxWidth,
placeholder: (_, __) => SizedBox(
width: constraints?.maxHeight,
height: constraints?.maxWidth,
child: const Center(
child: CircularProgressIndicator.adaptive(),
),
),
imageUrl: imageUrl,
errorWidget: (context, url, error) => AttachmentError(
constraints: constraints,
),
fit: BoxFit.cover,
),
),
),
Container(
color: StreamChatTheme.of(context)
.colorTheme
.textHighEmphasis
.withOpacity(0.2),
width: double.infinity,
height: 0.5,
),
Row(
children: [
Expanded(
child: SizedBox(
height: 50,
child: TextButton(
onPressed: () {
streamChannel.channel.sendAction(
message,
{
'image_action': 'cancel',
},
);
},
child: Text(
context.translations.cancelLabel
.toLowerCase()
.capitalize(),
style: StreamChatTheme.of(context)
.textTheme
.bodyBold
.copyWith(
color: StreamChatTheme.of(context)
.colorTheme
.textHighEmphasis
.withOpacity(0.5),
),
),
),
),
),
Container(
width: 0.5,
color: StreamChatTheme.of(context)
.colorTheme
.textHighEmphasis
.withOpacity(0.2),
height: 50,
),
Expanded(
child: SizedBox(
height: 50,
child: TextButton(
onPressed: () {
streamChannel.channel.sendAction(
message,
{
'image_action': 'shuffle',
},
);
},
child: Text(
context.translations.shuffleLabel,
style: StreamChatTheme.of(context)
.textTheme
.bodyBold
.copyWith(
color: StreamChatTheme.of(context)
.colorTheme
.textHighEmphasis
.withOpacity(0.5),
),
maxLines: 1,
),
),
),
),
Container(
width: 0.5,
color: StreamChatTheme.of(context)
.colorTheme
.textHighEmphasis
.withOpacity(0.2),
height: 50,
),
Expanded(
child: SizedBox(
height: 50,
child: TextButton(
onPressed: () {
streamChannel.channel.sendAction(
message,
{
'image_action': 'send',
},
);
},
child: Text(
context.translations.sendLabel,
style: TextStyle(
color: StreamChatTheme.of(context)
.colorTheme
.accentPrimary,
fontWeight: FontWeight.bold,
),
),
),
),
),
],
),
],
),
],
),
const SizedBox(height: 4),
const Align(
alignment: Alignment.centerRight,
child: StreamVisibleFootnote(),
),
],
),
),
);
}
Future<void> _onImageTap(BuildContext context) async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) {
final channel = StreamChannel.of(context).channel;
return StreamChannel(
channel: channel,
child: StreamFullScreenMediaBuilder(
mediaAttachmentPackages: message.getAttachmentPackageList(),
startIndex: message.attachments.indexOf(attachment),
userName: message.user!.name,
onShowMessage: onShowMessage,
onReplyMessage: onReplyMessage,
attachmentActionsModalBuilder: attachmentActionsModalBuilder,
),
);
},
),
);
}
Widget _buildSentAttachment(BuildContext context, String imageUrl) {
return GestureDetector(
onTap: () {
if (onAttachmentTap != null) {
onAttachmentTap?.call();
} else {
_onImageTap(context);
}
},
child: Stack(
children: [
CachedNetworkImage(
height: constraints?.maxHeight,
width: constraints?.maxWidth,
placeholder: (_, __) {
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,
);
},
imageUrl: imageUrl,
errorWidget: (context, url, error) => AttachmentError(
constraints: constraints,
),
fit: BoxFit.cover,
),
Positioned(
bottom: 8,
left: 8,
child: Material(
color: StreamChatTheme.of(context)
.colorTheme
.textHighEmphasis
.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
child: Row(
children: [
StreamSvgIcon.lightning(
color: StreamChatTheme.of(context).colorTheme.barsBg,
size: 16,
),
Text(
context.translations.giphyLabel.toUpperCase(),
style: TextStyle(
color: StreamChatTheme.of(context).colorTheme.barsBg,
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
],
),
),
),
),
],
),
);
}
// Future<void> _onTap(BuildContext context) async {
// if (onAttachmentTap != null) {
// return onAttachmentTap!();
// }
//
// await Navigator.of(context).push(
// MaterialPageRoute(
// builder: (_) {
// final channel = StreamChannel.of(context).channel;
// return StreamChannel(
// channel: channel,
// child: StreamFullScreenMediaBuilder(
// mediaAttachmentPackages: message.getAttachmentPackageList(),
// startIndex: message.attachments.indexOf(giphy),
// userName: message.user!.name,
// onShowMessage: onShowMessage,
// onReplyMessage: onReplyMessage,
// attachmentActionsModalBuilder: attachmentActionsModalBuilder,
// ),
// );
// },
// ),
// );
// }
}
@@ -1,44 +1,36 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamImageAttachment}
/// Shows an image attachment in a [StreamMessageWidget].
/// {@endtemplate}
class StreamImageAttachment extends StreamAttachmentWidget {
class StreamImageAttachment extends StatelessWidget {
/// {@macro streamImageAttachment}
const StreamImageAttachment({
super.key,
required super.message,
required super.attachment,
required this.messageTheme,
super.constraints,
this.showTitle = false,
this.onShowMessage,
this.onReplyMessage,
this.onAttachmentTap,
required this.message,
required this.image,
this.shape,
this.constraints = const BoxConstraints(),
this.imageThumbnailSize = const Size(400, 400),
this.imageThumbnailResizeType = 'clip',
this.imageThumbnailCropType = 'center',
this.attachmentActionsModalBuilder,
});
/// The [StreamMessageThemeData] to use for the image title
final StreamMessageThemeData messageTheme;
/// The [Message] that the image is attached to.
final Message message;
/// Flag for whether the title should be shown or not
final bool showTitle;
/// The [Attachment] object containing the image information.
final Attachment image;
/// {@macro showMessageCallback}
final ShowMessageCallback? onShowMessage;
/// The shape of the attachment.
///
/// Defaults to [RoundedRectangleBorder] with a radius of 14.
final ShapeBorder? shape;
/// {@macro replyMessageCallback}
final ReplyMessageCallback? onReplyMessage;
/// {@macro onAttachmentTap}
final OnAttachmentTap? onAttachmentTap;
/// The constraints to use when displaying the image.
final BoxConstraints constraints;
/// Size of the attachment image thumbnail.
final Size imageThumbnailSize;
@@ -53,149 +45,89 @@ class StreamImageAttachment extends StreamAttachmentWidget {
/// Defaults to [center]
final String /*center|top|bottom|left|right*/ imageThumbnailCropType;
/// {@macro attachmentActionsBuilder}
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
@override
Widget build(BuildContext context) {
return source.when(
local: () {
if (attachment.file?.bytes != null) {
return _buildImageAttachment(
context,
Image.memory(
attachment.file!.bytes!,
height: constraints?.maxHeight,
width: constraints?.maxWidth,
fit: BoxFit.cover,
errorBuilder: _imageErrorBuilder,
),
);
} else if (attachment.localUri != null) {
return _buildImageAttachment(
context,
Image.asset(
attachment.localUri!.path,
height: constraints?.maxHeight,
width: constraints?.maxWidth,
fit: BoxFit.cover,
errorBuilder: _imageErrorBuilder,
),
);
} else {
return AttachmentError(
constraints: constraints,
);
}
},
network: () {
var imageUrl =
attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl;
BoxFit? fit;
final imageSize = image.originalSize;
if (imageUrl == null) {
return AttachmentError(constraints: constraints);
}
// If attachment size is available, we will tighten the constraints max
// size to the attachment size.
var constraints = this.constraints;
if (imageSize != null) {
constraints = constraints.tightenMaxSize(imageSize);
} else {
// For backward compatibility, we will fill the available space if the
// attachment size is not available.
fit = BoxFit.cover;
}
imageUrl = imageUrl.getResizedImageUrl(
width: imageThumbnailSize.width,
height: imageThumbnailSize.height,
resize: imageThumbnailResizeType,
crop: imageThumbnailCropType,
);
return _buildImageAttachment(
context,
CachedNetworkImage(
imageUrl: imageUrl,
height: constraints?.maxHeight,
width: constraints?.maxWidth,
fit: BoxFit.cover,
placeholder: (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,
);
},
errorWidget: (context, url, error) =>
AttachmentError(constraints: constraints),
final chatTheme = StreamChatTheme.of(context);
final colorTheme = chatTheme.colorTheme;
final shape = this.shape ??
RoundedRectangleBorder(
side: BorderSide(
color: colorTheme.borders,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(14),
);
},
);
}
Widget _imageErrorBuilder(BuildContext _, Object __, StackTrace? ___) =>
Image.asset(
'images/placeholder.png',
package: 'stream_chat_flutter',
);
Widget _buildImageAttachment(BuildContext context, Widget imageWidget) {
return Container(
constraints: constraints,
child: Column(
children: <Widget>[
Expanded(
child: Stack(
children: [
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onAttachmentTap ??
() {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) {
final channel =
StreamChannel.of(context).channel;
return StreamChannel(
channel: channel,
child: StreamFullScreenMediaBuilder(
mediaAttachmentPackages:
message.getAttachmentPackageList(),
startIndex:
message.attachments.indexOf(attachment),
userName: message.user!.name,
onShowMessage: onShowMessage,
onReplyMessage: onReplyMessage,
attachmentActionsModalBuilder:
attachmentActionsModalBuilder,
),
);
},
),
);
},
child: imageWidget,
),
),
Padding(
padding: const EdgeInsets.all(8),
child: StreamAttachmentUploadStateBuilder(
message: message,
attachment: attachment,
),
),
],
clipBehavior: Clip.hardEdge,
decoration: ShapeDecoration(shape: shape),
child: AspectRatio(
aspectRatio: imageSize?.aspectRatio ?? 1,
child: Stack(
alignment: Alignment.center,
children: [
StreamImageAttachmentThumbnail(
image: image,
fit: fit,
width: double.infinity,
height: double.infinity,
thumbnailSize: imageThumbnailSize,
thumbnailResizeType: imageThumbnailResizeType,
thumbnailCropType: imageThumbnailCropType,
),
),
if (showTitle && attachment.title != null)
Material(
color: messageTheme.messageBackgroundColor,
child: StreamAttachmentTitle(
messageTheme: messageTheme,
attachment: attachment,
Padding(
padding: const EdgeInsets.all(8),
child: StreamAttachmentUploadStateBuilder(
message: message,
attachment: image,
),
),
],
],
),
),
);
}
}
// Future<void> _onTap(
// BuildContext context,
// int index,
// ) async {
// if (onAttachmentTap != null) {
// return onAttachmentTap!();
// }
//
// final channel = StreamChannel.of(context).channel;
//
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => StreamChannel(
// channel: channel,
// child: StreamFullScreenMediaBuilder(
// mediaAttachmentPackages: message.getAttachmentPackageList(),
// startIndex: index,
// userName: message.user!.name,
// onShowMessage: onShowMessage,
// onReplyMessage: onReplyMessage,
// attachmentActionsModalBuilder: attachmentActionsModalBuilder,
// ),
// ),
// ),
// );
// }
// }
@@ -1,181 +0,0 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamImageGroup}
/// Constructs a group of image attachments in a [StreamMessageWidget].
/// {@endtemplate}
class StreamImageGroup extends StatelessWidget {
/// {@macro streamImageGroup}
const StreamImageGroup({
super.key,
required this.images,
required this.message,
required this.messageTheme,
required this.constraints,
this.onShowMessage,
this.onReplyMessage,
this.onAttachmentTap,
this.imageThumbnailSize = const Size(400, 400),
this.imageThumbnailResizeType = 'clip',
this.imageThumbnailCropType = 'center',
this.attachmentActionsModalBuilder,
});
/// List of attachments to show
final List<Attachment> images;
/// {@macro onImageGroupAttachmentTap}
final OnImageGroupAttachmentTap? onAttachmentTap;
/// The [Message] that the images are attached to
final Message message;
/// The [StreamMessageThemeData] to apply to this [message]
final StreamMessageThemeData messageTheme;
/// The constraints of the [images]
final BoxConstraints constraints;
/// {@macro showMessageCallback}
final ShowMessageCallback? onShowMessage;
/// {@macro replyMessageCallback}
final ReplyMessageCallback? onReplyMessage;
/// 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;
/// {@macro attachmentActionsBuilder}
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: constraints,
child: Flex(
direction: Axis.vertical,
children: <Widget>[
Flexible(
fit: FlexFit.tight,
child: Flex(
crossAxisAlignment: CrossAxisAlignment.stretch,
direction: Axis.horizontal,
children: [
Flexible(
fit: FlexFit.tight,
child: _buildImage(context, 0),
),
Flexible(
fit: FlexFit.tight,
child: Padding(
padding: const EdgeInsets.only(left: 2),
child: _buildImage(context, 1),
),
),
],
),
),
if (images.length >= 3)
Flexible(
fit: FlexFit.tight,
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Flex(
direction: Axis.horizontal,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Flexible(
fit: FlexFit.tight,
child: _buildImage(context, 2),
),
if (images.length >= 4)
Flexible(
fit: FlexFit.tight,
child: Padding(
padding: const EdgeInsets.only(left: 2),
child: Stack(
fit: StackFit.expand,
children: <Widget>[
_buildImage(context, 3),
if (images.length > 4)
Positioned.fill(
child: GestureDetector(
onTap: () => _onTap(context, 3),
child: Material(
color: Colors.black38,
child: Center(
child: Text(
'+ ${images.length - 4}',
style: const TextStyle(
color: Colors.white,
fontSize: 26,
),
),
),
),
),
),
],
),
),
),
],
),
),
),
],
),
);
}
Future<void> _onTap(
BuildContext context,
int index,
) async {
if (onAttachmentTap != null) {
return onAttachmentTap!(message, images[index]);
}
final channel = StreamChannel.of(context).channel;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => StreamChannel(
channel: channel,
child: StreamFullScreenMediaBuilder(
mediaAttachmentPackages: message.getAttachmentPackageList(),
startIndex: index,
userName: message.user!.name,
onShowMessage: onShowMessage,
onReplyMessage: onReplyMessage,
attachmentActionsModalBuilder: attachmentActionsModalBuilder,
),
),
),
);
}
Widget _buildImage(BuildContext context, int index) {
return StreamImageAttachment(
attachment: images[index],
constraints: constraints,
message: message,
messageTheme: messageTheme,
onAttachmentTap: () => _onTap(context, index),
imageThumbnailSize: imageThumbnailSize,
imageThumbnailResizeType: imageThumbnailResizeType,
imageThumbnailCropType: imageThumbnailCropType,
attachmentActionsModalBuilder: attachmentActionsModalBuilder,
);
}
}
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/video_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/utils/helpers.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// {@template streamFileAttachmentThumbnail}
/// Widget for building file attachment thumbnail.
///
/// This widget first tries to build an image thumbnail for the file attachment.
/// If the image thumbnail fails to load, it tries to build a video thumbnail.
/// If the video thumbnail fails to load, it returns a generic file type icon.
/// {@endtemplate}
class StreamFileAttachmentThumbnail extends StatelessWidget {
/// {@macro streamFileAttachmentThumbnail}
const StreamFileAttachmentThumbnail({
super.key,
required this.file,
this.width,
this.height,
this.fit,
this.errorBuilder = _defaultErrorBuilder,
});
/// The file attachment to build the thumbnail for.
final Attachment file;
/// The width of the thumbnail.
final double? width;
/// The height of the thumbnail.
final double? height;
/// How to inscribe the thumbnail into the space allocated during layout.
final BoxFit? fit;
/// Builder used when the thumbnail fails to load.
final ThumbnailErrorBuilder errorBuilder;
// Default error builder for file attachment thumbnail.
static Widget _defaultErrorBuilder(
BuildContext context,
Object error,
StackTrace? stackTrace,
) {
// Return a generic file type icon.
return getFileTypeImage();
}
@override
Widget build(BuildContext context) {
final mimeType = file.title?.mimeType?.type;
final isImage = mimeType == 'image';
if (isImage) {
return StreamImageAttachmentThumbnail(
image: file,
width: width,
height: height,
fit: fit,
);
}
final isVideo = mimeType == 'video';
if (isVideo) {
return StreamVideoAttachmentThumbnail(
video: file,
width: width,
height: height,
fit: fit,
);
}
// Return a generic file type icon.
return getFileTypeImage(mimeType);
}
}
@@ -0,0 +1,100 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart';
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// {@template giphyAttachmentThumbnail}
/// Widget for building giphy attachment thumbnail.
///
/// This widget is used when the [Attachment.type] is [AttachmentType.giphy].
/// {@endtemplate}
class StreamGiphyAttachmentThumbnail extends StatelessWidget {
/// {@macro giphyAttachmentThumbnail}
const StreamGiphyAttachmentThumbnail({
super.key,
required this.giphy,
this.type = GiphyInfoType.original,
this.width,
this.height,
this.fit,
this.errorBuilder = _defaultErrorBuilder,
});
/// The giphy attachment to build the thumbnail for.
final Attachment giphy;
/// The type of giphy thumbnail to build.
final GiphyInfoType type;
/// The width of the thumbnail.
final double? width;
/// The height of the thumbnail.
final double? height;
/// How to inscribe the thumbnail into the space allocated during layout.
final BoxFit? fit;
/// Builder used when the thumbnail fails to load.
final ThumbnailErrorBuilder errorBuilder;
// Default error builder for image attachment thumbnail.
static Widget _defaultErrorBuilder(
BuildContext context,
Object error,
StackTrace? stackTrace,
) {
return ThumbnailError(
error: error,
stackTrace: stackTrace,
);
}
@override
Widget build(BuildContext context) {
// If the giphy info is not available, use the image attachment thumbnail
// instead.
final info = giphy.giphyInfo(type);
if (info == null) {
return StreamImageAttachmentThumbnail(
image: giphy,
width: width,
height: height,
fit: fit,
);
}
return CachedNetworkImage(
imageUrl: info.url,
width: width,
height: height,
fit: fit,
placeholder: (context, __) {
final image = Image.asset(
'images/placeholder.png',
width: width,
height: height,
fit: BoxFit.cover,
package: 'stream_chat_flutter',
);
final colorTheme = StreamChatTheme.of(context).colorTheme;
return Shimmer.fromColors(
baseColor: colorTheme.disabled,
highlightColor: colorTheme.inputBg,
child: image,
);
},
errorWidget: (context, url, error) {
return errorBuilder(
context,
error,
StackTrace.current,
);
},
);
}
}
@@ -0,0 +1,245 @@
import 'dart:io' show File;
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:image_size_getter/file_input.dart'; // For compatibility with flutter web.
import 'package:image_size_getter/image_size_getter.dart' hide Size;
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart';
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/utils/utils.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
extension AspectRatioX on Attachment {
/// Returns the size of the attachment if it is an image or giffy.
/// Otherwise, returns null.
Size? get originalSize {
// Return null if the attachment is not an image or giffy.
if (type != 'image' && type != 'giphy') return null;
// Calculate size locally if the attachment is not uploaded yet.
final file = this.file;
if (file != null) {
ImageInput? input;
if (file.bytes != null) {
input = MemoryInput(file.bytes!);
} else if (file.path != null) {
input = FileInput(File(file.path!));
}
// Return null if the file does not contain enough information.
if (input == null) return null;
final size = ImageSizeGetter.getSize(input);
if (size.needRotate) {
return Size(size.height.toDouble(), size.width.toDouble());
}
return Size(size.width.toDouble(), size.height.toDouble());
}
// Otherwise, use the size provided by the server.
final width = originalWidth;
final height = originalHeight;
if (width == null || height == null) return null;
return Size(width.toDouble(), height.toDouble());
}
}
/// {@template imageAttachmentThumbnail}
/// Widget for building image attachment thumbnail.
///
/// This widget is used when the [Attachment.type] is [AttachmentType.image].
/// {@endtemplate}
class StreamImageAttachmentThumbnail extends StatelessWidget {
/// {@macro imageAttachmentThumbnail}
const StreamImageAttachmentThumbnail({
super.key,
required this.image,
this.width,
this.height,
this.fit,
this.thumbnailSize,
this.thumbnailResizeType = 'clip',
this.thumbnailCropType = 'center',
this.errorBuilder = _defaultErrorBuilder,
});
/// The image attachment to show.
final Attachment image;
/// Width of the attachment image thumbnail.
final double? width;
/// Height of the attachment image thumbnail.
final double? height;
/// Fit of the attachment image thumbnail.
final BoxFit? fit;
/// Size of the attachment image thumbnail.
final Size? thumbnailSize;
/// Resize type of the image attachment thumbnail.
///
/// Defaults to [crop]
final String /*clip|crop|scale|fill*/ thumbnailResizeType;
/// Crop type of the image attachment thumbnail.
///
/// Defaults to [center]
final String /*center|top|bottom|left|right*/ thumbnailCropType;
/// Builder used when the thumbnail fails to load.
final ThumbnailErrorBuilder errorBuilder;
// Default error builder for image attachment thumbnail.
static Widget _defaultErrorBuilder(
BuildContext context,
Object error,
StackTrace? stackTrace,
) {
return ThumbnailError(
error: error,
stackTrace: stackTrace,
);
}
@override
Widget build(BuildContext context) {
final file = image.file;
if (file != null) {
return _LocalImageAttachment(
file: file,
width: width,
height: height,
fit: fit,
errorBuilder: errorBuilder,
);
}
var imageUrl = image.thumbUrl ?? image.imageUrl ?? image.assetUrl;
if (imageUrl != null) {
final thumbnailSize = this.thumbnailSize;
if (thumbnailSize != null) {
imageUrl = imageUrl.getResizedImageUrl(
width: thumbnailSize.width,
height: thumbnailSize.height,
resize: thumbnailResizeType,
crop: thumbnailCropType,
);
}
return _RemoteImageAttachment(
url: imageUrl,
width: width,
height: height,
fit: fit,
errorBuilder: errorBuilder,
);
}
// Return error widget if no image is found.
return errorBuilder(
context,
'Image attachment is not valid',
StackTrace.current,
);
}
}
class _LocalImageAttachment extends StatelessWidget {
const _LocalImageAttachment({
required this.file,
required this.errorBuilder,
this.width,
this.height,
this.fit,
});
final AttachmentFile file;
final double? width;
final double? height;
final BoxFit? fit;
final ThumbnailErrorBuilder errorBuilder;
@override
Widget build(BuildContext context) {
final bytes = file.bytes;
if (bytes != null) {
return Image.memory(
bytes,
width: width,
height: height,
fit: fit,
errorBuilder: errorBuilder,
);
}
final path = file.path;
if (path != null) {
return Image.file(
File(path),
width: width,
height: height,
fit: fit,
errorBuilder: errorBuilder,
);
}
// Return error widget if no image is found.
return errorBuilder(
context,
'Image attachment is not valid',
StackTrace.current,
);
}
}
class _RemoteImageAttachment extends StatelessWidget {
const _RemoteImageAttachment({
required this.url,
required this.errorBuilder,
this.width,
this.height,
this.fit,
});
final String url;
final double? width;
final double? height;
final BoxFit? fit;
final ThumbnailErrorBuilder errorBuilder;
@override
Widget build(BuildContext context) {
return CachedNetworkImage(
imageUrl: url,
width: width,
height: height,
fit: fit,
placeholder: (context, __) {
final image = Image.asset(
'images/placeholder.png',
width: width,
height: height,
fit: BoxFit.cover,
package: 'stream_chat_flutter',
);
final colorTheme = StreamChatTheme.of(context).colorTheme;
return Shimmer.fromColors(
baseColor: colorTheme.disabled,
highlightColor: colorTheme.inputBg,
child: image,
);
},
errorWidget: (context, url, error) {
return errorBuilder(
context,
error,
StackTrace.current,
);
},
);
}
}
@@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
/// {@template thumbnailErrorBuilder}
/// Signature for the builder callback used by [ThumbnailError.builder].
///
/// The parameters represent the [BuildContext], [error] and [stackTrace] of the
/// error that triggered this callback.
/// {@endtemplate}
typedef ThumbnailErrorBuilder = Widget Function(
BuildContext context,
Object error,
StackTrace? stackTrace,
);
/// {@template thumbnailError}
/// A widget that shows an error state when a thumbnail fails to load.
/// {@endtemplate}
class ThumbnailError extends StatelessWidget {
/// {@macro thumbnailError}
const ThumbnailError({
super.key,
required this.error,
this.stackTrace,
});
/// The error that triggered this error widget.
final Object error;
/// The stack trace of the error that triggered this error widget.
final StackTrace? stackTrace;
@override
Widget build(BuildContext context) {
return Image.asset(
'images/placeholder.png',
fit: BoxFit.cover,
package: 'stream_chat_flutter',
);
}
}
@@ -0,0 +1,126 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart';
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// {@template videoAttachmentThumbnail}
/// Widget for building video attachment thumbnail.
///
/// This widget is used when the [Attachment.type] is [AttachmentType.video].
/// {@endtemplate}
class StreamVideoAttachmentThumbnail extends StatelessWidget {
/// {@macro videoAttachmentThumbnail}
const StreamVideoAttachmentThumbnail({
super.key,
required this.video,
this.width,
this.height,
this.fit,
this.errorBuilder = _defaultErrorBuilder,
});
/// The video attachment to build the thumbnail for.
final Attachment video;
/// The width of the thumbnail.
final double? width;
/// The height of the thumbnail.
final double? height;
/// How to inscribe the thumbnail into the space allocated during layout.
final BoxFit? fit;
/// Builder used when the thumbnail fails to load.
final ThumbnailErrorBuilder errorBuilder;
// Default error builder for image attachment thumbnail.
static Widget _defaultErrorBuilder(
BuildContext context,
Object error,
StackTrace? stackTrace,
) {
return ThumbnailError(
error: error,
stackTrace: stackTrace,
);
}
@override
Widget build(BuildContext context) {
final thumbUrl = video.thumbUrl;
if (thumbUrl != null) {
return CachedNetworkImage(
imageUrl: thumbUrl,
width: width,
height: height,
fit: fit,
placeholder: (context, __) {
final image = Image.asset(
'images/placeholder.png',
width: width,
height: height,
fit: BoxFit.cover,
package: 'stream_chat_flutter',
);
final colorTheme = StreamChatTheme.of(context).colorTheme;
return Shimmer.fromColors(
baseColor: colorTheme.disabled,
highlightColor: colorTheme.inputBg,
child: image,
);
},
errorWidget: (context, url, error) {
return errorBuilder(
context,
error,
StackTrace.current,
);
},
);
}
final filePath = video.file?.path;
final videoAssetUrl = video.assetUrl;
if (filePath != null || videoAssetUrl != null) {
return Image(
image: StreamVideoThumbnailImage(video: filePath ?? videoAssetUrl!),
width: width,
height: height,
fit: fit,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (frame != null || wasSynchronouslyLoaded) {
return child;
}
final image = Image.asset(
'images/placeholder.png',
width: width,
height: height,
fit: BoxFit.cover,
package: 'stream_chat_flutter',
);
final colorTheme = StreamChatTheme.of(context).colorTheme;
return Shimmer.fromColors(
baseColor: colorTheme.disabled,
highlightColor: colorTheme.inputBg,
child: image,
);
},
errorBuilder: errorBuilder,
);
}
// Return error widget if no thumbnail is found.
return errorBuilder(
context,
'Video attachment is not valid',
StackTrace.current,
);
}
}
@@ -1,6 +1,5 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamUrlAttachment}
@@ -10,155 +9,138 @@ class StreamUrlAttachment extends StatelessWidget {
/// {@macro streamUrlAttachment}
const StreamUrlAttachment({
super.key,
required this.message,
required this.urlAttachment,
required this.hostDisplayName,
required this.messageTheme,
this.textPadding = const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
this.onLinkTap,
this.shape,
this.constraints = const BoxConstraints(),
});
/// The [Message] that the image is attached to.
final Message message;
/// Attachment to be displayed
final Attachment urlAttachment;
/// The shape of the attachment.
///
/// Defaults to [RoundedRectangleBorder] with a radius of 14.
final ShapeBorder? shape;
/// The constraints to use when displaying the file.
final BoxConstraints constraints;
/// Host name
final String hostDisplayName;
/// Padding for text
final EdgeInsets textPadding;
/// The [StreamMessageThemeData] to use for the image title
final StreamMessageThemeData messageTheme;
/// The function called when tapping on a link
final void Function(String)? onLinkTap;
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 400,
minWidth: 400,
),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
final ogScrapeUrl = urlAttachment.ogScrapeUrl;
if (ogScrapeUrl != null) {
onLinkTap != null
? onLinkTap!(ogScrapeUrl)
: launchURL(context, ogScrapeUrl);
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (urlAttachment.imageUrl != null)
Container(
clipBehavior: Clip.hardEdge,
margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
child: Stack(
children: [
AspectRatio(
// Default aspect ratio for Open Graph images.
// https://www.kapwing.com/resources/what-is-an-og-image-make-and-format-og-images-for-your-blog-or-webpage
aspectRatio: 1.91 / 1,
child: CachedNetworkImage(
imageUrl: urlAttachment.imageUrl!,
fit: BoxFit.cover,
placeholder: (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,
);
},
errorWidget: (_, __, ___) => const AttachmentError(),
),
),
Positioned(
left: 0,
bottom: 0,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topRight: Radius.circular(16),
),
color: messageTheme.urlAttachmentBackgroundColor,
),
child: Padding(
padding: const EdgeInsets.only(
top: 8,
left: 8,
right: 12,
bottom: 4,
),
child: Text(
hostDisplayName,
style: messageTheme.urlAttachmentHostStyle,
),
),
),
),
],
),
),
Padding(
padding: textPadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (urlAttachment.title != null)
Builder(builder: (context) {
final maxLines = messageTheme.urlAttachmentTitleMaxLine;
TextOverflow? overflow;
if (maxLines != null && maxLines > 0) {
overflow = TextOverflow.ellipsis;
}
return Text(
urlAttachment.title!.trim(),
maxLines: maxLines,
overflow: overflow,
style: messageTheme.urlAttachmentTitleStyle,
);
}),
if (urlAttachment.text != null)
Builder(builder: (context) {
final maxLines = messageTheme.urlAttachmentTextMaxLine;
TextOverflow? overflow;
if (maxLines != null && maxLines > 0) {
overflow = TextOverflow.ellipsis;
}
return Text(
urlAttachment.text!,
maxLines: maxLines,
overflow: overflow,
style: messageTheme.urlAttachmentTextStyle,
);
}),
].insertBetween(const SizedBox(height: 4)),
),
),
],
final chatTheme = StreamChatTheme.of(context);
final colorTheme = chatTheme.colorTheme;
final shape = this.shape ??
RoundedRectangleBorder(
side: BorderSide(
color: colorTheme.borders,
strokeAlign: BorderSide.strokeAlignOutside,
),
),
borderRadius: BorderRadius.circular(14),
);
final backgroundColor = messageTheme.urlAttachmentBackgroundColor;
return Container(
constraints: constraints,
clipBehavior: Clip.hardEdge,
decoration: ShapeDecoration(
shape: shape,
color: backgroundColor,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (urlAttachment.imageUrl != null)
Stack(
children: [
AspectRatio(
// Default aspect ratio for Open Graph images.
// https://www.kapwing.com/resources/what-is-an-og-image-make-and-format-og-images-for-your-blog-or-webpage
aspectRatio: 1.91 / 1,
child: StreamImageAttachmentThumbnail(
image: urlAttachment,
fit: BoxFit.cover,
),
),
Positioned(
left: 0,
bottom: 0,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topRight: Radius.circular(16),
),
color: backgroundColor,
),
child: Padding(
padding: const EdgeInsets.only(
top: 8,
left: 8,
right: 12,
bottom: 4,
),
child: Text(
hostDisplayName,
style: messageTheme.urlAttachmentHostStyle,
),
),
),
),
],
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (urlAttachment.title != null)
Builder(builder: (context) {
final maxLines = messageTheme.urlAttachmentTitleMaxLine;
TextOverflow? overflow;
if (maxLines != null && maxLines > 0) {
overflow = TextOverflow.ellipsis;
}
return Text(
urlAttachment.title!.trim(),
maxLines: maxLines,
overflow: overflow,
style: messageTheme.urlAttachmentTitleStyle,
);
}),
if (urlAttachment.text != null)
Builder(builder: (context) {
final maxLines = messageTheme.urlAttachmentTextMaxLine;
TextOverflow? overflow;
if (maxLines != null && maxLines > 0) {
overflow = TextOverflow.ellipsis;
}
return Text(
urlAttachment.text!,
maxLines: maxLines,
overflow: overflow,
style: messageTheme.urlAttachmentTextStyle,
);
}),
].insertBetween(const SizedBox(height: 4)),
),
),
],
),
);
}
@@ -1,123 +1,72 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/video_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamVideoAttachment}
/// Shows a video attachment in a [StreamMessageWidget].
/// {@endtemplate}
class StreamVideoAttachment extends StreamAttachmentWidget {
class StreamVideoAttachment extends StatelessWidget {
/// {@macro streamVideoAttachment}
const StreamVideoAttachment({
super.key,
required super.message,
required super.attachment,
required this.messageTheme,
super.constraints,
this.onShowMessage,
this.onReplyMessage,
this.onAttachmentTap,
this.attachmentActionsModalBuilder,
required this.message,
required this.video,
this.shape,
this.constraints = const BoxConstraints(),
});
/// The [StreamMessageThemeData] to use for the title
final StreamMessageThemeData messageTheme;
/// The [Message] that the video is attached to.
final Message message;
/// {@macro showMessageCallback}
final ShowMessageCallback? onShowMessage;
/// The [Attachment] object containing the video information.
final Attachment video;
/// {@macro replyMessageCallback}
final ReplyMessageCallback? onReplyMessage;
/// The shape of the attachment.
///
/// Defaults to [RoundedRectangleBorder] with a radius of 14.
final ShapeBorder? shape;
/// {@macro onAttachmentTap}
final OnAttachmentTap? onAttachmentTap;
/// {@macro attachmentActionsBuilder}
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
/// The constraints to use when displaying the video.
final BoxConstraints constraints;
@override
Widget build(BuildContext context) {
return source.when(
local: () {
if (attachment.file == null) {
return AttachmentError(constraints: constraints);
}
return _buildVideoAttachment(
context,
StreamVideoThumbnailImage(
video: attachment.file!.path,
thumbUrl: attachment.thumbUrl,
constraints: constraints,
final chatTheme = StreamChatTheme.of(context);
final colorTheme = chatTheme.colorTheme;
final shape = this.shape ??
RoundedRectangleBorder(
side: BorderSide(
color: colorTheme.borders,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(14),
);
},
network: () {
if (attachment.assetUrl == null) {
return AttachmentError(constraints: constraints);
}
return _buildVideoAttachment(
context,
StreamVideoThumbnailImage(
video: attachment.assetUrl,
thumbUrl: attachment.thumbUrl,
constraints: constraints,
),
);
},
);
}
Widget _buildVideoAttachment(BuildContext context, Widget videoWidget) {
return ConstrainedBox(
constraints: constraints ?? const BoxConstraints.expand(),
child: Column(
children: <Widget>[
Expanded(
child: GestureDetector(
onTap: onAttachmentTap ??
() async {
if (attachment.uploadState == const UploadState.success()) {
final channel = StreamChannel.of(context).channel;
await Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => StreamChannel(
channel: channel,
child: StreamFullScreenMediaBuilder(
mediaAttachmentPackages:
message.getAttachmentPackageList(),
startIndex:
message.attachments.indexOf(attachment),
userName: message.user!.name,
onShowMessage: onShowMessage,
onReplyMessage: onReplyMessage,
attachmentActionsModalBuilder:
attachmentActionsModalBuilder,
),
),
),
);
}
},
child: Stack(
children: [
videoWidget,
const Center(
child: Material(
shape: CircleBorder(),
child: Padding(
padding: EdgeInsets.all(16),
child: Icon(Icons.play_arrow),
),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: StreamAttachmentUploadStateBuilder(
message: message,
attachment: attachment,
),
),
],
),
return Container(
constraints: constraints,
clipBehavior: Clip.hardEdge,
decoration: ShapeDecoration(shape: shape),
child: Stack(
alignment: Alignment.center,
children: [
StreamVideoAttachmentThumbnail(
video: video,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
),
const Material(
shape: CircleBorder(),
child: Padding(
padding: EdgeInsets.all(16),
child: Icon(Icons.play_arrow),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: StreamAttachmentUploadStateBuilder(
message: message,
attachment: video,
),
),
],