diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index f588205d..7ffd8a8a 100644 --- a/packages/stream_chat/lib/src/client/channel.dart +++ b/packages/stream_chat/lib/src/client/channel.dart @@ -499,7 +499,7 @@ class Channel { ]); } - final isImage = it.type == 'image'; + final isImage = it.type == AttachmentType.image; final cancelToken = CancelToken(); Future future; if (isImage) { diff --git a/packages/stream_chat/lib/src/core/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart index bceaaacc..86d3efed 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.dart @@ -10,13 +10,25 @@ 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'; + static const audio = 'audio'; + + /// 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,26 +45,24 @@ 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, + _uploadState = uploadState, localUri = file?.path != null ? Uri.parse(file!.path!) : null, - actions = actions ?? [], // For backwards compatibility, // set 'file_size', 'mime_type' in [extraData]. extraData = { ...extraData, if (file?.size != null) 'file_size': file?.size, - if (file?.mimeType != null) 'mime_type': file?.mimeType?.mimeType, - } { - this.uploadState = uploadState ?? - ((assetUrl != null || imageUrl != null || thumbUrl != null) - ? const UploadState.success() - : const UploadState.preparing()); - } + if (file?.mediaType != null) 'mime_type': file?.mediaType?.mimeType, + }; /// Create a new instance from a json factory Attachment.fromJson(Map json) => @@ -69,7 +79,8 @@ class Attachment extends Equatable { factory Attachment.fromOGAttachment(OGAttachmentResponse ogAttachment) => Attachment( - type: ogAttachment.type, + // If the type is not specified, we default to urlPreview. + type: ogAttachment.type ?? AttachmentType.urlPreview, title: ogAttachment.title, titleLink: ogAttachment.titleLink, text: ogAttachment.text, @@ -84,7 +95,20 @@ 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 the attachment contains titleLink but is not of type giphy, we + // consider it as a urlPreview. + if (_type != AttachmentType.giphy && titleLink != null) { + return AttachmentType.urlPreview; + } + + return _type; + } + + final String? _type; + + /// The raw attachment type. + String? get rawType => _type; ///The link to which the attachment message points to. final String? titleLink; @@ -126,13 +150,27 @@ 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. final AttachmentFile? file; /// The current upload state of the attachment - late final UploadState uploadState; + UploadState get uploadState { + if (_uploadState case final state?) return state; + + return ((assetUrl != null || imageUrl != null || thumbUrl != null) + ? const UploadState.success() + : const UploadState.preparing()); + } + + final UploadState? _uploadState; /// Map of custom channel extraData final Map extraData; @@ -175,6 +213,8 @@ class Attachment extends Equatable { 'author_icon', 'asset_url', 'actions', + 'original_width', + 'original_height', ]; /// Known db specific top level fields. @@ -214,6 +254,8 @@ class Attachment extends Equatable { String? authorIcon, String? assetUrl, List? actions, + int? originalWidth, + int? originalHeight, AttachmentFile? file, UploadState? uploadState, Map? extraData, @@ -238,6 +280,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 +308,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 +337,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..51aae50e 100644 --- a/packages/stream_chat/lib/src/core/models/attachment.g.dart +++ b/packages/stream_chat/lib/src/core/models/attachment.g.dart @@ -26,8 +26,11 @@ Attachment _$AttachmentFromJson(Map json) => Attachment( authorIcon: json['author_icon'] as String?, assetUrl: json['asset_url'] as String?, actions: (json['actions'] as List?) - ?.map((e) => Action.fromJson(e as Map)) - .toList(), + ?.map((e) => Action.fromJson(e as Map)) + .toList() ?? + const [], + 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 +67,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_file.dart b/packages/stream_chat/lib/src/core/models/attachment_file.dart index ee6690ca..261d7db3 100644 --- a/packages/stream_chat/lib/src/core/models/attachment_file.dart +++ b/packages/stream_chat/lib/src/core/models/attachment_file.dart @@ -62,7 +62,7 @@ class AttachmentFile { String? get extension => name?.split('.').last; /// The mime type of this file. - MediaType? get mimeType => name?.mimeType; + MediaType? get mediaType => name?.mediaType; /// Serialize to json Map toJson() => _$AttachmentFileToJson(this); @@ -75,13 +75,13 @@ class AttachmentFile { multiPartFile = MultipartFile.fromBytes( bytes!, filename: name, - contentType: mimeType, + contentType: mediaType, ); } else { multiPartFile = await MultipartFile.fromFile( path!, filename: name, - contentType: mimeType, + contentType: mediaType, ); } return multiPartFile; 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/src/core/util/extension.dart b/packages/stream_chat/lib/src/core/util/extension.dart index a28dc12b..132413a6 100644 --- a/packages/stream_chat/lib/src/core/util/extension.dart +++ b/packages/stream_chat/lib/src/core/util/extension.dart @@ -20,8 +20,8 @@ extension MapX on Map { /// Useful extension functions for [String] extension StringX on String { - /// returns the mime type from the passed file name. - MediaType? get mimeType { + /// returns the media type from the passed file name. + MediaType? get mediaType { if (toLowerCase().endsWith('heic')) { return MediaType.parse('image/heic'); } else { 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/fixtures/message_to_json.json b/packages/stream_chat/test/fixtures/message_to_json.json index 94aed066..7cb32216 100644 --- a/packages/stream_chat/test/fixtures/message_to_json.json +++ b/packages/stream_chat/test/fixtures/message_to_json.json @@ -5,7 +5,7 @@ "silent": false, "attachments": [ { - "type": "video", + "type": "giphy", "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", "title": "The Lion King Disney GIF - Find & Share on GIPHY", "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index 87053d9b..19450f66 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/test/src/core/models/attachment_test.dart b/packages/stream_chat/test/src/core/models/attachment_test.dart index edcc0da6..e9add109 100644 --- a/packages/stream_chat/test/src/core/models/attachment_test.dart +++ b/packages/stream_chat/test/src/core/models/attachment_test.dart @@ -27,7 +27,7 @@ void main() { test('should serialize to json correctly', () { final channel = Attachment( - type: 'image', + type: 'giphy', title: 'soo', titleLink: 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', @@ -36,7 +36,7 @@ void main() { expect( channel.toJson(), { - 'type': 'image', + 'type': 'giphy', 'title': 'soo', 'title_link': 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', diff --git a/packages/stream_chat/test/src/core/models/message_test.dart b/packages/stream_chat/test/src/core/models/message_test.dart index b5e6ddcc..c627a739 100644 --- a/packages/stream_chat/test/src/core/models/message_test.dart +++ b/packages/stream_chat/test/src/core/models/message_test.dart @@ -38,7 +38,7 @@ void main() { 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA', attachments: [ Attachment.fromJson(const { - 'type': 'video', + 'type': 'giphy', 'author_name': 'GIPHY', 'title': 'The Lion King Disney GIF - Find \u0026 Share on GIPHY', 'title_link': diff --git a/packages/stream_chat/test/src/core/util/extension_test.dart b/packages/stream_chat/test/src/core/util/extension_test.dart index ae3c08d8..ad624c05 100644 --- a/packages/stream_chat/test/src/core/util/extension_test.dart +++ b/packages/stream_chat/test/src/core/util/extension_test.dart @@ -25,13 +25,13 @@ void main() { group('mimeType', () { test('should return null if `String` is not a filename', () { const fileName = 'not-a-file-name'; - final mimeType = fileName.mimeType; + final mimeType = fileName.mediaType; expect(mimeType, isNull); }); test('should return mimeType if string is a filename', () { const fileName = 'dummyFileName.jpeg'; - final mimeType = fileName.mimeType; + final mimeType = fileName.mediaType; expect(mimeType, isNotNull); expect(mimeType!.type, 'image'); expect(mimeType.subtype, 'jpeg'); @@ -39,7 +39,7 @@ void main() { test('should return `image/heic` if ends with `heic`', () { const fileName = 'dummyFileName.heic'; - final mimeType = fileName.mimeType; + final mimeType = fileName.mediaType; expect(mimeType, isNotNull); expect(mimeType!.type, 'image'); expect(mimeType.subtype, 'heic'); diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment.dart index 721d1dea..0b73a93c 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment.dart @@ -1,7 +1,7 @@ -export 'attachment_error.dart'; export 'attachment_upload_state_builder.dart'; -export 'attachment_widget.dart' show AttachmentSource; export 'file_attachment.dart'; +export 'gallery_attachment.dart'; export 'giphy_attachment.dart'; export 'image_attachment.dart'; +export 'url_attachment.dart'; export 'video_attachment.dart'; diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_error.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_error.dart deleted file mode 100644 index 85e806e9..00000000 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_error.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template attachmentError} -/// Widget for building in case of error -/// {@endtemplate} -class AttachmentError extends StatelessWidget { - /// {@macro attachmentError} - const AttachmentError({ - super.key, - this.constraints, - }); - - /// constraints of error - final BoxConstraints? constraints; - - @override - Widget build(BuildContext context) { - return Center( - child: Container( - constraints: constraints ?? const BoxConstraints.expand(), - color: - StreamChatTheme.of(context).colorTheme.accentError.withOpacity(0.1), - child: Center( - child: Icon( - Icons.error_outline, - color: StreamChatTheme.of(context).colorTheme.textHighEmphasis, - ), - ), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart deleted file mode 100644 index dc9c34c1..00000000 --- a/packages/stream_chat_flutter/lib/src/attachment/attachment_title.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template attachmentTitle} -/// Title for attachments -/// {@endtemplate} -class StreamAttachmentTitle extends StatelessWidget { - /// {@macro attachmentTitle} - const StreamAttachmentTitle({ - super.key, - required this.attachment, - required this.messageTheme, - }); - - /// Theme to apply to text - final StreamMessageThemeData messageTheme; - - /// Attachment data to display - final Attachment attachment; - - @override - Widget build(BuildContext context) { - final ogScrapeUrl = attachment.ogScrapeUrl; - return GestureDetector( - onTap: () { - final ogScrapeUrl = attachment.ogScrapeUrl; - if (ogScrapeUrl != null) launchURL(context, ogScrapeUrl); - }, - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (attachment.title != null) - Text( - attachment.title!, - overflow: TextOverflow.ellipsis, - style: messageTheme.messageTextStyle?.copyWith( - color: StreamChatTheme.of(context).colorTheme.accentPrimary, - fontWeight: FontWeight.bold, - ), - ), - if (ogScrapeUrl != null) - Text(ogScrapeUrl, style: messageTheme.messageTextStyle), - ], - ), - ), - ); - } -} 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..5e51c733 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/attachment_widget_catalog.dart @@ -0,0 +1,64 @@ +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(where((it) { + return it.type != null; + }), (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..f9b389ef --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/attachment_widget_builder.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/src/attachment/attachment.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/media_attachment_thumbnail.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'; + +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, + ShapeBorder? shape, + EdgeInsetsGeometry padding = const EdgeInsets.all(4), + StreamAttachmentWidgetTapCallback? onAttachmentTap, + }) { + return [ + // Handles a mix of image, gif, video, url and file attachments. + MixedAttachmentBuilder( + padding: padding, + onAttachmentTap: onAttachmentTap, + ), + + // Handles a mix of image, gif, and video attachments. + GalleryAttachmentBuilder( + shape: shape, + padding: padding, + runSpacing: padding.vertical / 2, + spacing: padding.horizontal / 2, + onAttachmentTap: onAttachmentTap, + ), + + // Handles file attachments. + FileAttachmentBuilder( + shape: shape, + padding: padding, + onAttachmentTap: onAttachmentTap, + ), + + // Handles giphy attachments. + GiphyAttachmentBuilder( + shape: shape, + padding: padding, + onAttachmentTap: onAttachmentTap, + ), + + // Handles image attachments. + ImageAttachmentBuilder( + shape: shape, + padding: padding, + onAttachmentTap: onAttachmentTap, + ), + + // Handles video attachments. + VideoAttachmentBuilder( + shape: shape, + padding: padding, + onAttachmentTap: onAttachmentTap, + ), + // We don't handle URL attachments if the message is a reply. + if (message.quotedMessage == null) + UrlAttachmentBuilder( + shape: shape, + padding: padding, + 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..cd2bf22e --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/gallery_attachment_builder.dart @@ -0,0 +1,126 @@ +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]; + + VoidCallback? onTap; + if (onAttachmentTap != null) { + onTap = () => onAttachmentTap!(message, attachment); + } + + return InkWell( + onTap: onTap, + child: Stack( + children: [ + StreamMediaAttachmentThumbnail( + media: attachment, + width: constraints.maxWidth, + height: constraints.maxHeight, + 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..14aa7c68 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/image_attachment_builder.dart @@ -0,0 +1,73 @@ +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( + shape: shape, + 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..3d0eb466 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/builder/mixed_attachment_builder.dart @@ -0,0 +1,126 @@ +part of 'attachment_widget_builder.dart'; + +/// {@template mixedAttachmentBuilder} +/// A widget builder for Mixed attachment type. +/// +/// This builder is used when a message contains a mix of media type and file +/// or url preview attachments. +/// +/// This builder will render first the url preview or file attachment and then +/// the media attachments. +/// {@endtemplate} +class MixedAttachmentBuilder extends StreamAttachmentWidgetBuilder { + /// {@macro mixedAttachmentBuilder} + MixedAttachmentBuilder({ + this.padding = const EdgeInsets.all(4), + StreamAttachmentWidgetTapCallback? onAttachmentTap, + }) : _imageAttachmentBuilder = ImageAttachmentBuilder( + padding: EdgeInsets.zero, + onAttachmentTap: onAttachmentTap, + ), + _videoAttachmentBuilder = VideoAttachmentBuilder( + padding: EdgeInsets.zero, + onAttachmentTap: onAttachmentTap, + ), + _giphyAttachmentBuilder = GiphyAttachmentBuilder( + padding: EdgeInsets.zero, + onAttachmentTap: onAttachmentTap, + ), + _galleryAttachmentBuilder = GalleryAttachmentBuilder( + padding: EdgeInsets.zero, + onAttachmentTap: onAttachmentTap, + ), + _fileAttachmentBuilder = FileAttachmentBuilder( + padding: EdgeInsets.zero, + onAttachmentTap: onAttachmentTap, + ), + _urlAttachmentBuilder = UrlAttachmentBuilder( + padding: EdgeInsets.zero, + onAttachmentTap: onAttachmentTap, + ); + + /// The padding to apply to the mixed attachment widget. + final EdgeInsetsGeometry padding; + + late final StreamAttachmentWidgetBuilder _imageAttachmentBuilder; + late final StreamAttachmentWidgetBuilder _videoAttachmentBuilder; + late final StreamAttachmentWidgetBuilder _giphyAttachmentBuilder; + late final StreamAttachmentWidgetBuilder _galleryAttachmentBuilder; + late final StreamAttachmentWidgetBuilder _fileAttachmentBuilder; + late final StreamAttachmentWidgetBuilder _urlAttachmentBuilder; + + @override + bool canHandle( + Message message, + Map> attachments, + ) { + final types = attachments.keys; + + final containsImage = types.contains(AttachmentType.image); + final containsVideo = types.contains(AttachmentType.video); + final containsGiphy = types.contains(AttachmentType.giphy); + final containsFile = types.contains(AttachmentType.file); + final containsUrlPreview = types.contains(AttachmentType.urlPreview); + + final containsMedia = containsImage || containsVideo || containsGiphy; + + return containsMedia && containsFile || + containsMedia && containsUrlPreview || + containsFile && containsUrlPreview || + containsMedia && containsFile && containsUrlPreview; + } + + @override + Widget build( + BuildContext context, + Message message, + Map> attachments, + ) { + assert(debugAssertCanHandle(message, attachments), ''); + + final urls = attachments[AttachmentType.urlPreview]; + 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 (urls != null) + _urlAttachmentBuilder.build(context, message, { + AttachmentType.urlPreview: urls, + }), + if (files != null) + _fileAttachmentBuilder.build(context, message, { + AttachmentType.file: files, + }), + 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..807ad2d6 --- /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: 256); + +/// {@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..3790b416 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,145 @@ 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, + ); - final colorTheme = StreamChatTheme.of(context).colorTheme; - return Shimmer.fromColors( - baseColor: colorTheme.disabled, - highlightColor: colorTheme.inputBg, - child: image, - ); - }, - ); - }, + final mediaType = file.title?.mediaType; + final isImage = mediaType?.type == AttachmentType.image; + final isVideo = mediaType?.type == AttachmentType.video; + if (isImage || isVideo) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + child = Container( + clipBehavior: Clip.hardEdge, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide( + color: colorTheme.borders, + strokeAlign: BorderSide.strokeAlignOutside, + ), + 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 +279,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..8acb39ba --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/gallery_attachment.dart @@ -0,0 +1,271 @@ +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], + ], + spacing: spacing, + runSpacing: runSpacing, + children: [ + itemBuilder(context, 0), + itemBuilder(context, 1), + ], + ); + } + + // Both the images are portrait. + if (!isLandscape1 && !isLandscape2) { + // ----------- + // | | | + // | | | + // | | | + // ----------- + return FlexGrid( + pattern: const [ + [1, 1], + ], + spacing: spacing, + runSpacing: runSpacing, + 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], + ], + spacing: spacing, + runSpacing: runSpacing, + 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], + ], + spacing: spacing, + runSpacing: runSpacing, + 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, + spacing: spacing, + runSpacing: runSpacing, + 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..a19e19ff 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/giphy_attachment.dart @@ -1,345 +1,105 @@ -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.original, + 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(); + BoxFit? fit; + final giphyInfo = giphy.giphyInfo(type); + + Size? giphySize; + if (giphyInfo != null) { + giphySize = Size(giphyInfo.width, giphyInfo.height); } - if (attachment.actions != null && attachment.actions!.isNotEmpty) { - return _buildSendingAttachment(context, imageUrl); + + // 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; } - return _buildSentAttachment(context, imageUrl); - } - 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), - ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - 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, - ), - ), - ], - ), - ), - 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, - ), - ), - ), - ), - ), - ], - ), - ], - ), + final chatTheme = StreamChatTheme.of(context); + final colorTheme = chatTheme.colorTheme; + final shape = this.shape ?? + RoundedRectangleBorder( + side: BorderSide( + color: colorTheme.borders, + strokeAlign: BorderSide.strokeAlignOutside, ), - const SizedBox(height: 4), - const Align( - alignment: Alignment.centerRight, - child: StreamVisibleFootnote(), - ), - ], - ), - ); - } + borderRadius: BorderRadius.circular(14), + ); - 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, + return 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, ), - ); - }, - ), - ); - } - - 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, - ), - ), - ], + if (giphy.uploadState.isSuccess) + const Positioned( + bottom: 8, + left: 8, + child: GiphyChip(), + ) + else + Padding( + padding: const EdgeInsets.all(8), + child: StreamAttachmentUploadStateBuilder( + message: message, + attachment: giphy, ), ), - ), - ), - ], + ], + ), ), ); } diff --git a/packages/stream_chat_flutter/lib/src/attachment/handler/common.dart b/packages/stream_chat_flutter/lib/src/attachment/handler/common.dart index 96d5e1ed..8c58fe2d 100644 --- a/packages/stream_chat_flutter/lib/src/attachment/handler/common.dart +++ b/packages/stream_chat_flutter/lib/src/attachment/handler/common.dart @@ -50,18 +50,18 @@ Future downloadAttachmentData( String? downloadUrl; String? fileName; /* ---IMAGES/GIFS--- */ - if (type == 'image') { + if (type == AttachmentType.image) { downloadUrl = attachment.imageUrl ?? attachment.assetUrl; fileName = attachment.title; fileName ??= 'attachment.${attachment.mimeType ?? 'png'}'; } /* ---GIPHY's--- */ - else if (type == 'giphy') { + else if (type == AttachmentType.giphy) { downloadUrl = attachment.thumbUrl; fileName = '${attachment.title}.gif'; } /* ---FILES AND VIDEOS--- */ - else if (type == 'file' || type == 'video') { + else if (type == AttachmentType.file || type == AttachmentType.video) { downloadUrl = attachment.assetUrl; fileName = attachment.title; } 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..5406cb87 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,148 +45,60 @@ 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, ), ), - ], + ], + ), ), ); } 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..89374932 --- /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 mediaType = file.title?.mediaType; + + final isImage = mediaType?.type == AttachmentType.image; + if (isImage) { + return StreamImageAttachmentThumbnail( + image: file, + width: width, + height: height, + fit: fit, + ); + } + + final isVideo = mediaType?.type == AttachmentType.video; + if (isVideo) { + return StreamVideoAttachmentThumbnail( + video: file, + width: width, + height: height, + fit: fit, + ); + } + + // Return a generic file type icon. + return getFileTypeImage(mediaType?.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..6b3a5e8d --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/giphy_attachment_thumbnail.dart @@ -0,0 +1,103 @@ +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, + height: double.infinity, + width: double.infinity, + fit: BoxFit.cover, + ); + } + + @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..590fe4e7 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/image_attachment_thumbnail.dart @@ -0,0 +1,211 @@ +import 'dart:io' show File; + +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/utils/utils.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// {@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, + height: double.infinity, + width: double.infinity, + fit: BoxFit.cover, + ); + } + + @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/media_attachment_thumbnail.dart b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/media_attachment_thumbnail.dart new file mode 100644 index 00000000..ab8c60c5 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/media_attachment_thumbnail.dart @@ -0,0 +1,131 @@ +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/thumbnail_error.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/video_attachment_thumbnail.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +/// {@template mediaAttachmentThumbnail} +/// Widget for building media attachment thumbnail. +/// +/// This widget is used when the [Attachment.type] is [AttachmentType.image], +/// [AttachmentType.video] or [AttachmentType.giphy]. +/// +/// see also: +/// * [StreamImageAttachmentThumbnail] +/// * [StreamVideoAttachmentThumbnail] +/// * [StreamGiphyAttachmentThumbnail] +/// {@endtemplate} +class StreamMediaAttachmentThumbnail extends StatelessWidget { + /// {@macro mediaAttachmentThumbnail} + const StreamMediaAttachmentThumbnail({ + super.key, + required this.media, + this.width, + this.height, + this.fit, + this.thumbnailSize, + this.thumbnailResizeType = 'clip', + this.thumbnailCropType = 'center', + this.gifInfoType = GiphyInfoType.original, + this.errorBuilder = _defaultErrorBuilder, + }); + + /// The giphy attachment to build the thumbnail for. + final Attachment media; + + /// 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; + + /// Size of the attachment image thumbnail. + /// + /// Ignored if the [Attachment.type] is not [AttachmentType.image]. + final Size? thumbnailSize; + + /// Resize type of the image attachment thumbnail. + /// + /// Defaults to [crop] + /// + /// Ignored if the [Attachment.type] is not [AttachmentType.image]. + final String /*clip|crop|scale|fill*/ thumbnailResizeType; + + /// Crop type of the image attachment thumbnail. + /// + /// Defaults to [center] + /// + /// Ignored if the [Attachment.type] is not [AttachmentType.image]. + final String /*center|top|bottom|left|right*/ thumbnailCropType; + + /// The type of giphy thumbnail to build. + /// + /// Ignored if the [Attachment.type] is not [AttachmentType.giphy]. + final GiphyInfoType gifInfoType; + + // Default error builder for image attachment thumbnail. + static Widget _defaultErrorBuilder( + BuildContext context, + Object error, + StackTrace? stackTrace, + ) { + return ThumbnailError( + error: error, + stackTrace: stackTrace, + height: double.infinity, + width: double.infinity, + fit: BoxFit.cover, + ); + } + + @override + Widget build(BuildContext context) { + final type = media.type; + if (type == AttachmentType.image) { + return StreamImageAttachmentThumbnail( + image: media, + width: width, + height: height, + fit: fit, + thumbnailSize: thumbnailSize, + thumbnailResizeType: thumbnailResizeType, + thumbnailCropType: thumbnailCropType, + errorBuilder: errorBuilder, + ); + } + + if (type == AttachmentType.giphy) { + return StreamGiphyAttachmentThumbnail( + giphy: media, + width: width, + height: height, + fit: fit, + type: gifInfoType, + errorBuilder: errorBuilder, + ); + } + + if (type == AttachmentType.video) { + return StreamVideoAttachmentThumbnail( + video: media, + width: width, + height: height, + fit: fit, + errorBuilder: errorBuilder, + ); + } + + return errorBuilder( + context, + 'Unsupported attachment type: $type', + 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..37b288fc --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/thumbnail_error.dart @@ -0,0 +1,54 @@ +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, + this.width, + this.height, + this.fit, + }); + + /// 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; + + /// 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', + width: width, + height: height, + fit: fit, + 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..0be758d5 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/attachment/thumbnail/video_attachment_thumbnail.dart @@ -0,0 +1,129 @@ +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, + height: double.infinity, + width: double.infinity, + fit: BoxFit.cover, + ); + } + + @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..9b44e259 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,137 @@ 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, + 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(8), + ); + + final backgroundColor = messageTheme.urlAttachmentBackgroundColor; + + return Container( + constraints: constraints, + clipBehavior: Clip.hardEdge, + decoration: ShapeDecoration( + shape: shape, + color: backgroundColor, ), - 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, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( 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, - ), - ), - ), - ), - ], - ), + 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, ), - 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)), + ), + 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/attachment_actions_modal/attachment_actions_modal.dart b/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart index 2125c71b..aafab865 100644 --- a/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/attachment_actions_modal/attachment_actions_modal.dart @@ -129,7 +129,7 @@ class AttachmentActionsModal extends StatelessWidget { if (showSave) _buildButton( context, - attachment.type == 'video' + attachment.type == AttachmentType.video ? context.translations.saveVideoLabel : context.translations.saveImageLabel, StreamSvgIcon.iconSave( diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart index 91e65d37..e6dd6a88 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart @@ -36,11 +36,11 @@ class StreamMessagePreviewText extends StatelessWidget { final messageTextParts = [ ...messageAttachments.map((it) { - if (it.type == 'image') { + if (it.type == AttachmentType.image) { return '📷'; - } else if (it.type == 'video') { + } else if (it.type == AttachmentType.video) { return '🎬'; - } else if (it.type == 'giphy') { + } else if (it.type == AttachmentType.giphy) { return '[GIF]'; } return it == message.attachments.last 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 a1708e17..53e6f1a1 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,12 @@ import 'dart:async'; import 'dart:io'; -import 'package:cached_network_image/cached_network_image.dart'; import 'package:chewie/chewie.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/media_attachment_thumbnail.dart'; import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.dart'; +import 'package:stream_chat_flutter/src/fullscreen_media/gallery_navigation_item.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:video_player/video_player.dart'; @@ -71,7 +69,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; } @@ -90,7 +88,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(); @@ -270,7 +269,7 @@ class _FullScreenMediaState extends State { } } if (widget.autoplayVideos && - currentAttachment.type == 'video') { + currentAttachment.type == AttachmentType.video) { final controller = videoPackages[currentAttachment.id]!; controller._chewieController?.play(); } @@ -289,44 +288,21 @@ class _FullScreenMediaState extends State { : Colors.black, child: Builder( builder: (context) { - if (attachment.type == 'image' || - attachment.type == 'giphy') { - final imageUrl = attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl; - - return 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, - ); - }, + if (attachment.type == AttachmentType.image || + attachment.type == AttachmentType.giphy) { + return PhotoView.customChild( maxScale: PhotoViewComputedScale.covered, minScale: PhotoViewComputedScale.contained, - heroAttributes: PhotoViewHeroAttributes( - tag: widget.mediaAttachmentPackages, - ), backgroundDecoration: const BoxDecoration( color: Colors.transparent, ), + child: StreamMediaAttachmentThumbnail( + media: attachment, + width: double.infinity, + height: double.infinity, + ), ); - } else if (attachment.type == 'video') { + } else if (attachment.type == AttachmentType.video) { final controller = videoPackages[attachment.id]!; if (!controller.initialized) { return const Center( @@ -365,74 +341,6 @@ class _FullScreenMediaState extends State { } } -/// A widget for desktop and web users to be able to navigate left and right -/// through a gallery of images. -class GalleryNavigationItem extends StatelessWidget { - /// Builds a [GalleryNavigationItem]. - const GalleryNavigationItem({ - super.key, - required this.icon, - this.iconSize = 48, - required this.onPressed, - required this.opacityAnimation, - this.left, - this.right, - }); - - /// The icon to display. - final Widget icon; - - /// The size of the icon. - /// - /// Defaults to 48. - final double iconSize; - - /// The callback to perform when the button is clicked. - final VoidCallback onPressed; - - /// The animation for showing & hiding this widget. - final ValueListenable opacityAnimation; - - /// The left-hand placement of the button. - final double? left; - - /// The right-hand placement of the button. - final double? right; - - @override - Widget build(BuildContext context) { - return PlatformWidgetBuilder( - desktop: (_, child) => child, - web: (_, child) => child, - child: Positioned( - left: left, - right: right, - top: MediaQuery.of(context).size.height / 2, - child: ValueListenableBuilder( - valueListenable: opacityAnimation, - builder: (context, shouldShow, child) { - return AnimatedOpacity( - opacity: shouldShow ? 1 : 0, - duration: kThemeAnimationDuration, - child: child, - ); - }, - child: Material( - color: Colors.transparent, - type: MaterialType.circle, - clipBehavior: Clip.antiAlias, - child: IconButton( - icon: icon, - iconSize: iconSize, - onPressed: onPressed, - ), - ), - ), - ), - ); - } -} - /// Class for packaging up things required for videos class VideoPackage { /// Constructor for creating [VideoPackage] diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart index 0b870f77..787b4b3d 100644 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart +++ b/packages/stream_chat_flutter/lib/src/fullscreen_media/full_screen_media_desktop.dart @@ -1,13 +1,11 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:contextmenu/contextmenu.dart'; import 'package:dart_vlc/dart_vlc.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/media_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/src/fullscreen_media/gallery_navigation_item.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Returns an instance of [FullScreenMediaDesktop]. @@ -94,7 +92,7 @@ class _FullScreenMediaDesktopState 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 = DesktopVideoPackage(attachment); videoPackages[attachment.id] = package; } @@ -298,7 +296,8 @@ class _FullScreenMediaDesktopState extends State { p.player.pause(); } } - if (widget.autoplayVideos && currentAttachment.type == 'video') { + if (widget.autoplayVideos && + currentAttachment.type == AttachmentType.video) { final package = videoPackages[currentAttachment.id]!; package.player.play(); } @@ -318,44 +317,21 @@ class _FullScreenMediaDesktopState extends State { : Colors.black, child: Builder( builder: (context) { - if (attachment.type == 'image' || - attachment.type == 'giphy') { - final imageUrl = attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl; - - return 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, - ); - }, + if (attachment.type == AttachmentType.image || + attachment.type == AttachmentType.giphy) { + return PhotoView.customChild( maxScale: PhotoViewComputedScale.covered, minScale: PhotoViewComputedScale.contained, - heroAttributes: PhotoViewHeroAttributes( - tag: widget.mediaAttachmentPackages, - ), backgroundDecoration: const BoxDecoration( color: Colors.transparent, ), + child: StreamMediaAttachmentThumbnail( + media: attachment, + width: double.infinity, + height: double.infinity, + ), ); - } else if (attachment.type == 'video') { + } else if (attachment.type == AttachmentType.video) { final package = videoPackages[attachment.id]!; package.player.open( Playlist( @@ -404,74 +380,6 @@ class _FullScreenMediaDesktopState extends State { } } -/// A widget for desktop and web users to be able to navigate left and right -/// through a gallery of images. -class GalleryNavigationItem extends StatelessWidget { - /// Builds a [GalleryNavigationItem]. - const GalleryNavigationItem({ - super.key, - required this.icon, - this.iconSize = 48, - required this.onPressed, - required this.opacityAnimation, - this.left, - this.right, - }); - - /// The icon to display. - final Widget icon; - - /// The size of the icon. - /// - /// Defaults to 48. - final double iconSize; - - /// The callback to perform when the button is clicked. - final VoidCallback onPressed; - - /// The animation for showing & hiding this widget. - final ValueListenable opacityAnimation; - - /// The left-hand placement of the button. - final double? left; - - /// The right-hand placement of the button. - final double? right; - - @override - Widget build(BuildContext context) { - return PlatformWidgetBuilder( - desktop: (_, child) => child, - web: (_, child) => child, - child: Positioned( - left: left, - right: right, - top: MediaQuery.of(context).size.height / 2, - child: ValueListenableBuilder( - valueListenable: opacityAnimation, - builder: (context, shouldShow, child) { - return AnimatedOpacity( - opacity: shouldShow ? 1 : 0, - duration: kThemeAnimationDuration, - child: child, - ); - }, - child: Material( - color: Colors.transparent, - type: MaterialType.circle, - clipBehavior: Clip.antiAlias, - child: IconButton( - icon: icon, - iconSize: iconSize, - onPressed: onPressed, - ), - ), - ), - ), - ); - } -} - /// Class for packaging up things required for videos class DesktopVideoPackage { /// Constructor for creating [VideoPackage] diff --git a/packages/stream_chat_flutter/lib/src/fullscreen_media/gallery_navigation_item.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/gallery_navigation_item.dart new file mode 100644 index 00000000..5d1af701 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/fullscreen_media/gallery_navigation_item.dart @@ -0,0 +1,71 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/platform_widget_builder/src/platform_widget_builder.dart'; + +/// A widget for desktop and web users to be able to navigate left and right +/// through a gallery of images. +class GalleryNavigationItem extends StatelessWidget { + /// Builds a [GalleryNavigationItem]. + const GalleryNavigationItem({ + super.key, + required this.icon, + this.iconSize = 48, + required this.onPressed, + required this.opacityAnimation, + this.left, + this.right, + }); + + /// The icon to display. + final Widget icon; + + /// The size of the icon. + /// + /// Defaults to 48. + final double iconSize; + + /// The callback to perform when the button is clicked. + final VoidCallback onPressed; + + /// The animation for showing & hiding this widget. + final ValueListenable opacityAnimation; + + /// The left-hand placement of the button. + final double? left; + + /// The right-hand placement of the button. + final double? right; + + @override + Widget build(BuildContext context) { + return PlatformWidgetBuilder( + desktop: (_, child) => child, + web: (_, child) => child, + child: Positioned( + left: left, + right: right, + top: MediaQuery.of(context).size.height / 2, + child: ValueListenableBuilder( + valueListenable: opacityAnimation, + builder: (context, shouldShow, child) { + return AnimatedOpacity( + opacity: shouldShow ? 1 : 0, + duration: kThemeAnimationDuration, + child: child, + ); + }, + child: Material( + color: Colors.transparent, + type: MaterialType.circle, + clipBehavior: Clip.antiAlias, + child: IconButton( + icon: icon, + iconSize: iconSize, + onPressed: onPressed, + ), + ), + ), + ), + ); + } +} 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..04928469 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} @@ -94,7 +95,7 @@ class _StreamGalleryFooterState extends State { final url = attachment.imageUrl ?? attachment.assetUrl ?? attachment.thumbUrl!; - final type = attachment.type == 'image' + final type = attachment.type == AttachmentType.image ? 'jpg' : url.split('?').first.split('.').last; final request = await HttpClient().getUrl(Uri.parse(url)); @@ -217,16 +218,15 @@ class _StreamGalleryFooterState extends State { widget.mediaAttachmentPackages[index]; final attachment = attachmentPackage.attachment; final message = attachmentPackage.message; - if (attachment.type == 'video') { + if (attachment.type == AttachmentType.video) { media = MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( 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_actions_modal/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal/message_actions_modal.dart index c9ff5595..2fb83715 100644 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal/message_actions_modal.dart @@ -114,119 +114,121 @@ class _MessageActionsModalState extends State { final child = Center( child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.showReactionPicker && hasReactionPermission) - LayoutBuilder( - builder: (context, constraints) { - return Align( - alignment: Alignment( - calculateReactionsHorizontalAlignment( - user, - widget.message, - constraints, - fontSize, - orientation, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.showReactionPicker && hasReactionPermission) + LayoutBuilder( + builder: (context, constraints) { + return Align( + alignment: Alignment( + calculateReactionsHorizontalAlignment( + user, + widget.message, + constraints, + fontSize, + orientation, + ), + 0, ), - 0, - ), - child: StreamReactionPicker( - message: widget.message, - ), - ); - }, + child: StreamReactionPicker( + message: widget.message, + ), + ); + }, + ), + const SizedBox(height: 10), + IgnorePointer( + child: widget.messageWidget, ), - const SizedBox(height: 10), - IgnorePointer( - child: widget.messageWidget, - ), - const SizedBox(height: 8), - Padding( - padding: EdgeInsets.only( - left: widget.reverse ? 0 : 40, - ), - child: SizedBox( - width: mediaQueryData.size.width * 0.75, - child: Material( - color: streamChatThemeData.colorTheme.appBg, - clipBehavior: Clip.hardEdge, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (widget.showReplyMessage && - widget.message.state.isCompleted) - ReplyButton( - onTap: () { - Navigator.of(context).pop(); - if (widget.onReplyTap != null) { - widget.onReplyTap?.call(widget.message); - } - }, + const SizedBox(height: 8), + Padding( + padding: EdgeInsets.only( + left: widget.reverse ? 0 : 40, + ), + child: SizedBox( + width: mediaQueryData.size.width * 0.75, + child: Material( + color: streamChatThemeData.colorTheme.appBg, + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.showReplyMessage && + widget.message.state.isCompleted) + ReplyButton( + onTap: () { + Navigator.of(context).pop(); + if (widget.onReplyTap != null) { + widget.onReplyTap?.call(widget.message); + } + }, + ), + if (widget.showThreadReplyMessage && + (widget.message.state.isCompleted) && + widget.message.parentId == null) + ThreadReplyButton( + message: widget.message, + onThreadReplyTap: widget.onThreadReplyTap, + ), + if (widget.showResendMessage) + ResendMessageButton( + message: widget.message, + channel: channel, + ), + if (widget.showEditMessage) + EditMessageButton( + onTap: () { + Navigator.of(context).pop(); + _showEditBottomSheet(context); + }, + ), + if (widget.showCopyMessage) + CopyMessageButton( + onTap: () { + widget.onCopyTap?.call(widget.message); + Navigator.of(context).pop(); + }, + ), + if (widget.showFlagButton) + FlagMessageButton( + onTap: _showFlagDialog, + ), + if (widget.showPinButton) + PinMessageButton( + onTap: _togglePin, + pinned: widget.message.pinned, + ), + if (widget.showDeleteMessage) + DeleteMessageButton( + isDeleteFailed: + widget.message.state.isDeletingFailed, + onTap: _showDeleteBottomSheet, + ), + ...widget.customActions + .map((action) => _buildCustomAction( + context, + action, + )), + ].insertBetween( + Container( + height: 1, + color: streamChatThemeData.colorTheme.borders, ), - if (widget.showThreadReplyMessage && - (widget.message.state.isCompleted) && - widget.message.parentId == null) - ThreadReplyButton( - message: widget.message, - onThreadReplyTap: widget.onThreadReplyTap, - ), - if (widget.showResendMessage) - ResendMessageButton( - message: widget.message, - channel: channel, - ), - if (widget.showEditMessage) - EditMessageButton( - onTap: () { - Navigator.of(context).pop(); - _showEditBottomSheet(context); - }, - ), - if (widget.showCopyMessage) - CopyMessageButton( - onTap: () { - widget.onCopyTap?.call(widget.message); - Navigator.of(context).pop(); - }, - ), - if (widget.showFlagButton) - FlagMessageButton( - onTap: _showFlagDialog, - ), - if (widget.showPinButton) - PinMessageButton( - onTap: _togglePin, - pinned: widget.message.pinned, - ), - if (widget.showDeleteMessage) - DeleteMessageButton( - isDeleteFailed: - widget.message.state.isDeletingFailed, - onTap: _showDeleteBottomSheet, - ), - ...widget.customActions - .map((action) => _buildCustomAction( - context, - action, - )), - ].insertBetween( - Container( - height: 1, - color: streamChatThemeData.colorTheme.borders, ), ), ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart index a361f1ad..e37cd379 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/options/stream_gallery_picker.dart @@ -217,7 +217,7 @@ extension StreamImagePickerX on StreamAttachmentPickerController { final extraDataMap = {}; - final mimeType = file.mimeType?.mimeType; + final mimeType = file.mediaType?.mimeType; if (mimeType != null) { extraDataMap['mime_type'] = mimeType; diff --git a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart index 94a81412..6623e8f7 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/attachment_picker/stream_attachment_picker.dart @@ -240,10 +240,10 @@ class WebOrDesktopAttachmentPickerOption extends AttachmentPickerOption { extension AttachmentPickerOptionTypeX on StreamAttachmentPickerController { /// Returns the list of available attachment picker options. Set get currentAttachmentPickerTypes { - final containsImage = value.any((it) => it.type == 'image'); - final containsVideo = value.any((it) => it.type == 'video'); - final containsAudio = value.any((it) => it.type == 'audio'); - final containsFile = value.any((it) => it.type == 'file'); + final containsImage = value.any((it) => it.type == AttachmentType.image); + final containsVideo = value.any((it) => it.type == AttachmentType.video); + final containsAudio = value.any((it) => it.type == AttachmentType.audio); + final containsFile = value.any((it) => it.type == AttachmentType.file); return { if (containsImage) AttachmentPickerType.images, 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..83e04cc6 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,9 +1,10 @@ -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/file_attachment_thumbnail.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/image_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'; + +typedef _Builders = Map; /// {@template streamQuotedMessage} /// Widget for the quoted message. @@ -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, @@ -38,8 +40,7 @@ class StreamQuotedMessageWidget extends StatelessWidget { final int textLimit; /// Map that defines a thumbnail builder for an attachment type - final Map? - attachmentThumbnailBuilders; + final _Builders? attachmentThumbnailBuilders; /// Padding around the widget final EdgeInsetsGeometry padding; @@ -47,6 +48,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 +61,7 @@ class StreamQuotedMessageWidget extends StatelessWidget { messageTheme: messageTheme, showBorder: showBorder, reverse: reverse, + textBuilder: textBuilder, onQuotedMessageClear: onQuotedMessageClear, attachmentThumbnailBuilders: attachmentThumbnailBuilders, ), @@ -90,6 +95,7 @@ class _QuotedMessage extends StatelessWidget { required this.messageTheme, required this.showBorder, required this.reverse, + this.textBuilder, this.onQuotedMessageClear, this.attachmentThumbnailBuilders, }); @@ -100,20 +106,19 @@ 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? - attachmentThumbnailBuilders; + final _Builders? attachmentThumbnailBuilders; bool get _hasAttachments => message.attachments.isNotEmpty; bool get _containsText => message.text?.isNotEmpty == true; bool get _containsLinkAttachment => - message.attachments.any((element) => element.titleLink != null); + message.attachments.any((it) => it.type == AttachmentType.urlPreview); - bool get _isGiphy => - message.attachments.any((element) => element.type == 'giphy'); + bool get _isGiphy => message.attachments + .any((element) => element.type == AttachmentType.giphy); bool get _isDeleted => message.isDeleted || message.deletedAt != null; @@ -142,14 +147,6 @@ class _QuotedMessage extends StatelessWidget { } else { // Show quoted message children = [ - if (onQuotedMessageClear != null) - PlatformWidgetBuilder( - web: (context, child) => child, - desktop: (context, child) => child, - child: ClearInputItemButton( - onTap: onQuotedMessageClear, - ), - ), if (_hasAttachments) _ParseAttachments( message: message, @@ -158,24 +155,38 @@ 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)); + ]; } + // Add clear button if needed. + if (isDesktopDeviceOrWeb && onQuotedMessageClear != null) { + children.insert( + 0, + ClearInputItemButton(onTap: onQuotedMessageClear), + ); + } + + // Add some spacing between the children. + children = children.insertBetween(const SizedBox(width: 8)); + return Container( decoration: BoxDecoration( color: _getBackgroundColor(context), @@ -218,192 +229,106 @@ class _ParseAttachments extends StatelessWidget { final Message message; final StreamMessageThemeData messageTheme; - final Map? - attachmentThumbnailBuilders; - - bool get _containsLinkAttachment => - message.attachments.any((element) => element.titleLink != null); + final _Builders? attachmentThumbnailBuilders; @override Widget build(BuildContext context) { - Widget child; - Attachment attachment; - if (_containsLinkAttachment) { - attachment = message.attachments.firstWhere( - (element) => element.ogScrapeUrl != null || element.titleLink != null, + final attachment = message.attachments.first; + + var attachmentBuilders = attachmentThumbnailBuilders; + attachmentBuilders ??= _createDefaultAttachmentBuilders(); + + // Build the attachment widget using the builder for the attachment type. + final attachmentWidget = attachmentBuilders[attachment.type]?.call( + context, + attachment, + ); + + // Return empty container if no attachment widget is returned. + if (attachmentWidget == null) return const SizedBox.shrink(); + + final colorTheme = StreamChatTheme.of(context).colorTheme; + + var clipBehavior = Clip.none; + ShapeDecoration? decoration; + if (attachment.type != AttachmentType.file) { + clipBehavior = Clip.hardEdge; + decoration = ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide( + color: colorTheme.borders, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: BorderRadius.circular(8), + ), ); - child = _UrlAttachment(attachment: attachment); - } else { - QuotedMessageAttachmentThumbnailBuilder? attachmentBuilder; - attachment = message.attachments.last; - if (attachmentThumbnailBuilders?.containsKey(attachment.type) == true) { - attachmentBuilder = attachmentThumbnailBuilders![attachment.type]; - } - attachmentBuilder = _defaultAttachmentBuilder[attachment.type]; - if (attachmentBuilder == null) { - child = const Offstage(); - } else { - child = attachmentBuilder(context, attachment); - } } - final isImageFile = attachment.title?.mimeType?.type == 'image'; - final isVideoFile = attachment.title?.mimeType?.type == 'video'; + return Container( + key: Key(attachment.id), + clipBehavior: clipBehavior, + decoration: decoration, + constraints: const BoxConstraints.tightFor(width: 36, height: 36), + child: AbsorbPointer(child: attachmentWidget), + ); + } - return Material( - clipBehavior: Clip.hardEdge, - type: MaterialType.transparency, - shape: attachment.type == 'file' && (!isImageFile && !isVideoFile) - ? null - : RoundedRectangleBorder( - side: const BorderSide(width: 0, color: Colors.transparent), + _Builders _createDefaultAttachmentBuilders() { + Widget _createMediaThumbnail(BuildContext context, Attachment media) { + return StreamImageAttachmentThumbnail( + image: media, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ); + } + + Widget _createUrlThumbnail(BuildContext context, Attachment media) { + return StreamImageAttachmentThumbnail( + image: media, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ); + } + + Widget _createFileThumbnail(BuildContext context, Attachment file) { + Widget thumbnail = StreamFileAttachmentThumbnail( + file: file, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ); + + final mediaType = file.title?.mediaType; + final isImage = mediaType?.type == AttachmentType.image; + final isVideo = mediaType?.type == AttachmentType.video; + if (isImage || isVideo) { + final colorTheme = StreamChatTheme.of(context).colorTheme; + thumbnail = Container( + clipBehavior: Clip.hardEdge, + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide( + color: colorTheme.borders, + strokeAlign: BorderSide.strokeAlignOutside, + ), borderRadius: BorderRadius.circular(8), ), - child: AbsorbPointer(child: child), - ); - } - - Map - get _defaultAttachmentBuilder { - final builders = { - 'image': (_, attachment) { - return StreamImageAttachment( - attachment: attachment, - message: message, - messageTheme: messageTheme, - constraints: BoxConstraints.loose(const Size(32, 32)), - ); - }, - 'video': (_, attachment) { - return StreamVideoThumbnailImage( - 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)), ), + child: thumbnail, ); - }, - 'giphy': (_, attachment) { - const size = Size(32, 32); - return CachedNetworkImage( - height: size.height, - width: size.width, - placeholder: (_, __) { - return SizedBox( - width: size.width, - height: size.height, - child: const Center( - child: CircularProgressIndicator.adaptive(), - ), - ); - }, - imageUrl: attachment.thumbUrl ?? - attachment.imageUrl ?? - attachment.assetUrl!, - errorWidget: (context, url, error) => - AttachmentError(constraints: BoxConstraints.loose(size)), - fit: BoxFit.cover, - ); - }, - }; + } - builders['file'] = (_, attachment) { - return SizedBox( - height: 32, - width: 32, - child: Builder( - builder: (context) { - final isImageFile = attachment.title?.mimeType?.type == 'image'; - if (isImageFile) { - return builders['image']!(context, attachment); - } - - final isVideoFile = attachment.title?.mimeType?.type == 'video'; - if (isVideoFile) { - return builders['video']!(context, attachment); - } - - return getFileTypeImage( - attachment.extraData['mime_type'] as String?, - ); - }, - ), - ); - }; - - return builders; - } -} - -class _UrlAttachment extends StatelessWidget { - const _UrlAttachment({ - required this.attachment, - }); - - final Attachment attachment; - - @override - Widget build(BuildContext context) { - const size = Size(32, 32); - if (attachment.thumbUrl != null) { - return Container( - height: size.height, - width: size.width, - decoration: BoxDecoration( - image: DecorationImage( - fit: BoxFit.cover, - image: CachedNetworkImageProvider( - attachment.thumbUrl!, - ), - ), - ), - ); + return thumbnail; } - return AttachmentError(constraints: BoxConstraints.loose(size)); - } -} -class _VideoAttachmentThumbnail extends StatefulWidget { - const _VideoAttachmentThumbnail({ - required this.attachment, - }); - - final Attachment attachment; - - @override - _VideoAttachmentThumbnailState createState() => - _VideoAttachmentThumbnailState(); -} - -class _VideoAttachmentThumbnailState extends State<_VideoAttachmentThumbnail> { - late VideoPlayerController _controller; - - @override - void initState() { - super.initState(); - _controller = VideoPlayerController.networkUrl( - Uri.parse(widget.attachment.assetUrl!), - )..initialize().then((_) { - // ignore: no-empty-block - setState(() {}); //when your thumbnail will show. - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 32, - width: 32, - child: _controller.value.isInitialized - ? VideoPlayer(_controller) - : const CircularProgressIndicator.adaptive(), - ); + return { + AttachmentType.image: _createMediaThumbnail, + AttachmentType.giphy: _createMediaThumbnail, + AttachmentType.video: _createMediaThumbnail, + AttachmentType.urlPreview: _createUrlThumbnail, + AttachmentType.file: _createFileThumbnail, + }; } } diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart index 549809d3..7a02dac1 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_input.dart @@ -1170,7 +1170,7 @@ class StreamMessageInputState extends State } final containsUrl = quotedMessage.attachments.any((it) { - return it.titleLink != null; + return it.type == AttachmentType.urlPreview; }); return StreamQuotedMessageWidget( @@ -1317,8 +1317,6 @@ class StreamMessageInputState extends State message = message.copyWith(text: '/${message.command} ${message.text}'); } - final skipEnrichUrl = _effectiveController.ogAttachment == null; - var shouldKeepFocus = widget.shouldKeepFocusAfterMessage; shouldKeepFocus ??= !_commandEnabled; @@ -1342,10 +1340,7 @@ class StreamMessageInputState extends State await WidgetsBinding.instance.endOfFrame; } - await _sendOrUpdateMessage( - message: message, - skipEnrichUrl: skipEnrichUrl, - ); + await _sendOrUpdateMessage(message: message); if (mounted) { if (shouldKeepFocus) { @@ -1358,36 +1353,29 @@ class StreamMessageInputState extends State Future _sendOrUpdateMessage({ required Message message, - bool skipEnrichUrl = false, }) async { final channel = StreamChannel.of(context).channel; try { Future sendingFuture; if (_isEditing) { - sendingFuture = channel.updateMessage( - message, - skipEnrichUrl: skipEnrichUrl, - ); + sendingFuture = channel.updateMessage(message); } else { - sendingFuture = channel.sendMessage( - message, - skipEnrichUrl: skipEnrichUrl, - ); + sendingFuture = channel.sendMessage(message); } final resp = await sendingFuture; - if (resp.message?.type == 'error') { + if (resp.message?.isError ?? false) { _effectiveController.message = message; } _startSlowMode(); widget.onMessageSent?.call(resp.message); } catch (e, stk) { if (widget.onError != null) { - widget.onError?.call(e, stk); - } else { - rethrow; + return widget.onError?.call(e, stk); } + + rethrow; } } 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..56dabbfe 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 @@ -1,10 +1,9 @@ -import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/src/attachment/attachment.dart'; +import 'package:stream_chat_flutter/src/attachment/file_attachment.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/media_attachment_thumbnail.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'; /// WidgetBuilder used to build the message input attachment list. @@ -91,7 +90,7 @@ class _StreamMessageInputAttachmentListState // Split the attachments into file and media attachments. for (final attachment in widget.attachments) { - if (attachment.type == 'file') { + if (attachment.type == AttachmentType.file) { fileAttachments.add(attachment); } else { mediaAttachments.add(attachment); @@ -121,7 +120,7 @@ class _StreamMessageInputAttachmentListState } return SingleChildScrollView( - padding: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.only(top: 6), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -201,23 +200,19 @@ class MessageInputFileAttachments extends StatelessWidget { } // Otherwise, use the default builder. - return ClipRRect( - key: Key(attachment.id), - borderRadius: BorderRadius.circular(10), - child: StreamFileAttachment( - message: Message(), // dummy message - attachment: attachment, - constraints: BoxConstraints.loose(Size( - MediaQuery.of(context).size.width * 0.65, - 56, - )), - trailing: Padding( - padding: const EdgeInsets.all(8), - child: RemoveAttachmentButton( - onPressed: onRemovePressed != null - ? () => onRemovePressed!(attachment) - : null, - ), + return StreamFileAttachment( + message: Message(), // Dummy message + file: attachment, + constraints: BoxConstraints.loose(Size( + MediaQuery.of(context).size.width * 0.65, + 56, + )), + trailing: Padding( + padding: const EdgeInsets.all(8), + child: RemoveAttachmentButton( + onPressed: onRemovePressed != null + ? () => onRemovePressed!(attachment) + : null, ), ), ); @@ -256,7 +251,8 @@ class MessageInputMediaAttachments extends StatelessWidget { height: 104, child: ListView( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 8), + cacheExtent: 104 * 10, // Cache 10 items ahead. children: attachments.map( (attachment) { // If a custom builder is provided, use it. @@ -265,27 +261,47 @@ class MessageInputMediaAttachments extends StatelessWidget { return builder(context, attachment, onRemovePressed); } - return ClipRRect( + final colorTheme = StreamChatTheme.of(context).colorTheme; + final shape = RoundedRectangleBorder( + side: BorderSide( + color: colorTheme.borders, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: BorderRadius.circular(14), + ); + + return Container( key: Key(attachment.id), - borderRadius: BorderRadius.circular(10), - child: Stack( - children: [ - AspectRatio( - aspectRatio: 1, - child: MessageInputMediaAttachmentThumbnail( - attachment: attachment, + clipBehavior: Clip.hardEdge, + decoration: ShapeDecoration(shape: shape), + child: AspectRatio( + aspectRatio: 1, + child: Stack( + alignment: Alignment.center, + children: [ + StreamMediaAttachmentThumbnail( + media: attachment, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, ), - ), - Positioned( - top: 8, - right: 8, - child: RemoveAttachmentButton( - onPressed: onRemovePressed != null - ? () => onRemovePressed!(attachment) - : null, + if (attachment.type == AttachmentType.video) + Positioned( + left: 8, + bottom: 8, + child: StreamSvgIcon.videoCall(), + ), + Positioned( + top: 8, + right: 8, + child: RemoveAttachmentButton( + onPressed: onRemovePressed != null + ? () => onRemovePressed!(attachment) + : null, + ), ), - ), - ], + ], + ), ), ); }, @@ -295,63 +311,6 @@ class MessageInputMediaAttachments extends StatelessWidget { } } -/// A widget that displays a thumbnail for a media attachment. -class MessageInputMediaAttachmentThumbnail extends StatelessWidget { - /// Creates a new media attachment widget. - const MessageInputMediaAttachmentThumbnail({ - super.key, - required this.attachment, - }); - - /// The attachment to display. - final Attachment attachment; - - @override - Widget build(BuildContext context) { - switch (attachment.type) { - case 'image': - case 'giphy': - return attachment.file != null - ? Image.memory( - attachment.file!.bytes!, - fit: BoxFit.cover, - errorBuilder: (context, _, __) => Image.asset( - 'images/placeholder.png', - package: 'stream_chat_flutter', - ), - ) - : CachedNetworkImage( - imageUrl: attachment.imageUrl ?? - attachment.assetUrl ?? - attachment.thumbUrl!, - fit: BoxFit.cover, - errorWidget: (_, obj, trace) => Image.asset( - 'images/placeholder.png', - package: 'stream_chat_flutter', - ), - ); - case 'video': - return Stack( - children: [ - StreamVideoThumbnailImage( - video: attachment.file?.path ?? attachment.assetUrl, - ), - Positioned( - left: 8, - bottom: 10, - child: StreamSvgIcon.videoCall(), - ), - ], - ); - default: - return const ColoredBox( - color: Colors.black26, - child: Icon(Icons.insert_drive_file), - ); - } - } -} - /// Material Button used for removing attachments. class RemoveAttachmentButton extends StatelessWidget { /// Creates a new remove attachment button. diff --git a/packages/stream_chat_flutter/lib/src/message_list_view/floating_date_divider.dart b/packages/stream_chat_flutter/lib/src/message_list_view/floating_date_divider.dart index dbaccc17..61b43e21 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/floating_date_divider.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/floating_date_divider.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/src/message_list_view/mlv_utils.dart'; @@ -22,7 +23,7 @@ class FloatingDateDivider extends StatelessWidget { final bool isThreadConversation; // ignore: public_member_api_docs - final ItemPositionsListener itemPositionListener; + final ValueListenable> itemPositionListener; // ignore: public_member_api_docs final bool reverse; @@ -38,61 +39,38 @@ class FloatingDateDivider extends StatelessWidget { @override Widget build(BuildContext context) { - return Positioned( - top: 20, - left: 0, - right: 0, - child: BetterStreamBuilder>( - initialData: itemPositionListener.itemPositions.value, - stream: valueListenableToStreamAdapter( - itemPositionListener.itemPositions, - ), - comparator: (a, b) { - if (a == null || b == null) { - return false; - } + return ValueListenableBuilder( + valueListenable: itemPositionListener, + builder: (context, positions, child) { + if (positions.isEmpty || messages.isEmpty) { + return const Offstage(); + } + + int? index; + if (reverse) { + index = getTopElementIndex(positions); + } else { + index = getBottomElementIndex(positions); + } + + if ((index == null) || + (!isThreadConversation && index == itemCount - 2) || + (isThreadConversation && index == itemCount - 1)) { + return const Offstage(); + } + + if (index <= 2 || index >= itemCount - 3) { if (reverse) { - final aTop = getTopElementIndex(a); - final bTop = getTopElementIndex(b); - return aTop == bTop; + index = itemCount - 4; } else { - final aBottom = getBottomElementIndex(a); - final bBottom = getBottomElementIndex(b); - return aBottom == bBottom; - } - }, - builder: (context, values) { - if (values.isEmpty || messages.isEmpty) { - return const Offstage(); + index = 2; } + } - int? index; - if (reverse) { - index = getTopElementIndex(values); - } else { - index = getBottomElementIndex(values); - } - - if ((index == null) || - (!isThreadConversation && index == itemCount - 2) || - (isThreadConversation && index == itemCount - 1)) { - return const Offstage(); - } - - if (index <= 2 || index >= itemCount - 3) { - if (reverse) { - index = itemCount - 4; - } else { - index = 2; - } - } - - final message = messages[index - 2]; - return dateDividerBuilder != null - ? dateDividerBuilder!(message.createdAt.toLocal()) - : StreamDateDivider(dateTime: message.createdAt.toLocal()); - }, - ), + final message = messages[index - 2]; + return dateDividerBuilder?.call(message.createdAt.toLocal()) ?? + StreamDateDivider(dateTime: message.createdAt.toLocal()); + }, ); } } 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..f3ed4084 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 @@ -103,6 +104,7 @@ class StreamMessageListView extends StatefulWidget { this.loadingBuilder, this.emptyBuilder, this.systemMessageBuilder, + this.ephemeralMessageBuilder, this.messageListBuilder, this.errorBuilder, this.messageFilter, @@ -148,6 +150,9 @@ class StreamMessageListView extends StatefulWidget { /// {@macro systemMessageBuilder} final SystemMessageBuilder? systemMessageBuilder; + /// {@macro ephemeralMessageBuilder} + final EphemeralMessageBuilder? ephemeralMessageBuilder; + /// {@macro parentMessageBuilder} final ParentMessageBuilder? parentMessageBuilder; @@ -805,13 +810,18 @@ class _StreamMessageListViewState extends State { ), ), if (widget.showFloatingDateDivider) - FloatingDateDivider( - itemCount: itemCount, - reverse: widget.reverse, - itemPositionListener: _itemPositionListener, - messages: messages, - dateDividerBuilder: widget.dateDividerBuilder, - isThreadConversation: _isThreadConversation, + Positioned( + top: 20, + left: 0, + right: 0, + child: FloatingDateDivider( + itemCount: itemCount, + reverse: widget.reverse, + itemPositionListener: _itemPositionListener.itemPositions, + messages: messages, + dateDividerBuilder: widget.dateDividerBuilder, + isThreadConversation: _isThreadConversation, + ), ), ], ); @@ -914,13 +924,19 @@ class _StreamMessageListViewState extends State { final currentUserMember = members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); + final hasFileAttachment = + message.attachments.any((it) => it.type == AttachmentType.file); + final hasUrlAttachment = - message.attachments.any((it) => it.ogScrapeUrl != null); + message.attachments.any((it) => it.type == AttachmentType.urlPreview); - final isEphemeral = message.isEphemeral; + final attachmentBorderRadius = hasUrlAttachment + ? 8.0 + : hasFileAttachment + ? 12.0 + : 14.0; - final borderSide = - isOnlyEmoji || hasUrlAttachment || isEphemeral ? BorderSide.none : null; + final borderSide = isOnlyEmoji ? BorderSide.none : null; final defaultMessageWidget = StreamMessageWidget( showReplyMessage: false, @@ -934,13 +950,34 @@ class _StreamMessageListViewState extends State { showUsername: !isMyMessage, padding: const EdgeInsets.all(8), showSendingIndicator: false, + attachmentPadding: EdgeInsets.all( + hasUrlAttachment + ? 8 + : hasFileAttachment + ? 4 + : 2, + ), + attachmentShape: RoundedRectangleBorder( + side: BorderSide( + color: _streamTheme.colorTheme.borders, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(attachmentBorderRadius), + bottomLeft: isMyMessage + ? Radius.circular(attachmentBorderRadius) + : Radius.zero, + topRight: Radius.circular(attachmentBorderRadius), + bottomRight: isMyMessage + ? Radius.zero + : Radius.circular(attachmentBorderRadius), + ), + ), borderRadiusGeometry: BorderRadius.only( topLeft: const Radius.circular(16), - bottomLeft: - isMyMessage ? const Radius.circular(16) : const Radius.circular(2), + bottomLeft: isMyMessage ? const Radius.circular(16) : Radius.zero, topRight: const Radius.circular(16), - bottomRight: - isMyMessage ? const Radius.circular(2) : const Radius.circular(16), + bottomRight: isMyMessage ? Radius.zero : const Radius.circular(16), ), textPadding: EdgeInsets.symmetric( vertical: 8, @@ -1047,7 +1084,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 +1096,11 @@ class _StreamMessageListViewState extends State { ); } + if (message.isEphemeral) { + return widget.ephemeralMessageBuilder?.call(context, message) ?? + StreamEphemeralMessage(message: message); + } + final userId = StreamChat.of(context).currentUser!.id; final isMyMessage = message.user?.id == userId; final nextMessage = index - 1 >= 0 ? messages[index - 1] : null; @@ -1076,14 +1118,21 @@ class _StreamMessageListViewState extends State { } final hasFileAttachment = - message.attachments.any((it) => it.type == 'file'); + message.attachments.any((it) => it.type == AttachmentType.file); + + final hasUrlAttachment = + message.attachments.any((it) => it.type == AttachmentType.urlPreview); final isThreadMessage = message.parentId != null && message.showInChannel == true; final hasReplies = message.replyCount! > 0; - final attachmentBorderRadius = hasFileAttachment ? 12.0 : 14.0; + final attachmentBorderRadius = hasUrlAttachment + ? 8.0 + : hasFileAttachment + ? 12.0 + : 14.0; final showTimeStamp = (!isThreadMessage || _isThreadConversation) && !hasReplies && @@ -1107,13 +1156,7 @@ class _StreamMessageListViewState extends State { final showThreadReplyIndicator = !_isThreadConversation && hasReplies; final isOnlyEmoji = message.text?.isOnlyEmoji ?? false; - final isEphemeral = message.isEphemeral; - - final hasUrlAttachment = - message.attachments.any((it) => it.ogScrapeUrl != null); - - final borderSide = - isOnlyEmoji || hasUrlAttachment || isEphemeral ? BorderSide.none : null; + final borderSide = isOnlyEmoji ? BorderSide.none : null; final currentUser = StreamChat.of(context).currentUser; final members = StreamChannel.of(context).channel.state?.members ?? []; @@ -1158,27 +1201,39 @@ class _StreamMessageListViewState extends State { showFlagButton: !isMyMessage, borderSide: borderSide, onThreadTap: _onThreadTap, - attachmentBorderRadiusGeometry: BorderRadius.only( - topLeft: Radius.circular(attachmentBorderRadius), - bottomLeft: isMyMessage - ? Radius.circular(attachmentBorderRadius) - : Radius.circular( - (hasTimeDiff || !isNextUserSame) && - !(hasReplies || isThreadMessage || hasFileAttachment) - ? 0 - : attachmentBorderRadius, - ), - topRight: Radius.circular(attachmentBorderRadius), - bottomRight: isMyMessage - ? Radius.circular( - (hasTimeDiff || !isNextUserSame) && - !(hasReplies || isThreadMessage || hasFileAttachment) - ? 0 - : attachmentBorderRadius, - ) - : Radius.circular(attachmentBorderRadius), + attachmentShape: RoundedRectangleBorder( + side: BorderSide( + color: _streamTheme.colorTheme.borders, + strokeAlign: BorderSide.strokeAlignOutside, + ), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(attachmentBorderRadius), + bottomLeft: isMyMessage + ? Radius.circular(attachmentBorderRadius) + : Radius.circular( + (hasTimeDiff || !isNextUserSame) && + !(hasReplies || isThreadMessage || hasFileAttachment) + ? 0 + : attachmentBorderRadius, + ), + topRight: Radius.circular(attachmentBorderRadius), + bottomRight: isMyMessage + ? Radius.circular( + (hasTimeDiff || !isNextUserSame) && + !(hasReplies || isThreadMessage || hasFileAttachment) + ? 0 + : attachmentBorderRadius, + ) + : Radius.circular(attachmentBorderRadius), + ), + ), + attachmentPadding: EdgeInsets.all( + hasUrlAttachment + ? 8 + : hasFileAttachment + ? 4 + : 2, ), - attachmentPadding: EdgeInsets.all(hasFileAttachment ? 4 : 2), borderRadiusGeometry: BorderRadius.only( topLeft: const Radius.circular(16), bottomLeft: isMyMessage 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..b1e3d271 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 @@ -1,7 +1,4 @@ -import 'dart:async'; - import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -74,30 +71,3 @@ int? getBottomElementIndex(Iterable values) { bool isInitialMessage(String id, StreamChannelState? channelState) { return channelState!.initialMessageId == id; } - -/// Converts a [ValueListenable] to a [Stream]. -Stream valueListenableToStreamAdapter(ValueListenable listenable) { - // ignore: close_sinks - late StreamController _controller; - - void listener() { - _controller.add(listenable.value); - } - - void start() { - listenable.addListener(listener); - } - - void end() { - listenable.removeListener(listener); - } - - _controller = StreamController( - onListen: start, - onPause: end, - onResume: start, - onCancel: end, - ); - - return _controller.stream; -} diff --git a/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart b/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart index 83a40a4d..d7eb2f6c 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/bottom_row.dart @@ -201,8 +201,8 @@ class BottomRow extends StatelessWidget { ), ]; - final showThreadTail = !(hasUrlAttachments || isGiphy || isOnlyEmoji) && - (showThreadReplyIndicator || showInChannel); + final showThreadTail = + (showThreadReplyIndicator || showInChannel) && !isOnlyEmoji; final threadIndicatorWidgets = [ if (showThreadTail) 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..455da220 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,11 @@ class MessageCard extends StatefulWidget { required this.isGiphy, required this.attachmentBuilders, required this.attachmentPadding, + required this.attachmentShape, + required this.onAttachmentTap, + required this.onShowMessage, + required this.onReplyTap, + required this.attachmentActionsModalBuilder, required this.textPadding, required this.reverse, this.shape, @@ -72,11 +76,26 @@ class MessageCard extends StatefulWidget { final Message message; /// {@macro attachmentBuilders} - final Map attachmentBuilders; + final List? attachmentBuilders; /// {@macro attachmentPadding} final EdgeInsetsGeometry attachmentPadding; + /// {@macro attachmentShape} + final ShapeBorder? attachmentShape; + + /// {@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 +122,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 +159,71 @@ 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, + attachmentShape: widget.attachmentShape, + 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 +232,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 +243,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 6185c04a..1a0e71ed 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,18 +42,16 @@ enum DisplayWidget { /// {@endtemplate} class StreamMessageWidget extends StatefulWidget { /// {@macro messageWidget} - StreamMessageWidget({ + const StreamMessageWidget({ super.key, required this.message, required this.messageTheme, this.reverse = false, this.translateUserAvatar = true, this.shape, - this.attachmentShape, this.borderSide, - this.attachmentBorderSide, this.borderRadiusGeometry, - this.attachmentBorderRadiusGeometry, + this.attachmentShape, this.onMentionTap, this.onMessageTap, this.onReactionsTap, @@ -86,7 +85,7 @@ class StreamMessageWidget extends StatefulWidget { this.editMessageInputBuilder, this.textBuilder, this.bottomRowBuilderWithDefaultWidget, - this.customAttachmentBuilders, + this.attachmentBuilders, this.padding, this.textPadding = const EdgeInsets.symmetric( horizontal: 16, @@ -101,185 +100,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 @@ -362,21 +333,11 @@ class StreamMessageWidget extends StatefulWidget { /// {@endtemplate} final BorderSide? borderSide; - /// {@template attachmentBorderSide} - /// The borderSide of an attachment - /// {@endtemplate} - final BorderSide? attachmentBorderSide; - /// {@template borderRadiusGeometry} /// The border radius of the message text /// {@endtemplate} final BorderRadiusGeometry? borderRadiusGeometry; - /// {@template attachmentBorderRadiusGeometry} - /// The border radius of an attachment - /// {@endtemplate} - final BorderRadiusGeometry? attachmentBorderRadiusGeometry; - /// {@template padding} /// The padding of the widget /// {@endtemplate} @@ -505,14 +466,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 @@ -534,7 +494,7 @@ class StreamMessageWidget extends StatefulWidget { final List customActions; /// {@macro onMessageWidgetAttachmentTap} - final OnMessageWidgetAttachmentTap? onAttachmentTap; + final StreamAttachmentWidgetTapCallback? onAttachmentTap; /// {@macro attachmentActionsBuilder} final AttachmentActionsBuilder? attachmentActionsModalBuilder; @@ -574,9 +534,7 @@ class StreamMessageWidget extends StatefulWidget { ShapeBorder? shape, ShapeBorder? attachmentShape, BorderSide? borderSide, - BorderSide? attachmentBorderSide, BorderRadiusGeometry? borderRadiusGeometry, - BorderRadiusGeometry? attachmentBorderRadiusGeometry, EdgeInsetsGeometry? padding, EdgeInsets? textPadding, EdgeInsetsGeometry? attachmentPadding, @@ -604,7 +562,7 @@ class StreamMessageWidget extends StatefulWidget { bool? showFlagButton, bool? showPinButton, bool? showPinHighlight, - Map? customAttachmentBuilders, + List? attachmentBuilders, bool? translateUserAvatar, OnQuotedMessageTap? onQuotedMessageTap, void Function(Message)? onMessageTap, @@ -636,10 +594,7 @@ class StreamMessageWidget extends StatefulWidget { shape: shape ?? this.shape, attachmentShape: attachmentShape ?? this.attachmentShape, borderSide: borderSide ?? this.borderSide, - attachmentBorderSide: attachmentBorderSide ?? this.attachmentBorderSide, borderRadiusGeometry: borderRadiusGeometry ?? this.borderRadiusGeometry, - attachmentBorderRadiusGeometry: - attachmentBorderRadiusGeometry ?? this.attachmentBorderRadiusGeometry, padding: padding ?? this.padding, textPadding: textPadding ?? this.textPadding, attachmentPadding: attachmentPadding ?? this.attachmentPadding, @@ -669,8 +624,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, @@ -726,8 +680,8 @@ class _StreamMessageWidgetState extends State /// {@template isGiphy} /// `true` if any of the [message]'s attachments are a giphy. /// {@endtemplate} - bool get isGiphy => - widget.message.attachments.any((element) => element.type == 'giphy'); + bool get isGiphy => widget.message.attachments + .any((element) => element.type == AttachmentType.giphy); /// {@template isOnlyEmoji} /// `true` if [message.text] contains only emoji. @@ -739,15 +693,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`: @@ -785,7 +738,8 @@ class _StreamMessageWidgetState extends State bool get shouldShowEditAction => widget.showEditMessage && !isDeleteFailed && - !widget.message.attachments.any((element) => element.type == 'giphy'); + !widget.message.attachments + .any((element) => element.type == AttachmentType.giphy); bool get shouldShowResendAction => widget.showResendMessage && (isSendFailed || isUpdateFailed); @@ -798,7 +752,8 @@ class _StreamMessageWidgetState extends State bool get shouldShowEditMessage => widget.showEditMessage && !isDeleteFailed && - !widget.message.attachments.any((element) => element.type == 'giphy'); + !widget.message.attachments + .any((element) => element.type == AttachmentType.giphy); bool get shouldShowThreadReplyAction => widget.showThreadReplyMessage && @@ -889,6 +844,12 @@ class _StreamMessageWidgetState extends State textPadding: widget.textPadding, attachmentBuilders: widget.attachmentBuilders, attachmentPadding: widget.attachmentPadding, + attachmentShape: widget.attachmentShape, + 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..59c99732 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,11 @@ class MessageWidgetContent extends StatelessWidget { required this.isGiphy, required this.attachmentBuilders, required this.attachmentPadding, + required this.attachmentShape, + required this.onAttachmentTap, + required this.onShowMessage, + required this.onReplyTap, + required this.attachmentActionsModalBuilder, required this.textPadding, required this.showReactionPickerTail, required this.translateUserAvatar, @@ -140,11 +146,26 @@ class MessageWidgetContent extends StatelessWidget { final bool isGiphy; /// {@macro attachmentBuilders} - final Map attachmentBuilders; + final List? attachmentBuilders; /// {@macro attachmentPadding} final EdgeInsetsGeometry attachmentPadding; + /// {@macro attachmentShape} + final ShapeBorder? attachmentShape; + + /// {@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 +345,12 @@ class MessageWidgetContent extends StatelessWidget { isGiphy: isGiphy, attachmentBuilders: attachmentBuilders, attachmentPadding: attachmentPadding, + attachmentShape: attachmentShape, + 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..57b849ed 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,126 @@ class ParseAttachments extends StatelessWidget { required this.message, required this.attachmentBuilders, required this.attachmentPadding, + this.attachmentShape, + 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 attachmentShape} + final ShapeBorder? attachmentShape; + + /// {@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, + shape: attachmentShape, + padding: attachmentPadding, + 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/message_widget/reactions/message_reactions_modal.dart b/packages/stream_chat_flutter/lib/src/message_widget/reactions/message_reactions_modal.dart index 8dadc47d..072ee5be 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/reactions/message_reactions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/reactions/message_reactions_modal.dart @@ -50,45 +50,47 @@ class StreamMessageReactionsModal extends StatelessWidget { final child = Center( child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showReactionPicker && hasReactionPermission) - LayoutBuilder( - builder: (context, constraints) { - return Align( - alignment: Alignment( - calculateReactionsHorizontalAlignment( - user, - message, - constraints, - fontSize, - orientation, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showReactionPicker && hasReactionPermission) + LayoutBuilder( + builder: (context, constraints) { + return Align( + alignment: Alignment( + calculateReactionsHorizontalAlignment( + user, + message, + constraints, + fontSize, + orientation, + ), + 0, ), - 0, - ), - child: StreamReactionPicker( - message: message, - ), - ); - }, - ), - const SizedBox(height: 10), - IgnorePointer( - child: messageWidget, - ), - if (message.latestReactions?.isNotEmpty == true) ...[ - const SizedBox(height: 8), - ReactionsCard( - currentUser: user!, - message: message, - messageTheme: messageTheme, + child: StreamReactionPicker( + message: message, + ), + ); + }, + ), + const SizedBox(height: 10), + IgnorePointer( + child: messageWidget, ), + if (message.latestReactions?.isNotEmpty == true) ...[ + const SizedBox(height: 8), + ReactionsCard( + currentUser: user!, + message: message, + messageTheme: messageTheme, + ), + ], ], - ], + ), ), ), ), diff --git a/packages/stream_chat_flutter/lib/src/message_widget/text_bubble.dart b/packages/stream_chat_flutter/lib/src/message_widget/text_bubble.dart index 2fa017ed..af5260a2 100644 --- a/packages/stream_chat_flutter/lib/src/message_widget/text_bubble.dart +++ b/packages/stream_chat_flutter/lib/src/message_widget/text_bubble.dart @@ -51,7 +51,7 @@ class TextBubble extends StatelessWidget { @override Widget build(BuildContext context) { - if (message.text?.trim().isEmpty ?? false) return const Offstage(); + if (message.text?.trim().isEmpty ?? true) return const Offstage(); return Padding( padding: isOnlyEmoji ? EdgeInsets.zero : textPadding, child: textBuilder != null 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/scroll_view/channel_scroll_view/stream_channel_list_tile.dart b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart index b1b165ba..36b676f4 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/channel_scroll_view/stream_channel_list_tile.dart @@ -228,8 +228,7 @@ class StreamChannelListTile extends StatelessWidget { } final hasNonUrlAttachments = lastMessage.attachments - .where((it) => it.titleLink == null || it.type == 'giphy') - .isNotEmpty; + .any((it) => it.type != AttachmentType.urlPreview); return Padding( padding: const EdgeInsets.only(right: 4), 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/theme/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart index e7449343..c24948b3 100644 --- a/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/stream_chat_theme.dart @@ -201,6 +201,7 @@ class StreamChatThemeData { urlAttachmentTitleStyle: textTheme.footnoteBold, urlAttachmentTextStyle: textTheme.footnote, urlAttachmentTitleMaxLine: 1, + urlAttachmentTextMaxLine: 3, ), otherMessageTheme: StreamMessageThemeData( reactionsBackgroundColor: colorTheme.borders, @@ -227,6 +228,7 @@ class StreamChatThemeData { urlAttachmentTitleStyle: textTheme.footnoteBold, urlAttachmentTextStyle: textTheme.footnote, urlAttachmentTitleMaxLine: 1, + urlAttachmentTextMaxLine: 3, ), messageInputTheme: StreamMessageInputThemeData( borderRadius: BorderRadius.circular(20), diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index 94a1bd83..3671e4a4 100644 --- a/packages/stream_chat_flutter/lib/src/utils/extensions.dart +++ b/packages/stream_chat_flutter/lib/src/utils/extensions.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:math'; import 'package:diacritic/diacritic.dart'; @@ -5,6 +6,8 @@ import 'package:file_picker/file_picker.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.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:stream_chat_flutter/src/localization/translations.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; @@ -114,7 +117,7 @@ extension PlatformFileX on PlatformFile { final file = toAttachmentFile; final extraDataMap = {}; - final mimeType = file.mimeType?.mimeType; + final mimeType = file.mediaType?.mimeType; if (mimeType != null) { extraDataMap['mime_type'] = mimeType; @@ -151,7 +154,7 @@ extension XFileX on XFile { final extraDataMap = {}; - final mimeType = this.mimeType ?? file.mimeType?.mimeType; + final mimeType = this.mimeType ?? file.mediaType?.mimeType; if (mimeType != null) { extraDataMap['mime_type'] = mimeType; @@ -367,7 +370,7 @@ extension MessageX on Message { /// Returns an approximation of message size double roughMessageSize(double? fontSize) { - var messageTextLength = min(text!.biggestLine().length, 65); + var messageTextLength = min(text?.biggestLine().length ?? 0, 65); if (quotedMessage != null) { var quotedMessageLength = @@ -475,3 +478,59 @@ 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), + ); + } +} + +/// Useful extensions on [Attachment]. +extension OriginalSizeX 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 != AttachmentType.image && type != AttachmentType.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; + + try { + final size = ImageSizeGetter.getSize(input); + if (size.needRotate) { + return Size(size.height.toDouble(), size.width.toDouble()); + } + return Size(size.width.toDouble(), size.height.toDouble()); + } catch (e, stk) { + debugPrint('Error getting image size: $e\n$stk'); + return null; + } + } + + // 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()); + } +} 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 d0c8b99a..47c8ec97 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} @@ -273,6 +264,14 @@ typedef SystemMessageBuilder = Widget Function( Message, ); +/// {@template ephemeralMessageBuilder} +/// A widget builder for creating custom ephemeral messages. +/// {@endtemplate} +typedef EphemeralMessageBuilder = Widget Function( + BuildContext, + Message, +); + /// {@template threadBuilder} /// A widget builder for creating custom thread UI. /// {@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..69327452 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,149 @@ -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() { + final runtimeType = objectRuntimeType(this, 'StreamVideoThumbnailImage'); + return '$runtimeType($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..72470ee9 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -6,10 +6,9 @@ export 'package:stream_chat_flutter/src/message_widget/quoted_message.dart'; export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; export 'src/attachment/attachment.dart'; -export 'src/attachment/attachment_title.dart'; +export 'src/attachment/gallery_attachment.dart'; export 'src/attachment/handler/stream_attachment_handler.dart'; export 'src/attachment/image_attachment.dart'; -export 'src/attachment/image_group.dart'; export 'src/attachment/stream_attachment_package.dart'; export 'src/attachment/url_attachment.dart'; export 'src/attachment/video_attachment.dart'; @@ -96,4 +95,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 4b20e61d..11b15134 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.2 + image_size_getter: ^2.1.2 jiffy: ^6.2.1 lottie: ^2.6.0 meta: ^1.9.1 diff --git a/packages/stream_chat_flutter/test/src/attachment/attachment_error_test.dart b/packages/stream_chat_flutter/test/src/attachment/attachment_error_test.dart deleted file mode 100644 index 6e038505..00000000 --- a/packages/stream_chat_flutter/test/src/attachment/attachment_error_test.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mocks.dart'; - -void main() { - testWidgets('AttachmentError test', (tester) async { - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: MockClient(), - child: child, - ), - home: const Scaffold( - body: Center( - child: AttachmentError(), - ), - ), - ), - ); - - expect(find.byType(Icon), findsOneWidget); - }); -} diff --git a/packages/stream_chat_flutter/test/src/attachment/attachment_title_test.dart b/packages/stream_chat_flutter/test/src/attachment/attachment_title_test.dart deleted file mode 100644 index bfe1223e..00000000 --- a/packages/stream_chat_flutter/test/src/attachment/attachment_title_test.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mocks.dart'; - -void main() { - testWidgets('AttachmentTitle renders properly', (tester) async { - final mockClient = MockClient(); - await tester.pumpWidget( - MaterialApp( - builder: (context, child) => StreamChat( - client: mockClient, - streamChatThemeData: StreamChatThemeData.light(), - child: child, - ), - home: Scaffold( - body: Builder( - builder: (context) { - return Center( - child: StreamAttachmentTitle( - attachment: Attachment( - title: 'Test Attachment', - type: 'video', - titleLink: 'https://www.youtube.com/watch?v=lytQi-slT5Y', - ogScrapeUrl: 'https://www.youtube.com/watch?v=lytQi-slT5Y', - ), - messageTheme: StreamChatTheme.of(context).ownMessageTheme, - ), - ); - }, - ), - ), - ), - ); - - expect(find.byType(StreamAttachmentTitle), findsOneWidget); - expect(find.text('Test Attachment'), findsOneWidget); - expect(find.text('https://www.youtube.com/watch?v=lytQi-slT5Y'), - findsOneWidget); - }); -} 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/image_group_test.dart b/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart similarity index 50% rename from packages/stream_chat_flutter/test/src/attachment/image_group_test.dart rename to packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart index 43fea9e7..5a1270c0 100644 --- a/packages/stream_chat_flutter/test/src/attachment/image_group_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/gallery_attachment_test.dart @@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../mocks.dart'; @@ -18,6 +19,27 @@ void main() { final themeData = ThemeData(); final streamTheme = StreamChatThemeData.fromTheme(themeData); + final attachments = [ + Attachment( + type: 'image', + title: 'example.png', + imageUrl: + 'https://logowik.com/content/uploads/images/flutter5786.jpg', + extraData: const { + 'mime_type': 'png', + }, + ), + Attachment( + type: 'image', + title: 'example.png', + imageUrl: + 'https://logowik.com/content/uploads/images/flutter5786.jpg', + extraData: const { + 'mime_type': 'png', + }, + ), + ]; + await tester.pumpWidget( MaterialApp( home: StreamChatTheme( @@ -25,33 +47,23 @@ 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: [ - Attachment( - type: 'image', - title: 'example.png', - imageUrl: - 'https://logowik.com/content/uploads/images/flutter5786.jpg', - extraData: const { - 'mime_type': 'png', - }, - ), - Attachment( - type: 'image', - title: 'example.png', - imageUrl: - 'https://logowik.com/content/uploads/images/flutter5786.jpg', - extraData: const { - 'mime_type': 'png', - }, - ), - ], + attachments: attachments, + itemBuilder: (context, index) { + final attachment = attachments[index]; + + return StreamImageAttachmentThumbnail( + image: attachment, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ); + }, ), ), ), 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..755686c7 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( + giphy: 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..03ec5fa8 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( + image: Attachment( type: 'image', title: 'example.png', imageUrl: diff --git a/packages/stream_chat_flutter/test/src/attachment/url_attachment_test.dart b/packages/stream_chat_flutter/test/src/attachment/url_attachment_test.dart index b1636fd4..cf12e07e 100644 --- a/packages/stream_chat_flutter/test/src/attachment/url_attachment_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment/url_attachment_test.dart @@ -26,6 +26,7 @@ void main() { child: SizedBox( child: StreamUrlAttachment( messageTheme: streamTheme.ownMessageTheme, + message: Message(), hostDisplayName: 'Test', urlAttachment: Attachment( title: 'Flutter', diff --git a/packages/stream_chat_flutter/test/src/goldens/attachment_button_0.png b/packages/stream_chat_flutter/test/src/goldens/attachment_button_0.png index 2294ea84..096c345d 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/attachment_button_0.png and b/packages/stream_chat_flutter/test/src/goldens/attachment_button_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/attachment_modal_sheet_0.png b/packages/stream_chat_flutter/test/src/goldens/attachment_modal_sheet_0.png index 8c4b011c..c4704cde 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/attachment_modal_sheet_0.png and b/packages/stream_chat_flutter/test/src/goldens/attachment_modal_sheet_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/clear_input_item_0.png b/packages/stream_chat_flutter/test/src/goldens/clear_input_item_0.png index a6503af8..53e98a8b 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/clear_input_item_0.png and b/packages/stream_chat_flutter/test/src/goldens/clear_input_item_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/command_button_0.png b/packages/stream_chat_flutter/test/src/goldens/command_button_0.png index c294b279..0674402d 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/command_button_0.png and b/packages/stream_chat_flutter/test/src/goldens/command_button_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/confirmation_dialog_0.png b/packages/stream_chat_flutter/test/src/goldens/confirmation_dialog_0.png index 9a2676ad..55b0c8c9 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/confirmation_dialog_0.png and b/packages/stream_chat_flutter/test/src/goldens/confirmation_dialog_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/countdown_button_0.png b/packages/stream_chat_flutter/test/src/goldens/countdown_button_0.png index e18ed82d..8623c9c3 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/countdown_button_0.png and b/packages/stream_chat_flutter/test/src/goldens/countdown_button_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/delete_message_dialog_0.png b/packages/stream_chat_flutter/test/src/goldens/delete_message_dialog_0.png index 9e7f6a73..e144913d 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/delete_message_dialog_0.png and b/packages/stream_chat_flutter/test/src/goldens/delete_message_dialog_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png b/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png index 36f80c62..5bb1cc01 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png and b/packages/stream_chat_flutter/test/src/goldens/deleted_message_custom.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_0.png b/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_0.png index 39535158..8e84f2e9 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_0.png and b/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_1.png b/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_1.png index f07b2a9d..51f7c436 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_1.png and b/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_1.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_2.png b/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_2.png index a5b1eda7..5d411f66 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_2.png and b/packages/stream_chat_flutter/test/src/goldens/dm_checkbox_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/download_menu_item_0.png b/packages/stream_chat_flutter/test/src/goldens/download_menu_item_0.png index 2b593918..b1fbcad8 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/download_menu_item_0.png and b/packages/stream_chat_flutter/test/src/goldens/download_menu_item_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/edit_message_sheet_0.png b/packages/stream_chat_flutter/test/src/goldens/edit_message_sheet_0.png index babe1ef3..2cdcf5e6 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/edit_message_sheet_0.png and b/packages/stream_chat_flutter/test/src/goldens/edit_message_sheet_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/error_alert_sheet_0.png b/packages/stream_chat_flutter/test/src/goldens/error_alert_sheet_0.png index f484ee2a..4c163773 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/error_alert_sheet_0.png and b/packages/stream_chat_flutter/test/src/goldens/error_alert_sheet_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gallery_footer_0.png b/packages/stream_chat_flutter/test/src/goldens/gallery_footer_0.png index 8f21a6ca..203d7842 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/gallery_footer_0.png and b/packages/stream_chat_flutter/test/src/goldens/gallery_footer_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gallery_header_0.png b/packages/stream_chat_flutter/test/src/goldens/gallery_header_0.png index 3c8e020f..eaac9b11 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/gallery_header_0.png and b/packages/stream_chat_flutter/test/src/goldens/gallery_header_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_0.png b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_0.png index bf15f6af..5b153d86 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_0.png and b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_1.png b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_1.png index bd466f79..4d3ab3b6 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_1.png and b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_1.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_2.png b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_2.png index d7d671c4..30fe9dc9 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_2.png and b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_3.png b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_3.png index 5381c0f0..6b3f5cdb 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_3.png and b/packages/stream_chat_flutter/test/src/goldens/gradient_avatar_3.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/group_avatar_0.png b/packages/stream_chat_flutter/test/src/goldens/group_avatar_0.png index bb14fc09..5eb45d37 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/group_avatar_0.png and b/packages/stream_chat_flutter/test/src/goldens/group_avatar_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/message_dialog_0.png b/packages/stream_chat_flutter/test/src/goldens/message_dialog_0.png index 1a7faf9c..68abd020 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/message_dialog_0.png and b/packages/stream_chat_flutter/test/src/goldens/message_dialog_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/message_dialog_1.png b/packages/stream_chat_flutter/test/src/goldens/message_dialog_1.png index ea7f428c..b24758f9 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/message_dialog_1.png and b/packages/stream_chat_flutter/test/src/goldens/message_dialog_1.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/message_dialog_2.png b/packages/stream_chat_flutter/test/src/goldens/message_dialog_2.png index 75313e05..4e31bd43 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/message_dialog_2.png and b/packages/stream_chat_flutter/test/src/goldens/message_dialog_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png index 9a4aac36..aeb97727 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png index 1694dcb6..45d6fd59 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png index d67e928d..3b9e6366 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_3_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png index 86a7ae2c..af735d6a 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_dark.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png index 86cdacac..7187ab2d 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png and b/packages/stream_chat_flutter/test/src/goldens/reaction_bubble_like_light.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/sending_indicator_0.png b/packages/stream_chat_flutter/test/src/goldens/sending_indicator_0.png index 27eb2714..58d1f890 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/sending_indicator_0.png and b/packages/stream_chat_flutter/test/src/goldens/sending_indicator_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/sending_indicator_1.png b/packages/stream_chat_flutter/test/src/goldens/sending_indicator_1.png index dc823015..4c92e52e 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/sending_indicator_1.png and b/packages/stream_chat_flutter/test/src/goldens/sending_indicator_1.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/sending_indicator_2.png b/packages/stream_chat_flutter/test/src/goldens/sending_indicator_2.png index dc823015..4c92e52e 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/sending_indicator_2.png and b/packages/stream_chat_flutter/test/src/goldens/sending_indicator_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/stream_chat_context_menu_item_0.png b/packages/stream_chat_flutter/test/src/goldens/stream_chat_context_menu_item_0.png index 9b6e8ed0..558b8753 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/stream_chat_context_menu_item_0.png and b/packages/stream_chat_flutter/test/src/goldens/stream_chat_context_menu_item_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_0.png b/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_0.png index 9b5f1769..b0730449 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_0.png and b/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_1.png b/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_1.png index 1d936b36..3e86d820 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_1.png and b/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_1.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_2.png b/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_2.png index 8bb24319..c4cfff20 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_2.png and b/packages/stream_chat_flutter/test/src/goldens/upload_progress_indicator_2.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/user_avatar_0.png b/packages/stream_chat_flutter/test/src/goldens/user_avatar_0.png index a155ccc4..0224053b 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/user_avatar_0.png and b/packages/stream_chat_flutter/test/src/goldens/user_avatar_0.png differ diff --git a/packages/stream_chat_flutter/test/src/goldens/user_avatar_1.png b/packages/stream_chat_flutter/test/src/goldens/user_avatar_1.png index 189e3432..9546c827 100644 Binary files a/packages/stream_chat_flutter/test/src/goldens/user_avatar_1.png and b/packages/stream_chat_flutter/test/src/goldens/user_avatar_1.png differ diff --git a/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart b/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart index a7afeea8..870f5464 100644 --- a/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart +++ b/packages/stream_chat_flutter/test/src/message_input/message_input_attachment_list_test.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter/src/attachment/thumbnail/media_attachment_thumbnail.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import '../mocks.dart'; @@ -37,7 +38,9 @@ void main() { // Expect 2 file attachments and 1 media attachment expect(find.byType(MessageInputFileAttachments), findsOneWidget); + expect(find.byType(StreamFileAttachment), findsNWidgets(2)); expect(find.byType(MessageInputMediaAttachments), findsOneWidget); + expect(find.byType(StreamMediaAttachmentThumbnail), findsOneWidget); }, ); @@ -111,7 +114,7 @@ void main() { ); // Expect 2 file attachments - expect(find.byType(ClipRRect), findsNWidgets(2)); + expect(find.byType(StreamFileAttachment), findsNWidgets(2)); }, ); diff --git a/packages/stream_chat_flutter/test/src/message_list_view/floating_date_divider_test.dart b/packages/stream_chat_flutter/test/src/message_list_view/floating_date_divider_test.dart index a6403331..f3d3c365 100644 --- a/packages/stream_chat_flutter/test/src/message_list_view/floating_date_divider_test.dart +++ b/packages/stream_chat_flutter/test/src/message_list_view/floating_date_divider_test.dart @@ -1,11 +1,32 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:stream_chat_flutter/src/message_list_view/floating_date_divider.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import '../mocks.dart'; + void main() { testWidgets('FloatingDateDivider', (tester) async { + final client = MockClient(); + final clientState = MockClientState(); + + when(() => client.state).thenReturn(clientState); + when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); + + final createdAt = DateTime.now(); + + final itemPositionsListener = ValueNotifier( + [ + const ItemPosition( + index: 0, + itemLeadingEdge: 0, + itemTrailingEdge: 0, + ), + ], + ); + await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -14,12 +35,9 @@ void main() { FloatingDateDivider( reverse: false, itemCount: 3, - itemPositionListener: ItemPositionsListener.create(), - messages: [ - Message(), - Message(), - Message(), - ], + itemPositionListener: itemPositionsListener, + messages: [Message(createdAt: createdAt)], + dateDividerBuilder: (dateTime) => Text('$dateTime'), ), ], ), @@ -27,6 +45,6 @@ void main() { ), ); - expect(find.byType(Positioned), findsOneWidget); + expect(find.text('$createdAt'), findsOneWidget); }); } diff --git a/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart index 1b5499dd..576e1a8a 100644 --- a/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/channel_header_theme_test.dart @@ -70,7 +70,7 @@ final _channelThemeControlMidLerp = StreamChannelHeaderThemeData( width: 40, ), ), - color: const Color(0xff101418), + color: const Color(0xff111417), titleStyle: const TextStyle( color: Color(0xffffffff), fontWeight: FontWeight.bold, diff --git a/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart index 14c09706..1dd0340e 100644 --- a/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/channel_list_header_theme_test.dart @@ -73,7 +73,7 @@ final _channelListHeaderThemeControlMidLerp = StreamChannelListHeaderThemeData( width: 40, ), ), - color: const Color(0xff87898b), + color: const Color(0xff88898a), titleStyle: const TextStyle( color: Color(0xff7f7f7f), fontSize: 16, diff --git a/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart index f8d1d164..c66d41f1 100644 --- a/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/gallery_footer_theme_test.dart @@ -73,11 +73,7 @@ void main() { home: Builder( builder: (context) { _context = context; - return Scaffold( - appBar: StreamGalleryFooter( - mediaAttachmentPackages: Message().getAttachmentPackageList(), - ), - ); + return const SizedBox.shrink(); }, ), ), @@ -116,11 +112,7 @@ void main() { home: Builder( builder: (context) { _context = context; - return Scaffold( - appBar: StreamGalleryFooter( - mediaAttachmentPackages: Message().getAttachmentPackageList(), - ), - ); + return const SizedBox.shrink(); }, ), ), @@ -160,7 +152,7 @@ final _galleryFooterThemeDataControl = StreamGalleryFooterThemeData( // Mid-lerp theme control const _galleryFooterThemeDataControlMidLerp = StreamGalleryFooterThemeData( - backgroundColor: Color(0xff87898b), + backgroundColor: Color(0xff88898a), shareIconColor: Color(0xff7f7f7f), titleTextStyle: TextStyle( color: Color(0xff7f7f7f), @@ -169,7 +161,7 @@ const _galleryFooterThemeDataControlMidLerp = StreamGalleryFooterThemeData( ), gridIconButtonColor: Color(0xff7f7f7f), bottomSheetBarrierColor: Color(0x4c000000), - bottomSheetBackgroundColor: Color(0xff87898b), + bottomSheetBackgroundColor: Color(0xff88898a), bottomSheetPhotosTextStyle: TextStyle( color: Color(0xff7f7f7f), fontSize: 16, diff --git a/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart index 651da1ce..27f98cb3 100644 --- a/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/gallery_header_theme_test.dart @@ -66,22 +66,7 @@ void main() { home: Builder( builder: (context) { _context = context; - final attachment = Attachment( - type: 'video', - title: 'video.mp4', - ); - final _message = Message( - createdAt: DateTime.now(), - attachments: [ - attachment, - ], - ); - return Scaffold( - appBar: StreamGalleryHeader( - message: _message, - attachment: _message.attachments[0], - ), - ); + return const SizedBox.shrink(); }, ), ), @@ -116,22 +101,7 @@ void main() { home: Builder( builder: (context) { _context = context; - final attachment = Attachment( - type: 'video', - title: 'video.mp4', - ); - final _message = Message( - createdAt: DateTime.now(), - attachments: [ - attachment, - ], - ); - return Scaffold( - appBar: StreamGalleryHeader( - message: _message, - attachment: _message.attachments[0], - ), - ); + return const SizedBox.shrink(); }, ), ), @@ -175,7 +145,7 @@ final _galleryHeaderThemeDataControl = StreamGalleryHeaderThemeData( // Light theme test control. final _galleryHeaderThemeDataHalfLerpControl = StreamGalleryHeaderThemeData( closeButtonColor: const Color(0xff7f7f7f), - backgroundColor: const Color(0xff87898b), + backgroundColor: const Color(0xff88898a), iconMenuPointColor: const Color(0xff7f7f7f), titleTextStyle: const TextStyle( fontSize: 16, @@ -194,7 +164,7 @@ final _galleryHeaderThemeDataHalfLerpControl = StreamGalleryHeaderThemeData( // Dark theme test control. final _galleryHeaderThemeDataDarkControl = StreamGalleryHeaderThemeData( closeButtonColor: const Color(0xffffffff), - backgroundColor: const Color(0xff101418), + backgroundColor: const Color(0xff121416), iconMenuPointColor: const Color(0xffffffff), titleTextStyle: const TextStyle( fontSize: 16, diff --git a/packages/stream_chat_flutter/test/src/theme/message_input_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/message_input_theme_test.dart index f267d388..80912e9a 100644 --- a/packages/stream_chat_flutter/test/src/theme/message_input_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/message_input_theme_test.dart @@ -68,7 +68,7 @@ final _messageInputThemeControl = StreamMessageInputThemeData( final _messageInputThemeControlMidLerp = StreamMessageInputThemeData( borderRadius: BorderRadius.circular(20), sendAnimationDuration: const Duration(milliseconds: 300), - inputBackgroundColor: const Color(0xff87898b), + inputBackgroundColor: const Color(0xff88898a), actionButtonColor: const Color(0xff196eff), actionButtonIdleColor: const Color(0xff7a7a7a), sendButtonColor: const Color(0xff196eff), diff --git a/packages/stream_chat_flutter/test/src/theme/message_list_view_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/message_list_view_theme_test.dart index 264710ec..79fb6dae 100644 --- a/packages/stream_chat_flutter/test/src/theme/message_list_view_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/message_list_view_theme_test.dart @@ -68,12 +68,7 @@ void main() { home: Builder( builder: (BuildContext context) { _context = context; - return Scaffold( - body: StreamChannel( - channel: MockChannel(), - child: const StreamMessageListView(), - ), - ); + return const SizedBox.shrink(); }, ), ), @@ -98,12 +93,7 @@ void main() { home: Builder( builder: (BuildContext context) { _context = context; - return Scaffold( - body: StreamChannel( - channel: MockChannel(), - child: const StreamMessageListView(), - ), - ); + return const SizedBox.shrink(); }, ), ), @@ -151,7 +141,7 @@ final _messageListViewThemeDataControl = StreamMessageListViewThemeData( ); const _messageListViewThemeDataControlHalfLerp = StreamMessageListViewThemeData( - backgroundColor: Color(0xff87898b), + backgroundColor: Color(0xff88898a), ); final _messageListViewThemeDataControlDark = StreamMessageListViewThemeData(