diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 08fc2a7b..3f8e22d1 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -178,7 +178,7 @@ class MessageInput extends StatefulWidget { this.keyboardType = TextInputType.multiline, this.disableAttachments = false, this.initialMessage, - this.textEditingController, + this.messageInputController, this.actions = const [], this.actionsLocation = ActionsLocation.left, this.attachmentThumbnailBuilders, @@ -256,7 +256,7 @@ class MessageInput extends StatefulWidget { final bool hideSendAsDm; /// The text controller of the TextField - final TextEditingController? textEditingController; + final MessageInputController? messageInputController; /// List of action widgets final List actions; @@ -339,9 +339,6 @@ class MessageInput extends StatefulWidget { /// State of [MessageInput] class MessageInputState extends State { - final _attachments = {}; - final List _mentionedUsers = []; - final _imagePicker = ImagePicker(); late final _focusNode = widget.focusNode ?? FocusNode(); bool _inputEnabled = true; @@ -356,15 +353,15 @@ class MessageInputState extends State { int _filePickerIndex = 0; /// The editing controller passed to the input TextField - late final TextEditingController textEditingController = - widget.textEditingController ?? TextEditingController(); + late final MessageInputController messageInputController = + widget.messageInputController ?? MessageInputController(); late StreamChatThemeData _streamChatTheme; late MessageInputThemeData _messageInputTheme; bool get _hasQuotedMessage => widget.quotedMessage != null; - bool get _messageIsPresent => textEditingController.text.trim().isNotEmpty; + bool get _messageIsPresent => messageInputController.text.trim().isNotEmpty; @override void initState() { @@ -372,7 +369,8 @@ class MessageInputState extends State { if (widget.editMessage != null || widget.initialMessage != null) { _parseExistingMessage(widget.editMessage ?? widget.initialMessage!); } - textEditingController.addListener(_onChangedDebounced); + messageInputController.textEditingController + .addListener(_onChangedDebounced); _focusNode.addListener(_focusNodeListener); } @@ -414,64 +412,67 @@ class MessageInputState extends State { @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; - }); + Widget child = ValueListenableBuilder( + valueListenable: messageInputController, + builder: (context, value, wid) => 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: [ - Padding( - padding: const EdgeInsets.all(8), - child: StreamSvgIcon.reply( - color: _streamChatTheme.colorTheme.disabled, + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_hasQuotedMessage) + Padding( + 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: widget.onQuotedMessageCleared, - ), - ], + Text( + context.translations.replyToMessageLabel, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + IconButton( + visualDensity: VisualDensity.compact, + icon: StreamSvgIcon.closeSmall(), + onPressed: widget.onQuotedMessageCleared, + ), + ], + ), ), - ), - 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(), + padding: const EdgeInsets.symmetric(vertical: 8), + child: _buildTextField(context), ), - _buildFilePickerSection(), - ], + if (widget.parentMessage != null && !widget.hideSendAsDm) + Padding( + padding: const EdgeInsets.only( + right: 12, + left: 12, + bottom: 12, + ), + child: _buildDmCheckbox(), + ), + _buildFilePickerSection(), + ], + ), ), ), ), @@ -493,12 +494,12 @@ class MessageInputState extends State { ), OverlayOptions( visible: _focusNode.hasFocus && - textEditingController.text.isNotEmpty && - textEditingController.selection.baseOffset > 0 && - textEditingController.text + messageInputController.text.isNotEmpty && + messageInputController.baseOffset > 0 && + messageInputController.text .substring( 0, - textEditingController.selection.baseOffset, + messageInputController.baseOffset, ) .contains(':'), widget: _buildEmojiOverlay(), @@ -591,7 +592,8 @@ class MessageInputState extends State { late Widget sendButton; if (_timeOut > 0) { sendButton = _CountdownButton(count: _timeOut); - } else if (!_messageIsPresent && _attachments.isEmpty) { + } else if (!_messageIsPresent && + messageInputController.attachments.isEmpty) { sendButton = widget.idleSendButton ?? _buildIdleSendButton(context); } else { sendButton = widget.activeSendButton != null @@ -700,7 +702,7 @@ class MessageInputState extends State { maxLines: null, onSubmitted: (_) => sendMessage(), keyboardType: widget.keyboardType, - controller: textEditingController, + controller: messageInputController.textEditingController, focusNode: _focusNode, style: _messageInputTheme.inputTextStyle, autofocus: widget.autofocus, @@ -823,7 +825,7 @@ class MessageInputState extends State { late final _onChangedDebounced = debounce( () { - var value = textEditingController.text; + var value = messageInputController.text; if (!mounted) return; value = value.trim(); @@ -853,7 +855,7 @@ class MessageInputState extends State { if (_commandEnabled && _chosenCommand!.name == 'giphy') { return context.translations.searchGifLabel; } - if (_attachments.isNotEmpty) { + if (messageInputController.attachments.isNotEmpty) { return context.translations.addACommentOrSendLabel; } if (_timeOut != 0) { @@ -865,12 +867,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) + messageInputController.baseOffset > 0 && + messageInputController.text + .substring( + 0, + messageInputController.baseOffset, + ) .contains(':')) { - final textToSelection = textEditingController.text - .substring(0, textEditingController.value.selection.start); + final textToSelection = messageInputController.text.substring( + 0, + messageInputController.textEditingController.value.selection.start, + ); final splits = textToSelection.split(':'); final query = splits[splits.length - 2].toLowerCase(); final emoji = Emoji.byName(query); @@ -883,9 +890,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) + messageInputController.baseOffset > 0 && + messageInputController.text + .substring( + 0, + messageInputController.baseOffset, + ) .split(' ') .last .contains('@')) { @@ -921,7 +931,7 @@ class MessageInputState extends State { } Widget _buildCommandsOverlayEntry() { - final text = textEditingController.text.trimLeft(); + final text = messageInputController.text.trimLeft(); final renderObject = context.findRenderObject() as RenderBox?; if (renderObject == null) { @@ -937,16 +947,16 @@ class MessageInputState extends State { Widget _buildFilePickerSection() { final _attachmentContainsFile = - _attachments.values.any((it) => it.type == 'file'); + messageInputController.attachments.any((it) => it.type == 'file'); final attachmentLimitCrossed = - _attachments.length >= widget.attachmentLimit; + messageInputController.attachments.length >= widget.attachmentLimit; Color _getIconColor(int index) { final streamChatThemeData = _streamChatTheme; switch (index) { case 0: - return _attachments.isEmpty + return messageInputController.attachments.isEmpty ? streamChatThemeData.colorTheme.accentPrimary : (!_attachmentContainsFile ? streamChatThemeData.colorTheme.accentPrimary @@ -955,7 +965,7 @@ class MessageInputState extends State { case 1: return _attachmentContainsFile ? streamChatThemeData.colorTheme.accentPrimary - : (_attachments.isEmpty + : (messageInputController.attachments.isEmpty ? streamChatThemeData.colorTheme.textHighEmphasis .withOpacity(0.5) : streamChatThemeData.colorTheme.textHighEmphasis @@ -963,7 +973,8 @@ class MessageInputState extends State { case 2: return attachmentLimitCrossed ? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2) - : _attachmentContainsFile && _attachments.isNotEmpty + : _attachmentContainsFile && + messageInputController.attachments.isNotEmpty ? streamChatThemeData.colorTheme.textHighEmphasis .withOpacity(0.2) : streamChatThemeData.colorTheme.textHighEmphasis @@ -971,7 +982,8 @@ class MessageInputState extends State { case 3: return attachmentLimitCrossed ? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2) - : _attachmentContainsFile && _attachments.isNotEmpty + : _attachmentContainsFile && + messageInputController.attachments.isNotEmpty ? streamChatThemeData.colorTheme.textHighEmphasis .withOpacity(0.2) : streamChatThemeData.colorTheme.textHighEmphasis @@ -1001,26 +1013,26 @@ class MessageInputState extends State { icon: StreamSvgIcon.pictures( color: _getIconColor(0), ), - onPressed: - _attachmentContainsFile && _attachments.isNotEmpty - ? null - : () { - setState(() { - _filePickerIndex = 0; - }); - }, + onPressed: _attachmentContainsFile && + messageInputController.attachments.isNotEmpty + ? null + : () { + setState(() { + _filePickerIndex = 0; + }); + }, ), IconButton( iconSize: 32, icon: StreamSvgIcon.files( color: _getIconColor(1), ), - onPressed: - !_attachmentContainsFile && _attachments.isNotEmpty - ? null - : () { - pickFile(DefaultAttachmentTypes.file); - }, + onPressed: !_attachmentContainsFile && + messageInputController.attachments.isNotEmpty + ? null + : () { + pickFile(DefaultAttachmentTypes.file); + }, ), IconButton( icon: StreamSvgIcon.camera( @@ -1028,7 +1040,7 @@ class MessageInputState extends State { ), onPressed: attachmentLimitCrossed || (_attachmentContainsFile && - _attachments.isNotEmpty) + messageInputController.attachments.isNotEmpty) ? null : () { pickFile( @@ -1044,7 +1056,7 @@ class MessageInputState extends State { ), onPressed: attachmentLimitCrossed || (_attachmentContainsFile && - _attachments.isNotEmpty) + messageInputController.attachments.isNotEmpty) ? null : () { pickFile( @@ -1088,11 +1100,15 @@ class MessageInputState extends State { filePickerIndex: _filePickerIndex, streamChatTheme: _streamChatTheme, containsFile: _attachmentContainsFile, - selectedMedias: _attachments.keys.toList(), + selectedMedias: messageInputController.attachments + .map((e) => e.id) + .toList(), onAddMoreFilesClick: pickFile, onMediaSelected: (media) { - if (_attachments.containsKey(media.id)) { - setState(() => _attachments.remove(media.id)); + if (messageInputController.attachments + .any((e) => e.id == media.id)) { + setState(() => messageInputController.attachments + .removeWhere((e) => e.id == media.id)); } else { _addAssetAttachment(media); } @@ -1163,12 +1179,16 @@ class MessageInputState extends State { } Widget _buildMentionsOverlayEntry() { - if (textEditingController.value.selection.start < 0) { + if (messageInputController.textEditingController.value.selection.start < + 0) { return const Offstage(); } - final splits = textEditingController.text - .substring(0, textEditingController.value.selection.start) + final splits = messageInputController.text + .substring( + 0, + messageInputController.textEditingController.value.selection.start, + ) .split('@'); final query = splits.last.toLowerCase(); @@ -1196,14 +1216,15 @@ class MessageInputState extends State { size: Size(renderObject.size.width - 16, 400), mentionsTileBuilder: tileBuilder, onMentionUserTap: (user) { - _mentionedUsers.add(user); + messageInputController.mentionedUsers.add(user); splits[splits.length - 1] = user.name; final rejoin = splits.join('@'); - textEditingController.value = TextEditingValue( + messageInputController.textEditingController.value = TextEditingValue( text: rejoin + - textEditingController.text.substring( - textEditingController.selection.start, + messageInputController.text.substring( + messageInputController + .textEditingController.value.selection.start, ), selection: TextSelection.collapsed( offset: rejoin.length, @@ -1216,12 +1237,15 @@ class MessageInputState extends State { } Widget _buildEmojiOverlay() { - if (textEditingController.value.selection.baseOffset < 0) { + if (messageInputController.baseOffset < 0) { return const Offstage(); } - final splits = textEditingController.text - .substring(0, textEditingController.value.selection.baseOffset) + final splits = messageInputController.text + .substring( + 0, + messageInputController.baseOffset, + ) .split(':'); final query = splits.last.toLowerCase(); @@ -1240,10 +1264,11 @@ class MessageInputState extends State { void _chooseEmoji(List splits, Emoji emoji) { final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char!; - textEditingController.value = TextEditingValue( + messageInputController.textEditingController.value = TextEditingValue( text: rejoin + - textEditingController.text - .substring(textEditingController.selection.start), + messageInputController.text.substring( + messageInputController.textEditingController.selection.start, + ), selection: TextSelection.collapsed( offset: rejoin.length, ), @@ -1251,7 +1276,7 @@ class MessageInputState extends State { } void _setCommand(Command c) { - textEditingController.clear(); + messageInputController.clear(); setState(() { _chosenCommand = c; _commandEnabled = true; @@ -1273,11 +1298,11 @@ class MessageInputState extends State { } Widget _buildAttachments() { - if (_attachments.isEmpty) return const Offstage(); - final fileAttachments = _attachments.values + if (messageInputController.attachments.isEmpty) return const Offstage(); + final fileAttachments = messageInputController.attachments .where((it) => it.type == 'file') .toList(growable: false); - final remainingAttachments = _attachments.values + final remainingAttachments = messageInputController.attachments .where((it) => it.type != 'file') .toList(growable: false); return Column( @@ -1364,7 +1389,10 @@ class MessageInputState extends State { focusElevation: 0, hoverElevation: 0, onPressed: () { - setState(() => _attachments.remove(attachment.id)); + setState( + () => messageInputController.attachments + .removeWhere((e) => e.id == attachment.id), + ); }, fillColor: _streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5), @@ -1443,7 +1471,7 @@ class MessageInputState extends State { } Widget _buildCommandButton(BuildContext context) { - final s = textEditingController.text.trim(); + final s = messageInputController.text.trim(); final defaultButton = IconButton( icon: StreamSvgIcon.lightning( color: s.isNotEmpty @@ -1574,10 +1602,11 @@ class MessageInputState extends State { setState(() => _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 = + messageInputController.attachments.length + attachments.length; if (length > limit) { final onAttachmentLimitExceed = widget.onAttachmentLimitExceed; if (onAttachmentLimitExceed != null) { @@ -1591,7 +1620,7 @@ class MessageInputState extends State { ); } for (final attachment in attachments) { - _attachments[attachment.id] = attachment; + messageInputController.addAttachment(attachment); } } @@ -1754,8 +1783,8 @@ class MessageInputState extends State { /// Sends the current message Future sendMessage() async { - var text = textEditingController.text.trim(); - if (text.isEmpty && _attachments.isEmpty) { + var text = messageInputController.text.trim(); + if (text.isEmpty && messageInputController.attachments.isEmpty) { return; } @@ -1765,10 +1794,8 @@ class MessageInputState extends State { text = '${'/${_chosenCommand!.name} '}$text'; } - final attachments = [..._attachments.values]; - - textEditingController.clear(); - _attachments.clear(); + messageInputController.clear(); + messageInputController.attachments.clear(); widget.onQuotedMessageCleared?.call(); setState(() { @@ -1779,17 +1806,19 @@ class MessageInputState extends State { if (widget.editMessage != null) { message = widget.editMessage!.copyWith( text: text, - attachments: attachments, - mentionedUsers: - _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), + attachments: messageInputController.attachments, + mentionedUsers: messageInputController.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(), + attachments: messageInputController.attachments, + mentionedUsers: messageInputController.mentionedUsers + .where((u) => text.contains('@${u.name}')) + .toList(), showInChannel: widget.parentMessage != null ? _sendAsDm : null, ); } @@ -1810,7 +1839,7 @@ class MessageInputState extends State { await streamChannel.reloadChannel(); } - _mentionedUsers.clear(); + messageInputController.mentionedUsers.clear(); try { Future sendingFuture; @@ -1909,13 +1938,15 @@ class MessageInputState extends State { void _parseExistingMessage(Message message) { final messageText = message.text; - if (messageText != null) textEditingController.text = messageText; + if (messageText != null) messageInputController.text = messageText; _addAttachments(message.attachments); } @override void dispose() { - textEditingController.dispose(); + messageInputController.textEditingController + .removeListener(_onChangedDebounced); + messageInputController.dispose(); _focusNode.removeListener(_focusNodeListener); _stopSlowMode(); _onChangedDebounced.cancel(); diff --git a/packages/stream_chat_flutter/lib/src/mip/stream_message_send_button.dart b/packages/stream_chat_flutter/lib/src/mip/stream_message_send_button.dart new file mode 100644 index 00000000..e69de29b diff --git a/packages/stream_chat_flutter/lib/src/mip/stream_message_text_field.dart b/packages/stream_chat_flutter/lib/src/mip/stream_message_text_field.dart new file mode 100644 index 00000000..48ece391 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/mip/stream_message_text_field.dart @@ -0,0 +1,782 @@ +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; + +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. + /// + /// If [maxLengthEnforced] is set to false, then more than [maxLength] + /// characters may be entered, and the error counter and divider will + /// switch to the [decoration].errorStyle when the limit is exceeded. + /// + /// 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], + /// [maxLengthEnforced], [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.0), + 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(textAlign != null), + assert(readOnly != null), + assert(autofocus != null), + assert(obscuringCharacter != null && obscuringCharacter.length == 1), + assert(obscureText != null), + assert(autocorrect != null), + smartDashesType = smartDashesType ?? + (obscureText ? SmartDashesType.disabled : SmartDashesType.enabled), + smartQuotesType = smartQuotesType ?? + (obscureText ? SmartQuotesType.disabled : SmartQuotesType.enabled), + assert(enableSuggestions != null), + assert(enableInteractiveSelection != null), + assert(maxLengthEnforced != null), + assert( + maxLengthEnforced || maxLengthEnforcement == null, + 'maxLengthEnforced is deprecated, use only maxLengthEnforcement', + ), + assert(scrollPadding != null), + assert(dragStartBehavior != null), + assert(selectionHeightStyle != null), + assert(selectionWidthStyle != null), + assert(maxLines == null || maxLines > 0), + assert(minLines == null || minLines > 0), + assert( + (maxLines == null) || (minLines == null) || (maxLines >= minLines), + "minLines can't be greater than maxLines", + ), + assert(expands != null), + 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), + // 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.', + ), + assert(enableIMEPersonalizedLearning != null), + 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. + /// + /// If [maxLengthEnforced] is set to false or [maxLengthEnforcement] is + /// [MaxLengthEnforcement.none], then more than [maxLength] + /// characters may be entered, but the error counter and divider will switch + /// to the [decoration]'s [InputDecoration.errorStyle] when the limit is + /// exceeded. + /// + /// {@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(FlagProperty('maxLengthEnforced', + value: maxLengthEnforced, + defaultValue: true, + ifFalse: 'maxLength not enforced')); + 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.0))); + 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!, 'controller'); + } + + late final _onChangedDebounced = debounce( + (String newText) => _effectiveController.text = newText, + const Duration(milliseconds: 350), + leading: true, + ); + + @override + Widget build(BuildContext context) => TextField( + key: widget.key, + controller: _effectiveController.textEditingController, + onChanged: (newText) => _onChangedDebounced([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, + maxLengthEnforced: widget.maxLengthEnforced, + 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() { + _onChangedDebounced.cancel(); + _controller?.dispose(); + super.dispose(); + } +} 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..365dc017 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/message_input_controller.dart @@ -0,0 +1,170 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:stream_chat/stream_chat.dart'; + +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._(message ?? Message()); + + /// Creates a controller for an editable text field from an initial [text]. + factory MessageInputController.fromText(String? text) => + MessageInputController._(Message(text: text)); + + /// Creates a controller for an editable text field from an initial [attachments]. + factory MessageInputController.fromAttachments( + List attachments, + ) => + MessageInputController._(Message(attachments: attachments)); + + MessageInputController._(Message message) + : _textEditingController = TextEditingController(text: message.text), + super(message); + + /// + TextEditingController get textEditingController => _textEditingController; + final TextEditingController _textEditingController; + + /// + String get text => value.text ?? ''; + + /// + set message(Message message) { + value = message; + } + + set text(String? newText) { + value = value.copyWith(text: newText); + _textEditingController.text = newText ?? ''; + } + + set textEditingValue(TextEditingValue newValue) { + _textEditingController.value = newValue; + value = value.copyWith(text: _textEditingController.text); + } + + /// + List get attachments => value.attachments; + + set attachments(List attachments) { + value = value.copyWith(attachments: attachments); + } + + /// + get baseOffset { + return textEditingController.selection.baseOffset; + } + + /// + void addAttachment(Attachment attachment) { + attachments = [...attachments, attachment]; + } + + /// + void addAttachmentAt(int index, Attachment attachment) { + attachments = [...attachments]..insert(index, attachment); + } + + /// + void removeAttachment(Attachment attachment) { + attachments = [...attachments]..remove(attachment); + } + + /// + void removeAttachmentById(String attachmentId) { + attachments = [...attachments]..removeWhere((it) => it.id == attachmentId); + } + + /// + void removeAttachmentAt(int index) { + attachments = [...attachments]..removeAt(index); + } + + /// + List get mentionedUsers => value.mentionedUsers; + + set mentionedUsers(List users) { + value = value.copyWith(mentionedUsers: users); + } + + /// + void addMentionedUser(User user) { + mentionedUsers = [...mentionedUsers, user]; + } + + /// + void removeMentionedUser(User user) { + mentionedUsers = [...mentionedUsers]..remove(user); + } + + /// + void removeMentionedUserById(String userId) { + mentionedUsers = [...mentionedUsers]..removeWhere((it) => it.id == userId); + } + + /// Set the [value] to empty. + /// + /// After calling this function, [text], [attachments] and [mentionedUsers] + /// all will be empty. + /// + /// Calling this will notify all the listeners of this [MessageInputController] + /// that they need to update (it 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(); + } + + @override + void dispose() { + super.dispose(); + _textEditingController.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 treats a null `text` argument as if it were the empty + /// string. + RestorableMessageInputController({Message? message}) + : _initialValue = message ?? Message(); + + /// Creates a [RestorableMessageInputController] from an initial + /// [TextEditingValue]. + /// + /// This constructor treats a null `value` argument as if it were + /// [TextEditingValue.empty]. + 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/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;