diff --git a/.github/workflows/legacy_version_analyze.yml b/.github/workflows/legacy_version_analyze.yml index 66e44437b..e8b42a9c9 100644 --- a/.github/workflows/legacy_version_analyze.yml +++ b/.github/workflows/legacy_version_analyze.yml @@ -3,7 +3,7 @@ name: legacy_version_analyze env: # Note: The versions below should be manually updated after a new stable # version comes out. - flutter_version: "3.10.6" + flutter_version: "3.13.9" on: push: diff --git a/.github/workflows/stream_flutter_workflow.yml b/.github/workflows/stream_flutter_workflow.yml index 11e68ec73..fea2d0446 100644 --- a/.github/workflows/stream_flutter_workflow.yml +++ b/.github/workflows/stream_flutter_workflow.yml @@ -3,7 +3,7 @@ name: stream_flutter_workflow env: ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true' flutter_channel: "stable" - flutter_version: "3.10.6" + flutter_version: "3.13.9" on: pull_request: diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 142b59771..ad5b88236 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -1,3 +1,17 @@ +## 7.0.0 + +- 🛑️ Breaking + +- Removed deprecated `channelQuery.sort` property. Use `channelStateSort` instead. +- Removed deprecated `RetryPolicy.retryTimeout` property. Use `delayFactor` instead. +- Removed deprecated `StreamChatNetworkError.fromDioError` constructor. Use `StreamChatNetworkError.fromDioException` + instead. +- Removed deprecated `MessageSendingStatus` enum. Use `MessageState` instead. + +🔄 Changed + +- Updated minimum supported `SDK` version to Flutter 3.13/Dart 3.1 + # 6.10.0 🐞 Fixed diff --git a/packages/stream_chat/example/pubspec.yaml b/packages/stream_chat/example/pubspec.yaml index d16c8647d..7530a44fa 100644 --- a/packages/stream_chat/example/pubspec.yaml +++ b/packages/stream_chat/example/pubspec.yaml @@ -5,8 +5,8 @@ publish_to: "none" version: 1.0.0+1 environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" dependencies: cupertino_icons: ^1.0.5 diff --git a/packages/stream_chat/lib/src/client/channel.dart b/packages/stream_chat/lib/src/client/channel.dart index f588205d7..7ffd8a8af 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/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index 79bf17412..ccd59ce66 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -17,7 +17,6 @@ import 'package:stream_chat/src/core/http/stream_http_client.dart'; import 'package:stream_chat/src/core/http/token.dart'; import 'package:stream_chat/src/core/http/token_manager.dart'; import 'package:stream_chat/src/core/models/attachment_file.dart'; -import 'package:stream_chat/src/core/models/channel_model.dart'; import 'package:stream_chat/src/core/models/channel_state.dart'; import 'package:stream_chat/src/core/models/event.dart'; import 'package:stream_chat/src/core/models/filter.dart'; @@ -572,8 +571,6 @@ class StreamChatClient { /// Requests channels with a given query. Stream> queryChannels({ Filter? filter, - @Deprecated('Use channelStateSort instead.') - List>? sort, List>? channelStateSort, bool state = true, bool watch = true, @@ -590,7 +587,7 @@ class StreamChatClient { final hash = generateHash([ filter, - sort, + channelStateSort, state, watch, presence, @@ -604,8 +601,6 @@ class StreamChatClient { } else { final channels = await queryChannelsOffline( filter: filter, - // ignore: deprecated_member_use_from_same_package - sort: sort, channelStateSort: channelStateSort, paginationParams: paginationParams, ); @@ -614,7 +609,7 @@ class StreamChatClient { try { final newQueryChannelsFuture = queryChannelsOnline( filter: filter, - sort: channelStateSort ?? sort, + sort: channelStateSort, state: state, watch: watch, presence: presence, @@ -731,17 +726,11 @@ class StreamChatClient { /// Requests channels with a given query from the Persistence client. Future> queryChannelsOffline({ Filter? filter, - @Deprecated(''' - sort has been deprecated. - Please use channelStateSort instead.''') - List>? sort, List>? channelStateSort, PaginationParams paginationParams = const PaginationParams(), }) async { final offlineChannels = (await chatPersistenceClient?.getChannelStates( filter: filter, - // ignore: deprecated_member_use_from_same_package - sort: sort, channelStateSort: channelStateSort, paginationParams: paginationParams, )) ?? diff --git a/packages/stream_chat/lib/src/client/retry_policy.dart b/packages/stream_chat/lib/src/client/retry_policy.dart index f086d44b4..b5d080436 100644 --- a/packages/stream_chat/lib/src/client/retry_policy.dart +++ b/packages/stream_chat/lib/src/client/retry_policy.dart @@ -13,7 +13,6 @@ class RetryPolicy { /// Instantiate a new RetryPolicy RetryPolicy({ required this.shouldRetry, - @Deprecated("Use 'delayFactor' instead.") this.retryTimeout, this.maxRetryAttempts = 6, this.delayFactor = const Duration(milliseconds: 200), this.randomizationFactor = 0.25, @@ -53,13 +52,4 @@ class RetryPolicy { int attempt, StreamChatError? error, ) shouldRetry; - - /// In the case that we want to retry a failed request the retryTimeout - /// method is called to determine the timeout - @Deprecated("Use 'delayFactor' instead.") - final Duration Function( - StreamChatClient client, - int attempt, - StreamChatError? error, - )? retryTimeout; } diff --git a/packages/stream_chat/lib/src/core/api/requests.dart b/packages/stream_chat/lib/src/core/api/requests.dart index f81ee70b6..4850c2b34 100644 --- a/packages/stream_chat/lib/src/core/api/requests.dart +++ b/packages/stream_chat/lib/src/core/api/requests.dart @@ -1,5 +1,3 @@ -// ignore_for_file: deprecated_member_use_from_same_package - import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; diff --git a/packages/stream_chat/lib/src/core/error/stream_chat_error.dart b/packages/stream_chat/lib/src/core/error/stream_chat_error.dart index 333211f28..5ce26f00d 100644 --- a/packages/stream_chat/lib/src/core/error/stream_chat_error.dart +++ b/packages/stream_chat/lib/src/core/error/stream_chat_error.dart @@ -88,11 +88,6 @@ class StreamChatNetworkError extends StreamChatError { this.isRequestCancelledError = false, }) : super(message); - /// - @Deprecated('Use `StreamChatNetworkError.fromDioException` instead') - factory StreamChatNetworkError.fromDioError(DioException error) = - StreamChatNetworkError.fromDioException; - /// factory StreamChatNetworkError.fromDioException(DioException exception) { final response = exception.response; diff --git a/packages/stream_chat/lib/src/core/models/attachment.dart b/packages/stream_chat/lib/src/core/models/attachment.dart index bceaaacc8..86d3efedf 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 2ebe8c533..51aae50e3 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 73a428ea7..261d7db37 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); @@ -74,14 +74,14 @@ class AttachmentFile { if (CurrentPlatform.isWeb) { multiPartFile = MultipartFile.fromBytes( bytes!, - filename: name ?? 'file', - contentType: mimeType, + filename: name, + contentType: mediaType, ); } else { multiPartFile = await MultipartFile.fromFile( path!, - filename: name ?? 'file', - contentType: mimeType, + filename: name, + 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 000000000..bc5d59a32 --- /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/member.dart b/packages/stream_chat/lib/src/core/models/member.dart index 8fe65b446..0e1821026 100644 --- a/packages/stream_chat/lib/src/core/models/member.dart +++ b/packages/stream_chat/lib/src/core/models/member.dart @@ -1,5 +1,3 @@ -// ignore_for_file: deprecated_member_use_from_same_package - import 'package:equatable/equatable.dart'; import 'package:json_annotation/json_annotation.dart'; import 'package:stream_chat/src/core/models/user.dart'; diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 931107225..0d43bdaa5 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -15,70 +15,6 @@ class _NullConst { const _nullConst = _NullConst(); -/// Enum defining the status of a sending message. -enum MessageSendingStatus { - /// Message is being sent - sending, - - /// Message is being updated - updating, - - /// Message is being deleted - deleting, - - /// Message failed to send - failed, - - /// Message failed to updated - // ignore: constant_identifier_names - failed_update, - - /// Message failed to delete - // ignore: constant_identifier_names - failed_delete, - - /// Message correctly sent - sent; - - /// Returns a [MessageState] from a [MessageSendingStatus] - MessageState toMessageState() { - switch (this) { - case MessageSendingStatus.sending: - return MessageState.sending; - case MessageSendingStatus.updating: - return MessageState.updating; - case MessageSendingStatus.deleting: - return MessageState.softDeleting; - case MessageSendingStatus.failed: - return MessageState.sendingFailed; - case MessageSendingStatus.failed_update: - return MessageState.updatingFailed; - case MessageSendingStatus.failed_delete: - return MessageState.softDeletingFailed; - case MessageSendingStatus.sent: - return MessageState.sent; - } - } - - /// Returns a [MessageSendingStatus] from a [MessageState]. - static MessageSendingStatus fromMessageState(MessageState state) { - return state.when( - initial: () => MessageSendingStatus.sending, - outgoing: (it) => it.when( - sending: () => MessageSendingStatus.sending, - updating: () => MessageSendingStatus.updating, - deleting: (_) => MessageSendingStatus.deleting, - ), - completed: (_) => MessageSendingStatus.sent, - failed: (it, __) => it.when( - sendingFailed: () => MessageSendingStatus.failed, - updatingFailed: () => MessageSendingStatus.failed_update, - deletingFailed: (_) => MessageSendingStatus.failed_delete, - ), - ); - } -} - /// The class that contains the information about a message. @JsonSerializable() class Message extends Equatable { @@ -114,23 +50,14 @@ class Message extends Equatable { DateTime? pinExpires, this.pinnedBy, this.extraData = const {}, - @Deprecated('Use `state` instead') MessageSendingStatus? status, - MessageState? state, + this.state = const MessageState.initial(), this.i18n, }) : id = id ?? const Uuid().v4(), pinExpires = pinExpires?.toUtc(), remoteCreatedAt = createdAt, remoteUpdatedAt = updatedAt, remoteDeletedAt = deletedAt, - _quotedMessageId = quotedMessageId { - var messageState = state ?? const MessageState.initial(); - // Backward compatibility. TODO: Remove in the next major version - if (status != null) { - messageState = status.toMessageState(); - } - - this.state = messageState; - } + _quotedMessageId = quotedMessageId; /// Create a new instance from JSON. factory Message.fromJson(Map json) { @@ -155,17 +82,9 @@ class Message extends Equatable { /// The text of this message. final String? text; - /// The status of a sending message. - @Deprecated('Use `state` instead') - @JsonKey(includeFromJson: false, includeToJson: false) - MessageSendingStatus get status { - return MessageSendingStatus.fromMessageState(state); - } - - // TODO: Remove late modifier in the next major version. /// The current state of the message. @JsonKey(includeFromJson: false, includeToJson: false) - late final MessageState state; + final MessageState state; /// The message type. @JsonKey(includeIfNull: false, toJson: _typeToJson) @@ -304,6 +223,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'; @@ -388,7 +310,6 @@ class Message extends Equatable { Object? pinExpires = _nullConst, User? pinnedBy, Map? extraData, - @Deprecated('Use `state` instead') MessageSendingStatus? status, MessageState? state, Map? i18n, }) { @@ -423,8 +344,6 @@ class Message extends Equatable { return true; }(), 'Validate type for quotedMessage'); - final messageState = state ?? status?.toMessageState(); - return Message( id: id ?? this.id, text: text ?? this.text, @@ -461,7 +380,7 @@ class Message extends Equatable { pinExpires == _nullConst ? this.pinExpires : pinExpires as DateTime?, pinnedBy: pinnedBy ?? this.pinnedBy, extraData: extraData ?? this.extraData, - state: messageState ?? this.state, + state: state ?? this.state, i18n: i18n ?? this.i18n, ); } diff --git a/packages/stream_chat/lib/src/core/util/extension.dart b/packages/stream_chat/lib/src/core/util/extension.dart index a28dc12b2..132413a6f 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/src/db/chat_persistence_client.dart b/packages/stream_chat/lib/src/db/chat_persistence_client.dart index 42f650cdf..9eefe186e 100644 --- a/packages/stream_chat/lib/src/db/chat_persistence_client.dart +++ b/packages/stream_chat/lib/src/db/chat_persistence_client.dart @@ -102,8 +102,6 @@ abstract class ChatPersistenceClient { /// for filtering out states. Future> getChannelStates({ Filter? filter, - @Deprecated('Use channelStateSort instead.') - List>? sort, List>? channelStateSort, PaginationParams? paginationParams, }); diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index 71a217dce..7e843412e 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/lib/version.dart b/packages/stream_chat/lib/version.dart index 469f80df8..997f7dc8d 100644 --- a/packages/stream_chat/lib/version.dart +++ b/packages/stream_chat/lib/version.dart @@ -3,4 +3,4 @@ import 'package:stream_chat/src/client/client.dart'; /// Current package version /// Used in [StreamChatClient] to build the `x-stream-client` header // ignore: constant_identifier_names -const PACKAGE_VERSION = '6.10.0'; +const PACKAGE_VERSION = '7.0.0'; diff --git a/packages/stream_chat/pubspec.yaml b/packages/stream_chat/pubspec.yaml index 107f5c445..7d62b6aeb 100644 --- a/packages/stream_chat/pubspec.yaml +++ b/packages/stream_chat/pubspec.yaml @@ -1,16 +1,16 @@ name: stream_chat homepage: https://getstream.io/ description: The official Dart client for Stream Chat, a service for building chat applications. -version: 6.10.0 +version: 7.0.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: '>=3.0.0 <4.0.0' + sdk: '>=3.1.0 <4.0.0' dependencies: async: ^2.11.0 - collection: ^1.17.1 + collection: ^1.17.2 dio: ^5.3.2 equatable: ^2.0.5 freezed_annotation: ^2.4.1 diff --git a/packages/stream_chat/test/fixtures/message_to_json.json b/packages/stream_chat/test/fixtures/message_to_json.json index 94aed066e..7cb322161 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 87053d9bf..19450f664 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 edcc0da6f..e9add1095 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 b5e6ddcc0..c627a739d 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 ae3c08d87..ad624c056 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/test/src/db/chat_persistence_client_test.dart b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart index b9c1407b5..6acf50b5a 100644 --- a/packages/stream_chat/test/src/db/chat_persistence_client_test.dart +++ b/packages/stream_chat/test/src/db/chat_persistence_client_test.dart @@ -62,8 +62,6 @@ class TestPersistenceClient extends ChatPersistenceClient { @override Future> getChannelStates( {Filter? filter, - @Deprecated('Use channelStateSort instead.') - List>? sort, List>? channelStateSort, PaginationParams? paginationParams}) => throw UnimplementedError(); diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index c3a93e668..d8c9327f5 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -1,3 +1,54 @@ +## 7.0.0 + +🛑️ Breaking + +- Removed deprecated `ChannelPreview` widget. Use `StreamChannelListTile` instead. +- Removed deprecated `ChannelPreviewBuilder`, Use `StreamChannelListViewIndexedWidgetBuilder` instead. +- Removed deprecated `StreamUserItem` widget. Use `StreamUserListTile` instead. +- Removed deprecated `ReturnActionType` enum, No longer used. +- Removed deprecated `StreamMessageInput.attachmentThumbnailBuilders` parameter. Use + `StreamMessageInput.mediaAttachmentBuilder` instead. +- Removed deprecated `MessageListView.onMessageSwiped` parameter. Try wrapping the `MessageWidget` with + a `Swipeable`, `Dismissible` or a custom widget to achieve the swipe to reply behaviour. +- Removed deprecated `MessageWidget.showReactionPickerIndicator` parameter. Use `MessageWidget.showReactionPicker` + instead. +- Removed deprecated `MessageWidget.bottomRowBuilder` parameter. Use `MessageWidget.bottomRowBuilderWithDefaultWidget` + instead. +- Removed deprecated `MessageWidget.deletedBottomRowBuilder` parameter. + Use `MessageWidget.deletedBottomRowBuilderWithDefaultWidget` instead. +- Removed deprecated `MessageWidget.usernameBuilder` parameter. Use `MessageWidget.usernameBuilderWithDefaultWidget` + instead. +- Removed deprecated `MessageTheme.linkBackgroundColor` parameter. Use `MessageTheme.urlAttachmentBackgroundColor` + instead. +- Removed deprecated `showConfirmationDialog` method. Use `showConfirmationBottomSheet` instead. +- Removed deprecated `showInfoDialog` method. Use `showInfoBottomSheet` instead. +- Removed deprecated `wrapAttachmentWidget` method. Use `WrapAttachmentWidget` class instead. +- Removed deprecated `showReactionPickerTail` parameter. Use `showReactionPicker` instead. + +✅ Added + +- Added support for `StreamMessageInput.contentInsertionConfiguration` to specify the content insertion configuration. + [#1613](https://github.com/GetStream/stream-chat-flutter/issues/1613) + + ```dart + StreamMessageInput( + ..., + contentInsertionConfiguration: ContentInsertionConfiguration( + onContentInserted: (content) { + // Do something with the content. + controller.addAttachment(...); + }, + ), + ) + ``` + +🔄 Changed + +- Updated `jiffy` dependency to `^6.2.1`. +- Updated minimum supported `SDK` version to Flutter 3.13/Dart 3.1 +- Updated `stream_chat_flutter_core` dependency + to [`7.0.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). + # 6.12.0 🐞 Fixed @@ -24,7 +75,7 @@ - Added support for overriding the `MessageWidget.onReactionsHover` callback. > **Note** > Used only in desktop devices (web and desktop). - + ## 6.9.0 🐞 Fixed diff --git a/packages/stream_chat_flutter/example/pubspec.yaml b/packages/stream_chat_flutter/example/pubspec.yaml index 9ec9351c2..cfe3882cf 100644 --- a/packages/stream_chat_flutter/example/pubspec.yaml +++ b/packages/stream_chat_flutter/example/pubspec.yaml @@ -4,8 +4,8 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" dependencies: collection: ^1.15.0 diff --git a/packages/stream_chat_flutter/lib/src/attachment/attachment.dart b/packages/stream_chat_flutter/lib/src/attachment/attachment.dart index 721d1dea4..0b73a93c7 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 85e806e90..000000000 --- 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 dc9c34c1d..000000000 --- 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 e05ba3971..fcedd0125 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 182d5aea6..000000000 --- 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 000000000..5e51c7333 --- /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 000000000..f9b389ef2 --- /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 000000000..0226682fb --- /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 000000000..5a6a5b589 --- /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 000000000..cd2bf22e9 --- /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 000000000..2220ae924 --- /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 000000000..14aa7c687 --- /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 000000000..3d0eb466b --- /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 000000000..807ad2d67 --- /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 000000000..45f92ef8b --- /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 9e9d9e551..3790b4167 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 - final Widget? title; + /// The [Message] that the file is attached to. + final Message message; - /// Widget for displaying at the end of the attachment - /// (such as a download button) - final Widget? trailing; + /// The [Attachment] object containing the file information. + final Attachment file; - /// {@macro onAttachmentTap} - final OnAttachmentTap? onAttachmentTap; + /// The shape of the attachment. + /// + /// Defaults to [RoundedRectangleBorder] with a radius of 12. + final ShapeBorder? shape; - /// Checks if the attachment is a video - bool get isVideoAttachment => attachment.title?.mimeType?.type == 'video'; + /// The background color of the attachment. + /// + /// Defaults to [StreamChatTheme.colorTheme.barsBg]. + final Color? backgroundColor; - /// Checks if the attachment is an image - bool get isImageAttachment => attachment.title?.mimeType?.type == 'image'; + /// 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. + /// (such as a download button) + final Widget? trailing; @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, ), - 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, + 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(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(height: 3), + _FileAttachmentSubtitle(attachment: file), + ], + ), + ), + const SizedBox(width: 8), + Material( + type: MaterialType.transparency, + child: trailing ?? + _Trailing( + attachment: file, + message: message, ), - ), - 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', - ); - - final colorTheme = StreamChatTheme.of(context).colorTheme; - return Shimmer.fromColors( - baseColor: colorTheme.disabled, - highlightColor: colorTheme.inputBg, - child: image, - ); - }, - ); - }, - ), - ); - } + Widget child = StreamFileAttachmentThumbnail( + file: file, + width: double.infinity, + height: double.infinity, + ); - if (isVideoAttachment) { - return Material( + 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, - 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(), - ), + decoration: ShapeDecoration( + shape: RoundedRectangleBorder( + side: BorderSide( + color: colorTheme.borders, + strokeAlign: BorderSide.strokeAlignOutside, ), + borderRadius: BorderRadius.circular(8), ), ), + child: child, ); } - 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 000000000..8acb39baf --- /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 85997b644..a19e19ff0 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, - ), - ), - ), - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 4), - const Align( - alignment: Alignment.centerRight, - child: StreamVisibleFootnote(), + 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), + ); - 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 96d5e1ed5..8c58fe2dc 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 36a0df3a1..5406cb87c 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; - - /// {@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; + /// 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; - - if (imageUrl == null) { - return AttachmentError(constraints: constraints); - } - - 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), + BoxFit? fit; + final imageSize = image.originalSize; + + // 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; + } + + 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 5756b24ac..000000000 --- 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 000000000..89374932a --- /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 000000000..6b3a5e8d6 --- /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 000000000..590fe4e73 --- /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 000000000..ab8c60c57 --- /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 000000000..37b288fc6 --- /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 000000000..0be758d5c --- /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 a628ee60c..9b44e2595 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), + AspectRatio( + // Default aspect ratio for Open Graph images. + // https://www.kapwing.com/resources/what-is-an-og-image-make-and-format-og-images-for-your-blog-or-webpage + aspectRatio: 1.91 / 1, + child: StreamImageAttachmentThumbnail( + image: urlAttachment, + fit: BoxFit.cover, + ), + ), + Positioned( + left: 0, + bottom: 0, + child: DecoratedBox( decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(16), + ), + color: backgroundColor, ), - 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, - ), - ), - ), - ), - ], + child: Padding( + padding: const EdgeInsets.only( + top: 8, + left: 8, + right: 12, + bottom: 4, + ), + child: Text( + hostDisplayName, + style: messageTheme.urlAttachmentHostStyle, + ), ), ), - Padding( - padding: textPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (urlAttachment.title != null) - Builder(builder: (context) { - final maxLines = messageTheme.urlAttachmentTitleMaxLine; + ), + ], + ), + 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; - } + 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; + 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; - } + 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)), - ), - ), - ], + 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 0d5d4ecd9..bcbcd2f0e 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, - ), - ); - }, - network: () { - if (attachment.assetUrl == null) { - return AttachmentError(constraints: constraints); - } - return _buildVideoAttachment( - context, - StreamVideoThumbnailImage( - video: attachment.assetUrl, - 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), ); - }, - ); - } - 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 2125c71be..aafab8659 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/channel_preview.dart b/packages/stream_chat_flutter/lib/src/channel/channel_preview.dart deleted file mode 100644 index f64b9dc95..000000000 --- a/packages/stream_chat_flutter/lib/src/channel/channel_preview.dart +++ /dev/null @@ -1,510 +0,0 @@ -// ignore_for_file: deprecated_member_use_from_same_package - -import 'package:collection/collection.dart' - show IterableExtension, ListEquality; -import 'package:contextmenu/contextmenu.dart'; -import 'package:flutter/material.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'; -import 'package:stream_chat_flutter/src/message_widget/sending_indicator_builder.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template channelPreview} -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_preview.png) -/// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/channel_preview_paint.png) -/// -/// Shows a preview for the current [Channel]. -/// -/// Uses a [StreamBuilder] to render the channel information image as soon as -/// it updates. -/// -/// It is not recommended to use this widget directly as it is the -/// default channel preview widget used by [ChannelListView]. -/// -/// The UI is rendered based on the first ancestor of type [StreamChatTheme]. -/// Modify it to change the widget's appearance. -/// {@endtemplate} -@Deprecated('Use StreamChannelListTile instead.') -class ChannelPreview extends StatelessWidget { - /// {@macro channelPreview} - const ChannelPreview({ - required this.channel, - super.key, - this.onTap, - this.onLongPress, - this.onViewInfoTap, - this.onImageTap, - this.title, - this.subtitle, - this.leading, - this.sendingIndicator, - this.trailing, - }); - - /// The action to perform when this widget is tapped or clicked. - final void Function(Channel)? onTap; - - /// The action to perform when this widget is long pressed. - final void Function(Channel)? onLongPress; - - /// The action to perform when 'View Info' is tapped or clicked. - final ViewInfoCallback? onViewInfoTap; - - /// The [Channel] being previewed. - final Channel channel; - - /// The action to perform when the image is tapped - final VoidCallback? onImageTap; - - /// Widget rendering the title - final Widget? title; - - /// Widget rendering the subtitle - final Widget? subtitle; - - /// Widget rendering the leading element. By default it shows the - /// [StreamChannelAvatar]. - final Widget? leading; - - /// Widget rendering the trailing element. By default it shows the date of - /// the last message. - final Widget? trailing; - - /// Widget rendering the sending indicator. By default it uses the - /// [StreamSendingIndicator] widget. - final Widget? sendingIndicator; - - @override - Widget build(BuildContext context) { - final channelPreviewTheme = StreamChannelPreviewTheme.of(context); - final streamChatState = StreamChat.of(context); - final streamChatTheme = StreamChatTheme.of(context); - return BetterStreamBuilder( - stream: channel.isMutedStream, - initialData: channel.isMuted, - builder: (context, data) => AnimatedOpacity( - opacity: data ? 0.5 : 1, - duration: const Duration(milliseconds: 300), - child: ContextMenuArea( - verticalPadding: 0, - builder: (context) => [ - StreamChatContextMenuItem( - leading: StreamSvgIcon.user( - color: Colors.grey, - ), - title: Text(context.translations.viewInfoLabel), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - if (onViewInfoTap != null) { - onViewInfoTap?.call(channel); - } else { - showDialog( - context: context, - builder: (_) => ChannelInfoDialog( - channel: channel, - ), - ); - } - }, - ), - StreamChatContextMenuItem( - leading: StreamSvgIcon.mute( - color: Colors.grey, - ), - title: channel.isGroup - ? Text( - context.translations - .toggleMuteUnmuteGroupText(isMuted: channel.isMuted), - ) - : Text( - context.translations - .toggleMuteUnmuteUserText(isMuted: channel.isMuted), - ), - onClick: () async { - Navigator.of(context, rootNavigator: true).pop(); - showDialog( - context: context, - builder: (_) => ConfirmationDialog( - titleText: channel.isGroup - ? context.translations - .toggleMuteUnmuteGroupText(isMuted: channel.isMuted) - : context.translations - .toggleMuteUnmuteUserText(isMuted: channel.isMuted), - promptText: channel.isGroup - ? context.translations.toggleMuteUnmuteGroupQuestion( - isMuted: channel.isMuted, - ) - : context.translations.toggleMuteUnmuteUserQuestion( - isMuted: channel.isMuted, - ), - affirmativeText: context.translations - .toggleMuteUnmuteAction(isMuted: channel.isMuted), - onConfirmation: () async { - try { - if (channel.isMuted) { - await channel.unmute(); - } else { - await channel.mute(); - } - } catch (e) { - showDialog( - context: context, - builder: (_) => MessageDialog( - messageText: e.toString(), - ), - ); - } - }, - ), - ); - }, - ), - if (channel.isGroup) - StreamChatContextMenuItem( - leading: StreamSvgIcon.userRemove( - color: Colors.red, - ), - title: Text( - context.translations.leaveGroupLabel, - style: const TextStyle( - color: Colors.red, - ), - ), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - showDialog( - context: context, - builder: (_) => ConfirmationDialog( - titleText: context.translations.leaveGroupLabel, - promptText: - context.translations.leaveConversationQuestion, - affirmativeText: context.translations.leaveLabel, - onConfirmation: () async { - final userAsMember = channel.state?.members.firstWhere( - (e) => - e.user?.id == - StreamChat.of(context).currentUser?.id, - ); - try { - await channel.removeMembers([userAsMember!.user!.id]); - } catch (e) { - showDialog( - context: context, - builder: (_) => MessageDialog( - messageText: e.toString(), - ), - ); - } - }, - ), - ); - }, - ), - if (!channel.isGroup) - StreamChatContextMenuItem( - leading: StreamSvgIcon.delete( - color: Colors.red, - ), - title: Text( - context.translations.deleteConversationLabel, - style: const TextStyle( - color: Colors.red, - ), - ), - onClick: () { - Navigator.of(context, rootNavigator: true).pop(); - showDialog( - context: context, - builder: (_) => ConfirmationDialog( - titleText: context.translations.deleteConversationLabel, - promptText: - context.translations.deleteConversationQuestion, - affirmativeText: context.translations.deleteLabel, - onConfirmation: () async { - try { - await channel.delete(); - } catch (e) { - showDialog( - context: context, - builder: (_) => MessageDialog( - messageText: e.toString(), - ), - ); - } - }, - ), - ); - }, - ), - ], - child: ListTile( - visualDensity: VisualDensity.compact, - contentPadding: const EdgeInsets.symmetric( - horizontal: 8, - ), - onTap: () => onTap?.call(channel), - onLongPress: () => onLongPress?.call(channel), - leading: leading ?? - StreamChannelAvatar( - onTap: onImageTap, - channel: channel, - ), - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: title ?? - ChannelName( - textStyle: channelPreviewTheme.titleStyle, - ), - ), - BetterStreamBuilder>( - stream: channel.state?.membersStream, - initialData: channel.state?.members, - comparator: const ListEquality().equals, - builder: (context, members) { - if (members.isEmpty || - !members.any((Member e) => - e.user!.id == - channel.client.state.currentUser?.id)) { - return const SizedBox(); - } - return StreamUnreadIndicator( - cid: channel.cid, - ); - }, - ), - ], - ), - subtitle: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible(child: subtitle ?? _Subtitle(channel: channel)), - sendingIndicator ?? - Builder( - builder: (context) { - final lastMessage = - channel.state?.messages.lastWhereOrNull( - (m) => !m.isDeleted && !m.shadowed, - ); - if (lastMessage?.user?.id == - streamChatState.currentUser?.id) { - return Padding( - padding: const EdgeInsets.only(right: 4), - child: BetterStreamBuilder>( - stream: channel.state?.readStream, - initialData: channel.state?.read, - builder: (context, data) { - final hasNonUrlAttachments = lastMessage! - .attachments - .where((it) => - it.titleLink == null || - it.type == 'giphy') - .isNotEmpty; - - return SendingIndicatorBuilder( - messageTheme: streamChatTheme.ownMessageTheme, - message: lastMessage, - hasNonUrlAttachments: hasNonUrlAttachments, - streamChat: streamChatState, - streamChatTheme: streamChatTheme, - channel: channel, - ); - }, - ), - ); - } - return const SizedBox(); - }, - ), - trailing ?? _Date(channel: channel), - ], - ), - ), - ), - ), - ); - } -} - -class _Date extends StatelessWidget { - const _Date({ - required this.channel, - }); - - final Channel channel; - - @override - Widget build(BuildContext context) { - return BetterStreamBuilder( - stream: channel.lastMessageAtStream, - initialData: channel.lastMessageAt, - builder: (context, data) { - final lastMessageAt = data.toLocal(); - - String stringDate; - final now = DateTime.now(); - - final startOfDay = DateTime(now.year, now.month, now.day); - - if (lastMessageAt.millisecondsSinceEpoch >= - startOfDay.millisecondsSinceEpoch) { - stringDate = Jiffy.parseFromDateTime(lastMessageAt.toLocal()).jm; - } else if (lastMessageAt.millisecondsSinceEpoch >= - startOfDay - .subtract(const Duration(days: 1)) - .millisecondsSinceEpoch) { - stringDate = context.translations.yesterdayLabel; - } else if (startOfDay.difference(lastMessageAt).inDays < 7) { - stringDate = Jiffy.parseFromDateTime(lastMessageAt.toLocal()).EEEE; - } else { - stringDate = Jiffy.parseFromDateTime(lastMessageAt.toLocal()).yMd; - } - - return Text( - stringDate, - style: StreamChannelPreviewTheme.of(context).lastMessageAtStyle, - ); - }, - ); - } -} - -class _Subtitle extends StatelessWidget { - const _Subtitle({ - required this.channel, - }); - - final Channel channel; - - @override - Widget build(BuildContext context) { - final channelPreviewTheme = StreamChannelPreviewTheme.of(context); - if (channel.isMuted) { - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - StreamSvgIcon.mute( - size: 16, - ), - Text( - ' ${context.translations.channelIsMutedText}', - style: channelPreviewTheme.subtitleStyle, - ), - ], - ); - } - return StreamTypingIndicator( - channel: channel, - alternativeWidget: _LastMessage( - channel: channel, - ), - style: channelPreviewTheme.subtitleStyle, - ); - } -} - -class _LastMessage extends StatelessWidget { - const _LastMessage({ - required this.channel, - }); - - final Channel channel; - - @override - Widget build(BuildContext context) { - return Align( - alignment: Alignment.centerLeft, - child: BetterStreamBuilder>( - stream: channel.state!.messagesStream, - initialData: channel.state!.messages, - builder: (context, data) { - final lastMessage = - data.lastWhereOrNull((m) => !m.shadowed && !m.isDeleted); - if (lastMessage == null) { - return const SizedBox(); - } - - var text = lastMessage.text; - final parts = [ - ...lastMessage.attachments.map((e) { - if (e.type == 'image') { - return '📷'; - } else if (e.type == 'video') { - return '🎬'; - } else if (e.type == 'giphy') { - return '[GIF]'; - } - return e == lastMessage.attachments.last - ? (e.title ?? 'File') - : '${e.title ?? 'File'} , '; - }), - lastMessage.text ?? '', - ]; - - text = parts.join(' '); - - final channelPreviewTheme = StreamChannelPreviewTheme.of(context); - return Text.rich( - _getDisplayText( - text, - lastMessage.mentionedUsers, - lastMessage.attachments, - channelPreviewTheme.subtitleStyle?.copyWith( - color: channelPreviewTheme.subtitleStyle?.color, - fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) - ? FontStyle.italic - : FontStyle.normal, - ), - channelPreviewTheme.subtitleStyle?.copyWith( - color: channelPreviewTheme.subtitleStyle?.color, - fontStyle: (lastMessage.isSystem || lastMessage.isDeleted) - ? FontStyle.italic - : FontStyle.normal, - fontWeight: FontWeight.bold, - ), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.start, - ); - }, - ), - ); - } - - TextSpan _getDisplayText( - String text, - List mentions, - List attachments, - TextStyle? normalTextStyle, - TextStyle? mentionsTextStyle, - ) { - final textList = text.split(' '); - final resList = []; - for (final e in textList) { - if (mentions.isNotEmpty && - mentions.any((element) => '@${element.name}' == e)) { - resList.add(TextSpan( - text: '$e ', - style: mentionsTextStyle, - )); - } else if (attachments.isNotEmpty && - attachments - .where((e) => e.title != null) - .any((element) => element.title == e)) { - resList.add(TextSpan( - text: '$e ', - style: normalTextStyle?.copyWith(fontStyle: FontStyle.italic), - )); - } else { - resList.add(TextSpan( - text: e == textList.last ? e : '$e ', - style: normalTextStyle, - )); - } - } - - return TextSpan(children: resList); - } -} 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 91e65d371..e6dd6a884 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/fsm_enums.dart b/packages/stream_chat_flutter/lib/src/fullscreen_media/fsm_enums.dart deleted file mode 100644 index 854682e1c..000000000 --- a/packages/stream_chat_flutter/lib/src/fullscreen_media/fsm_enums.dart +++ /dev/null @@ -1,12 +0,0 @@ -// TODO: remove in v6 as this is no longer used. Currently exported. - -/// Return action for coming back from pages -@Deprecated(''' - ReturnActionType has been deprecated and is no longer used.''') -enum ReturnActionType { - /// No return action - none, - - /// Go to reply message action - reply, -} 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 a1708e17f..53e6f1a13 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 0b870f773..787b4b3d1 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 000000000..5d1af701b --- /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 e7384fd92..049284696 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 000000000..cc42af630 --- /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/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index e6bb87d97..b893b1f08 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -81,10 +81,7 @@ abstract class Translations { /// The text for showing the unread messages count /// in the [StreamMessageListView] - String unreadMessagesSeparatorText( - @Deprecated('unreadCount is not used anymore and will be removed ') - int unreadCount, - ); + String unreadMessagesSeparatorText(); /// The label for "connected" in [StreamConnectionStatusBuilder] String get connectedLabel; @@ -802,7 +799,7 @@ Attachment limit exceeded: it's not possible to add more than $limit attachments String get linkDisabledError => 'Links are disabled'; @override - String unreadMessagesSeparatorText(int unreadCount) => 'New messages'; + String unreadMessagesSeparatorText() => 'New messages'; @override String get enableFileAccessMessage => 'Please enable access to files' 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 c9ff55953..2fb837152 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, - ), - ); - }, - ), - const SizedBox(height: 10), - IgnorePointer( - child: widget.messageWidget, - ), - const SizedBox(height: 8), - Padding( - padding: EdgeInsets.only( - left: widget.reverse ? 0 : 40, + child: StreamReactionPicker( + message: widget.message, + ), + ); + }, + ), + const SizedBox(height: 10), + IgnorePointer( + child: widget.messageWidget, ), - 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, + 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, ), - ...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 a361f1ad6..e37cd379f 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 d62d6fb47..bc9d0cbc8 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/clear_input_item_button.dart b/packages/stream_chat_flutter/lib/src/message_input/clear_input_item_button.dart index 414b78315..e7a93aeda 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/clear_input_item_button.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/clear_input_item_button.dart @@ -1,5 +1,3 @@ -// ignore_for_file: deprecated_member_use_from_same_package - import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; 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 3bec19759..83e04cc60 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, - ); - 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 attachment = message.attachments.first; - final isImageFile = attachment.title?.mimeType?.type == 'image'; - final isVideoFile = attachment.title?.mimeType?.type == 'video'; + var attachmentBuilders = attachmentThumbnailBuilders; + attachmentBuilders ??= _createDefaultAttachmentBuilders(); - return Material( - clipBehavior: Clip.hardEdge, - type: MaterialType.transparency, - shape: attachment.type == 'file' && (!isImageFile && !isVideoFile) - ? null - : RoundedRectangleBorder( - side: const BorderSide(width: 0, color: Colors.transparent), - borderRadius: BorderRadius.circular(8), - ), - child: AbsorbPointer(child: child), + // Build the attachment widget using the builder for the attachment type. + final attachmentWidget = attachmentBuilders[attachment.type]?.call( + context, + attachment, ); - } - 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)), - ), - ); - }, - '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 empty container if no attachment widget is returned. + if (attachmentWidget == null) return const SizedBox.shrink(); - return builders; - } -} + final colorTheme = StreamChatTheme.of(context).colorTheme; -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!, - ), + 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), ), ); } - return AttachmentError(constraints: BoxConstraints.loose(size)); - } -} -class _VideoAttachmentThumbnail extends StatefulWidget { - const _VideoAttachmentThumbnail({ - required this.attachment, - }); + return Container( + key: Key(attachment.id), + clipBehavior: clipBehavior, + decoration: decoration, + constraints: const BoxConstraints.tightFor(width: 36, height: 36), + child: AbsorbPointer(child: attachmentWidget), + ); + } - final Attachment attachment; + _Builders _createDefaultAttachmentBuilders() { + Widget _createMediaThumbnail(BuildContext context, Attachment media) { + return StreamImageAttachmentThumbnail( + image: media, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ); + } - @override - _VideoAttachmentThumbnailState createState() => - _VideoAttachmentThumbnailState(); -} + Widget _createUrlThumbnail(BuildContext context, Attachment media) { + return StreamImageAttachmentThumbnail( + image: media, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ); + } -class _VideoAttachmentThumbnailState extends State<_VideoAttachmentThumbnail> { - late VideoPlayerController _controller; + Widget _createFileThumbnail(BuildContext context, Attachment file) { + Widget thumbnail = StreamFileAttachmentThumbnail( + file: file, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + ); - @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. - }); - } + 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: thumbnail, + ); + } - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } + return thumbnail; + } - @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/quoting_message_top_area.dart b/packages/stream_chat_flutter/lib/src/message_input/quoting_message_top_area.dart index 0adfbe48e..cdc4e3310 100644 --- a/packages/stream_chat_flutter/lib/src/message_input/quoting_message_top_area.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/quoting_message_top_area.dart @@ -1,5 +1,3 @@ -// ignore_for_file: deprecated_member_use_from_same_package - import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; 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 227a5192c..7a02dac14 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 @@ -1,5 +1,3 @@ -// ignore_for_file: deprecated_member_use_from_same_package - import 'dart:async'; import 'dart:math'; @@ -110,8 +108,6 @@ class StreamMessageInput extends StatefulWidget { this.mediaAttachmentListBuilder, this.fileAttachmentBuilder, this.mediaAttachmentBuilder, - @Deprecated('Use `mediaAttachmentBuilder` instead.') - this.attachmentThumbnailBuilders, this.focusNode, this.sendButtonLocation = SendButtonLocation.outside, this.autofocus = false, @@ -235,10 +231,6 @@ class StreamMessageInput extends StatefulWidget { /// Builder used to build the media attachment item. final AttachmentItemBuilder? mediaAttachmentBuilder; - /// Map that defines a thumbnail builder for an attachment type. - @Deprecated('Use `mediaAttachmentBuilder` instead.') - final Map? attachmentThumbnailBuilders; - /// Map that defines a thumbnail builder for an attachment type. /// /// This is used to build the thumbnail for the attachment in the quoted @@ -1178,7 +1170,7 @@ class StreamMessageInputState extends State } final containsUrl = quotedMessage.attachments.any((it) { - return it.titleLink != null; + return it.type == AttachmentType.urlPreview; }); return StreamQuotedMessageWidget( @@ -1221,44 +1213,7 @@ class StreamMessageInputState extends State fileAttachmentListBuilder: widget.fileAttachmentListBuilder, mediaAttachmentListBuilder: widget.mediaAttachmentListBuilder, fileAttachmentBuilder: widget.fileAttachmentBuilder, - mediaAttachmentBuilder: widget.mediaAttachmentBuilder ?? - // For backward compatibility. - // TODO: Remove in the next major release. - (context, attachment, onRemovePressed) { - final Widget mediaAttachmentThumbnail; - - final builder = - widget.attachmentThumbnailBuilders?[attachment.type]; - if (builder != null) { - mediaAttachmentThumbnail = builder(context, attachment); - } else { - mediaAttachmentThumbnail = MessageInputMediaAttachmentThumbnail( - attachment: attachment, - ); - } - - return ClipRRect( - key: Key(attachment.id), - borderRadius: BorderRadius.circular(10), - child: Stack( - children: [ - AspectRatio( - aspectRatio: 1, - child: mediaAttachmentThumbnail, - ), - Positioned( - top: 8, - right: 8, - child: RemoveAttachmentButton( - onPressed: onRemovePressed != null - ? () => onRemovePressed(attachment) - : null, - ), - ), - ], - ), - ); - }, + mediaAttachmentBuilder: widget.mediaAttachmentBuilder, ), ); } @@ -1362,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; @@ -1387,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) { @@ -1403,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 46ebbd85f..56dabbfe7 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 dbaccc17b..61b43e211 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; - } - if (reverse) { - final aTop = getTopElementIndex(a); - final bTop = getTopElementIndex(b); - return aTop == bTop; - } else { - final aBottom = getBottomElementIndex(a); - final bBottom = getBottomElementIndex(b); - return aBottom == bBottom; - } - }, - builder: (context, values) { - if (values.isEmpty || messages.isEmpty) { - return const Offstage(); - } + return ValueListenableBuilder( + valueListenable: itemPositionListener, + builder: (context, positions, child) { + if (positions.isEmpty || messages.isEmpty) { + return const Offstage(); + } - int? index; - if (reverse) { - index = getTopElementIndex(values); - } else { - index = getBottomElementIndex(values); - } + 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 == 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; - } + 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 f9de17876..f3ed40843 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 @@ -1,7 +1,5 @@ // ignore_for_file: lines_longer_than_80_chars import 'dart:async'; -import 'dart:math' as math; -import 'dart:ui'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -12,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 @@ -97,11 +96,6 @@ class StreamMessageListView extends StatefulWidget { this.initialAlignment, this.scrollController, this.itemPositionListener, - @Deprecated( - 'Try wrapping the `MessageWidget` with a `Swipeable`, `Dismissible` or a ' - 'custom widget to achieve the swipe to reply behaviour.', - ) - this.onMessageSwiped, this.highlightInitialMessage = false, this.messageHighlightColor, this.showConnectionStateTile = false, @@ -110,6 +104,7 @@ class StreamMessageListView extends StatefulWidget { this.loadingBuilder, this.emptyBuilder, this.systemMessageBuilder, + this.ephemeralMessageBuilder, this.messageListBuilder, this.errorBuilder, this.messageFilter, @@ -155,6 +150,9 @@ class StreamMessageListView extends StatefulWidget { /// {@macro systemMessageBuilder} final SystemMessageBuilder? systemMessageBuilder; + /// {@macro ephemeralMessageBuilder} + final EphemeralMessageBuilder? ephemeralMessageBuilder; + /// {@macro parentMessageBuilder} final ParentMessageBuilder? parentMessageBuilder; @@ -215,9 +213,6 @@ class StreamMessageListView extends StatefulWidget { /// The ScrollPhysics used by the ListView final ScrollPhysics? scrollPhysics; - /// {@macro onMessageSwiped} - final OnMessageSwiped? onMessageSwiped; - /// If true the list will highlight the initialMessage if there is any. /// /// Also See [StreamChannel] @@ -815,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, + ), ), ], ); @@ -924,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, @@ -944,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, @@ -1057,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( @@ -1069,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; @@ -1086,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 && @@ -1117,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 ?? []; @@ -1168,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 @@ -1263,76 +1308,6 @@ class _StreamMessageListViewState extends State { ); } - // Add swipeable if the callback is provided and the message is not deleted, - // system or ephemeral. - final onMessageSwiped = widget.onMessageSwiped; - if (onMessageSwiped != null && - !message.isDeleted && - !message.isSystem && - !message.isEphemeral) { - // The threshold after which the message is considered swiped. - const threshold = 0.2; - - // The direction in which the message can be swiped. - final swipeDirection = isMyMessage - ? SwipeDirection.endToStart // - : SwipeDirection.startToEnd; - - child = Swipeable( - key: ValueKey(message.id), - direction: swipeDirection, - swipeThreshold: threshold, - onSwiped: (_) => onMessageSwiped(message), - backgroundBuilder: (context, details) { - // The alignment of the swipe action. - final alignment = isMyMessage - ? Alignment.centerRight // - : Alignment.centerLeft; - - // The progress of the swipe action. - final progress = math.min(details.progress, threshold) / threshold; - - // The offset for the reply icon. - var offset = Offset.lerp( - const Offset(-24, 0), - const Offset(12, 0), - progress, - )!; - - // If the message is mine, we need to flip the offset. - if (isMyMessage) { - offset = Offset(-offset.dx, -offset.dy); - } - - return Align( - alignment: alignment, - child: Transform.translate( - offset: offset, - child: Opacity( - opacity: progress, - child: SizedBox.square( - dimension: 30, - child: CustomPaint( - painter: AnimatedCircleBorderPainter( - progress: progress, - color: _streamTheme.colorTheme.borders, - ), - child: Center( - child: StreamSvgIcon.reply( - size: lerpDouble(0, 18, progress), - color: _streamTheme.colorTheme.accentPrimary, - ), - ), - ), - ), - ), - ), - ); - }, - child: child, - ); - } - return child; } 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 f926eff0c..b1e3d271a 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_list_view/unread_messages_separator.dart b/packages/stream_chat_flutter/lib/src/message_list_view/unread_messages_separator.dart index 57e9257ca..b32c36d8e 100644 --- a/packages/stream_chat_flutter/lib/src/message_list_view/unread_messages_separator.dart +++ b/packages/stream_chat_flutter/lib/src/message_list_view/unread_messages_separator.dart @@ -24,7 +24,7 @@ class UnreadMessagesSeparator extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(8), child: Text( - context.translations.unreadMessagesSeparatorText(unreadCount), + context.translations.unreadMessagesSeparatorText(), textAlign: TextAlign.center, style: StreamChannelHeaderTheme.of(context).subtitleStyle, ), 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 83a40a4d5..d7eb2f6c9 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 000000000..9c76a4fa0 --- /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 000000000..16bdcace9 --- /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 cd84859f1..455da2200 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,104 +159,83 @@ 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, - ); - } - Color? _getBackgroundColor() { if (widget.hasQuotedMessage) { 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 92d0889a0..14be657d8 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 @@ -2,9 +2,9 @@ import 'package:contextmenu/contextmenu.dart'; import 'package:flutter/material.dart' hide ButtonStyle; import 'package:flutter/services.dart'; 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,28 +41,21 @@ 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, this.onReactionsHover, - bool? showReactionPicker, - @Deprecated('Use `showReactionPicker` instead') - bool showReactionPickerIndicator = true, - @internal - @Deprecated('Use `showReactionPicker` instead') - this.showReactionPickerTail, + this.showReactionPicker = true, this.showUserAvatar = DisplayWidget.show, this.showSendingIndicator = true, this.showThreadReplyIndicator = false, @@ -90,16 +83,8 @@ class StreamMessageWidget extends StatefulWidget { this.quotedMessageBuilder, this.editMessageInputBuilder, this.textBuilder, - @Deprecated(''' - Use [bottomRowBuilderWithDefaultWidget] instead. - Will be removed in the next major version. - ''') this.bottomRowBuilder, this.bottomRowBuilderWithDefaultWidget, - @Deprecated(''' - Use [bottomRowBuilderWithDefaultWidget] instead. - Will be removed in the next major version. - ''') this.deletedBottomRowBuilder, - this.customAttachmentBuilders, + this.attachmentBuilders, this.padding, this.textPadding = const EdgeInsets.symmetric( horizontal: 16, @@ -110,198 +95,161 @@ class StreamMessageWidget extends StatefulWidget { this.onQuotedMessageTap, this.customActions = const [], this.onAttachmentTap, - @Deprecated(''' - Use [bottomRowBuilderWithDefaultWidget] instead. - Will be removed in the next major version. - ''') this.usernameBuilder, this.imageAttachmentThumbnailSize = const Size(400, 400), this.imageAttachmentThumbnailResizeType = 'clip', this.imageAttachmentThumbnailCropType = 'center', this.attachmentActionsModalBuilder, - }) : assert( - bottomRowBuilder == null || bottomRowBuilderWithDefaultWidget == null, - 'You can only use one of the two bottom row builders', - ), - showReactionPicker = showReactionPicker ?? showReactionPickerIndicator, - 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 @@ -333,32 +281,17 @@ class StreamMessageWidget extends StatefulWidget { /// {@endtemplate} final Widget Function(BuildContext, Message)? textBuilder; - /// {@template usernameBuilder} - /// Widget builder for building username - /// {@endtemplate} - final Widget Function(BuildContext, Message)? usernameBuilder; - /// {@template onMessageActions} /// Function called on long press /// {@endtemplate} final void Function(BuildContext, Message)? onMessageActions; - /// {@template bottomRowBuilder} - /// Widget builder for building a bottom row below the message - /// {@endtemplate} - final BottomRowBuilder? bottomRowBuilder; - /// {@template bottomRowBuilderWithDefaultWidget} /// Widget builder for building a bottom row below the message. /// Also contains the default bottom row widget. /// {@endtemplate} final BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget; - /// {@template deletedBottomRowBuilder} - /// Widget builder for building a bottom row below a deleted message - /// {@endtemplate} - final Widget Function(BuildContext, Message)? deletedBottomRowBuilder; - /// {@template userAvatarBuilder} /// Widget builder for building user avatar /// {@endtemplate} @@ -399,21 +332,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} @@ -475,19 +398,6 @@ class StreamMessageWidget extends StatefulWidget { /// {@endtemplate} final bool showReactionPicker; - /// {@template showReactionPickerIndicator} - /// Used in [StreamMessageReactionsModal] and [MessageActionsModal] - /// {@endtemplate} - @Deprecated('Use `showReactionPicker` instead') - bool get showReactionPickerIndicator => showReactionPicker; - - /// {@template showReactionPickerTail} - /// Whether or not to show the reaction picker tail - /// {@endtemplate} - @internal - @Deprecated('Use `showReactionPicker` instead') - final bool? showReactionPickerTail; - /// {@template onShowMessage} /// Callback when show message is tapped /// {@endtemplate} @@ -549,14 +459,13 @@ class StreamMessageWidget extends StatefulWidget { final bool showPinHighlight; /// {@template attachmentBuilders} - /// Builder for respective attachment types - /// {@endtemplate} - final Map attachmentBuilders; - - /// {@template customAttachmentBuilders} - /// Builder for respective attachment types (user facing builder) + /// 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? customAttachmentBuilders; + final List? attachmentBuilders; /// {@template translateUserAvatar} /// Center user avatar with bottom of the message @@ -586,7 +495,7 @@ class StreamMessageWidget extends StatefulWidget { final List customActions; /// {@macro onMessageWidgetAttachmentTap} - final OnMessageWidgetAttachmentTap? onAttachmentTap; + final StreamAttachmentWidgetTapCallback? onAttachmentTap; /// {@macro attachmentActionsBuilder} final AttachmentActionsBuilder? attachmentActionsModalBuilder; @@ -618,19 +527,7 @@ class StreamMessageWidget extends StatefulWidget { Widget Function(BuildContext, Message)? editMessageInputBuilder, Widget Function(BuildContext, Message)? textBuilder, Widget Function(BuildContext, Message)? quotedMessageBuilder, - @Deprecated(''' - Use [bottomRowBuilderWithDefaultWidget] instead. - Will be removed in the next major version. - ''') Widget Function(BuildContext, Message)? usernameBuilder, - @Deprecated(''' - Use [bottomRowBuilderWithDefaultWidget] instead. - Will be removed in the next major version. - ''') BottomRowBuilder? bottomRowBuilder, BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget, - @Deprecated(''' - Use [bottomRowBuilderWithDefaultWidget] instead. - Will be removed in the next major version. - ''') Widget Function(BuildContext, Message)? deletedBottomRowBuilder, void Function(BuildContext, Message)? onMessageActions, Message? message, StreamMessageThemeData? messageTheme, @@ -638,9 +535,7 @@ class StreamMessageWidget extends StatefulWidget { ShapeBorder? shape, ShapeBorder? attachmentShape, BorderSide? borderSide, - BorderSide? attachmentBorderSide, BorderRadiusGeometry? borderRadiusGeometry, - BorderRadiusGeometry? attachmentBorderRadiusGeometry, EdgeInsetsGeometry? padding, EdgeInsets? textPadding, EdgeInsetsGeometry? attachmentPadding, @@ -655,9 +550,6 @@ class StreamMessageWidget extends StatefulWidget { void Function(String)? onLinkTap, bool? showReactionBrowser, bool? showReactionPicker, - @Deprecated('Use `showReactionPicker` instead') - bool? showReactionPickerIndicator, - @internal bool? showReactionPickerTail, List? readList, ShowMessageCallback? onShowMessage, bool? showUsername, @@ -671,7 +563,7 @@ class StreamMessageWidget extends StatefulWidget { bool? showFlagButton, bool? showPinButton, bool? showPinHighlight, - Map? customAttachmentBuilders, + List? attachmentBuilders, bool? translateUserAvatar, OnQuotedMessageTap? onQuotedMessageTap, void Function(Message)? onMessageTap, @@ -685,29 +577,6 @@ class StreamMessageWidget extends StatefulWidget { String? imageAttachmentThumbnailCropType, AttachmentActionsBuilder? attachmentActionsModalBuilder, }) { - assert( - bottomRowBuilder == null || bottomRowBuilderWithDefaultWidget == null, - 'You can only use one of the two bottom row builders', - ); - - var _bottomRowBuilderWithDefaultWidget = - bottomRowBuilderWithDefaultWidget ?? - this.bottomRowBuilderWithDefaultWidget; - - _bottomRowBuilderWithDefaultWidget ??= (context, message, defaultWidget) { - final _bottomRowBuilder = bottomRowBuilder ?? this.bottomRowBuilder; - if (_bottomRowBuilder != null) { - return _bottomRowBuilder(context, message); - } - - return defaultWidget.copyWith( - onThreadTap: onThreadTap ?? this.onThreadTap, - usernameBuilder: usernameBuilder ?? this.usernameBuilder, - deletedBottomRowBuilder: - deletedBottomRowBuilder ?? this.deletedBottomRowBuilder, - ); - }; - return StreamMessageWidget( key: key ?? this.key, onMentionTap: onMentionTap ?? this.onMentionTap, @@ -718,7 +587,8 @@ class StreamMessageWidget extends StatefulWidget { editMessageInputBuilder ?? this.editMessageInputBuilder, textBuilder: textBuilder ?? this.textBuilder, quotedMessageBuilder: quotedMessageBuilder ?? this.quotedMessageBuilder, - bottomRowBuilderWithDefaultWidget: _bottomRowBuilderWithDefaultWidget, + bottomRowBuilderWithDefaultWidget: bottomRowBuilderWithDefaultWidget ?? + this.bottomRowBuilderWithDefaultWidget, onMessageActions: onMessageActions ?? this.onMessageActions, message: message ?? this.message, messageTheme: messageTheme ?? this.messageTheme, @@ -726,10 +596,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, @@ -743,9 +610,7 @@ class StreamMessageWidget extends StatefulWidget { showInChannelIndicator ?? this.showInChannelIndicator, onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap, onLinkTap: onLinkTap ?? this.onLinkTap, - showReactionPicker: showReactionPicker ?? - showReactionPickerIndicator ?? - this.showReactionPicker, + showReactionPicker: showReactionPicker ?? this.showReactionPicker, onShowMessage: onShowMessage ?? this.onShowMessage, showUsername: showUsername ?? this.showUsername, showTimestamp: showTimestamp ?? this.showTimestamp, @@ -759,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, @@ -817,8 +681,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. @@ -830,15 +694,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`: @@ -876,7 +739,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); @@ -889,7 +753,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 && @@ -961,23 +826,6 @@ class _StreamMessageWidgetState extends State : Alignment.centerLeft, widthFactor: widget.widthFactor, child: Builder(builder: (context) { - var _bottomRowBuilderWithDefaultWidget = - widget.bottomRowBuilderWithDefaultWidget; - - _bottomRowBuilderWithDefaultWidget ??= - (context, message, defaultWidget) { - final _bottomRowBuilder = widget.bottomRowBuilder; - if (_bottomRowBuilder != null) { - return _bottomRowBuilder(context, message); - } - - return defaultWidget.copyWith( - onThreadTap: widget.onThreadTap, - usernameBuilder: widget.usernameBuilder, - deletedBottomRowBuilder: widget.deletedBottomRowBuilder, - ); - }; - return MessageWidgetContent( streamChatTheme: _streamChatTheme, showUsername: showUsername, @@ -996,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, @@ -1023,7 +877,7 @@ class _StreamMessageWidgetState extends State onMentionTap: widget.onMentionTap, onQuotedMessageTap: widget.onQuotedMessageTap, bottomRowBuilderWithDefaultWidget: - _bottomRowBuilderWithDefaultWidget, + widget.bottomRowBuilderWithDefaultWidget, onUserAvatarTap: widget.onUserAvatarTap, userAvatarBuilder: widget.userAvatarBuilder, ); @@ -1218,9 +1072,7 @@ class _StreamMessageWidgetState extends State translateUserAvatar: false, showSendingIndicator: false, padding: EdgeInsets.zero, - // Show both the tail if the picker is shown. showReactionPicker: widget.showReactionPicker, - showReactionPickerTail: widget.showReactionPicker, showPinHighlight: false, showUserAvatar: widget.message.user!.id == channel.client.state.currentUser!.id @@ -1260,7 +1112,6 @@ class _StreamMessageWidgetState extends State return StreamChannel( channel: channel, child: MessageActionsModal( - showReactionPicker: widget.showReactionPicker, messageWidget: widget.copyWith( key: const Key('MessageWidget'), message: widget.message.copyWith( @@ -1295,6 +1146,7 @@ class _StreamMessageWidgetState extends State showResendMessage: shouldShowResendAction, showCopyMessage: shouldShowCopyAction, showEditMessage: shouldShowEditAction, + showReactionPicker: widget.showReactionPicker, showReplyMessage: shouldShowReplyAction, showThreadReplyMessage: shouldShowThreadReplyAction, showFlagButton: widget.showFlagButton, 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 b01c65588..39e776754 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, @@ -67,28 +73,9 @@ class MessageWidgetContent extends StatelessWidget { this.onLinkTap, this.textBuilder, this.quotedMessageBuilder, - @Deprecated(''' - Use [bottomRowBuilderWithDefaultWidget] instead. - Will be removed in the next major version. - ''') this.bottomRowBuilder, this.bottomRowBuilderWithDefaultWidget, - @Deprecated(''' - Use [bottomRowBuilderWithDefaultWidget] instead. - Will be removed in the next major version. - ''') this.onThreadTap, - @Deprecated(''' - Use [bottomRowBuilderWithDefaultWidget] instead. - Will be removed in the next major version. - ''') this.deletedBottomRowBuilder, this.userAvatarBuilder, - @Deprecated(''' - Use [bottomRowBuilderWithDefaultWidget] instead. - Will be removed in the next major version. - ''') this.usernameBuilder, - }) : assert( - bottomRowBuilder == null || bottomRowBuilderWithDefaultWidget == null, - 'You can only use one of the two bottom row builders', - ); + }); /// {@macro reverse} final bool reverse; @@ -157,11 +144,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; @@ -189,9 +191,6 @@ class MessageWidgetContent extends StatelessWidget { /// The padding to use for this widget. final double bottomRowPadding; - /// {@macro bottomRowBuilder} - final BottomRowBuilder? bottomRowBuilder; - /// {@macro bottomRowBuilderWithDefaultWidget} final BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget; @@ -213,21 +212,12 @@ class MessageWidgetContent extends StatelessWidget { /// {@macro showUsername} final bool showUsername; - /// {@macro onThreadTap} - final void Function(Message)? onThreadTap; - - /// {@macro deletedBottomRowBuilder} - final Widget Function(BuildContext, Message)? deletedBottomRowBuilder; - /// {@macro messageWidget} final StreamMessageWidget messageWidget; /// {@macro userAvatarBuilder} final Widget Function(BuildContext, User)? userAvatarBuilder; - /// {@macro usernameBuilder} - final Widget Function(BuildContext, Message)? usernameBuilder; - @override Widget build(BuildContext context) { return Column( @@ -342,6 +332,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, @@ -444,16 +440,11 @@ class MessageWidgetContent extends StatelessWidget { showTimeStamp: showTimeStamp, showUsername: showUsername, streamChatTheme: streamChatTheme, - onThreadTap: onThreadTap, - deletedBottomRowBuilder: deletedBottomRowBuilder, streamChat: streamChat, hasNonUrlAttachments: hasNonUrlAttachments, - usernameBuilder: usernameBuilder, ); - if (bottomRowBuilder != null) { - return bottomRowBuilder!(context, message); - } else if (bottomRowBuilderWithDefaultWidget != null) { + if (bottomRowBuilderWithDefaultWidget != null) { return bottomRowBuilderWithDefaultWidget!( context, message, 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 dddf83ddf..57b849ede 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; + + // 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; - final attachmentList = []; + 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; + }, + ); - attachmentGroups.forEach((type, attachments) { - final attachmentBuilder = attachmentBuilders[type]; + 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, + ), + ); + }, + ), + ); - if (attachmentBuilder == null) return; - final attachmentWidget = attachmentBuilder( - context, - message, - attachments, - ); - attachmentList.add(attachmentWidget); - }); + return; + } + }; - return Padding( + // Create a default attachmentBuilders list if not provided. + var builders = attachmentBuilders; + builders ??= StreamAttachmentWidgetBuilder.defaultBuilders( + message: message, + shape: attachmentShape, padding: attachmentPadding, - child: Column( - mainAxisSize: MainAxisSize.min, - children: attachmentList.insertBetween( - SizedBox( - height: attachmentPadding.vertical / 2, - ), - ), - ), + 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 673853af4..8f73efb01 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 8dadc47d8..072ee5bec 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 2fa017ed2..af5260a23 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 000000000..2ffc9bfeb --- /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 000000000..8ecb1094c --- /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 d37e49576..05a09f6e9 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 b1b165ba2..36b676f4c 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/scroll_view/member_scroll_view/stream_member_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart index 5c01f5210..733f1d135 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/member_scroll_view/stream_member_list_view.dart @@ -1,5 +1,3 @@ -// ignore_for_file: deprecated_member_use_from_same_package - import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart index e4395e651..8b7d91df1 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/message_search_scroll_view/stream_message_search_list_view.dart @@ -1,5 +1,3 @@ -// ignore_for_file: deprecated_member_use_from_same_package - import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; diff --git a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart index f9a14270f..dcf8c7839 100644 --- a/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/scroll_view/user_scroll_view/stream_user_list_view.dart @@ -1,5 +1,3 @@ -// ignore_for_file: deprecated_member_use_from_same_package - import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; 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 379c1c140..f4259fd44 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 1d98b5aaa..010d8777c 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/message_theme.dart b/packages/stream_chat_flutter/lib/src/theme/message_theme.dart index b997fd4a3..32b2fa9d3 100644 --- a/packages/stream_chat_flutter/lib/src/theme/message_theme.dart +++ b/packages/stream_chat_flutter/lib/src/theme/message_theme.dart @@ -22,16 +22,13 @@ class StreamMessageThemeData with Diagnosticable { this.reactionsMaskColor, this.avatarTheme, this.createdAtStyle, - @Deprecated('Use urlAttachmentBackgroundColor instead') - Color? linkBackgroundColor, - Color? urlAttachmentBackgroundColor, + this.urlAttachmentBackgroundColor, this.urlAttachmentHostStyle, this.urlAttachmentTitleStyle, this.urlAttachmentTextStyle, this.urlAttachmentTitleMaxLine, this.urlAttachmentTextMaxLine, - }) : urlAttachmentBackgroundColor = - urlAttachmentBackgroundColor ?? linkBackgroundColor; + }); /// Text style for message text final TextStyle? messageTextStyle; @@ -66,10 +63,6 @@ class StreamMessageThemeData with Diagnosticable { /// Theme of the avatar final StreamAvatarThemeData? avatarTheme; - /// Background color for messages with url attachments. - @Deprecated('Use urlAttachmentBackgroundColor instead') - Color? get linkBackgroundColor => urlAttachmentBackgroundColor; - /// Background color for messages with url attachments. final Color? urlAttachmentBackgroundColor; @@ -101,8 +94,6 @@ class StreamMessageThemeData with Diagnosticable { Color? reactionsBackgroundColor, Color? reactionsBorderColor, Color? reactionsMaskColor, - @Deprecated('Use urlAttachmentBackgroundColor instead') - Color? linkBackgroundColor, Color? urlAttachmentBackgroundColor, TextStyle? urlAttachmentHostStyle, TextStyle? urlAttachmentTitleStyle, @@ -124,9 +115,8 @@ class StreamMessageThemeData with Diagnosticable { reactionsBackgroundColor ?? this.reactionsBackgroundColor, reactionsBorderColor: reactionsBorderColor ?? this.reactionsBorderColor, reactionsMaskColor: reactionsMaskColor ?? this.reactionsMaskColor, - urlAttachmentBackgroundColor: urlAttachmentBackgroundColor ?? - linkBackgroundColor ?? - this.urlAttachmentBackgroundColor, + urlAttachmentBackgroundColor: + urlAttachmentBackgroundColor ?? this.urlAttachmentBackgroundColor, urlAttachmentHostStyle: urlAttachmentHostStyle ?? this.urlAttachmentHostStyle, urlAttachmentTitleStyle: 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 e7449343d..c24948b38 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/user/user_item.dart b/packages/stream_chat_flutter/lib/src/user/user_item.dart deleted file mode 100644 index 0c13d95ec..000000000 --- a/packages/stream_chat_flutter/lib/src/user/user_item.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -/// {@template streamUserItem} -/// Shows a preview of the current [User]. -/// -/// This widget uses a [StreamBuilder] to render the user information -/// image as soon as it updates. -/// -/// It is not recommended to use this widget as it's the default user preview -/// used by [StreamUserListView]. -/// -/// The widget renders the ui based on the first ancestor of type -/// [StreamChatTheme]. -/// Modify it to change the widget's appearance. -/// {@endtemplate} -@Deprecated('Use `StreamUserListTile` instead.') -class StreamUserItem extends StatelessWidget { - /// {@macro streamUserItem} - const StreamUserItem({ - super.key, - required this.user, - this.onTap, - this.onLongPress, - this.onImageTap, - this.selected = false, - this.showLastOnline = true, - }); - - /// Function called when tapping or clicking on this widget - final void Function(User)? onTap; - - /// Function called when long pressing this widget - final void Function(User)? onLongPress; - - /// The user to display - final User user; - - /// The function called when the image is tapped or clicked - final void Function(User)? onImageTap; - - /// If true the [StreamUserItem] will show a trailing checkmark - final bool selected; - - /// If true the [StreamUserItem] will show the last seen - final bool showLastOnline; - - @override - Widget build(BuildContext context) { - final chatThemeData = StreamChatTheme.of(context); - return ListTile( - onTap: onTap == null ? null : () => onTap!(user), - onLongPress: onLongPress == null ? null : () => onLongPress!(user), - leading: StreamUserAvatar( - user: user, - onTap: onImageTap, - constraints: const BoxConstraints.tightFor( - height: 40, - width: 40, - ), - ), - trailing: selected - ? StreamSvgIcon.checkSend( - color: chatThemeData.colorTheme.accentPrimary, - ) - : null, - title: Text( - user.name, - style: chatThemeData.textTheme.bodyBold, - ), - subtitle: showLastOnline ? _buildLastActive(context) : null, - ); - } - - Widget _buildLastActive(BuildContext context) { - final chatTheme = StreamChatTheme.of(context); - final lastActive = user.lastActive ?? DateTime.now(); - return Text( - user.online - ? context.translations.userOnlineText - : '${context.translations.userLastOnlineText} ' - '${Jiffy.parseFromDateTime(lastActive).fromNow()}', - style: chatTheme.textTheme.footnote.copyWith( - color: chatTheme.colorTheme.textHighEmphasis.withOpacity(0.5), - ), - ); - } -} diff --git a/packages/stream_chat_flutter/lib/src/utils/extensions.dart b/packages/stream_chat_flutter/lib/src/utils/extensions.dart index 94a1bd838..3671e4a40 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 6bccc497d..f471f1a4d 100644 --- a/packages/stream_chat_flutter/lib/src/utils/helpers.dart +++ b/packages/stream_chat_flutter/lib/src/utils/helpers.dart @@ -59,29 +59,6 @@ bool getEffectiveCenterTitle( } } -/// Shows confirmation dialog -@Deprecated( - ''' - showConfirmationDialog is deprecated. - Use showConfirmationBottomSheet instead.''', -) -Future showConfirmationDialog( - BuildContext context, { - required String title, - required String okText, - Widget? icon, - String? question, - String? cancelText, -}) => - showConfirmationBottomSheet( - context, - title: title, - okText: okText, - icon: icon, - question: question, - cancelText: cancelText, - ); - /// Shows confirmation bottom sheet Future showConfirmationBottomSheet( BuildContext context, { @@ -169,29 +146,6 @@ Future showConfirmationBottomSheet( ); } -/// Shows info dialog -@Deprecated( - ''' - showInfoDialog is deprecated. - Use showInfoBottomSheet instead.''', -) -Future showInfoDialog( - BuildContext context, { - required String title, - required String okText, - Widget? icon, - String? details, - StreamChatThemeData? theme, -}) => - showInfoBottomSheet( - context, - title: title, - okText: okText, - icon: icon, - details: details, - theme: theme, - ); - /// Shows info bottom sheet Future showInfoBottomSheet( BuildContext context, { @@ -376,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': @@ -418,39 +372,20 @@ StreamSvgIcon getFileTypeImage(String? mimeType) { } } -/// Wraps attachment widget with custom shape -@Deprecated( - ''' -wrapAttachmentWidget is deprecated. -Use WrapAttachmentWidget instead -''', -) -Widget wrapAttachmentWidget( - BuildContext context, - Widget attachmentWidget, - ShapeBorder attachmentShape, - // ignore: avoid_positional_boolean_parameters - bool reverse, -) => - WrapAttachmentWidget( - attachmentWidget: attachmentWidget, - attachmentShape: attachmentShape, - ); - /// Wraps attachment widget with custom shape class WrapAttachmentWidget extends StatelessWidget { /// Builds a [WrapAttachmentWidget]. 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) { @@ -499,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 9ecb72ed4..4fa211108 100644 --- a/packages/stream_chat_flutter/lib/src/utils/typedefs.dart +++ b/packages/stream_chat_flutter/lib/src/utils/typedefs.dart @@ -60,14 +60,12 @@ typedef OnUserAvatarPress = void Function(User); typedef PlaceholderUserImage = Widget Function(BuildContext, User); /// {@template editMessageInputBuilder} -// ignore: deprecated_member_use_from_same_package /// A widget builder for building a pre-populated [MessageInput] for use in /// editing messages. /// {@endtemplate} typedef EditMessageInputBuilder = Widget Function(BuildContext, Message); /// {@template channelListHeaderTitleBuilder} -// ignore: deprecated_member_use_from_same_package /// A widget builder for custom [ChannelListHeader] title widgets. /// {@endtemplate} typedef ChannelListHeaderTitleBuilder = Widget Function( @@ -87,12 +85,6 @@ typedef ChannelTapCallback = void Function(Channel, Widget?); /// {@endtemplate} typedef ChannelInfoCallback = void Function(Channel); -/// {@template channelPreviewBuilder} -/// Builder used to create a custom ChannelPreview for a [Channel] -/// {@endtemplate} -@Deprecated('Use StreamChannelListViewIndexedWidgetBuilder instead') -typedef ChannelPreviewBuilder = Widget Function(BuildContext, Channel); - /// {@template viewInfoCallback} /// Callback for when 'View Info' is tapped /// {@endtemplate} @@ -164,7 +156,6 @@ typedef MentionTileOverlayBuilder = Widget Function( /// {@template userMentionTileBuilder} /// A builder function for representing a custom user mention tile. /// -// ignore: deprecated_member_use_from_same_package /// Use [UserMentionTile] for the default implementation. /// {@endtemplate} typedef UserMentionTileBuilder = Widget Function( @@ -202,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} @@ -243,7 +225,6 @@ typedef OnReactionsHover = void Function(bool isHovering); /// {@template messageSearchItemTapCallback} /// The action to perform when tapping or clicking on a user in a -// ignore: deprecated_member_use_from_same_package /// [MessageSearchListView]. /// {@endtemplate} typedef MessageSearchItemTapCallback = void Function(GetMessageResponse); @@ -289,6 +270,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 7ba8e5418..44d6643ca 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 4080c2dfe..693274527 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; - @override - _StreamVideoThumbnailImageState createState() => - _StreamVideoThumbnailImageState(); -} + /// The scale to place in the [ImageInfo] object of the image. + final double scale; -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, - ); - } + @override + Future obtainKey( + ImageConfiguration configuration, + ) { + return SynchronousFuture(this); } @override - void initState() { - super.initState(); - _generateThumbnail(); + @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), + ], + ); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _streamChatTheme = StreamChatTheme.of(context); + @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 - void didUpdateWidget(covariant StreamVideoThumbnailImage oldWidget) { - if (oldWidget.video != widget.video || oldWidget.format != widget.format) { - _generateThumbnail(); + bool operator ==(Object other) { + if (other.runtimeType != runtimeType) { + return false; } - super.didUpdateWidget(oldWidget); + return other is StreamVideoThumbnailImage && + other.video == video && + other.scale == scale; } @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, - ); - } + int get hashCode => Object.hash(video, scale); - 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, - ), - ); - }, - ), - ), - ), - ); + @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 50977784b..72470ee99 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'; @@ -26,11 +25,9 @@ export 'src/channel/channel_header.dart'; export 'src/channel/channel_info.dart'; export 'src/channel/channel_list_header.dart'; export 'src/channel/channel_name.dart'; -export 'src/channel/channel_preview.dart'; export 'src/channel/stream_channel_avatar.dart'; export 'src/channel/stream_channel_name.dart'; export 'src/channel/stream_message_preview_text.dart'; -export 'src/fullscreen_media/fsm_enums.dart'; export 'src/fullscreen_media/full_screen_media.dart'; export 'src/fullscreen_media/full_screen_media_builder.dart'; export 'src/gallery/gallery_footer.dart'; @@ -93,10 +90,10 @@ export 'src/stream_chat.dart'; export 'src/stream_chat_configuration.dart'; export 'src/theme/stream_chat_theme.dart'; export 'src/theme/themes.dart'; -export 'src/user/user_item.dart'; export 'src/user/user_mention_tile.dart'; 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 9a9d228e9..51fd40cda 100644 --- a/packages/stream_chat_flutter/pubspec.yaml +++ b/packages/stream_chat_flutter/pubspec.yaml @@ -1,18 +1,18 @@ name: stream_chat_flutter homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK. Build your own chat experience using Dart and Flutter. -version: 6.12.0 +version: 7.0.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" dependencies: cached_network_image: ^3.2.3 chewie: ^1.7.0 - collection: ^1.17.1 + collection: ^1.17.2 contextmenu: ^3.0.0 dart_vlc: ^0.4.0 desktop_drop: ^0.4.1 @@ -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 @@ -38,7 +39,7 @@ dependencies: rxdart: ^0.27.7 share_plus: ^7.1.0 shimmer: ^3.0.0 - stream_chat_flutter_core: ^6.11.0 + stream_chat_flutter_core: ^7.0.0 synchronized: ^3.1.0 thumblr: ^0.0.4 url_launcher: ^6.1.12 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 6e0385054..000000000 --- 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 bfe1223e5..000000000 --- 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 080cf6f5d..39aae1d01 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 43fea9e7e..5a1270c05 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 0399f1e87..755686c71 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 b90f28a43..03ec5fa8b 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 b1636fd40..cf12e07e7 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/channel/channel_preview_test.dart b/packages/stream_chat_flutter/test/src/channel/channel_preview_test.dart deleted file mode 100644 index 35d9bda64..000000000 --- a/packages/stream_chat_flutter/test/src/channel/channel_preview_test.dart +++ /dev/null @@ -1,97 +0,0 @@ -// ignore_for_file: deprecated_member_use_from_same_package - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/stream_chat_flutter.dart'; - -import '../mocks.dart'; - -void main() { - testWidgets( - 'it should show basic channel information', - (WidgetTester tester) async { - final client = MockClient(); - final clientState = MockClientState(); - final channel = MockChannel(); - final channelState = MockChannelState(); - final user = OwnUser(id: 'user-id'); - final lastMessageAt = DateTime.parse('2020-06-22 12:00:00'); - - when(() => channel.cid).thenReturn('cid'); - when(() => client.state).thenReturn(clientState); - when(() => clientState.currentUser).thenReturn(user); - when(() => clientState.currentUserStream) - .thenAnswer((_) => Stream.value(user)); - when(() => channel.lastMessageAtStream) - .thenAnswer((_) => Stream.value(lastMessageAt)); - when(() => channel.lastMessageAt).thenReturn(lastMessageAt); - when(() => channel.state).thenReturn(channelState); - when(() => channel.client).thenReturn(client); - when(() => channel.isMuted).thenReturn(false); - when(() => channel.isMutedStream).thenAnswer((i) => Stream.value(false)); - when(() => channel.nameStream) - .thenAnswer((i) => Stream.value('test name')); - when(() => channel.name).thenReturn('test name'); - when(() => channel.imageStream) - .thenAnswer((i) => Stream.value('https://bit.ly/321RmWb')); - when(() => channel.image).thenReturn('https://bit.ly/321RmWb'); - when(() => clientState.channels).thenReturn({ - channel.cid!: channel, - }); - when(() => channelState.unreadCount).thenReturn(1); - when(() => channelState.unreadCountStream) - .thenAnswer((i) => Stream.value(1)); - when(() => channelState.membersStream).thenAnswer( - (i) => Stream.value([ - Member( - userId: 'user-id', - user: User(id: 'user-id'), - ) - ]), - ); - when(() => channelState.members).thenReturn([ - Member( - userId: 'user-id', - user: User(id: 'user-id'), - ), - ]); - when(() => channelState.messages).thenReturn([ - Message( - text: 'hello', - user: User(id: 'other-user'), - ) - ]); - when(() => channelState.messagesStream).thenAnswer( - (i) => Stream.value([ - Message( - text: 'hello', - user: User(id: 'other-user'), - ) - ]), - ); - - await tester.pumpWidget( - MaterialApp( - home: StreamChat( - client: client, - child: StreamChannel( - channel: channel, - child: Scaffold( - body: ChannelPreview( - channel: channel, - ), - ), - ), - ), - ), - ); - - expect(find.text('6/22/2020'), findsOneWidget); - expect(find.text('test name'), findsOneWidget); - expect(find.text('1'), findsOneWidget); - expect(find.text('hello'), findsOneWidget); - expect(find.byType(StreamChannelAvatar), findsOneWidget); - }, - ); -} 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 2294ea844..096c345d2 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 8c4b011c5..c4704cde9 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 a6503af8f..53e98a8bc 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 c294b279e..0674402d2 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 9a2676adf..55b0c8c94 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 e18ed82d8..8623c9c36 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 9e7f6a738..e144913d4 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 36f80c62b..5bb1cc015 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 39535158f..8e84f2e9e 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 f07b2a9de..51f7c4366 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 a5b1eda75..5d411f66c 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 2b5939181..b1fbcad84 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 babe1ef3e..2cdcf5e60 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 f484ee2a0..4c1637736 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 8f21a6ca9..203d78420 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 3c8e020f8..eaac9b114 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 bf15f6aff..5b153d86f 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 bd466f798..4d3ab3b6e 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 d7d671c4f..30fe9dc9f 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 5381c0f0a..6b3f5cdb1 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 bb14fc09d..5eb45d370 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 1a7faf9cc..68abd020e 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 ea7f428c3..b24758f9a 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 75313e052..4e31bd438 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 9a4aac364..aeb97727d 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 1694dcb6c..45d6fd59c 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 d67e928d2..3b9e6366f 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 86a7ae2cc..af735d6a2 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 86cdacacc..7187ab2d8 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 27eb27148..58d1f8901 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 dc823015d..4c92e52e7 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 dc823015d..4c92e52e7 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 9b6e8ed06..558b87538 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 9b5f17694..b07304494 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 1d936b36e..3e86d8205 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 8bb24319d..c4cfff20d 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 a155ccc49..0224053b6 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 189e34326..9546c8275 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 a7afeea85..870f5464e 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 a64033319..f3d3c365f 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/misc/thread_header_test.dart b/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart index 51a246f2c..029e9f942 100644 --- a/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart +++ b/packages/stream_chat_flutter/test/src/misc/thread_header_test.dart @@ -1,5 +1,3 @@ -// ignore_for_file: deprecated_member_use_from_same_package - import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; 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 1b5499dd4..576e1a8a3 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 14c097066..1dd0340ed 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 f8d1d164d..c66d41f1f 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 651da1ce1..27f98cb35 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 f267d388f..80912e9a1 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 264710eca..79fb6dae9 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( diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index a68bb7a6e..4cdd1ddfb 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,10 @@ +## 7.0.0 + +- 🛑 **BREAKING** Removed deprecated `StreamChannelListController.sort` parameter. + Use `StreamChannelListController.channelStateSort` instead. +- Updated minimum supported `SDK` version to Flutter 3.13/Dart 3.1 +- Updated `stream_chat` dependency to [`7.0.0`](https://pub.dev/packages/stream_chat/changelog). + ## 6.11.0 🐞 Fixed diff --git a/packages/stream_chat_flutter_core/example/pubspec.yaml b/packages/stream_chat_flutter_core/example/pubspec.yaml index 4a45b71eb..513f58bbd 100644 --- a/packages/stream_chat_flutter_core/example/pubspec.yaml +++ b/packages/stream_chat_flutter_core/example/pubspec.yaml @@ -4,8 +4,8 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" dependencies: cupertino_icons: ^1.0.3 diff --git a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart index da93e7b8a..3195d2ea6 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_channel_list_controller.dart @@ -44,9 +44,6 @@ class StreamChannelListController extends PagedValueNotifier { required this.client, StreamChannelListEventHandler? eventHandler, this.filter, - @Deprecated(''' - sort has been deprecated. - Please use channelStateSort instead.''') this.sort, this.channelStateSort, this.presence = true, this.limit = defaultChannelPagedLimit, @@ -62,9 +59,6 @@ class StreamChannelListController extends PagedValueNotifier { StreamChannelListEventHandler? eventHandler, this.filter, this.channelStateSort, - @Deprecated(''' - sort has been deprecated. - Please use channelStateSort instead.''') this.sort, this.presence = true, this.limit = defaultChannelPagedLimit, this.messageLimit, @@ -84,20 +78,6 @@ class StreamChannelListController extends PagedValueNotifier { /// You can also filter other built-in channel fields. final Filter? filter; - /// The sorting used for the channels matching the filters. - /// - /// Sorting is based on field and direction, multiple sorting options - /// can be provided. - /// - /// You can sort based on last_updated, last_message_at, updated_at, - /// created_at or member_count. - /// - /// Direction can be ascending or descending. - @Deprecated(''' - sort has been deprecated. - Please use channelStateSort instead.''') - final List>? sort; - /// The sorting used for the channels matching the filters. /// /// Sorting is based on field and direction, multiple sorting options @@ -132,8 +112,6 @@ class StreamChannelListController extends PagedValueNotifier { await for (final channels in client.queryChannels( filter: filter, channelStateSort: channelStateSort, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - sort: sort, memberLimit: memberLimit, messageLimit: messageLimit, presence: presence, @@ -162,8 +140,6 @@ class StreamChannelListController extends PagedValueNotifier { try { await for (final channels in client.queryChannels( filter: filter, - // ignore: deprecated_member_use, deprecated_member_use_from_same_package - sort: sort, channelStateSort: channelStateSort, memberLimit: memberLimit, messageLimit: messageLimit, diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index e16ded8fc..1d3023d21 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -1,23 +1,23 @@ name: stream_chat_flutter_core homepage: https://github.com/GetStream/stream-chat-flutter description: Stream Chat official Flutter SDK Core. Build your own chat experience using Dart and Flutter. -version: 6.11.0 +version: 7.0.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" dependencies: - collection: ^1.17.1 - connectivity_plus: ">=4.0.2 <6.0.0" + collection: ^1.17.2 + connectivity_plus: ^4.0.2 flutter: sdk: flutter freezed_annotation: ^2.4.1 meta: ^1.9.1 rxdart: ^0.27.7 - stream_chat: ^6.10.0 + stream_chat: ^7.0.0 dev_dependencies: build_runner: ^2.4.6 diff --git a/packages/stream_chat_localizations/CHANGELOG.md b/packages/stream_chat_localizations/CHANGELOG.md index 92519d7fd..73ded4d10 100644 --- a/packages/stream_chat_localizations/CHANGELOG.md +++ b/packages/stream_chat_localizations/CHANGELOG.md @@ -1,3 +1,8 @@ +## 7.0.0 + +* Updated minimum supported `SDK` version to Flutter 3.13/Dart 3.1 +* Updated `stream_chat_flutter` dependency to [`7.0.0`](https://pub.dev/packages/stream_chat_flutter/changelog). + ## 5.12.0 * Updated `stream_chat_flutter` dependency to [`6.12.0`](https://pub.dev/packages/stream_chat_flutter/changelog). diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index e1d1335ac..3837851d6 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -461,7 +461,7 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { String get viewLibrary => 'View library'; @override - String unreadMessagesSeparatorText(int unreadCount) => 'New messages'; + String unreadMessagesSeparatorText() => 'New messages'; @override String get enableFileAccessMessage => 'Enable file access to continue'; diff --git a/packages/stream_chat_localizations/example/pubspec.yaml b/packages/stream_chat_localizations/example/pubspec.yaml index f3e11a290..6b3df4750 100644 --- a/packages/stream_chat_localizations/example/pubspec.yaml +++ b/packages/stream_chat_localizations/example/pubspec.yaml @@ -5,8 +5,8 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" dependencies: cupertino_icons: ^1.0.3 diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart index ed2d3d7ed..76a06f5c8 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ca.dart @@ -440,7 +440,7 @@ class StreamChatLocalizationsCa extends GlobalStreamChatLocalizations { String get linkDisabledError => 'Els enllaços estan deshabilitats'; @override - String unreadMessagesSeparatorText(int unreadCount) => 'Missatges nous'; + String unreadMessagesSeparatorText() => 'Missatges nous'; @override String get enableFileAccessMessage => "Habilita l'accés als fitxers" diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart index fe95f0f7f..5bbb8bbf8 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_de.dart @@ -433,7 +433,7 @@ class StreamChatLocalizationsDe extends GlobalStreamChatLocalizations { String get viewLibrary => 'Bibliothek öffnen'; @override - String unreadMessagesSeparatorText(int unreadCount) => 'Neue Nachrichten'; + String unreadMessagesSeparatorText() => 'Neue Nachrichten'; @override String get enableFileAccessMessage => diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index a744ff2ae..4431347ab 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -437,7 +437,7 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { String get viewLibrary => 'View library'; @override - String unreadMessagesSeparatorText(int unreadCount) => 'New messages'; + String unreadMessagesSeparatorText() => 'New messages'; @override String get enableFileAccessMessage => 'Please enable access to files' diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index 2d75b1028..71cdac1d1 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -442,7 +442,7 @@ No es posible añadir más de $limit archivos adjuntos String get linkDisabledError => 'Los enlaces están deshabilitados'; @override - String unreadMessagesSeparatorText(int unreadCount) => 'Nuevos mensajes'; + String unreadMessagesSeparatorText() => 'Nuevos mensajes'; @override String get enableFileAccessMessage => 'Habilite el acceso a los archivos' diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index 6a474b8a6..ac6efb6a3 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -441,7 +441,7 @@ Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $ String get linkDisabledError => 'Les liens sont désactivés'; @override - String unreadMessagesSeparatorText(int unreadCount) => 'Nouveaux messages'; + String unreadMessagesSeparatorText() => 'Nouveaux messages'; @override String get enableFileAccessMessage => diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index 4e056ab22..20535ba4a 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -435,7 +435,7 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { String get linkDisabledError => 'लिंक भेजना प्रतिबंधित'; @override - String unreadMessagesSeparatorText(int unreadCount) => 'नए संदेश।'; + String unreadMessagesSeparatorText() => 'नए संदेश।'; @override String get enableFileAccessMessage => 'कृपया फ़ाइलों तक पहुंच सक्षम करें ताकि' diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 51a808972..2fe3499c5 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -444,7 +444,7 @@ Attenzione: il limite massimo di $limit file è stato superato. String get linkDisabledError => 'I links sono disattivati'; @override - String unreadMessagesSeparatorText(int unreadCount) => 'Nouveaux messages'; + String unreadMessagesSeparatorText() => 'Nouveaux messages'; @override String get enableFileAccessMessage => "Per favore attiva l'accesso ai file" diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index 17285f6aa..e91962be1 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -420,7 +420,7 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { String get linkDisabledError => 'リンクが無効になっています'; @override - String unreadMessagesSeparatorText(int unreadCount) => '新しいメッセージ。'; + String unreadMessagesSeparatorText() => '新しいメッセージ。'; @override String get enableFileAccessMessage => diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index acf1f79f9..619af125e 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -421,7 +421,7 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { String get linkDisabledError => '링크가 비활성화되었습니다.'; @override - String unreadMessagesSeparatorText(int unreadCount) => '새 메시지.'; + String unreadMessagesSeparatorText() => '새 메시지.'; @override String get enableFileAccessMessage => '친구와 공유할 수 있도록 파일에 대한 액세스를 허용하세요.'; diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart index 8a6c4db31..28d5502f1 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_no.dart @@ -385,7 +385,7 @@ class StreamChatLocalizationsNo extends GlobalStreamChatLocalizations { String get viewLibrary => 'Se bibliotek'; @override - String unreadMessagesSeparatorText(int unreadCount) => 'Nye meldinger.'; + String unreadMessagesSeparatorText() => 'Nye meldinger.'; @override String get couldNotReadBytesFromFileError => diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart index f359808b2..eeaad7384 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_pt.dart @@ -440,7 +440,7 @@ Não é possível adicionar mais de $limit arquivos de uma vez String get viewLibrary => 'Ver biblioteca'; @override - String unreadMessagesSeparatorText(int unreadCount) => 'Novas mensagens'; + String unreadMessagesSeparatorText() => 'Novas mensagens'; @override String get enableFileAccessMessage => diff --git a/packages/stream_chat_localizations/pubspec.yaml b/packages/stream_chat_localizations/pubspec.yaml index 2b415c456..ada1f25ee 100644 --- a/packages/stream_chat_localizations/pubspec.yaml +++ b/packages/stream_chat_localizations/pubspec.yaml @@ -1,20 +1,20 @@ name: stream_chat_localizations description: The Official localizations for Stream Chat Flutter, a service for building chat applications -version: 5.12.0 +version: 7.0.0 homepage: https://github.com/GetStream/stream-chat-flutter repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" dependencies: flutter: sdk: flutter flutter_localizations: sdk: flutter - stream_chat_flutter: ^6.12.0 + stream_chat_flutter: ^7.0.0 dev_dependencies: flutter_test: diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index c59b07626..60789f89e 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -196,7 +196,7 @@ void main() { localizations.toggleMuteUnmuteUserQuestion(isMuted: true), isNotNull); expect(localizations.toggleMuteUnmuteUserText(isMuted: true), isNotNull); expect(localizations.viewLibrary, isNotNull); - expect(localizations.unreadMessagesSeparatorText(2), isNotNull); + expect(localizations.unreadMessagesSeparatorText(), isNotNull); expect(localizations.enableFileAccessMessage, isNotNull); expect(localizations.allowFileAccessMessage, isNotNull); }); diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 516226509..17a0e1593 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -1,3 +1,8 @@ +## 7.0.0 + +- Updated minimum supported `SDK` version to Flutter 3.13/Dart 3.1 +- 🛑 **BREAKING** Removed deprecated `getChannelStates.sort` parameter. Use `getChannelStates.channelStateSort` instead. + ## 6.10.0 - Updated `stream_chat` dependency to [`6.10.0`](https://pub.dev/packages/stream_chat/changelog). @@ -15,6 +20,7 @@ - [[#1683]](https://github.com/GetStream/stream-chat-flutter/issues/1683) Fixed SqliteException no such column `messages.state`. +- Updated `stream_chat` dependency to [`6.7.0`](https://pub.dev/packages/stream_chat/changelog). ## 6.6.0 diff --git a/packages/stream_chat_persistence/example/pubspec.yaml b/packages/stream_chat_persistence/example/pubspec.yaml index c384f1c8e..b6e4783a4 100644 --- a/packages/stream_chat_persistence/example/pubspec.yaml +++ b/packages/stream_chat_persistence/example/pubspec.yaml @@ -4,8 +4,8 @@ publish_to: 'none' version: 1.0.0+1 environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" dependencies: cupertino_icons: ^1.0.3 diff --git a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart index 8a44239c5..8b20715e8 100644 --- a/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart +++ b/packages/stream_chat_persistence/lib/src/stream_chat_persistence_client.dart @@ -251,38 +251,35 @@ class StreamChatPersistenceClient extends ChatPersistenceClient { @override Future> getChannelStates({ Filter? filter, - @Deprecated('Use channelStateSort instead.') - List>? sort, List>? channelStateSort, PaginationParams? paginationParams, }) async { assert(_debugIsConnected, ''); - assert( - sort == null || channelStateSort == null, - 'sort and channelStateSort cannot be used together', - ); + assert(() { + if (channelStateSort?.any((it) => it.comparator == null) ?? false) { + throw ArgumentError( + 'SortOption requires a comparator in order to sort', + ); + } + return true; + }(), ''); + _logger.info('getChannelStates'); - final channels = await db!.channelQueryDao.getChannels( - filter: filter, - sort: sort, - ); + final channels = await db!.channelQueryDao.getChannels(filter: filter); final channelStates = await Future.wait( channels.map((e) => getChannelStateByCid(e.cid)), ); - // Only sort the channel states if the channels are not already sorted. - if (sort == null) { - var comparator = _defaultChannelStateComparator; - if (channelStateSort != null && channelStateSort.isNotEmpty) { - comparator = _combineComparators( - channelStateSort.map((it) => it.comparator).withNullifyer, - ); - } - - channelStates.sort(comparator); + // Sort the channel states + var comparator = _defaultChannelStateComparator; + if (channelStateSort != null && channelStateSort.isNotEmpty) { + comparator = _combineComparators( + channelStateSort.map((it) => it.comparator).withNullifyer, + ); } + channelStates.sort(comparator); final offset = paginationParams?.offset; if (offset != null && offset > 0 && channelStates.isNotEmpty) { diff --git a/packages/stream_chat_persistence/pubspec.yaml b/packages/stream_chat_persistence/pubspec.yaml index 38d7537a9..be104674e 100644 --- a/packages/stream_chat_persistence/pubspec.yaml +++ b/packages/stream_chat_persistence/pubspec.yaml @@ -1,13 +1,13 @@ name: stream_chat_persistence homepage: https://github.com/GetStream/stream-chat-flutter description: Official Stream Chat Persistence library. Build your own chat experience using Dart and Flutter. -version: 6.10.0 +version: 7.0.0 repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: ">=3.0.0 <4.0.0" - flutter: ">=3.10.0" + sdk: ">=3.1.0 <4.0.0" + flutter: ">=3.13.0" dependencies: drift: ^2.11.0 @@ -18,7 +18,7 @@ dependencies: path: ^1.8.3 path_provider: ^2.1.0 sqlite3_flutter_libs: ^0.5.15 - stream_chat: ^6.10.0 + stream_chat: ^7.0.0 dev_dependencies: build_runner: ^2.4.6 diff --git a/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart index 91efdb6ea..91c7ad755 100644 --- a/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/channel_query_dao_test.dart @@ -148,7 +148,6 @@ void main() { // Should match with the inserted channels final updatedChannels = await channelQueryDao.getChannels( filter: filter, - // ignore: deprecated_member_use_from_same_package sort: [ SortOption( 'member_count', @@ -196,7 +195,6 @@ void main() { // Should match with the inserted channels final updatedChannels = await channelQueryDao.getChannels( filter: filter, - // ignore: deprecated_member_use_from_same_package sort: [SortOption('test_custom_field', comparator: sortComparator)], ); diff --git a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart index 0f3223a6e..f0392d0fa 100644 --- a/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart +++ b/packages/stream_chat_persistence/test/stream_chat_persistence_client_test.dart @@ -245,64 +245,81 @@ void main() { .called(1); }); - test('getChannelStates', () async { - const cid = 'testType:testId'; - final channels = List.generate(3, (index) => ChannelModel(cid: cid)); - final messages = List.generate(3, (index) => Message()); - final members = List.generate(3, (index) => Member()); - final reads = List.generate( - 3, - (index) => Read( - user: User(id: 'testUserId$index'), - lastRead: DateTime.now(), - ), - ); - final channel = ChannelModel(cid: cid); - final channelStates = channels - .map( - (channel) => ChannelState( - channel: channel, - messages: messages, - pinnedMessages: messages, - members: members, - read: reads, - ), - ) - .toList(growable: false); - - when(() => mockDatabase.channelQueryDao.getChannels()) - .thenAnswer((_) async => channels); - when(() => mockDatabase.memberDao.getMembersByCid(cid)) - .thenAnswer((_) async => members); - when(() => mockDatabase.readDao.getReadsByCid(cid)) - .thenAnswer((_) async => reads); - when(() => mockDatabase.channelDao.getChannelByCid(cid)) - .thenAnswer((_) async => channel); - when(() => mockDatabase.messageDao.getMessagesByCid(cid)) - .thenAnswer((_) async => messages); - when(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) - .thenAnswer((_) async => messages); + group('getChannelState', () { + test('should throw if sort is provided without comparator', () async { + final sort = [ + const SortOption( + 'testField', + direction: SortOption.ASC, + ), + ]; - final fetchedChannelStates = await client.getChannelStates(); - expect(fetchedChannelStates.length, channelStates.length); - - for (var i = 0; i < fetchedChannelStates.length; i++) { - final original = channelStates[i]; - final fetched = fetchedChannelStates[i]; - expect(fetched.members?.length, original.members?.length); - expect(fetched.messages?.length, original.messages?.length); - expect(fetched.pinnedMessages?.length, original.pinnedMessages?.length); - expect(fetched.read?.length, original.read?.length); - expect(fetched.channel!.cid, original.channel!.cid); - } + expect( + () => client.getChannelStates(channelStateSort: sort), + throwsA(isA()), + ); + }); - verify(() => mockDatabase.channelQueryDao.getChannels()).called(1); - verify(() => mockDatabase.memberDao.getMembersByCid(cid)).called(3); - verify(() => mockDatabase.readDao.getReadsByCid(cid)).called(3); - verify(() => mockDatabase.channelDao.getChannelByCid(cid)).called(3); - verify(() => mockDatabase.messageDao.getMessagesByCid(cid)).called(3); - verify(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) - .called(3); + test('should work fine', () async { + const cid = 'testType:testId'; + final channels = List.generate(3, (index) => ChannelModel(cid: cid)); + final messages = List.generate(3, (index) => Message()); + final members = List.generate(3, (index) => Member()); + final reads = List.generate( + 3, + (index) => Read( + user: User(id: 'testUserId$index'), + lastRead: DateTime.now(), + ), + ); + final channel = ChannelModel(cid: cid); + final channelStates = channels + .map( + (channel) => ChannelState( + channel: channel, + messages: messages, + pinnedMessages: messages, + members: members, + read: reads, + ), + ) + .toList(growable: false); + + when(() => mockDatabase.channelQueryDao.getChannels()) + .thenAnswer((_) async => channels); + when(() => mockDatabase.memberDao.getMembersByCid(cid)) + .thenAnswer((_) async => members); + when(() => mockDatabase.readDao.getReadsByCid(cid)) + .thenAnswer((_) async => reads); + when(() => mockDatabase.channelDao.getChannelByCid(cid)) + .thenAnswer((_) async => channel); + when(() => mockDatabase.messageDao.getMessagesByCid(cid)) + .thenAnswer((_) async => messages); + when(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) + .thenAnswer((_) async => messages); + + final fetchedChannelStates = await client.getChannelStates(); + expect(fetchedChannelStates.length, channelStates.length); + + for (var i = 0; i < fetchedChannelStates.length; i++) { + final original = channelStates[i]; + final fetched = fetchedChannelStates[i]; + expect(fetched.members?.length, original.members?.length); + expect(fetched.messages?.length, original.messages?.length); + expect( + fetched.pinnedMessages?.length, original.pinnedMessages?.length); + expect(fetched.read?.length, original.read?.length); + expect(fetched.channel!.cid, original.channel!.cid); + } + + verify(() => mockDatabase.channelQueryDao.getChannels()).called(1); + verify(() => mockDatabase.memberDao.getMembersByCid(cid)).called(3); + verify(() => mockDatabase.readDao.getReadsByCid(cid)).called(3); + verify(() => mockDatabase.channelDao.getChannelByCid(cid)).called(3); + verify(() => mockDatabase.messageDao.getMessagesByCid(cid)).called(3); + verify(() => mockDatabase.pinnedMessageDao.getMessagesByCid(cid)) + .called(3); + }); }); test('updateChannelQueries', () async { diff --git a/pubspec.yaml b/pubspec.yaml index 8c61d28a6..743b6d260 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: stream_chat_flutter_workspace environment: - sdk: '>=3.0.0 <4.0.0' + sdk: '>=3.1.0 <4.0.0' dev_dependencies: - melos: ^3.1.0 + melos: ^3.1.1