diff --git a/packages/stream_chat/lib/src/client/retry_queue.dart b/packages/stream_chat/lib/src/client/retry_queue.dart index e1b6df70..ad140ad6 100644 --- a/packages/stream_chat/lib/src/client/retry_queue.dart +++ b/packages/stream_chat/lib/src/client/retry_queue.dart @@ -1,18 +1,13 @@ import 'dart:async'; import 'package:collection/collection.dart'; -import 'package:logging/logging.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:stream_chat/src/client/channel.dart'; import 'package:stream_chat/src/client/retry_policy.dart'; -import 'package:stream_chat/src/core/error/error.dart'; -import 'package:stream_chat/src/core/models/message.dart'; -import 'package:stream_chat/src/event_type.dart'; import 'package:stream_chat/stream_chat.dart'; -/// The retry queue associated to a channel +/// The retry queue associated to a channel. class RetryQueue { - /// Instantiate a new RetryQueue object + /// Instantiate a new RetryQueue object. RetryQueue({ required this.channel, this.logger, @@ -22,13 +17,13 @@ class RetryQueue { _listenFailedEvents(); } - /// The channel of this queue + /// The channel of this queue. final Channel channel; - /// The client associated with this [channel] + /// The client associated with this [channel]. final StreamChatClient client; - /// The logger associated to this queue + /// The logger associated to this queue. final Logger? logger; late final RetryPolicy _retryPolicy; @@ -68,7 +63,7 @@ class RetryQueue { }).addTo(_compositeSubscription); } - /// Add a list of messages + /// Add a list of messages. void add(List messages) { if (messages.isEmpty) return; if (!_messageQueue.containsAllMessage(messages)) { @@ -118,6 +113,7 @@ class RetryQueue { } catch (e) { if (e is! StreamChatNetworkError || !e.isRetriable) { _messageQueue.removeMessage(message); + _sendFailedEvent(message); return true; } // retry logic @@ -179,10 +175,10 @@ class RetryQueue { } } - /// Whether our [_messageQueue] has messages or not + /// Whether our [_messageQueue] has messages or not. bool get hasMessages => _messageQueue.isNotEmpty; - /// Call this method to dispose this object + /// Call this method to dispose this object. void dispose() { _messageQueue.clear(); _compositeSubscription.dispose(); diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 10ff713e..017bb06f 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -8,13 +8,13 @@ import 'package:uuid/uuid.dart'; part 'message.g.dart'; -class _PinExpires { - const _PinExpires(); +class _NullConst { + const _NullConst(); } -const _pinExpires = _PinExpires(); +const _nullConst = _NullConst(); -/// Enum defining the status of a sending message +/// Enum defining the status of a sending message. enum MessageSendingStatus { /// Message is being sent sending, @@ -40,10 +40,10 @@ enum MessageSendingStatus { sent, } -/// The class that contains the information about a message +/// The class that contains the information about a message. @JsonSerializable() class Message extends Equatable { - /// Constructor used for json serialization + /// Constructor used for json serialization. Message({ String? id, this.text, @@ -58,44 +58,47 @@ class Message extends Equatable { this.ownReactions, this.parentId, this.quotedMessage, - this.quotedMessageId, + String? quotedMessageId, this.replyCount = 0, this.threadParticipants, this.showInChannel, this.command, DateTime? createdAt, DateTime? updatedAt, + this.deletedAt, this.user, this.pinned = false, this.pinnedAt, DateTime? pinExpires, this.pinnedBy, this.extraData = const {}, - this.deletedAt, - this.status = MessageSendingStatus.sent, + this.status = MessageSendingStatus.sending, this.i18n, }) : id = id ?? const Uuid().v4(), pinExpires = pinExpires?.toUtc(), - createdAt = createdAt ?? DateTime.now(), - updatedAt = updatedAt ?? DateTime.now(); + _createdAt = createdAt, + _updatedAt = updatedAt, + _quotedMessageId = quotedMessageId; - /// Create a new instance from a json + /// Create a new instance from JSON. factory Message.fromJson(Map json) => _$MessageFromJson( Serializer.moveToExtraDataFromRoot(json, topLevelFields), + ).copyWith( + status: MessageSendingStatus.sent, ); /// The message ID. This is either created by Stream or set client side when /// the message is added. final String id; - /// The text of this message + /// The text of this message. final String? text; - /// The status of a sending message + /// The status of a sending message. @JsonKey(ignore: true) final MessageSendingStatus status; - /// The message type + /// The message type. @JsonKey( includeIfNull: false, toJson: Serializer.readOnly, @@ -107,15 +110,15 @@ class Message extends Equatable { @JsonKey(includeIfNull: false) final List attachments; - /// The list of user mentioned in the message + /// The list of user mentioned in the message. @JsonKey(toJson: User.toIds) final List mentionedUsers; - /// A map describing the count of number of every reaction + /// A map describing the count of number of every reaction. @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final Map? reactionCounts; - /// A map describing the count of score of every reaction + /// A map describing the count of score of every reaction. @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final Map? reactionScores; @@ -130,12 +133,14 @@ class Message extends Equatable { /// The ID of the parent message, if the message is a thread reply. final String? parentId; - /// A quoted reply message + /// A quoted reply message. @JsonKey(toJson: Serializer.readOnly) final Message? quotedMessage; + final String? _quotedMessageId; + /// The ID of the quoted message, if the message is a quoted reply. - final String? quotedMessageId; + String? get quotedMessageId => _quotedMessageId ?? quotedMessage?.id; /// Reserved field indicating the number of replies for this message. @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) @@ -148,10 +153,10 @@ class Message extends Equatable { /// Check if this message needs to show in the channel. final bool? showInChannel; - /// If true the message is silent + /// If true the message is silent. final bool silent; - /// If true the message is shadowed + /// If true the message is shadowed. @JsonKey( includeIfNull: false, toJson: Serializer.readOnly, @@ -162,56 +167,61 @@ class Message extends Equatable { @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final String? command; - /// Reserved field indicating when the message was created. - @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) - final DateTime createdAt; - - /// Reserved field indicating when the message was updated last time. - @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) - final DateTime updatedAt; - - /// User who sent the message - @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) - final User? user; - - /// If true the message is pinned - final bool pinned; - - /// Reserved field indicating when the message was pinned - @JsonKey(toJson: Serializer.readOnly) - final DateTime? pinnedAt; - - /// Reserved field indicating when the message will expire - /// - /// if `null` message has no expiry - final DateTime? pinExpires; - - /// Reserved field indicating who pinned the message - @JsonKey(toJson: Serializer.readOnly) - final User? pinnedBy; - - /// Message custom extraData - @JsonKey(includeIfNull: false) - final Map extraData; - - /// True if the message is a system info - bool get isSystem => type == 'system'; - - /// True if the message has been deleted - bool get isDeleted => type == 'deleted'; - - /// True if the message is ephemeral - bool get isEphemeral => type == 'ephemeral'; + final DateTime? _createdAt; /// Reserved field indicating when the message was deleted. @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) final DateTime? deletedAt; + /// Reserved field indicating when the message was created. + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + DateTime get createdAt => _createdAt ?? DateTime.now(); + + final DateTime? _updatedAt; + + /// Reserved field indicating when the message was updated last time. + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + DateTime get updatedAt => _updatedAt ?? DateTime.now(); + + /// User who sent the message. + @JsonKey(includeIfNull: false, toJson: Serializer.readOnly) + final User? user; + + /// If true the message is pinned. + final bool pinned; + + /// Reserved field indicating when the message was pinned. + @JsonKey(toJson: Serializer.readOnly) + final DateTime? pinnedAt; + + /// Reserved field indicating when the message will expire. + /// + /// If `null` message has no expiry. + final DateTime? pinExpires; + + /// Reserved field indicating who pinned the message. + @JsonKey(toJson: Serializer.readOnly) + final User? pinnedBy; + + /// Message custom extraData. + @JsonKey(includeIfNull: false) + final Map extraData; + + /// True if the message is a system info. + bool get isSystem => type == 'system'; + + /// True if the message has been deleted. + bool get isDeleted => type == 'deleted'; + + /// True if the message is ephemeral. + bool get isEphemeral => type == 'ephemeral'; + /// A Map of translations. @JsonKey(includeIfNull: false) final Map? i18n; /// Known top level fields. + /// /// Useful for [Serializer] methods. static const topLevelFields = [ 'id', @@ -244,7 +254,7 @@ class Message extends Equatable { 'i18n', ]; - /// Serialize to json + /// Serialize to json. Map toJson() => Serializer.moveFromExtraDataToRoot( _$MessageToJson(this), ); @@ -256,18 +266,18 @@ class Message extends Equatable { String? type, List? attachments, List? mentionedUsers, + bool? silent, + bool? shadowed, Map? reactionCounts, Map? reactionScores, List? latestReactions, List? ownReactions, String? parentId, - Message? quotedMessage, - String? quotedMessageId, + Object? quotedMessage = _nullConst, + Object? quotedMessageId = _nullConst, int? replyCount, List? threadParticipants, bool? showInChannel, - bool? shadowed, - bool? silent, String? command, DateTime? createdAt, DateTime? updatedAt, @@ -275,7 +285,7 @@ class Message extends Equatable { User? user, bool? pinned, DateTime? pinnedAt, - Object? pinExpires = _pinExpires, + Object? pinExpires = _nullConst, User? pinnedBy, Map? extraData, MessageSendingStatus? status, @@ -284,41 +294,68 @@ class Message extends Equatable { assert(() { if (pinExpires is! DateTime && pinExpires != null && - pinExpires is! _PinExpires) { + pinExpires is! _NullConst) { throw ArgumentError('`pinExpires` can only be set as DateTime or null'); } return true; }(), 'Validate type for pinExpires'); + + assert(() { + if (quotedMessage is! Message && + quotedMessage != null && + quotedMessage is! _NullConst) { + throw ArgumentError( + '`quotedMessage` can only be set as Message or null', + ); + } + return true; + }(), 'Validate type for quotedMessage'); + + assert(() { + if (quotedMessageId is! String && + quotedMessageId != null && + quotedMessageId is! _NullConst) { + throw ArgumentError( + '`quotedMessage` can only be set as String or null', + ); + } + return true; + }(), 'Validate type for quotedMessage'); + return Message( id: id ?? this.id, text: text ?? this.text, type: type ?? this.type, attachments: attachments ?? this.attachments, mentionedUsers: mentionedUsers ?? this.mentionedUsers, + silent: silent ?? this.silent, + shadowed: shadowed ?? this.shadowed, reactionCounts: reactionCounts ?? this.reactionCounts, reactionScores: reactionScores ?? this.reactionScores, latestReactions: latestReactions ?? this.latestReactions, ownReactions: ownReactions ?? this.ownReactions, parentId: parentId ?? this.parentId, - quotedMessage: quotedMessage ?? this.quotedMessage, - quotedMessageId: quotedMessageId ?? this.quotedMessageId, + quotedMessage: quotedMessage == _nullConst + ? this.quotedMessage + : quotedMessage as Message?, + quotedMessageId: quotedMessageId == _nullConst + ? _quotedMessageId + : quotedMessageId as String?, replyCount: replyCount ?? this.replyCount, threadParticipants: threadParticipants ?? this.threadParticipants, showInChannel: showInChannel ?? this.showInChannel, command: command ?? this.command, - createdAt: createdAt ?? this.createdAt, - silent: silent ?? this.silent, - extraData: extraData ?? this.extraData, - user: user ?? this.user, - shadowed: shadowed ?? this.shadowed, - updatedAt: updatedAt ?? this.updatedAt, + createdAt: createdAt ?? _createdAt, + updatedAt: updatedAt ?? _updatedAt, deletedAt: deletedAt ?? this.deletedAt, - status: status ?? this.status, + user: user ?? this.user, pinned: pinned ?? this.pinned, pinnedAt: pinnedAt ?? this.pinnedAt, - pinnedBy: pinnedBy ?? this.pinnedBy, pinExpires: - pinExpires == _pinExpires ? this.pinExpires : pinExpires as DateTime?, + pinExpires == _nullConst ? this.pinExpires : pinExpires as DateTime?, + pinnedBy: pinnedBy ?? this.pinnedBy, + extraData: extraData ?? this.extraData, + status: status ?? this.status, i18n: i18n ?? this.i18n, ); } @@ -331,6 +368,8 @@ class Message extends Equatable { type: other.type, attachments: other.attachments, mentionedUsers: other.mentionedUsers, + silent: other.silent, + shadowed: other.shadowed, reactionCounts: other.reactionCounts, reactionScores: other.reactionScores, latestReactions: other.latestReactions, @@ -343,17 +382,15 @@ class Message extends Equatable { showInChannel: other.showInChannel, command: other.command, createdAt: other.createdAt, - silent: other.silent, - extraData: other.extraData, - user: other.user, - shadowed: other.shadowed, updatedAt: other.updatedAt, deletedAt: other.deletedAt, - status: other.status, + user: other.user, pinned: other.pinned, pinnedAt: other.pinnedAt, pinExpires: other.pinExpires, pinnedBy: other.pinnedBy, + extraData: other.extraData, + status: other.status, i18n: other.i18n, ); @@ -377,8 +414,8 @@ class Message extends Equatable { shadowed, silent, command, - createdAt, - updatedAt, + _createdAt, + _updatedAt, deletedAt, user, pinned, diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index d196b675..c0ceab3a 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -7,6 +7,7 @@ export 'package:dio/src/options.dart'; export 'package:dio/src/options.dart' show ProgressCallback; export 'package:logging/logging.dart' show Logger, Level; export 'package:rate_limiter/rate_limiter.dart'; +export 'package:uuid/uuid.dart'; export './src/core/api/attachment_file_uploader.dart' show AttachmentFileUploader; diff --git a/packages/stream_chat/test/src/client/channel_test.dart b/packages/stream_chat/test/src/client/channel_test.dart index c217431a..7eb68b79 100644 --- a/packages/stream_chat/test/src/client/channel_test.dart +++ b/packages/stream_chat/test/src/client/channel_test.dart @@ -244,9 +244,13 @@ void main() { group('`.sendMessage`', () { test('should work fine', () async { - final message = Message(id: 'test-message-id'); + final message = Message( + id: 'test-message-id', + user: client.state.currentUser, + ); - final sendMessageResponse = SendMessageResponse()..message = message; + final sendMessageResponse = SendMessageResponse() + ..message = message.copyWith(status: MessageSendingStatus.sent); when(() => client.sendMessage( any(that: isSameMessageAs(message)), @@ -329,6 +333,7 @@ void main() { .map((it) => it.copyWith(uploadState: const UploadState.success())) .toList(growable: false), + status: MessageSendingStatus.sent, )); expectLater( @@ -455,7 +460,10 @@ void main() { group('`.updateMessage`', () { test('should work fine', () async { - final message = Message(id: 'test-message-id'); + final message = Message( + id: 'test-message-id', + status: MessageSendingStatus.sent, + ); final updateMessageResponse = UpdateMessageResponse() ..message = message; @@ -530,6 +538,7 @@ void main() { any(that: isSameMessageAs(message)), )).thenAnswer((_) async => UpdateMessageResponse() ..message = message.copyWith( + status: MessageSendingStatus.sent, attachments: attachments .map((it) => it.copyWith(uploadState: const UploadState.success())) @@ -678,7 +687,7 @@ void main() { [ isSameMessageAs( updateMessageResponse.message.copyWith( - status: MessageSendingStatus.sent, + status: MessageSendingStatus.sending, ), matchText: true, matchSendingStatus: true, @@ -707,7 +716,10 @@ void main() { group('`.deleteMessage`', () { test('should work fine', () async { const messageId = 'test-message-id'; - final message = Message(id: messageId); + final message = Message( + id: messageId, + status: MessageSendingStatus.sent, + ); when(() => client.deleteMessage(messageId)) .thenAnswer((_) async => EmptyResponse()); @@ -744,7 +756,6 @@ void main() { const messageId = 'test-message-id'; final message = Message( id: messageId, - status: MessageSendingStatus.sending, ); expectLater( @@ -1077,7 +1088,10 @@ void main() { group('`.sendReaction`', () { test('should work fine', () async { const type = 'test-reaction-type'; - final message = Message(id: 'test-message-id'); + final message = Message( + id: 'test-message-id', + status: MessageSendingStatus.sent, + ); final reaction = Reaction(type: type, messageId: message.id); @@ -1120,7 +1134,10 @@ void main() { 'should restore previous message if `client.sendReaction` throws', () async { const type = 'test-reaction-type'; - final message = Message(id: 'test-message-id'); + final message = Message( + id: 'test-message-id', + status: MessageSendingStatus.sent, + ); final reaction = Reaction(type: type, messageId: message.id); @@ -1181,6 +1198,7 @@ void main() { latestReactions: [prevReaction], reactionScores: const {prevType: 1}, reactionCounts: const {prevType: 1}, + status: MessageSendingStatus.sent, ); const type = 'test-reaction-type-2'; @@ -1212,7 +1230,7 @@ void main() { emitsInOrder([ [ isSameMessageAs( - newMessage.copyWith(status: MessageSendingStatus.sent), + newMessage, matchReactions: true, matchSendingStatus: true, ), @@ -1255,6 +1273,7 @@ void main() { latestReactions: [reaction], reactionScores: const {type: 1}, reactionCounts: const {type: 1}, + status: MessageSendingStatus.sent, ); when(() => client.deleteReaction(messageId, type)) @@ -1302,6 +1321,7 @@ void main() { latestReactions: [reaction], reactionScores: const {type: 1}, reactionCounts: const {type: 1}, + status: MessageSendingStatus.sent, ); when(() => client.deleteReaction(messageId, type)) diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index 65fe9767..02f21d3a 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -3,6 +3,7 @@ 🛑️ Breaking Changes - `pinPermissions` is no longer needed in `MessageListView`. +- `MessageInput` now works with a `MessageInputController` instead of a `TextEditingController` 🐞 Fixed diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart index 5a05e4f9..ea9afa2f 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_4.dart @@ -126,7 +126,9 @@ class ThreadPage extends StatelessWidget { ), ), MessageInput( - parentMessage: parent, + messageInputController: MessageInputController( + message: Message(parentId: parent!.id), + ), ), ], ), diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart index 19d5d1ae..2555e007 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_6.dart @@ -166,7 +166,9 @@ class ThreadPage extends StatelessWidget { ), ), MessageInput( - parentMessage: parent, + messageInputController: MessageInputController( + message: Message(parentId: parent!.id), + ), ), ], ), diff --git a/packages/stream_chat_flutter/example/pubspec.yaml b/packages/stream_chat_flutter/example/pubspec.yaml index 9977c198..bd785a29 100644 --- a/packages/stream_chat_flutter/example/pubspec.yaml +++ b/packages/stream_chat_flutter/example/pubspec.yaml @@ -27,9 +27,12 @@ dependencies: cupertino_icons: ^1.0.3 flutter: sdk: flutter - stream_chat_flutter: ^2.2.1 - stream_chat_localizations: ^1.1.0 - stream_chat_persistence: ^2.2.0 + stream_chat_flutter: + path: ../ + stream_chat_localizations: + path: ../../stream_chat_localizations + stream_chat_persistence: + path: ../../stream_chat_persistence dev_dependencies: flutter_test: diff --git a/packages/stream_chat_flutter/lib/src/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index 8aa8d071..8039ac47 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -1,6 +1,6 @@ import 'package:jiffy/jiffy.dart'; import 'package:stream_chat_flutter/src/connection_status_builder.dart'; -import 'package:stream_chat_flutter/src/message_input.dart'; +import 'package:stream_chat_flutter/src/message_input/message_input.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart'; import 'package:stream_chat_flutter/src/message_search_list_view.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart' diff --git a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart index 61bda279..115069d9 100644 --- a/packages/stream_chat_flutter/lib/src/message_actions_modal.dart +++ b/packages/stream_chat_flutter/lib/src/message_actions_modal.dart @@ -634,7 +634,9 @@ class _MessageActionsModalState extends State { widget.editMessageInputBuilder!(context, widget.message) else MessageInput( - editMessage: widget.message, + messageInputController: MessageInputController( + message: widget.message, + ), preMessageSending: (m) { FocusScope.of(context).unfocus(); Navigator.pop(context); diff --git a/packages/stream_chat_flutter/lib/src/message_input/countdown_button.dart b/packages/stream_chat_flutter/lib/src/message_input/countdown_button.dart new file mode 100644 index 00000000..50f18e50 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/countdown_button.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// Button for showing visual component of slow mode. +class CountdownButton extends StatelessWidget { + /// Constructor for creating [CountdownButton]. + const CountdownButton({ + Key? key, + required this.count, + }) : super(key: key); + + /// Count of time remaining to show to the user. + final int count; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.all(8), + child: DecoratedBox( + decoration: BoxDecoration( + color: StreamChatTheme.of(context).colorTheme.disabled, + shape: BoxShape.circle, + ), + child: SizedBox( + height: 24, + width: 24, + child: Center( + child: Text('$count'), + ), + ), + ), + ); +} diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input/message_input.dart similarity index 62% rename from packages/stream_chat_flutter/lib/src/message_input.dart rename to packages/stream_chat_flutter/lib/src/message_input/message_input.dart index de2b9d09..e55fac19 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input/message_input.dart @@ -4,32 +4,28 @@ import 'dart:math'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:collection/collection.dart'; import 'package:file_picker/file_picker.dart'; -import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:photo_manager/photo_manager.dart'; import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/commands_overlay.dart'; import 'package:stream_chat_flutter/src/emoji/emoji.dart'; import 'package:stream_chat_flutter/src/emoji_overlay.dart'; import 'package:stream_chat_flutter/src/extension.dart'; -import 'package:stream_chat_flutter/src/media_list_view.dart'; -import 'package:stream_chat_flutter/src/message_list_view.dart'; import 'package:stream_chat_flutter/src/multi_overlay.dart'; import 'package:stream_chat_flutter/src/quoted_message_widget.dart'; -import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; -import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/user_mentions_overlay.dart'; import 'package:stream_chat_flutter/src/video_service.dart'; import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; -import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:video_compress/video_compress.dart'; export 'package:video_compress/video_compress.dart' show VideoQuality; +/// A function that returns true if the message is valid and can be sent. +typedef MessageValidator = bool Function(Message message); + /// A callback that can be passed to [MessageInput.onError]. /// /// This callback should not throw. @@ -44,13 +40,14 @@ typedef ErrorListener = void Function( /// /// This callback should not throw. /// -/// It exists merely for showing custom error, and should not be used otherwise. +/// It exists merely for showing a custom error, and should not be used +/// otherwise. typedef AttachmentLimitExceedListener = void Function( int limit, String error, ); -/// Builder for attachment thumbnails +/// Builder for attachment thumbnails. typedef AttachmentThumbnailBuilder = Widget Function( BuildContext, Attachment, @@ -79,7 +76,21 @@ typedef ActionButtonBuilder = Widget Function( IconButton defaultActionButton, ); -/// Location for actions on the [MessageInput] +/// Widget builder for widgets that may require data from the +/// [MessageInputController]. +typedef MessageRelatedBuilder = Widget Function( + BuildContext context, + MessageInputController messageInputController, +); + +/// Widget builder for a custom attachment picker. +typedef AttachmentsPickerBuilder = Widget Function( + BuildContext context, + MessageInputController messageInputController, + StreamAttachmentPicker defaultPicker, +); + +/// Location for actions on the [MessageInput]. enum ActionsLocation { /// Align to left left, @@ -94,7 +105,7 @@ enum ActionsLocation { rightInside, } -/// Default attachments for widget +/// Default attachments for widget. enum DefaultAttachmentTypes { /// Image Attachment image, @@ -106,7 +117,7 @@ enum DefaultAttachmentTypes { file, } -/// Available locations for the sendMessage button relative to the textField +/// Available locations for the `sendMessage` button relative to the textField. enum SendButtonLocation { /// inside the textField inside, @@ -119,17 +130,17 @@ const _kMinMediaPickerSize = 360.0; const _kDefaultMaxAttachmentSize = 20971520; // 20MB in Bytes -/// Inactive state +/// Inactive state: /// /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input_paint.png) /// -/// Focused state +/// Focused state: /// /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input2.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/packages/stream_chat_flutter/screenshots/message_input2_paint.png) /// -/// Widget used to enter the message and add attachments +/// Widget used to enter a message and add attachments: /// /// ```dart /// class ChannelPage extends StatelessWidget { @@ -164,27 +175,21 @@ const _kDefaultMaxAttachmentSize = 20971520; // 20MB in Bytes /// as the bottom widget. /// /// The widget renders the ui based on the first ancestor of -/// type [StreamChatTheme]. -/// Modify it to change the widget appearance. +/// type [StreamChatTheme]. Modify it to change the widget appearance. class MessageInput extends StatefulWidget { /// Instantiate a new MessageInput const MessageInput({ Key? key, this.onMessageSent, this.preMessageSending, - this.parentMessage, - this.editMessage, this.maxHeight = 150, this.keyboardType = TextInputType.multiline, this.disableAttachments = false, - this.initialMessage, - this.textEditingController, + this.messageInputController, this.actions = const [], this.actionsLocation = ActionsLocation.left, this.attachmentThumbnailBuilders, this.focusNode, - this.quotedMessage, - this.onQuotedMessageCleared, this.sendButtonLocation = SendButtonLocation.outside, this.autofocus = false, this.hideSendAsDm = false, @@ -204,79 +209,65 @@ class MessageInput extends StatefulWidget { this.commandButtonBuilder, this.customOverlays = const [], this.mentionAllAppUsers = false, + this.attachmentsPickerBuilder, + this.sendButtonBuilder, this.shouldKeepFocusAfterMessage, - }) : assert( - initialMessage == null || editMessage == null, - "Can't provide both `initialMessage` and `editMessage`", - ), - super(key: key); + this.validator = _defaultValidator, + this.restorationId, + }) : super(key: key); - /// List of options for showing overlays + /// List of options for showing overlays. final List customOverlays; - /// Message to edit - final Message? editMessage; - - /// Video quality to use when compressing the videos + /// Video quality to use when compressing the videos. final VideoQuality compressedVideoQuality; - /// Frame rate to use when compressing the videos + /// Frame rate to use when compressing the videos. final int compressedVideoFrameRate; - /// Max attachment size in bytes - /// Defaults to 20 MB - /// do not set it if you're using our default CDN + /// Max attachment size in bytes: + /// - Defaults to 20 MB + /// - Do not set it if you're using our default CDN final int maxAttachmentSize; - /// Message to start with - final Message? initialMessage; - - /// Function called after sending the message + /// Function called after sending the message. final void Function(Message)? onMessageSent; - /// Function called right before sending the message - /// Use this to transform the message + /// Function called right before sending the message. + /// + /// Use this to transform the message. final FutureOr Function(Message)? preMessageSending; - /// Parent message in case of a thread - final Message? parentMessage; - - /// Maximum Height for the TextField to grow before it starts scrolling + /// Maximum Height for the TextField to grow before it starts scrolling. final double maxHeight; - /// The keyboard type assigned to the TextField + /// The keyboard type assigned to the TextField. final TextInputType keyboardType; - /// If true the attachments button will not be displayed + /// If true the attachments button will not be displayed. final bool disableAttachments; - /// Use this property to hide/show the commands button + /// Use this property to hide/show the commands button. final bool showCommandsButton; - /// Hide send as dm checkbox + /// Hide send as dm checkbox. final bool hideSendAsDm; - /// The text controller of the TextField - final TextEditingController? textEditingController; + /// The text controller of the TextField. + final MessageInputController? messageInputController; - /// List of action widgets + /// List of action widgets. final List actions; - /// The location of the custom actions + /// The location of the custom actions. final ActionsLocation actionsLocation; - /// Map that defines a thumbnail builder for an attachment type + /// Map that defines a thumbnail builder for an attachment type. final Map? attachmentThumbnailBuilders; - /// The focus node associated to the TextField + /// The focus node associated to the TextField. final FocusNode? focusNode; - /// - final Message? quotedMessage; - - /// - final VoidCallback? onQuotedMessageCleared; - /// The location of the send button final SendButtonLocation sendButtonLocation; @@ -323,10 +314,25 @@ class MessageInput extends StatefulWidget { /// Defaults to false. final bool mentionAllAppUsers; + /// Builds bottom sheet when attachment picker is opened. + final AttachmentsPickerBuilder? attachmentsPickerBuilder; + + /// Builder for creating send button + final MessageRelatedBuilder? sendButtonBuilder; + /// Defines if the [MessageInput] loses focuses after a message is sent. /// The default behaviour keeps focus until a command is enabled. final bool? shouldKeepFocusAfterMessage; + /// A callback function that validates the message. + final MessageValidator validator; + + /// Restoration ID to save and restore the state of the MessageInput. + final String? restorationId; + + static bool _defaultValidator(Message message) => + message.text?.isNotEmpty == true || message.attachments.isNotEmpty; + @override MessageInputState createState() => MessageInputState(); @@ -343,44 +349,88 @@ class MessageInput extends StatefulWidget { } /// State of [MessageInput] -class MessageInputState extends State { - final _attachments = {}; - final List _mentionedUsers = []; - +class MessageInputState extends State + with RestorationMixin { final _imagePicker = ImagePicker(); late final _focusNode = widget.focusNode ?? FocusNode(); bool _inputEnabled = true; - bool _commandEnabled = false; + bool get _commandEnabled => _effectiveController.value.command != null; bool _showCommandsOverlay = false; bool _showMentionsOverlay = false; - Command? _chosenCommand; bool _actionsShrunk = false; - bool _sendAsDm = false; bool _openFilePickerSection = false; - int _filePickerIndex = 0; - - /// The editing controller passed to the input TextField - late final TextEditingController textEditingController = - widget.textEditingController ?? TextEditingController(); late StreamChatThemeData _streamChatTheme; late MessageInputThemeData _messageInputTheme; - bool get _hasQuotedMessage => widget.quotedMessage != null; + bool get _hasQuotedMessage => + _effectiveController.value.quotedMessage != null; - bool get _messageIsPresent => textEditingController.text.trim().isNotEmpty; + bool get _isEditing => + _effectiveController.value.status != MessageSendingStatus.sending; + + RestorableMessageInputController? _controller; + MessageInputController get _effectiveController => + widget.messageInputController ?? _controller!.value; + + void _createLocalController([Message? message]) { + assert(_controller == null, ''); + _controller = RestorableMessageInputController(message: message); + } + + void _registerController() { + assert(_controller != null, ''); + + registerForRestoration( + _controller!, + widget.restorationId ?? 'messageInputController', + ); + _effectiveController.textEditingController + .removeListener(_onChangedDebounced); + _effectiveController.textEditingController.addListener(_onChangedDebounced); + if (!_isEditing && _timeOut <= 0) _startSlowMode(); + } @override void initState() { super.initState(); - if (widget.editMessage != null || widget.initialMessage != null) { - _parseExistingMessage(widget.editMessage ?? widget.initialMessage!); + if (widget.messageInputController == null) { + _createLocalController(); + } else { + _effectiveController.textEditingController + .removeListener(_onChangedDebounced); + _effectiveController.textEditingController + .addListener(_onChangedDebounced); + if (!_isEditing && _timeOut <= 0) _startSlowMode(); } - textEditingController.addListener(_onChangedDebounced); _focusNode.addListener(_focusNodeListener); } + @override + void didUpdateWidget(covariant MessageInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.messageInputController == null && + oldWidget.messageInputController != null) { + _createLocalController(oldWidget.messageInputController!.value); + } else if (widget.messageInputController != null && + oldWidget.messageInputController == null) { + unregisterFromRestoration(_controller!); + _controller!.dispose(); + _controller = null; + } + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + if (_controller != null) { + _registerController(); + } + } + + @override + String? get restorationId => widget.restorationId; + void _focusNodeListener() { if (_focusNode.hasFocus) { _openFilePickerSection = false; @@ -418,105 +468,112 @@ class MessageInputState extends State { void _stopSlowMode() => _slowModeTimer?.cancel(); @override - Widget build(BuildContext context) { - Widget child = DecoratedBox( - decoration: BoxDecoration( - color: _messageInputTheme.inputBackgroundColor, - ), - child: SafeArea( - child: GestureDetector( - onPanUpdate: (details) { - if (details.delta.dy > 0) { - _focusNode.unfocus(); - if (_openFilePickerSection) { - setState(() { - _openFilePickerSection = false; - }); - } - } - }, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_hasQuotedMessage) - Padding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + Widget build(BuildContext context) => MessageValueListenableBuilder( + valueListenable: _effectiveController, + builder: (context, value, _) { + Widget child = DecoratedBox( + decoration: BoxDecoration( + color: _messageInputTheme.inputBackgroundColor, + ), + child: SafeArea( + child: GestureDetector( + onPanUpdate: (details) { + if (details.delta.dy > 0) { + _focusNode.unfocus(); + if (_openFilePickerSection) { + setState(() { + _openFilePickerSection = false; + }); + } + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_hasQuotedMessage) Padding( - padding: const EdgeInsets.all(8), - child: StreamSvgIcon.reply( - color: _streamChatTheme.colorTheme.disabled, + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: StreamSvgIcon.reply( + color: _streamChatTheme.colorTheme.disabled, + ), + ), + Text( + context.translations.replyToMessageLabel, + style: + const TextStyle(fontWeight: FontWeight.bold), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: StreamSvgIcon.closeSmall(), + onPressed: () { + _effectiveController.clearQuotedMessage(); + _focusNode.unfocus(); + }, + ), + ], ), ), - Text( - context.translations.replyToMessageLabel, - style: const TextStyle(fontWeight: FontWeight.bold), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: _buildTextField(context), + ), + if (_effectiveController.value.parentId != null && + !widget.hideSendAsDm) + Padding( + padding: const EdgeInsets.only( + right: 12, + left: 12, + bottom: 12, + ), + child: _buildDmCheckbox(), ), - IconButton( - visualDensity: VisualDensity.compact, - icon: StreamSvgIcon.closeSmall(), - onPressed: widget.onQuotedMessageCleared, - ), - ], - ), + _buildFilePickerSection(), + ], ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: _buildTextField(context), ), - if (widget.parentMessage != null && !widget.hideSendAsDm) - Padding( - padding: const EdgeInsets.only( - right: 12, - left: 12, - bottom: 12, - ), - child: _buildDmCheckbox(), - ), - _buildFilePickerSection(), + ), + ); + if (!_isEditing) { + child = Material( + elevation: 8, + child: child, + ); + } + return MultiOverlay( + childAnchor: Alignment.topCenter, + overlayAnchor: Alignment.bottomCenter, + overlayOptions: [ + OverlayOptions( + visible: _showCommandsOverlay, + widget: _buildCommandsOverlayEntry(), + ), + OverlayOptions( + visible: _focusNode.hasFocus && + _effectiveController.text.isNotEmpty && + _effectiveController.baseOffset > 0 && + _effectiveController.text + .substring( + 0, + _effectiveController.baseOffset, + ) + .contains(':'), + widget: _buildEmojiOverlay(), + ), + OverlayOptions( + visible: _showMentionsOverlay, + widget: _buildMentionsOverlayEntry(), + ), + ...widget.customOverlays, ], - ), - ), - ), - ); - if (widget.editMessage == null) { - child = Material( - elevation: 8, - child: child, + child: child, + ); + }, ); - } - - return MultiOverlay( - childAnchor: Alignment.topCenter, - overlayAnchor: Alignment.bottomCenter, - overlayOptions: [ - OverlayOptions( - visible: _showCommandsOverlay, - widget: _buildCommandsOverlayEntry(), - ), - OverlayOptions( - visible: _focusNode.hasFocus && - textEditingController.text.isNotEmpty && - textEditingController.selection.baseOffset > 0 && - textEditingController.text - .substring( - 0, - textEditingController.selection.baseOffset, - ) - .contains(':'), - widget: _buildEmojiOverlay(), - ), - OverlayOptions( - visible: _showMentionsOverlay, - widget: _buildMentionsOverlayEntry(), - ), - ...widget.customOverlays, - ], - child: child, - ); - } Flex _buildTextField(BuildContext context) => Flex( direction: Axis.horizontal, @@ -529,7 +586,7 @@ class MessageInputState extends State { widget.actionsLocation == ActionsLocation.right) _buildExpandActionsButton(context), if (widget.sendButtonLocation == SendButtonLocation.outside) - _animateSendButton(context), + _buildSendButton(context), ], ); @@ -539,7 +596,7 @@ class MessageInputState extends State { height: 16, width: 16, foregroundDecoration: BoxDecoration( - border: _sendAsDm + border: _effectiveController.showInChannel ? null : Border.all( color: _streamChatTheme.colorTheme.textHighEmphasis @@ -551,19 +608,18 @@ class MessageInputState extends State { child: Center( child: Material( borderRadius: BorderRadius.circular(3), - color: _sendAsDm + color: _effectiveController.showInChannel ? _streamChatTheme.colorTheme.accentPrimary : _streamChatTheme.colorTheme.barsBg, child: InkWell( onTap: () { - setState(() { - _sendAsDm = !_sendAsDm; - }); + _effectiveController.showInChannel = + !_effectiveController.showInChannel; }, child: AnimatedCrossFade( duration: const Duration(milliseconds: 300), reverseDuration: const Duration(milliseconds: 300), - crossFadeState: _sendAsDm + crossFadeState: _effectiveController.showInChannel ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: StreamSvgIcon.check( @@ -592,24 +648,18 @@ class MessageInputState extends State { ], ); - Widget _animateSendButton(BuildContext context) { - late Widget sendButton; - if (_timeOut > 0) { - sendButton = _CountdownButton(count: _timeOut); - } else if (!_messageIsPresent && _attachments.isEmpty) { - sendButton = widget.idleSendButton ?? _buildIdleSendButton(context); - } else { - sendButton = widget.activeSendButton != null - ? InkWell( - onTap: sendMessage, - child: widget.activeSendButton, - ) - : _buildSendButton(context); + Widget _buildSendButton(BuildContext context) { + if (widget.sendButtonBuilder != null) { + return widget.sendButtonBuilder!(context, _effectiveController); } - return AnimatedSwitcher( - duration: _streamChatTheme.messageInputTheme.sendAnimationDuration!, - child: sendButton, + return StreamMessageSendButton( + onSendMessage: sendMessage, + timeOut: _timeOut, + isIdle: !widget.validator(_effectiveController.message), + isEditEnabled: _isEditing, + idleSendButton: widget.idleSendButton, + activeSendButton: widget.activeSendButton, ); } @@ -654,7 +704,7 @@ class MessageInputState extends State { if (!widget.disableAttachments) _buildAttachmentButton(context), if (widget.showCommandsButton && - widget.editMessage == null && + !_isEditing && channel.state != null && channel.config?.commands.isNotEmpty == true) _buildCommandButton(context), @@ -699,13 +749,13 @@ class MessageInputState extends State { _buildAttachments(), LimitedBox( maxHeight: widget.maxHeight, - child: TextField( + child: StreamMessageTextField( key: const Key('messageInputText'), enabled: _inputEnabled, maxLines: null, onSubmitted: (_) => sendMessage(), keyboardType: widget.keyboardType, - controller: textEditingController, + controller: _effectiveController, focusNode: _focusNode, style: _messageInputTheme.inputTextStyle, autofocus: widget.autofocus, @@ -777,7 +827,7 @@ class MessageInputState extends State { size: 16, ), Text( - _chosenCommand?.name.toUpperCase() ?? '', + _effectiveController.value.command!.toUpperCase(), style: _streamChatTheme.textTheme.footnoteBold.copyWith( color: Colors.white, @@ -811,16 +861,14 @@ class MessageInputState extends State { height: 24, width: 24, ), - onPressed: () { - setState(() => _commandEnabled = false); - }, + onPressed: _effectiveController.clear, ), ), if (!_commandEnabled && widget.actionsLocation == ActionsLocation.rightInside) _buildExpandActionsButton(context), if (widget.sendButtonLocation == SendButtonLocation.inside) - _animateSendButton(context), + _buildSendButton(context), ], ), ).merge(passedDecoration); @@ -828,14 +876,16 @@ class MessageInputState extends State { late final _onChangedDebounced = debounce( () { - var value = textEditingController.text; + var value = _effectiveController.text; if (!mounted) return; value = value.trim(); final channel = StreamChannel.of(context).channel; if (value.isNotEmpty) { - // ignore: no-empty-block - channel.keyStroke(widget.parentMessage?.id).catchError((e) {}); + channel + .keyStroke(_effectiveController.value.parentId) + // ignore: no-empty-block + .catchError((e) {}); } var actionsLength = widget.actions.length; @@ -855,10 +905,10 @@ class MessageInputState extends State { ); String _getHint(BuildContext context) { - if (_commandEnabled && _chosenCommand!.name == 'giphy') { + if (_commandEnabled && _effectiveController.value.command == 'giphy') { return context.translations.searchGifLabel; } - if (_attachments.isNotEmpty) { + if (_effectiveController.attachments.isNotEmpty) { return context.translations.addACommentOrSendLabel; } if (_timeOut != 0) { @@ -870,12 +920,17 @@ class MessageInputState extends State { void _checkEmoji(String s, BuildContext context) { if (s.isNotEmpty && - textEditingController.selection.baseOffset > 0 && - textEditingController.text - .substring(0, textEditingController.selection.baseOffset) + _effectiveController.baseOffset > 0 && + _effectiveController.text + .substring( + 0, + _effectiveController.baseOffset, + ) .contains(':')) { - final textToSelection = textEditingController.text - .substring(0, textEditingController.value.selection.start); + final textToSelection = _effectiveController.text.substring( + 0, + _effectiveController.selectionStart, + ); final splits = textToSelection.split(':'); final query = splits[splits.length - 2].toLowerCase(); final emoji = Emoji.byName(query); @@ -888,9 +943,12 @@ class MessageInputState extends State { void _checkMentions(String s, BuildContext context) { if (s.isNotEmpty && - textEditingController.selection.baseOffset > 0 && - textEditingController.text - .substring(0, textEditingController.selection.baseOffset) + _effectiveController.baseOffset > 0 && + _effectiveController.text + .substring( + 0, + _effectiveController.baseOffset, + ) .split(' ') .last .contains('@')) { @@ -926,7 +984,7 @@ class MessageInputState extends State { } Widget _buildCommandsOverlayEntry() { - final text = textEditingController.text.trimLeft(); + final text = _effectiveController.text.trimLeft(); final renderObject = context.findRenderObject() as RenderBox?; if (renderObject == null) { @@ -941,241 +999,41 @@ class MessageInputState extends State { } Widget _buildFilePickerSection() { - final _attachmentContainsFile = - _attachments.values.any((it) => it.type == 'file'); - - final attachmentLimitCrossed = - _attachments.length >= widget.attachmentLimit; - - Color _getIconColor(int index) { - final streamChatThemeData = _streamChatTheme; - switch (index) { - case 0: - return _attachments.isEmpty - ? streamChatThemeData.colorTheme.accentPrimary - : (!_attachmentContainsFile - ? streamChatThemeData.colorTheme.accentPrimary - : streamChatThemeData.colorTheme.textHighEmphasis - .withOpacity(0.2)); - case 1: - return _attachmentContainsFile - ? streamChatThemeData.colorTheme.accentPrimary - : (_attachments.isEmpty - ? streamChatThemeData.colorTheme.textHighEmphasis - .withOpacity(0.5) - : streamChatThemeData.colorTheme.textHighEmphasis - .withOpacity(0.2)); - case 2: - return attachmentLimitCrossed - ? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2) - : _attachmentContainsFile && _attachments.isNotEmpty - ? streamChatThemeData.colorTheme.textHighEmphasis - .withOpacity(0.2) - : streamChatThemeData.colorTheme.textHighEmphasis - .withOpacity(0.5); - case 3: - return attachmentLimitCrossed - ? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2) - : _attachmentContainsFile && _attachments.isNotEmpty - ? streamChatThemeData.colorTheme.textHighEmphasis - .withOpacity(0.2) - : streamChatThemeData.colorTheme.textHighEmphasis - .withOpacity(0.5); - default: - return Colors.black; - } - } - - return AnimatedContainer( - duration: _openFilePickerSection - ? const Duration(milliseconds: 300) - : const Duration(), - curve: Curves.easeOut, - height: _openFilePickerSection ? _kMinMediaPickerSize : 0, - child: SingleChildScrollView( - child: SizedBox( - height: _kMinMediaPickerSize, - child: Material( - color: _streamChatTheme.colorTheme.inputBg, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - IconButton( - icon: StreamSvgIcon.pictures( - color: _getIconColor(0), - ), - onPressed: - _attachmentContainsFile && _attachments.isNotEmpty - ? null - : () { - setState(() { - _filePickerIndex = 0; - }); - }, - ), - IconButton( - iconSize: 32, - icon: StreamSvgIcon.files( - color: _getIconColor(1), - ), - onPressed: - !_attachmentContainsFile && _attachments.isNotEmpty - ? null - : () { - pickFile(DefaultAttachmentTypes.file); - }, - ), - IconButton( - icon: StreamSvgIcon.camera( - color: _getIconColor(2), - ), - onPressed: attachmentLimitCrossed || - (_attachmentContainsFile && - _attachments.isNotEmpty) - ? null - : () { - pickFile( - DefaultAttachmentTypes.image, - camera: true, - ); - }, - ), - IconButton( - padding: const EdgeInsets.all(0), - icon: StreamSvgIcon.record( - color: _getIconColor(3), - ), - onPressed: attachmentLimitCrossed || - (_attachmentContainsFile && - _attachments.isNotEmpty) - ? null - : () { - pickFile( - DefaultAttachmentTypes.video, - camera: true, - ); - }, - ), - ], - ), - DecoratedBox( - decoration: BoxDecoration( - color: _streamChatTheme.colorTheme.barsBg, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), - ), - child: Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: _streamChatTheme.colorTheme.inputBg, - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ), - ), - if (_openFilePickerSection) - Expanded( - child: DecoratedBox( - decoration: BoxDecoration( - color: _streamChatTheme.colorTheme.barsBg, - borderRadius: BorderRadius.circular(8), - ), - child: _PickerWidget( - filePickerIndex: _filePickerIndex, - streamChatTheme: _streamChatTheme, - containsFile: _attachmentContainsFile, - selectedMedias: _attachments.keys.toList(), - onAddMoreFilesClick: pickFile, - onMediaSelected: (media) { - if (_attachments.containsKey(media.id)) { - setState(() => _attachments.remove(media.id)); - } else { - _addAssetAttachment(media); - } - }, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } - - void _addAssetAttachment(AssetEntity medium) async { - final mediaFile = await medium.originFile.timeout( - const Duration(seconds: 5), - onTimeout: () => medium.originFile, + final picker = StreamAttachmentPicker( + messageInputController: _effectiveController, + onFilePicked: pickFile, + isOpen: _openFilePickerSection, + pickerSize: _openFilePickerSection ? _kMinMediaPickerSize : 0, + attachmentLimit: widget.attachmentLimit, + onAttachmentLimitExceeded: widget.onAttachmentLimitExceed, + maxAttachmentSize: widget.maxAttachmentSize, + compressedVideoQuality: widget.compressedVideoQuality, + compressedVideoFrameRate: widget.compressedVideoFrameRate, + onError: _showErrorAlert, ); - if (mediaFile == null) return; - - var file = AttachmentFile( - path: mediaFile.path, - size: await mediaFile.length(), - bytes: mediaFile.readAsBytesSync(), - ); - - if (file.size! > widget.maxAttachmentSize) { - if (medium.type == AssetType.video && file.path != null) { - final mediaInfo = await (VideoService.compressVideo( - file.path!, - frameRate: widget.compressedVideoFrameRate, - quality: widget.compressedVideoQuality, - ) as FutureOr); - - if (mediaInfo.filesize! > widget.maxAttachmentSize) { - _showErrorAlert( - context.translations.fileTooLargeAfterCompressionError( - widget.maxAttachmentSize / (1024 * 1024), - ), - ); - return; - } - file = AttachmentFile( - name: file.name, - size: mediaInfo.filesize, - bytes: await mediaInfo.file?.readAsBytes(), - path: mediaInfo.path, - ); - } else { - _showErrorAlert(context.translations.fileTooLargeError( - widget.maxAttachmentSize / (1024 * 1024), - )); - return; - } - } - - setState(() { - final attachment = Attachment( - id: medium.id, - file: file, - type: medium.type == AssetType.image ? 'image' : 'video', + if (_openFilePickerSection && widget.attachmentsPickerBuilder != null) { + return widget.attachmentsPickerBuilder!( + context, + _effectiveController, + picker, ); - _addAttachments([attachment]); - }); + } + + return picker; } Widget _buildMentionsOverlayEntry() { final channel = StreamChannel.of(context).channel; - if (textEditingController.value.selection.start < 0 || - channel.state == null) { + if (_effectiveController.selectionStart < 0 || channel.state == null) { return const Offstage(); } - final splits = textEditingController.text - .substring(0, textEditingController.value.selection.start) + final splits = _effectiveController.text + .substring( + 0, + _effectiveController.selectionStart, + ) .split('@'); final query = splits.last.toLowerCase(); @@ -1203,19 +1061,15 @@ class MessageInputState extends State { size: Size(renderObject.size.width - 16, 400), mentionsTileBuilder: tileBuilder, onMentionUserTap: (user) { - _mentionedUsers.add(user); + _effectiveController.addMentionedUser(user); splits[splits.length - 1] = user.name; final rejoin = splits.join('@'); - textEditingController.value = TextEditingValue( - text: rejoin + - textEditingController.text.substring( - textEditingController.selection.start, - ), - selection: TextSelection.collapsed( - offset: rejoin.length, - ), - ); + _effectiveController.text = rejoin + + _effectiveController.text.substring( + _effectiveController.selectionStart, + ); + _onChangedDebounced.cancel(); setState(() => _showMentionsOverlay = false); }, @@ -1223,12 +1077,15 @@ class MessageInputState extends State { } Widget _buildEmojiOverlay() { - if (textEditingController.value.selection.baseOffset < 0) { + if (_effectiveController.baseOffset < 0) { return const Offstage(); } - final splits = textEditingController.text - .substring(0, textEditingController.value.selection.baseOffset) + final splits = _effectiveController.text + .substring( + 0, + _effectiveController.baseOffset, + ) .split(':'); final query = splits.last.toLowerCase(); @@ -1247,44 +1104,40 @@ class MessageInputState extends State { void _chooseEmoji(List splits, Emoji emoji) { final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char!; - textEditingController.value = TextEditingValue( - text: rejoin + - textEditingController.text - .substring(textEditingController.selection.start), - selection: TextSelection.collapsed( - offset: rejoin.length, - ), - ); + _effectiveController.text = rejoin + + _effectiveController.text.substring( + _effectiveController.selectionStart, + ); } void _setCommand(Command c) { - textEditingController.clear(); + _effectiveController + ..clear() + ..command = c; setState(() { - _chosenCommand = c; - _commandEnabled = true; _showCommandsOverlay = false; }); } Widget _buildReplyToMessage() { if (!_hasQuotedMessage) return const Offstage(); - final containsUrl = widget.quotedMessage!.attachments + final containsUrl = _effectiveController.value.quotedMessage!.attachments .any((element) => element.titleLink != null); return QuotedMessageWidget( reverse: true, showBorder: !containsUrl, - message: widget.quotedMessage!, + message: _effectiveController.value.quotedMessage!, messageTheme: _streamChatTheme.otherMessageTheme, padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), ); } Widget _buildAttachments() { - if (_attachments.isEmpty) return const Offstage(); - final fileAttachments = _attachments.values + if (_effectiveController.attachments.isEmpty) return const Offstage(); + final fileAttachments = _effectiveController.attachments .where((it) => it.type == 'file') .toList(growable: false); - final remainingAttachments = _attachments.values + final remainingAttachments = _effectiveController.attachments .where((it) => it.type != 'file') .toList(growable: false); return Column( @@ -1302,9 +1155,7 @@ class MessageInputState extends State { (e) => ClipRRect( borderRadius: BorderRadius.circular(10), child: FileAttachment( - message: Message( - status: MessageSendingStatus.sending, - ), // dummy message + message: Message(), // dummy message attachment: e, size: Size( MediaQuery.of(context).size.width * 0.65, @@ -1371,7 +1222,11 @@ class MessageInputState extends State { focusElevation: 0, hoverElevation: 0, onPressed: () { - setState(() => _attachments.remove(attachment.id)); + _effectiveController.value = _effectiveController.value.copyWith( + attachments: _effectiveController.attachments + .where((it) => it.id != attachment.id) + .toList(), + ); }, fillColor: _streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5), @@ -1450,7 +1305,7 @@ class MessageInputState extends State { } Widget _buildCommandButton(BuildContext context) { - final s = textEditingController.text.trim(); + final s = _effectiveController.text.trim(); final defaultButton = IconButton( icon: StreamSvgIcon.lightning( color: s.isNotEmpty @@ -1578,13 +1433,13 @@ class MessageInputState extends State { /// /// Note: Only meant to be used from outside the state. void addAttachment(Attachment attachment) { - setState(() => _addAttachments([attachment])); + _addAttachments([attachment]); } - /// Adds an attachment to the [_attachments] map + /// Adds an attachment to the [messageInputController.attachments] map void _addAttachments(Iterable attachments) { final limit = widget.attachmentLimit; - final length = _attachments.length + attachments.length; + final length = _effectiveController.attachments.length + attachments.length; if (length > limit) { final onAttachmentLimitExceed = widget.onAttachmentLimitExceed; if (onAttachmentLimitExceed != null) { @@ -1598,7 +1453,7 @@ class MessageInputState extends State { ); } for (final attachment in attachments) { - _attachments[attachment.id] = attachment; + _effectiveController.addAttachment(attachment); } } @@ -1704,109 +1559,24 @@ class MessageInputState extends State { } } - setState(() { - _addAttachments([ - attachment.copyWith( - file: file, - extraData: {...attachment.extraData} - ..update('file_size', ((_) => file!.size!)), - ), - ]); - }); - } - - Widget _buildIdleSendButton(BuildContext context) => Padding( - padding: const EdgeInsets.all(8), - child: StreamSvgIcon( - assetName: _getIdleSendIcon(), - color: _messageInputTheme.sendButtonIdleColor, - ), - ); - - Widget _buildSendButton(BuildContext context) => Padding( - padding: const EdgeInsets.all(8), - child: IconButton( - onPressed: sendMessage, - padding: const EdgeInsets.all(0), - splashRadius: 24, - constraints: const BoxConstraints.tightFor( - height: 24, - width: 24, - ), - icon: StreamSvgIcon( - assetName: _getSendIcon(), - color: _messageInputTheme.sendButtonColor, - ), - ), - ); - - String _getIdleSendIcon() { - if (_commandEnabled) { - return 'Icon_search.svg'; - } else { - return 'Icon_circle_right.svg'; - } - } - - String _getSendIcon() { - if (widget.editMessage != null) { - return 'Icon_circle_up.svg'; - } else if (_commandEnabled) { - return 'Icon_search.svg'; - } else { - return 'Icon_circle_up.svg'; - } + _addAttachments([ + attachment.copyWith( + file: file, + extraData: {...attachment.extraData} + ..update('file_size', ((_) => file!.size!)), + ), + ]); } /// Sends the current message Future sendMessage() async { - var text = textEditingController.text.trim(); - if (text.isEmpty && _attachments.isEmpty) { - return; - } + var message = _effectiveController.value; var shouldKeepFocus = widget.shouldKeepFocusAfterMessage; shouldKeepFocus ??= !_commandEnabled; - if (_commandEnabled) { - text = '${'/${_chosenCommand!.name} '}$text'; - } - - final attachments = [..._attachments.values]; - - textEditingController.clear(); - _attachments.clear(); - widget.onQuotedMessageCleared?.call(); - - setState(() { - _commandEnabled = false; - }); - - Message message; - if (widget.editMessage != null) { - message = widget.editMessage!.copyWith( - text: text, - attachments: attachments, - mentionedUsers: - _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), - ); - } else { - message = (widget.initialMessage ?? Message()).copyWith( - parentId: widget.parentMessage?.id, - text: text, - attachments: attachments, - mentionedUsers: - _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), - showInChannel: widget.parentMessage != null ? _sendAsDm : null, - ); - } - - if (widget.quotedMessage != null) { - message = message.copyWith( - quotedMessageId: widget.quotedMessage!.id, - ); - } + _effectiveController.reset(); if (widget.preMessageSending != null) { message = await widget.preMessageSending!(message); @@ -1818,13 +1588,9 @@ class MessageInputState extends State { await streamChannel.reloadChannel(); } - _mentionedUsers.clear(); - try { Future sendingFuture; - if (widget.editMessage == null || - widget.editMessage!.status == MessageSendingStatus.failed || - widget.editMessage!.status == MessageSendingStatus.sending) { + if (!_isEditing) { sendingFuture = channel.sendMessage(message); } else { sendingFuture = channel.updateMessage(message); @@ -1838,7 +1604,7 @@ class MessageInputState extends State { final resp = await sendingFuture; if (resp.message?.type == 'error') { - _parseExistingMessage(message); + _effectiveController.value = message; } _startSlowMode(); widget.onMessageSent?.call(resp.message); @@ -1917,171 +1683,22 @@ class MessageInputState extends State { ); } - void _parseExistingMessage(Message message) { - final messageText = message.text; - if (messageText != null) textEditingController.text = messageText; - _addAttachments(message.attachments); - } - @override void dispose() { - textEditingController.dispose(); + _effectiveController.textEditingController + .removeListener(_onChangedDebounced); + _controller?.dispose(); _focusNode.removeListener(_focusNodeListener); _stopSlowMode(); _onChangedDebounced.cancel(); super.dispose(); } - bool _initialized = false; - @override void didChangeDependencies() { _streamChatTheme = StreamChatTheme.of(context); _messageInputTheme = MessageInputTheme.of(context); - if (widget.editMessage == null) _startSlowMode(); - if ((widget.editMessage != null || widget.initialMessage != null) && - !_initialized) { - FocusScope.of(context).requestFocus(_focusNode); - _initialized = true; - } super.didChangeDependencies(); } } - -class _PickerWidget extends StatefulWidget { - const _PickerWidget({ - Key? key, - required this.filePickerIndex, - required this.containsFile, - required this.selectedMedias, - required this.onAddMoreFilesClick, - required this.onMediaSelected, - required this.streamChatTheme, - }) : super(key: key); - - final int filePickerIndex; - final bool containsFile; - final List selectedMedias; - final void Function(DefaultAttachmentTypes) onAddMoreFilesClick; - final void Function(AssetEntity) onMediaSelected; - final StreamChatThemeData streamChatTheme; - - @override - _PickerWidgetState createState() => _PickerWidgetState(); -} - -class _PickerWidgetState extends State<_PickerWidget> { - Future? requestPermission; - - @override - void initState() { - super.initState(); - requestPermission = PhotoManager.requestPermission(); - } - - @override - Widget build(BuildContext context) { - if (widget.filePickerIndex != 0) { - return const Offstage(); - } - return FutureBuilder( - future: requestPermission, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Offstage(); - } - - if (snapshot.data!) { - if (widget.containsFile) { - return GestureDetector( - onTap: () { - widget.onAddMoreFilesClick(DefaultAttachmentTypes.file); - }, - child: Container( - constraints: const BoxConstraints.expand(), - color: widget.streamChatTheme.colorTheme.inputBg, - alignment: Alignment.center, - child: Text( - context.translations.addMoreFilesLabel, - style: TextStyle( - color: widget.streamChatTheme.colorTheme.accentPrimary, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } - return MediaListView( - selectedIds: widget.selectedMedias, - onSelect: widget.onMediaSelected, - ); - } - - return InkWell( - onTap: () async { - PhotoManager.openSetting(); - }, - child: Container( - color: widget.streamChatTheme.colorTheme.inputBg, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SvgPicture.asset( - 'svgs/icon_picture_empty_state.svg', - package: 'stream_chat_flutter', - height: 140, - color: widget.streamChatTheme.colorTheme.disabled, - ), - Text( - context.translations.enablePhotoAndVideoAccessMessage, - style: widget.streamChatTheme.textTheme.body.copyWith( - color: widget.streamChatTheme.colorTheme.textLowEmphasis, - ), - textAlign: TextAlign.center, - ), - const SizedBox(height: 6), - Center( - child: Text( - context.translations.allowGalleryAccessMessage, - style: widget.streamChatTheme.textTheme.bodyBold.copyWith( - color: widget.streamChatTheme.colorTheme.accentPrimary, - ), - ), - ), - ], - ), - ), - ); - }, - ); - } -} - -class _CountdownButton extends StatelessWidget { - const _CountdownButton({ - Key? key, - required this.count, - }) : super(key: key); - - final int count; - - @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.all(8), - child: DecoratedBox( - decoration: BoxDecoration( - color: StreamChatTheme.of(context).colorTheme.disabled, - shape: BoxShape.circle, - ), - child: SizedBox( - height: 24, - width: 24, - child: Center( - child: Text('$count'), - ), - ), - ), - ); -} diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_attachment_picker.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_attachment_picker.dart new file mode 100644 index 00000000..909f9561 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_attachment_picker.dart @@ -0,0 +1,575 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:photo_manager/photo_manager.dart'; +import 'package:stream_chat_flutter/src/extension.dart'; +import 'package:stream_chat_flutter/src/media_list_view.dart'; +import 'package:stream_chat_flutter/src/video_service.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:video_compress/video_compress.dart'; + +/// Callback for when a file has to be picked. +typedef FilePickerCallback = void Function( + DefaultAttachmentTypes fileType, { + bool camera, +}); + +/// Callback for building an icon for a custom attachment type. +typedef CustomAttachmentIconBuilder = Widget Function( + BuildContext context, + bool active, +); + +/// A widget that allows to pick an attachment. +class StreamAttachmentPicker extends StatefulWidget { + /// Default constructor for [StreamAttachmentPicker] which creates the Stream + /// attachment picker widget. + const StreamAttachmentPicker({ + Key? key, + required this.messageInputController, + required this.onFilePicked, + this.isOpen = false, + this.pickerSize = 360.0, + this.attachmentLimit = 10, + this.onAttachmentLimitExceeded, + this.maxAttachmentSize = 20971520, + this.compressedVideoQuality = VideoQuality.DefaultQuality, + this.compressedVideoFrameRate = 30, + this.onError, + this.allowedAttachmentTypes = const [ + DefaultAttachmentTypes.image, + DefaultAttachmentTypes.file, + DefaultAttachmentTypes.video, + ], + this.customAttachmentTypes = const [], + }) : super(key: key); + + /// True if the picker is open. + final bool isOpen; + + /// The picker size in height. + final double pickerSize; + + /// The [MessageInputController] linked to this picker. + final MessageInputController messageInputController; + + /// The limit of attachments that can be picked. + final int attachmentLimit; + + /// The callback for when the attachment limit is exceeded. + final AttachmentLimitExceedListener? onAttachmentLimitExceeded; + + /// Callback for when an error occurs in the attachment picker. + final ValueChanged? onError; + + /// Callback for when file is picked. + final FilePickerCallback onFilePicked; + + /// Video quality to use when compressing the videos. + final VideoQuality compressedVideoQuality; + + /// Frame rate to use when compressing the videos. + final int compressedVideoFrameRate; + + /// Max attachment size in bytes: + /// - Defaults to 20 MB + /// - Do not set it if you're using our default CDN + final int maxAttachmentSize; + + /// The list of attachment types that can be picked. + final List allowedAttachmentTypes; + + /// The list of custom attachment types that can be picked. + final List customAttachmentTypes; + + /// Used to create a new copy of [StreamAttachmentPicker] with modified + /// properties. + StreamAttachmentPicker copyWith({ + Key? key, + MessageInputController? messageInputController, + FilePickerCallback? onFilePicked, + bool? isOpen, + double? pickerSize, + int? attachmentLimit, + AttachmentLimitExceedListener? onAttachmentLimitExceeded, + int? maxAttachmentSize, + VideoQuality? compressedVideoQuality, + int? compressedVideoFrameRate, + ValueChanged? onChangeInputState, + ValueChanged? onError, + List? allowedAttachmentTypes, + List? customAttachmentTypes = const [], + }) => + StreamAttachmentPicker( + key: key ?? this.key, + messageInputController: + messageInputController ?? this.messageInputController, + onFilePicked: onFilePicked ?? this.onFilePicked, + isOpen: isOpen ?? this.isOpen, + pickerSize: pickerSize ?? this.pickerSize, + attachmentLimit: attachmentLimit ?? this.attachmentLimit, + onAttachmentLimitExceeded: + onAttachmentLimitExceeded ?? this.onAttachmentLimitExceeded, + maxAttachmentSize: maxAttachmentSize ?? this.maxAttachmentSize, + compressedVideoQuality: + compressedVideoQuality ?? this.compressedVideoQuality, + compressedVideoFrameRate: + compressedVideoFrameRate ?? this.compressedVideoFrameRate, + onError: onError ?? this.onError, + allowedAttachmentTypes: + allowedAttachmentTypes ?? this.allowedAttachmentTypes, + customAttachmentTypes: + customAttachmentTypes ?? this.customAttachmentTypes, + ); + + @override + State createState() => _StreamAttachmentPickerState(); +} + +class _StreamAttachmentPickerState extends State { + int _filePickerIndex = 0; + + @override + Widget build(BuildContext context) { + final _streamChatTheme = StreamChatTheme.of(context); + final messageInputController = widget.messageInputController; + + final _attachmentContainsImage = + messageInputController.attachments.any((it) => it.type == 'image'); + + final _attachmentContainsFile = + messageInputController.attachments.any((it) => it.type == 'file'); + + final _attachmentContainsVideo = + messageInputController.attachments.any((it) => it.type == 'video'); + + final attachmentLimitCrossed = + messageInputController.attachments.length >= widget.attachmentLimit; + + Color _getIconColor(int index) { + final streamChatThemeData = _streamChatTheme; + switch (index) { + case 0: + return _filePickerIndex == 0 || _attachmentContainsImage + ? streamChatThemeData.colorTheme.accentPrimary + : (_attachmentContainsImage + ? streamChatThemeData.colorTheme.accentPrimary + : streamChatThemeData.colorTheme.textHighEmphasis.withOpacity( + messageInputController.attachments.isEmpty ? 0.5 : 0.2, + )); + case 1: + return _attachmentContainsFile + ? streamChatThemeData.colorTheme.accentPrimary + : (messageInputController.attachments.isEmpty + ? streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.5) + : streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.2)); + case 2: + return widget.messageInputController.attachments.isNotEmpty && + (!_attachmentContainsImage || attachmentLimitCrossed) + ? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2) + : _attachmentContainsFile && + messageInputController.attachments.isNotEmpty + ? streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.2) + : streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.5); + case 3: + return widget.messageInputController.attachments.isNotEmpty && + (!_attachmentContainsVideo || attachmentLimitCrossed) + ? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2) + : _attachmentContainsFile && + messageInputController.attachments.isNotEmpty + ? streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.2) + : streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.5); + default: + return Colors.black; + } + } + + return AnimatedContainer( + duration: + widget.isOpen ? const Duration(milliseconds: 300) : const Duration(), + curve: Curves.easeOut, + height: widget.isOpen ? widget.pickerSize : 0, + child: SingleChildScrollView( + child: SizedBox( + height: widget.pickerSize, + child: Material( + color: _streamChatTheme.colorTheme.inputBg, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + if (widget.allowedAttachmentTypes + .contains(DefaultAttachmentTypes.image)) + IconButton( + icon: StreamSvgIcon.pictures( + color: _getIconColor(0), + ), + onPressed: + messageInputController.attachments.isNotEmpty && + !_attachmentContainsImage + ? null + : () { + setState(() { + _filePickerIndex = 0; + }); + }, + ), + if (widget.allowedAttachmentTypes + .contains(DefaultAttachmentTypes.file)) + IconButton( + iconSize: 32, + icon: StreamSvgIcon.files( + color: _getIconColor(1), + ), + onPressed: messageInputController + .attachments.isNotEmpty && + !_attachmentContainsFile + ? null + : () { + widget + .onFilePicked(DefaultAttachmentTypes.file); + }, + ), + if (widget.allowedAttachmentTypes + .contains(DefaultAttachmentTypes.image)) + IconButton( + icon: StreamSvgIcon.camera( + color: _getIconColor(2), + ), + onPressed: attachmentLimitCrossed || + (messageInputController + .attachments.isNotEmpty && + !_attachmentContainsVideo) + ? null + : () { + widget.onFilePicked( + DefaultAttachmentTypes.image, + camera: true, + ); + }, + ), + if (widget.allowedAttachmentTypes + .contains(DefaultAttachmentTypes.video)) + IconButton( + padding: const EdgeInsets.all(0), + icon: StreamSvgIcon.record( + color: _getIconColor(3), + ), + onPressed: attachmentLimitCrossed || + (messageInputController + .attachments.isNotEmpty && + !_attachmentContainsVideo) + ? null + : () { + widget.onFilePicked( + DefaultAttachmentTypes.video, + camera: true, + ); + }, + ), + for (int i = 0; + i < widget.customAttachmentTypes.length; + i++) + IconButton( + onPressed: () { + if (messageInputController.attachments.isNotEmpty) { + if (!messageInputController.attachments.any((e) => + e.type == + widget.customAttachmentTypes[i].type)) { + return; + } + } + + setState(() { + _filePickerIndex = i + 1; + }); + }, + icon: widget.customAttachmentTypes[i] + .iconBuilder(context, _filePickerIndex == i + 1), + ), + ], + ), + DecoratedBox( + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.barsBg, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.inputBg, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + ), + if (widget.isOpen && + (widget.allowedAttachmentTypes + .contains(DefaultAttachmentTypes.image) || + (widget.allowedAttachmentTypes + .contains(DefaultAttachmentTypes.file)))) + Expanded( + child: DecoratedBox( + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.barsBg, + borderRadius: BorderRadius.circular(8), + ), + child: _PickerWidget( + filePickerIndex: _filePickerIndex, + streamChatTheme: _streamChatTheme, + containsFile: _attachmentContainsFile, + selectedMedias: messageInputController.attachments + .map((e) => e.id) + .toList(), + onAddMoreFilesClick: widget.onFilePicked, + onMediaSelected: (media) { + if (messageInputController.attachments + .any((e) => e.id == media.id)) { + messageInputController + .removeAttachmentById(media.id); + } else { + _addAssetAttachment(media); + } + }, + allowedAttachmentTypes: widget.allowedAttachmentTypes, + customAttachmentTypes: widget.customAttachmentTypes, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + void _addAssetAttachment(AssetEntity medium) async { + final mediaFile = await medium.originFile.timeout( + const Duration(seconds: 5), + onTimeout: () => medium.originFile, + ); + + if (mediaFile == null) return; + + var file = AttachmentFile( + path: mediaFile.path, + size: await mediaFile.length(), + bytes: mediaFile.readAsBytesSync(), + ); + + if (file.size! > widget.maxAttachmentSize) { + if (medium.type == AssetType.video && file.path != null) { + final mediaInfo = await (VideoService.compressVideo( + file.path!, + frameRate: widget.compressedVideoFrameRate, + quality: widget.compressedVideoQuality, + ) as FutureOr); + + if (mediaInfo.filesize! > widget.maxAttachmentSize) { + widget.onError?.call( + context.translations.fileTooLargeAfterCompressionError( + widget.maxAttachmentSize / (1024 * 1024), + ), + ); + return; + } + file = AttachmentFile( + name: file.name, + size: mediaInfo.filesize, + bytes: await mediaInfo.file?.readAsBytes(), + path: mediaInfo.path, + ); + } else { + widget.onError?.call(context.translations.fileTooLargeError( + widget.maxAttachmentSize / (1024 * 1024), + )); + return; + } + } + + setState(() { + final attachment = Attachment( + id: medium.id, + file: file, + type: medium.type == AssetType.image ? 'image' : 'video', + ); + _addAttachments([attachment]); + }); + } + + /// Adds an attachment to the [messageInputController.attachments] map + void _addAttachments(Iterable attachments) { + final limit = widget.attachmentLimit; + final length = + widget.messageInputController.attachments.length + attachments.length; + if (length > limit) { + final onAttachmentLimitExceed = widget.onAttachmentLimitExceeded; + if (onAttachmentLimitExceed != null) { + return onAttachmentLimitExceed( + widget.attachmentLimit, + context.translations.attachmentLimitExceedError(limit), + ); + } + return widget.onError?.call( + context.translations.attachmentLimitExceedError(limit), + ); + } + for (final attachment in attachments) { + widget.messageInputController.addAttachment(attachment); + } + } +} + +class _PickerWidget extends StatefulWidget { + const _PickerWidget({ + Key? key, + required this.filePickerIndex, + required this.containsFile, + required this.selectedMedias, + required this.onAddMoreFilesClick, + required this.onMediaSelected, + required this.streamChatTheme, + required this.allowedAttachmentTypes, + required this.customAttachmentTypes, + }) : super(key: key); + + final int filePickerIndex; + final bool containsFile; + final List selectedMedias; + final void Function(DefaultAttachmentTypes) onAddMoreFilesClick; + final void Function(AssetEntity) onMediaSelected; + final StreamChatThemeData streamChatTheme; + final List allowedAttachmentTypes; + final List customAttachmentTypes; + + @override + _PickerWidgetState createState() => _PickerWidgetState(); +} + +class _PickerWidgetState extends State<_PickerWidget> { + Future? requestPermission; + + @override + void initState() { + super.initState(); + requestPermission = PhotoManager.requestPermission(); + } + + @override + Widget build(BuildContext context) { + if (widget.filePickerIndex != 0) { + return widget.customAttachmentTypes[widget.filePickerIndex - 1] + .pickerBuilder(context); + } + return FutureBuilder( + future: requestPermission, + builder: (context, snapshot) { + if (!snapshot.hasData) { + return const Offstage(); + } + + if (snapshot.data!) { + if (widget.containsFile || + !widget.allowedAttachmentTypes + .contains(DefaultAttachmentTypes.image)) { + return GestureDetector( + onTap: () { + widget.onAddMoreFilesClick(DefaultAttachmentTypes.file); + }, + child: Container( + constraints: const BoxConstraints.expand(), + color: widget.streamChatTheme.colorTheme.inputBg, + alignment: Alignment.center, + child: Text( + context.translations.addMoreFilesLabel, + style: TextStyle( + color: widget.streamChatTheme.colorTheme.accentPrimary, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + return MediaListView( + selectedIds: widget.selectedMedias, + onSelect: widget.onMediaSelected, + ); + } + + return InkWell( + onTap: () async { + PhotoManager.openSetting(); + }, + child: Container( + color: widget.streamChatTheme.colorTheme.inputBg, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SvgPicture.asset( + 'svgs/icon_picture_empty_state.svg', + package: 'stream_chat_flutter', + height: 140, + color: widget.streamChatTheme.colorTheme.disabled, + ), + Text( + context.translations.enablePhotoAndVideoAccessMessage, + style: widget.streamChatTheme.textTheme.body.copyWith( + color: widget.streamChatTheme.colorTheme.textLowEmphasis, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Center( + child: Text( + context.translations.allowGalleryAccessMessage, + style: widget.streamChatTheme.textTheme.bodyBold.copyWith( + color: widget.streamChatTheme.colorTheme.accentPrimary, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +/// Class which holds data for a custom attachment type in the attachment picker +class CustomAttachmentType { + /// Default constructor for creating a custom attachment for the attachment + /// picker. + CustomAttachmentType({ + required this.type, + required this.iconBuilder, + required this.pickerBuilder, + }); + + /// Type name. + String type; + + /// Builds the icon in the attachment picker top row. + CustomAttachmentIconBuilder iconBuilder; + + /// Builds content in the attachment builder when icon is selected. + WidgetBuilder pickerBuilder; +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_send_button.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_send_button.dart new file mode 100644 index 00000000..9916f6f7 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_send_button.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:stream_chat_flutter/stream_chat_flutter.dart'; + +/// A widget that displays a sending button. +class StreamMessageSendButton extends StatelessWidget { + /// Returns a [StreamMessageSendButton] with the given [timeOut], [isIdle], + /// [isCommandEnabled], [isEditEnabled], [idleSendButton], [activeSendButton], + /// [onSendMessage]. + const StreamMessageSendButton({ + Key? key, + this.timeOut = 0, + this.isIdle = true, + this.isCommandEnabled = false, + this.isEditEnabled = false, + this.idleSendButton, + this.activeSendButton, + required this.onSendMessage, + }) : super(key: key); + + /// Time out related to slow mode. + final int timeOut; + + /// If true the button will be disabled. + final bool isIdle; + + /// True if a command is being sent. + final bool isCommandEnabled; + + /// True if in editing mode. + final bool isEditEnabled; + + /// The widget to display when the button is disabled. + final Widget? idleSendButton; + + /// The widget to display when the button is enabled. + final Widget? activeSendButton; + + /// The callback to call when the button is pressed. + final VoidCallback onSendMessage; + + @override + Widget build(BuildContext context) { + final _streamChatTheme = StreamChatTheme.of(context); + + late Widget sendButton; + if (timeOut > 0) { + sendButton = CountdownButton(count: timeOut); + } else if (isIdle) { + sendButton = idleSendButton ?? _buildIdleSendButton(context); + } else { + sendButton = activeSendButton != null + ? InkWell( + onTap: onSendMessage, + child: activeSendButton, + ) + : _buildSendButton(context); + } + + return AnimatedSwitcher( + duration: _streamChatTheme.messageInputTheme.sendAnimationDuration!, + child: sendButton, + ); + } + + Widget _buildIdleSendButton(BuildContext context) { + final _messageInputTheme = MessageInputTheme.of(context); + + return Padding( + padding: const EdgeInsets.all(8), + child: StreamSvgIcon( + assetName: _getIdleSendIcon(), + color: _messageInputTheme.sendButtonIdleColor, + ), + ); + } + + Widget _buildSendButton(BuildContext context) { + final _messageInputTheme = MessageInputTheme.of(context); + + return Padding( + padding: const EdgeInsets.all(8), + child: IconButton( + onPressed: onSendMessage, + padding: const EdgeInsets.all(0), + splashRadius: 24, + constraints: const BoxConstraints.tightFor( + height: 24, + width: 24, + ), + icon: StreamSvgIcon( + assetName: _getSendIcon(), + color: _messageInputTheme.sendButtonColor, + ), + ), + ); + } + + String _getIdleSendIcon() { + if (isCommandEnabled) { + return 'Icon_search.svg'; + } else { + return 'Icon_circle_right.svg'; + } + } + + String _getSendIcon() { + if (isEditEnabled) { + return 'Icon_circle_up.svg'; + } else if (isCommandEnabled) { + return 'Icon_search.svg'; + } else { + return 'Icon_circle_up.svg'; + } + } +} diff --git a/packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart b/packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart new file mode 100644 index 00000000..61466aaf --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/message_input/stream_message_text_field.dart @@ -0,0 +1,766 @@ +// ignore_for_file: prefer-trailing-comma, cascade_invocations, lines_longer_than_80_chars + +import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +export 'package:flutter/services.dart' + show + TextInputType, + TextInputAction, + TextCapitalization, + SmartQuotesType, + SmartDashesType; + +/// A widget the wraps the [TextField] and adds some StreamChat specifics. +class StreamMessageTextField extends StatefulWidget { + /// Creates a Material Design text field. + /// + /// If [decoration] is non-null (which is the default), the text field + /// requires one of its ancestors to be a [Material] widget. + /// + /// To remove the decoration entirely (including the extra padding introduced + /// by the decoration to save space for the labels), set the [decoration] to + /// null. + /// + /// The [maxLines] property can be set to null to remove the restriction on + /// the number of lines. By default, it is one, meaning this is a single-line + /// text field. [maxLines] must not be zero. + /// + /// The [maxLength] property is set to null by default, which means the + /// number of characters allowed in the text field is not restricted. If + /// [maxLength] is set a character counter will be displayed below the + /// field showing how many characters have been entered. If the value is + /// set to a positive integer it will also display the maximum allowed + /// number of characters to be entered. If the value is set to + /// [TextField.noMaxLength] then only the current length is displayed. + /// + /// After [maxLength] characters have been input, additional input + /// is ignored, unless [maxLengthEnforcement] is set to + /// [MaxLengthEnforcement.none]. + /// The text field enforces the length with a + /// [LengthLimitingTextInputFormatter], + /// which is evaluated after the supplied [inputFormatters], if any. + /// The [maxLength] value must be either null or greater than zero. + /// + /// The text cursor is not shown if [showCursor] is false or if [showCursor] + /// is null (the default) and [readOnly] is true. + /// + /// The [selectionHeightStyle] and [selectionWidthStyle] properties allow + /// changing the shape of the selection highlighting. These properties default + /// to [ui.BoxHeightStyle.tight] and [ui.BoxWidthStyle.tight] respectively and + /// must not be null. + /// + /// The [textAlign], [autofocus], [obscureText], [readOnly], [autocorrect], + /// [scrollPadding], [maxLines], [maxLength], + /// [selectionHeightStyle], [selectionWidthStyle], [enableSuggestions], and + /// [enableIMEPersonalizedLearning] arguments must not be null. + /// + /// See also: + /// + /// * [maxLength], which discusses the precise meaning of "number of + /// characters" and how it may differ from the intuitive meaning. + const StreamMessageTextField({ + Key? key, + this.controller, + this.focusNode, + this.decoration = const InputDecoration(), + TextInputType? keyboardType, + this.textInputAction, + this.textCapitalization = TextCapitalization.none, + this.style, + this.strutStyle, + this.textAlign = TextAlign.start, + this.textAlignVertical, + this.textDirection, + this.readOnly = false, + ToolbarOptions? toolbarOptions, + this.showCursor, + this.autofocus = false, + this.obscuringCharacter = '•', + this.obscureText = false, + this.autocorrect = true, + SmartDashesType? smartDashesType, + SmartQuotesType? smartQuotesType, + this.enableSuggestions = true, + this.maxLines = 1, + this.minLines, + this.expands = false, + this.maxLength, + @Deprecated( + 'Use maxLengthEnforcement parameter which provides more specific ' + 'behavior related to the maxLength limit. ' + 'This feature was deprecated after v1.25.0-5.0.pre.', + ) + this.maxLengthEnforced = true, + this.maxLengthEnforcement, + this.onChanged, + this.onEditingComplete, + this.onSubmitted, + this.onAppPrivateCommand, + this.inputFormatters, + this.enabled, + this.cursorWidth = 2.0, + this.cursorHeight, + this.cursorRadius, + this.cursorColor, + this.selectionHeightStyle = ui.BoxHeightStyle.tight, + this.selectionWidthStyle = ui.BoxWidthStyle.tight, + this.keyboardAppearance, + this.scrollPadding = const EdgeInsets.all(20), + this.dragStartBehavior = DragStartBehavior.start, + this.enableInteractiveSelection = true, + this.selectionControls, + this.onTap, + this.mouseCursor, + this.buildCounter, + this.scrollController, + this.scrollPhysics, + this.autofillHints, + this.restorationId, + this.enableIMEPersonalizedLearning = true, + }) : assert(obscuringCharacter.length == 1, + '`obscuringCharacter.length` must be 1'), + smartDashesType = smartDashesType ?? + (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), + smartQuotesType = smartQuotesType ?? + (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), + assert( + maxLengthEnforced || maxLengthEnforcement == null, + 'maxLengthEnforced is deprecated, use only maxLengthEnforcement', + ), + assert(maxLines == null || maxLines > 0, + '`maxLines` needs to be left as null or bigger than 0'), + assert(minLines == null || minLines > 0, + '`minLines` needs to be left as null or bigger than 0'), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert( + !expands || (maxLines == null && minLines == null), + 'minLines and maxLines must be null when expands is true.', + ), + assert(!obscureText || maxLines == 1, + 'Obscured fields cannot be multiline.'), + assert( + maxLength == null || + maxLength == TextField.noMaxLength || + maxLength > 0, + '`maxLength` needs to be null or a positive integer'), + + // Assert the following instead of setting it directly to avoid + // surprising the user by silently changing the value they set. + assert( + !identical(textInputAction, TextInputAction.newline) || + maxLines == 1 || + !identical(keyboardType, TextInputType.text), + '''Use keyboardType TextInputType.multiline when using TextInputAction.newline on a multiline TextField.''', + ), + keyboardType = keyboardType ?? + (maxLines == 1 ? TextInputType.text : TextInputType.multiline), + toolbarOptions = toolbarOptions ?? + (obscureText + ? const ToolbarOptions( + selectAll: true, + paste: true, + ) + : const ToolbarOptions( + copy: true, + cut: true, + selectAll: true, + paste: true, + )), + super(key: key); + + /// Controls the message being edited. + /// + /// If null, this widget will create its own [MessageInputController]. + final MessageInputController? controller; + + /// Defines the keyboard focus for this widget. + /// + /// The [focusNode] is a long-lived object that's typically managed by a + /// [StatefulWidget] parent. See [FocusNode] for more information. + /// + /// To give the keyboard focus to this widget, provide a [focusNode] and then + /// use the current [FocusScope] to request the focus: + /// + /// ```dart + /// FocusScope.of(context).requestFocus(myFocusNode); + /// ``` + /// + /// This happens automatically when the widget is tapped. + /// + /// To be notified when the widget gains or loses the focus, add a listener + /// to the [focusNode]: + /// + /// ```dart + /// focusNode.addListener(() { print(myFocusNode.hasFocus); }); + /// ``` + /// + /// If null, this widget will create its own [FocusNode]. + /// + /// ## Keyboard + /// + /// Requesting the focus will typically cause the keyboard to be shown + /// if it's not showing already. + /// + /// On Android, the user can hide the keyboard - without changing the focus - + /// with the system back button. They can restore the keyboard's visibility + /// by tapping on a text field. The user might hide the keyboard and + /// switch to a physical keyboard, or they might just need to get it + /// out of the way for a moment, to expose something it's + /// obscuring. In this case requesting the focus again will not + /// cause the focus to change, and will not make the keyboard visible. + /// + /// This widget builds an [EditableText] and will ensure that the keyboard is + /// showing when it is tapped by calling + /// [EditableTextState.requestKeyboard()]. + final FocusNode? focusNode; + + /// The decoration to show around the text field. + /// + /// By default, draws a horizontal line under the text field but can be + /// configured to show an icon, label, hint text, and error text. + /// + /// Specify null to remove the decoration entirely (including the + /// extra padding introduced by the decoration to save space for the labels). + final InputDecoration? decoration; + + /// {@macro flutter.widgets.editableText.keyboardType} + final TextInputType keyboardType; + + /// The type of action button to use for the keyboard. + /// + /// Defaults to [TextInputAction.newline] if [keyboardType] is + /// [TextInputType.multiline] and [TextInputAction.done] otherwise. + final TextInputAction? textInputAction; + + /// {@macro flutter.widgets.editableText.textCapitalization} + final TextCapitalization textCapitalization; + + /// The style to use for the text being edited. + /// + /// This text style is also used as the base style for the [decoration]. + /// + /// If null, defaults to the `subtitle1` text style from the current [Theme]. + final TextStyle? style; + + /// {@macro flutter.widgets.editableText.strutStyle} + final StrutStyle? strutStyle; + + /// {@macro flutter.widgets.editableText.textAlign} + final TextAlign textAlign; + + /// {@macro flutter.material.InputDecorator.textAlignVertical} + final TextAlignVertical? textAlignVertical; + + /// {@macro flutter.widgets.editableText.textDirection} + final TextDirection? textDirection; + + /// {@macro flutter.widgets.editableText.autofocus} + final bool autofocus; + + /// {@macro flutter.widgets.editableText.obscuringCharacter} + final String obscuringCharacter; + + /// {@macro flutter.widgets.editableText.obscureText} + final bool obscureText; + + /// {@macro flutter.widgets.editableText.autocorrect} + final bool autocorrect; + + /// {@macro flutter.services.TextInputConfiguration.smartDashesType} + final SmartDashesType smartDashesType; + + /// {@macro flutter.services.TextInputConfiguration.smartQuotesType} + final SmartQuotesType smartQuotesType; + + /// {@macro flutter.services.TextInputConfiguration.enableSuggestions} + final bool enableSuggestions; + + /// {@macro flutter.widgets.editableText.maxLines} + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? maxLines; + + /// {@macro flutter.widgets.editableText.minLines} + /// * [expands], which determines whether the field should fill the height of + /// its parent. + final int? minLines; + + /// {@macro flutter.widgets.editableText.expands} + final bool expands; + + /// {@macro flutter.widgets.editableText.readOnly} + final bool readOnly; + + /// Configuration of toolbar options. + /// + /// If not set, select all and paste will default to be enabled. Copy and cut + /// will be disabled if [obscureText] is true. If [readOnly] is true, + /// paste and cut will be disabled regardless. + final ToolbarOptions toolbarOptions; + + /// {@macro flutter.widgets.editableText.showCursor} + final bool? showCursor; + + /// If [maxLength] is set to this value, only the "current input length" + /// part of the character counter is shown. + static const int noMaxLength = -1; + + /// The maximum number of characters (Unicode scalar values) to allow in the + /// text field. + /// + /// If set, a character counter will be displayed below the + /// field showing how many characters have been entered. If set to a number + /// greater than 0, it will also display the maximum number allowed. If set + /// to [TextField.noMaxLength] then only the current character count is + /// displayed. + /// + /// After [maxLength] characters have been input, additional input + /// is ignored, unless [maxLengthEnforcement] is set to + /// [MaxLengthEnforcement.none]. + /// + /// The text field enforces the length with a + /// [LengthLimitingTextInputFormatter], which is evaluated after the supplied + /// [inputFormatters], if any. + /// + /// This value must be either null, [TextField.noMaxLength], or greater than + /// 0. + /// + /// If null (the default) then there is no limit to the number of characters + /// that can be entered. If set to [TextField.noMaxLength], then no limit will + /// be enforced, but the number of characters entered will still be displayed. + /// + /// Whitespace characters (e.g. newline, space, tab) are included in the + /// character count. + /// + /// {@macro flutter.services.lengthLimitingTextInputFormatter.maxLength} + final int? maxLength; + + /// If [maxLength] is set, [maxLengthEnforced] indicates whether or not to + /// enforce the limit, or merely provide a character counter and warning when + /// [maxLength] is exceeded. + /// + /// If true, prevents the field from allowing more than [maxLength] + /// characters. + @Deprecated( + 'Use maxLengthEnforcement parameter which provides more specific ' + 'behavior related to the maxLength limit. ' + 'This feature was deprecated after v1.25.0-5.0.pre.', + ) + final bool maxLengthEnforced; + + /// Determines how the [maxLength] limit should be enforced. + /// + /// {@macro flutter.services.textFormatter.effectiveMaxLengthEnforcement} + /// + /// {@macro flutter.services.textFormatter.maxLengthEnforcement} + final MaxLengthEnforcement? maxLengthEnforcement; + + /// {@macro flutter.widgets.editableText.onChanged} + /// + /// See also: + /// + /// * [inputFormatters], which are called before [onChanged] + /// runs and can validate and change ("format") the input value. + /// * [onEditingComplete], [onSubmitted]: + /// which are more specialized input change notifications. + final ValueChanged? onChanged; + + /// {@macro flutter.widgets.editableText.onEditingComplete} + final VoidCallback? onEditingComplete; + + /// {@macro flutter.widgets.editableText.onSubmitted} + /// + /// See also: + /// + /// * [TextInputAction.next] and [TextInputAction.previous], which + /// automatically shift the focus to the next/previous focusable item when + /// the user is done editing. + final ValueChanged? onSubmitted; + + /// {@macro flutter.widgets.editableText.onAppPrivateCommand} + final AppPrivateCommandCallback? onAppPrivateCommand; + + /// {@macro flutter.widgets.editableText.inputFormatters} + final List? inputFormatters; + + /// If false the text field is "disabled": it ignores taps and its + /// [decoration] is rendered in grey. + /// + /// If non-null this property overrides the [decoration]'s + /// [InputDecoration.enabled] property. + final bool? enabled; + + /// {@macro flutter.widgets.editableText.cursorWidth} + final double cursorWidth; + + /// {@macro flutter.widgets.editableText.cursorHeight} + final double? cursorHeight; + + /// {@macro flutter.widgets.editableText.cursorRadius} + final Radius? cursorRadius; + + /// The color of the cursor. + /// + /// The cursor indicates the current location of text insertion point in + /// the field. + /// + /// If this is null it will default to the ambient + /// [TextSelectionThemeData.cursorColor]. If that is null, and the + /// [ThemeData.platform] is [TargetPlatform.iOS] or [TargetPlatform.macOS] + /// it will use [CupertinoThemeData.primaryColor]. Otherwise it will use + /// the value of [ColorScheme.primary] of [ThemeData.colorScheme]. + final Color? cursorColor; + + /// Controls how tall the selection highlight boxes are computed to be. + /// + /// See [ui.BoxHeightStyle] for details on available styles. + final ui.BoxHeightStyle selectionHeightStyle; + + /// Controls how wide the selection highlight boxes are computed to be. + /// + /// See [ui.BoxWidthStyle] for details on available styles. + final ui.BoxWidthStyle selectionWidthStyle; + + /// The appearance of the keyboard. + /// + /// This setting is only honored on iOS devices. + /// + /// If unset, defaults to the brightness of + /// [ThemeData.primaryColorBrightness]. + final Brightness? keyboardAppearance; + + /// {@macro flutter.widgets.editableText.scrollPadding} + final EdgeInsets scrollPadding; + + /// {@macro flutter.widgets.editableText.enableInteractiveSelection} + final bool enableInteractiveSelection; + + /// {@macro flutter.widgets.editableText.selectionControls} + final TextSelectionControls? selectionControls; + + /// {@macro flutter.widgets.scrollable.dragStartBehavior} + final DragStartBehavior dragStartBehavior; + + /// {@macro flutter.widgets.editableText.selectionEnabled} + bool get selectionEnabled => enableInteractiveSelection; + + /// {@template flutter.material.textfield.onTap} + /// Called for each distinct tap except for every second tap of a double tap. + /// + /// The text field builds a [GestureDetector] to handle input events like tap, + /// to trigger focus requests, to move the caret, adjust the selection, etc. + /// Handling some of those events by wrapping the text field with a competing + /// GestureDetector is problematic. + /// + /// To unconditionally handle taps, without interfering with the text field's + /// internal gesture detector, provide this callback. + /// + /// If the text field is created with [enabled] false, taps will not be + /// recognized. + /// + /// To be notified when the text field gains or loses the focus, provide a + /// [focusNode] and add a listener to that. + /// + /// To listen to arbitrary pointer events without competing with the + /// text field's internal gesture detector, use a [Listener]. + /// {@endtemplate} + final GestureTapCallback? onTap; + + /// The cursor for a mouse pointer when it enters or is hovering over the + /// widget. + /// + /// If [mouseCursor] is a [MaterialStateProperty], + /// [MaterialStateProperty.resolve] is used for the following + /// [MaterialState]s: + /// + /// * [MaterialState.error]. + /// * [MaterialState.hovered]. + /// * [MaterialState.focused]. + /// * [MaterialState.disabled]. + /// + /// If this property is null, [MaterialStateMouseCursor.textable] will be + /// used. + /// + /// The [mouseCursor] is the only property of [TextField] that controls the + /// appearance of the mouse pointer. All other properties related to "cursor" + /// stand for the text cursor, which is usually a blinking vertical line at + /// the editing position. + final MouseCursor? mouseCursor; + + /// Callback that generates a custom [InputDecoration.counter] widget. + /// + /// See [InputCounterWidgetBuilder] for an explanation of the passed in + /// arguments. The returned widget will be placed below the line in place of + /// the default widget built when [InputDecoration.counterText] is specified. + /// + /// The returned widget will be wrapped in a [Semantics] widget for + /// accessibility, but it also needs to be accessible itself. For example, + /// if returning a Text widget, set the [Text.semanticsLabel] property. + /// + /// {@tool snippet} + /// ```dart + /// Widget counter( + /// BuildContext context, + /// { + /// required int currentLength, + /// required int? maxLength, + /// required bool isFocused, + /// } + /// ) { + /// return Text( + /// '$currentLength of $maxLength characters', + /// semanticsLabel: 'character count', + /// ); + /// } + /// ``` + /// {@end-tool} + /// + /// If buildCounter returns null, then no counter and no Semantics widget will + /// be created at all. + final InputCounterWidgetBuilder? buildCounter; + + /// {@macro flutter.widgets.editableText.scrollPhysics} + final ScrollPhysics? scrollPhysics; + + /// {@macro flutter.widgets.editableText.scrollController} + final ScrollController? scrollController; + + /// {@macro flutter.widgets.editableText.autofillHints} + /// {@macro flutter.services.AutofillConfiguration.autofillHints} + final Iterable? autofillHints; + + /// {@template flutter.material.textfield.restorationId} + /// Restoration ID to save and restore the state of the text field. + /// + /// If non-null, the text field will persist and restore its current scroll + /// offset and - if no [controller] has been provided - the content of the + /// text field. If a [controller] has been provided, it is the responsibility + /// of the owner of that controller to persist and restore it, e.g. by using + /// a [RestorableTextEditingController]. + /// + /// The state of this widget is persisted in a [RestorationBucket] claimed + /// from the surrounding [RestorationScope] using the provided restoration ID. + /// + /// See also: + /// + /// * [RestorationManager], which explains how state restoration works in + /// Flutter. + /// {@endtemplate} + final String? restorationId; + + /// {@macro flutter.services.TextInputConfiguration.enableIMEPersonalizedLearning} + final bool enableIMEPersonalizedLearning; + + @override + _StreamMessageTextFieldState createState() => _StreamMessageTextFieldState(); + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('focusNode', focusNode, + defaultValue: null)); + properties + .add(DiagnosticsProperty('enabled', enabled, defaultValue: null)); + properties.add(DiagnosticsProperty( + 'decoration', decoration, + defaultValue: const InputDecoration())); + properties.add(DiagnosticsProperty( + 'keyboardType', keyboardType, + defaultValue: TextInputType.text)); + properties.add( + DiagnosticsProperty('style', style, defaultValue: null)); + properties.add( + DiagnosticsProperty('autofocus', autofocus, defaultValue: false)); + properties.add(DiagnosticsProperty( + 'obscuringCharacter', obscuringCharacter, + defaultValue: '•')); + properties.add(DiagnosticsProperty('obscureText', obscureText, + defaultValue: false)); + properties.add(DiagnosticsProperty('autocorrect', autocorrect, + defaultValue: true)); + properties.add(EnumProperty( + 'smartDashesType', smartDashesType, + defaultValue: + obscureText ? SmartDashesType.disabled : SmartDashesType.enabled)); + properties.add(EnumProperty( + 'smartQuotesType', smartQuotesType, + defaultValue: + obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled)); + properties.add(DiagnosticsProperty( + 'enableSuggestions', enableSuggestions, + defaultValue: true)); + properties.add(IntProperty('maxLines', maxLines, defaultValue: 1)); + properties.add(IntProperty('minLines', minLines, defaultValue: null)); + properties.add( + DiagnosticsProperty('expands', expands, defaultValue: false)); + properties.add(IntProperty('maxLength', maxLength, defaultValue: null)); + properties.add(EnumProperty( + 'maxLengthEnforcement', maxLengthEnforcement, + defaultValue: null)); + properties.add(EnumProperty( + 'textInputAction', textInputAction, + defaultValue: null)); + properties.add(EnumProperty( + 'textCapitalization', textCapitalization, + defaultValue: TextCapitalization.none)); + properties.add(EnumProperty('textAlign', textAlign, + defaultValue: TextAlign.start)); + properties.add(DiagnosticsProperty( + 'textAlignVertical', textAlignVertical, + defaultValue: null)); + properties.add(EnumProperty('textDirection', textDirection, + defaultValue: null)); + properties + .add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0)); + properties + .add(DoubleProperty('cursorHeight', cursorHeight, defaultValue: null)); + properties.add(DiagnosticsProperty('cursorRadius', cursorRadius, + defaultValue: null)); + properties + .add(ColorProperty('cursorColor', cursorColor, defaultValue: null)); + properties.add(DiagnosticsProperty( + 'keyboardAppearance', keyboardAppearance, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'scrollPadding', scrollPadding, + defaultValue: const EdgeInsets.all(20))); + properties.add(FlagProperty('selectionEnabled', + value: selectionEnabled, + defaultValue: true, + ifFalse: 'selection disabled')); + properties.add(DiagnosticsProperty( + 'selectionControls', selectionControls, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'scrollController', scrollController, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'scrollPhysics', scrollPhysics, + defaultValue: null)); + properties.add(DiagnosticsProperty( + 'enableIMEPersonalizedLearning', enableIMEPersonalizedLearning, + defaultValue: true)); + } +} + +class _StreamMessageTextFieldState extends State + with RestorationMixin { + RestorableMessageInputController? _controller; + + MessageInputController get _effectiveController => + widget.controller ?? _controller!.value; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _createLocalController(); + } + } + + void _createLocalController([Message? message]) { + assert(_controller == null, ''); + _controller = RestorableMessageInputController(message: message); + } + + @override + void didUpdateWidget(covariant StreamMessageTextField oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.controller == null && oldWidget.controller != null) { + _createLocalController(oldWidget.controller!.value); + } else if (widget.controller != null && oldWidget.controller == null) { + unregisterFromRestoration(_controller!); + _controller!.dispose(); + _controller = null; + } + } + + @override + void restoreState(RestorationBucket? oldBucket, bool initialRestore) { + if (_controller != null) { + _registerController(); + } + } + + @override + String? get restorationId => widget.restorationId; + + void _registerController() { + assert(_controller != null, ''); + registerForRestoration(_controller!, restorationId ?? 'controller'); + } + + @override + Widget build(BuildContext context) => TextField( + controller: _effectiveController.textEditingController, + onChanged: (newText) { + _effectiveController.text = newText; + }, + focusNode: widget.focusNode, + decoration: widget.decoration, + keyboardType: widget.keyboardType, + textInputAction: widget.textInputAction, + textCapitalization: widget.textCapitalization, + style: widget.style, + strutStyle: widget.strutStyle, + textAlign: widget.textAlign, + textAlignVertical: widget.textAlignVertical, + textDirection: widget.textDirection, + readOnly: widget.readOnly, + toolbarOptions: widget.toolbarOptions, + showCursor: widget.showCursor, + autofocus: widget.autofocus, + obscuringCharacter: widget.obscuringCharacter, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + smartDashesType: widget.smartDashesType, + smartQuotesType: widget.smartQuotesType, + enableSuggestions: widget.enableSuggestions, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + maxLength: widget.maxLength, + maxLengthEnforcement: widget.maxLengthEnforcement, + onEditingComplete: widget.onEditingComplete, + onSubmitted: widget.onSubmitted, + onAppPrivateCommand: widget.onAppPrivateCommand, + inputFormatters: widget.inputFormatters, + enabled: widget.enabled, + cursorWidth: widget.cursorWidth, + cursorHeight: widget.cursorHeight, + cursorRadius: widget.cursorRadius, + cursorColor: widget.cursorColor, + selectionHeightStyle: widget.selectionHeightStyle, + selectionWidthStyle: widget.selectionWidthStyle, + keyboardAppearance: widget.keyboardAppearance, + scrollPadding: widget.scrollPadding, + dragStartBehavior: widget.dragStartBehavior, + enableInteractiveSelection: widget.enableInteractiveSelection, + selectionControls: widget.selectionControls, + onTap: widget.onTap, + mouseCursor: widget.mouseCursor, + buildCounter: widget.buildCounter, + scrollController: widget.scrollController, + scrollPhysics: widget.scrollPhysics, + autofillHints: widget.autofillHints, + restorationId: widget.restorationId, + enableIMEPersonalizedLearning: widget.enableIMEPersonalizedLearning, + ); + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } +} diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart index 23d913cb..f3818b2e 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart @@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide TextTheme; import 'package:stream_chat_flutter/src/channel_preview.dart'; import 'package:stream_chat_flutter/src/gradient_avatar.dart'; -import 'package:stream_chat_flutter/src/message_input.dart'; +import 'package:stream_chat_flutter/src/message_input/message_input.dart'; import 'package:stream_chat_flutter/src/reaction_icon.dart'; import 'package:stream_chat_flutter/src/theme/themes.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index cd0d93a0..a4e9d715 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -23,7 +23,11 @@ export 'src/localization/stream_chat_localizations.dart'; export 'src/localization/translations.dart' show DefaultTranslations; export 'src/mention_tile.dart'; export 'src/message_action.dart'; -export 'src/message_input.dart'; +export 'src/message_input/countdown_button.dart'; +export 'src/message_input/message_input.dart'; +export 'src/message_input/stream_attachment_picker.dart'; +export 'src/message_input/stream_message_send_button.dart'; +export 'src/message_input/stream_message_text_field.dart'; export 'src/message_list_view.dart'; export 'src/message_search_item.dart'; export 'src/message_search_list_view.dart'; diff --git a/packages/stream_chat_flutter/test/src/attachment_actions_modal_test.dart b/packages/stream_chat_flutter/test/src/attachment_actions_modal_test.dart index c8832f6f..3b711a85 100644 --- a/packages/stream_chat_flutter/test/src/attachment_actions_modal_test.dart +++ b/packages/stream_chat_flutter/test/src/attachment_actions_modal_test.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat_flutter/src/attachment_actions_modal.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'mocks.dart'; diff --git a/packages/stream_chat_flutter/test/src/message_action_modal_test.dart b/packages/stream_chat_flutter/test/src/message_action_modal_test.dart index 702e162c..92d0a6a6 100644 --- a/packages/stream_chat_flutter/test/src/message_action_modal_test.dart +++ b/packages/stream_chat_flutter/test/src/message_action_modal_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:stream_chat_flutter/src/message_actions_modal.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'mocks.dart'; @@ -37,6 +38,7 @@ void main() { user: User( id: 'user-id', ), + status: MessageSendingStatus.sent, ), messageWidget: const Text( 'test', @@ -194,6 +196,7 @@ void main() { user: User( id: 'user-id', ), + status: MessageSendingStatus.sent, ), messageTheme: streamTheme.ownMessageTheme, ), @@ -240,6 +243,7 @@ void main() { user: User( id: 'user-id', ), + status: MessageSendingStatus.sent, ), messageTheme: streamTheme.ownMessageTheme, ), diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index b69c4262..2e89b6e7 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -1,3 +1,9 @@ +# Upcoming + +✅ Added + +- Added `MessageInputController` to hold `Message` related data. + ## 3.3.1 - Updated `stream_chat` dependency to [`3.3.1`](https://pub.dev/packages/stream_chat/changelog). diff --git a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart index 0e8d48bc..6514d66b 100644 --- a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:rxdart/rxdart.dart'; import 'package:stream_chat/stream_chat.dart'; diff --git a/packages/stream_chat_flutter_core/lib/src/message_input_controller.dart b/packages/stream_chat_flutter_core/lib/src/message_input_controller.dart new file mode 100644 index 00000000..29bd2db2 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/message_input_controller.dart @@ -0,0 +1,273 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat/stream_chat.dart'; + +/// A value listenable builder related to a [Message]. +/// +/// Pass in a [MessageInputController] as the `valueListenable`. +typedef MessageValueListenableBuilder = ValueListenableBuilder; + +/// Controller for storing and mutating a [Message] value. +class MessageInputController extends ValueNotifier { + /// Creates a controller for an editable text field. + /// + /// This constructor treats a null [message] argument as if it were the empty + /// message. + factory MessageInputController({ + Message? message, + }) => + MessageInputController._( + initialMessage: message ?? Message(), + ); + + /// Creates a controller for an editable text field from an initial [text]. + factory MessageInputController.fromText(String? text) => + MessageInputController._( + initialMessage: Message(text: text), + ); + + /// Creates a controller for an editable text field from initial + /// [attachments]. + factory MessageInputController.fromAttachments( + List attachments, + ) => + MessageInputController._( + initialMessage: Message(attachments: attachments), + ); + + MessageInputController._({ + required Message initialMessage, + }) : _textEditingController = + TextEditingController.fromValue(TextEditingValue( + text: initialMessage.text ?? '', + composing: TextRange.collapsed(initialMessage.text?.length ?? 0), + )), + _initialMessage = initialMessage, + super(initialMessage) { + addListener(_textEditingSyncer); + } + + void _textEditingSyncer() { + final cleanText = value.command == null + ? value.text + : value.text?.replaceFirst( + '/${value.command} ', + '', + ); + + if (cleanText != _textEditingController.text) { + final previousOffset = _textEditingController.value.selection.start; + final previousText = _textEditingController.text; + final diff = (cleanText?.length ?? 0) - previousText.length; + _textEditingController + ..text = cleanText ?? '' + ..selection = TextSelection.collapsed( + offset: previousOffset + diff, + ); + } + } + + /// Returns the current message associated with this controller. + Message get message => value; + + /// Returns the controller of the text field linked to this controller. + TextEditingController get textEditingController => _textEditingController; + final TextEditingController _textEditingController; + + /// Returns the text of the message. + String get text => _textEditingController.text; + + Message _initialMessage; + + /// Sets the message. + set message(Message message) { + value = message; + } + + /// Sets the message that's being quoted. + set quotedMessage(Message message) { + value = value.copyWith( + quotedMessage: message, + quotedMessageId: message.id, + ); + } + + /// Clears the quoted message. + void clearQuotedMessage() { + value = value.copyWith( + quotedMessageId: null, + quotedMessage: null, + ); + } + + /// Sets a command for the message. + set command(Command command) { + value = value.copyWith( + command: command.name, + text: '/${command.name} ', + ); + } + + /// Sets the text of the message. + set text(String newText) { + var newTextWithCommand = newText; + if (value.command != null) { + if (!newText.startsWith('/${value.command}')) { + newTextWithCommand = '/${value.command} $newText'; + } + } + value = value.copyWith(text: newTextWithCommand); + } + + /// Returns the baseOffset of the text field. + int get baseOffset => textEditingController.selection.baseOffset; + + /// Returns the start of the selection of the text field. + int get selectionStart => textEditingController.selection.start; + + /// Sets the [showInChannel] flag of the message. + set showInChannel(bool newValue) { + value = value.copyWith(showInChannel: newValue); + } + + /// Returns true if the message is in a thread and + /// should be shown in the main channel as well. + bool get showInChannel => value.showInChannel ?? false; + + /// Returns the attachments of the message. + List get attachments => value.attachments; + + /// Sets the list of [attachments] for the message. + set attachments(List attachments) { + value = value.copyWith(attachments: attachments); + } + + /// Adds a new attachment to the message. + void addAttachment(Attachment attachment) { + attachments = [...attachments, attachment]; + } + + /// Adds a new attachment at the specified [index]. + void addAttachmentAt(int index, Attachment attachment) { + attachments = [...attachments]..insert(index, attachment); + } + + /// Removes the specified [attachment] from the message. + void removeAttachment(Attachment attachment) { + attachments = [...attachments]..remove(attachment); + } + + /// Remove the attachment with the given [attachmentId]. + void removeAttachmentById(String attachmentId) { + attachments = [...attachments]..removeWhere((it) => it.id == attachmentId); + } + + /// Removes the attachment at the given [index]. + void removeAttachmentAt(int index) { + attachments = [...attachments]..removeAt(index); + } + + /// Clears the message attachments. + void clearAttachments() { + attachments = []; + } + + /// Returns the list of mentioned users in the message. + List get mentionedUsers => value.mentionedUsers; + + /// Sets the mentioned users. + set mentionedUsers(List users) { + value = value.copyWith(mentionedUsers: users); + } + + /// Adds a user to the list of mentioned users. + void addMentionedUser(User user) { + mentionedUsers = [...mentionedUsers, user]; + } + + /// Removes the specified [user] from the mentioned users list. + void removeMentionedUser(User user) { + mentionedUsers = [...mentionedUsers]..remove(user); + } + + /// Removes the mentioned user with the given [userId]. + void removeMentionedUserById(String userId) { + mentionedUsers = [...mentionedUsers]..removeWhere((it) => it.id == userId); + } + + /// Removes all mentioned users from the message. + void clearMentionedUsers() { + mentionedUsers = []; + } + + /// Sets the [message], or [value], to empty. + /// + /// After calling this function, [text], [attachments] and [mentionedUsers] + /// will all be empty. + /// + /// Calling this will notify all the listeners of this + /// [MessageInputController] that they need to update + /// (calls [notifyListeners]). For this reason, + /// this method should only be called between frames, e.g. in response to user + /// actions, not during the build, layout, or paint phases. + void clear() { + value = Message(); + _textEditingController.clear(); + } + + /// Sets the [value] to the initial [Message] value. + void reset({bool resetId = true}) { + if (resetId) { + _initialMessage = _initialMessage.copyWith( + id: const Uuid().v4(), + ); + } + value = _initialMessage; + } + + @override + void dispose() { + removeListener(_textEditingSyncer); + _textEditingController.dispose(); + super.dispose(); + } +} + +/// A [RestorableProperty] that knows how to store and restore a +/// [MessageInputController]. +/// +/// The [MessageInputController] is accessible via the [value] getter. During +/// state restoration, the property will restore [MessageInputController.value] +/// to the value it had when the restoration data it is getting restored from +/// was collected. +class RestorableMessageInputController + extends RestorableChangeNotifier { + /// Creates a [RestorableMessageInputController]. + /// + /// This constructor creates a default [Message] when no `message` argument + /// is supplied. + RestorableMessageInputController({Message? message}) + : _initialValue = message ?? Message(); + + /// Creates a [RestorableMessageInputController] from an initial + /// [text] value. + factory RestorableMessageInputController.fromText(String? text) => + RestorableMessageInputController(message: Message(text: text)); + + final Message _initialValue; + + @override + MessageInputController createDefaultValue() => + MessageInputController(message: _initialValue); + + @override + MessageInputController fromPrimitives(Object? data) { + final message = Message.fromJson(json.decode(data! as String)); + return MessageInputController(message: message); + } + + @override + String toPrimitives() => json.encode(value.value); +} diff --git a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart index 74e40d7f..a28d6d73 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_list_core.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'package:collection/collection.dart'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter_core/src/better_stream_builder.dart'; diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index 8bd41c76..bf191ace 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -7,6 +7,7 @@ export 'src/better_stream_builder.dart'; export 'src/channel_list_core.dart' hide ChannelListCoreState; export 'src/channels_bloc.dart'; export 'src/lazy_load_scroll_view.dart'; +export 'src/message_input_controller.dart'; export 'src/message_list_core.dart' hide MessageListCoreState; export 'src/message_search_bloc.dart'; export 'src/message_search_list_core.dart' hide MessageSearchListCoreState; diff --git a/packages/stream_chat_flutter_core/pubspec.yaml b/packages/stream_chat_flutter_core/pubspec.yaml index 1b1734be..fb9307c1 100644 --- a/packages/stream_chat_flutter_core/pubspec.yaml +++ b/packages/stream_chat_flutter_core/pubspec.yaml @@ -6,7 +6,7 @@ repository: https://github.com/GetStream/stream-chat-flutter issue_tracker: https://github.com/GetStream/stream-chat-flutter/issues environment: - sdk: '>=2.12.0 <3.0.0' + sdk: '>=2.14.0 <3.0.0' flutter: ">=1.17.0" dependencies: diff --git a/packages/stream_chat_flutter_core/test/message_input_controller_test.dart b/packages/stream_chat_flutter_core/test/message_input_controller_test.dart new file mode 100644 index 00000000..5e7934bd --- /dev/null +++ b/packages/stream_chat_flutter_core/test/message_input_controller_test.dart @@ -0,0 +1,14 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +void main() { + testWidgets( + 'should instantiate a new MessageInputController with empty message', + (tester) async { + final controller = MessageInputController()..text = 'test'; + + expect(controller.text, 'test'); + expect(controller.message.text, 'test'); + }, + ); +} diff --git a/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart index d2ea8b93..8a06f02d 100644 --- a/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/message_mapper_test.dart @@ -157,7 +157,6 @@ void main() { (prev, curr) => prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1), ), - status: MessageSendingStatus.sending, updatedAt: DateTime.now(), extraData: const {'extra_test_data': 'extraData'}, user: user, diff --git a/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart index 9c2378fd..9c4d99a2 100644 --- a/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/pinned_message_mapper_test.dart @@ -147,7 +147,6 @@ void main() { (prev, curr) => prev?..update(curr.type, (value) => value + 1, ifAbsent: () => 1), ), - status: MessageSendingStatus.sending, updatedAt: DateTime.now(), extraData: const {'extra_test_data': 'extraData'}, user: user,