Merge remote-tracking branch 'origin/v7.0.0'

This commit is contained in:
Efthymis Sarmpanis
2023-12-04 12:16:34 +02:00
188 changed files with 4662 additions and 4119 deletions
+23
View File
@@ -14,6 +14,11 @@
- [[#1724]](https://github.com/GetStream/stream-chat-flutter/issues/1724) Fixed sendFile error - [[#1724]](https://github.com/GetStream/stream-chat-flutter/issues/1724) Fixed sendFile error
on `AttachmentFile` with `bytes` and no `name`. on `AttachmentFile` with `bytes` and no `name`.
## 7.0.0-beta.3
- Included the changes from version [6.8.0](#670).
- Updated minimum supported `SDK` version to Dart 3.1
## 6.8.0 ## 6.8.0
🐞 Fixed 🐞 Fixed
@@ -29,6 +34,10 @@
- Updated minimum supported `SDK` version to Dart 3.0 - Updated minimum supported `SDK` version to Dart 3.0
## 7.0.0-beta.2
- Included the changes from version [6.7.0](#670).
## 6.7.0 ## 6.7.0
✅ Added ✅ Added
@@ -41,6 +50,20 @@
should have the Create System Message permission. Server-side system messages don't need that permission. should have the Create System Message permission. Server-side system messages don't need that permission.
``` ```
## 7.0.0-beta.1
🛑️ 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 Dart 3.0
## 6.6.0 ## 6.6.0
🔄 Changed 🔄 Changed
+2 -2
View File
@@ -5,8 +5,8 @@ publish_to: "none"
version: 1.0.0+1 version: 1.0.0+1
environment: environment:
sdk: ">=3.0.0 <4.0.0" sdk: ">=3.1.0 <4.0.0"
flutter: ">=3.10.0" flutter: ">=3.13.0"
dependencies: dependencies:
cupertino_icons: ^1.0.5 cupertino_icons: ^1.0.5
@@ -499,7 +499,7 @@ class Channel {
]); ]);
} }
final isImage = it.type == 'image'; final isImage = it.type == AttachmentType.image;
final cancelToken = CancelToken(); final cancelToken = CancelToken();
Future<SendAttachmentResponse> future; Future<SendAttachmentResponse> future;
if (isImage) { if (isImage) {
@@ -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.dart';
import 'package:stream_chat/src/core/http/token_manager.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/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/channel_state.dart';
import 'package:stream_chat/src/core/models/event.dart'; import 'package:stream_chat/src/core/models/event.dart';
import 'package:stream_chat/src/core/models/filter.dart'; import 'package:stream_chat/src/core/models/filter.dart';
@@ -572,8 +571,6 @@ class StreamChatClient {
/// Requests channels with a given query. /// Requests channels with a given query.
Stream<List<Channel>> queryChannels({ Stream<List<Channel>> queryChannels({
Filter? filter, Filter? filter,
@Deprecated('Use channelStateSort instead.')
List<SortOption<ChannelModel>>? sort,
List<SortOption<ChannelState>>? channelStateSort, List<SortOption<ChannelState>>? channelStateSort,
bool state = true, bool state = true,
bool watch = true, bool watch = true,
@@ -590,7 +587,7 @@ class StreamChatClient {
final hash = generateHash([ final hash = generateHash([
filter, filter,
sort, channelStateSort,
state, state,
watch, watch,
presence, presence,
@@ -604,8 +601,6 @@ class StreamChatClient {
} else { } else {
final channels = await queryChannelsOffline( final channels = await queryChannelsOffline(
filter: filter, filter: filter,
// ignore: deprecated_member_use_from_same_package
sort: sort,
channelStateSort: channelStateSort, channelStateSort: channelStateSort,
paginationParams: paginationParams, paginationParams: paginationParams,
); );
@@ -614,7 +609,7 @@ class StreamChatClient {
try { try {
final newQueryChannelsFuture = queryChannelsOnline( final newQueryChannelsFuture = queryChannelsOnline(
filter: filter, filter: filter,
sort: channelStateSort ?? sort, sort: channelStateSort,
state: state, state: state,
watch: watch, watch: watch,
presence: presence, presence: presence,
@@ -731,17 +726,11 @@ class StreamChatClient {
/// Requests channels with a given query from the Persistence client. /// Requests channels with a given query from the Persistence client.
Future<List<Channel>> queryChannelsOffline({ Future<List<Channel>> queryChannelsOffline({
Filter? filter, Filter? filter,
@Deprecated('''
sort has been deprecated.
Please use channelStateSort instead.''')
List<SortOption<ChannelModel>>? sort,
List<SortOption<ChannelState>>? channelStateSort, List<SortOption<ChannelState>>? channelStateSort,
PaginationParams paginationParams = const PaginationParams(), PaginationParams paginationParams = const PaginationParams(),
}) async { }) async {
final offlineChannels = (await chatPersistenceClient?.getChannelStates( final offlineChannels = (await chatPersistenceClient?.getChannelStates(
filter: filter, filter: filter,
// ignore: deprecated_member_use_from_same_package
sort: sort,
channelStateSort: channelStateSort, channelStateSort: channelStateSort,
paginationParams: paginationParams, paginationParams: paginationParams,
)) ?? )) ??
@@ -13,7 +13,6 @@ class RetryPolicy {
/// Instantiate a new RetryPolicy /// Instantiate a new RetryPolicy
RetryPolicy({ RetryPolicy({
required this.shouldRetry, required this.shouldRetry,
@Deprecated("Use 'delayFactor' instead.") this.retryTimeout,
this.maxRetryAttempts = 6, this.maxRetryAttempts = 6,
this.delayFactor = const Duration(milliseconds: 200), this.delayFactor = const Duration(milliseconds: 200),
this.randomizationFactor = 0.25, this.randomizationFactor = 0.25,
@@ -53,13 +52,4 @@ class RetryPolicy {
int attempt, int attempt,
StreamChatError? error, StreamChatError? error,
) shouldRetry; ) 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;
} }
@@ -1,5 +1,3 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
@@ -88,11 +88,6 @@ class StreamChatNetworkError extends StreamChatError {
this.isRequestCancelledError = false, this.isRequestCancelledError = false,
}) : super(message); }) : super(message);
///
@Deprecated('Use `StreamChatNetworkError.fromDioException` instead')
factory StreamChatNetworkError.fromDioError(DioException error) =
StreamChatNetworkError.fromDioException;
/// ///
factory StreamChatNetworkError.fromDioException(DioException exception) { factory StreamChatNetworkError.fromDioException(DioException exception) {
final response = exception.response; final response = exception.response;
@@ -10,13 +10,25 @@ import 'package:uuid/uuid.dart';
part 'attachment.g.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 /// The class that contains the information about an attachment
@JsonSerializable(includeIfNull: false) @JsonSerializable(includeIfNull: false)
class Attachment extends Equatable { class Attachment extends Equatable {
/// Constructor used for json serialization /// Constructor used for json serialization
Attachment({ Attachment({
String? id, String? id,
this.type, String? type,
this.titleLink, this.titleLink,
String? title, String? title,
this.thumbUrl, this.thumbUrl,
@@ -33,26 +45,24 @@ class Attachment extends Equatable {
this.authorLink, this.authorLink,
this.authorIcon, this.authorIcon,
this.assetUrl, this.assetUrl,
List<Action>? actions, this.actions = const [],
this.originalWidth,
this.originalHeight,
Map<String, Object?> extraData = const {}, Map<String, Object?> extraData = const {},
this.file, this.file,
UploadState? uploadState, UploadState? uploadState,
}) : id = id ?? const Uuid().v4(), }) : id = id ?? const Uuid().v4(),
_type = type,
title = title ?? file?.name, title = title ?? file?.name,
_uploadState = uploadState,
localUri = file?.path != null ? Uri.parse(file!.path!) : null, localUri = file?.path != null ? Uri.parse(file!.path!) : null,
actions = actions ?? [],
// For backwards compatibility, // For backwards compatibility,
// set 'file_size', 'mime_type' in [extraData]. // set 'file_size', 'mime_type' in [extraData].
extraData = { extraData = {
...extraData, ...extraData,
if (file?.size != null) 'file_size': file?.size, if (file?.size != null) 'file_size': file?.size,
if (file?.mimeType != null) 'mime_type': file?.mimeType?.mimeType, if (file?.mediaType != null) 'mime_type': file?.mediaType?.mimeType,
} { };
this.uploadState = uploadState ??
((assetUrl != null || imageUrl != null || thumbUrl != null)
? const UploadState.success()
: const UploadState.preparing());
}
/// Create a new instance from a json /// Create a new instance from a json
factory Attachment.fromJson(Map<String, dynamic> json) => factory Attachment.fromJson(Map<String, dynamic> json) =>
@@ -69,7 +79,8 @@ class Attachment extends Equatable {
factory Attachment.fromOGAttachment(OGAttachmentResponse ogAttachment) => factory Attachment.fromOGAttachment(OGAttachmentResponse ogAttachment) =>
Attachment( Attachment(
type: ogAttachment.type, // If the type is not specified, we default to urlPreview.
type: ogAttachment.type ?? AttachmentType.urlPreview,
title: ogAttachment.title, title: ogAttachment.title,
titleLink: ogAttachment.titleLink, titleLink: ogAttachment.titleLink,
text: ogAttachment.text, text: ogAttachment.text,
@@ -84,7 +95,20 @@ class Attachment extends Equatable {
///The attachment type based on the URL resource. This can be: audio, ///The attachment type based on the URL resource. This can be: audio,
///image or video ///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. ///The link to which the attachment message points to.
final String? titleLink; final String? titleLink;
@@ -126,13 +150,27 @@ class Attachment extends Equatable {
/// Actions from a command /// Actions from a command
final List<Action>? actions; final List<Action>? actions;
/// The original width of the attached image.
final int? originalWidth;
/// The original height of the attached image.
final int? originalHeight;
final Uri? localUri; final Uri? localUri;
/// The file present inside this attachment. /// The file present inside this attachment.
final AttachmentFile? file; final AttachmentFile? file;
/// The current upload state of the attachment /// 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 /// Map of custom channel extraData
final Map<String, Object?> extraData; final Map<String, Object?> extraData;
@@ -175,6 +213,8 @@ class Attachment extends Equatable {
'author_icon', 'author_icon',
'asset_url', 'asset_url',
'actions', 'actions',
'original_width',
'original_height',
]; ];
/// Known db specific top level fields. /// Known db specific top level fields.
@@ -214,6 +254,8 @@ class Attachment extends Equatable {
String? authorIcon, String? authorIcon,
String? assetUrl, String? assetUrl,
List<Action>? actions, List<Action>? actions,
int? originalWidth,
int? originalHeight,
AttachmentFile? file, AttachmentFile? file,
UploadState? uploadState, UploadState? uploadState,
Map<String, Object?>? extraData, Map<String, Object?>? extraData,
@@ -238,6 +280,8 @@ class Attachment extends Equatable {
authorIcon: authorIcon ?? this.authorIcon, authorIcon: authorIcon ?? this.authorIcon,
assetUrl: assetUrl ?? this.assetUrl, assetUrl: assetUrl ?? this.assetUrl,
actions: actions ?? this.actions, actions: actions ?? this.actions,
originalWidth: originalWidth ?? this.originalWidth,
originalHeight: originalHeight ?? this.originalHeight,
file: file ?? this.file, file: file ?? this.file,
uploadState: uploadState ?? this.uploadState, uploadState: uploadState ?? this.uploadState,
extraData: extraData ?? this.extraData, extraData: extraData ?? this.extraData,
@@ -264,6 +308,8 @@ class Attachment extends Equatable {
authorIcon: other.authorIcon, authorIcon: other.authorIcon,
assetUrl: other.assetUrl, assetUrl: other.assetUrl,
actions: other.actions, actions: other.actions,
originalWidth: other.originalWidth,
originalHeight: other.originalHeight,
file: other.file, file: other.file,
uploadState: other.uploadState, uploadState: other.uploadState,
extraData: other.extraData, extraData: other.extraData,
@@ -291,6 +337,8 @@ class Attachment extends Equatable {
authorIcon, authorIcon,
assetUrl, assetUrl,
actions, actions,
originalWidth,
originalHeight,
file, file,
uploadState, uploadState,
extraData, extraData,
@@ -26,8 +26,11 @@ Attachment _$AttachmentFromJson(Map<String, dynamic> json) => Attachment(
authorIcon: json['author_icon'] as String?, authorIcon: json['author_icon'] as String?,
assetUrl: json['asset_url'] as String?, assetUrl: json['asset_url'] as String?,
actions: (json['actions'] as List<dynamic>?) actions: (json['actions'] as List<dynamic>?)
?.map((e) => Action.fromJson(e as Map<String, dynamic>)) ?.map((e) => Action.fromJson(e as Map<String, dynamic>))
.toList(), .toList() ??
const [],
originalWidth: json['original_width'] as int?,
originalHeight: json['original_height'] as int?,
extraData: json['extra_data'] as Map<String, dynamic>? ?? const {}, extraData: json['extra_data'] as Map<String, dynamic>? ?? const {},
file: json['file'] == null file: json['file'] == null
? null ? null
@@ -64,6 +67,8 @@ Map<String, dynamic> _$AttachmentToJson(Attachment instance) {
writeNotNull('author_icon', instance.authorIcon); writeNotNull('author_icon', instance.authorIcon);
writeNotNull('asset_url', instance.assetUrl); writeNotNull('asset_url', instance.assetUrl);
writeNotNull('actions', instance.actions?.map((e) => e.toJson()).toList()); writeNotNull('actions', instance.actions?.map((e) => e.toJson()).toList());
writeNotNull('original_width', instance.originalWidth);
writeNotNull('original_height', instance.originalHeight);
writeNotNull('file', instance.file?.toJson()); writeNotNull('file', instance.file?.toJson());
val['upload_state'] = instance.uploadState.toJson(); val['upload_state'] = instance.uploadState.toJson();
val['extra_data'] = instance.extraData; val['extra_data'] = instance.extraData;
@@ -62,7 +62,7 @@ class AttachmentFile {
String? get extension => name?.split('.').last; String? get extension => name?.split('.').last;
/// The mime type of this file. /// The mime type of this file.
MediaType? get mimeType => name?.mimeType; MediaType? get mediaType => name?.mediaType;
/// Serialize to json /// Serialize to json
Map<String, dynamic> toJson() => _$AttachmentFileToJson(this); Map<String, dynamic> toJson() => _$AttachmentFileToJson(this);
@@ -74,14 +74,14 @@ class AttachmentFile {
if (CurrentPlatform.isWeb) { if (CurrentPlatform.isWeb) {
multiPartFile = MultipartFile.fromBytes( multiPartFile = MultipartFile.fromBytes(
bytes!, bytes!,
filename: name ?? 'file', filename: name,
contentType: mimeType, contentType: mediaType,
); );
} else { } else {
multiPartFile = await MultipartFile.fromFile( multiPartFile = await MultipartFile.fromFile(
path!, path!,
filename: name ?? 'file', filename: name,
contentType: mimeType, contentType: mediaType,
); );
} }
return multiPartFile; return multiPartFile;
@@ -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<String, Object?>?;
if (giphy == null) return null;
final info = giphy[type.value] as Map<String, Object?>?;
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),
);
}
}
@@ -1,5 +1,3 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import 'package:stream_chat/src/core/models/user.dart'; import 'package:stream_chat/src/core/models/user.dart';
@@ -15,70 +15,6 @@ class _NullConst {
const _nullConst = _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. /// The class that contains the information about a message.
@JsonSerializable() @JsonSerializable()
class Message extends Equatable { class Message extends Equatable {
@@ -114,23 +50,14 @@ class Message extends Equatable {
DateTime? pinExpires, DateTime? pinExpires,
this.pinnedBy, this.pinnedBy,
this.extraData = const {}, this.extraData = const {},
@Deprecated('Use `state` instead') MessageSendingStatus? status, this.state = const MessageState.initial(),
MessageState? state,
this.i18n, this.i18n,
}) : id = id ?? const Uuid().v4(), }) : id = id ?? const Uuid().v4(),
pinExpires = pinExpires?.toUtc(), pinExpires = pinExpires?.toUtc(),
remoteCreatedAt = createdAt, remoteCreatedAt = createdAt,
remoteUpdatedAt = updatedAt, remoteUpdatedAt = updatedAt,
remoteDeletedAt = deletedAt, remoteDeletedAt = deletedAt,
_quotedMessageId = quotedMessageId { _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;
}
/// Create a new instance from JSON. /// Create a new instance from JSON.
factory Message.fromJson(Map<String, dynamic> json) { factory Message.fromJson(Map<String, dynamic> json) {
@@ -155,17 +82,9 @@ class Message extends Equatable {
/// The text of this message. /// The text of this message.
final String? text; 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. /// The current state of the message.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
late final MessageState state; final MessageState state;
/// The message type. /// The message type.
@JsonKey(includeIfNull: false, toJson: _typeToJson) @JsonKey(includeIfNull: false, toJson: _typeToJson)
@@ -304,6 +223,9 @@ class Message extends Equatable {
/// Message custom extraData. /// Message custom extraData.
final Map<String, Object?> extraData; final Map<String, Object?> extraData;
/// True if the message is a error.
bool get isError => type == 'error';
/// True if the message is a system info. /// True if the message is a system info.
bool get isSystem => type == 'system'; bool get isSystem => type == 'system';
@@ -388,7 +310,6 @@ class Message extends Equatable {
Object? pinExpires = _nullConst, Object? pinExpires = _nullConst,
User? pinnedBy, User? pinnedBy,
Map<String, Object?>? extraData, Map<String, Object?>? extraData,
@Deprecated('Use `state` instead') MessageSendingStatus? status,
MessageState? state, MessageState? state,
Map<String, String>? i18n, Map<String, String>? i18n,
}) { }) {
@@ -423,8 +344,6 @@ class Message extends Equatable {
return true; return true;
}(), 'Validate type for quotedMessage'); }(), 'Validate type for quotedMessage');
final messageState = state ?? status?.toMessageState();
return Message( return Message(
id: id ?? this.id, id: id ?? this.id,
text: text ?? this.text, text: text ?? this.text,
@@ -461,7 +380,7 @@ class Message extends Equatable {
pinExpires == _nullConst ? this.pinExpires : pinExpires as DateTime?, pinExpires == _nullConst ? this.pinExpires : pinExpires as DateTime?,
pinnedBy: pinnedBy ?? this.pinnedBy, pinnedBy: pinnedBy ?? this.pinnedBy,
extraData: extraData ?? this.extraData, extraData: extraData ?? this.extraData,
state: messageState ?? this.state, state: state ?? this.state,
i18n: i18n ?? this.i18n, i18n: i18n ?? this.i18n,
); );
} }
@@ -20,8 +20,8 @@ extension MapX<K, V> on Map<K?, V?> {
/// Useful extension functions for [String] /// Useful extension functions for [String]
extension StringX on String { extension StringX on String {
/// returns the mime type from the passed file name. /// returns the media type from the passed file name.
MediaType? get mimeType { MediaType? get mediaType {
if (toLowerCase().endsWith('heic')) { if (toLowerCase().endsWith('heic')) {
return MediaType.parse('image/heic'); return MediaType.parse('image/heic');
} else { } else {
@@ -102,8 +102,6 @@ abstract class ChatPersistenceClient {
/// for filtering out states. /// for filtering out states.
Future<List<ChannelState>> getChannelStates({ Future<List<ChannelState>> getChannelStates({
Filter? filter, Filter? filter,
@Deprecated('Use channelStateSort instead.')
List<SortOption<ChannelModel>>? sort,
List<SortOption<ChannelState>>? channelStateSort, List<SortOption<ChannelState>>? channelStateSort,
PaginationParams? paginationParams, PaginationParams? paginationParams,
}); });
@@ -28,6 +28,7 @@ export 'src/core/http/interceptor/logging_interceptor.dart';
export 'src/core/models/action.dart'; export 'src/core/models/action.dart';
export 'src/core/models/attachment.dart'; export 'src/core/models/attachment.dart';
export 'src/core/models/attachment_file.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_config.dart';
export 'src/core/models/channel_model.dart'; export 'src/core/models/channel_model.dart';
export 'src/core/models/channel_mute.dart'; export 'src/core/models/channel_mute.dart';
+1 -1
View File
@@ -3,4 +3,4 @@ import 'package:stream_chat/src/client/client.dart';
/// Current package version /// Current package version
/// Used in [StreamChatClient] to build the `x-stream-client` header /// Used in [StreamChatClient] to build the `x-stream-client` header
// ignore: constant_identifier_names // ignore: constant_identifier_names
const PACKAGE_VERSION = '6.10.0'; const PACKAGE_VERSION = '7.0.0';
+3 -3
View File
@@ -1,16 +1,16 @@
name: stream_chat name: stream_chat
homepage: https://getstream.io/ homepage: https://getstream.io/
description: The official Dart client for Stream Chat, a service for building chat applications. 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 repository: https://github.com/GetStream/stream-chat-flutter
issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.1.0 <4.0.0'
dependencies: dependencies:
async: ^2.11.0 async: ^2.11.0
collection: ^1.17.1 collection: ^1.17.2
dio: ^5.3.2 dio: ^5.3.2
equatable: ^2.0.5 equatable: ^2.0.5
freezed_annotation: ^2.4.1 freezed_annotation: ^2.4.1
+1 -1
View File
@@ -5,7 +5,7 @@
"silent": false, "silent": false,
"attachments": [ "attachments": [
{ {
"type": "video", "type": "giphy",
"title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", "title_link": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif",
"title": "The Lion King Disney GIF - Find & Share on GIPHY", "title": "The Lion King Disney GIF - Find & Share on GIPHY",
"thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif", "thumb_url": "https://media.giphy.com/media/5zvN79uTGfLMOVfQaA/giphy.gif",
@@ -805,7 +805,7 @@ void main() {
emits(ConnectionStatus.disconnected), emits(ConnectionStatus.disconnected),
); );
await client.disconnectUser(); await client.disconnectUser(flushChatPersistence: true);
expect(client.state.currentUser, isNull); expect(client.state.currentUser, isNull);
expect(client.wsConnectionStatus, ConnectionStatus.disconnected); expect(client.wsConnectionStatus, ConnectionStatus.disconnected);
@@ -27,7 +27,7 @@ void main() {
test('should serialize to json correctly', () { test('should serialize to json correctly', () {
final channel = Attachment( final channel = Attachment(
type: 'image', type: 'giphy',
title: 'soo', title: 'soo',
titleLink: titleLink:
'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti',
@@ -36,7 +36,7 @@ void main() {
expect( expect(
channel.toJson(), channel.toJson(),
{ {
'type': 'image', 'type': 'giphy',
'title': 'soo', 'title': 'soo',
'title_link': 'title_link':
'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti', 'https://giphy.com/gifs/nrkp3-dance-happy-3o7TKnCdBx5cMg0qti',
@@ -38,7 +38,7 @@ void main() {
'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA', 'https://giphy.com/gifs/the-lion-king-live-action-5zvN79uTGfLMOVfQaA',
attachments: [ attachments: [
Attachment.fromJson(const { Attachment.fromJson(const {
'type': 'video', 'type': 'giphy',
'author_name': 'GIPHY', 'author_name': 'GIPHY',
'title': 'The Lion King Disney GIF - Find \u0026 Share on GIPHY', 'title': 'The Lion King Disney GIF - Find \u0026 Share on GIPHY',
'title_link': 'title_link':
@@ -25,13 +25,13 @@ void main() {
group('mimeType', () { group('mimeType', () {
test('should return null if `String` is not a filename', () { test('should return null if `String` is not a filename', () {
const fileName = 'not-a-file-name'; const fileName = 'not-a-file-name';
final mimeType = fileName.mimeType; final mimeType = fileName.mediaType;
expect(mimeType, isNull); expect(mimeType, isNull);
}); });
test('should return mimeType if string is a filename', () { test('should return mimeType if string is a filename', () {
const fileName = 'dummyFileName.jpeg'; const fileName = 'dummyFileName.jpeg';
final mimeType = fileName.mimeType; final mimeType = fileName.mediaType;
expect(mimeType, isNotNull); expect(mimeType, isNotNull);
expect(mimeType!.type, 'image'); expect(mimeType!.type, 'image');
expect(mimeType.subtype, 'jpeg'); expect(mimeType.subtype, 'jpeg');
@@ -39,7 +39,7 @@ void main() {
test('should return `image/heic` if ends with `heic`', () { test('should return `image/heic` if ends with `heic`', () {
const fileName = 'dummyFileName.heic'; const fileName = 'dummyFileName.heic';
final mimeType = fileName.mimeType; final mimeType = fileName.mediaType;
expect(mimeType, isNotNull); expect(mimeType, isNotNull);
expect(mimeType!.type, 'image'); expect(mimeType!.type, 'image');
expect(mimeType.subtype, 'heic'); expect(mimeType.subtype, 'heic');
@@ -62,8 +62,6 @@ class TestPersistenceClient extends ChatPersistenceClient {
@override @override
Future<List<ChannelState>> getChannelStates( Future<List<ChannelState>> getChannelStates(
{Filter? filter, {Filter? filter,
@Deprecated('Use channelStateSort instead.')
List<SortOption<ChannelModel>>? sort,
List<SortOption<ChannelState>>? channelStateSort, List<SortOption<ChannelState>>? channelStateSort,
PaginationParams? paginationParams}) => PaginationParams? paginationParams}) =>
throw UnimplementedError(); throw UnimplementedError();
+64
View File
@@ -24,6 +24,13 @@
- Added support for overriding the `MessageWidget.onReactionsHover` callback. - Added support for overriding the `MessageWidget.onReactionsHover` callback.
> **Note** > **Note**
> Used only in desktop devices (web and desktop). > Used only in desktop devices (web and desktop).
## 7.0.0-beta.4
- Included the changes from version [6.9.0](#681).
- Updated minimum supported `SDK` version to Flutter 3.13/Dart 3.1
- Updated `stream_chat_flutter_core` dependency
to [`7.0.0-beta.3`](https://pub.dev/packages/stream_chat_flutter_core/changelog).
## 6.9.0 ## 6.9.0
@@ -61,12 +68,21 @@
to [`6.8.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). to [`6.8.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog).
- Updated jiffy dependency to ^6.2.1. - Updated jiffy dependency to ^6.2.1.
## 7.0.0-beta.3
- Included the changes from version [6.8.1](#681).
## 6.8.1 ## 6.8.1
🐞 Fixed 🐞 Fixed
- Fixed `StreamMessageInput` always sending message as `system`. - Fixed `StreamMessageInput` always sending message as `system`.
## 7.0.0-beta.2
- Updated `stream_chat_flutter_core` dependency
to [`7.0.0-beta.2`](https://pub.dev/packages/stream_chat_flutter_core/changelog).
## 6.8.0 ## 6.8.0
🔄 Changed 🔄 Changed
@@ -74,6 +90,54 @@
- Updated `stream_chat_flutter_core` dependency - Updated `stream_chat_flutter_core` dependency
to [`6.7.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). to [`6.7.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog).
## 7.0.0-beta.1
🛑️ 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.
✅ 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 minimum supported `SDK` version to Flutter 3.10/Dart 3.0
- Updated `jiffy` dependency to `^6.2.1`.
## 6.7.0 ## 6.7.0
🔄 Changed 🔄 Changed
@@ -4,8 +4,8 @@ publish_to: 'none'
version: 1.0.0+1 version: 1.0.0+1
environment: environment:
sdk: ">=3.0.0 <4.0.0" sdk: ">=3.1.0 <4.0.0"
flutter: ">=3.10.0" flutter: ">=3.13.0"
dependencies: dependencies:
collection: ^1.15.0 collection: ^1.15.0
@@ -1,7 +1,7 @@
export 'attachment_error.dart';
export 'attachment_upload_state_builder.dart'; export 'attachment_upload_state_builder.dart';
export 'attachment_widget.dart' show AttachmentSource;
export 'file_attachment.dart'; export 'file_attachment.dart';
export 'gallery_attachment.dart';
export 'giphy_attachment.dart'; export 'giphy_attachment.dart';
export 'image_attachment.dart'; export 'image_attachment.dart';
export 'url_attachment.dart';
export 'video_attachment.dart'; export 'video_attachment.dart';
@@ -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,
),
),
),
);
}
}
@@ -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: <Widget>[
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),
],
),
),
);
}
}
@@ -198,6 +198,7 @@ class _FailedState extends StatelessWidget {
children: [ children: [
_IconButton( _IconButton(
icon: StreamSvgIcon.retry( icon: StreamSvgIcon.retry(
size: 14,
color: theme.colorTheme.barsBg, color: theme.colorTheme.barsBg,
), ),
onPressed: () { onPressed: () {
@@ -217,6 +218,7 @@ class _FailedState extends StatelessWidget {
), ),
child: Text( child: Text(
context.translations.uploadErrorLabel, context.translations.uploadErrorLabel,
textAlign: TextAlign.center,
style: theme.textTheme.footnote.copyWith( style: theme.textTheme.footnote.copyWith(
color: theme.colorTheme.barsBg, color: theme.colorTheme.barsBg,
), ),
@@ -1,57 +0,0 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Enum for identifying type of attachment
enum AttachmentSource {
/// Attachment is attached
local,
/// Attachment is uploaded
network;
/// The [when] method is the equivalent to pattern matching.
/// Its prototype depends on the AttachmentSource defined.
T when<T>({
required T Function() local,
required T Function() network,
}) {
switch (this) {
case AttachmentSource.local:
return local();
case AttachmentSource.network:
return network();
}
}
}
/// {@template streamAttachmentWidget}
/// Abstract class for deriving attachment types
/// {@endtemplate}
abstract class StreamAttachmentWidget extends StatelessWidget {
/// {@macro streamAttachmentWidget}
const StreamAttachmentWidget({
super.key,
required this.message,
required this.attachment,
this.constraints,
AttachmentSource? source,
}) : _source = source;
/// Contraints of attachments
final BoxConstraints? constraints;
final AttachmentSource? _source;
/// The message that [attachment] is associated with
final Message message;
/// The [Attachment] to display
final Attachment attachment;
/// Getter for source of attachment
AttachmentSource get source =>
_source ??
(attachment.file != null
? AttachmentSource.local
: AttachmentSource.network);
}
@@ -0,0 +1,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<StreamAttachmentWidgetBuilder> builders;
/// Builds a widget for the given [message] and [attachments].
///
/// It iterates through the list of builders and uses the first builder
/// that can handle the message and attachments.
///
/// Throws an [Exception] if no builder is found for the message.
Widget build(BuildContext context, Message message) {
assert(!message.isDeleted, 'Cannot build attachment for deleted message');
assert(
message.attachments.isNotEmpty,
'Cannot build attachment for message without attachments',
);
// The list of attachments to build the widget for.
final attachments = message.attachments.grouped;
for (final builder in builders) {
if (builder.canHandle(message, attachments)) {
return builder.build(context, message, attachments);
}
}
throw Exception('No builder found for $message and $attachments');
}
}
extension on List<Attachment> {
/// Groups the attachments by their type.
Map<String, List<Attachment>> get grouped {
return groupBy(where((it) {
return it.type != null;
}), (attachment) => attachment.type!);
}
}
@@ -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<StreamAttachmentWidgetBuilder> 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<String, List<Attachment>> attachments);
/// Builds a widget for the given [message] and [attachments].
/// This will only be called if [canHandle] returns `true`.
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
);
/// Asserts that this builder can handle the given [message] and
/// [attachments].
///
/// This is used to ensure that the [defaultBuilders] are used correctly.
///
/// **Note**: This method is only called in debug mode.
bool debugAssertCanHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(() {
if (!canHandle(message, attachments)) {
throw FlutterError.fromParts(<DiagnosticsNode>[
ErrorSummary(
'A $runtimeType was used to build a attachment for a message, but '
'it cant handle the message.',
),
ErrorDescription(
'The builders in the list must be checked in order. Check the '
'documentation for $runtimeType for more details.',
),
]);
}
return true;
}(), '');
return true;
}
}
@@ -0,0 +1,33 @@
part of 'attachment_widget_builder.dart';
/// {@template fallbackAttachmentBuilder}
/// A widget builder for when no other builder can handle the attachments.
///
/// Saves you from getting an error when you have an attachment type that is not
/// supported by the SDK.
/// {@endtemplate}
class FallbackAttachmentBuilder extends StreamAttachmentWidgetBuilder {
/// {@macro fallbackAttachmentBuilder}
const FallbackAttachmentBuilder();
@override
bool canHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
// Always returns True because this builder will be used as a fallback when
// no other builder can handle the attachments.
return true;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
// Returns an empty widget because this builder will be used as a fallback
// when no other builder can handle the attachments.
return const SizedBox.shrink();
}
}
@@ -0,0 +1,87 @@
part of 'attachment_widget_builder.dart';
/// {@template fileAttachmentBuilder}
/// A widget builder for [AttachmentType.file] attachment type.
/// {@endtemplate}
class FileAttachmentBuilder extends StreamAttachmentWidgetBuilder {
/// {@macro fileAttachmentBuilder}
const FileAttachmentBuilder({
this.shape,
this.backgroundColor,
this.constraints = const BoxConstraints(),
this.padding = const EdgeInsets.all(4),
this.onAttachmentTap,
});
/// The shape of the file attachment.
final ShapeBorder? shape;
/// The background color of the file attachment.
final Color? backgroundColor;
/// The constraints to apply to the file attachment widget.
final BoxConstraints constraints;
/// The padding to apply to the file attachment widget.
final EdgeInsetsGeometry padding;
/// The callback to call when the attachment is tapped.
final StreamAttachmentWidgetTapCallback? onAttachmentTap;
@override
bool canHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
final files = attachments[AttachmentType.file];
return files != null && files.isNotEmpty;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(debugAssertCanHandle(message, attachments), '');
final files = attachments[AttachmentType.file]!;
Widget _buildFileAttachment(Attachment file) {
VoidCallback? onTap;
if (onAttachmentTap != null) {
onTap = () => onAttachmentTap!(message, file);
}
return InkWell(
onTap: onTap,
child: StreamFileAttachment(
file: file,
message: message,
shape: shape,
constraints: constraints,
backgroundColor: backgroundColor,
),
);
}
Widget child;
if (files.length == 1) {
child = _buildFileAttachment(files.first);
} else {
child = Column(
children: <Widget>[
for (final file in files) _buildFileAttachment(file),
].insertBetween(
// Add a small vertical padding between each attachment.
SizedBox(height: padding.vertical / 2),
),
);
}
return Padding(
padding: padding,
child: child,
);
}
}
@@ -0,0 +1,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<String, List<Attachment>> attachments,
) {
final images = attachments[AttachmentType.image];
if (images != null && images.length > 1) return true;
final videos = attachments[AttachmentType.video];
if (videos != null && videos.length > 1) return true;
final giphys = attachments[AttachmentType.giphy];
if (giphys != null && giphys.length > 1) return true;
if (images != null && videos != null) return true;
if (images != null && giphys != null) return true;
if (videos != null && giphys != null) return true;
return false;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(debugAssertCanHandle(message, attachments), '');
final galleryAttachments = [...attachments.values.expand((it) => it)];
return Padding(
padding: padding,
child: StreamGalleryAttachment(
shape: shape,
message: message,
spacing: spacing,
runSpacing: runSpacing,
constraints: constraints,
attachments: galleryAttachments,
itemBuilder: (context, index) {
final attachment = galleryAttachments[index];
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,
),
),
],
),
);
},
),
);
}
}
@@ -0,0 +1,73 @@
part of 'attachment_widget_builder.dart';
const _kDefaultGiphyConstraints = BoxConstraints(
minWidth: 170,
maxWidth: 256,
minHeight: 100,
maxHeight: 300,
);
/// {@template giphyAttachmentBuilder}
/// A widget builder for [AttachmentType.giphy] attachment type.
///
/// This builder is used when a message contains only a single giphy attachment.
/// {@endtemplate}
class GiphyAttachmentBuilder extends StreamAttachmentWidgetBuilder {
/// {@macro giphyAttachmentBuilder}
const GiphyAttachmentBuilder({
this.shape,
this.padding = const EdgeInsets.all(2),
this.constraints = _kDefaultGiphyConstraints,
this.onAttachmentTap,
});
/// The shape of the giphy attachment.
final ShapeBorder? shape;
/// The constraints to apply to the giphy attachment widget.
final BoxConstraints constraints;
/// The padding to apply to the giphy attachment widget.
final EdgeInsetsGeometry padding;
/// The callback to call when the attachment is tapped.
final StreamAttachmentWidgetTapCallback? onAttachmentTap;
@override
bool canHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
final giphyAttachments = attachments[AttachmentType.giphy];
return giphyAttachments != null && giphyAttachments.length == 1;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(debugAssertCanHandle(message, attachments), '');
final giphy = attachments[AttachmentType.giphy]!.first;
VoidCallback? onTap;
if (onAttachmentTap != null) {
onTap = () => onAttachmentTap!(message, giphy);
}
return Padding(
padding: padding,
child: InkWell(
onTap: onTap,
child: StreamGiphyAttachment(
message: message,
constraints: constraints,
giphy: giphy,
shape: shape,
),
),
);
}
}
@@ -0,0 +1,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<String, List<Attachment>> attachments,
) {
final images = attachments[AttachmentType.image];
return images != null && images.length == 1;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(debugAssertCanHandle(message, attachments), '');
final image = attachments[AttachmentType.image]!.first;
VoidCallback? onTap;
if (onAttachmentTap != null) {
onTap = () => onAttachmentTap!(message, image);
}
return Padding(
padding: padding,
child: InkWell(
onTap: onTap,
child: StreamImageAttachment(
shape: shape,
message: message,
constraints: constraints,
image: image,
),
),
);
}
}
@@ -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<String, List<Attachment>> 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<String, List<Attachment>> 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: <Widget>[
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),
),
),
);
}
}
@@ -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<String, List<Attachment>> attachments,
) {
final urls = attachments[AttachmentType.urlPreview];
return urls != null && urls.isNotEmpty;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(debugAssertCanHandle(message, attachments), '');
final urlPreviews = attachments[AttachmentType.urlPreview]!;
final client = StreamChat.of(context).client;
final isMyMessage = message.user?.id == client.state.currentUser?.id;
final streamChatTheme = StreamChatTheme.of(context);
final messageTheme = isMyMessage
? streamChatTheme.ownMessageTheme
: streamChatTheme.otherMessageTheme;
Widget _buildUrlPreview(Attachment urlPreview) {
VoidCallback? onTap;
if (onAttachmentTap != null) {
onTap = () => onAttachmentTap!(message, urlPreview);
}
final host = Uri.parse(urlPreview.titleLink!).host;
final splitList = host.split('.');
final hostName = splitList.length == 3 ? splitList[1] : splitList[0];
final hostDisplayName = urlPreview.authorName?.capitalize() ??
getWebsiteName(hostName.toLowerCase()) ??
hostName.capitalize();
return InkWell(
onTap: onTap,
child: StreamUrlAttachment(
message: message,
urlAttachment: urlPreview,
hostDisplayName: hostDisplayName,
messageTheme: messageTheme,
constraints: constraints,
shape: shape,
),
);
}
Widget child;
if (urlPreviews.length == 1) {
child = _buildUrlPreview(urlPreviews.first);
} else {
child = Column(
children: <Widget>[
for (final urlPreview in urlPreviews) _buildUrlPreview(urlPreview),
].insertBetween(
// Add a small vertical padding between each attachment.
SizedBox(height: padding.vertical / 2),
),
);
}
return Padding(
padding: padding,
child: child,
);
}
}
@@ -0,0 +1,72 @@
part of 'attachment_widget_builder.dart';
const _kDefaultVideoConstraints = BoxConstraints.tightFor(
width: 256,
height: 195,
);
/// {@template videoAttachmentBuilder}
/// A widget builder for [AttachmentType.video] attachment type.
///
/// This builder is used when a message contains only a single video attachment.
/// {@endtemplate}
class VideoAttachmentBuilder extends StreamAttachmentWidgetBuilder {
/// {@macro videoAttachmentBuilder}
const VideoAttachmentBuilder({
this.shape,
this.padding = const EdgeInsets.all(2),
this.constraints = _kDefaultVideoConstraints,
this.onAttachmentTap,
});
/// The shape of the video attachment.
final ShapeBorder? shape;
/// The constraints to apply to the video attachment widget.
final BoxConstraints constraints;
/// The padding to apply to the video attachment widget.
final EdgeInsetsGeometry padding;
/// The callback to call when the attachment is tapped.
final StreamAttachmentWidgetTapCallback? onAttachmentTap;
@override
bool canHandle(
Message message,
Map<String, List<Attachment>> attachments,
) {
final videos = attachments[AttachmentType.video];
if (videos != null && videos.length == 1) return true;
return false;
}
@override
Widget build(
BuildContext context,
Message message,
Map<String, List<Attachment>> attachments,
) {
assert(debugAssertCanHandle(message, attachments), '');
final video = attachments[AttachmentType.video]!.first;
VoidCallback? onTap;
if (onAttachmentTap != null) {
onTap = () => onAttachmentTap!(message, video);
}
return Padding(
padding: padding,
child: InkWell(
onTap: onTap,
child: StreamVideoAttachment(
message: message,
constraints: constraints,
video: video,
),
),
);
}
}
@@ -1,13 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package: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/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/indicators/upload_progress_indicator.dart';
import 'package:stream_chat_flutter/src/misc/stream_svg_icon.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/theme/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/utils/utils.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'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// {@template streamFileAttachment} /// {@template streamFileAttachment}
@@ -15,209 +12,145 @@ import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// ///
/// Used in [MessageWidget]. /// Used in [MessageWidget].
/// {@endtemplate} /// {@endtemplate}
class StreamFileAttachment extends StreamAttachmentWidget { class StreamFileAttachment extends StatelessWidget {
/// {@macro streamFileAttachment} /// {@macro streamFileAttachment}
const StreamFileAttachment({ const StreamFileAttachment({
super.key, super.key,
required super.message, required this.message,
required super.attachment, required this.file,
super.constraints,
this.title, this.title,
this.trailing, this.trailing,
this.onAttachmentTap, this.shape,
this.backgroundColor,
this.constraints = const BoxConstraints(),
}); });
/// Title for the attachment /// The [Message] that the file is attached to.
final Message message;
/// The [Attachment] object containing the file information.
final Attachment file;
/// The shape of the attachment.
///
/// Defaults to [RoundedRectangleBorder] with a radius of 12.
final ShapeBorder? shape;
/// The background color of the attachment.
///
/// Defaults to [StreamChatTheme.colorTheme.barsBg].
final Color? backgroundColor;
/// The constraints to use when displaying the file.
final BoxConstraints constraints;
/// Widget for displaying the title of the attachment.
/// (usually the file name)
final Widget? title; final Widget? title;
/// Widget for displaying at the end of the attachment /// Widget for displaying at the end of the attachment.
/// (such as a download button) /// (such as a download button)
final Widget? trailing; final Widget? trailing;
/// {@macro onAttachmentTap}
final OnAttachmentTap? onAttachmentTap;
/// Checks if the attachment is a video
bool get isVideoAttachment => attachment.title?.mimeType?.type == 'video';
/// Checks if the attachment is an image
bool get isImageAttachment => attachment.title?.mimeType?.type == 'image';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorTheme = StreamChatTheme.of(context).colorTheme; final chatTheme = StreamChatTheme.of(context);
return Material( final textTheme = chatTheme.textTheme;
child: GestureDetector( final colorTheme = chatTheme.colorTheme;
onTap: onAttachmentTap,
child: Container( final backgroundColor = this.backgroundColor ?? colorTheme.barsBg;
constraints: constraints ?? const BoxConstraints.tightFor(width: 100), final shape = this.shape ??
height: 56, RoundedRectangleBorder(
decoration: BoxDecoration( side: BorderSide(
color: colorTheme.barsBg, color: colorTheme.borders,
borderRadius: BorderRadius.circular(12), strokeAlign: BorderSide.strokeAlignOutside,
border: Border.all( ),
color: colorTheme.borders, borderRadius: BorderRadius.circular(12),
);
return Container(
constraints: constraints,
clipBehavior: Clip.hardEdge,
decoration: ShapeDecoration(
shape: shape,
color: backgroundColor,
),
child: Row(
children: [
Container(
width: 34,
height: 40,
margin: const EdgeInsets.all(8),
child: _FileTypeImage(file: file),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
file.title ?? context.translations.fileText,
maxLines: 1,
style: textTheme.bodyBold,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
_FileAttachmentSubtitle(attachment: file),
],
), ),
), ),
child: Row( const SizedBox(width: 8),
crossAxisAlignment: CrossAxisAlignment.start, Material(
children: [ type: MaterialType.transparency,
Container( child: trailing ??
height: 40, _Trailing(
width: 33.33, attachment: file,
margin: const EdgeInsets.all(8), message: message,
child: _FileTypeImage(
isImageAttachment: isImageAttachment,
isVideoAttachment: isVideoAttachment,
source: source,
attachment: attachment,
), ),
),
const SizedBox(width: 8),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
attachment.title ?? context.translations.fileText,
style: StreamChatTheme.of(context).textTheme.bodyBold,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
_FileAttachmentSubtitle(attachment: attachment),
],
),
),
const SizedBox(width: 8),
Material(
type: MaterialType.transparency,
child: trailing ??
_Trailing(
attachment: attachment,
message: message,
),
),
],
), ),
), ],
), ),
); );
} }
} }
class _FileTypeImage extends StatelessWidget { class _FileTypeImage extends StatelessWidget {
const _FileTypeImage({ const _FileTypeImage({required this.file});
required this.isImageAttachment,
required this.isVideoAttachment,
required this.source,
required this.attachment,
});
final bool isImageAttachment; final Attachment file;
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),
);
}
// TODO: Improve image memory. // TODO: Improve image memory.
// This is using the full image instead of a smaller version (thumbnail) // This is using the full image instead of a smaller version (thumbnail)
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (isImageAttachment) { Widget child = StreamFileAttachmentThumbnail(
return Material( file: file,
clipBehavior: Clip.hardEdge, width: double.infinity,
type: MaterialType.transparency, height: double.infinity,
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; final mediaType = file.title?.mediaType;
return Shimmer.fromColors( final isImage = mediaType?.type == AttachmentType.image;
baseColor: colorTheme.disabled, final isVideo = mediaType?.type == AttachmentType.video;
highlightColor: colorTheme.inputBg, if (isImage || isVideo) {
child: image, final colorTheme = StreamChatTheme.of(context).colorTheme;
); child = Container(
}, clipBehavior: Clip.hardEdge,
); decoration: ShapeDecoration(
}, shape: RoundedRectangleBorder(
side: BorderSide(
color: colorTheme.borders,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(8),
),
), ),
child: child,
); );
} }
if (isVideoAttachment) { return child;
return Material(
clipBehavior: Clip.hardEdge,
type: MaterialType.transparency,
shape: _getDefaultShape(context),
child: source.when(
local: () => StreamVideoThumbnailImage(
video: attachment.file!.path,
placeholderBuilder: (_) => const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(),
),
),
),
network: () => StreamVideoThumbnailImage(
video: attachment.assetUrl,
placeholderBuilder: (_) => const Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(),
),
),
),
),
);
}
return getFileTypeImage(attachment.extraData['mime_type'] as String?);
} }
} }
@@ -346,7 +279,6 @@ class _FileAttachmentSubtitle extends StatelessWidget {
uploaded: sent, uploaded: sent,
total: total, total: total,
showBackground: false, showBackground: false,
padding: EdgeInsets.zero,
textStyle: textStyle, textStyle: textStyle,
progressIndicatorColor: theme.colorTheme.accentPrimary, progressIndicatorColor: theme.colorTheme.accentPrimary,
), ),
@@ -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<Attachment> attachments;
/// The [Message] that the images are attached to
final Message message;
/// The shape of the attachment.
///
/// Defaults to [RoundedRectangleBorder] with a radius of 14.
final ShapeBorder? shape;
/// The constraints of the [attachments]
final BoxConstraints constraints;
/// How much space to place between children in a run in the main axis.
///
/// For example, if [spacing] is 10.0, the children will be spaced at least
/// 10.0 logical pixels apart in the main axis.
///
/// Defaults to 2.0.
final double spacing;
/// How much space to place between the runs themselves in the cross axis.
///
/// For example, if [runSpacing] is 10.0, the runs will be spaced at least
/// 10.0 logical pixels apart in the cross axis.
///
/// Defaults to 2.0.
final double runSpacing;
/// Item builder for the gallery.
final IndexedWidgetBuilder itemBuilder;
@override
Widget build(BuildContext context) {
assert(
attachments.length >= 2,
'Gallery should have at least 2 attachments, found ${attachments.length}',
);
final chatTheme = StreamChatTheme.of(context);
final colorTheme = chatTheme.colorTheme;
final shape = this.shape ??
RoundedRectangleBorder(
side: BorderSide(
color: colorTheme.borders,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(14),
);
return Container(
constraints: constraints,
clipBehavior: Clip.hardEdge,
decoration: ShapeDecoration(shape: shape),
// Added a builder just for the sake of calculating the image count
// and building the appropriate layout based on the image count.
child: Builder(
builder: (context) {
final attachmentCount = attachments.length;
if (attachmentCount == 2) {
return _buildForTwo(context, attachments);
}
if (attachmentCount == 3) {
return _buildForThree(context, attachments);
}
return _buildForFourOrMore(context, attachments);
},
),
);
}
Widget _buildForTwo(BuildContext context, List<Attachment> attachments) {
final aspectRatio1 = attachments[0].originalSize?.aspectRatio;
final aspectRatio2 = attachments[1].originalSize?.aspectRatio;
// check if one image is landscape and other is portrait or vice versa
final isLandscape1 = aspectRatio1 != null && aspectRatio1 > 1;
final isLandscape2 = aspectRatio2 != null && aspectRatio2 > 1;
// Both the images are landscape.
if (isLandscape1 && isLandscape2) {
// ----------
// | |
// ----------
// | |
// ----------
return FlexGrid(
pattern: const [
[1],
[1],
],
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<Attachment> attachments) {
final aspectRatio1 = attachments[0].originalSize?.aspectRatio;
final isLandscape1 = aspectRatio1 != null && aspectRatio1 > 1;
// We layout on the basis of isLandscape1.
// 1. True
// -----------
// | |
// | |
// |---------|
// | | |
// | | |
// -----------
//
// 2. False
// -----------
// | | |
// | | |
// | |----|
// | | |
// | | |
// -----------
return FlexGrid(
pattern: const [
[1],
[1, 1],
],
spacing: spacing,
runSpacing: runSpacing,
reverse: !isLandscape1,
children: [
itemBuilder(context, 0),
itemBuilder(context, 1),
itemBuilder(context, 2),
],
);
}
Widget _buildForFourOrMore(
BuildContext context, List<Attachment> attachments) {
final pattern = <List<int>>[];
final children = <Widget>[];
for (var i = 0; i < attachments.length; i++) {
if (i.isEven) {
pattern.add([1]);
} else {
pattern.last.add(1);
}
children.add(itemBuilder(context, i));
}
// -----------
// | | |
// | | |
// ------------
// | | |
// | | |
// ------------
return FlexGrid(
pattern: pattern,
maxChildren: 4,
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,
),
),
),
),
);
},
);
}
}
@@ -1,345 +1,105 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/giphy_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart'; import 'package:stream_chat_flutter/src/misc/giphy_chip.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamGiphyAttachment} /// {@template streamGiphyAttachment}
/// Shows a GIF attachment in a [StreamMessageWidget]. /// Shows a GIF attachment in a [StreamMessageWidget].
/// {@endtemplate} /// {@endtemplate}
class StreamGiphyAttachment extends StreamAttachmentWidget { class StreamGiphyAttachment extends StatelessWidget {
/// {@macro streamGiphyAttachment} /// {@macro streamGiphyAttachment}
const StreamGiphyAttachment({ const StreamGiphyAttachment({
super.key, super.key,
required super.message, required this.message,
required super.attachment, required this.giphy,
super.constraints, this.type = GiphyInfoType.original,
this.onShowMessage, this.shape,
this.onReplyMessage, this.constraints = const BoxConstraints(),
this.onAttachmentTap,
this.attachmentActionsModalBuilder,
}); });
/// {@macro showMessageCallback} /// The [Message] that the giphy is attached to.
final ShowMessageCallback? onShowMessage; final Message message;
/// {@macro replyMessageCallback} /// The [Attachment] object containing the giphy information.
final ReplyMessageCallback? onReplyMessage; final Attachment giphy;
/// {@macro onAttachmentTap} /// The type of giphy to display.
final OnAttachmentTap? onAttachmentTap; ///
/// Defaults to [GiphyInfoType.fixedHeight].
final GiphyInfoType type;
/// {@macro attachmentActionsBuilder} /// The shape of the attachment.
final AttachmentActionsBuilder? attachmentActionsModalBuilder; ///
/// Defaults to [RoundedRectangleBorder] with a radius of 14.
final ShapeBorder? shape;
/// The constraints to use when displaying the giphy.
final BoxConstraints constraints;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final imageUrl = BoxFit? fit;
attachment.thumbUrl ?? attachment.imageUrl ?? attachment.assetUrl; final giphyInfo = giphy.giphyInfo(type);
if (imageUrl == null) {
return const AttachmentError(); 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 chatTheme = StreamChatTheme.of(context);
final streamChannel = StreamChannel.of(context); final colorTheme = chatTheme.colorTheme;
return ConstrainedBox( final shape = this.shape ??
constraints: constraints?.copyWith( RoundedRectangleBorder(
maxHeight: double.infinity, side: BorderSide(
) ?? color: colorTheme.borders,
const BoxConstraints.expand(), strokeAlign: BorderSide.strokeAlignOutside,
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: <Widget>[
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), borderRadius: BorderRadius.circular(14),
const Align( );
alignment: Alignment.centerRight,
child: StreamVisibleFootnote(),
),
],
),
);
}
Future<void> _onImageTap(BuildContext context) async { return Container(
await Navigator.of(context).push( constraints: constraints,
MaterialPageRoute( clipBehavior: Clip.hardEdge,
builder: (_) { decoration: ShapeDecoration(shape: shape),
final channel = StreamChannel.of(context).channel; child: AspectRatio(
return StreamChannel( aspectRatio: giphySize?.aspectRatio ?? 1,
channel: channel, child: Stack(
child: StreamFullScreenMediaBuilder( alignment: Alignment.center,
mediaAttachmentPackages: message.getAttachmentPackageList(), children: [
startIndex: message.attachments.indexOf(attachment), StreamGiphyAttachmentThumbnail(
userName: message.user!.name, type: type,
onShowMessage: onShowMessage, giphy: giphy,
onReplyMessage: onReplyMessage, fit: fit,
attachmentActionsModalBuilder: attachmentActionsModalBuilder, width: double.infinity,
height: double.infinity,
), ),
); if (giphy.uploadState.isSuccess)
}, const Positioned(
), bottom: 8,
); left: 8,
} child: GiphyChip(),
)
Widget _buildSentAttachment(BuildContext context, String imageUrl) { else
return GestureDetector( Padding(
onTap: () { padding: const EdgeInsets.all(8),
if (onAttachmentTap != null) { child: StreamAttachmentUploadStateBuilder(
onAttachmentTap?.call(); message: message,
} else { attachment: giphy,
_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,
),
),
],
), ),
), ),
), ],
), ),
],
), ),
); );
} }
@@ -50,18 +50,18 @@ Future<AttachmentData> downloadAttachmentData(
String? downloadUrl; String? downloadUrl;
String? fileName; String? fileName;
/* ---IMAGES/GIFS--- */ /* ---IMAGES/GIFS--- */
if (type == 'image') { if (type == AttachmentType.image) {
downloadUrl = attachment.imageUrl ?? attachment.assetUrl; downloadUrl = attachment.imageUrl ?? attachment.assetUrl;
fileName = attachment.title; fileName = attachment.title;
fileName ??= 'attachment.${attachment.mimeType ?? 'png'}'; fileName ??= 'attachment.${attachment.mimeType ?? 'png'}';
} }
/* ---GIPHY's--- */ /* ---GIPHY's--- */
else if (type == 'giphy') { else if (type == AttachmentType.giphy) {
downloadUrl = attachment.thumbUrl; downloadUrl = attachment.thumbUrl;
fileName = '${attachment.title}.gif'; fileName = '${attachment.title}.gif';
} }
/* ---FILES AND VIDEOS--- */ /* ---FILES AND VIDEOS--- */
else if (type == 'file' || type == 'video') { else if (type == AttachmentType.file || type == AttachmentType.video) {
downloadUrl = attachment.assetUrl; downloadUrl = attachment.assetUrl;
fileName = attachment.title; fileName = attachment.title;
} }
@@ -1,44 +1,36 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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/attachment_widget.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamImageAttachment} /// {@template streamImageAttachment}
/// Shows an image attachment in a [StreamMessageWidget]. /// Shows an image attachment in a [StreamMessageWidget].
/// {@endtemplate} /// {@endtemplate}
class StreamImageAttachment extends StreamAttachmentWidget { class StreamImageAttachment extends StatelessWidget {
/// {@macro streamImageAttachment} /// {@macro streamImageAttachment}
const StreamImageAttachment({ const StreamImageAttachment({
super.key, super.key,
required super.message, required this.message,
required super.attachment, required this.image,
required this.messageTheme, this.shape,
super.constraints, this.constraints = const BoxConstraints(),
this.showTitle = false,
this.onShowMessage,
this.onReplyMessage,
this.onAttachmentTap,
this.imageThumbnailSize = const Size(400, 400), this.imageThumbnailSize = const Size(400, 400),
this.imageThumbnailResizeType = 'clip', this.imageThumbnailResizeType = 'clip',
this.imageThumbnailCropType = 'center', this.imageThumbnailCropType = 'center',
this.attachmentActionsModalBuilder,
}); });
/// The [StreamMessageThemeData] to use for the image title /// The [Message] that the image is attached to.
final StreamMessageThemeData messageTheme; final Message message;
/// Flag for whether the title should be shown or not /// The [Attachment] object containing the image information.
final bool showTitle; final Attachment image;
/// {@macro showMessageCallback} /// The shape of the attachment.
final ShowMessageCallback? onShowMessage; ///
/// Defaults to [RoundedRectangleBorder] with a radius of 14.
final ShapeBorder? shape;
/// {@macro replyMessageCallback} /// The constraints to use when displaying the image.
final ReplyMessageCallback? onReplyMessage; final BoxConstraints constraints;
/// {@macro onAttachmentTap}
final OnAttachmentTap? onAttachmentTap;
/// Size of the attachment image thumbnail. /// Size of the attachment image thumbnail.
final Size imageThumbnailSize; final Size imageThumbnailSize;
@@ -53,148 +45,60 @@ class StreamImageAttachment extends StreamAttachmentWidget {
/// Defaults to [center] /// Defaults to [center]
final String /*center|top|bottom|left|right*/ imageThumbnailCropType; final String /*center|top|bottom|left|right*/ imageThumbnailCropType;
/// {@macro attachmentActionsBuilder}
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return source.when( BoxFit? fit;
local: () { final imageSize = image.originalSize;
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) { // If attachment size is available, we will tighten the constraints max
return AttachmentError(constraints: constraints); // size to the attachment size.
} var constraints = this.constraints;
if (imageSize != null) {
constraints = constraints.tightenMaxSize(imageSize);
} else {
// For backward compatibility, we will fill the available space if the
// attachment size is not available.
fit = BoxFit.cover;
}
imageUrl = imageUrl.getResizedImageUrl( final chatTheme = StreamChatTheme.of(context);
width: imageThumbnailSize.width, final colorTheme = chatTheme.colorTheme;
height: imageThumbnailSize.height, final shape = this.shape ??
resize: imageThumbnailResizeType, RoundedRectangleBorder(
crop: imageThumbnailCropType, side: BorderSide(
); color: colorTheme.borders,
strokeAlign: BorderSide.strokeAlignOutside,
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),
), ),
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( return Container(
constraints: constraints, constraints: constraints,
child: Column( clipBehavior: Clip.hardEdge,
children: <Widget>[ decoration: ShapeDecoration(shape: shape),
Expanded( child: AspectRatio(
child: Stack( aspectRatio: imageSize?.aspectRatio ?? 1,
children: [ child: Stack(
MouseRegion( alignment: Alignment.center,
cursor: SystemMouseCursors.click, children: [
child: GestureDetector( StreamImageAttachmentThumbnail(
onTap: onAttachmentTap ?? image: image,
() { fit: fit,
Navigator.of(context).push( width: double.infinity,
MaterialPageRoute( height: double.infinity,
builder: (_) { thumbnailSize: imageThumbnailSize,
final channel = thumbnailResizeType: imageThumbnailResizeType,
StreamChannel.of(context).channel; thumbnailCropType: imageThumbnailCropType,
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,
),
),
],
), ),
), Padding(
if (showTitle && attachment.title != null) padding: const EdgeInsets.all(8),
Material( child: StreamAttachmentUploadStateBuilder(
color: messageTheme.messageBackgroundColor, message: message,
child: StreamAttachmentTitle( attachment: image,
messageTheme: messageTheme,
attachment: attachment,
), ),
), ),
], ],
),
), ),
); );
} }
@@ -1,181 +0,0 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamImageGroup}
/// Constructs a group of image attachments in a [StreamMessageWidget].
/// {@endtemplate}
class StreamImageGroup extends StatelessWidget {
/// {@macro streamImageGroup}
const StreamImageGroup({
super.key,
required this.images,
required this.message,
required this.messageTheme,
required this.constraints,
this.onShowMessage,
this.onReplyMessage,
this.onAttachmentTap,
this.imageThumbnailSize = const Size(400, 400),
this.imageThumbnailResizeType = 'clip',
this.imageThumbnailCropType = 'center',
this.attachmentActionsModalBuilder,
});
/// List of attachments to show
final List<Attachment> images;
/// {@macro onImageGroupAttachmentTap}
final OnImageGroupAttachmentTap? onAttachmentTap;
/// The [Message] that the images are attached to
final Message message;
/// The [StreamMessageThemeData] to apply to this [message]
final StreamMessageThemeData messageTheme;
/// The constraints of the [images]
final BoxConstraints constraints;
/// {@macro showMessageCallback}
final ShowMessageCallback? onShowMessage;
/// {@macro replyMessageCallback}
final ReplyMessageCallback? onReplyMessage;
/// Size of the attachment image thumbnail.
final Size imageThumbnailSize;
/// Resize type of the image attachment thumbnail.
///
/// Defaults to [crop]
final String /*clip|crop|scale|fill*/ imageThumbnailResizeType;
/// Crop type of the image attachment thumbnail.
///
/// Defaults to [center]
final String /*center|top|bottom|left|right*/ imageThumbnailCropType;
/// {@macro attachmentActionsBuilder}
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: constraints,
child: Flex(
direction: Axis.vertical,
children: <Widget>[
Flexible(
fit: FlexFit.tight,
child: Flex(
crossAxisAlignment: CrossAxisAlignment.stretch,
direction: Axis.horizontal,
children: [
Flexible(
fit: FlexFit.tight,
child: _buildImage(context, 0),
),
Flexible(
fit: FlexFit.tight,
child: Padding(
padding: const EdgeInsets.only(left: 2),
child: _buildImage(context, 1),
),
),
],
),
),
if (images.length >= 3)
Flexible(
fit: FlexFit.tight,
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: Flex(
direction: Axis.horizontal,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Flexible(
fit: FlexFit.tight,
child: _buildImage(context, 2),
),
if (images.length >= 4)
Flexible(
fit: FlexFit.tight,
child: Padding(
padding: const EdgeInsets.only(left: 2),
child: Stack(
fit: StackFit.expand,
children: <Widget>[
_buildImage(context, 3),
if (images.length > 4)
Positioned.fill(
child: GestureDetector(
onTap: () => _onTap(context, 3),
child: Material(
color: Colors.black38,
child: Center(
child: Text(
'+ ${images.length - 4}',
style: const TextStyle(
color: Colors.white,
fontSize: 26,
),
),
),
),
),
),
],
),
),
),
],
),
),
),
],
),
);
}
Future<void> _onTap(
BuildContext context,
int index,
) async {
if (onAttachmentTap != null) {
return onAttachmentTap!(message, images[index]);
}
final channel = StreamChannel.of(context).channel;
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => StreamChannel(
channel: channel,
child: StreamFullScreenMediaBuilder(
mediaAttachmentPackages: message.getAttachmentPackageList(),
startIndex: index,
userName: message.user!.name,
onShowMessage: onShowMessage,
onReplyMessage: onReplyMessage,
attachmentActionsModalBuilder: attachmentActionsModalBuilder,
),
),
),
);
}
Widget _buildImage(BuildContext context, int index) {
return StreamImageAttachment(
attachment: images[index],
constraints: constraints,
message: message,
messageTheme: messageTheme,
onAttachmentTap: () => _onTap(context, index),
imageThumbnailSize: imageThumbnailSize,
imageThumbnailResizeType: imageThumbnailResizeType,
imageThumbnailCropType: imageThumbnailCropType,
attachmentActionsModalBuilder: attachmentActionsModalBuilder,
);
}
}
@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/thumbnail_error.dart';
import 'package:stream_chat_flutter/src/attachment/thumbnail/video_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/src/utils/helpers.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// {@template streamFileAttachmentThumbnail}
/// Widget for building file attachment thumbnail.
///
/// This widget first tries to build an image thumbnail for the file attachment.
/// If the image thumbnail fails to load, it tries to build a video thumbnail.
/// If the video thumbnail fails to load, it returns a generic file type icon.
/// {@endtemplate}
class StreamFileAttachmentThumbnail extends StatelessWidget {
/// {@macro streamFileAttachmentThumbnail}
const StreamFileAttachmentThumbnail({
super.key,
required this.file,
this.width,
this.height,
this.fit,
this.errorBuilder = _defaultErrorBuilder,
});
/// The file attachment to build the thumbnail for.
final Attachment file;
/// The width of the thumbnail.
final double? width;
/// The height of the thumbnail.
final double? height;
/// How to inscribe the thumbnail into the space allocated during layout.
final BoxFit? fit;
/// Builder used when the thumbnail fails to load.
final ThumbnailErrorBuilder errorBuilder;
// Default error builder for file attachment thumbnail.
static Widget _defaultErrorBuilder(
BuildContext context,
Object error,
StackTrace? stackTrace,
) {
// Return a generic file type icon.
return getFileTypeImage();
}
@override
Widget build(BuildContext context) {
final 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);
}
}
@@ -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,
);
},
);
}
}
@@ -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,
);
},
);
}
}
@@ -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,
);
}
}
@@ -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',
);
}
}
@@ -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,
);
}
}
@@ -1,6 +1,5 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamUrlAttachment} /// {@template streamUrlAttachment}
@@ -10,155 +9,137 @@ class StreamUrlAttachment extends StatelessWidget {
/// {@macro streamUrlAttachment} /// {@macro streamUrlAttachment}
const StreamUrlAttachment({ const StreamUrlAttachment({
super.key, super.key,
required this.message,
required this.urlAttachment, required this.urlAttachment,
required this.hostDisplayName, required this.hostDisplayName,
required this.messageTheme, required this.messageTheme,
this.textPadding = const EdgeInsets.symmetric( this.shape,
horizontal: 16, this.constraints = const BoxConstraints(),
vertical: 8,
),
this.onLinkTap,
}); });
/// The [Message] that the image is attached to.
final Message message;
/// Attachment to be displayed /// Attachment to be displayed
final Attachment urlAttachment; 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 /// Host name
final String hostDisplayName; final String hostDisplayName;
/// Padding for text
final EdgeInsets textPadding;
/// The [StreamMessageThemeData] to use for the image title /// The [StreamMessageThemeData] to use for the image title
final StreamMessageThemeData messageTheme; final StreamMessageThemeData messageTheme;
/// The function called when tapping on a link
final void Function(String)? onLinkTap;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ConstrainedBox( final chatTheme = StreamChatTheme.of(context);
constraints: const BoxConstraints( final colorTheme = chatTheme.colorTheme;
maxWidth: 400, final shape = this.shape ??
minWidth: 400, 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( child: Column(
cursor: SystemMouseCursors.click, mainAxisSize: MainAxisSize.min,
child: GestureDetector( children: [
onTap: () { Stack(
final ogScrapeUrl = urlAttachment.ogScrapeUrl;
if (ogScrapeUrl != null) {
onLinkTap != null
? onLinkTap!(ogScrapeUrl)
: launchURL(context, ogScrapeUrl);
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (urlAttachment.imageUrl != null) AspectRatio(
Container( // Default aspect ratio for Open Graph images.
clipBehavior: Clip.hardEdge, // https://www.kapwing.com/resources/what-is-an-og-image-make-and-format-og-images-for-your-blog-or-webpage
margin: const EdgeInsets.symmetric(horizontal: 8), aspectRatio: 1.91 / 1,
decoration: BoxDecoration( child: StreamImageAttachmentThumbnail(
borderRadius: BorderRadius.circular(8), image: urlAttachment,
), fit: BoxFit.cover,
child: Stack(
children: [
AspectRatio(
// Default aspect ratio for Open Graph images.
// https://www.kapwing.com/resources/what-is-an-og-image-make-and-format-og-images-for-your-blog-or-webpage
aspectRatio: 1.91 / 1,
child: CachedNetworkImage(
imageUrl: urlAttachment.imageUrl!,
fit: BoxFit.cover,
placeholder: (context, __) {
final image = Image.asset(
'images/placeholder.png',
fit: BoxFit.cover,
package: 'stream_chat_flutter',
);
final colorTheme =
StreamChatTheme.of(context).colorTheme;
return Shimmer.fromColors(
baseColor: colorTheme.disabled,
highlightColor: colorTheme.inputBg,
child: image,
);
},
errorWidget: (_, __, ___) => const AttachmentError(),
),
),
Positioned(
left: 0,
bottom: 0,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topRight: Radius.circular(16),
),
color: messageTheme.urlAttachmentBackgroundColor,
),
child: Padding(
padding: const EdgeInsets.only(
top: 8,
left: 8,
right: 12,
bottom: 4,
),
child: Text(
hostDisplayName,
style: messageTheme.urlAttachmentHostStyle,
),
),
),
),
],
),
), ),
Padding( ),
padding: textPadding, Positioned(
child: Column( left: 0,
crossAxisAlignment: CrossAxisAlignment.start, bottom: 0,
children: <Widget>[ child: DecoratedBox(
if (urlAttachment.title != null) decoration: BoxDecoration(
Builder(builder: (context) { borderRadius: const BorderRadius.only(
final maxLines = messageTheme.urlAttachmentTitleMaxLine; topRight: Radius.circular(16),
),
TextOverflow? overflow; color: backgroundColor,
if (maxLines != null && maxLines > 0) { ),
overflow = TextOverflow.ellipsis; child: Padding(
} padding: const EdgeInsets.only(
top: 8,
return Text( left: 8,
urlAttachment.title!.trim(), right: 12,
maxLines: maxLines, bottom: 4,
overflow: overflow, ),
style: messageTheme.urlAttachmentTitleStyle, child: Text(
); hostDisplayName,
}), style: messageTheme.urlAttachmentHostStyle,
if (urlAttachment.text != null) ),
Builder(builder: (context) { ),
final maxLines = messageTheme.urlAttachmentTextMaxLine;
TextOverflow? overflow;
if (maxLines != null && maxLines > 0) {
overflow = TextOverflow.ellipsis;
}
return Text(
urlAttachment.text!,
maxLines: maxLines,
overflow: overflow,
style: messageTheme.urlAttachmentTextStyle,
);
}),
].insertBetween(const SizedBox(height: 4)),
), ),
), ),
], ],
), ),
), Padding(
padding: const EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
if (urlAttachment.title != null)
Builder(builder: (context) {
final maxLines = messageTheme.urlAttachmentTitleMaxLine;
TextOverflow? overflow;
if (maxLines != null && maxLines > 0) {
overflow = TextOverflow.ellipsis;
}
return Text(
urlAttachment.title!.trim(),
maxLines: maxLines,
overflow: overflow,
style: messageTheme.urlAttachmentTitleStyle,
);
}),
if (urlAttachment.text != null)
Builder(builder: (context) {
final maxLines = messageTheme.urlAttachmentTextMaxLine;
TextOverflow? overflow;
if (maxLines != null && maxLines > 0) {
overflow = TextOverflow.ellipsis;
}
return Text(
urlAttachment.text!,
maxLines: maxLines,
overflow: overflow,
style: messageTheme.urlAttachmentTextStyle,
);
}),
].insertBetween(const SizedBox(height: 4)),
),
),
],
), ),
); );
} }
@@ -1,123 +1,72 @@
import 'package:flutter/material.dart'; import 'package: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'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamVideoAttachment} /// {@template streamVideoAttachment}
/// Shows a video attachment in a [StreamMessageWidget]. /// Shows a video attachment in a [StreamMessageWidget].
/// {@endtemplate} /// {@endtemplate}
class StreamVideoAttachment extends StreamAttachmentWidget { class StreamVideoAttachment extends StatelessWidget {
/// {@macro streamVideoAttachment} /// {@macro streamVideoAttachment}
const StreamVideoAttachment({ const StreamVideoAttachment({
super.key, super.key,
required super.message, required this.message,
required super.attachment, required this.video,
required this.messageTheme, this.shape,
super.constraints, this.constraints = const BoxConstraints(),
this.onShowMessage,
this.onReplyMessage,
this.onAttachmentTap,
this.attachmentActionsModalBuilder,
}); });
/// The [StreamMessageThemeData] to use for the title /// The [Message] that the video is attached to.
final StreamMessageThemeData messageTheme; final Message message;
/// {@macro showMessageCallback} /// The [Attachment] object containing the video information.
final ShowMessageCallback? onShowMessage; final Attachment video;
/// {@macro replyMessageCallback} /// The shape of the attachment.
final ReplyMessageCallback? onReplyMessage; ///
/// Defaults to [RoundedRectangleBorder] with a radius of 14.
final ShapeBorder? shape;
/// {@macro onAttachmentTap} /// The constraints to use when displaying the video.
final OnAttachmentTap? onAttachmentTap; final BoxConstraints constraints;
/// {@macro attachmentActionsBuilder}
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return source.when( final chatTheme = StreamChatTheme.of(context);
local: () { final colorTheme = chatTheme.colorTheme;
if (attachment.file == null) { final shape = this.shape ??
return AttachmentError(constraints: constraints); RoundedRectangleBorder(
} side: BorderSide(
return _buildVideoAttachment( color: colorTheme.borders,
context, strokeAlign: BorderSide.strokeAlignOutside,
StreamVideoThumbnailImage(
video: attachment.file!.path,
thumbUrl: attachment.thumbUrl,
constraints: constraints,
), ),
borderRadius: BorderRadius.circular(14),
); );
},
network: () {
if (attachment.assetUrl == null) {
return AttachmentError(constraints: constraints);
}
return _buildVideoAttachment(
context,
StreamVideoThumbnailImage(
video: attachment.assetUrl,
thumbUrl: attachment.thumbUrl,
constraints: constraints,
),
);
},
);
}
Widget _buildVideoAttachment(BuildContext context, Widget videoWidget) { return Container(
return ConstrainedBox( constraints: constraints,
constraints: constraints ?? const BoxConstraints.expand(), clipBehavior: Clip.hardEdge,
child: Column( decoration: ShapeDecoration(shape: shape),
children: <Widget>[ child: Stack(
Expanded( alignment: Alignment.center,
child: GestureDetector( children: [
onTap: onAttachmentTap ?? StreamVideoAttachmentThumbnail(
() async { video: video,
if (attachment.uploadState == const UploadState.success()) { width: double.infinity,
final channel = StreamChannel.of(context).channel; height: double.infinity,
await Navigator.of(context).push( fit: BoxFit.cover,
MaterialPageRoute( ),
builder: (_) => StreamChannel( const Material(
channel: channel, shape: CircleBorder(),
child: StreamFullScreenMediaBuilder( child: Padding(
mediaAttachmentPackages: padding: EdgeInsets.all(16),
message.getAttachmentPackageList(), child: Icon(Icons.play_arrow),
startIndex: ),
message.attachments.indexOf(attachment), ),
userName: message.user!.name, Padding(
onShowMessage: onShowMessage, padding: const EdgeInsets.all(8),
onReplyMessage: onReplyMessage, child: StreamAttachmentUploadStateBuilder(
attachmentActionsModalBuilder: message: message,
attachmentActionsModalBuilder, attachment: video,
),
),
),
);
}
},
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,
),
),
],
),
), ),
), ),
], ],
@@ -129,7 +129,7 @@ class AttachmentActionsModal extends StatelessWidget {
if (showSave) if (showSave)
_buildButton( _buildButton(
context, context,
attachment.type == 'video' attachment.type == AttachmentType.video
? context.translations.saveVideoLabel ? context.translations.saveVideoLabel
: context.translations.saveImageLabel, : context.translations.saveImageLabel,
StreamSvgIcon.iconSave( StreamSvgIcon.iconSave(
@@ -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<bool>(
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: <Widget>[
Flexible(
child: title ??
ChannelName(
textStyle: channelPreviewTheme.titleStyle,
),
),
BetterStreamBuilder<List<Member>>(
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: <Widget>[
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<List<Read>>(
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<DateTime>(
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: <Widget>[
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<List<Message>>(
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 = <String>[
...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<User> mentions,
List<Attachment> attachments,
TextStyle? normalTextStyle,
TextStyle? mentionsTextStyle,
) {
final textList = text.split(' ');
final resList = <TextSpan>[];
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);
}
}
@@ -36,11 +36,11 @@ class StreamMessagePreviewText extends StatelessWidget {
final messageTextParts = [ final messageTextParts = [
...messageAttachments.map((it) { ...messageAttachments.map((it) {
if (it.type == 'image') { if (it.type == AttachmentType.image) {
return '📷'; return '📷';
} else if (it.type == 'video') { } else if (it.type == AttachmentType.video) {
return '🎬'; return '🎬';
} else if (it.type == 'giphy') { } else if (it.type == AttachmentType.giphy) {
return '[GIF]'; return '[GIF]';
} }
return it == message.attachments.last return it == message.attachments.last
@@ -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,
}
@@ -1,14 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:chewie/chewie.dart'; import 'package:chewie/chewie.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/media_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart';
import 'package:stream_chat_flutter/src/fullscreen_media/full_screen_media_widget.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:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:video_player/video_player.dart'; import 'package:video_player/video_player.dart';
@@ -71,7 +69,7 @@ class _FullScreenMediaState extends State<StreamFullScreenMedia> {
_pageController = PageController(initialPage: widget.startIndex); _pageController = PageController(initialPage: widget.startIndex);
for (var i = 0; i < widget.mediaAttachmentPackages.length; i++) { for (var i = 0; i < widget.mediaAttachmentPackages.length; i++) {
final attachment = widget.mediaAttachmentPackages[i].attachment; final attachment = widget.mediaAttachmentPackages[i].attachment;
if (attachment.type != 'video') continue; if (attachment.type != AttachmentType.video) continue;
final package = VideoPackage(attachment, showControls: true); final package = VideoPackage(attachment, showControls: true);
videoPackages[attachment.id] = package; videoPackages[attachment.id] = package;
} }
@@ -90,7 +88,8 @@ class _FullScreenMediaState extends State<StreamFullScreenMedia> {
(it) => it.initialize(), (it) => it.initialize(),
)); ));
if (widget.autoplayVideos && currentAttachment.type == 'video') { if (widget.autoplayVideos &&
currentAttachment.type == AttachmentType.video) {
final package = videoPackages.values final package = videoPackages.values
.firstWhere((e) => e._attachment == currentAttachment); .firstWhere((e) => e._attachment == currentAttachment);
package._chewieController?.play(); package._chewieController?.play();
@@ -270,7 +269,7 @@ class _FullScreenMediaState extends State<StreamFullScreenMedia> {
} }
} }
if (widget.autoplayVideos && if (widget.autoplayVideos &&
currentAttachment.type == 'video') { currentAttachment.type == AttachmentType.video) {
final controller = videoPackages[currentAttachment.id]!; final controller = videoPackages[currentAttachment.id]!;
controller._chewieController?.play(); controller._chewieController?.play();
} }
@@ -289,44 +288,21 @@ class _FullScreenMediaState extends State<StreamFullScreenMedia> {
: Colors.black, : Colors.black,
child: Builder( child: Builder(
builder: (context) { builder: (context) {
if (attachment.type == 'image' || if (attachment.type == AttachmentType.image ||
attachment.type == 'giphy') { attachment.type == AttachmentType.giphy) {
final imageUrl = attachment.imageUrl ?? return PhotoView.customChild(
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,
);
},
maxScale: PhotoViewComputedScale.covered, maxScale: PhotoViewComputedScale.covered,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
heroAttributes: PhotoViewHeroAttributes(
tag: widget.mediaAttachmentPackages,
),
backgroundDecoration: const BoxDecoration( backgroundDecoration: const BoxDecoration(
color: Colors.transparent, 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]!; final controller = videoPackages[attachment.id]!;
if (!controller.initialized) { if (!controller.initialized) {
return const Center( return const Center(
@@ -365,74 +341,6 @@ class _FullScreenMediaState extends State<StreamFullScreenMedia> {
} }
} }
/// 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<bool> 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<bool>(
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 for packaging up things required for videos
class VideoPackage { class VideoPackage {
/// Constructor for creating [VideoPackage] /// Constructor for creating [VideoPackage]
@@ -1,13 +1,11 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:contextmenu/contextmenu.dart'; import 'package:contextmenu/contextmenu.dart';
import 'package:dart_vlc/dart_vlc.dart'; import 'package:dart_vlc/dart_vlc.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/attachment/thumbnail/media_attachment_thumbnail.dart';
import 'package:stream_chat_flutter/platform_widget_builder/platform_widget_builder.dart';
import 'package:stream_chat_flutter/src/context_menu_items/download_menu_item.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/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:stream_chat_flutter/stream_chat_flutter.dart';
/// Returns an instance of [FullScreenMediaDesktop]. /// Returns an instance of [FullScreenMediaDesktop].
@@ -94,7 +92,7 @@ class _FullScreenMediaDesktopState extends State<FullScreenMediaDesktop> {
_pageController = PageController(initialPage: widget.startIndex); _pageController = PageController(initialPage: widget.startIndex);
for (var i = 0; i < widget.mediaAttachmentPackages.length; i++) { for (var i = 0; i < widget.mediaAttachmentPackages.length; i++) {
final attachment = widget.mediaAttachmentPackages[i].attachment; final attachment = widget.mediaAttachmentPackages[i].attachment;
if (attachment.type != 'video') continue; if (attachment.type != AttachmentType.video) continue;
final package = DesktopVideoPackage(attachment); final package = DesktopVideoPackage(attachment);
videoPackages[attachment.id] = package; videoPackages[attachment.id] = package;
} }
@@ -298,7 +296,8 @@ class _FullScreenMediaDesktopState extends State<FullScreenMediaDesktop> {
p.player.pause(); p.player.pause();
} }
} }
if (widget.autoplayVideos && currentAttachment.type == 'video') { if (widget.autoplayVideos &&
currentAttachment.type == AttachmentType.video) {
final package = videoPackages[currentAttachment.id]!; final package = videoPackages[currentAttachment.id]!;
package.player.play(); package.player.play();
} }
@@ -318,44 +317,21 @@ class _FullScreenMediaDesktopState extends State<FullScreenMediaDesktop> {
: Colors.black, : Colors.black,
child: Builder( child: Builder(
builder: (context) { builder: (context) {
if (attachment.type == 'image' || if (attachment.type == AttachmentType.image ||
attachment.type == 'giphy') { attachment.type == AttachmentType.giphy) {
final imageUrl = attachment.imageUrl ?? return PhotoView.customChild(
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,
);
},
maxScale: PhotoViewComputedScale.covered, maxScale: PhotoViewComputedScale.covered,
minScale: PhotoViewComputedScale.contained, minScale: PhotoViewComputedScale.contained,
heroAttributes: PhotoViewHeroAttributes(
tag: widget.mediaAttachmentPackages,
),
backgroundDecoration: const BoxDecoration( backgroundDecoration: const BoxDecoration(
color: Colors.transparent, 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]!; final package = videoPackages[attachment.id]!;
package.player.open( package.player.open(
Playlist( Playlist(
@@ -404,74 +380,6 @@ class _FullScreenMediaDesktopState extends State<FullScreenMediaDesktop> {
} }
} }
/// 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<bool> 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<bool>(
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 for packaging up things required for videos
class DesktopVideoPackage { class DesktopVideoPackage {
/// Constructor for creating [VideoPackage] /// Constructor for creating [VideoPackage]
@@ -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<bool> 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<bool>(
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,
),
),
),
),
);
}
}
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.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'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamGalleryFooter} /// {@template streamGalleryFooter}
@@ -94,7 +95,7 @@ class _StreamGalleryFooterState extends State<StreamGalleryFooter> {
final url = attachment.imageUrl ?? final url = attachment.imageUrl ??
attachment.assetUrl ?? attachment.assetUrl ??
attachment.thumbUrl!; attachment.thumbUrl!;
final type = attachment.type == 'image' final type = attachment.type == AttachmentType.image
? 'jpg' ? 'jpg'
: url.split('?').first.split('.').last; : url.split('?').first.split('.').last;
final request = await HttpClient().getUrl(Uri.parse(url)); final request = await HttpClient().getUrl(Uri.parse(url));
@@ -217,16 +218,15 @@ class _StreamGalleryFooterState extends State<StreamGalleryFooter> {
widget.mediaAttachmentPackages[index]; widget.mediaAttachmentPackages[index];
final attachment = attachmentPackage.attachment; final attachment = attachmentPackage.attachment;
final message = attachmentPackage.message; final message = attachmentPackage.message;
if (attachment.type == 'video') { if (attachment.type == AttachmentType.video) {
media = MouseRegion( media = MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
child: GestureDetector( child: GestureDetector(
onTap: () => widget.mediaSelectedCallBack!(index), onTap: () => widget.mediaSelectedCallBack!(index),
child: AspectRatio( child: AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: StreamVideoThumbnailImage( child: StreamVideoAttachmentThumbnail(
video: video: attachment,
attachment.file?.path ?? attachment.assetUrl,
), ),
), ),
), ),
@@ -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>(color),
);
}
}
@@ -81,10 +81,7 @@ abstract class Translations {
/// The text for showing the unread messages count /// The text for showing the unread messages count
/// in the [StreamMessageListView] /// in the [StreamMessageListView]
String unreadMessagesSeparatorText( String unreadMessagesSeparatorText();
@Deprecated('unreadCount is not used anymore and will be removed ')
int unreadCount,
);
/// The label for "connected" in [StreamConnectionStatusBuilder] /// The label for "connected" in [StreamConnectionStatusBuilder]
String get connectedLabel; 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'; String get linkDisabledError => 'Links are disabled';
@override @override
String unreadMessagesSeparatorText(int unreadCount) => 'New messages'; String unreadMessagesSeparatorText() => 'New messages';
@override @override
String get enableFileAccessMessage => 'Please enable access to files' String get enableFileAccessMessage => 'Please enable access to files'
@@ -114,119 +114,121 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
final child = Center( final child = Center(
child: SingleChildScrollView( child: SingleChildScrollView(
child: Padding( child: SafeArea(
padding: const EdgeInsets.all(8), child: Padding(
child: Column( padding: const EdgeInsets.all(8),
mainAxisAlignment: MainAxisAlignment.center, child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ crossAxisAlignment: CrossAxisAlignment.stretch,
if (widget.showReactionPicker && hasReactionPermission) children: <Widget>[
LayoutBuilder( if (widget.showReactionPicker && hasReactionPermission)
builder: (context, constraints) { LayoutBuilder(
return Align( builder: (context, constraints) {
alignment: Alignment( return Align(
calculateReactionsHorizontalAlignment( alignment: Alignment(
user, calculateReactionsHorizontalAlignment(
widget.message, user,
constraints, widget.message,
fontSize, constraints,
orientation, fontSize,
orientation,
),
0,
), ),
0, child: StreamReactionPicker(
), message: widget.message,
child: StreamReactionPicker( ),
message: widget.message, );
), },
); ),
}, const SizedBox(height: 10),
IgnorePointer(
child: widget.messageWidget,
), ),
const SizedBox(height: 10), const SizedBox(height: 8),
IgnorePointer( Padding(
child: widget.messageWidget, padding: EdgeInsets.only(
), left: widget.reverse ? 0 : 40,
const SizedBox(height: 8), ),
Padding( child: SizedBox(
padding: EdgeInsets.only( width: mediaQueryData.size.width * 0.75,
left: widget.reverse ? 0 : 40, child: Material(
), color: streamChatThemeData.colorTheme.appBg,
child: SizedBox( clipBehavior: Clip.hardEdge,
width: mediaQueryData.size.width * 0.75, shape: RoundedRectangleBorder(
child: Material( borderRadius: BorderRadius.circular(16),
color: streamChatThemeData.colorTheme.appBg, ),
clipBehavior: Clip.hardEdge, child: Column(
shape: RoundedRectangleBorder( crossAxisAlignment: CrossAxisAlignment.stretch,
borderRadius: BorderRadius.circular(16), children: [
), if (widget.showReplyMessage &&
child: Column( widget.message.state.isCompleted)
crossAxisAlignment: CrossAxisAlignment.stretch, ReplyButton(
children: [ onTap: () {
if (widget.showReplyMessage && Navigator.of(context).pop();
widget.message.state.isCompleted) if (widget.onReplyTap != null) {
ReplyButton( widget.onReplyTap?.call(widget.message);
onTap: () { }
Navigator.of(context).pop(); },
if (widget.onReplyTap != null) { ),
widget.onReplyTap?.call(widget.message); if (widget.showThreadReplyMessage &&
} (widget.message.state.isCompleted) &&
}, widget.message.parentId == null)
ThreadReplyButton(
message: widget.message,
onThreadReplyTap: widget.onThreadReplyTap,
),
if (widget.showResendMessage)
ResendMessageButton(
message: widget.message,
channel: channel,
),
if (widget.showEditMessage)
EditMessageButton(
onTap: () {
Navigator.of(context).pop();
_showEditBottomSheet(context);
},
),
if (widget.showCopyMessage)
CopyMessageButton(
onTap: () {
widget.onCopyTap?.call(widget.message);
Navigator.of(context).pop();
},
),
if (widget.showFlagButton)
FlagMessageButton(
onTap: _showFlagDialog,
),
if (widget.showPinButton)
PinMessageButton(
onTap: _togglePin,
pinned: widget.message.pinned,
),
if (widget.showDeleteMessage)
DeleteMessageButton(
isDeleteFailed:
widget.message.state.isDeletingFailed,
onTap: _showDeleteBottomSheet,
),
...widget.customActions
.map((action) => _buildCustomAction(
context,
action,
)),
].insertBetween(
Container(
height: 1,
color: streamChatThemeData.colorTheme.borders,
), ),
if (widget.showThreadReplyMessage &&
(widget.message.state.isCompleted) &&
widget.message.parentId == null)
ThreadReplyButton(
message: widget.message,
onThreadReplyTap: widget.onThreadReplyTap,
),
if (widget.showResendMessage)
ResendMessageButton(
message: widget.message,
channel: channel,
),
if (widget.showEditMessage)
EditMessageButton(
onTap: () {
Navigator.of(context).pop();
_showEditBottomSheet(context);
},
),
if (widget.showCopyMessage)
CopyMessageButton(
onTap: () {
widget.onCopyTap?.call(widget.message);
Navigator.of(context).pop();
},
),
if (widget.showFlagButton)
FlagMessageButton(
onTap: _showFlagDialog,
),
if (widget.showPinButton)
PinMessageButton(
onTap: _togglePin,
pinned: widget.message.pinned,
),
if (widget.showDeleteMessage)
DeleteMessageButton(
isDeleteFailed:
widget.message.state.isDeletingFailed,
onTap: _showDeleteBottomSheet,
),
...widget.customActions
.map((action) => _buildCustomAction(
context,
action,
)),
].insertBetween(
Container(
height: 1,
color: streamChatThemeData.colorTheme.borders,
), ),
), ),
), ),
), ),
), ),
), ],
], ),
), ),
), ),
), ),
@@ -217,7 +217,7 @@ extension StreamImagePickerX on StreamAttachmentPickerController {
final extraDataMap = <String, Object>{}; final extraDataMap = <String, Object>{};
final mimeType = file.mimeType?.mimeType; final mimeType = file.mediaType?.mimeType;
if (mimeType != null) { if (mimeType != null) {
extraDataMap['mime_type'] = mimeType; extraDataMap['mime_type'] = mimeType;
@@ -240,10 +240,10 @@ class WebOrDesktopAttachmentPickerOption extends AttachmentPickerOption {
extension AttachmentPickerOptionTypeX on StreamAttachmentPickerController { extension AttachmentPickerOptionTypeX on StreamAttachmentPickerController {
/// Returns the list of available attachment picker options. /// Returns the list of available attachment picker options.
Set<AttachmentPickerType> get currentAttachmentPickerTypes { Set<AttachmentPickerType> get currentAttachmentPickerTypes {
final containsImage = value.any((it) => it.type == 'image'); final containsImage = value.any((it) => it.type == AttachmentType.image);
final containsVideo = value.any((it) => it.type == 'video'); final containsVideo = value.any((it) => it.type == AttachmentType.video);
final containsAudio = value.any((it) => it.type == 'audio'); final containsAudio = value.any((it) => it.type == AttachmentType.audio);
final containsFile = value.any((it) => it.type == 'file'); final containsFile = value.any((it) => it.type == AttachmentType.file);
return { return {
if (containsImage) AttachmentPickerType.images, if (containsImage) AttachmentPickerType.images,
@@ -1,5 +1,3 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
@@ -1,9 +1,10 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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/src/message_input/clear_input_item_button.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:video_player/video_player.dart';
typedef _Builders = Map<String, QuotedMessageAttachmentThumbnailBuilder>;
/// {@template streamQuotedMessage} /// {@template streamQuotedMessage}
/// Widget for the quoted message. /// Widget for the quoted message.
@@ -17,6 +18,7 @@ class StreamQuotedMessageWidget extends StatelessWidget {
this.reverse = false, this.reverse = false,
this.showBorder = false, this.showBorder = false,
this.textLimit = 170, this.textLimit = 170,
this.textBuilder,
this.attachmentThumbnailBuilders, this.attachmentThumbnailBuilders,
this.padding = const EdgeInsets.all(8), this.padding = const EdgeInsets.all(8),
this.onQuotedMessageClear, this.onQuotedMessageClear,
@@ -38,8 +40,7 @@ class StreamQuotedMessageWidget extends StatelessWidget {
final int textLimit; final int textLimit;
/// Map that defines a thumbnail builder for an attachment type /// Map that defines a thumbnail builder for an attachment type
final Map<String, QuotedMessageAttachmentThumbnailBuilder>? final _Builders? attachmentThumbnailBuilders;
attachmentThumbnailBuilders;
/// Padding around the widget /// Padding around the widget
final EdgeInsetsGeometry padding; final EdgeInsetsGeometry padding;
@@ -47,6 +48,9 @@ class StreamQuotedMessageWidget extends StatelessWidget {
/// Callback for clearing quoted messages. /// Callback for clearing quoted messages.
final VoidCallback? onQuotedMessageClear; final VoidCallback? onQuotedMessageClear;
/// {@macro textBuilder}
final Widget Function(BuildContext, Message)? textBuilder;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final children = [ final children = [
@@ -57,6 +61,7 @@ class StreamQuotedMessageWidget extends StatelessWidget {
messageTheme: messageTheme, messageTheme: messageTheme,
showBorder: showBorder, showBorder: showBorder,
reverse: reverse, reverse: reverse,
textBuilder: textBuilder,
onQuotedMessageClear: onQuotedMessageClear, onQuotedMessageClear: onQuotedMessageClear,
attachmentThumbnailBuilders: attachmentThumbnailBuilders, attachmentThumbnailBuilders: attachmentThumbnailBuilders,
), ),
@@ -90,6 +95,7 @@ class _QuotedMessage extends StatelessWidget {
required this.messageTheme, required this.messageTheme,
required this.showBorder, required this.showBorder,
required this.reverse, required this.reverse,
this.textBuilder,
this.onQuotedMessageClear, this.onQuotedMessageClear,
this.attachmentThumbnailBuilders, this.attachmentThumbnailBuilders,
}); });
@@ -100,20 +106,19 @@ class _QuotedMessage extends StatelessWidget {
final StreamMessageThemeData messageTheme; final StreamMessageThemeData messageTheme;
final bool showBorder; final bool showBorder;
final bool reverse; final bool reverse;
final Widget Function(BuildContext, Message)? textBuilder;
/// Map that defines a thumbnail builder for an attachment type final _Builders? attachmentThumbnailBuilders;
final Map<String, QuotedMessageAttachmentThumbnailBuilder>?
attachmentThumbnailBuilders;
bool get _hasAttachments => message.attachments.isNotEmpty; bool get _hasAttachments => message.attachments.isNotEmpty;
bool get _containsText => message.text?.isNotEmpty == true; bool get _containsText => message.text?.isNotEmpty == true;
bool get _containsLinkAttachment => bool get _containsLinkAttachment =>
message.attachments.any((element) => element.titleLink != null); message.attachments.any((it) => it.type == AttachmentType.urlPreview);
bool get _isGiphy => bool get _isGiphy => message.attachments
message.attachments.any((element) => element.type == 'giphy'); .any((element) => element.type == AttachmentType.giphy);
bool get _isDeleted => message.isDeleted || message.deletedAt != null; bool get _isDeleted => message.isDeleted || message.deletedAt != null;
@@ -142,14 +147,6 @@ class _QuotedMessage extends StatelessWidget {
} else { } else {
// Show quoted message // Show quoted message
children = [ children = [
if (onQuotedMessageClear != null)
PlatformWidgetBuilder(
web: (context, child) => child,
desktop: (context, child) => child,
child: ClearInputItemButton(
onTap: onQuotedMessageClear,
),
),
if (_hasAttachments) if (_hasAttachments)
_ParseAttachments( _ParseAttachments(
message: message, message: message,
@@ -158,24 +155,38 @@ class _QuotedMessage extends StatelessWidget {
), ),
if (msg.text!.isNotEmpty && !_isGiphy) if (msg.text!.isNotEmpty && !_isGiphy)
Flexible( Flexible(
child: StreamMessageText( child: textBuilder?.call(context, msg) ??
message: msg, StreamMessageText(
messageTheme: isOnlyEmoji && _containsText message: msg,
? messageTheme.copyWith( messageTheme: isOnlyEmoji && _containsText
messageTextStyle: messageTheme.messageTextStyle?.copyWith( ? messageTheme.copyWith(
fontSize: 32, messageTextStyle:
), messageTheme.messageTextStyle?.copyWith(
) fontSize: 32,
: messageTheme.copyWith( ),
messageTextStyle: messageTheme.messageTextStyle?.copyWith( )
fontSize: 12, : 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( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: _getBackgroundColor(context), color: _getBackgroundColor(context),
@@ -218,192 +229,106 @@ class _ParseAttachments extends StatelessWidget {
final Message message; final Message message;
final StreamMessageThemeData messageTheme; final StreamMessageThemeData messageTheme;
final Map<String, QuotedMessageAttachmentThumbnailBuilder>? final _Builders? attachmentThumbnailBuilders;
attachmentThumbnailBuilders;
bool get _containsLinkAttachment =>
message.attachments.any((element) => element.titleLink != null);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget child; final attachment = message.attachments.first;
Attachment attachment;
if (_containsLinkAttachment) { var attachmentBuilders = attachmentThumbnailBuilders;
attachment = message.attachments.firstWhere( attachmentBuilders ??= _createDefaultAttachmentBuilders();
(element) => element.ogScrapeUrl != null || element.titleLink != null,
// Build the attachment widget using the builder for the attachment type.
final attachmentWidget = attachmentBuilders[attachment.type]?.call(
context,
attachment,
);
// Return empty container if no attachment widget is returned.
if (attachmentWidget == null) return const SizedBox.shrink();
final colorTheme = StreamChatTheme.of(context).colorTheme;
var clipBehavior = Clip.none;
ShapeDecoration? decoration;
if (attachment.type != AttachmentType.file) {
clipBehavior = Clip.hardEdge;
decoration = ShapeDecoration(
shape: RoundedRectangleBorder(
side: BorderSide(
color: colorTheme.borders,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(8),
),
); );
child = _UrlAttachment(attachment: attachment);
} else {
QuotedMessageAttachmentThumbnailBuilder? attachmentBuilder;
attachment = message.attachments.last;
if (attachmentThumbnailBuilders?.containsKey(attachment.type) == true) {
attachmentBuilder = attachmentThumbnailBuilders![attachment.type];
}
attachmentBuilder = _defaultAttachmentBuilder[attachment.type];
if (attachmentBuilder == null) {
child = const Offstage();
} else {
child = attachmentBuilder(context, attachment);
}
} }
final isImageFile = attachment.title?.mimeType?.type == 'image'; return Container(
final isVideoFile = attachment.title?.mimeType?.type == 'video'; key: Key(attachment.id),
clipBehavior: clipBehavior,
decoration: decoration,
constraints: const BoxConstraints.tightFor(width: 36, height: 36),
child: AbsorbPointer(child: attachmentWidget),
);
}
return Material( _Builders _createDefaultAttachmentBuilders() {
clipBehavior: Clip.hardEdge, Widget _createMediaThumbnail(BuildContext context, Attachment media) {
type: MaterialType.transparency, return StreamImageAttachmentThumbnail(
shape: attachment.type == 'file' && (!isImageFile && !isVideoFile) image: media,
? null width: double.infinity,
: RoundedRectangleBorder( height: double.infinity,
side: const BorderSide(width: 0, color: Colors.transparent), fit: BoxFit.cover,
);
}
Widget _createUrlThumbnail(BuildContext context, Attachment media) {
return StreamImageAttachmentThumbnail(
image: media,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
);
}
Widget _createFileThumbnail(BuildContext context, Attachment file) {
Widget thumbnail = StreamFileAttachmentThumbnail(
file: file,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
);
final mediaType = file.title?.mediaType;
final isImage = mediaType?.type == AttachmentType.image;
final isVideo = mediaType?.type == AttachmentType.video;
if (isImage || isVideo) {
final colorTheme = StreamChatTheme.of(context).colorTheme;
thumbnail = Container(
clipBehavior: Clip.hardEdge,
decoration: ShapeDecoration(
shape: RoundedRectangleBorder(
side: BorderSide(
color: colorTheme.borders,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: AbsorbPointer(child: child),
);
}
Map<String, QuotedMessageAttachmentThumbnailBuilder>
get _defaultAttachmentBuilder {
final builders = <String, QuotedMessageAttachmentThumbnailBuilder>{
'image': (_, attachment) {
return StreamImageAttachment(
attachment: attachment,
message: message,
messageTheme: messageTheme,
constraints: BoxConstraints.loose(const Size(32, 32)),
);
},
'video': (_, attachment) {
return StreamVideoThumbnailImage(
key: ValueKey(attachment.assetUrl),
video: attachment.file?.path ?? attachment.assetUrl,
constraints: BoxConstraints.loose(const Size(32, 32)),
errorBuilder: (_, __) => AttachmentError(
constraints: BoxConstraints.loose(const Size(32, 32)),
), ),
child: thumbnail,
); );
}, }
'giphy': (_, attachment) {
const size = Size(32, 32);
return CachedNetworkImage(
height: size.height,
width: size.width,
placeholder: (_, __) {
return SizedBox(
width: size.width,
height: size.height,
child: const Center(
child: CircularProgressIndicator.adaptive(),
),
);
},
imageUrl: attachment.thumbUrl ??
attachment.imageUrl ??
attachment.assetUrl!,
errorWidget: (context, url, error) =>
AttachmentError(constraints: BoxConstraints.loose(size)),
fit: BoxFit.cover,
);
},
};
builders['file'] = (_, attachment) { return thumbnail;
return SizedBox(
height: 32,
width: 32,
child: Builder(
builder: (context) {
final isImageFile = attachment.title?.mimeType?.type == 'image';
if (isImageFile) {
return builders['image']!(context, attachment);
}
final isVideoFile = attachment.title?.mimeType?.type == 'video';
if (isVideoFile) {
return builders['video']!(context, attachment);
}
return getFileTypeImage(
attachment.extraData['mime_type'] as String?,
);
},
),
);
};
return builders;
}
}
class _UrlAttachment extends StatelessWidget {
const _UrlAttachment({
required this.attachment,
});
final Attachment attachment;
@override
Widget build(BuildContext context) {
const size = Size(32, 32);
if (attachment.thumbUrl != null) {
return Container(
height: size.height,
width: size.width,
decoration: BoxDecoration(
image: DecorationImage(
fit: BoxFit.cover,
image: CachedNetworkImageProvider(
attachment.thumbUrl!,
),
),
),
);
} }
return AttachmentError(constraints: BoxConstraints.loose(size));
}
}
class _VideoAttachmentThumbnail extends StatefulWidget { return {
const _VideoAttachmentThumbnail({ AttachmentType.image: _createMediaThumbnail,
required this.attachment, AttachmentType.giphy: _createMediaThumbnail,
}); AttachmentType.video: _createMediaThumbnail,
AttachmentType.urlPreview: _createUrlThumbnail,
final Attachment attachment; AttachmentType.file: _createFileThumbnail,
};
@override
_VideoAttachmentThumbnailState createState() =>
_VideoAttachmentThumbnailState();
}
class _VideoAttachmentThumbnailState extends State<_VideoAttachmentThumbnail> {
late VideoPlayerController _controller;
@override
void initState() {
super.initState();
_controller = VideoPlayerController.networkUrl(
Uri.parse(widget.attachment.assetUrl!),
)..initialize().then((_) {
// ignore: no-empty-block
setState(() {}); //when your thumbnail will show.
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
height: 32,
width: 32,
child: _controller.value.isInitialized
? VideoPlayer(_controller)
: const CircularProgressIndicator.adaptive(),
);
} }
} }
@@ -1,5 +1,3 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
@@ -1,5 +1,3 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'dart:async'; import 'dart:async';
import 'dart:math'; import 'dart:math';
@@ -110,8 +108,6 @@ class StreamMessageInput extends StatefulWidget {
this.mediaAttachmentListBuilder, this.mediaAttachmentListBuilder,
this.fileAttachmentBuilder, this.fileAttachmentBuilder,
this.mediaAttachmentBuilder, this.mediaAttachmentBuilder,
@Deprecated('Use `mediaAttachmentBuilder` instead.')
this.attachmentThumbnailBuilders,
this.focusNode, this.focusNode,
this.sendButtonLocation = SendButtonLocation.outside, this.sendButtonLocation = SendButtonLocation.outside,
this.autofocus = false, this.autofocus = false,
@@ -235,10 +231,6 @@ class StreamMessageInput extends StatefulWidget {
/// Builder used to build the media attachment item. /// Builder used to build the media attachment item.
final AttachmentItemBuilder? mediaAttachmentBuilder; final AttachmentItemBuilder? mediaAttachmentBuilder;
/// Map that defines a thumbnail builder for an attachment type.
@Deprecated('Use `mediaAttachmentBuilder` instead.')
final Map<String, AttachmentThumbnailBuilder>? attachmentThumbnailBuilders;
/// Map that defines a thumbnail builder for an attachment type. /// Map that defines a thumbnail builder for an attachment type.
/// ///
/// This is used to build the thumbnail for the attachment in the quoted /// This is used to build the thumbnail for the attachment in the quoted
@@ -1178,7 +1170,7 @@ class StreamMessageInputState extends State<StreamMessageInput>
} }
final containsUrl = quotedMessage.attachments.any((it) { final containsUrl = quotedMessage.attachments.any((it) {
return it.titleLink != null; return it.type == AttachmentType.urlPreview;
}); });
return StreamQuotedMessageWidget( return StreamQuotedMessageWidget(
@@ -1221,44 +1213,7 @@ class StreamMessageInputState extends State<StreamMessageInput>
fileAttachmentListBuilder: widget.fileAttachmentListBuilder, fileAttachmentListBuilder: widget.fileAttachmentListBuilder,
mediaAttachmentListBuilder: widget.mediaAttachmentListBuilder, mediaAttachmentListBuilder: widget.mediaAttachmentListBuilder,
fileAttachmentBuilder: widget.fileAttachmentBuilder, fileAttachmentBuilder: widget.fileAttachmentBuilder,
mediaAttachmentBuilder: widget.mediaAttachmentBuilder ?? 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: <Widget>[
AspectRatio(
aspectRatio: 1,
child: mediaAttachmentThumbnail,
),
Positioned(
top: 8,
right: 8,
child: RemoveAttachmentButton(
onPressed: onRemovePressed != null
? () => onRemovePressed(attachment)
: null,
),
),
],
),
);
},
), ),
); );
} }
@@ -1362,8 +1317,6 @@ class StreamMessageInputState extends State<StreamMessageInput>
message = message.copyWith(text: '/${message.command} ${message.text}'); message = message.copyWith(text: '/${message.command} ${message.text}');
} }
final skipEnrichUrl = _effectiveController.ogAttachment == null;
var shouldKeepFocus = widget.shouldKeepFocusAfterMessage; var shouldKeepFocus = widget.shouldKeepFocusAfterMessage;
shouldKeepFocus ??= !_commandEnabled; shouldKeepFocus ??= !_commandEnabled;
@@ -1387,10 +1340,7 @@ class StreamMessageInputState extends State<StreamMessageInput>
await WidgetsBinding.instance.endOfFrame; await WidgetsBinding.instance.endOfFrame;
} }
await _sendOrUpdateMessage( await _sendOrUpdateMessage(message: message);
message: message,
skipEnrichUrl: skipEnrichUrl,
);
if (mounted) { if (mounted) {
if (shouldKeepFocus) { if (shouldKeepFocus) {
@@ -1403,36 +1353,29 @@ class StreamMessageInputState extends State<StreamMessageInput>
Future<void> _sendOrUpdateMessage({ Future<void> _sendOrUpdateMessage({
required Message message, required Message message,
bool skipEnrichUrl = false,
}) async { }) async {
final channel = StreamChannel.of(context).channel; final channel = StreamChannel.of(context).channel;
try { try {
Future sendingFuture; Future sendingFuture;
if (_isEditing) { if (_isEditing) {
sendingFuture = channel.updateMessage( sendingFuture = channel.updateMessage(message);
message,
skipEnrichUrl: skipEnrichUrl,
);
} else { } else {
sendingFuture = channel.sendMessage( sendingFuture = channel.sendMessage(message);
message,
skipEnrichUrl: skipEnrichUrl,
);
} }
final resp = await sendingFuture; final resp = await sendingFuture;
if (resp.message?.type == 'error') { if (resp.message?.isError ?? false) {
_effectiveController.message = message; _effectiveController.message = message;
} }
_startSlowMode(); _startSlowMode();
widget.onMessageSent?.call(resp.message); widget.onMessageSent?.call(resp.message);
} catch (e, stk) { } catch (e, stk) {
if (widget.onError != null) { if (widget.onError != null) {
widget.onError?.call(e, stk); return widget.onError?.call(e, stk);
} else {
rethrow;
} }
rethrow;
} }
} }
@@ -1,10 +1,9 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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/misc/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/theme/stream_chat_theme.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/utils/utils.dart';
import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// WidgetBuilder used to build the message input attachment list. /// WidgetBuilder used to build the message input attachment list.
@@ -91,7 +90,7 @@ class _StreamMessageInputAttachmentListState
// Split the attachments into file and media attachments. // Split the attachments into file and media attachments.
for (final attachment in widget.attachments) { for (final attachment in widget.attachments) {
if (attachment.type == 'file') { if (attachment.type == AttachmentType.file) {
fileAttachments.add(attachment); fileAttachments.add(attachment);
} else { } else {
mediaAttachments.add(attachment); mediaAttachments.add(attachment);
@@ -121,7 +120,7 @@ class _StreamMessageInputAttachmentListState
} }
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 6),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
@@ -201,23 +200,19 @@ class MessageInputFileAttachments extends StatelessWidget {
} }
// Otherwise, use the default builder. // Otherwise, use the default builder.
return ClipRRect( return StreamFileAttachment(
key: Key(attachment.id), message: Message(), // Dummy message
borderRadius: BorderRadius.circular(10), file: attachment,
child: StreamFileAttachment( constraints: BoxConstraints.loose(Size(
message: Message(), // dummy message MediaQuery.of(context).size.width * 0.65,
attachment: attachment, 56,
constraints: BoxConstraints.loose(Size( )),
MediaQuery.of(context).size.width * 0.65, trailing: Padding(
56, padding: const EdgeInsets.all(8),
)), child: RemoveAttachmentButton(
trailing: Padding( onPressed: onRemovePressed != null
padding: const EdgeInsets.all(8), ? () => onRemovePressed!(attachment)
child: RemoveAttachmentButton( : null,
onPressed: onRemovePressed != null
? () => onRemovePressed!(attachment)
: null,
),
), ),
), ),
); );
@@ -256,7 +251,8 @@ class MessageInputMediaAttachments extends StatelessWidget {
height: 104, height: 104,
child: ListView( child: ListView(
scrollDirection: Axis.horizontal, 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<Widget>( children: attachments.map<Widget>(
(attachment) { (attachment) {
// If a custom builder is provided, use it. // If a custom builder is provided, use it.
@@ -265,27 +261,47 @@ class MessageInputMediaAttachments extends StatelessWidget {
return builder(context, attachment, onRemovePressed); 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), key: Key(attachment.id),
borderRadius: BorderRadius.circular(10), clipBehavior: Clip.hardEdge,
child: Stack( decoration: ShapeDecoration(shape: shape),
children: <Widget>[ child: AspectRatio(
AspectRatio( aspectRatio: 1,
aspectRatio: 1, child: Stack(
child: MessageInputMediaAttachmentThumbnail( alignment: Alignment.center,
attachment: attachment, children: <Widget>[
StreamMediaAttachmentThumbnail(
media: attachment,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
), ),
), if (attachment.type == AttachmentType.video)
Positioned( Positioned(
top: 8, left: 8,
right: 8, bottom: 8,
child: RemoveAttachmentButton( child: StreamSvgIcon.videoCall(),
onPressed: onRemovePressed != null ),
? () => onRemovePressed!(attachment) Positioned(
: null, 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. /// Material Button used for removing attachments.
class RemoveAttachmentButton extends StatelessWidget { class RemoveAttachmentButton extends StatelessWidget {
/// Creates a new remove attachment button. /// Creates a new remove attachment button.
@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/scrollable_positioned_list/scrollable_positioned_list.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'; import 'package:stream_chat_flutter/src/message_list_view/mlv_utils.dart';
@@ -22,7 +23,7 @@ class FloatingDateDivider extends StatelessWidget {
final bool isThreadConversation; final bool isThreadConversation;
// ignore: public_member_api_docs // ignore: public_member_api_docs
final ItemPositionsListener itemPositionListener; final ValueListenable<Iterable<ItemPosition>> itemPositionListener;
// ignore: public_member_api_docs // ignore: public_member_api_docs
final bool reverse; final bool reverse;
@@ -38,61 +39,38 @@ class FloatingDateDivider extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Positioned( return ValueListenableBuilder(
top: 20, valueListenable: itemPositionListener,
left: 0, builder: (context, positions, child) {
right: 0, if (positions.isEmpty || messages.isEmpty) {
child: BetterStreamBuilder<Iterable<ItemPosition>>( return const Offstage();
initialData: itemPositionListener.itemPositions.value, }
stream: valueListenableToStreamAdapter(
itemPositionListener.itemPositions, int? index;
), if (reverse) {
comparator: (a, b) { index = getTopElementIndex(positions);
if (a == null || b == null) { } else {
return false; index = getBottomElementIndex(positions);
} }
if ((index == null) ||
(!isThreadConversation && index == itemCount - 2) ||
(isThreadConversation && index == itemCount - 1)) {
return const Offstage();
}
if (index <= 2 || index >= itemCount - 3) {
if (reverse) { if (reverse) {
final aTop = getTopElementIndex(a); index = itemCount - 4;
final bTop = getTopElementIndex(b);
return aTop == bTop;
} else { } else {
final aBottom = getBottomElementIndex(a); index = 2;
final bBottom = getBottomElementIndex(b);
return aBottom == bBottom;
}
},
builder: (context, values) {
if (values.isEmpty || messages.isEmpty) {
return const Offstage();
} }
}
int? index; final message = messages[index - 2];
if (reverse) { return dateDividerBuilder?.call(message.createdAt.toLocal()) ??
index = getTopElementIndex(values); StreamDateDivider(dateTime: message.createdAt.toLocal());
} else { },
index = getBottomElementIndex(values);
}
if ((index == null) ||
(!isThreadConversation && index == itemCount - 2) ||
(isThreadConversation && index == itemCount - 1)) {
return const Offstage();
}
if (index <= 2 || index >= itemCount - 3) {
if (reverse) {
index = itemCount - 4;
} else {
index = 2;
}
}
final message = messages[index - 2];
return dateDividerBuilder != null
? dateDividerBuilder!(message.createdAt.toLocal())
: StreamDateDivider(dateTime: message.createdAt.toLocal());
},
),
); );
} }
} }
@@ -1,7 +1,5 @@
// ignore_for_file: lines_longer_than_80_chars // ignore_for_file: lines_longer_than_80_chars
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'dart:ui';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.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/mlv_utils.dart';
import 'package:stream_chat_flutter/src/message_list_view/thread_separator.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_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'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Spacing Types (These are properties of a message to help inform the decision /// Spacing Types (These are properties of a message to help inform the decision
@@ -97,11 +96,6 @@ class StreamMessageListView extends StatefulWidget {
this.initialAlignment, this.initialAlignment,
this.scrollController, this.scrollController,
this.itemPositionListener, 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.highlightInitialMessage = false,
this.messageHighlightColor, this.messageHighlightColor,
this.showConnectionStateTile = false, this.showConnectionStateTile = false,
@@ -110,6 +104,7 @@ class StreamMessageListView extends StatefulWidget {
this.loadingBuilder, this.loadingBuilder,
this.emptyBuilder, this.emptyBuilder,
this.systemMessageBuilder, this.systemMessageBuilder,
this.ephemeralMessageBuilder,
this.messageListBuilder, this.messageListBuilder,
this.errorBuilder, this.errorBuilder,
this.messageFilter, this.messageFilter,
@@ -155,6 +150,9 @@ class StreamMessageListView extends StatefulWidget {
/// {@macro systemMessageBuilder} /// {@macro systemMessageBuilder}
final SystemMessageBuilder? systemMessageBuilder; final SystemMessageBuilder? systemMessageBuilder;
/// {@macro ephemeralMessageBuilder}
final EphemeralMessageBuilder? ephemeralMessageBuilder;
/// {@macro parentMessageBuilder} /// {@macro parentMessageBuilder}
final ParentMessageBuilder? parentMessageBuilder; final ParentMessageBuilder? parentMessageBuilder;
@@ -215,9 +213,6 @@ class StreamMessageListView extends StatefulWidget {
/// The ScrollPhysics used by the ListView /// The ScrollPhysics used by the ListView
final ScrollPhysics? scrollPhysics; final ScrollPhysics? scrollPhysics;
/// {@macro onMessageSwiped}
final OnMessageSwiped? onMessageSwiped;
/// If true the list will highlight the initialMessage if there is any. /// If true the list will highlight the initialMessage if there is any.
/// ///
/// Also See [StreamChannel] /// Also See [StreamChannel]
@@ -815,13 +810,18 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
), ),
), ),
if (widget.showFloatingDateDivider) if (widget.showFloatingDateDivider)
FloatingDateDivider( Positioned(
itemCount: itemCount, top: 20,
reverse: widget.reverse, left: 0,
itemPositionListener: _itemPositionListener, right: 0,
messages: messages, child: FloatingDateDivider(
dateDividerBuilder: widget.dateDividerBuilder, itemCount: itemCount,
isThreadConversation: _isThreadConversation, reverse: widget.reverse,
itemPositionListener: _itemPositionListener.itemPositions,
messages: messages,
dateDividerBuilder: widget.dateDividerBuilder,
isThreadConversation: _isThreadConversation,
),
), ),
], ],
); );
@@ -924,13 +924,19 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
final currentUserMember = final currentUserMember =
members.firstWhereOrNull((e) => e.user!.id == currentUser!.id); members.firstWhereOrNull((e) => e.user!.id == currentUser!.id);
final hasFileAttachment =
message.attachments.any((it) => it.type == AttachmentType.file);
final hasUrlAttachment = 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 = final borderSide = isOnlyEmoji ? BorderSide.none : null;
isOnlyEmoji || hasUrlAttachment || isEphemeral ? BorderSide.none : null;
final defaultMessageWidget = StreamMessageWidget( final defaultMessageWidget = StreamMessageWidget(
showReplyMessage: false, showReplyMessage: false,
@@ -944,13 +950,34 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
showUsername: !isMyMessage, showUsername: !isMyMessage,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
showSendingIndicator: false, 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( borderRadiusGeometry: BorderRadius.only(
topLeft: const Radius.circular(16), topLeft: const Radius.circular(16),
bottomLeft: bottomLeft: isMyMessage ? const Radius.circular(16) : Radius.zero,
isMyMessage ? const Radius.circular(16) : const Radius.circular(2),
topRight: const Radius.circular(16), topRight: const Radius.circular(16),
bottomRight: bottomRight: isMyMessage ? Radius.zero : const Radius.circular(16),
isMyMessage ? const Radius.circular(2) : const Radius.circular(16),
), ),
textPadding: EdgeInsets.symmetric( textPadding: EdgeInsets.symmetric(
vertical: 8, vertical: 8,
@@ -1057,7 +1084,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
} }
Widget buildMessage(Message message, List<Message> messages, int index) { Widget buildMessage(Message message, List<Message> messages, int index) {
if ((message.type == 'system' || message.type == 'error') && if ((message.isSystem || message.isError) &&
message.text?.isNotEmpty == true) { message.text?.isNotEmpty == true) {
return widget.systemMessageBuilder?.call(context, message) ?? return widget.systemMessageBuilder?.call(context, message) ??
StreamSystemMessage( StreamSystemMessage(
@@ -1069,6 +1096,11 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
); );
} }
if (message.isEphemeral) {
return widget.ephemeralMessageBuilder?.call(context, message) ??
StreamEphemeralMessage(message: message);
}
final userId = StreamChat.of(context).currentUser!.id; final userId = StreamChat.of(context).currentUser!.id;
final isMyMessage = message.user?.id == userId; final isMyMessage = message.user?.id == userId;
final nextMessage = index - 1 >= 0 ? messages[index - 1] : null; final nextMessage = index - 1 >= 0 ? messages[index - 1] : null;
@@ -1086,14 +1118,21 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
} }
final hasFileAttachment = 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 = final isThreadMessage =
message.parentId != null && message.showInChannel == true; message.parentId != null && message.showInChannel == true;
final hasReplies = message.replyCount! > 0; 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) && final showTimeStamp = (!isThreadMessage || _isThreadConversation) &&
!hasReplies && !hasReplies &&
@@ -1117,13 +1156,7 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
final showThreadReplyIndicator = !_isThreadConversation && hasReplies; final showThreadReplyIndicator = !_isThreadConversation && hasReplies;
final isOnlyEmoji = message.text?.isOnlyEmoji ?? false; final isOnlyEmoji = message.text?.isOnlyEmoji ?? false;
final isEphemeral = message.isEphemeral; final borderSide = isOnlyEmoji ? BorderSide.none : null;
final hasUrlAttachment =
message.attachments.any((it) => it.ogScrapeUrl != null);
final borderSide =
isOnlyEmoji || hasUrlAttachment || isEphemeral ? BorderSide.none : null;
final currentUser = StreamChat.of(context).currentUser; final currentUser = StreamChat.of(context).currentUser;
final members = StreamChannel.of(context).channel.state?.members ?? []; final members = StreamChannel.of(context).channel.state?.members ?? [];
@@ -1168,27 +1201,39 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
showFlagButton: !isMyMessage, showFlagButton: !isMyMessage,
borderSide: borderSide, borderSide: borderSide,
onThreadTap: _onThreadTap, onThreadTap: _onThreadTap,
attachmentBorderRadiusGeometry: BorderRadius.only( attachmentShape: RoundedRectangleBorder(
topLeft: Radius.circular(attachmentBorderRadius), side: BorderSide(
bottomLeft: isMyMessage color: _streamTheme.colorTheme.borders,
? Radius.circular(attachmentBorderRadius) strokeAlign: BorderSide.strokeAlignOutside,
: Radius.circular( ),
(hasTimeDiff || !isNextUserSame) && borderRadius: BorderRadius.only(
!(hasReplies || isThreadMessage || hasFileAttachment) topLeft: Radius.circular(attachmentBorderRadius),
? 0 bottomLeft: isMyMessage
: attachmentBorderRadius, ? Radius.circular(attachmentBorderRadius)
), : Radius.circular(
topRight: Radius.circular(attachmentBorderRadius), (hasTimeDiff || !isNextUserSame) &&
bottomRight: isMyMessage !(hasReplies || isThreadMessage || hasFileAttachment)
? Radius.circular( ? 0
(hasTimeDiff || !isNextUserSame) && : attachmentBorderRadius,
!(hasReplies || isThreadMessage || hasFileAttachment) ),
? 0 topRight: Radius.circular(attachmentBorderRadius),
: attachmentBorderRadius, bottomRight: isMyMessage
) ? Radius.circular(
: Radius.circular(attachmentBorderRadius), (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( borderRadiusGeometry: BorderRadius.only(
topLeft: const Radius.circular(16), topLeft: const Radius.circular(16),
bottomLeft: isMyMessage bottomLeft: isMyMessage
@@ -1263,76 +1308,6 @@ class _StreamMessageListViewState extends State<StreamMessageListView> {
); );
} }
// 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; return child;
} }
@@ -1,7 +1,4 @@
import 'dart:async';
import 'package:collection/collection.dart'; 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/scrollable_positioned_list/scrollable_positioned_list.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
@@ -74,30 +71,3 @@ int? getBottomElementIndex(Iterable<ItemPosition> values) {
bool isInitialMessage(String id, StreamChannelState? channelState) { bool isInitialMessage(String id, StreamChannelState? channelState) {
return channelState!.initialMessageId == id; return channelState!.initialMessageId == id;
} }
/// Converts a [ValueListenable] to a [Stream].
Stream<T> valueListenableToStreamAdapter<T>(ValueListenable<T> listenable) {
// ignore: close_sinks
late StreamController<T> _controller;
void listener() {
_controller.add(listenable.value);
}
void start() {
listenable.addListener(listener);
}
void end() {
listenable.removeListener(listener);
}
_controller = StreamController<T>(
onListen: start,
onPause: end,
onResume: start,
onCancel: end,
);
return _controller.stream;
}
@@ -24,7 +24,7 @@ class UnreadMessagesSeparator extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: Text( child: Text(
context.translations.unreadMessagesSeparatorText(unreadCount), context.translations.unreadMessagesSeparatorText(),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: StreamChannelHeaderTheme.of(context).subtitleStyle, style: StreamChannelHeaderTheme.of(context).subtitleStyle,
), ),
@@ -201,8 +201,8 @@ class BottomRow extends StatelessWidget {
), ),
]; ];
final showThreadTail = !(hasUrlAttachments || isGiphy || isOnlyEmoji) && final showThreadTail =
(showThreadReplyIndicator || showInChannel); (showThreadReplyIndicator || showInChannel) && !isOnlyEmoji;
final threadIndicatorWidgets = [ final threadIndicatorWidgets = [
if (showThreadTail) if (showThreadTail)
@@ -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();
}
}
@@ -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),
),
),
),
],
);
}
}
@@ -1,6 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart'; 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'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template messageCard} /// {@template messageCard}
@@ -23,6 +22,11 @@ class MessageCard extends StatefulWidget {
required this.isGiphy, required this.isGiphy,
required this.attachmentBuilders, required this.attachmentBuilders,
required this.attachmentPadding, required this.attachmentPadding,
required this.attachmentShape,
required this.onAttachmentTap,
required this.onShowMessage,
required this.onReplyTap,
required this.attachmentActionsModalBuilder,
required this.textPadding, required this.textPadding,
required this.reverse, required this.reverse,
this.shape, this.shape,
@@ -72,11 +76,26 @@ class MessageCard extends StatefulWidget {
final Message message; final Message message;
/// {@macro attachmentBuilders} /// {@macro attachmentBuilders}
final Map<String, AttachmentBuilder> attachmentBuilders; final List<StreamAttachmentWidgetBuilder>? attachmentBuilders;
/// {@macro attachmentPadding} /// {@macro attachmentPadding}
final EdgeInsetsGeometry 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} /// {@macro textPadding}
final EdgeInsets textPadding; final EdgeInsets textPadding;
@@ -103,32 +122,36 @@ class MessageCard extends StatefulWidget {
} }
class _MessageCardState extends State<MessageCard> { class _MessageCardState extends State<MessageCard> {
final GlobalKey attachmentsKey = GlobalKey(); final attachmentsKey = GlobalKey();
final GlobalKey linksKey = GlobalKey();
double? widthLimit; 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 @override
void initState() { void didChangeDependencies() {
WidgetsBinding.instance.addPostFrameCallback((_) { super.didChangeDependencies();
final attachmentsRenderBox = // If there is an attachment, we need to wait for the attachment to be
attachmentsKey.currentContext?.findRenderObject() as RenderBox?; // rendered to get the width of the attachment and set it as the width
final attachmentsWidth = attachmentsRenderBox?.size.width; // limit of the message card.
if (hasAttachments) {
final linkRenderBox = WidgetsBinding.instance.addPostFrameCallback((_) {
linksKey.currentContext?.findRenderObject() as RenderBox?; _updateWidthLimit();
final linkWidth = linkRenderBox?.size.width; });
}
if (mounted) {
setState(() {
if (attachmentsWidth != null && linkWidth != null) {
widthLimit = max(attachmentsWidth, linkWidth);
} else {
widthLimit = attachmentsWidth ?? linkWidth;
}
});
}
});
super.initState();
} }
@override @override
@@ -136,95 +159,71 @@ class _MessageCardState extends State<MessageCard> {
final onQuotedMessageTap = widget.onQuotedMessageTap; final onQuotedMessageTap = widget.onQuotedMessageTap;
final quotedMessageBuilder = widget.quotedMessageBuilder; final quotedMessageBuilder = widget.quotedMessageBuilder;
return Card( return Container(
elevation: 0, constraints: const BoxConstraints().copyWith(maxWidth: widthLimit),
clipBehavior: Clip.hardEdge,
margin: EdgeInsets.symmetric( margin: EdgeInsets.symmetric(
horizontal: (widget.isFailedState ? 15.0 : 0.0) + horizontal: (widget.isFailedState ? 15.0 : 0.0) +
(widget.showUserAvatar == DisplayWidget.gone ? 0 : 4.0), (widget.showUserAvatar == DisplayWidget.gone ? 0 : 4.0),
), ),
shape: widget.shape ?? clipBehavior: Clip.hardEdge,
RoundedRectangleBorder( decoration: ShapeDecoration(
side: widget.borderSide ?? color: _getBackgroundColor(),
BorderSide( shape: widget.shape ??
color: widget.messageTheme.messageBorderColor ?? RoundedRectangleBorder(
Colors.transparent, side: widget.borderSide ??
), BorderSide(
borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero, color: widget.messageTheme.messageBorderColor ??
Colors.transparent,
),
borderRadius: widget.borderRadiusGeometry ?? BorderRadius.zero,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.hasQuotedMessage)
InkWell(
onTap: !widget.message.quotedMessage!.isDeleted &&
onQuotedMessageTap != null
? () => onQuotedMessageTap(widget.message.quotedMessageId)
: null,
child: quotedMessageBuilder?.call(
context,
widget.message.quotedMessage!,
) ??
QuotedMessage(
message: widget.message,
textBuilder: widget.textBuilder,
hasNonUrlAttachments: widget.hasNonUrlAttachments,
),
),
if (hasAttachments)
ParseAttachments(
key: attachmentsKey,
message: widget.message,
attachmentBuilders: widget.attachmentBuilders,
attachmentPadding: widget.attachmentPadding,
attachmentShape: widget.attachmentShape,
onAttachmentTap: widget.onAttachmentTap,
onShowMessage: widget.onShowMessage,
onReplyTap: widget.onReplyTap,
attachmentActionsModalBuilder:
widget.attachmentActionsModalBuilder,
),
TextBubble(
messageTheme: widget.messageTheme,
message: widget.message,
textPadding: widget.textPadding,
textBuilder: widget.textBuilder,
isOnlyEmoji: widget.isOnlyEmoji,
hasQuotedMessage: widget.hasQuotedMessage,
hasUrlAttachments: widget.hasUrlAttachments,
onLinkTap: widget.onLinkTap,
onMentionTap: widget.onMentionTap,
), ),
color: _getBackgroundColor(), ],
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: widthLimit ?? double.infinity,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.hasQuotedMessage)
MouseRegion(
cursor: SystemMouseCursors.click,
child: InkWell(
onTap: !widget.message.quotedMessage!.isDeleted &&
onQuotedMessageTap != null
? () => onQuotedMessageTap(widget.message.quotedMessageId)
: null,
child: quotedMessageBuilder?.call(
context,
widget.message.quotedMessage!,
) ??
QuotedMessage(
reverse: widget.reverse,
message: widget.message,
hasNonUrlAttachments: widget.hasNonUrlAttachments,
),
),
),
if (widget.hasNonUrlAttachments)
ParseAttachments(
key: attachmentsKey,
message: widget.message,
attachmentBuilders: widget.attachmentBuilders,
attachmentPadding: widget.attachmentPadding,
),
if (!widget.isGiphy)
TextBubble(
messageTheme: widget.messageTheme,
message: widget.message,
textPadding: widget.textPadding,
textBuilder: widget.textBuilder,
isOnlyEmoji: widget.isOnlyEmoji,
hasQuotedMessage: widget.hasQuotedMessage,
hasUrlAttachments: widget.hasUrlAttachments,
onLinkTap: widget.onLinkTap,
onMentionTap: widget.onMentionTap,
),
if (widget.hasUrlAttachments && !widget.hasQuotedMessage)
_buildUrlAttachment(),
],
),
), ),
);
}
Widget _buildUrlAttachment() {
final urlAttachment = widget.message.attachments
.firstWhere((element) => element.titleLink != null);
final host = Uri.parse(urlAttachment.titleLink!).host;
final splitList = host.split('.');
final hostName = splitList.length == 3 ? splitList[1] : splitList[0];
final hostDisplayName = urlAttachment.authorName?.capitalize() ??
getWebsiteName(hostName.toLowerCase()) ??
hostName.capitalize();
return StreamUrlAttachment(
key: linksKey,
onLinkTap: widget.onLinkTap,
urlAttachment: urlAttachment,
hostDisplayName: hostDisplayName,
textPadding: widget.textPadding,
messageTheme: widget.messageTheme,
); );
} }
@@ -233,7 +232,10 @@ class _MessageCardState extends State<MessageCard> {
return widget.messageTheme.messageBackgroundColor; return widget.messageTheme.messageBackgroundColor;
} }
if (widget.hasUrlAttachments) { final containsOnlyUrlAttachment =
widget.hasUrlAttachments && !widget.hasNonUrlAttachments;
if (containsOnlyUrlAttachment) {
return widget.messageTheme.urlAttachmentBackgroundColor; return widget.messageTheme.urlAttachmentBackgroundColor;
} }
@@ -241,10 +243,6 @@ class _MessageCardState extends State<MessageCard> {
return Colors.transparent; return Colors.transparent;
} }
if (widget.isGiphy) {
return Colors.transparent;
}
return widget.messageTheme.messageBackgroundColor; return widget.messageTheme.messageBackgroundColor;
} }
} }
@@ -5,6 +5,7 @@ import 'package:flutter_portal/flutter_portal.dart';
import 'package:meta/meta.dart'; import 'package:meta/meta.dart';
import 'package:stream_chat_flutter/conditional_parent_builder/conditional_parent_builder.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/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/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/context_menu_items/stream_chat_context_menu_item.dart';
import 'package:stream_chat_flutter/src/dialogs/dialogs.dart'; import 'package:stream_chat_flutter/src/dialogs/dialogs.dart';
@@ -41,28 +42,22 @@ enum DisplayWidget {
/// {@endtemplate} /// {@endtemplate}
class StreamMessageWidget extends StatefulWidget { class StreamMessageWidget extends StatefulWidget {
/// {@macro messageWidget} /// {@macro messageWidget}
StreamMessageWidget({ const StreamMessageWidget({
super.key, super.key,
required this.message, required this.message,
required this.messageTheme, required this.messageTheme,
this.reverse = false, this.reverse = false,
this.translateUserAvatar = true, this.translateUserAvatar = true,
this.shape, this.shape,
this.attachmentShape,
this.borderSide, this.borderSide,
this.attachmentBorderSide,
this.borderRadiusGeometry, this.borderRadiusGeometry,
this.attachmentBorderRadiusGeometry, this.attachmentShape,
this.onMentionTap, this.onMentionTap,
this.onMessageTap, this.onMessageTap,
this.onReactionsTap, this.onReactionsTap,
this.onReactionsHover, this.onReactionsHover,
bool? showReactionPicker, this.showReactionPicker = true,
@Deprecated('Use `showReactionPicker` instead') @internal this.showReactionPickerTail = false,
bool showReactionPickerIndicator = true,
@internal
@Deprecated('Use `showReactionPicker` instead')
this.showReactionPickerTail,
this.showUserAvatar = DisplayWidget.show, this.showUserAvatar = DisplayWidget.show,
this.showSendingIndicator = true, this.showSendingIndicator = true,
this.showThreadReplyIndicator = false, this.showThreadReplyIndicator = false,
@@ -90,16 +85,8 @@ class StreamMessageWidget extends StatefulWidget {
this.quotedMessageBuilder, this.quotedMessageBuilder,
this.editMessageInputBuilder, this.editMessageInputBuilder,
this.textBuilder, this.textBuilder,
@Deprecated('''
Use [bottomRowBuilderWithDefaultWidget] instead.
Will be removed in the next major version.
''') this.bottomRowBuilder,
this.bottomRowBuilderWithDefaultWidget, this.bottomRowBuilderWithDefaultWidget,
@Deprecated(''' this.attachmentBuilders,
Use [bottomRowBuilderWithDefaultWidget] instead.
Will be removed in the next major version.
''') this.deletedBottomRowBuilder,
this.customAttachmentBuilders,
this.padding, this.padding,
this.textPadding = const EdgeInsets.symmetric( this.textPadding = const EdgeInsets.symmetric(
horizontal: 16, horizontal: 16,
@@ -110,198 +97,161 @@ class StreamMessageWidget extends StatefulWidget {
this.onQuotedMessageTap, this.onQuotedMessageTap,
this.customActions = const [], this.customActions = const [],
this.onAttachmentTap, this.onAttachmentTap,
@Deprecated('''
Use [bottomRowBuilderWithDefaultWidget] instead.
Will be removed in the next major version.
''') this.usernameBuilder,
this.imageAttachmentThumbnailSize = const Size(400, 400), this.imageAttachmentThumbnailSize = const Size(400, 400),
this.imageAttachmentThumbnailResizeType = 'clip', this.imageAttachmentThumbnailResizeType = 'clip',
this.imageAttachmentThumbnailCropType = 'center', this.imageAttachmentThumbnailCropType = 'center',
this.attachmentActionsModalBuilder, 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); // attachmentBuilders = {
if (attachments.length > 1) { // // Add all default builders
return Padding( // 'image': (context, message, attachments) {
padding: attachmentPadding, // final color = StreamChatTheme.of(context).colorTheme.borders;
child: WrapAttachmentWidget( // final border = RoundedRectangleBorder(
attachmentWidget: Material( // side: attachmentBorderSide ?? BorderSide(color: color),
color: messageTheme.messageBackgroundColor, // borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero,
child: StreamImageGroup( // );
constraints: BoxConstraints( //
maxWidth: 400, // if (attachments.length > 1) {
minWidth: 400, // return WrapAttachmentWidget(
maxHeight: mediaQueryData.size.height * 0.3, // attachmentShape: border,
), // attachmentWidget: Material(
images: attachments, // color: messageTheme.messageBackgroundColor,
message: message, // child: StreamGalleryAttachment(
messageTheme: messageTheme, // constraints: const BoxConstraints.tightFor(
onShowMessage: onShowMessage, // width: 256,
onReplyMessage: onReplyTap, // height: 195,
onAttachmentTap: onAttachmentTap, // ),
imageThumbnailSize: imageAttachmentThumbnailSize, // attachments: attachments,
imageThumbnailResizeType: // message: message,
imageAttachmentThumbnailResizeType, // itemBuilder: (context, index) {
imageThumbnailCropType: imageAttachmentThumbnailCropType, // return Placeholder();
attachmentActionsModalBuilder: // },
attachmentActionsModalBuilder, // // onShowMessage: onShowMessage,
), // // onReplyMessage: onReplyTap,
), // // onAttachmentTap: onAttachmentTap,
attachmentShape: border, // // imageThumbnailSize: imageAttachmentThumbnailSize,
), // // imageThumbnailResizeType:
); // // imageAttachmentThumbnailResizeType,
} // // imageThumbnailCropType: imageAttachmentThumbnailCropType,
// // attachmentActionsModalBuilder:
return WrapAttachmentWidget( // // attachmentActionsModalBuilder,
attachmentWidget: StreamImageAttachment( // ),
attachment: attachments[0], // ),
message: message, // );
messageTheme: messageTheme, // }
constraints: BoxConstraints( //
maxWidth: 400, // return WrapAttachmentWidget(
minWidth: 400, // attachmentShape: border,
maxHeight: mediaQueryData.size.height * 0.3, // attachmentWidget: StreamImageAttachment(
), // message: message,
onShowMessage: onShowMessage, // image: attachments.first,
onReplyMessage: onReplyTap, // constraints: const BoxConstraints(
onAttachmentTap: onAttachmentTap != null // minWidth: 170,
? () { // maxWidth: 256,
onAttachmentTap.call(message, attachments[0]); // minHeight: 100,
} // maxHeight: 300,
: null, // ),
imageThumbnailSize: imageAttachmentThumbnailSize, // // onShowMessage: onShowMessage,
imageThumbnailResizeType: imageAttachmentThumbnailResizeType, // // onReplyMessage: onReplyTap,
imageThumbnailCropType: imageAttachmentThumbnailCropType, // imageThumbnailSize: imageAttachmentThumbnailSize,
attachmentActionsModalBuilder: attachmentActionsModalBuilder, // imageThumbnailResizeType: imageAttachmentThumbnailResizeType,
), // imageThumbnailCropType: imageAttachmentThumbnailCropType,
attachmentShape: border, // // attachmentActionsModalBuilder: attachmentActionsModalBuilder,
); // // onAttachmentTap: onAttachmentTap != null
}, // // ? () => onAttachmentTap.call(message, attachments.first)
'video': (context, message, attachments) { // // : null,
final border = RoundedRectangleBorder( // ),
side: attachmentBorderSide ?? // );
BorderSide( // },
color: StreamChatTheme.of(context).colorTheme.borders, // 'video': (context, message, attachments) {
), // final color = StreamChatTheme.of(context).colorTheme.borders;
borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, // final border = RoundedRectangleBorder(
); // side: attachmentBorderSide ?? BorderSide(color: color),
// borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero,
return WrapAttachmentWidget( // );
attachmentWidget: Column( //
children: attachments.map((attachment) { // return WrapAttachmentWidget(
final mediaQueryData = MediaQuery.of(context); // attachmentShape: border,
return StreamVideoAttachment( // attachmentWidget: Column(
attachment: attachment, // children: [
messageTheme: messageTheme, // ...attachments.map((attachment) {
constraints: BoxConstraints( // return StreamVideoAttachment(
maxWidth: 400, // video: attachment,
minWidth: 400, // constraints: const BoxConstraints.tightFor(
maxHeight: mediaQueryData.size.height * 0.3, // width: 256,
), // height: 195,
message: message, // ),
onShowMessage: onShowMessage, // message: message,
onReplyMessage: onReplyTap, // );
onAttachmentTap: onAttachmentTap != null // }),
? () { // ],
onAttachmentTap(message, attachment); // ),
} // );
: null, // },
attachmentActionsModalBuilder: // 'giphy': (context, message, attachments) {
attachmentActionsModalBuilder, // final color = StreamChatTheme.of(context).colorTheme.borders;
); // final border = RoundedRectangleBorder(
}).toList(), // side: attachmentBorderSide ?? BorderSide(color: color),
), // borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero,
attachmentShape: border, // );
); //
}, // return WrapAttachmentWidget(
'giphy': (context, message, attachments) { // attachmentShape: border,
final attachmentWidget = Column( // attachmentWidget: Column(
children: [ // children: [
...attachments.map((attachment) { // ...attachments.map((attachment) {
final mediaQueryData = MediaQuery.of(context); // return StreamGiphyAttachment(
return StreamGiphyAttachment( // giphy: attachment,
attachment: attachment, // message: message,
message: message, // constraints: const BoxConstraints(
constraints: BoxConstraints( // minWidth: 170,
maxWidth: 400, // maxWidth: 256,
minWidth: 400, // minHeight: 100,
maxHeight: mediaQueryData.size.height * 0.3, // maxHeight: 300,
), // ),
onShowMessage: onShowMessage, // );
onReplyMessage: onReplyTap, // }),
onAttachmentTap: onAttachmentTap != null // ],
? () => onAttachmentTap(message, attachment) // ),
: null, // );
); // },
}), // 'file': (context, message, attachments) {
], // final color = StreamChatTheme.of(context).colorTheme.borders;
); // final border = RoundedRectangleBorder(
// side: attachmentBorderSide ?? BorderSide(color: color),
// If the message is ephemeral, we don't want to show the border. // borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero,
if (message.isEphemeral) return attachmentWidget; // );
//
final color = StreamChatTheme.of(context).colorTheme.borders; // return Column(
final border = RoundedRectangleBorder( // children: [
side: attachmentBorderSide ?? BorderSide(color: color), // ...attachments.map<Widget>((attachment) {
borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, // final mediaQueryData = MediaQuery.of(context);
); // return WrapAttachmentWidget(
// attachmentShape: border,
return WrapAttachmentWidget( // attachmentWidget: StreamFileAttachment(
attachmentShape: border, // message: message,
attachmentWidget: attachmentWidget, // file: attachment,
); // constraints: BoxConstraints(
}, // maxWidth: 400,
'file': (context, message, attachments) { // minWidth: 400,
final border = RoundedRectangleBorder( // maxHeight: mediaQueryData.size.height * 0.3,
side: attachmentBorderSide ?? // ),
BorderSide( // // onAttachmentTap: onAttachmentTap != null
color: StreamChatTheme.of(context).colorTheme.borders, // // ? () => onAttachmentTap(message, attachment)
), // // : null,
borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero, // ),
); // );
// }).insertBetween(
return Column( // SizedBox(height: attachmentPadding.vertical / 2),
children: attachments // ),
.map<Widget>((attachment) { // ],
final mediaQueryData = MediaQuery.of(context); // );
return WrapAttachmentWidget( // },
attachmentWidget: StreamFileAttachment( //
message: message, // // Add all custom builders, overriding the defaults if needed.
attachment: attachment, // ...?customAttachmentBuilders,
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 ?? {});
/// {@template onMentionTap} /// {@template onMentionTap}
/// Function called on mention tap /// Function called on mention tap
@@ -333,32 +283,17 @@ class StreamMessageWidget extends StatefulWidget {
/// {@endtemplate} /// {@endtemplate}
final Widget Function(BuildContext, Message)? textBuilder; final Widget Function(BuildContext, Message)? textBuilder;
/// {@template usernameBuilder}
/// Widget builder for building username
/// {@endtemplate}
final Widget Function(BuildContext, Message)? usernameBuilder;
/// {@template onMessageActions} /// {@template onMessageActions}
/// Function called on long press /// Function called on long press
/// {@endtemplate} /// {@endtemplate}
final void Function(BuildContext, Message)? onMessageActions; final void Function(BuildContext, Message)? onMessageActions;
/// {@template bottomRowBuilder}
/// Widget builder for building a bottom row below the message
/// {@endtemplate}
final BottomRowBuilder? bottomRowBuilder;
/// {@template bottomRowBuilderWithDefaultWidget} /// {@template bottomRowBuilderWithDefaultWidget}
/// Widget builder for building a bottom row below the message. /// Widget builder for building a bottom row below the message.
/// Also contains the default bottom row widget. /// Also contains the default bottom row widget.
/// {@endtemplate} /// {@endtemplate}
final BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget; 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} /// {@template userAvatarBuilder}
/// Widget builder for building user avatar /// Widget builder for building user avatar
/// {@endtemplate} /// {@endtemplate}
@@ -399,21 +334,11 @@ class StreamMessageWidget extends StatefulWidget {
/// {@endtemplate} /// {@endtemplate}
final BorderSide? borderSide; final BorderSide? borderSide;
/// {@template attachmentBorderSide}
/// The borderSide of an attachment
/// {@endtemplate}
final BorderSide? attachmentBorderSide;
/// {@template borderRadiusGeometry} /// {@template borderRadiusGeometry}
/// The border radius of the message text /// The border radius of the message text
/// {@endtemplate} /// {@endtemplate}
final BorderRadiusGeometry? borderRadiusGeometry; final BorderRadiusGeometry? borderRadiusGeometry;
/// {@template attachmentBorderRadiusGeometry}
/// The border radius of an attachment
/// {@endtemplate}
final BorderRadiusGeometry? attachmentBorderRadiusGeometry;
/// {@template padding} /// {@template padding}
/// The padding of the widget /// The padding of the widget
/// {@endtemplate} /// {@endtemplate}
@@ -475,12 +400,6 @@ class StreamMessageWidget extends StatefulWidget {
/// {@endtemplate} /// {@endtemplate}
final bool showReactionPicker; final bool showReactionPicker;
/// {@template showReactionPickerIndicator}
/// Used in [StreamMessageReactionsModal] and [MessageActionsModal]
/// {@endtemplate}
@Deprecated('Use `showReactionPicker` instead')
bool get showReactionPickerIndicator => showReactionPicker;
/// {@template showReactionPickerTail} /// {@template showReactionPickerTail}
/// Whether or not to show the reaction picker tail /// Whether or not to show the reaction picker tail
/// {@endtemplate} /// {@endtemplate}
@@ -549,14 +468,13 @@ class StreamMessageWidget extends StatefulWidget {
final bool showPinHighlight; final bool showPinHighlight;
/// {@template attachmentBuilders} /// {@template attachmentBuilders}
/// Builder for respective attachment types /// List of attachment builders for rendering attachment widgets pre-defined
/// and custom attachment types.
///
/// If null, the widget will create a default list of attachment builders
/// based on the [Attachment.type] of the attachment.
/// {@endtemplate} /// {@endtemplate}
final Map<String, AttachmentBuilder> attachmentBuilders; final List<StreamAttachmentWidgetBuilder>? attachmentBuilders;
/// {@template customAttachmentBuilders}
/// Builder for respective attachment types (user facing builder)
/// {@endtemplate}
final Map<String, AttachmentBuilder>? customAttachmentBuilders;
/// {@template translateUserAvatar} /// {@template translateUserAvatar}
/// Center user avatar with bottom of the message /// Center user avatar with bottom of the message
@@ -586,7 +504,7 @@ class StreamMessageWidget extends StatefulWidget {
final List<StreamMessageAction> customActions; final List<StreamMessageAction> customActions;
/// {@macro onMessageWidgetAttachmentTap} /// {@macro onMessageWidgetAttachmentTap}
final OnMessageWidgetAttachmentTap? onAttachmentTap; final StreamAttachmentWidgetTapCallback? onAttachmentTap;
/// {@macro attachmentActionsBuilder} /// {@macro attachmentActionsBuilder}
final AttachmentActionsBuilder? attachmentActionsModalBuilder; final AttachmentActionsBuilder? attachmentActionsModalBuilder;
@@ -618,19 +536,7 @@ class StreamMessageWidget extends StatefulWidget {
Widget Function(BuildContext, Message)? editMessageInputBuilder, Widget Function(BuildContext, Message)? editMessageInputBuilder,
Widget Function(BuildContext, Message)? textBuilder, Widget Function(BuildContext, Message)? textBuilder,
Widget Function(BuildContext, Message)? quotedMessageBuilder, 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, BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget,
@Deprecated('''
Use [bottomRowBuilderWithDefaultWidget] instead.
Will be removed in the next major version.
''') Widget Function(BuildContext, Message)? deletedBottomRowBuilder,
void Function(BuildContext, Message)? onMessageActions, void Function(BuildContext, Message)? onMessageActions,
Message? message, Message? message,
StreamMessageThemeData? messageTheme, StreamMessageThemeData? messageTheme,
@@ -638,9 +544,7 @@ class StreamMessageWidget extends StatefulWidget {
ShapeBorder? shape, ShapeBorder? shape,
ShapeBorder? attachmentShape, ShapeBorder? attachmentShape,
BorderSide? borderSide, BorderSide? borderSide,
BorderSide? attachmentBorderSide,
BorderRadiusGeometry? borderRadiusGeometry, BorderRadiusGeometry? borderRadiusGeometry,
BorderRadiusGeometry? attachmentBorderRadiusGeometry,
EdgeInsetsGeometry? padding, EdgeInsetsGeometry? padding,
EdgeInsets? textPadding, EdgeInsets? textPadding,
EdgeInsetsGeometry? attachmentPadding, EdgeInsetsGeometry? attachmentPadding,
@@ -655,8 +559,6 @@ class StreamMessageWidget extends StatefulWidget {
void Function(String)? onLinkTap, void Function(String)? onLinkTap,
bool? showReactionBrowser, bool? showReactionBrowser,
bool? showReactionPicker, bool? showReactionPicker,
@Deprecated('Use `showReactionPicker` instead')
bool? showReactionPickerIndicator,
@internal bool? showReactionPickerTail, @internal bool? showReactionPickerTail,
List<Read>? readList, List<Read>? readList,
ShowMessageCallback? onShowMessage, ShowMessageCallback? onShowMessage,
@@ -671,7 +573,7 @@ class StreamMessageWidget extends StatefulWidget {
bool? showFlagButton, bool? showFlagButton,
bool? showPinButton, bool? showPinButton,
bool? showPinHighlight, bool? showPinHighlight,
Map<String, AttachmentBuilder>? customAttachmentBuilders, List<StreamAttachmentWidgetBuilder>? attachmentBuilders,
bool? translateUserAvatar, bool? translateUserAvatar,
OnQuotedMessageTap? onQuotedMessageTap, OnQuotedMessageTap? onQuotedMessageTap,
void Function(Message)? onMessageTap, void Function(Message)? onMessageTap,
@@ -685,29 +587,6 @@ class StreamMessageWidget extends StatefulWidget {
String? imageAttachmentThumbnailCropType, String? imageAttachmentThumbnailCropType,
AttachmentActionsBuilder? attachmentActionsModalBuilder, 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( return StreamMessageWidget(
key: key ?? this.key, key: key ?? this.key,
onMentionTap: onMentionTap ?? this.onMentionTap, onMentionTap: onMentionTap ?? this.onMentionTap,
@@ -718,7 +597,8 @@ class StreamMessageWidget extends StatefulWidget {
editMessageInputBuilder ?? this.editMessageInputBuilder, editMessageInputBuilder ?? this.editMessageInputBuilder,
textBuilder: textBuilder ?? this.textBuilder, textBuilder: textBuilder ?? this.textBuilder,
quotedMessageBuilder: quotedMessageBuilder ?? this.quotedMessageBuilder, quotedMessageBuilder: quotedMessageBuilder ?? this.quotedMessageBuilder,
bottomRowBuilderWithDefaultWidget: _bottomRowBuilderWithDefaultWidget, bottomRowBuilderWithDefaultWidget: bottomRowBuilderWithDefaultWidget ??
this.bottomRowBuilderWithDefaultWidget,
onMessageActions: onMessageActions ?? this.onMessageActions, onMessageActions: onMessageActions ?? this.onMessageActions,
message: message ?? this.message, message: message ?? this.message,
messageTheme: messageTheme ?? this.messageTheme, messageTheme: messageTheme ?? this.messageTheme,
@@ -726,10 +606,7 @@ class StreamMessageWidget extends StatefulWidget {
shape: shape ?? this.shape, shape: shape ?? this.shape,
attachmentShape: attachmentShape ?? this.attachmentShape, attachmentShape: attachmentShape ?? this.attachmentShape,
borderSide: borderSide ?? this.borderSide, borderSide: borderSide ?? this.borderSide,
attachmentBorderSide: attachmentBorderSide ?? this.attachmentBorderSide,
borderRadiusGeometry: borderRadiusGeometry ?? this.borderRadiusGeometry, borderRadiusGeometry: borderRadiusGeometry ?? this.borderRadiusGeometry,
attachmentBorderRadiusGeometry:
attachmentBorderRadiusGeometry ?? this.attachmentBorderRadiusGeometry,
padding: padding ?? this.padding, padding: padding ?? this.padding,
textPadding: textPadding ?? this.textPadding, textPadding: textPadding ?? this.textPadding,
attachmentPadding: attachmentPadding ?? this.attachmentPadding, attachmentPadding: attachmentPadding ?? this.attachmentPadding,
@@ -743,9 +620,9 @@ class StreamMessageWidget extends StatefulWidget {
showInChannelIndicator ?? this.showInChannelIndicator, showInChannelIndicator ?? this.showInChannelIndicator,
onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap, onUserAvatarTap: onUserAvatarTap ?? this.onUserAvatarTap,
onLinkTap: onLinkTap ?? this.onLinkTap, onLinkTap: onLinkTap ?? this.onLinkTap,
showReactionPicker: showReactionPicker ?? showReactionPicker: showReactionPicker ?? this.showReactionPicker,
showReactionPickerIndicator ?? showReactionPickerTail:
this.showReactionPicker, showReactionPickerTail ?? this.showReactionPickerTail,
onShowMessage: onShowMessage ?? this.onShowMessage, onShowMessage: onShowMessage ?? this.onShowMessage,
showUsername: showUsername ?? this.showUsername, showUsername: showUsername ?? this.showUsername,
showTimestamp: showTimestamp ?? this.showTimestamp, showTimestamp: showTimestamp ?? this.showTimestamp,
@@ -759,8 +636,7 @@ class StreamMessageWidget extends StatefulWidget {
showFlagButton: showFlagButton ?? this.showFlagButton, showFlagButton: showFlagButton ?? this.showFlagButton,
showPinButton: showPinButton ?? this.showPinButton, showPinButton: showPinButton ?? this.showPinButton,
showPinHighlight: showPinHighlight ?? this.showPinHighlight, showPinHighlight: showPinHighlight ?? this.showPinHighlight,
customAttachmentBuilders: attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders,
customAttachmentBuilders ?? this.customAttachmentBuilders,
translateUserAvatar: translateUserAvatar ?? this.translateUserAvatar, translateUserAvatar: translateUserAvatar ?? this.translateUserAvatar,
onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap, onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap,
onMessageTap: onMessageTap ?? this.onMessageTap, onMessageTap: onMessageTap ?? this.onMessageTap,
@@ -817,8 +693,8 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
/// {@template isGiphy} /// {@template isGiphy}
/// `true` if any of the [message]'s attachments are a giphy. /// `true` if any of the [message]'s attachments are a giphy.
/// {@endtemplate} /// {@endtemplate}
bool get isGiphy => bool get isGiphy => widget.message.attachments
widget.message.attachments.any((element) => element.type == 'giphy'); .any((element) => element.type == AttachmentType.giphy);
/// {@template isOnlyEmoji} /// {@template isOnlyEmoji}
/// `true` if [message.text] contains only emoji. /// `true` if [message.text] contains only emoji.
@@ -830,15 +706,14 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
/// have a [Attachment.titleLink]. /// have a [Attachment.titleLink].
/// {@endtemplate} /// {@endtemplate}
bool get hasNonUrlAttachments => widget.message.attachments bool get hasNonUrlAttachments => widget.message.attachments
.where((it) => it.titleLink == null || it.type == 'giphy') .any((it) => it.type != AttachmentType.urlPreview);
.isNotEmpty;
/// {@template hasUrlAttachments} /// {@template hasUrlAttachments}
/// `true` if any of the [message]'s attachments are a giphy with a /// `true` if any of the [message]'s attachments are a giphy with a
/// [Attachment.titleLink]. /// [Attachment.titleLink].
/// {@endtemplate} /// {@endtemplate}
bool get hasUrlAttachments => widget.message.attachments bool get hasUrlAttachments => widget.message.attachments
.any((it) => it.titleLink != null && it.type != 'giphy'); .any((it) => it.type == AttachmentType.urlPreview);
/// {@template showBottomRow} /// {@template showBottomRow}
/// Show the [BottomRow] widget if any of the following are `true`: /// Show the [BottomRow] widget if any of the following are `true`:
@@ -876,7 +751,8 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
bool get shouldShowEditAction => bool get shouldShowEditAction =>
widget.showEditMessage && widget.showEditMessage &&
!isDeleteFailed && !isDeleteFailed &&
!widget.message.attachments.any((element) => element.type == 'giphy'); !widget.message.attachments
.any((element) => element.type == AttachmentType.giphy);
bool get shouldShowResendAction => bool get shouldShowResendAction =>
widget.showResendMessage && (isSendFailed || isUpdateFailed); widget.showResendMessage && (isSendFailed || isUpdateFailed);
@@ -889,7 +765,8 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
bool get shouldShowEditMessage => bool get shouldShowEditMessage =>
widget.showEditMessage && widget.showEditMessage &&
!isDeleteFailed && !isDeleteFailed &&
!widget.message.attachments.any((element) => element.type == 'giphy'); !widget.message.attachments
.any((element) => element.type == AttachmentType.giphy);
bool get shouldShowThreadReplyAction => bool get shouldShowThreadReplyAction =>
widget.showThreadReplyMessage && widget.showThreadReplyMessage &&
@@ -961,23 +838,6 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
: Alignment.centerLeft, : Alignment.centerLeft,
widthFactor: widget.widthFactor, widthFactor: widget.widthFactor,
child: Builder(builder: (context) { 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( return MessageWidgetContent(
streamChatTheme: _streamChatTheme, streamChatTheme: _streamChatTheme,
showUsername: showUsername, showUsername: showUsername,
@@ -996,6 +856,12 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
textPadding: widget.textPadding, textPadding: widget.textPadding,
attachmentBuilders: widget.attachmentBuilders, attachmentBuilders: widget.attachmentBuilders,
attachmentPadding: widget.attachmentPadding, attachmentPadding: widget.attachmentPadding,
attachmentShape: widget.attachmentShape,
onAttachmentTap: widget.onAttachmentTap,
onReplyTap: widget.onReplyTap,
onShowMessage: widget.onShowMessage,
attachmentActionsModalBuilder:
widget.attachmentActionsModalBuilder,
avatarWidth: avatarWidth, avatarWidth: avatarWidth,
bottomRowPadding: bottomRowPadding, bottomRowPadding: bottomRowPadding,
isFailedState: isFailedState, isFailedState: isFailedState,
@@ -1023,7 +889,7 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
onMentionTap: widget.onMentionTap, onMentionTap: widget.onMentionTap,
onQuotedMessageTap: widget.onQuotedMessageTap, onQuotedMessageTap: widget.onQuotedMessageTap,
bottomRowBuilderWithDefaultWidget: bottomRowBuilderWithDefaultWidget:
_bottomRowBuilderWithDefaultWidget, widget.bottomRowBuilderWithDefaultWidget,
onUserAvatarTap: widget.onUserAvatarTap, onUserAvatarTap: widget.onUserAvatarTap,
userAvatarBuilder: widget.userAvatarBuilder, userAvatarBuilder: widget.userAvatarBuilder,
); );
@@ -1295,6 +1161,7 @@ class _StreamMessageWidgetState extends State<StreamMessageWidget>
showResendMessage: shouldShowResendAction, showResendMessage: shouldShowResendAction,
showCopyMessage: shouldShowCopyAction, showCopyMessage: shouldShowCopyAction,
showEditMessage: shouldShowEditAction, showEditMessage: shouldShowEditAction,
showReactionPicker: widget.showReactionPicker,
showReplyMessage: shouldShowReplyAction, showReplyMessage: shouldShowReplyAction,
showThreadReplyMessage: shouldShowThreadReplyAction, showThreadReplyMessage: shouldShowThreadReplyAction,
showFlagButton: widget.showFlagButton, showFlagButton: widget.showFlagButton,
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_portal/flutter_portal.dart';
import 'package:meta/meta.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/message_widget_content_components.dart';
import 'package:stream_chat_flutter/src/message_widget/reactions/desktop_reactions_builder.dart'; import 'package:stream_chat_flutter/src/message_widget/reactions/desktop_reactions_builder.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
@@ -47,6 +48,11 @@ class MessageWidgetContent extends StatelessWidget {
required this.isGiphy, required this.isGiphy,
required this.attachmentBuilders, required this.attachmentBuilders,
required this.attachmentPadding, required this.attachmentPadding,
required this.attachmentShape,
required this.onAttachmentTap,
required this.onShowMessage,
required this.onReplyTap,
required this.attachmentActionsModalBuilder,
required this.textPadding, required this.textPadding,
required this.showReactionPickerTail, required this.showReactionPickerTail,
required this.translateUserAvatar, required this.translateUserAvatar,
@@ -67,28 +73,9 @@ class MessageWidgetContent extends StatelessWidget {
this.onLinkTap, this.onLinkTap,
this.textBuilder, this.textBuilder,
this.quotedMessageBuilder, this.quotedMessageBuilder,
@Deprecated('''
Use [bottomRowBuilderWithDefaultWidget] instead.
Will be removed in the next major version.
''') this.bottomRowBuilder,
this.bottomRowBuilderWithDefaultWidget, 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, 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} /// {@macro reverse}
final bool reverse; final bool reverse;
@@ -157,11 +144,26 @@ class MessageWidgetContent extends StatelessWidget {
final bool isGiphy; final bool isGiphy;
/// {@macro attachmentBuilders} /// {@macro attachmentBuilders}
final Map<String, AttachmentBuilder> attachmentBuilders; final List<StreamAttachmentWidgetBuilder>? attachmentBuilders;
/// {@macro attachmentPadding} /// {@macro attachmentPadding}
final EdgeInsetsGeometry 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} /// {@macro textPadding}
final EdgeInsets textPadding; final EdgeInsets textPadding;
@@ -189,9 +191,6 @@ class MessageWidgetContent extends StatelessWidget {
/// The padding to use for this widget. /// The padding to use for this widget.
final double bottomRowPadding; final double bottomRowPadding;
/// {@macro bottomRowBuilder}
final BottomRowBuilder? bottomRowBuilder;
/// {@macro bottomRowBuilderWithDefaultWidget} /// {@macro bottomRowBuilderWithDefaultWidget}
final BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget; final BottomRowBuilderWithDefaultWidget? bottomRowBuilderWithDefaultWidget;
@@ -213,21 +212,12 @@ class MessageWidgetContent extends StatelessWidget {
/// {@macro showUsername} /// {@macro showUsername}
final bool showUsername; final bool showUsername;
/// {@macro onThreadTap}
final void Function(Message)? onThreadTap;
/// {@macro deletedBottomRowBuilder}
final Widget Function(BuildContext, Message)? deletedBottomRowBuilder;
/// {@macro messageWidget} /// {@macro messageWidget}
final StreamMessageWidget messageWidget; final StreamMessageWidget messageWidget;
/// {@macro userAvatarBuilder} /// {@macro userAvatarBuilder}
final Widget Function(BuildContext, User)? userAvatarBuilder; final Widget Function(BuildContext, User)? userAvatarBuilder;
/// {@macro usernameBuilder}
final Widget Function(BuildContext, Message)? usernameBuilder;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Column(
@@ -342,6 +332,12 @@ class MessageWidgetContent extends StatelessWidget {
isGiphy: isGiphy, isGiphy: isGiphy,
attachmentBuilders: attachmentBuilders, attachmentBuilders: attachmentBuilders,
attachmentPadding: attachmentPadding, attachmentPadding: attachmentPadding,
attachmentShape: attachmentShape,
onAttachmentTap: onAttachmentTap,
onReplyTap: onReplyTap,
onShowMessage: onShowMessage,
attachmentActionsModalBuilder:
attachmentActionsModalBuilder,
textPadding: textPadding, textPadding: textPadding,
reverse: reverse, reverse: reverse,
onQuotedMessageTap: onQuotedMessageTap, onQuotedMessageTap: onQuotedMessageTap,
@@ -444,16 +440,11 @@ class MessageWidgetContent extends StatelessWidget {
showTimeStamp: showTimeStamp, showTimeStamp: showTimeStamp,
showUsername: showUsername, showUsername: showUsername,
streamChatTheme: streamChatTheme, streamChatTheme: streamChatTheme,
onThreadTap: onThreadTap,
deletedBottomRowBuilder: deletedBottomRowBuilder,
streamChat: streamChat, streamChat: streamChat,
hasNonUrlAttachments: hasNonUrlAttachments, hasNonUrlAttachments: hasNonUrlAttachments,
usernameBuilder: usernameBuilder,
); );
if (bottomRowBuilder != null) { if (bottomRowBuilderWithDefaultWidget != null) {
return bottomRowBuilder!(context, message);
} else if (bottomRowBuilderWithDefaultWidget != null) {
return bottomRowBuilderWithDefaultWidget!( return bottomRowBuilderWithDefaultWidget!(
context, context,
message, message,
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; 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/src/message_widget/message_widget_content_components.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
@@ -14,57 +16,126 @@ class ParseAttachments extends StatelessWidget {
required this.message, required this.message,
required this.attachmentBuilders, required this.attachmentBuilders,
required this.attachmentPadding, required this.attachmentPadding,
this.attachmentShape,
this.onAttachmentTap,
this.onShowMessage,
this.onReplyTap,
this.attachmentActionsModalBuilder,
}); });
/// {@macro message} /// {@macro message}
final Message message; final Message message;
/// {@macro attachmentBuilders} /// {@macro attachmentBuilders}
final Map<String, AttachmentBuilder> attachmentBuilders; final List<StreamAttachmentWidgetBuilder>? attachmentBuilders;
/// {@macro attachmentPadding} /// {@macro attachmentPadding}
final EdgeInsetsGeometry 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final attachmentGroups = <String, List<Attachment>>{}; // Create a default onAttachmentTap callback if not provided.
var onAttachmentTap = this.onAttachmentTap;
message.attachments onAttachmentTap ??= (message, attachment) {
.where((element) => // If the current attachment is a url preview attachment, open the url
(element.titleLink == null && element.type != null) || // in the browser.
element.type == 'giphy') final isUrlPreview = attachment.type == AttachmentType.urlPreview;
.forEach((e) { if (isUrlPreview) {
if (attachmentGroups[e.type] == null) { final url = attachment.ogScrapeUrl ?? '';
attachmentGroups[e.type!] = []; launchURL(context, url);
return;
} }
attachmentGroups[e.type]?.add(e); final isImage = attachment.type == AttachmentType.image;
}); final isVideo = attachment.type == AttachmentType.video;
final isGiphy = attachment.type == AttachmentType.giphy;
final attachmentList = <Widget>[]; // If the current attachment is a media attachment, open the media
// attachment in full screen.
final isMedia = isImage || isVideo || isGiphy;
if (isMedia) {
final channel = StreamChannel.of(context).channel;
attachmentGroups.forEach((type, attachments) { final attachments = message.toAttachmentPackage(
final attachmentBuilder = attachmentBuilders[type]; filter: (it) {
final isImage = it.type == AttachmentType.image;
final isVideo = it.type == AttachmentType.video;
final isGiphy = it.type == AttachmentType.giphy;
return isImage || isVideo || isGiphy;
},
);
if (attachmentBuilder == null) return; Navigator.of(context).push(
final attachmentWidget = attachmentBuilder( MaterialPageRoute(
context, builder: (context) {
message, return StreamChannel(
attachments, channel: channel,
); child: StreamFullScreenMediaBuilder(
attachmentList.add(attachmentWidget); userName: message.user!.name,
}); mediaAttachmentPackages: attachments,
startIndex: attachments.indexWhere(
return Padding( (it) => it.attachment.id == attachment.id,
padding: attachmentPadding, ),
child: Column( onReplyMessage: onReplyTap,
mainAxisSize: MainAxisSize.min, onShowMessage: onShowMessage,
children: attachmentList.insertBetween( attachmentActionsModalBuilder: attachmentActionsModalBuilder,
SizedBox( ),
height: attachmentPadding.vertical / 2, );
},
), ),
), );
),
return;
}
};
// Create a default attachmentBuilders list if not provided.
var builders = attachmentBuilders;
builders ??= StreamAttachmentWidgetBuilder.defaultBuilders(
message: message,
shape: attachmentShape,
padding: attachmentPadding,
onAttachmentTap: onAttachmentTap,
); );
final catalog = AttachmentWidgetCatalog(builders: builders);
return catalog.build(context, message);
}
}
extension on Message {
List<StreamAttachmentPackage> 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,
);
})
];
} }
} }
@@ -12,31 +12,34 @@ class QuotedMessage extends StatelessWidget {
const QuotedMessage({ const QuotedMessage({
super.key, super.key,
required this.message, required this.message,
required this.reverse,
required this.hasNonUrlAttachments, required this.hasNonUrlAttachments,
this.textBuilder,
}); });
/// {@macro message} /// {@macro message}
final Message message; final Message message;
/// {@macro reverse}
final bool reverse;
/// {@macro hasNonUrlAttachments} /// {@macro hasNonUrlAttachments}
final bool hasNonUrlAttachments; final bool hasNonUrlAttachments;
/// {@macro textBuilder}
final Widget Function(BuildContext, Message)? textBuilder;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final streamChat = StreamChat.of(context); final streamChat = StreamChat.of(context);
final chatThemeData = StreamChatTheme.of(context); final chatThemeData = StreamChatTheme.of(context);
final isMyMessage = message.user?.id == streamChat.currentUser?.id; final isMyMessage = message.user?.id == streamChat.currentUser?.id;
final isMyQuotedMessage =
message.quotedMessage?.user?.id == streamChat.currentUser?.id;
return StreamQuotedMessageWidget( return StreamQuotedMessageWidget(
message: message.quotedMessage!, message: message.quotedMessage!,
messageTheme: isMyMessage messageTheme: isMyMessage
? chatThemeData.otherMessageTheme ? chatThemeData.otherMessageTheme
: chatThemeData.ownMessageTheme, : chatThemeData.ownMessageTheme,
reverse: reverse, reverse: !isMyQuotedMessage,
textBuilder: textBuilder,
padding: EdgeInsets.only( padding: EdgeInsets.only(
right: 8, right: 8,
left: 8, left: 8,
@@ -50,45 +50,47 @@ class StreamMessageReactionsModal extends StatelessWidget {
final child = Center( final child = Center(
child: SingleChildScrollView( child: SingleChildScrollView(
child: Padding( child: SafeArea(
padding: const EdgeInsets.all(8), child: Padding(
child: Column( padding: const EdgeInsets.all(8),
mainAxisAlignment: MainAxisAlignment.center, child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ crossAxisAlignment: CrossAxisAlignment.stretch,
if (showReactionPicker && hasReactionPermission) children: <Widget>[
LayoutBuilder( if (showReactionPicker && hasReactionPermission)
builder: (context, constraints) { LayoutBuilder(
return Align( builder: (context, constraints) {
alignment: Alignment( return Align(
calculateReactionsHorizontalAlignment( alignment: Alignment(
user, calculateReactionsHorizontalAlignment(
message, user,
constraints, message,
fontSize, constraints,
orientation, fontSize,
orientation,
),
0,
), ),
0, child: StreamReactionPicker(
), message: message,
child: StreamReactionPicker( ),
message: message, );
), },
); ),
}, const SizedBox(height: 10),
), IgnorePointer(
const SizedBox(height: 10), child: messageWidget,
IgnorePointer(
child: messageWidget,
),
if (message.latestReactions?.isNotEmpty == true) ...[
const SizedBox(height: 8),
ReactionsCard(
currentUser: user!,
message: message,
messageTheme: messageTheme,
), ),
if (message.latestReactions?.isNotEmpty == true) ...[
const SizedBox(height: 8),
ReactionsCard(
currentUser: user!,
message: message,
messageTheme: messageTheme,
),
],
], ],
], ),
), ),
), ),
), ),
@@ -51,7 +51,7 @@ class TextBubble extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (message.text?.trim().isEmpty ?? false) return const Offstage(); if (message.text?.trim().isEmpty ?? true) return const Offstage();
return Padding( return Padding(
padding: isOnlyEmoji ? EdgeInsets.zero : textPadding, padding: isOnlyEmoji ? EdgeInsets.zero : textPadding,
child: textBuilder != null child: textBuilder != null
@@ -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<List<int>>;
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<Widget> 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: <Widget>[
for (final row in pattern)
Expanded(
child: Flex(
direction: secondaryDirection,
children: <Widget>[
...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);
}
}
}
@@ -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,
),
),
],
),
);
}
}
@@ -18,14 +18,15 @@ class StreamVisibleFootnote extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
StreamSvgIcon.eye( StreamSvgIcon.eye(
color: chatThemeData.colorTheme.textLowEmphasis,
size: 16, size: 16,
color: chatThemeData.colorTheme.textLowEmphasis,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
context.translations.onlyVisibleToYouText, context.translations.onlyVisibleToYouText,
style: chatThemeData.textTheme.footnote style: chatThemeData.textTheme.footnote.copyWith(
.copyWith(color: chatThemeData.colorTheme.textLowEmphasis), color: chatThemeData.colorTheme.textLowEmphasis,
),
), ),
], ],
); );
@@ -228,8 +228,7 @@ class StreamChannelListTile extends StatelessWidget {
} }
final hasNonUrlAttachments = lastMessage.attachments final hasNonUrlAttachments = lastMessage.attachments
.where((it) => it.titleLink == null || it.type == 'giphy') .any((it) => it.type != AttachmentType.urlPreview);
.isNotEmpty;
return Padding( return Padding(
padding: const EdgeInsets.only(right: 4), padding: const EdgeInsets.only(right: 4),
@@ -1,5 +1,3 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart';
@@ -1,5 +1,3 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart';
@@ -1,5 +1,3 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart'; import 'package:stream_chat_flutter/src/scroll_view/stream_scroll_view_error_widget.dart';
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/indicators/loading_indicator.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamChatConfiguration} /// {@template streamChatConfiguration}
@@ -108,12 +109,14 @@ You must have a StreamChatConfigurationProvider widget at the top of your widget
class StreamChatConfigurationData { class StreamChatConfigurationData {
/// {@macro streamChatConfigurationData} /// {@macro streamChatConfigurationData}
factory StreamChatConfigurationData({ factory StreamChatConfigurationData({
Widget loadingIndicator = const StreamLoadingIndicator(),
Widget Function(BuildContext, User)? defaultUserImage, Widget Function(BuildContext, User)? defaultUserImage,
Widget Function(BuildContext, User)? placeholderUserImage, Widget Function(BuildContext, User)? placeholderUserImage,
List<StreamReactionIcon>? reactionIcons, List<StreamReactionIcon>? reactionIcons,
bool? enforceUniqueReactions, bool? enforceUniqueReactions,
}) { }) {
return StreamChatConfigurationData._( return StreamChatConfigurationData._(
loadingIndicator: loadingIndicator,
defaultUserImage: defaultUserImage ?? _defaultUserImage, defaultUserImage: defaultUserImage ?? _defaultUserImage,
placeholderUserImage: placeholderUserImage, placeholderUserImage: placeholderUserImage,
reactionIcons: reactionIcons ?? _defaultReactionIcons, reactionIcons: reactionIcons ?? _defaultReactionIcons,
@@ -122,6 +125,7 @@ class StreamChatConfigurationData {
} }
StreamChatConfigurationData._({ StreamChatConfigurationData._({
required this.loadingIndicator,
required this.defaultUserImage, required this.defaultUserImage,
required this.placeholderUserImage, required this.placeholderUserImage,
required this.reactionIcons, required this.reactionIcons,
@@ -131,20 +135,25 @@ class StreamChatConfigurationData {
/// Copies the configuration options from one [StreamChatConfigurationData] to /// Copies the configuration options from one [StreamChatConfigurationData] to
/// another. /// another.
StreamChatConfigurationData copyWith({ StreamChatConfigurationData copyWith({
Widget? loadingIndicator,
Widget Function(BuildContext, User)? defaultUserImage, Widget Function(BuildContext, User)? defaultUserImage,
Widget Function(BuildContext, User)? placeholderUserImage, Widget Function(BuildContext, User)? placeholderUserImage,
List<StreamReactionIcon>? reactionIcons, List<StreamReactionIcon>? reactionIcons,
bool? enforceUniqueReactions, bool? enforceUniqueReactions,
}) { }) {
return StreamChatConfigurationData( return StreamChatConfigurationData(
reactionIcons: reactionIcons ?? this.reactionIcons,
defaultUserImage: defaultUserImage ?? this.defaultUserImage, defaultUserImage: defaultUserImage ?? this.defaultUserImage,
placeholderUserImage: placeholderUserImage ?? this.placeholderUserImage, placeholderUserImage: placeholderUserImage ?? this.placeholderUserImage,
reactionIcons: reactionIcons ?? this.reactionIcons, loadingIndicator: loadingIndicator ?? this.loadingIndicator,
enforceUniqueReactions: enforceUniqueReactions:
enforceUniqueReactions ?? this.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. /// The widget that will be built when the user image is unavailable.
final Widget Function(BuildContext, User) defaultUserImage; final Widget Function(BuildContext, User) defaultUserImage;
@@ -11,7 +11,7 @@ class StreamColorTheme {
this.disabled = const Color(0xffdbdbdb), this.disabled = const Color(0xffdbdbdb),
this.borders = const Color(0xffecebeb), this.borders = const Color(0xffecebeb),
this.inputBg = const Color(0xfff2f2f2), this.inputBg = const Color(0xfff2f2f2),
this.appBg = const Color(0xfffcfcfc), this.appBg = const Color(0xfff7f7f8),
this.barsBg = const Color(0xffffffff), this.barsBg = const Color(0xffffffff),
this.linkBg = const Color(0xffe9f2ff), this.linkBg = const Color(0xffe9f2ff),
this.accentPrimary = const Color(0xff005FFF), this.accentPrimary = const Color(0xff005FFF),
@@ -63,8 +63,8 @@ class StreamColorTheme {
this.disabled = const Color(0xff2d2f2f), this.disabled = const Color(0xff2d2f2f),
this.borders = const Color(0xff1c1e22), this.borders = const Color(0xff1c1e22),
this.inputBg = const Color(0xff13151b), this.inputBg = const Color(0xff13151b),
this.appBg = const Color(0xff070A0D), this.appBg = const Color(0xff000000),
this.barsBg = const Color(0xff101418), this.barsBg = const Color(0xff121416),
this.linkBg = const Color(0xff00193D), this.linkBg = const Color(0xff00193D),
this.accentPrimary = const Color(0xff337eff), this.accentPrimary = const Color(0xff337eff),
this.accentError = const Color(0xffFF3742), this.accentError = const Color(0xffFF3742),
@@ -22,16 +22,13 @@ class StreamMessageThemeData with Diagnosticable {
this.reactionsMaskColor, this.reactionsMaskColor,
this.avatarTheme, this.avatarTheme,
this.createdAtStyle, this.createdAtStyle,
@Deprecated('Use urlAttachmentBackgroundColor instead') this.urlAttachmentBackgroundColor,
Color? linkBackgroundColor,
Color? urlAttachmentBackgroundColor,
this.urlAttachmentHostStyle, this.urlAttachmentHostStyle,
this.urlAttachmentTitleStyle, this.urlAttachmentTitleStyle,
this.urlAttachmentTextStyle, this.urlAttachmentTextStyle,
this.urlAttachmentTitleMaxLine, this.urlAttachmentTitleMaxLine,
this.urlAttachmentTextMaxLine, this.urlAttachmentTextMaxLine,
}) : urlAttachmentBackgroundColor = });
urlAttachmentBackgroundColor ?? linkBackgroundColor;
/// Text style for message text /// Text style for message text
final TextStyle? messageTextStyle; final TextStyle? messageTextStyle;
@@ -66,10 +63,6 @@ class StreamMessageThemeData with Diagnosticable {
/// Theme of the avatar /// Theme of the avatar
final StreamAvatarThemeData? avatarTheme; 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. /// Background color for messages with url attachments.
final Color? urlAttachmentBackgroundColor; final Color? urlAttachmentBackgroundColor;
@@ -101,8 +94,6 @@ class StreamMessageThemeData with Diagnosticable {
Color? reactionsBackgroundColor, Color? reactionsBackgroundColor,
Color? reactionsBorderColor, Color? reactionsBorderColor,
Color? reactionsMaskColor, Color? reactionsMaskColor,
@Deprecated('Use urlAttachmentBackgroundColor instead')
Color? linkBackgroundColor,
Color? urlAttachmentBackgroundColor, Color? urlAttachmentBackgroundColor,
TextStyle? urlAttachmentHostStyle, TextStyle? urlAttachmentHostStyle,
TextStyle? urlAttachmentTitleStyle, TextStyle? urlAttachmentTitleStyle,
@@ -124,9 +115,8 @@ class StreamMessageThemeData with Diagnosticable {
reactionsBackgroundColor ?? this.reactionsBackgroundColor, reactionsBackgroundColor ?? this.reactionsBackgroundColor,
reactionsBorderColor: reactionsBorderColor ?? this.reactionsBorderColor, reactionsBorderColor: reactionsBorderColor ?? this.reactionsBorderColor,
reactionsMaskColor: reactionsMaskColor ?? this.reactionsMaskColor, reactionsMaskColor: reactionsMaskColor ?? this.reactionsMaskColor,
urlAttachmentBackgroundColor: urlAttachmentBackgroundColor ?? urlAttachmentBackgroundColor:
linkBackgroundColor ?? urlAttachmentBackgroundColor ?? this.urlAttachmentBackgroundColor,
this.urlAttachmentBackgroundColor,
urlAttachmentHostStyle: urlAttachmentHostStyle:
urlAttachmentHostStyle ?? this.urlAttachmentHostStyle, urlAttachmentHostStyle ?? this.urlAttachmentHostStyle,
urlAttachmentTitleStyle: urlAttachmentTitleStyle:
@@ -201,6 +201,7 @@ class StreamChatThemeData {
urlAttachmentTitleStyle: textTheme.footnoteBold, urlAttachmentTitleStyle: textTheme.footnoteBold,
urlAttachmentTextStyle: textTheme.footnote, urlAttachmentTextStyle: textTheme.footnote,
urlAttachmentTitleMaxLine: 1, urlAttachmentTitleMaxLine: 1,
urlAttachmentTextMaxLine: 3,
), ),
otherMessageTheme: StreamMessageThemeData( otherMessageTheme: StreamMessageThemeData(
reactionsBackgroundColor: colorTheme.borders, reactionsBackgroundColor: colorTheme.borders,
@@ -227,6 +228,7 @@ class StreamChatThemeData {
urlAttachmentTitleStyle: textTheme.footnoteBold, urlAttachmentTitleStyle: textTheme.footnoteBold,
urlAttachmentTextStyle: textTheme.footnote, urlAttachmentTextStyle: textTheme.footnote,
urlAttachmentTitleMaxLine: 1, urlAttachmentTitleMaxLine: 1,
urlAttachmentTextMaxLine: 3,
), ),
messageInputTheme: StreamMessageInputThemeData( messageInputTheme: StreamMessageInputThemeData(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
@@ -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),
),
);
}
}
@@ -1,3 +1,4 @@
import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'package:diacritic/diacritic.dart'; import 'package:diacritic/diacritic.dart';
@@ -5,6 +6,8 @@ import 'package:file_picker/file_picker.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.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/src/localization/translations.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
@@ -114,7 +117,7 @@ extension PlatformFileX on PlatformFile {
final file = toAttachmentFile; final file = toAttachmentFile;
final extraDataMap = <String, Object>{}; final extraDataMap = <String, Object>{};
final mimeType = file.mimeType?.mimeType; final mimeType = file.mediaType?.mimeType;
if (mimeType != null) { if (mimeType != null) {
extraDataMap['mime_type'] = mimeType; extraDataMap['mime_type'] = mimeType;
@@ -151,7 +154,7 @@ extension XFileX on XFile {
final extraDataMap = <String, Object>{}; final extraDataMap = <String, Object>{};
final mimeType = this.mimeType ?? file.mimeType?.mimeType; final mimeType = this.mimeType ?? file.mediaType?.mimeType;
if (mimeType != null) { if (mimeType != null) {
extraDataMap['mime_type'] = mimeType; extraDataMap['mime_type'] = mimeType;
@@ -367,7 +370,7 @@ extension MessageX on Message {
/// Returns an approximation of message size /// Returns an approximation of message size
double roughMessageSize(double? fontSize) { double roughMessageSize(double? fontSize) {
var messageTextLength = min(text!.biggestLine().length, 65); var messageTextLength = min(text?.biggestLine().length ?? 0, 65);
if (quotedMessage != null) { if (quotedMessage != null) {
var quotedMessageLength = var quotedMessageLength =
@@ -475,3 +478,59 @@ extension StreamSvgIconX on StreamSvgIcon {
return StreamIconThemeSvgIcon.fromStreamSvgIcon(this); 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());
}
}

Some files were not shown because too many files have changed in this diff Show More