refactor(ui): improve message input controller

This commit is contained in:
Salvatore Giordano
2021-12-01 10:52:45 +01:00
parent 04f9208273
commit 0b0ff753d4
14 changed files with 241 additions and 241 deletions
@@ -58,7 +58,7 @@ class Message extends Equatable {
this.ownReactions, this.ownReactions,
this.parentId, this.parentId,
this.quotedMessage, this.quotedMessage,
this.quotedMessageId, String? quotedMessageId,
this.replyCount = 0, this.replyCount = 0,
this.threadParticipants, this.threadParticipants,
this.showInChannel, this.showInChannel,
@@ -72,16 +72,19 @@ class Message extends Equatable {
this.pinnedBy, this.pinnedBy,
this.extraData = const {}, this.extraData = const {},
this.deletedAt, this.deletedAt,
this.status = MessageSendingStatus.sent, this.status = MessageSendingStatus.sending,
this.i18n, this.i18n,
}) : id = id ?? const Uuid().v4(), }) : id = id ?? const Uuid().v4(),
pinExpires = pinExpires?.toUtc(), pinExpires = pinExpires?.toUtc(),
createdAt = createdAt ?? DateTime.now(), _createdAt = createdAt,
updatedAt = updatedAt ?? DateTime.now(); _updatedAt = updatedAt,
_quotedMessageId = quotedMessageId;
/// Create a new instance from a json /// Create a new instance from a json
factory Message.fromJson(Map<String, dynamic> json) => _$MessageFromJson( factory Message.fromJson(Map<String, dynamic> json) => _$MessageFromJson(
Serializer.moveToExtraDataFromRoot(json, topLevelFields), Serializer.moveToExtraDataFromRoot(json, topLevelFields),
).copyWith(
status: MessageSendingStatus.sent,
); );
/// The message ID. This is either created by Stream or set client side when /// The message ID. This is either created by Stream or set client side when
@@ -134,8 +137,10 @@ class Message extends Equatable {
@JsonKey(toJson: Serializer.readOnly) @JsonKey(toJson: Serializer.readOnly)
final Message? quotedMessage; final Message? quotedMessage;
final String? _quotedMessageId;
/// The ID of the quoted message, if the message is a quoted reply. /// 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. /// Reserved field indicating the number of replies for this message.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly) @JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
@@ -162,13 +167,17 @@ class Message extends Equatable {
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly) @JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final String? command; final String? command;
final DateTime? _createdAt;
/// Reserved field indicating when the message was created. /// Reserved field indicating when the message was created.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly) @JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime createdAt; DateTime get createdAt => _createdAt ?? DateTime.now();
final DateTime? _updatedAt;
/// Reserved field indicating when the message was updated last time. /// Reserved field indicating when the message was updated last time.
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly) @JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
final DateTime updatedAt; DateTime get updatedAt => _updatedAt ?? DateTime.now();
/// User who sent the message /// User who sent the message
@JsonKey(includeIfNull: false, toJson: Serializer.readOnly) @JsonKey(includeIfNull: false, toJson: Serializer.readOnly)
@@ -301,17 +310,17 @@ class Message extends Equatable {
ownReactions: ownReactions ?? this.ownReactions, ownReactions: ownReactions ?? this.ownReactions,
parentId: parentId ?? this.parentId, parentId: parentId ?? this.parentId,
quotedMessage: quotedMessage ?? this.quotedMessage, quotedMessage: quotedMessage ?? this.quotedMessage,
quotedMessageId: quotedMessageId ?? this.quotedMessageId, quotedMessageId: quotedMessageId ?? _quotedMessageId,
replyCount: replyCount ?? this.replyCount, replyCount: replyCount ?? this.replyCount,
threadParticipants: threadParticipants ?? this.threadParticipants, threadParticipants: threadParticipants ?? this.threadParticipants,
showInChannel: showInChannel ?? this.showInChannel, showInChannel: showInChannel ?? this.showInChannel,
command: command ?? this.command, command: command ?? this.command,
createdAt: createdAt ?? this.createdAt, createdAt: createdAt ?? _createdAt,
silent: silent ?? this.silent, silent: silent ?? this.silent,
extraData: extraData ?? this.extraData, extraData: extraData ?? this.extraData,
user: user ?? this.user, user: user ?? this.user,
shadowed: shadowed ?? this.shadowed, shadowed: shadowed ?? this.shadowed,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? _updatedAt,
deletedAt: deletedAt ?? this.deletedAt, deletedAt: deletedAt ?? this.deletedAt,
status: status ?? this.status, status: status ?? this.status,
pinned: pinned ?? this.pinned, pinned: pinned ?? this.pinned,
@@ -126,7 +126,9 @@ class ThreadPage extends StatelessWidget {
), ),
), ),
MessageInput( MessageInput(
parentMessage: parent, messageInputController: MessageInputController(
message: Message(parentId: parent!.id),
),
), ),
], ],
), ),
@@ -166,7 +166,9 @@ class ThreadPage extends StatelessWidget {
), ),
), ),
MessageInput( MessageInput(
parentMessage: parent, messageInputController: MessageInputController(
message: Message(parentId: parent!.id),
),
), ),
], ],
), ),
@@ -27,9 +27,12 @@ dependencies:
cupertino_icons: ^1.0.3 cupertino_icons: ^1.0.3
flutter: flutter:
sdk: flutter sdk: flutter
stream_chat_flutter: ^2.2.1 stream_chat_flutter:
stream_chat_localizations: ^1.1.0 path: ../
stream_chat_persistence: ^2.2.0 stream_chat_localizations:
path: ../../stream_chat_localizations
stream_chat_persistence:
path: ../../stream_chat_persistence
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -1,6 +1,6 @@
import 'package:jiffy/jiffy.dart'; import 'package:jiffy/jiffy.dart';
import 'package:stream_chat_flutter/src/connection_status_builder.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_list_view.dart';
import 'package:stream_chat_flutter/src/message_search_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' import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'
@@ -610,7 +610,9 @@ class _MessageActionsModalState extends State<MessageActionsModal> {
widget.editMessageInputBuilder!(context, widget.message) widget.editMessageInputBuilder!(context, widget.message)
else else
MessageInput( MessageInput(
editMessage: widget.message, messageInputController: MessageInputController(
message: widget.message,
),
preMessageSending: (m) { preMessageSending: (m) {
FocusScope.of(context).unfocus(); FocusScope.of(context).unfocus();
Navigator.pop(context); Navigator.pop(context);
@@ -184,12 +184,9 @@ class MessageInput extends StatefulWidget {
Key? key, Key? key,
this.onMessageSent, this.onMessageSent,
this.preMessageSending, this.preMessageSending,
this.parentMessage,
this.editMessage,
this.maxHeight = 150, this.maxHeight = 150,
this.keyboardType = TextInputType.multiline, this.keyboardType = TextInputType.multiline,
this.disableAttachments = false, this.disableAttachments = false,
this.initialMessage,
this.messageInputController, this.messageInputController,
this.actions = const [], this.actions = const [],
this.actionsLocation = ActionsLocation.left, this.actionsLocation = ActionsLocation.left,
@@ -219,18 +216,11 @@ class MessageInput extends StatefulWidget {
this.attachmentsPickerBuilder, this.attachmentsPickerBuilder,
this.sendButtonBuilder, this.sendButtonBuilder,
this.shouldKeepFocusAfterMessage, this.shouldKeepFocusAfterMessage,
}) : assert( }) : super(key: key);
initialMessage == null || editMessage == null,
"Can't provide both `initialMessage` and `editMessage`",
),
super(key: key);
/// List of options for showing overlays /// List of options for showing overlays
final List<OverlayOptions> customOverlays; final List<OverlayOptions> 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; final VideoQuality compressedVideoQuality;
@@ -242,9 +232,6 @@ class MessageInput extends StatefulWidget {
/// do not set it if you're using our default CDN /// do not set it if you're using our default CDN
final int maxAttachmentSize; 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; final void Function(Message)? onMessageSent;
@@ -252,9 +239,6 @@ class MessageInput extends StatefulWidget {
/// Use this to transform the message /// Use this to transform the message
final FutureOr<Message> Function(Message)? preMessageSending; final FutureOr<Message> 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; final double maxHeight;
@@ -367,11 +351,10 @@ class MessageInputState extends State<MessageInput> {
final _imagePicker = ImagePicker(); final _imagePicker = ImagePicker();
late final _focusNode = widget.focusNode ?? FocusNode(); late final _focusNode = widget.focusNode ?? FocusNode();
bool _inputEnabled = true; bool _inputEnabled = true;
bool _commandEnabled = false; bool get _commandEnabled => messageInputController.value.command != null;
bool _showCommandsOverlay = false; bool _showCommandsOverlay = false;
bool _showMentionsOverlay = false; bool _showMentionsOverlay = false;
Command? _chosenCommand;
bool _actionsShrunk = false; bool _actionsShrunk = false;
bool _openFilePickerSection = false; bool _openFilePickerSection = false;
@@ -382,16 +365,17 @@ class MessageInputState extends State<MessageInput> {
late StreamChatThemeData _streamChatTheme; late StreamChatThemeData _streamChatTheme;
late MessageInputThemeData _messageInputTheme; late MessageInputThemeData _messageInputTheme;
bool get _hasQuotedMessage => widget.quotedMessage != null; bool get _hasQuotedMessage =>
messageInputController.value.quotedMessage != null;
bool get _messageIsPresent => messageInputController.text.trim().isNotEmpty; bool get _messageIsPresent => messageInputController.text.trim().isNotEmpty;
bool get _isEditing =>
messageInputController.value.status != MessageSendingStatus.sending;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.editMessage != null || widget.initialMessage != null) {
_parseExistingMessage(widget.editMessage ?? widget.initialMessage!);
}
messageInputController.textEditingController messageInputController.textEditingController
.addListener(_onChangedDebounced); .addListener(_onChangedDebounced);
_focusNode.addListener(_focusNodeListener); _focusNode.addListener(_focusNodeListener);
@@ -437,70 +421,74 @@ class MessageInputState extends State<MessageInput> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
Widget child = ValueListenableBuilder<Message>( Widget child = ValueListenableBuilder<Message>(
valueListenable: messageInputController, valueListenable: messageInputController,
builder: (context, value, wid) => DecoratedBox( builder: (context, value, wid) {
decoration: BoxDecoration( print('VALUE ${value.text}');
color: _messageInputTheme.inputBackgroundColor, return DecoratedBox(
), decoration: BoxDecoration(
child: SafeArea( color: _messageInputTheme.inputBackgroundColor,
child: GestureDetector( ),
onPanUpdate: (details) { child: SafeArea(
if (details.delta.dy > 0) { child: GestureDetector(
_focusNode.unfocus(); onPanUpdate: (details) {
if (_openFilePickerSection) { if (details.delta.dy > 0) {
setState(() { _focusNode.unfocus();
_openFilePickerSection = false; if (_openFilePickerSection) {
}); setState(() {
_openFilePickerSection = false;
});
}
} }
} },
}, child: Column(
child: Column( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ if (_hasQuotedMessage)
if (_hasQuotedMessage) Padding(
Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 0),
padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), child: Row(
child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween,
mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
children: [ Padding(
Padding( padding: const EdgeInsets.all(8),
padding: const EdgeInsets.all(8), child: StreamSvgIcon.reply(
child: StreamSvgIcon.reply( color: _streamChatTheme.colorTheme.disabled,
color: _streamChatTheme.colorTheme.disabled, ),
), ),
), Text(
Text( context.translations.replyToMessageLabel,
context.translations.replyToMessageLabel, style: const TextStyle(fontWeight: FontWeight.bold),
style: const TextStyle(fontWeight: FontWeight.bold), ),
), IconButton(
IconButton( visualDensity: VisualDensity.compact,
visualDensity: VisualDensity.compact, icon: StreamSvgIcon.closeSmall(),
icon: StreamSvgIcon.closeSmall(), onPressed: widget.onQuotedMessageCleared,
onPressed: widget.onQuotedMessageCleared, ),
), ],
], ),
), ),
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: _buildTextField(context),
),
if (widget.parentMessage != null && !widget.hideSendAsDm)
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.symmetric(vertical: 8),
right: 12, child: _buildTextField(context),
left: 12,
bottom: 12,
),
child: _buildDmCheckbox(),
), ),
_buildFilePickerSection(), if (messageInputController.value.parentId != null &&
], !widget.hideSendAsDm)
Padding(
padding: const EdgeInsets.only(
right: 12,
left: 12,
bottom: 12,
),
child: _buildDmCheckbox(),
),
_buildFilePickerSection(),
],
),
), ),
), ),
), );
), },
); );
if (widget.editMessage == null) { if (_isEditing) {
child = Material( child = Material(
elevation: 8, elevation: 8,
child: child, child: child,
@@ -575,10 +563,8 @@ class MessageInputState extends State<MessageInput> {
: _streamChatTheme.colorTheme.barsBg, : _streamChatTheme.colorTheme.barsBg,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
setState(() { messageInputController.showInChannel =
messageInputController.showInChannel = !messageInputController.showInChannel;
!messageInputController.showInChannel;
});
}, },
child: AnimatedCrossFade( child: AnimatedCrossFade(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@@ -621,7 +607,7 @@ class MessageInputState extends State<MessageInput> {
onSendMessage: sendMessage, onSendMessage: sendMessage,
timeOut: _timeOut, timeOut: _timeOut,
isIdle: !_messageIsPresent && messageInputController.attachments.isEmpty, isIdle: !_messageIsPresent && messageInputController.attachments.isEmpty,
isEditEnabled: widget.editMessage != null, isEditEnabled: _isEditing,
idleSendButton: widget.idleSendButton, idleSendButton: widget.idleSendButton,
activeSendButton: widget.activeSendButton, activeSendButton: widget.activeSendButton,
); );
@@ -668,7 +654,7 @@ class MessageInputState extends State<MessageInput> {
if (!widget.disableAttachments) if (!widget.disableAttachments)
_buildAttachmentButton(context), _buildAttachmentButton(context),
if (widget.showCommandsButton && if (widget.showCommandsButton &&
widget.editMessage == null && !_isEditing &&
channel.state != null && channel.state != null &&
channel.config?.commands.isNotEmpty == true) channel.config?.commands.isNotEmpty == true)
_buildCommandButton(context), _buildCommandButton(context),
@@ -791,7 +777,7 @@ class MessageInputState extends State<MessageInput> {
size: 16, size: 16,
), ),
Text( Text(
_chosenCommand?.name.toUpperCase() ?? '', messageInputController.value.command!.toUpperCase(),
style: style:
_streamChatTheme.textTheme.footnoteBold.copyWith( _streamChatTheme.textTheme.footnoteBold.copyWith(
color: Colors.white, color: Colors.white,
@@ -825,9 +811,7 @@ class MessageInputState extends State<MessageInput> {
height: 24, height: 24,
width: 24, width: 24,
), ),
onPressed: () { onPressed: messageInputController.clear,
setState(() => _commandEnabled = false);
},
), ),
), ),
if (!_commandEnabled && if (!_commandEnabled &&
@@ -848,8 +832,10 @@ class MessageInputState extends State<MessageInput> {
final channel = StreamChannel.of(context).channel; final channel = StreamChannel.of(context).channel;
if (value.isNotEmpty) { if (value.isNotEmpty) {
// ignore: no-empty-block channel
channel.keyStroke(widget.parentMessage?.id).catchError((e) {}); .keyStroke(messageInputController.value.parentId)
// ignore: no-empty-block
.catchError((e) {});
} }
var actionsLength = widget.actions.length; var actionsLength = widget.actions.length;
@@ -869,7 +855,7 @@ class MessageInputState extends State<MessageInput> {
); );
String _getHint(BuildContext context) { String _getHint(BuildContext context) {
if (_commandEnabled && _chosenCommand!.name == 'giphy') { if (_commandEnabled && messageInputController.value.command == 'giphy') {
return context.translations.searchGifLabel; return context.translations.searchGifLabel;
} }
if (messageInputController.attachments.isNotEmpty) { if (messageInputController.attachments.isNotEmpty) {
@@ -1034,15 +1020,11 @@ class MessageInputState extends State<MessageInput> {
splits[splits.length - 1] = user.name; splits[splits.length - 1] = user.name;
final rejoin = splits.join('@'); final rejoin = splits.join('@');
messageInputController.textEditingController.value = TextEditingValue( messageInputController.text = rejoin +
text: rejoin + messageInputController.text.substring(
messageInputController.text.substring( messageInputController.selectionStart,
messageInputController.selectionStart, );
),
selection: TextSelection.collapsed(
offset: rejoin.length,
),
);
_onChangedDebounced.cancel(); _onChangedDebounced.cancel();
setState(() => _showMentionsOverlay = false); setState(() => _showMentionsOverlay = false);
}, },
@@ -1077,22 +1059,17 @@ class MessageInputState extends State<MessageInput> {
void _chooseEmoji(List<String> splits, Emoji emoji) { void _chooseEmoji(List<String> splits, Emoji emoji) {
final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char!; final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char!;
messageInputController.textEditingController.value = TextEditingValue( messageInputController.text = rejoin +
text: rejoin + messageInputController.text.substring(
messageInputController.text.substring( messageInputController.selectionStart,
messageInputController.selectionStart, );
),
selection: TextSelection.collapsed(
offset: rejoin.length,
),
);
} }
void _setCommand(Command c) { void _setCommand(Command c) {
messageInputController.clear(); messageInputController
..clear()
..command = c;
setState(() { setState(() {
_chosenCommand = c;
_commandEnabled = true;
_showCommandsOverlay = false; _showCommandsOverlay = false;
}); });
} }
@@ -1133,9 +1110,7 @@ class MessageInputState extends State<MessageInput> {
(e) => ClipRRect( (e) => ClipRRect(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
child: FileAttachment( child: FileAttachment(
message: Message( message: Message(), // dummy message
status: MessageSendingStatus.sending,
), // dummy message
attachment: e, attachment: e,
size: Size( size: Size(
MediaQuery.of(context).size.width * 0.65, MediaQuery.of(context).size.width * 0.65,
@@ -1202,9 +1177,11 @@ class MessageInputState extends State<MessageInput> {
focusElevation: 0, focusElevation: 0,
hoverElevation: 0, hoverElevation: 0,
onPressed: () { onPressed: () {
setState( messageInputController.value =
() => messageInputController.attachments messageInputController.value.copyWith(
.removeWhere((e) => e.id == attachment.id), attachments: messageInputController.attachments
.where((it) => it.id != attachment.id)
.toList(),
); );
}, },
fillColor: fillColor:
@@ -1412,7 +1389,7 @@ class MessageInputState extends State<MessageInput> {
/// ///
/// Note: Only meant to be used from outside the state. /// Note: Only meant to be used from outside the state.
void addAttachment(Attachment attachment) { void addAttachment(Attachment attachment) {
setState(() => _addAttachments([attachment])); _addAttachments([attachment]);
} }
/// Adds an attachment to the [messageInputController.attachments] map /// Adds an attachment to the [messageInputController.attachments] map
@@ -1539,23 +1516,20 @@ class MessageInputState extends State<MessageInput> {
} }
} }
setState(() { _addAttachments([
_addAttachments([ attachment.copyWith(
attachment.copyWith( file: file,
file: file, extraData: {...attachment.extraData}
extraData: {...attachment.extraData} ..update('file_size', ((_) => file!.size!)),
..update('file_size', ((_) => file!.size!)), ),
), ]);
]);
});
} }
/// Sends the current message /// Sends the current message
Future<void> sendMessage() async { Future<void> sendMessage() async {
var text = messageInputController.text.trim(); var message = messageInputController.value;
final attachments = messageInputController.attachments;
if (text.isEmpty && attachments.isEmpty) { if (messageInputController.isValid) {
return; return;
} }
@@ -1563,48 +1537,9 @@ class MessageInputState extends State<MessageInput> {
shouldKeepFocus ??= !_commandEnabled; shouldKeepFocus ??= !_commandEnabled;
if (_commandEnabled) { messageInputController.reset();
text = '${'/${_chosenCommand!.name} '}$text';
}
messageInputController
..text = ''
..clearAttachments();
widget.onQuotedMessageCleared?.call(); widget.onQuotedMessageCleared?.call();
setState(() {
_commandEnabled = false;
});
Message message;
if (widget.editMessage != null) {
message = widget.editMessage!.copyWith(
text: text,
attachments: 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: messageInputController.mentionedUsers
.where((u) => text.contains('@${u.name}'))
.toList(),
showInChannel: widget.parentMessage != null
? messageInputController.showInChannel
: null,
);
}
if (widget.quotedMessage != null) {
message = message.copyWith(
quotedMessageId: widget.quotedMessage!.id,
);
}
if (widget.preMessageSending != null) { if (widget.preMessageSending != null) {
message = await widget.preMessageSending!(message); message = await widget.preMessageSending!(message);
} }
@@ -1615,13 +1550,9 @@ class MessageInputState extends State<MessageInput> {
await streamChannel.reloadChannel(); await streamChannel.reloadChannel();
} }
messageInputController.clearMentionedUsers();
try { try {
Future sendingFuture; Future sendingFuture;
if (widget.editMessage == null || if (!_isEditing) {
widget.editMessage!.status == MessageSendingStatus.failed ||
widget.editMessage!.status == MessageSendingStatus.sending) {
sendingFuture = channel.sendMessage(message); sendingFuture = channel.sendMessage(message);
} else { } else {
sendingFuture = channel.updateMessage(message); sendingFuture = channel.updateMessage(message);
@@ -1635,7 +1566,7 @@ class MessageInputState extends State<MessageInput> {
final resp = await sendingFuture; final resp = await sendingFuture;
if (resp.message?.type == 'error') { if (resp.message?.type == 'error') {
_parseExistingMessage(message); messageInputController.value = message;
} }
_startSlowMode(); _startSlowMode();
widget.onMessageSent?.call(resp.message); widget.onMessageSent?.call(resp.message);
@@ -1714,12 +1645,6 @@ class MessageInputState extends State<MessageInput> {
); );
} }
void _parseExistingMessage(Message message) {
final messageText = message.text;
if (messageText != null) messageInputController.text = messageText;
_addAttachments(message.attachments);
}
@override @override
void dispose() { void dispose() {
messageInputController.textEditingController messageInputController.textEditingController
@@ -1731,19 +1656,12 @@ class MessageInputState extends State<MessageInput> {
super.dispose(); super.dispose();
} }
bool _initialized = false;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
_streamChatTheme = StreamChatTheme.of(context); _streamChatTheme = StreamChatTheme.of(context);
_messageInputTheme = MessageInputTheme.of(context); _messageInputTheme = MessageInputTheme.of(context);
if (widget.editMessage == null && _timeOut <= 0) _startSlowMode(); if (!_isEditing && _timeOut <= 0) _startSlowMode();
if ((widget.editMessage != null || widget.initialMessage != null) &&
!_initialized) {
FocusScope.of(context).requestFocus(_focusNode);
_initialized = true;
}
super.didChangeDependencies(); super.didChangeDependencies();
} }
} }
@@ -116,8 +116,8 @@ class _StreamAttachmentPickerState extends State<StreamAttachmentPicker> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var _streamChatTheme = StreamChatTheme.of(context); final _streamChatTheme = StreamChatTheme.of(context);
var messageInputController = widget.messageInputController; final messageInputController = widget.messageInputController;
final _attachmentContainsImage = final _attachmentContainsImage =
messageInputController.attachments.any((it) => it.type == 'image'); messageInputController.attachments.any((it) => it.type == 'image');
@@ -23,7 +23,7 @@ class StreamMessageSendButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var _streamChatTheme = StreamChatTheme.of(context); final _streamChatTheme = StreamChatTheme.of(context);
late Widget sendButton; late Widget sendButton;
if (timeOut > 0) { if (timeOut > 0) {
@@ -46,7 +46,7 @@ class StreamMessageSendButton extends StatelessWidget {
} }
Widget _buildIdleSendButton(BuildContext context) { Widget _buildIdleSendButton(BuildContext context) {
var _messageInputTheme = MessageInputTheme.of(context); final _messageInputTheme = MessageInputTheme.of(context);
return Padding( return Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -58,7 +58,7 @@ class StreamMessageSendButton extends StatelessWidget {
} }
Widget _buildSendButton(BuildContext context) { Widget _buildSendButton(BuildContext context) {
var _messageInputTheme = MessageInputTheme.of(context); final _messageInputTheme = MessageInputTheme.of(context);
return Padding( return Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
@@ -1,3 +1,5 @@
// ignore_for_file: prefer-trailing-comma, cascade_invocations
import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle; import 'dart:ui' as ui show BoxHeightStyle, BoxWidthStyle;
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' hide TextTheme; import 'package:flutter/material.dart' hide TextTheme;
import 'package:stream_chat_flutter/src/channel_preview.dart'; import 'package:stream_chat_flutter/src/channel_preview.dart';
import 'package:stream_chat_flutter/src/gradient_avatar.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/reaction_icon.dart';
import 'package:stream_chat_flutter/src/theme/themes.dart'; import 'package:stream_chat_flutter/src/theme/themes.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
@@ -23,16 +23,16 @@ export 'src/localization/stream_chat_localizations.dart';
export 'src/localization/translations.dart' show DefaultTranslations; export 'src/localization/translations.dart' show DefaultTranslations;
export 'src/mention_tile.dart'; export 'src/mention_tile.dart';
export 'src/message_action.dart'; export 'src/message_action.dart';
export 'src/message_input.dart'; export 'src/message_input/message_input.dart';
export 'src/message_list_view.dart'; export 'src/message_list_view.dart';
export 'src/message_search_item.dart'; export 'src/message_search_item.dart';
export 'src/message_search_list_view.dart'; export 'src/message_search_list_view.dart';
export 'src/message_text.dart'; export 'src/message_text.dart';
export 'src/message_widget.dart'; export 'src/message_widget.dart';
export 'src/mip/countdown_button.dart'; export 'src/message_input/countdown_button.dart';
export 'src/mip/stream_attachment_picker.dart'; export 'src/message_input/stream_attachment_picker.dart';
export 'src/mip/stream_message_send_button.dart'; export 'src/message_input/stream_message_send_button.dart';
export 'src/mip/stream_message_text_field.dart'; export 'src/message_input/stream_message_text_field.dart';
export 'src/option_list_tile.dart'; export 'src/option_list_tile.dart';
export 'src/reaction_icon.dart'; export 'src/reaction_icon.dart';
export 'src/reaction_picker.dart'; export 'src/reaction_picker.dart';
@@ -4,29 +4,82 @@ import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat/stream_chat.dart';
typedef MessageValidator = bool Function(Message message);
/// Controller for storing and mutating a [Message] value. /// Controller for storing and mutating a [Message] value.
class MessageInputController extends ValueNotifier<Message> { class MessageInputController extends ValueNotifier<Message> {
/// Creates a controller for an editable text field. /// Creates a controller for an editable text field.
/// ///
/// This constructor treats a null [message] argument as if it were the empty /// This constructor treats a null [message] argument as if it were the empty
/// message. /// message.
factory MessageInputController({Message? message}) => factory MessageInputController({
MessageInputController._(message ?? Message()); Message? message,
MessageValidator? validator,
}) =>
MessageInputController._(
initialMessage: message ?? Message(),
validator: validator ?? _defaultValidator,
);
/// Creates a controller for an editable text field from an initial [text]. /// Creates a controller for an editable text field from an initial [text].
factory MessageInputController.fromText(String? text) => factory MessageInputController.fromText(
MessageInputController._(Message(text: text)); String? text, {
MessageValidator? validator,
}) =>
MessageInputController._(
initialMessage: Message(text: text),
validator: validator ?? _defaultValidator,
);
/// Creates a controller for an editable text field from an initial /// Creates a controller for an editable text field from an initial
/// [attachments]. /// [attachments].
factory MessageInputController.fromAttachments( factory MessageInputController.fromAttachments(
List<Attachment> attachments, List<Attachment> attachments, {
) => MessageValidator? validator,
MessageInputController._(Message(attachments: attachments)); }) =>
MessageInputController._(
initialMessage: Message(attachments: attachments),
validator: validator ?? _defaultValidator,
);
MessageInputController._(Message message) MessageInputController._({
: _textEditingController = TextEditingController(text: message.text), required Message initialMessage,
super(message); this.validator = _defaultValidator,
}) : _textEditingController =
TextEditingController(text: initialMessage.text),
_initialMessage = initialMessage,
super(initialMessage) {
addListener(_textEditingSyncer);
}
/// A callback function that validates the message.
final MessageValidator validator;
/// Checks if the message is valid.
bool get isValid => validator(value);
static bool _defaultValidator(Message message) =>
message.text?.isNotEmpty != true && message.attachments.isEmpty;
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,
);
}
}
/// ///
TextEditingController get textEditingController => _textEditingController; TextEditingController get textEditingController => _textEditingController;
@@ -35,24 +88,29 @@ class MessageInputController extends ValueNotifier<Message> {
/// ///
String get text => _textEditingController.text; String get text => _textEditingController.text;
final Message _initialMessage;
/// ///
set message(Message message) { set message(Message message) {
value = message; value = message;
} }
set text(String newText) { ///
value = value.copyWith(text: newText); set command(Command command) {
_textEditingController value = value.copyWith(
..text = newText command: command.name,
..selection = TextSelection.fromPosition( text: '/${command.name} ',
TextPosition(offset: _textEditingController.text.length), );
);
} }
/// set text(String newText) {
set textEditingValue(TextEditingValue newValue) { var newTextWithCommand = newText;
_textEditingController.value = newValue; if (value.command != null) {
value = value.copyWith(text: _textEditingController.text); if (!newText.startsWith('/${value.command}')) {
newTextWithCommand = '/${value.command} $newText';
}
}
value = value.copyWith(text: newTextWithCommand);
} }
/// ///
@@ -147,9 +205,13 @@ class MessageInputController extends ValueNotifier<Message> {
_textEditingController.clear(); _textEditingController.clear();
} }
/// Set the [value] to the initial [Message] value.
void reset() => value = _initialMessage;
@override @override
void dispose() { void dispose() {
super.dispose(); super.dispose();
removeListener(_textEditingSyncer);
_textEditingController.dispose(); _textEditingController.dispose();
} }
} }