refactor!: initial attachments refactor.
Signed-off-by: xsahil03x <[email protected]>
This commit is contained in:
@@ -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!);
|
||||
}
|
||||
}
|
||||
+168
@@ -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;
|
||||
}
|
||||
}
|
||||
+33
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+152
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+118
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+78
@@ -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);
|
||||
}
|
||||
}
|
||||
+100
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+245
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
+126
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user