Merge pull request #1667 from GetStream/refactor/attachments
refactor!(ui, llc): Attachments refactor.
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -75,13 +75,13 @@ class AttachmentFile {
|
||||
multiPartFile = MultipartFile.fromBytes(
|
||||
bytes!,
|
||||
filename: name,
|
||||
contentType: mimeType,
|
||||
contentType: mediaType,
|
||||
);
|
||||
} else {
|
||||
multiPartFile = await MultipartFile.fromFile(
|
||||
path!,
|
||||
filename: name,
|
||||
contentType: mimeType,
|
||||
contentType: mediaType,
|
||||
);
|
||||
}
|
||||
return multiPartFile;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1170,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(
|
||||
@@ -1317,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;
|
||||
|
||||
@@ -1342,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) {
|
||||
@@ -1358,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());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'package:stream_chat_flutter/src/message_list_view/loading_indicator.dart
|
||||
import 'package:stream_chat_flutter/src/message_list_view/mlv_utils.dart';
|
||||
import 'package:stream_chat_flutter/src/message_list_view/thread_separator.dart';
|
||||
import 'package:stream_chat_flutter/src/message_list_view/unread_messages_separator.dart';
|
||||
import 'package:stream_chat_flutter/src/message_widget/ephemeral_message.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
/// Spacing Types (These are properties of a message to help inform the decision
|
||||
@@ -103,6 +104,7 @@ class StreamMessageListView extends StatefulWidget {
|
||||
this.loadingBuilder,
|
||||
this.emptyBuilder,
|
||||
this.systemMessageBuilder,
|
||||
this.ephemeralMessageBuilder,
|
||||
this.messageListBuilder,
|
||||
this.errorBuilder,
|
||||
this.messageFilter,
|
||||
@@ -148,6 +150,9 @@ class StreamMessageListView extends StatefulWidget {
|
||||
/// {@macro systemMessageBuilder}
|
||||
final SystemMessageBuilder? systemMessageBuilder;
|
||||
|
||||
/// {@macro ephemeralMessageBuilder}
|
||||
final EphemeralMessageBuilder? ephemeralMessageBuilder;
|
||||
|
||||
/// {@macro parentMessageBuilder}
|
||||
final ParentMessageBuilder? parentMessageBuilder;
|
||||
|
||||
@@ -805,13 +810,18 @@ class _StreamMessageListViewState extends State<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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -914,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,
|
||||
@@ -934,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,
|
||||
@@ -1047,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(
|
||||
@@ -1059,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;
|
||||
@@ -1076,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 &&
|
||||
@@ -1107,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 ?? [];
|
||||
@@ -1158,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,18 +42,16 @@ enum DisplayWidget {
|
||||
/// {@endtemplate}
|
||||
class StreamMessageWidget extends StatefulWidget {
|
||||
/// {@macro messageWidget}
|
||||
StreamMessageWidget({
|
||||
const StreamMessageWidget({
|
||||
super.key,
|
||||
required this.message,
|
||||
required this.messageTheme,
|
||||
this.reverse = false,
|
||||
this.translateUserAvatar = true,
|
||||
this.shape,
|
||||
this.attachmentShape,
|
||||
this.borderSide,
|
||||
this.attachmentBorderSide,
|
||||
this.borderRadiusGeometry,
|
||||
this.attachmentBorderRadiusGeometry,
|
||||
this.attachmentShape,
|
||||
this.onMentionTap,
|
||||
this.onMessageTap,
|
||||
this.onReactionsTap,
|
||||
@@ -86,7 +85,7 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
this.editMessageInputBuilder,
|
||||
this.textBuilder,
|
||||
this.bottomRowBuilderWithDefaultWidget,
|
||||
this.customAttachmentBuilders,
|
||||
this.attachmentBuilders,
|
||||
this.padding,
|
||||
this.textPadding = const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
@@ -101,185 +100,157 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
this.imageAttachmentThumbnailResizeType = 'clip',
|
||||
this.imageAttachmentThumbnailCropType = 'center',
|
||||
this.attachmentActionsModalBuilder,
|
||||
}) : attachmentBuilders = {
|
||||
'image': (context, message, attachments) {
|
||||
final border = RoundedRectangleBorder(
|
||||
side: attachmentBorderSide ??
|
||||
BorderSide(
|
||||
color: StreamChatTheme.of(context).colorTheme.borders,
|
||||
),
|
||||
borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero,
|
||||
);
|
||||
});
|
||||
|
||||
final mediaQueryData = MediaQuery.of(context);
|
||||
if (attachments.length > 1) {
|
||||
return Padding(
|
||||
padding: attachmentPadding,
|
||||
child: WrapAttachmentWidget(
|
||||
attachmentWidget: Material(
|
||||
color: messageTheme.messageBackgroundColor,
|
||||
child: StreamImageGroup(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 400,
|
||||
minWidth: 400,
|
||||
maxHeight: mediaQueryData.size.height * 0.3,
|
||||
),
|
||||
images: attachments,
|
||||
message: message,
|
||||
messageTheme: messageTheme,
|
||||
onShowMessage: onShowMessage,
|
||||
onReplyMessage: onReplyTap,
|
||||
onAttachmentTap: onAttachmentTap,
|
||||
imageThumbnailSize: imageAttachmentThumbnailSize,
|
||||
imageThumbnailResizeType:
|
||||
imageAttachmentThumbnailResizeType,
|
||||
imageThumbnailCropType: imageAttachmentThumbnailCropType,
|
||||
attachmentActionsModalBuilder:
|
||||
attachmentActionsModalBuilder,
|
||||
),
|
||||
),
|
||||
attachmentShape: border,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return WrapAttachmentWidget(
|
||||
attachmentWidget: StreamImageAttachment(
|
||||
attachment: attachments[0],
|
||||
message: message,
|
||||
messageTheme: messageTheme,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 400,
|
||||
minWidth: 400,
|
||||
maxHeight: mediaQueryData.size.height * 0.3,
|
||||
),
|
||||
onShowMessage: onShowMessage,
|
||||
onReplyMessage: onReplyTap,
|
||||
onAttachmentTap: onAttachmentTap != null
|
||||
? () {
|
||||
onAttachmentTap.call(message, attachments[0]);
|
||||
}
|
||||
: null,
|
||||
imageThumbnailSize: imageAttachmentThumbnailSize,
|
||||
imageThumbnailResizeType: imageAttachmentThumbnailResizeType,
|
||||
imageThumbnailCropType: imageAttachmentThumbnailCropType,
|
||||
attachmentActionsModalBuilder: attachmentActionsModalBuilder,
|
||||
),
|
||||
attachmentShape: border,
|
||||
);
|
||||
},
|
||||
'video': (context, message, attachments) {
|
||||
final border = RoundedRectangleBorder(
|
||||
side: attachmentBorderSide ??
|
||||
BorderSide(
|
||||
color: StreamChatTheme.of(context).colorTheme.borders,
|
||||
),
|
||||
borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero,
|
||||
);
|
||||
|
||||
return WrapAttachmentWidget(
|
||||
attachmentWidget: Column(
|
||||
children: attachments.map((attachment) {
|
||||
final mediaQueryData = MediaQuery.of(context);
|
||||
return StreamVideoAttachment(
|
||||
attachment: attachment,
|
||||
messageTheme: messageTheme,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 400,
|
||||
minWidth: 400,
|
||||
maxHeight: mediaQueryData.size.height * 0.3,
|
||||
),
|
||||
message: message,
|
||||
onShowMessage: onShowMessage,
|
||||
onReplyMessage: onReplyTap,
|
||||
onAttachmentTap: onAttachmentTap != null
|
||||
? () {
|
||||
onAttachmentTap(message, attachment);
|
||||
}
|
||||
: null,
|
||||
attachmentActionsModalBuilder:
|
||||
attachmentActionsModalBuilder,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
attachmentShape: border,
|
||||
);
|
||||
},
|
||||
'giphy': (context, message, attachments) {
|
||||
final attachmentWidget = Column(
|
||||
children: [
|
||||
...attachments.map((attachment) {
|
||||
final mediaQueryData = MediaQuery.of(context);
|
||||
return StreamGiphyAttachment(
|
||||
attachment: attachment,
|
||||
message: message,
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: 400,
|
||||
minWidth: 400,
|
||||
maxHeight: mediaQueryData.size.height * 0.3,
|
||||
),
|
||||
onShowMessage: onShowMessage,
|
||||
onReplyMessage: onReplyTap,
|
||||
onAttachmentTap: onAttachmentTap != null
|
||||
? () => onAttachmentTap(message, attachment)
|
||||
: null,
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
// If the message is ephemeral, we don't want to show the border.
|
||||
if (message.isEphemeral) return attachmentWidget;
|
||||
|
||||
final color = StreamChatTheme.of(context).colorTheme.borders;
|
||||
final border = RoundedRectangleBorder(
|
||||
side: attachmentBorderSide ?? BorderSide(color: color),
|
||||
borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero,
|
||||
);
|
||||
|
||||
return WrapAttachmentWidget(
|
||||
attachmentShape: border,
|
||||
attachmentWidget: attachmentWidget,
|
||||
);
|
||||
},
|
||||
'file': (context, message, attachments) {
|
||||
final border = RoundedRectangleBorder(
|
||||
side: attachmentBorderSide ??
|
||||
BorderSide(
|
||||
color: StreamChatTheme.of(context).colorTheme.borders,
|
||||
),
|
||||
borderRadius: attachmentBorderRadiusGeometry ?? BorderRadius.zero,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: attachments
|
||||
.map<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
|
||||
@@ -362,21 +333,11 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
/// {@endtemplate}
|
||||
final BorderSide? borderSide;
|
||||
|
||||
/// {@template attachmentBorderSide}
|
||||
/// The borderSide of an attachment
|
||||
/// {@endtemplate}
|
||||
final BorderSide? attachmentBorderSide;
|
||||
|
||||
/// {@template borderRadiusGeometry}
|
||||
/// The border radius of the message text
|
||||
/// {@endtemplate}
|
||||
final BorderRadiusGeometry? borderRadiusGeometry;
|
||||
|
||||
/// {@template attachmentBorderRadiusGeometry}
|
||||
/// The border radius of an attachment
|
||||
/// {@endtemplate}
|
||||
final BorderRadiusGeometry? attachmentBorderRadiusGeometry;
|
||||
|
||||
/// {@template padding}
|
||||
/// The padding of the widget
|
||||
/// {@endtemplate}
|
||||
@@ -505,14 +466,13 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
final bool showPinHighlight;
|
||||
|
||||
/// {@template attachmentBuilders}
|
||||
/// Builder for respective attachment types
|
||||
/// List of attachment builders for rendering attachment widgets pre-defined
|
||||
/// and custom attachment types.
|
||||
///
|
||||
/// If null, the widget will create a default list of attachment builders
|
||||
/// based on the [Attachment.type] of the attachment.
|
||||
/// {@endtemplate}
|
||||
final Map<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
|
||||
@@ -534,7 +494,7 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
final List<StreamMessageAction> customActions;
|
||||
|
||||
/// {@macro onMessageWidgetAttachmentTap}
|
||||
final OnMessageWidgetAttachmentTap? onAttachmentTap;
|
||||
final StreamAttachmentWidgetTapCallback? onAttachmentTap;
|
||||
|
||||
/// {@macro attachmentActionsBuilder}
|
||||
final AttachmentActionsBuilder? attachmentActionsModalBuilder;
|
||||
@@ -574,9 +534,7 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
ShapeBorder? shape,
|
||||
ShapeBorder? attachmentShape,
|
||||
BorderSide? borderSide,
|
||||
BorderSide? attachmentBorderSide,
|
||||
BorderRadiusGeometry? borderRadiusGeometry,
|
||||
BorderRadiusGeometry? attachmentBorderRadiusGeometry,
|
||||
EdgeInsetsGeometry? padding,
|
||||
EdgeInsets? textPadding,
|
||||
EdgeInsetsGeometry? attachmentPadding,
|
||||
@@ -604,7 +562,7 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
bool? showFlagButton,
|
||||
bool? showPinButton,
|
||||
bool? showPinHighlight,
|
||||
Map<String, AttachmentBuilder>? customAttachmentBuilders,
|
||||
List<StreamAttachmentWidgetBuilder>? attachmentBuilders,
|
||||
bool? translateUserAvatar,
|
||||
OnQuotedMessageTap? onQuotedMessageTap,
|
||||
void Function(Message)? onMessageTap,
|
||||
@@ -636,10 +594,7 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
shape: shape ?? this.shape,
|
||||
attachmentShape: attachmentShape ?? this.attachmentShape,
|
||||
borderSide: borderSide ?? this.borderSide,
|
||||
attachmentBorderSide: attachmentBorderSide ?? this.attachmentBorderSide,
|
||||
borderRadiusGeometry: borderRadiusGeometry ?? this.borderRadiusGeometry,
|
||||
attachmentBorderRadiusGeometry:
|
||||
attachmentBorderRadiusGeometry ?? this.attachmentBorderRadiusGeometry,
|
||||
padding: padding ?? this.padding,
|
||||
textPadding: textPadding ?? this.textPadding,
|
||||
attachmentPadding: attachmentPadding ?? this.attachmentPadding,
|
||||
@@ -669,8 +624,7 @@ class StreamMessageWidget extends StatefulWidget {
|
||||
showFlagButton: showFlagButton ?? this.showFlagButton,
|
||||
showPinButton: showPinButton ?? this.showPinButton,
|
||||
showPinHighlight: showPinHighlight ?? this.showPinHighlight,
|
||||
customAttachmentBuilders:
|
||||
customAttachmentBuilders ?? this.customAttachmentBuilders,
|
||||
attachmentBuilders: attachmentBuilders ?? this.attachmentBuilders,
|
||||
translateUserAvatar: translateUserAvatar ?? this.translateUserAvatar,
|
||||
onQuotedMessageTap: onQuotedMessageTap ?? this.onQuotedMessageTap,
|
||||
onMessageTap: onMessageTap ?? this.onMessageTap,
|
||||
@@ -726,8 +680,8 @@ class _StreamMessageWidgetState extends State<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.
|
||||
@@ -739,15 +693,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`:
|
||||
@@ -785,7 +738,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);
|
||||
@@ -798,7 +752,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 &&
|
||||
@@ -889,6 +844,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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_portal/flutter_portal.dart';
|
||||
import 'package:meta/meta.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/builder/attachment_widget_builder.dart';
|
||||
import 'package:stream_chat_flutter/src/message_widget/message_widget_content_components.dart';
|
||||
import 'package:stream_chat_flutter/src/message_widget/reactions/desktop_reactions_builder.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
@@ -47,6 +48,11 @@ class MessageWidgetContent extends StatelessWidget {
|
||||
required this.isGiphy,
|
||||
required this.attachmentBuilders,
|
||||
required this.attachmentPadding,
|
||||
required this.attachmentShape,
|
||||
required this.onAttachmentTap,
|
||||
required this.onShowMessage,
|
||||
required this.onReplyTap,
|
||||
required this.attachmentActionsModalBuilder,
|
||||
required this.textPadding,
|
||||
required this.showReactionPickerTail,
|
||||
required this.translateUserAvatar,
|
||||
@@ -140,11 +146,26 @@ class MessageWidgetContent extends StatelessWidget {
|
||||
final bool isGiphy;
|
||||
|
||||
/// {@macro attachmentBuilders}
|
||||
final Map<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;
|
||||
|
||||
@@ -324,6 +345,12 @@ class MessageWidgetContent extends StatelessWidget {
|
||||
isGiphy: isGiphy,
|
||||
attachmentBuilders: attachmentBuilders,
|
||||
attachmentPadding: attachmentPadding,
|
||||
attachmentShape: attachmentShape,
|
||||
onAttachmentTap: onAttachmentTap,
|
||||
onReplyTap: onReplyTap,
|
||||
onShowMessage: onShowMessage,
|
||||
attachmentActionsModalBuilder:
|
||||
attachmentActionsModalBuilder,
|
||||
textPadding: textPadding,
|
||||
reverse: reverse,
|
||||
onQuotedMessageTap: onQuotedMessageTap,
|
||||
|
||||
@@ -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,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),
|
||||
|
||||
@@ -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,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,7 +330,7 @@ String fileSize(dynamic size, [int round = 2]) {
|
||||
}
|
||||
|
||||
///
|
||||
StreamSvgIcon getFileTypeImage(String? mimeType) {
|
||||
StreamSvgIcon getFileTypeImage([String? mimeType]) {
|
||||
final subtype = mimeType?.split('/').last;
|
||||
switch (subtype) {
|
||||
case '7z':
|
||||
@@ -378,14 +378,14 @@ class WrapAttachmentWidget extends StatelessWidget {
|
||||
const WrapAttachmentWidget({
|
||||
super.key,
|
||||
required this.attachmentWidget,
|
||||
required this.attachmentShape,
|
||||
this.attachmentShape,
|
||||
});
|
||||
|
||||
/// The widget to wrap
|
||||
final Widget attachmentWidget;
|
||||
|
||||
/// The shape of the wrapper
|
||||
final ShapeBorder attachmentShape;
|
||||
final ShapeBorder? attachmentShape;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -434,22 +434,6 @@ int levenshtein(String s, String t, {bool caseSensitive = true}) {
|
||||
return v1[t.length];
|
||||
}
|
||||
|
||||
/// An easy way to handle attachment related operations on a message
|
||||
extension AttachmentPackagesX on Message {
|
||||
/// This extension will return a List of type [StreamAttachmentPackage] from
|
||||
/// the existing attachments of the message
|
||||
List<StreamAttachmentPackage> getAttachmentPackageList() {
|
||||
final _attachmentPackages = List<StreamAttachmentPackage>.generate(
|
||||
attachments.length,
|
||||
(index) => StreamAttachmentPackage(
|
||||
attachment: attachments[index],
|
||||
message: this,
|
||||
),
|
||||
);
|
||||
return _attachmentPackages;
|
||||
}
|
||||
}
|
||||
|
||||
/// PortalLabel that refers to [StreamMessageListView]
|
||||
const kPortalMessageListViewLabel = _PortalMessageListViewLabel();
|
||||
|
||||
|
||||
@@ -193,15 +193,6 @@ typedef QuotedMessageAttachmentThumbnailBuilder = Widget Function(
|
||||
Attachment,
|
||||
);
|
||||
|
||||
/// {@template onMessageWidgetAttachmentTap}
|
||||
/// The action to perform when an attachment in an [StreamMessageWidget]
|
||||
/// is tapped or clicked.
|
||||
/// {@endtemplate}
|
||||
typedef OnMessageWidgetAttachmentTap = void Function(
|
||||
Message message,
|
||||
Attachment attachment,
|
||||
);
|
||||
|
||||
/// {@template attachmentBuilder}
|
||||
/// A widget builder for representing attachments.
|
||||
/// {@endtemplate}
|
||||
@@ -273,6 +264,14 @@ typedef SystemMessageBuilder = Widget Function(
|
||||
Message,
|
||||
);
|
||||
|
||||
/// {@template ephemeralMessageBuilder}
|
||||
/// A widget builder for creating custom ephemeral messages.
|
||||
/// {@endtemplate}
|
||||
typedef EphemeralMessageBuilder = Widget Function(
|
||||
BuildContext,
|
||||
Message,
|
||||
);
|
||||
|
||||
/// {@template threadBuilder}
|
||||
/// A widget builder for creating custom thread UI.
|
||||
/// {@endtemplate}
|
||||
|
||||
@@ -33,6 +33,7 @@ class _IVideoService {
|
||||
/// PNG format.
|
||||
Future<Uint8List?> generateVideoThumbnail({
|
||||
String? video,
|
||||
Map<String, String>? headers,
|
||||
ImageFormat imageFormat = ImageFormat.PNG,
|
||||
int maxHeight = 0,
|
||||
int maxWidth = 0,
|
||||
@@ -63,6 +64,7 @@ class _IVideoService {
|
||||
} else if (isMobileDevice) {
|
||||
return VideoThumbnail.thumbnailData(
|
||||
video: video,
|
||||
headers: headers,
|
||||
imageFormat: imageFormat,
|
||||
maxHeight: maxHeight,
|
||||
maxWidth: maxWidth,
|
||||
|
||||
@@ -1,162 +1,149 @@
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
import 'package:stream_chat_flutter/src/video/video_service.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
import 'package:video_thumbnail/video_thumbnail.dart';
|
||||
import 'package:video_thumbnail/video_thumbnail.dart' show ImageFormat;
|
||||
|
||||
/// {@template streamVideoThumbnailImage}
|
||||
/// Displays a video thumbnail for video attachments.
|
||||
/// {@template video_thumbnail_image}
|
||||
/// A custom [ImageProvider] class for loading video thumbnails as images in
|
||||
/// Flutter.
|
||||
///
|
||||
/// [thumbUrl] is used if provided.
|
||||
/// Use this class to load a video thumbnail as an image. It takes a video URL
|
||||
/// or path and generates a thumbnail image from the video. The generated
|
||||
/// thumbnail image can be used with the [Image] widget.
|
||||
///
|
||||
/// Else [video] (path to local or remote video) is used to generate
|
||||
/// a thumbnail from the video asset.
|
||||
/// {@tool snippet}
|
||||
/// Load a video thumbnail from a URL:
|
||||
///
|
||||
/// WARNING! a local path does not work on web.
|
||||
/// ```dart
|
||||
/// Image(
|
||||
/// image: StreamVideoThumbnailImage(
|
||||
/// video: 'https://example.com/video.mp4',
|
||||
/// maxHeight: 200,
|
||||
/// maxWidth: 200,
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
///
|
||||
/// If both [thumbUrl] and [video] are null, or if a thumbnail can't be
|
||||
/// generated, a stock default image will be used.
|
||||
/// {@tool snippet}
|
||||
/// Load a video thumbnail from a local file path:
|
||||
///
|
||||
/// ```dart
|
||||
/// Image(
|
||||
/// image: StreamVideoThumbnailImage(
|
||||
/// video: '/path/to/video.mp4',
|
||||
/// maxHeight: 200,
|
||||
/// maxWidth: 200,
|
||||
/// ),
|
||||
/// )
|
||||
/// ```
|
||||
/// {@end-tool}
|
||||
/// {@endtemplate}
|
||||
class StreamVideoThumbnailImage extends StatefulWidget {
|
||||
/// {@macro streamVideoThumbnailImage}
|
||||
class StreamVideoThumbnailImage
|
||||
extends ImageProvider<StreamVideoThumbnailImage> {
|
||||
/// {@macro video_thumbnail_image}
|
||||
const StreamVideoThumbnailImage({
|
||||
super.key,
|
||||
this.video,
|
||||
this.thumbUrl,
|
||||
this.constraints,
|
||||
this.fit = BoxFit.cover,
|
||||
this.format = ImageFormat.PNG,
|
||||
this.errorBuilder,
|
||||
this.placeholderBuilder,
|
||||
required this.video,
|
||||
this.headers,
|
||||
this.imageFormat = ImageFormat.PNG,
|
||||
this.maxHeight = 0,
|
||||
this.maxWidth = 0,
|
||||
this.timeMs = 0,
|
||||
this.quality = 10,
|
||||
this.scale = 1.0,
|
||||
});
|
||||
|
||||
/// Video path or url
|
||||
final String? video;
|
||||
/// The URL or path of the video from which to generate the thumbnail.
|
||||
final String video;
|
||||
|
||||
/// Video thumbnail url
|
||||
final String? thumbUrl;
|
||||
/// Additional headers to include in the HTTP request when fetching the video.
|
||||
final Map<String, String>? headers;
|
||||
|
||||
/// Contraints of attachments
|
||||
final BoxConstraints? constraints;
|
||||
/// The format of the generated thumbnail image.
|
||||
final ImageFormat imageFormat;
|
||||
|
||||
/// Fit of image
|
||||
final BoxFit? fit;
|
||||
/// The maximum height of the generated thumbnail image.
|
||||
final int maxHeight;
|
||||
|
||||
/// Image format
|
||||
final ImageFormat format;
|
||||
/// The maximum width of the generated thumbnail image.
|
||||
final int maxWidth;
|
||||
|
||||
/// A builder for building a custom error widget when the thumbnail
|
||||
/// creation fails
|
||||
final Widget Function(BuildContext, Object?)? errorBuilder;
|
||||
/// The timestamp in milliseconds at which to generate the thumbnail.
|
||||
final int timeMs;
|
||||
|
||||
/// A builder for building custom thumbnail loading UI
|
||||
final WidgetBuilder? placeholderBuilder;
|
||||
/// The quality of the generated thumbnail image.
|
||||
final int quality;
|
||||
|
||||
/// The scale to place in the [ImageInfo] object of the image.
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
_StreamVideoThumbnailImageState createState() =>
|
||||
_StreamVideoThumbnailImageState();
|
||||
}
|
||||
|
||||
class _StreamVideoThumbnailImageState extends State<StreamVideoThumbnailImage> {
|
||||
late Future<Uint8List?> thumbnailFuture;
|
||||
late StreamChatThemeData _streamChatTheme;
|
||||
|
||||
void _generateThumbnail() {
|
||||
// Only generate thumbnail if the thumbnail url is not provided.
|
||||
if (widget.thumbUrl == null) {
|
||||
thumbnailFuture = StreamVideoService.generateVideoThumbnail(
|
||||
video: widget.video,
|
||||
imageFormat: widget.format,
|
||||
);
|
||||
}
|
||||
Future<StreamVideoThumbnailImage> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture<StreamVideoThumbnailImage>(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_generateThumbnail();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_streamChatTheme = StreamChatTheme.of(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant StreamVideoThumbnailImage oldWidget) {
|
||||
if (oldWidget.video != widget.video || oldWidget.format != widget.format) {
|
||||
_generateThumbnail();
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final placeHolderWidget = widget.placeholderBuilder?.call(context) ??
|
||||
Shimmer.fromColors(
|
||||
baseColor: _streamChatTheme.colorTheme.disabled,
|
||||
highlightColor: _streamChatTheme.colorTheme.inputBg,
|
||||
child: Image.asset(
|
||||
'images/placeholder.png',
|
||||
fit: BoxFit.cover,
|
||||
height: widget.constraints?.maxHeight,
|
||||
width: widget.constraints?.maxWidth,
|
||||
package: 'stream_chat_flutter',
|
||||
),
|
||||
);
|
||||
|
||||
final errorWidget = widget.errorBuilder?.call(context, null) ??
|
||||
AttachmentError(constraints: widget.constraints);
|
||||
|
||||
final thumbUrl = widget.thumbUrl;
|
||||
if (thumbUrl != null) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: thumbUrl,
|
||||
fit: widget.fit,
|
||||
height: widget.constraints?.maxHeight,
|
||||
width: widget.constraints?.maxWidth,
|
||||
placeholder: (context, __) => placeHolderWidget,
|
||||
errorWidget: (context, url, error) => errorWidget,
|
||||
);
|
||||
}
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: widget.constraints ?? const BoxConstraints.expand(),
|
||||
child: FutureBuilder<Uint8List?>(
|
||||
future: thumbnailFuture,
|
||||
builder: (context, snapshot) => AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
child: Builder(
|
||||
key: ValueKey<AsyncSnapshot<Uint8List?>>(snapshot),
|
||||
builder: (_) {
|
||||
if (snapshot.hasError) return errorWidget;
|
||||
|
||||
if (!snapshot.hasData) {
|
||||
return SizedBox(
|
||||
height: double.maxFinite,
|
||||
width: double.maxFinite,
|
||||
child: placeHolderWidget,
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: double.maxFinite,
|
||||
width: double.maxFinite,
|
||||
child: Image.memory(
|
||||
snapshot.data!,
|
||||
fit: widget.fit,
|
||||
height: widget.constraints?.maxHeight ?? double.infinity,
|
||||
width: widget.constraints?.maxWidth ?? double.infinity,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@Deprecated('Will get replaced by loadImage in the next major version.')
|
||||
ImageStreamCompleter loadBuffer(
|
||||
StreamVideoThumbnailImage key,
|
||||
DecoderBufferCallback decode,
|
||||
) {
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadAsync(key, decode),
|
||||
scale: key.scale,
|
||||
debugLabel: key.video,
|
||||
informationCollector: () => <DiagnosticsNode>[
|
||||
DiagnosticsProperty<ImageProvider>('Image provider', this),
|
||||
DiagnosticsProperty<StreamVideoThumbnailImage>('Image key', key),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@Deprecated('Will get replaced by loadImage in the next major version.')
|
||||
Future<ui.Codec> _loadAsync(
|
||||
StreamVideoThumbnailImage key,
|
||||
DecoderBufferCallback decode,
|
||||
) async {
|
||||
assert(key == this, '$key is not $this');
|
||||
|
||||
final bytes = await StreamVideoService.generateVideoThumbnail(
|
||||
video: key.video,
|
||||
headers: key.headers,
|
||||
imageFormat: key.imageFormat,
|
||||
maxHeight: key.maxHeight,
|
||||
maxWidth: key.maxWidth,
|
||||
timeMs: key.timeMs,
|
||||
quality: key.quality,
|
||||
);
|
||||
|
||||
if (bytes == null || bytes.lengthInBytes == 0) {
|
||||
throw Exception('VideoThumbnailImage is an empty file: ${key.video}');
|
||||
}
|
||||
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
|
||||
return decode(buffer);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) {
|
||||
return false;
|
||||
}
|
||||
return other is StreamVideoThumbnailImage &&
|
||||
other.video == video &&
|
||||
other.scale == scale;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(video, scale);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
final runtimeType = objectRuntimeType(this, 'StreamVideoThumbnailImage');
|
||||
return '$runtimeType($video, scale: $scale)';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,9 @@ export 'package:stream_chat_flutter/src/message_widget/quoted_message.dart';
|
||||
export 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
|
||||
|
||||
export 'src/attachment/attachment.dart';
|
||||
export 'src/attachment/attachment_title.dart';
|
||||
export 'src/attachment/gallery_attachment.dart';
|
||||
export 'src/attachment/handler/stream_attachment_handler.dart';
|
||||
export 'src/attachment/image_attachment.dart';
|
||||
export 'src/attachment/image_group.dart';
|
||||
export 'src/attachment/stream_attachment_package.dart';
|
||||
export 'src/attachment/url_attachment.dart';
|
||||
export 'src/attachment/video_attachment.dart';
|
||||
@@ -96,4 +95,5 @@ export 'src/utils/device_segmentation.dart';
|
||||
export 'src/utils/extensions.dart';
|
||||
export 'src/utils/helpers.dart';
|
||||
export 'src/utils/typedefs.dart';
|
||||
// TODO: Remove this in favor of StreamVideoAttachmentThumbnail.
|
||||
export 'src/video/video_thumbnail_image.dart';
|
||||
|
||||
@@ -29,6 +29,7 @@ dependencies:
|
||||
http_parser: ^4.0.2
|
||||
image_gallery_saver: ^2.0.3
|
||||
image_picker: ^1.0.2
|
||||
image_size_getter: ^2.1.2
|
||||
jiffy: ^6.2.1
|
||||
lottie: ^2.6.0
|
||||
meta: ^1.9.1
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
import '../mocks.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('AttachmentError test', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
builder: (context, child) => StreamChat(
|
||||
client: MockClient(),
|
||||
child: child,
|
||||
),
|
||||
home: const Scaffold(
|
||||
body: Center(
|
||||
child: AttachmentError(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(Icon), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
import '../mocks.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('AttachmentTitle renders properly', (tester) async {
|
||||
final mockClient = MockClient();
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
builder: (context, child) => StreamChat(
|
||||
client: mockClient,
|
||||
streamChatThemeData: StreamChatThemeData.light(),
|
||||
child: child,
|
||||
),
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
return Center(
|
||||
child: StreamAttachmentTitle(
|
||||
attachment: Attachment(
|
||||
title: 'Test Attachment',
|
||||
type: 'video',
|
||||
titleLink: 'https://www.youtube.com/watch?v=lytQi-slT5Y',
|
||||
ogScrapeUrl: 'https://www.youtube.com/watch?v=lytQi-slT5Y',
|
||||
),
|
||||
messageTheme: StreamChatTheme.of(context).ownMessageTheme,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.byType(StreamAttachmentTitle), findsOneWidget);
|
||||
expect(find.text('Test Attachment'), findsOneWidget);
|
||||
expect(find.text('https://www.youtube.com/watch?v=lytQi-slT5Y'),
|
||||
findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -30,7 +30,7 @@ void main() {
|
||||
300,
|
||||
)),
|
||||
message: Message(),
|
||||
attachment: Attachment(
|
||||
file: Attachment(
|
||||
type: 'file',
|
||||
title: 'example.pdf',
|
||||
extraData: const {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
import 'package:stream_chat_flutter/src/attachment/thumbnail/image_attachment_thumbnail.dart';
|
||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
||||
|
||||
import '../mocks.dart';
|
||||
@@ -18,6 +19,27 @@ void main() {
|
||||
final themeData = ThemeData();
|
||||
final streamTheme = StreamChatThemeData.fromTheme(themeData);
|
||||
|
||||
final attachments = [
|
||||
Attachment(
|
||||
type: 'image',
|
||||
title: 'example.png',
|
||||
imageUrl:
|
||||
'https://logowik.com/content/uploads/images/flutter5786.jpg',
|
||||
extraData: const {
|
||||
'mime_type': 'png',
|
||||
},
|
||||
),
|
||||
Attachment(
|
||||
type: 'image',
|
||||
title: 'example.png',
|
||||
imageUrl:
|
||||
'https://logowik.com/content/uploads/images/flutter5786.jpg',
|
||||
extraData: const {
|
||||
'mime_type': 'png',
|
||||
},
|
||||
),
|
||||
];
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: StreamChatTheme(
|
||||
@@ -25,33 +47,23 @@ void main() {
|
||||
child: StreamChannel(
|
||||
channel: channel,
|
||||
child: SizedBox(
|
||||
child: StreamImageGroup(
|
||||
messageTheme: streamTheme.ownMessageTheme,
|
||||
child: StreamGalleryAttachment(
|
||||
constraints: BoxConstraints.tight(const Size(
|
||||
300,
|
||||
300,
|
||||
)),
|
||||
message: Message(),
|
||||
images: [
|
||||
Attachment(
|
||||
type: 'image',
|
||||
title: 'example.png',
|
||||
imageUrl:
|
||||
'https://logowik.com/content/uploads/images/flutter5786.jpg',
|
||||
extraData: const {
|
||||
'mime_type': 'png',
|
||||
},
|
||||
),
|
||||
Attachment(
|
||||
type: 'image',
|
||||
title: 'example.png',
|
||||
imageUrl:
|
||||
'https://logowik.com/content/uploads/images/flutter5786.jpg',
|
||||
extraData: const {
|
||||
'mime_type': 'png',
|
||||
},
|
||||
),
|
||||
],
|
||||
attachments: attachments,
|
||||
itemBuilder: (context, index) {
|
||||
final attachment = attachments[index];
|
||||
|
||||
return StreamImageAttachmentThumbnail(
|
||||
image: attachment,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -30,7 +30,7 @@ void main() {
|
||||
300,
|
||||
)),
|
||||
message: Message(),
|
||||
attachment: Attachment(
|
||||
giphy: Attachment(
|
||||
type: 'giphy',
|
||||
title: 'example.gif',
|
||||
imageUrl:
|
||||
|
||||
@@ -26,13 +26,12 @@ void main() {
|
||||
channel: channel,
|
||||
child: SizedBox(
|
||||
child: StreamImageAttachment(
|
||||
messageTheme: streamTheme.ownMessageTheme,
|
||||
constraints: BoxConstraints.tight(const Size(
|
||||
300,
|
||||
300,
|
||||
)),
|
||||
message: Message(),
|
||||
attachment: Attachment(
|
||||
image: Attachment(
|
||||
type: 'image',
|
||||
title: 'example.png',
|
||||
imageUrl:
|
||||
|
||||
@@ -26,6 +26,7 @@ void main() {
|
||||
child: SizedBox(
|
||||
child: StreamUrlAttachment(
|
||||
messageTheme: streamTheme.ownMessageTheme,
|
||||
message: Message(),
|
||||
hostDisplayName: 'Test',
|
||||
urlAttachment: Attachment(
|
||||
title: 'Flutter',
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |