Merge pull request #1631 from GetStream/feat/message-input-attachment-builder

This commit is contained in:
Sahil Kumar
2023-06-21 21:03:28 +05:30
committed by GitHub
7 changed files with 521 additions and 180 deletions
+20
View File
@@ -7,6 +7,26 @@
- [[#1621]](https://github.com/GetStream/stream-chat-flutter/issues/1621) Fixed `createdAtStyle` null check error
in `SendingIndicatorBuilder`.
✅ Added
- Added support for customizing attachments in `StreamMessageInput`. Use various properties mentioned
below. [#1511](https://github.com/GetStream/stream-chat-flutter/issues/1511)
* `StreamMessageInput.attachmentListBuilder` to customize the attachment list.
* `StreamMessageInput.fileAttachmentListBuilder` to customize the file attachment list.
* `StreamMessageInput.mediaAttachmentListBuilder` to customize the media attachment list. Includes images, videos
and gifs.
* `StreamMessageInput.fileAttachmentBuilder` to customize the file attachment item shown in `FileAttachmentList`.
* `StreamMessageInput.mediaAttachmentBuilder` to customize the media attachment item shown in
`MediaAttachmentList`.
- Added `StreamMessageInput.quotedMessageAttachmentThumbnailBuilders` to customize the thumbnail builders for quoted
message attachments.
🔄 Changed
- Deprecated `StreamMessageInput.attachmentThumbnailBuilders` in favor of `StreamMessageInput.mediaAttachmentBuilder`.
## 6.4.0
🐞 Fixed
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/attachment/attachment_widget.dart';
import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamVideoAttachment}
@@ -5,7 +5,6 @@ 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/video/video_thumbnail_image.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@template streamGalleryFooter}
@@ -2,7 +2,6 @@ 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/message_input/clear_input_item_button.dart';
import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:video_player/video_player.dart';
@@ -3,14 +3,10 @@
import 'dart:async';
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart'
hide ErrorListener;
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/platform_widget_builder/src/platform_widget_builder.dart';
import 'package:stream_chat_flutter/src/message_input/attachment_button.dart';
import 'package:stream_chat_flutter/src/message_input/command_button.dart';
@@ -19,7 +15,6 @@ import 'package:stream_chat_flutter/src/message_input/quoted_message_widget.dart
import 'package:stream_chat_flutter/src/message_input/quoting_message_top_area.dart';
import 'package:stream_chat_flutter/src/message_input/simple_safe_area.dart';
import 'package:stream_chat_flutter/src/message_input/tld.dart';
import 'package:stream_chat_flutter/src/video/video_thumbnail_image.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
const _kCommandTrigger = '/';
@@ -110,6 +105,12 @@ class StreamMessageInput extends StatefulWidget {
this.messageInputController,
this.actions = const [],
this.actionsLocation = ActionsLocation.left,
this.attachmentListBuilder,
this.fileAttachmentListBuilder,
this.mediaAttachmentListBuilder,
this.fileAttachmentBuilder,
this.mediaAttachmentBuilder,
@Deprecated('Use `mediaAttachmentBuilder` instead.')
this.attachmentThumbnailBuilders,
this.focusNode,
this.sendButtonLocation = SendButtonLocation.outside,
@@ -130,6 +131,7 @@ class StreamMessageInput extends StatefulWidget {
this.mentionAllAppUsers = false,
this.sendButtonBuilder,
this.quotedMessageBuilder,
this.quotedMessageAttachmentThumbnailBuilders,
this.shouldKeepFocusAfterMessage,
this.validator = _defaultValidator,
this.restorationId,
@@ -209,9 +211,41 @@ class StreamMessageInput extends StatefulWidget {
/// The location of the custom actions.
final ActionsLocation actionsLocation;
/// Builder used to build the attachment list present in the message input.
///
/// In case you want to customize only sub-parts of the attachment list,
/// consider using [fileAttachmentListBuilder], [mediaAttachmentListBuilder].
final AttachmentListBuilder? attachmentListBuilder;
/// Builder used to build the file type attachment list.
///
/// In case you want to customize the attachment item, consider using
/// [fileAttachmentBuilder].
final AttachmentListBuilder? fileAttachmentListBuilder;
/// Builder used to build the media type attachment list.
///
/// In case you want to customize the attachment item, consider using
/// [mediaAttachmentBuilder].
final AttachmentListBuilder? mediaAttachmentListBuilder;
/// Builder used to build the file attachment item.
final AttachmentItemBuilder? fileAttachmentBuilder;
/// Builder used to build the media attachment item.
final AttachmentItemBuilder? mediaAttachmentBuilder;
/// Map that defines a thumbnail builder for an attachment type.
@Deprecated('Use `mediaAttachmentBuilder` instead.')
final Map<String, AttachmentThumbnailBuilder>? attachmentThumbnailBuilders;
/// Map that defines a thumbnail builder for an attachment type.
///
/// This is used to build the thumbnail for the attachment in the quoted
/// message.
final Map<String, QuotedMessageAttachmentThumbnailBuilder>?
quotedMessageAttachmentThumbnailBuilders;
/// The focus node associated to the TextField.
final FocusNode? focusNode;
@@ -1147,193 +1181,93 @@ class StreamMessageInputState extends State<StreamMessageInput>
messageTheme: _streamChatTheme.otherMessageTheme,
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
onQuotedMessageClear: widget.onQuotedMessageCleared,
attachmentThumbnailBuilders: widget.attachmentThumbnailBuilders,
attachmentThumbnailBuilders:
widget.quotedMessageAttachmentThumbnailBuilders,
);
}
Widget _buildAttachments() {
final nonOGAttachments = _effectiveController.attachments.where(
(it) => it.titleLink == null,
);
final attachments = _effectiveController.attachments;
final nonOGAttachments = attachments.where((it) {
return it.titleLink == null;
}).toList(growable: false);
// If there are no attachments, return an empty widget
if (nonOGAttachments.isEmpty) return const Offstage();
final fileAttachments = nonOGAttachments
.where((it) => it.type == 'file')
.toList(growable: false);
final remainingAttachments = nonOGAttachments
.where((it) => it.type != 'file')
.toList(growable: false);
return Column(
children: [
if (fileAttachments.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: LimitedBox(
maxHeight: 136,
child: ListView(
reverse: true,
shrinkWrap: true,
children: fileAttachments.reversed
.map<Widget>(
(e) => ClipRRect(
borderRadius: BorderRadius.circular(10),
child: StreamFileAttachment(
message: Message(), // dummy message
attachment: e,
constraints: BoxConstraints.loose(Size(
MediaQuery.of(context).size.width * 0.65,
56,
)),
trailing: Padding(
padding: const EdgeInsets.all(8),
child: _buildRemoveButton(e),
),
),
),
)
.insertBetween(const SizedBox(height: 8)),
),
),
),
if (remainingAttachments.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
child: LimitedBox(
maxHeight: 104,
child: ListView(
scrollDirection: Axis.horizontal,
children: remainingAttachments
.map<Widget>(
(attachment) => ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Stack(
children: <Widget>[
AspectRatio(
aspectRatio: 1,
child: SizedBox(
height: 104,
width: 104,
child: _buildAttachment(attachment),
),
),
Positioned(
top: 8,
right: 8,
child: _buildRemoveButton(attachment),
),
],
),
),
)
.insertBetween(const SizedBox(width: 8)),
),
),
),
],
);
}
Widget _buildRemoveButton(Attachment attachment) {
return SizedBox(
height: 24,
width: 24,
child: RawMaterialButton(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
highlightElevation: 0,
focusElevation: 0,
hoverElevation: 0,
onPressed: () async {
final file = attachment.file;
final uploadState = attachment.uploadState;
// Default callback for removing an attachment.
Future<void> onAttachmentRemovePressed(Attachment attachment) async {
final file = attachment.file;
final uploadState = attachment.uploadState;
if (file != null && !uploadState.isSuccess && !isWeb) {
await StreamAttachmentHandler.instance.deleteAttachmentFile(
attachmentFile: file,
);
}
if (file != null && !uploadState.isSuccess && !isWeb) {
await StreamAttachmentHandler.instance.deleteAttachmentFile(
attachmentFile: file,
);
}
_effectiveController.removeAttachmentById(attachment.id);
},
fillColor:
_streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5),
child: Center(
child: StreamSvgIcon.close(
size: 24,
color: _streamChatTheme.colorTheme.barsBg,
),
),
),
);
}
_effectiveController.removeAttachmentById(attachment.id);
}
Widget _buildAttachment(Attachment attachment) {
if (widget.attachmentThumbnailBuilders?.containsKey(attachment.type) ==
true) {
return widget.attachmentThumbnailBuilders![attachment.type!]!(
// If the user has provided a custom attachment list builder, use that.
final attachmentListBuilder = widget.attachmentListBuilder;
if (attachmentListBuilder != null) {
return attachmentListBuilder(
context,
attachment,
nonOGAttachments,
onAttachmentRemovePressed,
);
}
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) =>
getFileTypeImage(attachment.extraData['other'] as String?),
placeholder: (context, _) => Shimmer.fromColors(
baseColor: _streamChatTheme.colorTheme.disabled,
highlightColor: _streamChatTheme.colorTheme.inputBg,
child: Image.asset(
'images/placeholder.png',
fit: BoxFit.cover,
package: 'stream_chat_flutter',
),
// Otherwise, use the default attachment list builder.
return LimitedBox(
maxHeight: 240,
child: StreamMessageInputAttachmentList(
attachments: nonOGAttachments,
onRemovePressed: onAttachmentRemovePressed,
fileAttachmentListBuilder: widget.fileAttachmentListBuilder,
mediaAttachmentListBuilder: widget.mediaAttachmentListBuilder,
fileAttachmentBuilder: widget.fileAttachmentBuilder,
mediaAttachmentBuilder: widget.mediaAttachmentBuilder ??
// For backward compatibility.
// TODO: Remove in the next major release.
(context, attachment, onRemovePressed) {
final Widget mediaAttachmentThumbnail;
final builder =
widget.attachmentThumbnailBuilders?[attachment.type];
if (builder != null) {
mediaAttachmentThumbnail = builder(context, attachment);
} else {
mediaAttachmentThumbnail = MessageInputMediaAttachmentThumbnail(
attachment: attachment,
);
}
return ClipRRect(
key: Key(attachment.id),
borderRadius: BorderRadius.circular(10),
child: Stack(
children: <Widget>[
AspectRatio(
aspectRatio: 1,
child: mediaAttachmentThumbnail,
),
Positioned(
top: 8,
right: 8,
child: RemoveAttachmentButton(
onPressed: onRemovePressed != null
? () => onRemovePressed(attachment)
: null,
),
),
],
),
);
case 'video':
return Stack(
children: [
StreamVideoThumbnailImage(
constraints: BoxConstraints.loose(
const Size(
104,
104,
),
),
video: attachment.file?.path ?? attachment.assetUrl,
),
Positioned(
left: 8,
bottom: 10,
child: SvgPicture.asset(
'svgs/video_call_icon.svg',
package: 'stream_chat_flutter',
),
),
],
);
default:
return const ColoredBox(
color: Colors.black26,
child: Icon(Icons.insert_drive_file),
);
}
},
),
);
}
Widget _buildCommandButton(BuildContext context) {
@@ -0,0 +1,388 @@
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/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.
///
/// see more:
/// - [StreamMessageInputAttachmentList]
typedef AttachmentListBuilder = Widget Function(
BuildContext context,
List<Attachment> attachments,
ValueSetter<Attachment>? onRemovePressed,
);
/// WidgetBuilder used to build the message input attachment item.
///
/// see more:
/// - [StreamMessageInputAttachmentList]
typedef AttachmentItemBuilder = Widget Function(
BuildContext context,
Attachment attachment,
ValueSetter<Attachment>? onRemovePressed,
);
/// {@template stream_message_input_attachment_list}
/// Widget used to display the list of attachments added to the message input.
///
/// By default, it displays the list of file attachments and media attachments
/// separately.
///
/// You can customize the list of file attachments and media attachments using
/// [fileAttachmentListBuilder] and [mediaAttachmentListBuilder] respectively.
///
/// You can also customize the attachment item using [fileAttachmentBuilder] and
/// [mediaAttachmentBuilder] respectively.
///
/// You can override the default action of removing an attachment by providing
/// [onRemovePressed].
/// {@endtemplate}
class StreamMessageInputAttachmentList extends StatefulWidget {
/// {@macro stream_message_input_attachment_list}
const StreamMessageInputAttachmentList({
super.key,
required this.attachments,
this.onRemovePressed,
this.fileAttachmentBuilder,
this.mediaAttachmentBuilder,
this.fileAttachmentListBuilder,
this.mediaAttachmentListBuilder,
});
/// List of attachments to display thumbnails for.
///
/// Open graph should be filtered out.
final Iterable<Attachment> attachments;
/// Builder used to build the file attachment item.
final AttachmentItemBuilder? fileAttachmentBuilder;
/// Builder used to build the media attachment item.
final AttachmentItemBuilder? mediaAttachmentBuilder;
/// Builder used to build the file attachment list.
final AttachmentListBuilder? fileAttachmentListBuilder;
/// Builder used to build the media attachment list.
final AttachmentListBuilder? mediaAttachmentListBuilder;
/// Callback called when the remove button is pressed.
final ValueSetter<Attachment>? onRemovePressed;
@override
State<StreamMessageInputAttachmentList> createState() =>
_StreamMessageInputAttachmentListState();
}
class _StreamMessageInputAttachmentListState
extends State<StreamMessageInputAttachmentList> {
List<Attachment> fileAttachments = [];
List<Attachment> mediaAttachments = [];
void _updateAttachments() {
// Clear the lists.
fileAttachments.clear();
mediaAttachments.clear();
// Split the attachments into file and media attachments.
for (final attachment in widget.attachments) {
if (attachment.type == 'file') {
fileAttachments.add(attachment);
} else {
mediaAttachments.add(attachment);
}
}
}
@override
void initState() {
super.initState();
_updateAttachments();
}
@override
void didUpdateWidget(covariant StreamMessageInputAttachmentList oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.attachments != widget.attachments) {
_updateAttachments();
}
}
@override
Widget build(BuildContext context) {
// If there are no attachments, return an empty box.
if (fileAttachments.isEmpty && mediaAttachments.isEmpty) {
return const SizedBox();
}
return SingleChildScrollView(
padding: const EdgeInsets.only(top: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (mediaAttachments.isNotEmpty)
Flexible(
child: widget.mediaAttachmentListBuilder?.call(
context,
mediaAttachments,
widget.onRemovePressed,
) ??
MessageInputMediaAttachments(
attachments: mediaAttachments,
attachmentBuilder: widget.mediaAttachmentBuilder,
onRemovePressed: widget.onRemovePressed,
),
),
if (fileAttachments.isNotEmpty)
Flexible(
child: widget.fileAttachmentListBuilder?.call(
context,
fileAttachments,
widget.onRemovePressed,
) ??
MessageInputFileAttachments(
attachments: fileAttachments,
attachmentBuilder: widget.fileAttachmentBuilder,
onRemovePressed: widget.onRemovePressed,
),
),
].insertBetween(
Divider(
height: 16,
indent: 16,
endIndent: 16,
thickness: 1,
color: StreamChatTheme.of(context).colorTheme.disabled,
),
),
),
);
}
}
/// Widget used to display the list of file type attachments added to the
/// message input.
class MessageInputFileAttachments extends StatelessWidget {
/// Creates a new FileAttachments widget.
const MessageInputFileAttachments({
super.key,
required this.attachments,
this.attachmentBuilder,
this.onRemovePressed,
});
/// List of file type attachments to display thumbnails for.
final List<Attachment> attachments;
/// Builder used to build the file type attachment item.
final AttachmentItemBuilder? attachmentBuilder;
/// Callback called when the remove button is pressed.
final ValueSetter<Attachment>? onRemovePressed;
@override
Widget build(BuildContext context) {
return ListView(
reverse: true,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 8),
children: attachments.reversed.map<Widget>(
(attachment) {
// If a custom builder is provided, use it.
final builder = attachmentBuilder;
if (builder != null) {
return builder(context, attachment, onRemovePressed);
}
// 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.sizeOf(context).width * 0.65,
56,
)),
trailing: Padding(
padding: const EdgeInsets.all(8),
child: RemoveAttachmentButton(
onPressed: onRemovePressed != null
? () => onRemovePressed!(attachment)
: null,
),
),
),
);
},
).insertBetween(const SizedBox(height: 8)),
);
}
}
/// Widget used to display the list of media type attachments added to the
/// message input.
class MessageInputMediaAttachments extends StatelessWidget {
/// Creates a new MediaAttachments widget.
const MessageInputMediaAttachments({
super.key,
required this.attachments,
this.attachmentBuilder,
this.onRemovePressed,
});
/// List of media type attachments to display thumbnails for.
///
/// Only attachments of type `image`, `video` and `giphy` are supported. Shows
/// a placeholder for other types of attachments.
final List<Attachment> attachments;
/// Builder used to build the media type attachment item.
final AttachmentItemBuilder? attachmentBuilder;
/// Callback called when the remove button is pressed.
final ValueSetter<Attachment>? onRemovePressed;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 104,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: attachments.map<Widget>(
(attachment) {
// If a custom builder is provided, use it.
final builder = attachmentBuilder;
if (builder != null) {
return builder(context, attachment, onRemovePressed);
}
return ClipRRect(
key: Key(attachment.id),
borderRadius: BorderRadius.circular(10),
child: Stack(
children: <Widget>[
AspectRatio(
aspectRatio: 1,
child: MessageInputMediaAttachmentThumbnail(
attachment: attachment,
),
),
Positioned(
top: 8,
right: 8,
child: RemoveAttachmentButton(
onPressed: onRemovePressed != null
? () => onRemovePressed!(attachment)
: null,
),
),
],
),
);
},
).insertBetween(const SizedBox(width: 8)),
),
);
}
}
/// 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.
const RemoveAttachmentButton({super.key, this.onPressed});
/// Callback when the remove attachment button is pressed.
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final theme = StreamChatTheme.of(context);
final colorTheme = theme.colorTheme;
return SizedBox(
width: 24,
height: 24,
child: RawMaterialButton(
elevation: 0,
focusElevation: 0,
hoverElevation: 0,
highlightElevation: 0,
onPressed: onPressed,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
fillColor: colorTheme.textHighEmphasis.withOpacity(0.5),
child: StreamSvgIcon.close(
size: 24,
color: colorTheme.barsBg,
),
),
);
}
}
@@ -48,6 +48,7 @@ export 'src/message_input/attachment_picker/stream_attachment_picker_bottom_shee
export 'src/message_input/countdown_button.dart';
export 'src/message_input/enums.dart';
export 'src/message_input/stream_message_input.dart';
export 'src/message_input/stream_message_input_attachment_list.dart';
export 'src/message_input/stream_message_send_button.dart';
export 'src/message_input/stream_message_text_field.dart';
export 'src/message_list_view/message_details.dart';
@@ -96,3 +97,4 @@ export 'src/utils/device_segmentation.dart';
export 'src/utils/extensions.dart';
export 'src/utils/helpers.dart';
export 'src/utils/typedefs.dart';
export 'src/video/video_thumbnail_image.dart';