Merge branch 'v4' of https://github.com/GetStream/stream-chat-flutter into feat/capabilities

 Conflicts:
	packages/stream_chat_flutter/lib/src/message_input/message_input.dart
This commit is contained in:
Deven Joshi
2022-01-13 17:25:53 +05:30
16 changed files with 2114 additions and 189 deletions
@@ -14,6 +14,10 @@
- Videos can now be auto-played in `FullScreenMedia`
🔄 Changed
- Add `didUpdateWidget` override in `MessageInput` widget to handle changes to `focusNode`.
## 3.3.2
- Updated `stream_chat_flutter_core` dependency to [`3.3.1`](https://pub.dev/packages/stream_chat_flutter_core/changelog).
@@ -13,6 +13,7 @@ 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/message_input/tld.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/user_mentions_overlay.dart';
@@ -335,25 +336,15 @@ class MessageInput extends StatefulWidget {
@override
MessageInputState createState() => MessageInputState();
/// Use this method to get the current [StreamChatState] instance
static MessageInputState of(BuildContext context) {
MessageInputState? messageInputState;
messageInputState = context.findAncestorStateOfType<MessageInputState>();
assert(
messageInputState != null,
'You must have a MessageInput widget as ancestor of your widget tree',
);
return messageInputState!;
}
}
/// State of [MessageInput]
class MessageInputState extends State<MessageInput>
with RestorationMixin<MessageInput> {
final _imagePicker = ImagePicker();
late final _focusNode = widget.focusNode ?? FocusNode();
late FocusNode _focusNode = widget.focusNode ?? FocusNode();
bool _inputEnabled = true;
bool get _commandEnabled => _effectiveController.value.command != null;
bool _showCommandsOverlay = false;
bool _showMentionsOverlay = false;
@@ -371,6 +362,7 @@ class MessageInputState extends State<MessageInput>
_effectiveController.value.status != MessageSendingStatus.sending;
RestorableMessageInputController? _controller;
MessageInputController get _effectiveController =>
widget.messageInputController ?? _controller!.value;
@@ -398,11 +390,7 @@ class MessageInputState extends State<MessageInput>
if (widget.messageInputController == null) {
_createLocalController();
} else {
_effectiveController.textEditingController
.removeListener(_onChangedDebounced);
_effectiveController.textEditingController
.addListener(_onChangedDebounced);
if (!_isEditing && _timeOut <= 0) _startSlowMode();
_initialiseEffectiveController();
}
_focusNode.addListener(_focusNodeListener);
}
@@ -418,6 +406,14 @@ class MessageInputState extends State<MessageInput>
unregisterFromRestoration(_controller!);
_controller!.dispose();
_controller = null;
_initialiseEffectiveController();
}
// Update _focusNode
if (widget.focusNode != null && oldWidget.focusNode != widget.focusNode) {
_focusNode.removeListener(_focusNodeListener);
_focusNode = widget.focusNode!;
_focusNode.addListener(_focusNodeListener);
}
}
@@ -440,6 +436,13 @@ class MessageInputState extends State<MessageInput>
int _timeOut = 0;
Timer? _slowModeTimer;
void _initialiseEffectiveController() {
_effectiveController.textEditingController
.removeListener(_onChangedDebounced);
_effectiveController.textEditingController.addListener(_onChangedDebounced);
if (!_isEditing && _timeOut <= 0) _startSlowMode();
}
void _startSlowMode() {
if (!mounted) {
return;
@@ -487,110 +490,119 @@ class MessageInputState extends State<MessageInput>
);
}
return 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;
});
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.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: () {
_effectiveController.clearQuotedMessage();
_focusNode.unfocus();
},
),
],
Text(
context.translations.replyToMessageLabel,
style:
const TextStyle(fontWeight: FontWeight.bold),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: StreamSvgIcon.closeSmall(),
onPressed: () {
_effectiveController.clearQuotedMessage();
_focusNode.unfocus();
},
),
],
),
)
else if (_effectiveController.ogAttachment != null)
OGAttachmentPreview(
attachment: _effectiveController.ogAttachment!,
onDismissPreviewPressed: () {
_effectiveController.clearOGAttachment();
_focusNode.unfocus();
},
),
),
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(),
padding: const EdgeInsets.symmetric(vertical: 8),
child: _buildTextField(context),
),
_buildFilePickerSection(),
],
if (_effectiveController.value.parentId != null &&
!widget.hideSendAsDm)
Padding(
padding: const EdgeInsets.only(
right: 12,
left: 12,
bottom: 12,
),
child: _buildDmCheckbox(),
),
_buildFilePickerSection(),
],
),
),
),
),
);
if (!_isEditing) {
child = Material(
elevation: 8,
);
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,
],
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,
],
child: child,
);
},
);
},
);
}
Flex _buildTextField(BuildContext context) => Flex(
@@ -917,6 +929,7 @@ class MessageInputState extends State<MessageInput>
_actionsShrunk = value.isNotEmpty && actionsLength > 1;
});
_checkContainsUrl(value, context);
_checkCommands(value, context);
_checkMentions(value, context);
_checkEmoji(value, context);
@@ -939,14 +952,75 @@ class MessageInputState extends State<MessageInput>
return context.translations.writeAMessageLabel;
}
void _checkEmoji(String s, BuildContext context) {
if (s.isNotEmpty &&
String? _lastSearchedContainsUrlText;
CancelableOperation? _enrichUrlOperation;
final _urlRegex = RegExp(
r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+',
);
void _checkContainsUrl(String value, BuildContext context) async {
// Cancel the previous operation if it's still running
_enrichUrlOperation?.cancel();
// If the text is same as the last time, don't do anything
if (_lastSearchedContainsUrlText == value) return;
_lastSearchedContainsUrlText = value;
final matchedUrls = _urlRegex.allMatches(value).toList()
..removeWhere((it) => it.group(0)?.split('.').last.isValidTLD() == false);
// Reset the og attachment if the text doesn't contain any url
if (matchedUrls.isEmpty) {
_effectiveController
..text = value
..clearOGAttachment();
return;
}
final firstMatchedUrl = matchedUrls.first.group(0)!;
// If the parsed url matches the ogAttachment url, don't do anything
if (_effectiveController.ogAttachment?.titleLink == firstMatchedUrl) {
return;
}
final client = StreamChat.of(context).client;
_enrichUrlOperation = CancelableOperation.fromFuture(
_enrichUrl(firstMatchedUrl, client),
).then(
(ogAttachment) {
final attachment = Attachment.fromOGAttachment(ogAttachment);
_effectiveController.setOGAttachment(attachment);
},
onError: (error, stackTrace) {
// Reset the ogAttachment if there was an error
_effectiveController.clearOGAttachment();
widget.onError?.call(error, stackTrace);
},
);
}
final _ogAttachmentCache = <String, OGAttachmentResponse>{};
Future<OGAttachmentResponse> _enrichUrl(
String url,
StreamChatClient client,
) async {
var response = _ogAttachmentCache[url];
if (response == null) {
final client = StreamChat.of(context).client;
response = await client.enrichUrl(url);
_ogAttachmentCache[url] = response;
}
return response;
}
void _checkEmoji(String value, BuildContext context) {
if (value.isNotEmpty &&
_effectiveController.baseOffset > 0 &&
_effectiveController.text
.substring(
0,
_effectiveController.baseOffset,
)
.substring(0, _effectiveController.baseOffset)
.contains(':')) {
final textToSelection = _effectiveController.text.substring(
0,
@@ -962,14 +1036,11 @@ class MessageInputState extends State<MessageInput>
}
}
void _checkMentions(String s, BuildContext context) {
if (s.isNotEmpty &&
void _checkMentions(String value, BuildContext context) {
if (value.isNotEmpty &&
_effectiveController.baseOffset > 0 &&
_effectiveController.text
.substring(
0,
_effectiveController.baseOffset,
)
.substring(0, _effectiveController.baseOffset)
.split(' ')
.last
.contains('@')) {
@@ -985,11 +1056,11 @@ class MessageInputState extends State<MessageInput>
}
}
void _checkCommands(String s, BuildContext context) {
if (s.startsWith('/')) {
void _checkCommands(String value, BuildContext context) {
if (value.startsWith('/')) {
final allCommands = StreamChannel.of(context).channel.config?.commands;
final command =
allCommands?.firstWhereOrNull((it) => it.name == s.substring(1));
allCommands?.firstWhereOrNull((it) => it.name == value.substring(1));
if (command != null) {
return _setCommand(command);
} else if (!_showCommandsOverlay) {
@@ -1051,10 +1122,7 @@ class MessageInputState extends State<MessageInput>
}
final splits = _effectiveController.text
.substring(
0,
_effectiveController.selectionStart,
)
.substring(0, _effectiveController.selectionStart)
.split('@');
final query = splits.last.toLowerCase();
@@ -1103,10 +1171,7 @@ class MessageInputState extends State<MessageInput>
}
final splits = _effectiveController.text
.substring(
0,
_effectiveController.baseOffset,
)
.substring(0, _effectiveController.baseOffset)
.split(':');
final query = splits.last.toLowerCase();
@@ -1154,11 +1219,14 @@ class MessageInputState extends State<MessageInput>
}
Widget _buildAttachments() {
if (_effectiveController.attachments.isEmpty) return const Offstage();
final fileAttachments = _effectiveController.attachments
final nonOGAttachments = _effectiveController.attachments.where(
(it) => it.titleLink == null,
);
if (nonOGAttachments.isEmpty) return const Offstage();
final fileAttachments = nonOGAttachments
.where((it) => it.type == 'file')
.toList(growable: false);
final remainingAttachments = _effectiveController.attachments
final remainingAttachments = nonOGAttachments
.where((it) => it.type != 'file')
.toList(growable: false);
return Column(
@@ -1243,11 +1311,7 @@ class MessageInputState extends State<MessageInput>
focusElevation: 0,
hoverElevation: 0,
onPressed: () {
_effectiveController.value = _effectiveController.value.copyWith(
attachments: _effectiveController.attachments
.where((it) => it.id != attachment.id)
.toList(),
);
_effectiveController.removeAttachmentById(attachment.id);
},
fillColor:
_streamChatTheme.colorTheme.textHighEmphasis.withOpacity(0.5),
@@ -1449,14 +1513,6 @@ class MessageInputState extends State<MessageInput>
}
}
/// Add an attachment to the sending message
/// Use this to add custom type attachments
///
/// Note: Only meant to be used from outside the state.
void addAttachment(Attachment attachment) {
_addAttachments([attachment]);
}
/// Adds an attachment to the [messageInputController.attachments] map
void _addAttachments(Iterable<Attachment> attachments) {
final limit = widget.attachmentLimit;
@@ -1591,6 +1647,8 @@ class MessageInputState extends State<MessageInput>
/// Sends the current message
Future<void> sendMessage() async {
final skipEnrichUrl = _effectiveController.ogAttachment == null;
var message = _effectiveController.value;
var shouldKeepFocus = widget.shouldKeepFocusAfterMessage;
@@ -1611,10 +1669,16 @@ class MessageInputState extends State<MessageInput>
try {
Future sendingFuture;
if (!_isEditing) {
sendingFuture = channel.sendMessage(message);
if (_isEditing) {
sendingFuture = channel.updateMessage(
message,
skipEnrichUrl: skipEnrichUrl,
);
} else {
sendingFuture = channel.updateMessage(message);
sendingFuture = channel.sendMessage(
message,
skipEnrichUrl: skipEnrichUrl,
);
}
if (shouldKeepFocus) {
@@ -1723,3 +1787,78 @@ class MessageInputState extends State<MessageInput>
super.didChangeDependencies();
}
}
/// Preview of an Open Graph attachment.
class OGAttachmentPreview extends StatelessWidget {
/// Returns a new instance of [OGAttachmentPreview]
const OGAttachmentPreview({
Key? key,
required this.attachment,
this.onDismissPreviewPressed,
}) : super(key: key);
/// The attachment to be rendered.
final Attachment attachment;
/// Called when the dismiss button is pressed.
final VoidCallback? onDismissPreviewPressed;
@override
Widget build(BuildContext context) {
final chatTheme = StreamChatTheme.of(context);
final textTheme = chatTheme.textTheme;
final colorTheme = chatTheme.colorTheme;
final attachmentTitle = attachment.title;
final attachmentText = attachment.text;
return Row(
children: [
Padding(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.link,
color: colorTheme.accentPrimary,
),
),
Expanded(
child: Container(
decoration: BoxDecoration(
border: Border(
left: BorderSide(
color: colorTheme.accentPrimary,
width: 2,
),
),
),
padding: const EdgeInsets.only(left: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (attachmentTitle != null)
Text(
attachmentTitle.trim(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.body.copyWith(fontWeight: FontWeight.w700),
),
if (attachmentText != null)
Text(
attachmentText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.body.copyWith(fontWeight: FontWeight.w400),
),
],
),
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: StreamSvgIcon.closeSmall(),
onPressed: onDismissPreviewPressed,
),
],
);
}
}
@@ -0,0 +1,307 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// A value listenable builder related to a [Message].
///
/// Pass in a [MessageInputController] as the `valueListenable`.
typedef MessageValueListenableBuilder = ValueListenableBuilder<Message>;
/// Controller for storing and mutating a [Message] value.
class MessageInputController extends ValueNotifier<Message> {
/// 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,
Map<RegExp, TextStyleBuilder>? textPatternStyle,
}) =>
MessageInputController._(
initialMessage: message ?? Message(),
textPatternStyle: textPatternStyle,
);
/// Creates a controller for an editable text field from an initial [text].
factory MessageInputController.fromText(
String? text, {
Map<RegExp, TextStyleBuilder>? textPatternStyle,
}) =>
MessageInputController._(
initialMessage: Message(text: text),
textPatternStyle: textPatternStyle,
);
/// Creates a controller for an editable text field from initial
/// [attachments].
factory MessageInputController.fromAttachments(
List<Attachment> attachments, {
Map<RegExp, TextStyleBuilder>? textPatternStyle,
}) =>
MessageInputController._(
initialMessage: Message(attachments: attachments),
textPatternStyle: textPatternStyle,
);
MessageInputController._({
required Message initialMessage,
Map<RegExp, TextStyleBuilder>? textPatternStyle,
}) : _textEditingController = MessageTextFieldController.fromValue(
initialMessage.text == null
? const TextEditingValue()
: TextEditingValue(
text: initialMessage.text!,
composing: TextRange.collapsed(initialMessage.text!.length),
),
textPatternStyle: textPatternStyle,
),
_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.
MessageTextFieldController get textEditingController =>
_textEditingController;
final MessageTextFieldController _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<Attachment> get attachments => value.attachments;
/// Sets the list of [attachments] for the message.
set attachments(List<Attachment> 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 = [];
}
// Only used to store the value locally in order to remove it if we call
// [clearOGAttachment] or [setOGAttachment] again.
Attachment? _ogAttachment;
/// Returns the og attachment of the message if set
Attachment? get ogAttachment =>
attachments.firstWhereOrNull((it) => it.id == _ogAttachment?.id);
/// Sets the og attachment in the message.
void setOGAttachment(Attachment attachment) {
attachments = [...attachments]
..remove(_ogAttachment)
..insert(0, attachment);
_ogAttachment = attachment;
}
/// Removes the og attachment.
void clearOGAttachment() {
if (_ogAttachment != null) {
removeAttachment(_ogAttachment!);
}
_ogAttachment = null;
}
/// Returns the list of mentioned users in the message.
List<User> get mentionedUsers => value.mentionedUsers;
/// Sets the mentioned users.
set mentionedUsers(List<User> 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) {
final newId = const Uuid().v4();
_initialMessage = _initialMessage.copyWith(id: newId);
}
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<MessageInputController> {
/// 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);
}
@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/message_input/tld.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// A function that takes a [BuildContext] and returns a [TextStyle].
typedef TextStyleBuilder = TextStyle? Function(
BuildContext context,
String text,
);
/// Controller for the [StreamTextField] widget.
class MessageTextFieldController extends TextEditingController {
/// Returns a new MessageTextFieldController
MessageTextFieldController({
String? text,
this.textPatternStyle,
}) : super(text: text);
/// Returns a new MessageTextFieldController with the given text [value].
MessageTextFieldController.fromValue(
TextEditingValue? value, {
this.textPatternStyle,
}) : super.fromValue(value);
/// A map of style to apply to the text matching the RegExp patterns.
final Map<RegExp, TextStyleBuilder>? textPatternStyle;
/// Builds a [TextSpan] from the current text,
/// highlighting the matches for [textPatternStyle].
@override
TextSpan buildTextSpan({
required BuildContext context,
TextStyle? style,
required bool withComposing,
}) {
final pattern = textPatternStyle ??
{
RegExp(r'(?:(?:https?|ftp):\/\/)?[\w/\-?=%.]+\.[\w/\-?=%.]+'):
(context, text) {
if (!text.split('.').last.isValidTLD()) return null;
return TextStyle(
color: MessageInputTheme.of(context).linkHighlightColor,
);
},
};
if (pattern.isEmpty) {
return super.buildTextSpan(
context: context,
style: style,
withComposing: withComposing,
);
}
return TextSpan(text: text, style: style).splitMapJoin(
RegExp(pattern.keys.map((it) => it.pattern).join('|')),
onMatch: (match) {
final text = match[0]!;
final key = pattern.keys.firstWhere((it) => it.hasMatch(text));
return TextSpan(
text: text,
style: pattern[key]?.call(
context,
text,
),
);
},
);
}
}
extension _TextSpanX on TextSpan {
TextSpan splitMapJoin(
Pattern pattern, {
TextSpan Function(Match)? onMatch,
TextSpan Function(TextSpan)? onNonMatch,
}) {
final children = <TextSpan>[];
toPlainText().splitMapJoin(
pattern,
onMatch: (match) {
final span = TextSpan(text: match.group(0), style: style);
final updated = onMatch?.call(match);
children.add(updated ?? span);
return span.toPlainText();
},
onNonMatch: (text) {
final span = TextSpan(text: text, style: style);
final updatedSpan = onNonMatch?.call(span);
children.add(updatedSpan ?? span);
return span.toPlainText();
},
);
return TextSpan(style: style, children: children);
}
}
@@ -8,7 +8,7 @@ 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';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
export 'package:flutter/services.dart'
show
File diff suppressed because it is too large Load Diff
@@ -259,6 +259,7 @@ class StreamChatThemeData {
sendButtonIdleColor: colorTheme.disabled,
inputBackgroundColor: colorTheme.barsBg,
inputTextStyle: textTheme.body,
linkHighlightColor: colorTheme.accentPrimary,
idleBorderGradient: LinearGradient(
colors: [
colorTheme.disabled,
@@ -65,6 +65,7 @@ class MessageInputThemeData with Diagnosticable {
this.idleBorderGradient,
this.borderRadius,
this.expandButtonColor,
this.linkHighlightColor,
});
/// Duration of the [MessageInput] send button animation
@@ -73,6 +74,9 @@ class MessageInputThemeData with Diagnosticable {
/// Background color of [MessageInput] send button
final Color? sendButtonColor;
/// Color of a link
final Color? linkHighlightColor;
/// Background color of [MessageInput] action buttons
final Color? actionButtonColor;
@@ -110,6 +114,7 @@ class MessageInputThemeData with Diagnosticable {
Color? actionButtonColor,
Color? sendButtonColor,
Color? actionButtonIdleColor,
Color? linkHighlightColor,
Color? sendButtonIdleColor,
Color? expandButtonColor,
TextStyle? inputTextStyle,
@@ -133,6 +138,7 @@ class MessageInputThemeData with Diagnosticable {
activeBorderGradient: activeBorderGradient ?? this.activeBorderGradient,
idleBorderGradient: idleBorderGradient ?? this.idleBorderGradient,
borderRadius: borderRadius ?? this.borderRadius,
linkHighlightColor: linkHighlightColor ?? this.linkHighlightColor,
);
/// Linearly interpolate from one [MessageInputThemeData] to another.
@@ -161,6 +167,8 @@ class MessageInputThemeData with Diagnosticable {
Color.lerp(a.sendButtonIdleColor, b.sendButtonIdleColor, t),
sendAnimationDuration: a.sendAnimationDuration,
inputDecoration: a.inputDecoration,
linkHighlightColor:
Color.lerp(a.linkHighlightColor, b.linkHighlightColor, t),
);
/// Merges [this] [MessageInputThemeData] with the [other]
@@ -181,6 +189,7 @@ class MessageInputThemeData with Diagnosticable {
idleBorderGradient: other.idleBorderGradient,
borderRadius: other.borderRadius,
expandButtonColor: other.expandButtonColor,
linkHighlightColor: other.linkHighlightColor,
);
}
@@ -200,7 +209,8 @@ class MessageInputThemeData with Diagnosticable {
inputDecoration == other.inputDecoration &&
idleBorderGradient == other.idleBorderGradient &&
activeBorderGradient == other.activeBorderGradient &&
borderRadius == other.borderRadius;
borderRadius == other.borderRadius &&
linkHighlightColor == other.linkHighlightColor;
@override
int get hashCode =>
@@ -215,7 +225,8 @@ class MessageInputThemeData with Diagnosticable {
inputDecoration.hashCode ^
idleBorderGradient.hashCode ^
activeBorderGradient.hashCode ^
borderRadius.hashCode;
borderRadius.hashCode ^
linkHighlightColor.hashCode;
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
@@ -232,6 +243,7 @@ class MessageInputThemeData with Diagnosticable {
..add(DiagnosticsProperty('activeBorderGradient', activeBorderGradient))
..add(DiagnosticsProperty('idleBorderGradient', idleBorderGradient))
..add(DiagnosticsProperty('borderRadius', borderRadius))
..add(ColorProperty('expandButtonColor', expandButtonColor));
..add(ColorProperty('expandButtonColor', expandButtonColor))
..add(ColorProperty('linkHighlightColor', linkHighlightColor));
}
}
@@ -25,6 +25,8 @@ export 'src/mention_tile.dart';
export 'src/message_action.dart';
export 'src/message_input/countdown_button.dart';
export 'src/message_input/message_input.dart';
export 'src/message_input/message_input_controller.dart';
export 'src/message_input/message_text_field_controller.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';
@@ -0,0 +1,14 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.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');
},
);
}