From f246de55a1358a1fd9042cad34913fde6eec8873 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Tue, 11 Jul 2023 15:56:42 +0530 Subject: [PATCH] refactor!: initial attachments refactor. Signed-off-by: xsahil03x --- .../lib/src/core/models/attachment.dart | 45 +- .../lib/src/core/models/attachment.g.dart | 4 + .../core/models/attachment_giphy_info.dart | 69 +++ .../lib/src/core/models/message.dart | 3 + packages/stream_chat/lib/stream_chat.dart | 1 + .../test/src/client/client_test.dart | 2 +- .../lib/src/attachment/attachment.dart | 1 - .../attachment_upload_state_builder.dart | 2 + .../lib/src/attachment/attachment_widget.dart | 57 --- .../attachment/attachment_widget_catalog.dart | 62 +++ .../builder/attachment_widget_builder.dart | 168 +++++++ .../builder/fallback_attachment_builder.dart | 33 ++ .../builder/file_attachment_builder.dart | 87 ++++ .../builder/gallery_attachment_builder.dart | 152 +++++++ .../builder/giphy_attachment_builder.dart | 73 +++ .../builder/image_attachment_builder.dart | 72 +++ .../builder/mixed_attachment_builder.dart | 118 +++++ .../builder/url_attachment_builder.dart | 104 +++++ .../builder/video_attachment_builder.dart | 72 +++ .../lib/src/attachment/file_attachment.dart | 276 +++++------- .../src/attachment/gallery_attachment.dart | 261 +++++++++++ .../lib/src/attachment/giphy_attachment.dart | 422 +++++------------- .../lib/src/attachment/image_attachment.dart | 240 ++++------ .../lib/src/attachment/image_group.dart | 181 -------- .../thumbnail/file_attachment_thumbnail.dart | 78 ++++ .../thumbnail/giphy_attachment_thumbnail.dart | 100 +++++ .../thumbnail/image_attachment_thumbnail.dart | 245 ++++++++++ .../attachment/thumbnail/thumbnail_error.dart | 40 ++ .../thumbnail/video_attachment_thumbnail.dart | 126 ++++++ .../lib/src/attachment/url_attachment.dart | 250 +++++------ .../lib/src/attachment/video_attachment.dart | 149 ++----- .../fullscreen_media/full_screen_media.dart | 72 +-- .../lib/src/gallery/gallery_footer.dart | 6 +- .../lib/src/indicators/loading_indicator.dart | 21 + .../message_input/quoted_message_widget.dart | 56 ++- .../stream_message_input_attachment_list.dart | 8 +- .../message_list_view/message_list_view.dart | 14 +- .../lib/src/message_list_view/mlv_utils.dart | 1 - .../src/message_widget/ephemeral_message.dart | 43 ++ .../giphy_ephemeral_message.dart | 232 ++++++++++ .../lib/src/message_widget/message_card.dart | 221 +++++---- .../src/message_widget/message_widget.dart | 363 +++++++-------- .../message_widget_content.dart | 24 +- .../src/message_widget/parse_attachments.dart | 131 ++++-- .../src/message_widget/quoted_message.dart | 13 +- .../lib/src/misc/flex_grid.dart | 259 +++++++++++ .../lib/src/misc/giphy_chip.dart | 40 ++ .../lib/src/misc/visible_footnote.dart | 7 +- .../lib/src/stream_chat_configuration.dart | 11 +- .../lib/src/theme/color_theme.dart | 6 +- .../lib/src/utils/extensions.dart | 13 + .../lib/src/utils/helpers.dart | 22 +- .../lib/src/utils/typedefs.dart | 9 - .../lib/src/video/video_service.dart | 2 + .../lib/src/video/video_thumbnail_image.dart | 257 +++++------ .../lib/stream_chat_flutter.dart | 3 +- packages/stream_chat_flutter/pubspec.yaml | 1 + .../src/attachment/file_attachment_test.dart | 2 +- .../src/attachment/giphy_attachment_test.dart | 2 +- .../src/attachment/image_attachment_test.dart | 3 +- .../test/src/attachment/image_group_test.dart | 5 +- 61 files changed, 3628 insertions(+), 1712 deletions(-) create mode 100644 packages/stream_chat/lib/src/core/models/attachment_giphy_info.dart delete mode 100644 packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/attachment_widget_catalog.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/fallback_attachment_builder.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/file_attachment_builder.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/gallery_attachment_builder.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/giphy_attachment_builder.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/image_attachment_builder.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/url_attachment_builder.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/builder/video_attachment_builder.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/gallery_attachment.dart delete mode 100644 packages/stream_chat_flutter/lib/src/attachment/image_group.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/thumbnail/file_attachment_thumbnail.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_error.dart create mode 100644 packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart create mode 100644 packages/stream_chat_flutter/lib/src/indicators/loading_indicator.dart create mode 100644 packages/stream_chat_flutter/lib/src/message_widget/ephemeral_message.dart create mode 100644 packages/stream_chat_flutter/lib/src/message_widget/giphy_ephemeral_message.dart create mode 100644 packages/stream_chat_flutter/lib/src/misc/flex_grid.dart create mode 100644 packages/stream_chat_flutter/lib/src/misc/giphy_chip.dart diff --git a/packages/stream_chat/lib/src/core/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart index bceaaacc..b53e73b1 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -10,13 +10,24 @@ import 'package:uuid/uuid.dart'; part 'attachment.g.dart'; +mixin AttachmentType { + /// Backend specified types. + static const image = 'image'; + static const file = 'file'; + static const giphy = 'giphy'; + static const video = 'video'; + + /// Application custom types. + static const urlPreview = 'url_preview'; +} + /// The class that contains the information about an attachment @JsonSerializable(includeIfNull: false) class Attachment extends Equatable { /// Constructor used for json serialization Attachment({ String? id, - this.type, + String? type, this.titleLink, String? title, this.thumbUrl, @@ -33,14 +44,16 @@ class Attachment extends Equatable { this.authorLink, this.authorIcon, this.assetUrl, - List? actions, + this.actions = const [], + this.originalWidth, + this.originalHeight, Map extraData = const {}, this.file, UploadState? uploadState, }) : id = id ?? const Uuid().v4(), + _type = type, title = title ?? file?.name, localUri = file?.path != null ? Uri.parse(file!.path!) : null, - actions = actions ?? [], // For backwards compatibility, // set 'file_size', 'mime_type' in [extraData]. extraData = { @@ -84,7 +97,15 @@ class Attachment extends Equatable { ///The attachment type based on the URL resource. This can be: audio, ///image or video - final String? type; + String? get type { + if (_type == AttachmentType.image && titleLink != null) { + return AttachmentType.urlPreview; + } + + return _type; + } + + final String? _type; ///The link to which the attachment message points to. final String? titleLink; @@ -126,6 +147,12 @@ class Attachment extends Equatable { /// Actions from a command final List? actions; + /// The original width of the attached image. + final int? originalWidth; + + /// The original height of the attached image. + final int? originalHeight; + final Uri? localUri; /// The file present inside this attachment. @@ -175,6 +202,8 @@ class Attachment extends Equatable { 'author_icon', 'asset_url', 'actions', + 'original_width', + 'original_height', ]; /// Known db specific top level fields. @@ -214,6 +243,8 @@ class Attachment extends Equatable { String? authorIcon, String? assetUrl, List? actions, + int? originalWidth, + int? originalHeight, AttachmentFile? file, UploadState? uploadState, Map? extraData, @@ -238,6 +269,8 @@ class Attachment extends Equatable { authorIcon: authorIcon ?? this.authorIcon, assetUrl: assetUrl ?? this.assetUrl, actions: actions ?? this.actions, + originalWidth: originalWidth ?? this.originalWidth, + originalHeight: originalHeight ?? this.originalHeight, file: file ?? this.file, uploadState: uploadState ?? this.uploadState, extraData: extraData ?? this.extraData, @@ -264,6 +297,8 @@ class Attachment extends Equatable { authorIcon: other.authorIcon, assetUrl: other.assetUrl, actions: other.actions, + originalWidth: other.originalWidth, + originalHeight: other.originalHeight, file: other.file, uploadState: other.uploadState, extraData: other.extraData, @@ -291,6 +326,8 @@ class Attachment extends Equatable { authorIcon, assetUrl, actions, + originalWidth, + originalHeight, file, uploadState, extraData, diff --git a/packages/stream_chat/lib/src/core/models/attachment.g.dart b/packages/stream_chat/lib/src/core/models/attachment.g.dart index 2ebe8c53..38a8be16 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.g.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.g.dart @@ -28,6 +28,8 @@ Attachment _$AttachmentFromJson(Map json) => Attachment( actions: (json['actions'] as List?) ?.map((e) => Action.fromJson(e as Map)) .toList(), + originalWidth: json['original_width'] as int?, + originalHeight: json['original_height'] as int?, extraData: json['extra_data'] as Map? ?? const {}, file: json['file'] == null ? null @@ -64,6 +66,8 @@ Map _$AttachmentToJson(Attachment instance) { writeNotNull('author_icon', instance.authorIcon); writeNotNull('asset_url', instance.assetUrl); writeNotNull('actions', instance.actions?.map((e) => e.toJson()).toList()); + writeNotNull('original_width', instance.originalWidth); + writeNotNull('original_height', instance.originalHeight); writeNotNull('file', instance.file?.toJson()); val['upload_state'] = instance.uploadState.toJson(); val['extra_data'] = instance.extraData; diff --git a/packages/stream_chat/lib/src/core/models/attachment_giphy_info.dart b/packages/stream_chat/lib/src/core/models/attachment_giphy_info.dart new file mode 100644 index 00000000..bc5d59a3 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/attachment_giphy_info.dart @@ -0,0 +1,69 @@ +import 'package:stream_chat/src/core/models/attachment.dart'; + +/// {@template giphy_info_type} +/// The different types of quality for a Giphy attachment. +/// {@endtemplate} +enum GiphyInfoType { + /// Original quality giphy, the largest size to load. + original('original'), + + /// Lower quality with a fixed height, adjusts width according to the + /// Giphy aspect ratio. Lower size than [original]. + fixedHeight('fixed_height'), + + /// Still image of the [fixedHeight] giphy. + fixedHeightStill('fixed_height_still'), + + /// Lower quality with a fixed height with width adjusted according to the + /// aspect ratio and played at a lower frame rate. Significantly lower size, + /// but visually less appealing. + fixedHeightDownsampled('fixed_height_downsampled'); + + /// {@macro giphy_info_type} + const GiphyInfoType(this.value); + + /// The value of the [GiphyInfoType]. + final String value; +} + +/// {@template giphy_info} +/// A class that contains extra information about a Giphy attachment. +/// {@endtemplate} +class GiphyInfo { + /// {@macro giphy_info} + const GiphyInfo({ + required this.url, + required this.width, + required this.height, + }); + + /// The url for the Giphy image. + final String url; + + /// The width of the Giphy image. + final double width; + + /// The height of the Giphy image. + final double height; + + @override + String toString() => 'GiphyInfo{url: $url, width: $width, height: $height}'; +} + +/// GiphyInfo extension on [Attachment] class. +extension GiphyInfoX on Attachment { + /// Returns the [GiphyInfo] for the given [type]. + GiphyInfo? giphyInfo(GiphyInfoType type) { + final giphy = extraData['giphy'] as Map?; + if (giphy == null) return null; + + final info = giphy[type.value] as Map?; + if (info == null) return null; + + return GiphyInfo( + url: info['url']! as String, + width: double.parse(info['width']! as String), + height: double.parse(info['height']! as String), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index a6f88033..958ae425 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -215,6 +215,9 @@ class Message extends Equatable { /// Message custom extraData. final Map extraData; + /// True if the message is a error. + bool get isError => type == 'error'; + /// True if the message is a system info. bool get isSystem => type == 'system'; diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 71a217dc..7e843412 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -28,6 +28,7 @@ export 'src/core/http/interceptor/logging_interceptor.dart'; export 'src/core/models/action.dart'; export 'src/core/models/attachment.dart'; export 'src/core/models/attachment_file.dart'; +export 'src/core/models/attachment_giphy_info.dart'; export 'src/core/models/channel_config.dart'; export 'src/core/models/channel_model.dart'; export 'src/core/models/channel_mute.dart'; diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index df167b23..96501574 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -805,7 +805,7 @@ void main() { emits(ConnectionStatus.disconnected), ); - await client.disconnectUser(); + await client.disconnectUser(flushChatPersistence: true); expect(client.state.currentUser, isNull); expect(client.wsConnectionStatus, ConnectionStatus.disconnected); diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment.dart index 721d1dea..2fb98820 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment.dart @@ -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'; diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart index e05ba397..fcedd012 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_upload_state_builder.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, ), diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart deleted file mode 100644 index 182d5aea..00000000 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_widget.dart +++ /dev/null @@ -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({ - 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); -} diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_widget_catalog.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget_catalog.dart new file mode 100644 index 00000000..976fa32f --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget_catalog.dart @@ -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 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 { + /// Groups the attachments by their type. + Map> get grouped { + return groupBy(this, (attachment) => attachment.type!); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart new file mode 100644 index 00000000..0abf4750 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart @@ -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 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> 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> 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> attachments, + ) { + assert(() { + if (!canHandle(message, attachments)) { + throw FlutterError.fromParts([ + 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; + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/fallback_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/fallback_attachment_builder.dart new file mode 100644 index 00000000..0226682f --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/fallback_attachment_builder.dart @@ -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> 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> 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(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/file_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/file_attachment_builder.dart new file mode 100644 index 00000000..5a6a5b58 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/file_attachment_builder.dart @@ -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> attachments, + ) { + final files = attachments[AttachmentType.file]; + return files != null && files.isNotEmpty; + } + + @override + Widget build( + BuildContext context, + Message message, + Map> 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: [ + 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, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/gallery_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/gallery_attachment_builder.dart new file mode 100644 index 00000000..9b86749d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/gallery_attachment_builder.dart @@ -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> 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> 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, + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/giphy_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/giphy_attachment_builder.dart new file mode 100644 index 00000000..2220ae92 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/giphy_attachment_builder.dart @@ -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> attachments, + ) { + final giphyAttachments = attachments[AttachmentType.giphy]; + return giphyAttachments != null && giphyAttachments.length == 1; + } + + @override + Widget build( + BuildContext context, + Message message, + Map> 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, + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/image_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/image_attachment_builder.dart new file mode 100644 index 00000000..145649db --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/image_attachment_builder.dart @@ -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> attachments, + ) { + final images = attachments[AttachmentType.image]; + return images != null && images.length == 1; + } + + @override + Widget build( + BuildContext context, + Message message, + Map> 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, + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart new file mode 100644 index 00000000..b0eb7844 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart @@ -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> 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> 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: [ + 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), + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/url_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/url_attachment_builder.dart new file mode 100644 index 00000000..3d073643 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/url_attachment_builder.dart @@ -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> attachments, + ) { + final urls = attachments[AttachmentType.urlPreview]; + return urls != null && urls.isNotEmpty; + } + + @override + Widget build( + BuildContext context, + Message message, + Map> 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: [ + 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, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/builder/video_attachment_builder.dart b/packages/stream_chat_flutter/lib/src/attachment/builder/video_attachment_builder.dart new file mode 100644 index 00000000..45f92ef8 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/video_attachment_builder.dart @@ -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> attachments, + ) { + final videos = attachments[AttachmentType.video]; + if (videos != null && videos.length == 1) return true; + + return false; + } + + @override + Widget build( + BuildContext context, + Message message, + Map> 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, + ), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart index 9e9d9e55..964b3034 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/file_attachment.dart @@ -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, ), diff --git a/packages/stream_chat_flutter/lib/src/attachment/gallery_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/gallery_attachment.dart new file mode 100644 index 00000000..76106eb3 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/gallery_attachment.dart @@ -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 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 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 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 attachments) { + final pattern = >[]; + final children = []; + + 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, + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart index 85997b64..f8ba4e04 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart @@ -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: [ + 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 _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 _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, +// ), +// ); +// }, +// ), +// ); +// } } diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart index 36a0df3a..386eb2df 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/image_attachment.dart @@ -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: [ - 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 _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, +// ), +// ), +// ), +// ); +// } +// } diff --git a/packages/stream_chat_flutter/lib/src/attachment/image_group.dart b/packages/stream_chat_flutter/lib/src/attachment/image_group.dart deleted file mode 100644 index 5756b24a..00000000 --- a/packages/stream_chat_flutter/lib/src/attachment/image_group.dart +++ /dev/null @@ -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 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: [ - 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: [ - 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: [ - _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 _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, - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/file_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/file_attachment_thumbnail.dart new file mode 100644 index 00000000..996d3c4d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/file_attachment_thumbnail.dart @@ -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); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart new file mode 100644 index 00000000..98b8e026 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart @@ -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, + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart new file mode 100644 index 00000000..2d04c0fa --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart @@ -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, + ); + }, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_error.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_error.dart new file mode 100644 index 00000000..e1387403 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_error.dart @@ -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', + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart new file mode 100644 index 00000000..ac4da7fa --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart @@ -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, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart index a628ee60..b47f573f 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/url_attachment.dart @@ -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: [ - 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: [ + 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)), + ), + ), + ], ), ); } diff --git a/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart index 0d5d4ecd..bcbcd2f0 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/video_attachment.dart @@ -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: [ - 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, ), ), ], diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart index 2a791954..1b0d82b7 100644 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart +++ b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media.dart @@ -1,14 +1,13 @@ import 'dart:async'; import 'dart:io'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:chewie/chewie.dart'; import 'package:contextmenu/contextmenu.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; -import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart'; import 'package:stream_chat_flutter/src/context_menu_items/download_menu_item.dart'; import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -73,7 +72,7 @@ class _FullScreenMediaState extends State { _pageController = PageController(initialPage: widget.startIndex); for (var i = 0; i < widget.mediaAttachmentPackages.length; i++) { final attachment = widget.mediaAttachmentPackages[i].attachment; - if (attachment.type != 'video') continue; + if (attachment.type != AttachmentType.video) continue; final package = VideoPackage(attachment, showControls: true); videoPackages[attachment.id] = package; } @@ -92,7 +91,8 @@ class _FullScreenMediaState extends State { (it) => it.initialize(), )); - if (widget.autoplayVideos && currentAttachment.type == 'video') { + if (widget.autoplayVideos && + currentAttachment.type == AttachmentType.video) { final package = videoPackages.values .firstWhere((e) => e._attachment == currentAttachment); package._chewieController?.play(); @@ -281,10 +281,8 @@ class _FullScreenMediaState extends State { final currentAttachmentPackage = widget.mediaAttachmentPackages[index]; final attachment = currentAttachmentPackage.attachment; - if (attachment.type == 'image' || attachment.type == 'giphy') { - final imageUrl = attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl; + if (attachment.type == AttachmentType.image || + attachment.type == AttachmentType.giphy) { return ValueListenableBuilder( valueListenable: _isDisplayingDetail, builder: (context, isDisplayingDetail, _) => @@ -300,32 +298,40 @@ class _FullScreenMediaState extends State { attachment: attachment, ), ], - child: PhotoView( - imageProvider: (imageUrl == null && - attachment.localUri != null && - attachment.file?.bytes != null) - ? Image.memory(attachment.file!.bytes!).image - : CachedNetworkImageProvider(imageUrl!), - errorBuilder: (_, __, ___) => const AttachmentError(), - loadingBuilder: (context, _) { - final image = Image.asset( - 'images/placeholder.png', - fit: BoxFit.cover, - package: 'stream_chat_flutter', - ); - final colorTheme = - StreamChatTheme.of(context).colorTheme; - return Shimmer.fromColors( - baseColor: colorTheme.disabled, - highlightColor: colorTheme.inputBg, - child: image, - ); - }, + child: PhotoView.customChild( + heroAttributes: PhotoViewHeroAttributes( + tag: attachment.id, + ), + // imageProvider: (imageUrl == null && + // attachment.localUri != null && + // attachment.file?.bytes != null) + // ? Image.memory(attachment.file!.bytes!).image + // : CachedNetworkImageProvider(imageUrl!), + // errorBuilder: (_, __, ___) => const AttachmentError(), + // loadingBuilder: (context, _) { + // final image = Image.asset( + // 'images/placeholder.png', + // fit: BoxFit.cover, + // package: 'stream_chat_flutter', + // ); + // final colorTheme = + // StreamChatTheme.of(context).colorTheme; + // return Shimmer.fromColors( + // baseColor: colorTheme.disabled, + // highlightColor: colorTheme.inputBg, + // child: image, + // ); + // }, + child: StreamImageAttachmentThumbnail( + image: attachment, + width: double.infinity, + height: double.infinity, + ), maxScale: PhotoViewComputedScale.covered, minScale: PhotoViewComputedScale.contained, - heroAttributes: PhotoViewHeroAttributes( - tag: widget.mediaAttachmentPackages, - ), + // heroAttributes: PhotoViewHeroAttributes( + // tag: widget.mediaAttachmentPackages, + // ), backgroundDecoration: const BoxDecoration( color: Colors.transparent, ), @@ -333,7 +339,7 @@ class _FullScreenMediaState extends State { ), ), ); - } else if (attachment.type == 'video') { + } else if (attachment.type == AttachmentType.video) { final controller = videoPackages[attachment.id]!; if (!controller.initialized) { return const Center( diff --git a/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart b/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart index e7384fd9..f63a7fb7 100644 --- a/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart +++ b/packages/stream_chat_flutter/lib/src/gallery/gallery_footer.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/video_attachment_thumbnail.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamGalleryFooter} @@ -224,9 +225,8 @@ class _StreamGalleryFooterState extends State { onTap: () => widget.mediaSelectedCallBack!(index), child: AspectRatio( aspectRatio: 1, - child: StreamVideoThumbnailImage( - video: - attachment.file?.path ?? attachment.assetUrl, + child: StreamVideoAttachmentThumbnail( + video: attachment, ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/indicators/loading_indicator.dart b/packages/stream_chat_flutter/lib/src/indicators/loading_indicator.dart new file mode 100644 index 00000000..cc42af63 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/indicators/loading_indicator.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; + +/// {@template streamProgressIndicator} +/// A simple progress indicator that can be used in place of the default +/// [CircularProgressIndicator] in the Stream Chat widgets. +/// {@endtemplate} +class StreamLoadingIndicator extends StatelessWidget { + /// {@macro streamProgressIndicator} + const StreamLoadingIndicator({super.key}); + + @override + Widget build(BuildContext context) { + final color = StreamChatTheme.of(context).colorTheme.accentPrimary; + return CircularProgressIndicator.adaptive( + strokeWidth: 2, + backgroundColor: color, + valueColor: AlwaysStoppedAnimation(color), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart b/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart index 3bec1975..60238301 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/quoted_message_widget.dart @@ -1,6 +1,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/video_attachment_thumbnail.dart'; import 'package:stream_chat_flutter/src/message_input/clear_input_item_button.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:video_player/video_player.dart'; @@ -17,6 +18,7 @@ class StreamQuotedMessageWidget extends StatelessWidget { this.reverse = false, this.showBorder = false, this.textLimit = 170, + this.textBuilder, this.attachmentThumbnailBuilders, this.padding = const EdgeInsets.all(8), this.onQuotedMessageClear, @@ -47,6 +49,9 @@ class StreamQuotedMessageWidget extends StatelessWidget { /// Callback for clearing quoted messages. final VoidCallback? onQuotedMessageClear; + /// {@macro textBuilder} + final Widget Function(BuildContext, Message)? textBuilder; + @override Widget build(BuildContext context) { final children = [ @@ -57,6 +62,7 @@ class StreamQuotedMessageWidget extends StatelessWidget { messageTheme: messageTheme, showBorder: showBorder, reverse: reverse, + textBuilder: textBuilder, onQuotedMessageClear: onQuotedMessageClear, attachmentThumbnailBuilders: attachmentThumbnailBuilders, ), @@ -90,6 +96,7 @@ class _QuotedMessage extends StatelessWidget { required this.messageTheme, required this.showBorder, required this.reverse, + this.textBuilder, this.onQuotedMessageClear, this.attachmentThumbnailBuilders, }); @@ -100,6 +107,7 @@ class _QuotedMessage extends StatelessWidget { final StreamMessageThemeData messageTheme; final bool showBorder; final bool reverse; + final Widget Function(BuildContext, Message)? textBuilder; /// Map that defines a thumbnail builder for an attachment type final Map? @@ -158,20 +166,23 @@ class _QuotedMessage extends StatelessWidget { ), if (msg.text!.isNotEmpty && !_isGiphy) Flexible( - child: StreamMessageText( - message: msg, - messageTheme: isOnlyEmoji && _containsText - ? messageTheme.copyWith( - messageTextStyle: messageTheme.messageTextStyle?.copyWith( - fontSize: 32, - ), - ) - : messageTheme.copyWith( - messageTextStyle: messageTheme.messageTextStyle?.copyWith( - fontSize: 12, - ), - ), - ), + child: textBuilder?.call(context, msg) ?? + StreamMessageText( + message: msg, + messageTheme: isOnlyEmoji && _containsText + ? messageTheme.copyWith( + messageTextStyle: + messageTheme.messageTextStyle?.copyWith( + fontSize: 32, + ), + ) + : messageTheme.copyWith( + messageTextStyle: + messageTheme.messageTextStyle?.copyWith( + fontSize: 12, + ), + ), + ), ), ].insertBetween(const SizedBox(width: 8)); } @@ -268,20 +279,21 @@ class _ParseAttachments extends StatelessWidget { final builders = { 'image': (_, attachment) { return StreamImageAttachment( - attachment: attachment, message: message, - messageTheme: messageTheme, + image: attachment, constraints: BoxConstraints.loose(const Size(32, 32)), ); }, 'video': (_, attachment) { - return StreamVideoThumbnailImage( + return StreamVideoAttachmentThumbnail( key: ValueKey(attachment.assetUrl), - video: attachment.file?.path ?? attachment.assetUrl, - constraints: BoxConstraints.loose(const Size(32, 32)), - errorBuilder: (_, __) => AttachmentError( - constraints: BoxConstraints.loose(const Size(32, 32)), - ), + video: attachment, + width: 32, + height: 32, + // constraints: BoxConstraints.loose(const Size(32, 32)), + // errorBuilder: (_, __) => AttachmentError( + // constraints: BoxConstraints.loose(const Size(32, 32)), + // ), ); }, 'giphy': (_, attachment) { diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_attachment_list.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_attachment_list.dart index 46ebbd85..cfb9e25c 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_attachment_list.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input_attachment_list.dart @@ -7,6 +7,8 @@ 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'; +import '../attachment/thumbnail/video_attachment_thumbnail.dart'; + /// WidgetBuilder used to build the message input attachment list. /// /// see more: @@ -206,7 +208,7 @@ class MessageInputFileAttachments extends StatelessWidget { borderRadius: BorderRadius.circular(10), child: StreamFileAttachment( message: Message(), // dummy message - attachment: attachment, + file: attachment, constraints: BoxConstraints.loose(Size( MediaQuery.of(context).size.width * 0.65, 56, @@ -333,9 +335,7 @@ class MessageInputMediaAttachmentThumbnail extends StatelessWidget { case 'video': return Stack( children: [ - StreamVideoThumbnailImage( - video: attachment.file?.path ?? attachment.assetUrl, - ), + StreamVideoAttachmentThumbnail(video: attachment), Positioned( left: 8, bottom: 10, diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart index b08b7ecf..ee85693a 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/message_list_view.dart @@ -10,6 +10,7 @@ import 'package:stream_chat_flutter/src/message_list_view/loading_indicator.dart import 'package:stream_chat_flutter/src/message_list_view/mlv_utils.dart'; import 'package:stream_chat_flutter/src/message_list_view/thread_separator.dart'; import 'package:stream_chat_flutter/src/message_list_view/unread_messages_separator.dart'; +import 'package:stream_chat_flutter/src/message_widget/ephemeral_message.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Spacing Types (These are properties of a message to help inform the decision @@ -1047,7 +1048,7 @@ class _StreamMessageListViewState extends State { } Widget buildMessage(Message message, List messages, int index) { - if ((message.type == 'system' || message.type == 'error') && + if ((message.isSystem || message.isError) && message.text?.isNotEmpty == true) { return widget.systemMessageBuilder?.call(context, message) ?? StreamSystemMessage( @@ -1059,6 +1060,17 @@ class _StreamMessageListViewState extends State { ); } + if (message.isEphemeral) { + // return widget.ephemeralMessageBuilder?.call(context, message) ?? + return StreamEphemeralMessage( + message: message, + // onMessageTap: (message) { + // widget.onEphemeralMessageTap?.call(message); + // FocusScope.of(context).unfocus(); + // }, + ); + } + final userId = StreamChat.of(context).currentUser!.id; final isMyMessage = message.user?.id == userId; final nextMessage = index - 1 >= 0 ? messages[index - 1] : null; diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart index f926eff0..9524cef9 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/mlv_utils.dart @@ -77,7 +77,6 @@ bool isInitialMessage(String id, StreamChannelState? channelState) { /// Converts a [ValueListenable] to a [Stream]. Stream valueListenableToStreamAdapter(ValueListenable listenable) { - // ignore: close_sinks late StreamController _controller; void listener() { diff --git a/packages/stream_chat_flutter/lib/src/message_widget/ephemeral_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/ephemeral_message.dart new file mode 100644 index 00000000..9c76a4fa --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/ephemeral_message.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/message_widget/giphy_ephemeral_message.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// {@template streamEphemeralMessage} +/// Shows an ephemeral message in a [MessageWidget]. +/// {@endtemplate} +class StreamEphemeralMessage extends StatelessWidget { + /// {@macro streamEphemeralMessage} + const StreamEphemeralMessage({ + super.key, + required this.message, + }); + + /// The underlying [Message] object which this widget represents. + final Message message; + + @override + Widget build(BuildContext context) { + final streamChannel = StreamChannel.of(context); + + // If the message is a giphy command, we will show the giphy ephemeral + // message instead. + final isGiphy = message.command == 'giphy'; + if (isGiphy) { + return GiphyEphemeralMessage( + message: message, + onActionPressed: (name, value) { + streamChannel.channel.sendAction( + message, + {name: value}, + ); + }, + ); + } + + // Assert if the message is not handled. + assert(true, 'Ephemeral message not handled, Please add a handler'); + + // Show nothing if we don't know how to handle the message. + return const SizedBox.shrink(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/giphy_ephemeral_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/giphy_ephemeral_message.dart new file mode 100644 index 00000000..16bdcace --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_widget/giphy_ephemeral_message.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/giphy_attachment_thumbnail.dart'; +import 'package:stream_chat_flutter/src/misc/stream_svg_icon.dart'; +import 'package:stream_chat_flutter/src/misc/visible_footnote.dart'; +import 'package:stream_chat_flutter/src/theme/stream_chat_theme.dart'; +import 'package:stream_chat_flutter/src/utils/extensions.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// Signature for the action callback passed to [GiphyEphemeralMessage]. +/// +/// Used by [GiphyEphemeralMessage.onActionPressed]. +typedef GiffyAction = void Function(String name, String value); + +/// {@template giphyEphemeralMessage} +/// Shows an ephemeral message of type giphy in a [MessageWidget]. +/// {@endtemplate} +class GiphyEphemeralMessage extends StatelessWidget { + /// {@macro giphyEphemeralMessage} + const GiphyEphemeralMessage({ + super.key, + required this.message, + this.onActionPressed, + }); + + /// The underlying [Message] object which this widget represents. + final Message message; + + /// Callback called when an action is pressed. + final GiffyAction? onActionPressed; + + @override + Widget build(BuildContext context) { + final giphy = message.attachments.first; + + final actions = giphy.actions; + assert(actions != null && actions.isNotEmpty, 'actions cannot be null'); + + final chatTheme = StreamChatTheme.of(context); + final textTheme = chatTheme.textTheme; + final colorTheme = chatTheme.colorTheme; + + final divider = Divider(thickness: 1, height: 0, color: colorTheme.borders); + + return Padding( + padding: const EdgeInsets.all(8), + child: Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: 304, + height: 343, + child: Column( + children: [ + Expanded( + child: Card( + elevation: 2, + color: colorTheme.barsBg, + margin: EdgeInsets.zero, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: Radius.circular(16), + topLeft: Radius.circular(16), + bottomLeft: Radius.circular(16), + ), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: GiphyHeader(title: giphy.title), + ), + divider, + Expanded( + child: Padding( + padding: const EdgeInsets.all(2), + child: ClipRRect( + borderRadius: BorderRadius.circular(2), + child: StreamGiphyAttachmentThumbnail( + giphy: giphy, + width: double.infinity, + height: double.infinity, + ), + ), + ), + ), + divider, + SizedBox( + height: 48, + child: Padding( + padding: const EdgeInsets.all(2), + child: GiphyActions( + giphy: giphy, + onActionPressed: onActionPressed, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + const StreamVisibleFootnote(), + const SizedBox(width: 4), + Text( + Jiffy.parseFromDateTime(message.createdAt.toLocal()).jm, + style: textTheme.footnote.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +/// {@template giphyActions} +/// Shows the actions for a giphy ephemeral message. +/// {@endtemplate} +class GiphyActions extends StatelessWidget { + /// {@macro giphyActions} + const GiphyActions({ + super.key, + required this.giphy, + required this.onActionPressed, + }); + + /// The underlying [Attachment] object which this widget represents. + final Attachment giphy; + + /// Callback called when an action is pressed. + final GiffyAction? onActionPressed; + + @override + Widget build(BuildContext context) { + final theme = StreamChatTheme.of(context); + final textTheme = theme.textTheme; + final colorTheme = theme.colorTheme; + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Expanded( + child: TextButton( + onPressed: () { + onActionPressed?.call('image_action', 'cancel'); + }, + child: Text( + context.translations.cancelLabel.capitalize(), + style: textTheme.bodyBold.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ), + ), + VerticalDivider(thickness: 1, width: 4, color: colorTheme.borders), + Expanded( + child: TextButton( + onPressed: () { + onActionPressed?.call('image_action', 'shuffle'); + }, + child: Text( + context.translations.shuffleLabel.capitalize(), + style: textTheme.bodyBold.copyWith( + color: colorTheme.textLowEmphasis, + ), + ), + ), + ), + VerticalDivider(thickness: 1, width: 4, color: colorTheme.borders), + Expanded( + child: TextButton( + onPressed: () { + onActionPressed?.call('image_action', 'send'); + }, + child: Text( + context.translations.sendLabel.capitalize(), + style: textTheme.bodyBold.copyWith( + color: colorTheme.accentPrimary, + ), + ), + ), + ), + ], + ); + } +} + +/// {@template giphyHeader} +/// Shows the header for a giphy ephemeral message. +/// {@endtemplate} +class GiphyHeader extends StatelessWidget { + /// {@macro giphyHeader} + const GiphyHeader({super.key, this.title}); + + /// The title of the giphy. + final String? title; + + @override + Widget build(BuildContext context) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + return Row( + children: [ + StreamSvgIcon.giphyIcon(), + const SizedBox(width: 8), + Text( + context.translations.giphyLabel, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 8), + if (title != null) + Expanded( + child: Text( + title!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: colorTheme.textHighEmphasis.withOpacity(0.5), + ), + ), + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart index cd84859f..24da1ac3 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_card.dart @@ -1,6 +1,5 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/attachment/builder/attachment_widget_builder.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template messageCard} @@ -23,6 +22,10 @@ class MessageCard extends StatefulWidget { required this.isGiphy, required this.attachmentBuilders, required this.attachmentPadding, + required this.onAttachmentTap, + required this.onShowMessage, + required this.onReplyTap, + required this.attachmentActionsModalBuilder, required this.textPadding, required this.reverse, this.shape, @@ -72,11 +75,23 @@ class MessageCard extends StatefulWidget { final Message message; /// {@macro attachmentBuilders} - final Map attachmentBuilders; + final List? attachmentBuilders; /// {@macro attachmentPadding} final EdgeInsetsGeometry attachmentPadding; + /// {@macro onAttachmentTap} + final StreamAttachmentWidgetTapCallback? onAttachmentTap; + + /// {@macro onShowMessage} + final ShowMessageCallback? onShowMessage; + + /// {@macro onReplyTap} + final void Function(Message)? onReplyTap; + + /// {@macro attachmentActionsBuilder} + final AttachmentActionsBuilder? attachmentActionsModalBuilder; + /// {@macro textPadding} final EdgeInsets textPadding; @@ -103,32 +118,36 @@ class MessageCard extends StatefulWidget { } class _MessageCardState extends State { - final GlobalKey attachmentsKey = GlobalKey(); - final GlobalKey linksKey = GlobalKey(); + final attachmentsKey = GlobalKey(); double? widthLimit; + bool get hasAttachments { + return widget.hasUrlAttachments || widget.hasNonUrlAttachments; + } + + void _updateWidthLimit() { + final attachmentContext = attachmentsKey.currentContext; + final renderBox = attachmentContext?.findRenderObject() as RenderBox?; + final attachmentsWidth = renderBox?.size.width; + + if (attachmentsWidth == null || attachmentsWidth == 0) return; + + if (mounted) { + setState(() => widthLimit = attachmentsWidth); + } + } + @override - void initState() { - WidgetsBinding.instance.addPostFrameCallback((_) { - final attachmentsRenderBox = - attachmentsKey.currentContext?.findRenderObject() as RenderBox?; - final attachmentsWidth = attachmentsRenderBox?.size.width; - - final linkRenderBox = - linksKey.currentContext?.findRenderObject() as RenderBox?; - final linkWidth = linkRenderBox?.size.width; - - if (mounted) { - setState(() { - if (attachmentsWidth != null && linkWidth != null) { - widthLimit = max(attachmentsWidth, linkWidth); - } else { - widthLimit = attachmentsWidth ?? linkWidth; - } - }); - } - }); - super.initState(); + void didChangeDependencies() { + super.didChangeDependencies(); + // If there is an attachment, we need to wait for the attachment to be + // rendered to get the width of the attachment and set it as the width + // limit of the message card. + if (hasAttachments) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateWidthLimit(); + }); + } } @override @@ -136,95 +155,70 @@ class _MessageCardState extends State { final onQuotedMessageTap = widget.onQuotedMessageTap; final quotedMessageBuilder = widget.quotedMessageBuilder; - return Card( - elevation: 0, - clipBehavior: Clip.hardEdge, + return Container( + constraints: const BoxConstraints().copyWith(maxWidth: widthLimit), margin: EdgeInsets.symmetric( horizontal: (widget.isFailedState ? 15.0 : 0.0) + (widget.showUserAvatar == DisplayWidget.gone ? 0 : 4.0), ), - shape: widget.shape ?? - RoundedRectangleBorder( - side: widget.borderSide ?? - BorderSide( - color: widget.messageTheme.messageBorderColor ?? - Colors.transparent, - ), - borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, + clipBehavior: Clip.hardEdge, + decoration: ShapeDecoration( + color: _getBackgroundColor(), + shape: widget.shape ?? + RoundedRectangleBorder( + side: widget.borderSide ?? + BorderSide( + color: widget.messageTheme.messageBorderColor ?? + Colors.transparent, + ), + borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.hasQuotedMessage) + InkWell( + onTap: !widget.message.quotedMessage!.isDeleted && + onQuotedMessageTap != null + ? () => onQuotedMessageTap(widget.message.quotedMessageId) + : null, + child: quotedMessageBuilder?.call( + context, + widget.message.quotedMessage!, + ) ?? + QuotedMessage( + message: widget.message, + textBuilder: widget.textBuilder, + hasNonUrlAttachments: widget.hasNonUrlAttachments, + ), + ), + if (hasAttachments) + ParseAttachments( + key: attachmentsKey, + message: widget.message, + attachmentBuilders: widget.attachmentBuilders, + attachmentPadding: widget.attachmentPadding, + onAttachmentTap: widget.onAttachmentTap, + onShowMessage: widget.onShowMessage, + onReplyTap: widget.onReplyTap, + attachmentActionsModalBuilder: + widget.attachmentActionsModalBuilder, + ), + TextBubble( + messageTheme: widget.messageTheme, + message: widget.message, + textPadding: widget.textPadding, + textBuilder: widget.textBuilder, + isOnlyEmoji: widget.isOnlyEmoji, + hasQuotedMessage: widget.hasQuotedMessage, + hasUrlAttachments: widget.hasUrlAttachments, + onLinkTap: widget.onLinkTap, + onMentionTap: widget.onMentionTap, ), - color: _getBackgroundColor(), - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: widthLimit ?? double.infinity, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (widget.hasQuotedMessage) - MouseRegion( - cursor: SystemMouseCursors.click, - child: InkWell( - onTap: !widget.message.quotedMessage!.isDeleted && - onQuotedMessageTap != null - ? () => onQuotedMessageTap(widget.message.quotedMessageId) - : null, - child: quotedMessageBuilder?.call( - context, - widget.message.quotedMessage!, - ) ?? - QuotedMessage( - reverse: widget.reverse, - message: widget.message, - hasNonUrlAttachments: widget.hasNonUrlAttachments, - ), - ), - ), - if (widget.hasNonUrlAttachments) - ParseAttachments( - key: attachmentsKey, - message: widget.message, - attachmentBuilders: widget.attachmentBuilders, - attachmentPadding: widget.attachmentPadding, - ), - if (!widget.isGiphy) - TextBubble( - messageTheme: widget.messageTheme, - message: widget.message, - textPadding: widget.textPadding, - textBuilder: widget.textBuilder, - isOnlyEmoji: widget.isOnlyEmoji, - hasQuotedMessage: widget.hasQuotedMessage, - hasUrlAttachments: widget.hasUrlAttachments, - onLinkTap: widget.onLinkTap, - onMentionTap: widget.onMentionTap, - ), - if (widget.hasUrlAttachments && !widget.hasQuotedMessage) - _buildUrlAttachment(), - ], - ), + ], ), - ); - } - - Widget _buildUrlAttachment() { - final urlAttachment = widget.message.attachments - .firstWhere((element) => element.titleLink != null); - - final host = Uri.parse(urlAttachment.titleLink!).host; - final splitList = host.split('.'); - final hostName = splitList.length == 3 ? splitList[1] : splitList[0]; - final hostDisplayName = urlAttachment.authorName?.capitalize() ?? - getWebsiteName(hostName.toLowerCase()) ?? - hostName.capitalize(); - - return StreamUrlAttachment( - key: linksKey, - onLinkTap: widget.onLinkTap, - urlAttachment: urlAttachment, - hostDisplayName: hostDisplayName, - textPadding: widget.textPadding, - messageTheme: widget.messageTheme, ); } @@ -233,7 +227,10 @@ class _MessageCardState extends State { return widget.messageTheme.messageBackgroundColor; } - if (widget.hasUrlAttachments) { + final containsOnlyUrlAttachment = + widget.hasUrlAttachments && !widget.hasNonUrlAttachments; + + if (containsOnlyUrlAttachment) { return widget.messageTheme.urlAttachmentBackgroundColor; } @@ -241,10 +238,6 @@ class _MessageCardState extends State { return Colors.transparent; } - if (widget.isGiphy) { - return Colors.transparent; - } - return widget.messageTheme.messageBackgroundColor; } } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart index d0ac97a9..74835bd6 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget.dart @@ -5,6 +5,7 @@ import 'package:flutter_portal/flutter_portal.dart'; import 'package:meta/meta.dart'; import 'package:stream_chat_flutter/conditional_parent_builder/conditional_parent_builder.dart'; import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart'; +import 'package:stream_chat_flutter/src/attachment/builder/attachment_widget_builder.dart'; import 'package:stream_chat_flutter/src/context_menu_items/context_menu_reaction_picker.dart'; import 'package:stream_chat_flutter/src/context_menu_items/stream_chat_context_menu_item.dart'; import 'package:stream_chat_flutter/src/dialogs/dialogs.dart'; @@ -41,7 +42,7 @@ enum DisplayWidget { /// {@endtemplate} class StreamMessageWidget extends StatefulWidget { /// {@macro messageWidget} - StreamMessageWidget({ + const StreamMessageWidget({ super.key, required this.message, required this.messageTheme, @@ -85,7 +86,7 @@ class StreamMessageWidget extends StatefulWidget { this.editMessageInputBuilder, this.textBuilder, this.bottomRowBuilderWithDefaultWidget, - this.customAttachmentBuilders, + this.attachmentBuilders, this.padding, this.textPadding = const EdgeInsets.symmetric( horizontal: 16, @@ -100,185 +101,157 @@ class StreamMessageWidget extends StatefulWidget { this.imageAttachmentThumbnailResizeType = 'clip', this.imageAttachmentThumbnailCropType = 'center', this.attachmentActionsModalBuilder, - }) : attachmentBuilders = { - 'image': (context, message, attachments) { - final border = RoundedRectangleBorder( - side: attachmentBorderSide ?? - BorderSide( - color: StreamChatTheme.of(context).colorTheme.borders, - ), - borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, - ); + }); - final mediaQueryData = MediaQuery.of(context); - if (attachments.length > 1) { - return Padding( - padding: attachmentPadding, - child: WrapAttachmentWidget( - attachmentWidget: Material( - color: messageTheme.messageBackgroundColor, - child: StreamImageGroup( - constraints: BoxConstraints( - maxWidth: 400, - minWidth: 400, - maxHeight: mediaQueryData.size.height * 0.3, - ), - images: attachments, - message: message, - messageTheme: messageTheme, - onShowMessage: onShowMessage, - onReplyMessage: onReplyTap, - onAttachmentTap: onAttachmentTap, - imageThumbnailSize: imageAttachmentThumbnailSize, - imageThumbnailResizeType: - imageAttachmentThumbnailResizeType, - imageThumbnailCropType: imageAttachmentThumbnailCropType, - attachmentActionsModalBuilder: - attachmentActionsModalBuilder, - ), - ), - attachmentShape: border, - ), - ); - } - - return WrapAttachmentWidget( - attachmentWidget: StreamImageAttachment( - attachment: attachments[0], - message: message, - messageTheme: messageTheme, - constraints: BoxConstraints( - maxWidth: 400, - minWidth: 400, - maxHeight: mediaQueryData.size.height * 0.3, - ), - onShowMessage: onShowMessage, - onReplyMessage: onReplyTap, - onAttachmentTap: onAttachmentTap != null - ? () { - onAttachmentTap.call(message, attachments[0]); - } - : null, - imageThumbnailSize: imageAttachmentThumbnailSize, - imageThumbnailResizeType: imageAttachmentThumbnailResizeType, - imageThumbnailCropType: imageAttachmentThumbnailCropType, - attachmentActionsModalBuilder: attachmentActionsModalBuilder, - ), - attachmentShape: border, - ); - }, - 'video': (context, message, attachments) { - final border = RoundedRectangleBorder( - side: attachmentBorderSide ?? - BorderSide( - color: StreamChatTheme.of(context).colorTheme.borders, - ), - borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, - ); - - return WrapAttachmentWidget( - attachmentWidget: Column( - children: attachments.map((attachment) { - final mediaQueryData = MediaQuery.of(context); - return StreamVideoAttachment( - attachment: attachment, - messageTheme: messageTheme, - constraints: BoxConstraints( - maxWidth: 400, - minWidth: 400, - maxHeight: mediaQueryData.size.height * 0.3, - ), - message: message, - onShowMessage: onShowMessage, - onReplyMessage: onReplyTap, - onAttachmentTap: onAttachmentTap != null - ? () { - onAttachmentTap(message, attachment); - } - : null, - attachmentActionsModalBuilder: - attachmentActionsModalBuilder, - ); - }).toList(), - ), - attachmentShape: border, - ); - }, - 'giphy': (context, message, attachments) { - final attachmentWidget = Column( - children: [ - ...attachments.map((attachment) { - final mediaQueryData = MediaQuery.of(context); - return StreamGiphyAttachment( - attachment: attachment, - message: message, - constraints: BoxConstraints( - maxWidth: 400, - minWidth: 400, - maxHeight: mediaQueryData.size.height * 0.3, - ), - onShowMessage: onShowMessage, - onReplyMessage: onReplyTap, - onAttachmentTap: onAttachmentTap != null - ? () => onAttachmentTap(message, attachment) - : null, - ); - }), - ], - ); - - // If the message is ephemeral, we don't want to show the border. - if (message.isEphemeral) return attachmentWidget; - - final color = StreamChatTheme.of(context).colorTheme.borders; - final border = RoundedRectangleBorder( - side: attachmentBorderSide ?? BorderSide(color: color), - borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, - ); - - return WrapAttachmentWidget( - attachmentShape: border, - attachmentWidget: attachmentWidget, - ); - }, - 'file': (context, message, attachments) { - final border = RoundedRectangleBorder( - side: attachmentBorderSide ?? - BorderSide( - color: StreamChatTheme.of(context).colorTheme.borders, - ), - borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, - ); - - return Column( - children: attachments - .map((attachment) { - final mediaQueryData = MediaQuery.of(context); - return WrapAttachmentWidget( - attachmentWidget: StreamFileAttachment( - message: message, - attachment: attachment, - constraints: BoxConstraints( - maxWidth: 400, - minWidth: 400, - maxHeight: mediaQueryData.size.height * 0.3, - ), - onAttachmentTap: onAttachmentTap != null - ? () { - onAttachmentTap(message, attachment); - } - : null, - ), - attachmentShape: border, - ); - }) - .insertBetween(SizedBox( - height: attachmentPadding.vertical / 2, - )) - .toList(), - ); - }, - }..addAll(customAttachmentBuilders ?? {}); + // attachmentBuilders = { + // // Add all default builders + // 'image': (context, message, attachments) { + // final color = StreamChatTheme.of(context).colorTheme.borders; + // final border = RoundedRectangleBorder( + // side: attachmentBorderSide ?? BorderSide(color: color), + // borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, + // ); + // + // if (attachments.length > 1) { + // return WrapAttachmentWidget( + // attachmentShape: border, + // attachmentWidget: Material( + // color: messageTheme.messageBackgroundColor, + // child: StreamGalleryAttachment( + // constraints: const BoxConstraints.tightFor( + // width: 256, + // height: 195, + // ), + // attachments: attachments, + // message: message, + // itemBuilder: (context, index) { + // return Placeholder(); + // }, + // // onShowMessage: onShowMessage, + // // onReplyMessage: onReplyTap, + // // onAttachmentTap: onAttachmentTap, + // // imageThumbnailSize: imageAttachmentThumbnailSize, + // // imageThumbnailResizeType: + // // imageAttachmentThumbnailResizeType, + // // imageThumbnailCropType: imageAttachmentThumbnailCropType, + // // attachmentActionsModalBuilder: + // // attachmentActionsModalBuilder, + // ), + // ), + // ); + // } + // + // return WrapAttachmentWidget( + // attachmentShape: border, + // attachmentWidget: StreamImageAttachment( + // message: message, + // image: attachments.first, + // constraints: const BoxConstraints( + // minWidth: 170, + // maxWidth: 256, + // minHeight: 100, + // maxHeight: 300, + // ), + // // onShowMessage: onShowMessage, + // // onReplyMessage: onReplyTap, + // imageThumbnailSize: imageAttachmentThumbnailSize, + // imageThumbnailResizeType: imageAttachmentThumbnailResizeType, + // imageThumbnailCropType: imageAttachmentThumbnailCropType, + // // attachmentActionsModalBuilder: attachmentActionsModalBuilder, + // // onAttachmentTap: onAttachmentTap != null + // // ? () => onAttachmentTap.call(message, attachments.first) + // // : null, + // ), + // ); + // }, + // 'video': (context, message, attachments) { + // final color = StreamChatTheme.of(context).colorTheme.borders; + // final border = RoundedRectangleBorder( + // side: attachmentBorderSide ?? BorderSide(color: color), + // borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, + // ); + // + // return WrapAttachmentWidget( + // attachmentShape: border, + // attachmentWidget: Column( + // children: [ + // ...attachments.map((attachment) { + // return StreamVideoAttachment( + // video: attachment, + // constraints: const BoxConstraints.tightFor( + // width: 256, + // height: 195, + // ), + // message: message, + // ); + // }), + // ], + // ), + // ); + // }, + // 'giphy': (context, message, attachments) { + // final color = StreamChatTheme.of(context).colorTheme.borders; + // final border = RoundedRectangleBorder( + // side: attachmentBorderSide ?? BorderSide(color: color), + // borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, + // ); + // + // return WrapAttachmentWidget( + // attachmentShape: border, + // attachmentWidget: Column( + // children: [ + // ...attachments.map((attachment) { + // return StreamGiphyAttachment( + // giphy: attachment, + // message: message, + // constraints: const BoxConstraints( + // minWidth: 170, + // maxWidth: 256, + // minHeight: 100, + // maxHeight: 300, + // ), + // ); + // }), + // ], + // ), + // ); + // }, + // 'file': (context, message, attachments) { + // final color = StreamChatTheme.of(context).colorTheme.borders; + // final border = RoundedRectangleBorder( + // side: attachmentBorderSide ?? BorderSide(color: color), + // borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, + // ); + // + // return Column( + // children: [ + // ...attachments.map((attachment) { + // final mediaQueryData = MediaQuery.of(context); + // return WrapAttachmentWidget( + // attachmentShape: border, + // attachmentWidget: StreamFileAttachment( + // message: message, + // file: attachment, + // constraints: BoxConstraints( + // maxWidth: 400, + // minWidth: 400, + // maxHeight: mediaQueryData.size.height * 0.3, + // ), + // // onAttachmentTap: onAttachmentTap != null + // // ? () => onAttachmentTap(message, attachment) + // // : null, + // ), + // ); + // }).insertBetween( + // SizedBox(height: attachmentPadding.vertical / 2), + // ), + // ], + // ); + // }, + // + // // Add all custom builders, overriding the defaults if needed. + // ...?customAttachmentBuilders, + // }; /// {@template onMentionTap} /// Function called on mention tap @@ -504,14 +477,13 @@ class StreamMessageWidget extends StatefulWidget { final bool showPinHighlight; /// {@template attachmentBuilders} - /// Builder for respective attachment types + /// List of attachment builders for rendering attachment widgets pre-defined + /// and custom attachment types. + /// + /// If null, the widget will create a default list of attachment builders + /// based on the [Attachment.type] of the attachment. /// {@endtemplate} - final Map attachmentBuilders; - - /// {@template customAttachmentBuilders} - /// Builder for respective attachment types (user facing builder) - /// {@endtemplate} - final Map? customAttachmentBuilders; + final List? attachmentBuilders; /// {@template translateUserAvatar} /// Center user avatar with bottom of the message @@ -530,7 +502,7 @@ class StreamMessageWidget extends StatefulWidget { final List customActions; /// {@macro onMessageWidgetAttachmentTap} - final OnMessageWidgetAttachmentTap? onAttachmentTap; + final StreamAttachmentWidgetTapCallback? onAttachmentTap; /// {@macro attachmentActionsBuilder} final AttachmentActionsBuilder? attachmentActionsModalBuilder; @@ -600,7 +572,7 @@ class StreamMessageWidget extends StatefulWidget { bool? showFlagButton, bool? showPinButton, bool? showPinHighlight, - Map? customAttachmentBuilders, + List? attachmentBuilders, bool? translateUserAvatar, OnQuotedMessageTap? onQuotedMessageTap, void Function(Message)? onMessageTap, @@ -664,8 +636,7 @@ class StreamMessageWidget extends StatefulWidget { showFlagButton: showFlagButton ?? this.showFlagButton, showPinButton: showPinButton ?? this.showPinButton, showPinHighlight: showPinHighlight ?? this.showPinHighlight, - customAttachmentBuilders: - customAttachmentBuilders ?? this.customAttachmentBuilders, + attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders, translateUserAvatar: translateUserAvatar ?? this.translateUserAvatar, onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap, onMessageTap: onMessageTap ?? this.onMessageTap, @@ -733,15 +704,14 @@ class _StreamMessageWidgetState extends State /// have a [Attachment.titleLink]. /// {@endtemplate} bool get hasNonUrlAttachments => widget.message.attachments - .where((it) => it.titleLink == null || it.type == 'giphy') - .isNotEmpty; + .any((it) => it.type != AttachmentType.urlPreview); /// {@template hasUrlAttachments} /// `true` if any of the [message]'s attachments are a giphy with a /// [Attachment.titleLink]. /// {@endtemplate} bool get hasUrlAttachments => widget.message.attachments - .any((it) => it.titleLink != null && it.type != 'giphy'); + .any((it) => it.type == AttachmentType.urlPreview); /// {@template showBottomRow} /// Show the [BottomRow] widget if any of the following are `true`: @@ -883,6 +853,11 @@ class _StreamMessageWidgetState extends State textPadding: widget.textPadding, attachmentBuilders: widget.attachmentBuilders, attachmentPadding: widget.attachmentPadding, + onAttachmentTap: widget.onAttachmentTap, + onReplyTap: widget.onReplyTap, + onShowMessage: widget.onShowMessage, + attachmentActionsModalBuilder: + widget.attachmentActionsModalBuilder, avatarWidth: avatarWidth, bottomRowPadding: bottomRowPadding, isFailedState: isFailedState, diff --git a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart index 531bd9d6..e84f91f1 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/message_widget_content.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:meta/meta.dart'; +import 'package:stream_chat_flutter/src/attachment/builder/attachment_widget_builder.dart'; import 'package:stream_chat_flutter/src/message_widget/message_widget_content_components.dart'; import 'package:stream_chat_flutter/src/message_widget/reactions/desktop_reactions_builder.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -47,6 +48,10 @@ class MessageWidgetContent extends StatelessWidget { required this.isGiphy, required this.attachmentBuilders, required this.attachmentPadding, + required this.onAttachmentTap, + required this.onShowMessage, + required this.onReplyTap, + required this.attachmentActionsModalBuilder, required this.textPadding, required this.showReactionPickerTail, required this.translateUserAvatar, @@ -140,11 +145,23 @@ class MessageWidgetContent extends StatelessWidget { final bool isGiphy; /// {@macro attachmentBuilders} - final Map attachmentBuilders; + final List? attachmentBuilders; /// {@macro attachmentPadding} final EdgeInsetsGeometry attachmentPadding; + /// {@macro onAttachmentTap} + final StreamAttachmentWidgetTapCallback? onAttachmentTap; + + /// {@macro onShowMessage} + final ShowMessageCallback? onShowMessage; + + /// {@macro onReplyTap} + final void Function(Message)? onReplyTap; + + /// {@macro attachmentActionsBuilder} + final AttachmentActionsBuilder? attachmentActionsModalBuilder; + /// {@macro textPadding} final EdgeInsets textPadding; @@ -324,6 +341,11 @@ class MessageWidgetContent extends StatelessWidget { isGiphy: isGiphy, attachmentBuilders: attachmentBuilders, attachmentPadding: attachmentPadding, + onAttachmentTap: onAttachmentTap, + onReplyTap: onReplyTap, + onShowMessage: onShowMessage, + attachmentActionsModalBuilder: + attachmentActionsModalBuilder, textPadding: textPadding, reverse: reverse, onQuotedMessageTap: onQuotedMessageTap, diff --git a/packages/stream_chat_flutter/lib/src/message_widget/parse_attachments.dart b/packages/stream_chat_flutter/lib/src/message_widget/parse_attachments.dart index dddf83dd..3477a751 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/parse_attachments.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/parse_attachments.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/attachment/attachment_widget_catalog.dart'; +import 'package:stream_chat_flutter/src/attachment/builder/attachment_widget_builder.dart'; import 'package:stream_chat_flutter/src/message_widget/message_widget_content_components.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -14,57 +16,120 @@ class ParseAttachments extends StatelessWidget { required this.message, required this.attachmentBuilders, required this.attachmentPadding, + this.onAttachmentTap, + this.onShowMessage, + this.onReplyTap, + this.attachmentActionsModalBuilder, }); /// {@macro message} final Message message; /// {@macro attachmentBuilders} - final Map attachmentBuilders; + final List? attachmentBuilders; /// {@macro attachmentPadding} final EdgeInsetsGeometry attachmentPadding; + /// {@macro onAttachmentTap} + final StreamAttachmentWidgetTapCallback? onAttachmentTap; + + /// {@macro onShowMessage} + final ShowMessageCallback? onShowMessage; + + /// {@macro onReplyTap} + final void Function(Message)? onReplyTap; + + /// {@macro attachmentActionsBuilder} + final AttachmentActionsBuilder? attachmentActionsModalBuilder; + @override Widget build(BuildContext context) { - final attachmentGroups = >{}; - - message.attachments - .where((element) => - (element.titleLink == null && element.type != null) || - element.type == 'giphy') - .forEach((e) { - if (attachmentGroups[e.type] == null) { - attachmentGroups[e.type!] = []; + // Create a default onAttachmentTap callback if not provided. + var onAttachmentTap = this.onAttachmentTap; + onAttachmentTap ??= (message, attachment) { + // If the current attachment is a url preview attachment, open the url + // in the browser. + final isUrlPreview = attachment.type == AttachmentType.urlPreview; + if (isUrlPreview) { + final url = attachment.ogScrapeUrl ?? ''; + launchURL(context, url); + return; } - attachmentGroups[e.type]?.add(e); - }); + final isImage = attachment.type == AttachmentType.image; + final isVideo = attachment.type == AttachmentType.video; + final isGiphy = attachment.type == AttachmentType.giphy; - final attachmentList = []; + // If the current attachment is a media attachment, open the media + // attachment in full screen. + final isMedia = isImage || isVideo || isGiphy; + if (isMedia) { + final channel = StreamChannel.of(context).channel; - attachmentGroups.forEach((type, attachments) { - final attachmentBuilder = attachmentBuilders[type]; + final attachments = message.toAttachmentPackage( + filter: (it) { + final isImage = it.type == AttachmentType.image; + final isVideo = it.type == AttachmentType.video; + final isGiphy = it.type == AttachmentType.giphy; + return isImage || isVideo || isGiphy; + }, + ); - if (attachmentBuilder == null) return; - final attachmentWidget = attachmentBuilder( - context, - message, - attachments, - ); - attachmentList.add(attachmentWidget); - }); - - return Padding( - padding: attachmentPadding, - child: Column( - mainAxisSize: MainAxisSize.min, - children: attachmentList.insertBetween( - SizedBox( - height: attachmentPadding.vertical / 2, + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) { + return StreamChannel( + channel: channel, + child: StreamFullScreenMediaBuilder( + userName: message.user!.name, + mediaAttachmentPackages: attachments, + startIndex: attachments.indexWhere( + (it) => it.attachment.id == attachment.id, + ), + onReplyMessage: onReplyTap, + onShowMessage: onShowMessage, + attachmentActionsModalBuilder: attachmentActionsModalBuilder, + ), + ); + }, ), - ), - ), + ); + + return; + } + }; + + // Create a default attachmentBuilders list if not provided. + var builders = attachmentBuilders; + builders ??= StreamAttachmentWidgetBuilder.defaultBuilders( + message: message, + onAttachmentTap: onAttachmentTap, ); + + final catalog = AttachmentWidgetCatalog(builders: builders); + return catalog.build(context, message); + } +} + +extension on Message { + List toAttachmentPackage({ + bool Function(Attachment)? filter, + }) { + // Create a copy of the attachments list. + var attachments = [...this.attachments]; + if (filter != null) { + attachments = [...attachments.where(filter)]; + } + + // Create a list of StreamAttachmentPackage from the attachments list. + return [ + ...attachments.map((it) { + return StreamAttachmentPackage( + attachment: it, + message: this, + ); + }) + ]; } } diff --git a/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart b/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart index 673853af..8f73efb0 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/quoted_message.dart @@ -12,31 +12,34 @@ class QuotedMessage extends StatelessWidget { const QuotedMessage({ super.key, required this.message, - required this.reverse, required this.hasNonUrlAttachments, + this.textBuilder, }); /// {@macro message} final Message message; - /// {@macro reverse} - final bool reverse; - /// {@macro hasNonUrlAttachments} final bool hasNonUrlAttachments; + /// {@macro textBuilder} + final Widget Function(BuildContext, Message)? textBuilder; + @override Widget build(BuildContext context) { final streamChat = StreamChat.of(context); final chatThemeData = StreamChatTheme.of(context); final isMyMessage = message.user?.id == streamChat.currentUser?.id; + final isMyQuotedMessage = + message.quotedMessage?.user?.id == streamChat.currentUser?.id; return StreamQuotedMessageWidget( message: message.quotedMessage!, messageTheme: isMyMessage ? chatThemeData.otherMessageTheme : chatThemeData.ownMessageTheme, - reverse: reverse, + reverse: !isMyQuotedMessage, + textBuilder: textBuilder, padding: EdgeInsets.only( right: 8, left: 8, diff --git a/packages/stream_chat_flutter/lib/src/misc/flex_grid.dart b/packages/stream_chat_flutter/lib/src/misc/flex_grid.dart new file mode 100644 index 00000000..2ffc9bfe --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/flex_grid.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/utils/utils.dart'; + +/// {@template matrix} +/// A matrix represents a 2D array of integers. +/// {@endtemplate} +typedef Matrix = List>; + +extension on Matrix { + /// Returns the total number of items in the matrix. + int get count => fold(0, (sum, row) => sum + row.length); + + /// Creates a lazy iterable of the [count] first elements of this iterable. + /// + /// The returned `Iterable` may contain fewer than `count` elements, if `this` + /// contains fewer than `count` elements. + Matrix takeItems(int count) { + final matrix = [...this]; + + // Remove items from the end of the matrix until the count is equal to n. + while (matrix.count > count) { + matrix.last.removeLast(); + if (matrix.last.isEmpty) { + matrix.removeLast(); + } + } + + return matrix; + } +} + +/// Signature for a function that builds a widget for the overlay of the last +/// child in the grid in case the number of children exceeds the maximum. +/// +/// The [remaining] parameter represents the number of children that are not +/// displayed in the grid. +typedef OverlayBuilder = Widget Function(BuildContext context, int remaining); + +/// {@template flex_grid} +/// A flexible grid widget that arranges its children based on a provided +/// [pattern]. +/// +/// The [FlexGrid] widget displays a grid of [children] widgets based on a +/// provided [pattern]. Each numeric value in the matrix represents the +/// flex value of the corresponding widget in the grid. The number of widgets +/// must match the number of cells in the matrix. +/// +/// The grid can be configured to have a maximum number of children to display. +/// If the number of children exceeds the maximum, the last child will show the +/// remaining number of children as a count in an overlay. An overlay builder +/// can be provided to customize the overlay for the last child. +/// +/// The direction of the grid can be reversed, with either the column or row as +/// the primary direction. Spacing can be applied between children in the main +/// axis and between the runs (rows or columns) themselves in the cross axis. +/// +/// Example usage: +/// ```dart +/// FlexGrid( +/// pattern: const [ +/// [1, 1], +/// [1, 1], +/// ], +/// children: [ +/// Container(color: Colors.red), +/// Container(color: Colors.blue), +/// Container(color: Colors.green), +/// Container(color: Colors.yellow), +/// ], +/// ) +/// ``` +/// {@endtemplate} +class FlexGrid extends StatelessWidget { + /// {@macro flex_grid} + FlexGrid({ + super.key, + required this.pattern, + required this.children, + this.maxChildren, + this.overlayBuilder, + this.reverse = false, + this.spacing = 2.0, + this.runSpacing = 2.0, + }) : assert( + pattern.count == children.length, + 'The number of children must match the number of cells in the matrix', + ), + assert( + maxChildren == null || maxChildren <= pattern.count, + 'The number of maxChildren must be less than or equal to the number ' + 'of cells in the matrix', + ), + assert( + maxChildren == null || overlayBuilder != null, + 'overlayBuilder must be provided when maxChildren is not null', + ); + + /// The pattern of the grid. + /// + /// Each numeric value in the array represents the flex value of + /// corresponding widget in grid. + /// + /// For example, a grid with 2 rows and 2 columns can be represented as: + /// + /// ```dart + /// [ + /// [1, 1], + /// [1, 1], + /// ] + /// ``` + /// + /// This will create a grid with 4 cells with each cell having a flex value + /// of 1. + final Matrix pattern; + + /// The widgets to display in the grid. + /// + /// The number of widgets must match the number of cells in the matrix. + final List children; + + /// Whether to reverse the direction of the grid. + /// + /// By default, the grid is rendered with column as primary direction and row + /// as secondary direction. + /// + /// If this is set to `true`, the grid will be rendered with row as primary + /// direction and column as secondary direction. + final bool reverse; + + /// The maximum number of children to display in the grid. + /// + /// If this is set, the grid will be rendered with a maximum of [maxChildren] + /// children. If the number of children is greater than [maxChildren], The + /// last child will show the remaining number of children as a count in a + /// overlay. + final int? maxChildren; + + /// The builder to use to build the overlay for the last child in case the + /// number of children is greater than [maxChildren]. + /// + /// The builder will be passed the number of remaining children to display. + final OverlayBuilder? overlayBuilder; + + /// 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; + + @override + Widget build(BuildContext context) { + // Determine the primary and secondary directions. + final primaryDirection = reverse ? Axis.horizontal : Axis.vertical; + final secondaryDirection = reverse ? Axis.vertical : Axis.horizontal; + + var pattern = [...this.pattern]; + var children = [...this.children]; + + // If the number of children is greater than the maximum number of children, + // remove the extra children. + final maxChildren = this.maxChildren; + if (maxChildren != null && maxChildren < pattern.count) { + children = [...children.take(maxChildren)]; + pattern = [...pattern.takeItems(maxChildren)]; + } + + // Track the current child index. + // + // This is used to determine which child to render next. + var childIndex = 0; + + return Flex( + direction: primaryDirection, + children: [ + for (final row in pattern) + Expanded( + child: Flex( + direction: secondaryDirection, + children: [ + ...row.map((flex) { + final isLastChild = childIndex == children.length - 1; + + // Determine the number of remaining children. + final remaining = (this.children.length - 1) - childIndex; + + // If we are at the last child and there are remaining + // children, show the remaining number of children as a + // count in a overlay. + if (isLastChild && remaining > 0) { + return Expanded( + flex: flex, + child: Stack( + fit: StackFit.passthrough, + children: [ + children[childIndex++], + overlayBuilder!.call(context, remaining), + ], + ), + ); + } + + // Otherwise, return the child. + return Expanded( + flex: flex, + child: children[childIndex++], + ); + }), + ].insertBetween( + Gap(direction: secondaryDirection, spacing: runSpacing), + ), + ), + ), + ].insertBetween( + Gap(direction: primaryDirection, spacing: spacing), + ), + ); + } +} + +/// {@template gap} +/// A gap widget used to add spacing between children in either the horizontal +/// or vertical direction. +/// {@endtemplate} +class Gap extends StatelessWidget { + /// {@macro gap} + const Gap({ + super.key, + required this.direction, + this.spacing = 0.0, + }); + + /// The direction of the gap. + final Axis direction; + + /// The spacing between children in the gap. + /// + /// Defaults to 0.0. + final double spacing; + + @override + Widget build(BuildContext context) { + switch (direction) { + case Axis.horizontal: + return SizedBox(width: spacing); + case Axis.vertical: + return SizedBox(height: spacing); + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/misc/giphy_chip.dart b/packages/stream_chat_flutter/lib/src/misc/giphy_chip.dart new file mode 100644 index 00000000..8ecb1094 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/misc/giphy_chip.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.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/extensions.dart'; + +/// {@template giphy_chip} +/// Simple widget which displays a Giphy attribution chip. +/// {@endtemplate} +class GiphyChip extends StatelessWidget { + /// {@macro giphy_chip} + const GiphyChip({super.key}); + + @override + Widget build(BuildContext context) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + return Container( + decoration: BoxDecoration( + color: colorTheme.overlayDark, + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.fromLTRB(4, 4, 8, 4), + child: Row( + children: [ + StreamSvgIcon.lightning( + size: 16, + color: colorTheme.barsBg, + ), + Text( + context.translations.giphyLabel.toUpperCase(), + style: TextStyle( + color: StreamChatTheme.of(context).colorTheme.barsBg, + fontWeight: FontWeight.bold, + fontSize: 10, + ), + ), + ], + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/misc/visible_footnote.dart b/packages/stream_chat_flutter/lib/src/misc/visible_footnote.dart index d37e4957..05a09f6e 100644 --- a/packages/stream_chat_flutter/lib/src/misc/visible_footnote.dart +++ b/packages/stream_chat_flutter/lib/src/misc/visible_footnote.dart @@ -18,14 +18,15 @@ class StreamVisibleFootnote extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ StreamSvgIcon.eye( - color: chatThemeData.colorTheme.textLowEmphasis, size: 16, + color: chatThemeData.colorTheme.textLowEmphasis, ), const SizedBox(width: 8), Text( context.translations.onlyVisibleToYouText, - style: chatThemeData.textTheme.footnote - .copyWith(color: chatThemeData.colorTheme.textLowEmphasis), + style: chatThemeData.textTheme.footnote.copyWith( + color: chatThemeData.colorTheme.textLowEmphasis, + ), ), ], ); diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart index 379c1c14..f4259fd4 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_configuration.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/indicators/loading_indicator.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// {@template streamChatConfiguration} @@ -108,12 +109,14 @@ You must have a StreamChatConfigurationProvider widget at the top of your widget class StreamChatConfigurationData { /// {@macro streamChatConfigurationData} factory StreamChatConfigurationData({ + Widget loadingIndicator = const StreamLoadingIndicator(), Widget Function(BuildContext, User)? defaultUserImage, Widget Function(BuildContext, User)? placeholderUserImage, List? reactionIcons, bool? enforceUniqueReactions, }) { return StreamChatConfigurationData._( + loadingIndicator: loadingIndicator, defaultUserImage: defaultUserImage ?? _defaultUserImage, placeholderUserImage: placeholderUserImage, reactionIcons: reactionIcons ?? _defaultReactionIcons, @@ -122,6 +125,7 @@ class StreamChatConfigurationData { } StreamChatConfigurationData._({ + required this.loadingIndicator, required this.defaultUserImage, required this.placeholderUserImage, required this.reactionIcons, @@ -131,20 +135,25 @@ class StreamChatConfigurationData { /// Copies the configuration options from one [StreamChatConfigurationData] to /// another. StreamChatConfigurationData copyWith({ + Widget? loadingIndicator, Widget Function(BuildContext, User)? defaultUserImage, Widget Function(BuildContext, User)? placeholderUserImage, List? reactionIcons, bool? enforceUniqueReactions, }) { return StreamChatConfigurationData( + reactionIcons: reactionIcons ?? this.reactionIcons, defaultUserImage: defaultUserImage ?? this.defaultUserImage, placeholderUserImage: placeholderUserImage ?? this.placeholderUserImage, - reactionIcons: reactionIcons ?? this.reactionIcons, + loadingIndicator: loadingIndicator ?? this.loadingIndicator, enforceUniqueReactions: enforceUniqueReactions ?? this.enforceUniqueReactions, ); } + /// The widget that will be shown to indicate loading. + final Widget loadingIndicator; + /// The widget that will be built when the user image is unavailable. final Widget Function(BuildContext, User) defaultUserImage; diff --git a/packages/stream_chat_flutter/lib/src/theme/color_theme.dart b/packages/stream_chat_flutter/lib/src/theme/color_theme.dart index 1d98b5aa..010d8777 100644 --- a/packages/stream_chat_flutter/lib/src/theme/color_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/color_theme.dart @@ -11,7 +11,7 @@ class StreamColorTheme { this.disabled = const Color(0xffdbdbdb), this.borders = const Color(0xffecebeb), this.inputBg = const Color(0xfff2f2f2), - this.appBg = const Color(0xfffcfcfc), + this.appBg = const Color(0xfff7f7f8), this.barsBg = const Color(0xffffffff), this.linkBg = const Color(0xffe9f2ff), this.accentPrimary = const Color(0xff005FFF), @@ -63,8 +63,8 @@ class StreamColorTheme { this.disabled = const Color(0xff2d2f2f), this.borders = const Color(0xff1c1e22), this.inputBg = const Color(0xff13151b), - this.appBg = const Color(0xff070A0D), - this.barsBg = const Color(0xff101418), + this.appBg = const Color(0xff000000), + this.barsBg = const Color(0xff121416), this.linkBg = const Color(0xff00193D), this.accentPrimary = const Color(0xff337eff), this.accentError = const Color(0xffFF3742), diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index 06b02aae..e8358306 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -475,3 +475,16 @@ extension StreamSvgIconX on StreamSvgIcon { return StreamIconThemeSvgIcon.fromStreamSvgIcon(this); } } + +/// Useful extensions on [BoxConstraints]. +extension ConstraintsX on BoxConstraints { + /// Returns new box constraints that tightens the max width and max height + /// to the given [size]. + BoxConstraints tightenMaxSize(Size? size) { + if (size == null) return this; + return copyWith( + maxWidth: clampDouble(size.width, minWidth, maxWidth), + maxHeight: clampDouble(size.height, minHeight, maxHeight), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/utils/helpers.dart b/packages/stream_chat_flutter/lib/src/utils/helpers.dart index 950318e9..f471f1a4 100644 --- a/packages/stream_chat_flutter/lib/src/utils/helpers.dart +++ b/packages/stream_chat_flutter/lib/src/utils/helpers.dart @@ -330,7 +330,7 @@ String fileSize(dynamic size, [int round = 2]) { } /// -StreamSvgIcon getFileTypeImage(String? mimeType) { +StreamSvgIcon getFileTypeImage([String? mimeType]) { final subtype = mimeType?.split('/').last; switch (subtype) { case '7z': @@ -378,14 +378,14 @@ class WrapAttachmentWidget extends StatelessWidget { const WrapAttachmentWidget({ super.key, required this.attachmentWidget, - required this.attachmentShape, + this.attachmentShape, }); /// The widget to wrap final Widget attachmentWidget; /// The shape of the wrapper - final ShapeBorder attachmentShape; + final ShapeBorder? attachmentShape; @override Widget build(BuildContext context) { @@ -434,22 +434,6 @@ int levenshtein(String s, String t, {bool caseSensitive = true}) { return v1[t.length]; } -/// An easy way to handle attachment related operations on a message -extension AttachmentPackagesX on Message { - /// This extension will return a List of type [StreamAttachmentPackage] from - /// the existing attachments of the message - List getAttachmentPackageList() { - final _attachmentPackages = List.generate( - attachments.length, - (index) => StreamAttachmentPackage( - attachment: attachments[index], - message: this, - ), - ); - return _attachmentPackages; - } -} - /// PortalLabel that refers to [StreamMessageListView] const kPortalMessageListViewLabel = _PortalMessageListViewLabel(); diff --git a/packages/stream_chat_flutter/lib/src/utils/typedefs.dart b/packages/stream_chat_flutter/lib/src/utils/typedefs.dart index a0551544..2a504b2d 100644 --- a/packages/stream_chat_flutter/lib/src/utils/typedefs.dart +++ b/packages/stream_chat_flutter/lib/src/utils/typedefs.dart @@ -193,15 +193,6 @@ typedef QuotedMessageAttachmentThumbnailBuilder = Widget Function( Attachment, ); -/// {@template onMessageWidgetAttachmentTap} -/// The action to perform when an attachment in an [StreamMessageWidget] -/// is tapped or clicked. -/// {@endtemplate} -typedef OnMessageWidgetAttachmentTap = void Function( - Message message, - Attachment attachment, -); - /// {@template attachmentBuilder} /// A widget builder for representing attachments. /// {@endtemplate} diff --git a/packages/stream_chat_flutter/lib/src/video/video_service.dart b/packages/stream_chat_flutter/lib/src/video/video_service.dart index 7ba8e541..44d6643c 100644 --- a/packages/stream_chat_flutter/lib/src/video/video_service.dart +++ b/packages/stream_chat_flutter/lib/src/video/video_service.dart @@ -33,6 +33,7 @@ class _IVideoService { /// PNG format. Future generateVideoThumbnail({ String? video, + Map? headers, ImageFormat imageFormat = ImageFormat.PNG, int maxHeight = 0, int maxWidth = 0, @@ -63,6 +64,7 @@ class _IVideoService { } else if (isMobileDevice) { return VideoThumbnail.thumbnailData( video: video, + headers: headers, imageFormat: imageFormat, maxHeight: maxHeight, maxWidth: maxWidth, diff --git a/packages/stream_chat_flutter/lib/src/video/video_thumbnail_image.dart b/packages/stream_chat_flutter/lib/src/video/video_thumbnail_image.dart index 4080c2df..11ff945e 100644 --- a/packages/stream_chat_flutter/lib/src/video/video_thumbnail_image.dart +++ b/packages/stream_chat_flutter/lib/src/video/video_thumbnail_image.dart @@ -1,162 +1,147 @@ -import 'dart:typed_data'; +import 'dart:ui' as ui; -import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/video/video_service.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:video_thumbnail/video_thumbnail.dart'; +import 'package:video_thumbnail/video_thumbnail.dart' show ImageFormat; -/// {@template streamVideoThumbnailImage} -/// Displays a video thumbnail for video attachments. +/// {@template video_thumbnail_image} +/// A custom [ImageProvider] class for loading video thumbnails as images in +/// Flutter. /// -/// [thumbUrl] is used if provided. +/// Use this class to load a video thumbnail as an image. It takes a video URL +/// or path and generates a thumbnail image from the video. The generated +/// thumbnail image can be used with the [Image] widget. /// -/// Else [video] (path to local or remote video) is used to generate -/// a thumbnail from the video asset. +/// {@tool snippet} +/// Load a video thumbnail from a URL: /// -/// WARNING! a local path does not work on web. +/// ```dart +/// Image( +/// image: StreamVideoThumbnailImage( +/// video: 'https://example.com/video.mp4', +/// maxHeight: 200, +/// maxWidth: 200, +/// ), +/// ) +/// ``` +/// {@end-tool} /// -/// If both [thumbUrl] and [video] are null, or if a thumbnail can't be -/// generated, a stock default image will be used. +/// {@tool snippet} +/// Load a video thumbnail from a local file path: +/// +/// ```dart +/// Image( +/// image: StreamVideoThumbnailImage( +/// video: '/path/to/video.mp4', +/// maxHeight: 200, +/// maxWidth: 200, +/// ), +/// ) +/// ``` +/// {@end-tool} /// {@endtemplate} -class StreamVideoThumbnailImage extends StatefulWidget { - /// {@macro streamVideoThumbnailImage} +class StreamVideoThumbnailImage + extends ImageProvider { + /// {@macro video_thumbnail_image} const StreamVideoThumbnailImage({ - super.key, - this.video, - this.thumbUrl, - this.constraints, - this.fit = BoxFit.cover, - this.format = ImageFormat.PNG, - this.errorBuilder, - this.placeholderBuilder, + required this.video, + this.headers, + this.imageFormat = ImageFormat.PNG, + this.maxHeight = 0, + this.maxWidth = 0, + this.timeMs = 0, + this.quality = 10, + this.scale = 1.0, }); - /// Video path or url - final String? video; + /// The URL or path of the video from which to generate the thumbnail. + final String video; - /// Video thumbnail url - final String? thumbUrl; + /// Additional headers to include in the HTTP request when fetching the video. + final Map? headers; - /// Contraints of attachments - final BoxConstraints? constraints; + /// The format of the generated thumbnail image. + final ImageFormat imageFormat; - /// Fit of image - final BoxFit? fit; + /// The maximum height of the generated thumbnail image. + final int maxHeight; - /// Image format - final ImageFormat format; + /// The maximum width of the generated thumbnail image. + final int maxWidth; - /// A builder for building a custom error widget when the thumbnail - /// creation fails - final Widget Function(BuildContext, Object?)? errorBuilder; + /// The timestamp in milliseconds at which to generate the thumbnail. + final int timeMs; - /// A builder for building custom thumbnail loading UI - final WidgetBuilder? placeholderBuilder; + /// The quality of the generated thumbnail image. + final int quality; + + /// The scale to place in the [ImageInfo] object of the image. + final double scale; @override - _StreamVideoThumbnailImageState createState() => - _StreamVideoThumbnailImageState(); -} - -class _StreamVideoThumbnailImageState extends State { - late Future thumbnailFuture; - late StreamChatThemeData _streamChatTheme; - - void _generateThumbnail() { - // Only generate thumbnail if the thumbnail url is not provided. - if (widget.thumbUrl == null) { - thumbnailFuture = StreamVideoService.generateVideoThumbnail( - video: widget.video, - imageFormat: widget.format, - ); - } + Future obtainKey( + ImageConfiguration configuration, + ) { + return SynchronousFuture(this); } @override - void initState() { - super.initState(); - _generateThumbnail(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _streamChatTheme = StreamChatTheme.of(context); - } - - @override - void didUpdateWidget(covariant StreamVideoThumbnailImage oldWidget) { - if (oldWidget.video != widget.video || oldWidget.format != widget.format) { - _generateThumbnail(); - } - super.didUpdateWidget(oldWidget); - } - - @override - Widget build(BuildContext context) { - final placeHolderWidget = widget.placeholderBuilder?.call(context) ?? - Shimmer.fromColors( - baseColor: _streamChatTheme.colorTheme.disabled, - highlightColor: _streamChatTheme.colorTheme.inputBg, - child: Image.asset( - 'images/placeholder.png', - fit: BoxFit.cover, - height: widget.constraints?.maxHeight, - width: widget.constraints?.maxWidth, - package: 'stream_chat_flutter', - ), - ); - - final errorWidget = widget.errorBuilder?.call(context, null) ?? - AttachmentError(constraints: widget.constraints); - - final thumbUrl = widget.thumbUrl; - if (thumbUrl != null) { - return CachedNetworkImage( - imageUrl: thumbUrl, - fit: widget.fit, - height: widget.constraints?.maxHeight, - width: widget.constraints?.maxWidth, - placeholder: (context, __) => placeHolderWidget, - errorWidget: (context, url, error) => errorWidget, - ); - } - - return ConstrainedBox( - constraints: widget.constraints ?? const BoxConstraints.expand(), - child: FutureBuilder( - future: thumbnailFuture, - builder: (context, snapshot) => AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - child: Builder( - key: ValueKey>(snapshot), - builder: (_) { - if (snapshot.hasError) return errorWidget; - - if (!snapshot.hasData) { - return SizedBox( - height: double.maxFinite, - width: double.maxFinite, - child: placeHolderWidget, - ); - } - - return SizedBox( - height: double.maxFinite, - width: double.maxFinite, - child: Image.memory( - snapshot.data!, - fit: widget.fit, - height: widget.constraints?.maxHeight ?? double.infinity, - width: widget.constraints?.maxWidth ?? double.infinity, - ), - ); - }, - ), - ), - ), + @Deprecated('Will get replaced by loadImage in the next major version.') + ImageStreamCompleter loadBuffer( + StreamVideoThumbnailImage key, + DecoderBufferCallback decode, + ) { + return MultiFrameImageStreamCompleter( + codec: _loadAsync(key, decode), + scale: key.scale, + debugLabel: key.video, + informationCollector: () => [ + DiagnosticsProperty('Image provider', this), + DiagnosticsProperty('Image key', key), + ], ); } + + @Deprecated('Will get replaced by loadImage in the next major version.') + Future _loadAsync( + StreamVideoThumbnailImage key, + DecoderBufferCallback decode, + ) async { + assert(key == this, '$key is not $this'); + + final bytes = await StreamVideoService.generateVideoThumbnail( + video: key.video, + headers: key.headers, + imageFormat: key.imageFormat, + maxHeight: key.maxHeight, + maxWidth: key.maxWidth, + timeMs: key.timeMs, + quality: key.quality, + ); + + if (bytes == null || bytes.lengthInBytes == 0) { + throw Exception('VideoThumbnailImage is an empty file: ${key.video}'); + } + + final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + return decode(buffer); + } + + @override + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; + } + return other is StreamVideoThumbnailImage && + other.video == video && + other.scale == scale; + } + + @override + int get hashCode => Object.hash(video, scale); + + @override + String toString() => + '${objectRuntimeType(this, 'StreamVideoThumbnailImage')}($video, scale: $scale)'; } diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 875f79f7..3b793463 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -9,7 +9,7 @@ export 'src/attachment/attachment.dart'; export 'src/attachment/attachment_title.dart'; export 'src/attachment/handler/stream_attachment_handler.dart'; export 'src/attachment/image_attachment.dart'; -export 'src/attachment/image_group.dart'; +export 'src/attachment/gallery_attachment.dart'; export 'src/attachment/stream_attachment_package.dart'; export 'src/attachment/url_attachment.dart'; export 'src/attachment/video_attachment.dart'; @@ -96,4 +96,5 @@ export 'src/utils/device_segmentation.dart'; export 'src/utils/extensions.dart'; export 'src/utils/helpers.dart'; export 'src/utils/typedefs.dart'; +// TODO: Remove this in favor of StreamVideoAttachmentThumbnail. export 'src/video/video_thumbnail_image.dart'; diff --git a/packages/stream_chat_flutter/pubspec.yaml b/packages/stream_chat_flutter/pubspec.yaml index 5aaa9f85..90b37897 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -29,6 +29,7 @@ dependencies: http_parser: ^4.0.2 image_gallery_saver: ^2.0.3 image_picker: ^1.0.1 + image_size_getter: ^2.1.2 jiffy: ^6.2.1 lottie: ^2.4.0 meta: ^1.9.1 diff --git a/packages/stream_chat_flutter/test/src/attachment/file_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/file_attachment_test.dart index 080cf6f5..39aae1d0 100644 --- a/packages/stream_chat_flutter/test/src/attachment/file_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/file_attachment_test.dart @@ -30,7 +30,7 @@ void main() { 300, )), message: Message(), - attachment: Attachment( + file: Attachment( type: 'file', title: 'example.pdf', extraData: const { diff --git a/packages/stream_chat_flutter/test/src/attachment/giphy_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/giphy_attachment_test.dart index 0399f1e8..2203de7c 100644 --- a/packages/stream_chat_flutter/test/src/attachment/giphy_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/giphy_attachment_test.dart @@ -30,7 +30,7 @@ void main() { 300, )), message: Message(), - attachment: Attachment( + file: Attachment( type: 'giphy', title: 'example.gif', imageUrl: diff --git a/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart index b90f28a4..37a5c56f 100644 --- a/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/image_attachment_test.dart @@ -26,13 +26,12 @@ void main() { channel: channel, child: SizedBox( child: StreamImageAttachment( - messageTheme: streamTheme.ownMessageTheme, constraints: BoxConstraints.tight(const Size( 300, 300, )), message: Message(), - attachment: Attachment( + file: Attachment( type: 'image', title: 'example.png', imageUrl: diff --git a/packages/stream_chat_flutter/test/src/attachment/image_group_test.dart b/packages/stream_chat_flutter/test/src/attachment/image_group_test.dart index 43fea9e7..c8c8d264 100644 --- a/packages/stream_chat_flutter/test/src/attachment/image_group_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/image_group_test.dart @@ -25,14 +25,13 @@ void main() { child: StreamChannel( channel: channel, child: SizedBox( - child: StreamImageGroup( - messageTheme: streamTheme.ownMessageTheme, + child: StreamGalleryAttachment( constraints: BoxConstraints.tight(const Size( 300, 300, )), message: Message(), - images: [ + attachments: [ Attachment( type: 'image', title: 'example.png',