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

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