Merge pull request #698 from GetStream/overlay-portal

refactor(ui): use portal in messageinput overlays
This commit is contained in:
Salvatore Giordano
2021-10-01 09:36:47 +02:00
committed by GitHub
21 changed files with 1338 additions and 634 deletions
@@ -351,7 +351,7 @@ class Channel {
Future<bool> get initialized => _initializedCompleter.future; Future<bool> get initialized => _initializedCompleter.future;
final _cancelableAttachmentUploadRequest = <String, CancelToken>{}; final _cancelableAttachmentUploadRequest = <String, CancelToken>{};
final _messageAttachmentsUploadCompleter = <String, Completer>{}; final _messageAttachmentsUploadCompleter = <String, Completer<Message>>{};
/// Cancels [attachmentId] upload request. Throws exception if the request /// Cancels [attachmentId] upload request. Throws exception if the request
/// hasn't even started yet, Already completed or Already cancelled. /// hasn't even started yet, Already completed or Already cancelled.
@@ -382,12 +382,10 @@ class Channel {
String messageId, String messageId,
Iterable<String> attachmentIds, Iterable<String> attachmentIds,
) { ) {
final message = [ var message = [
...state!.messages, ...state!.messages,
...state!.threads.values.expand((messages) => messages), ...state!.threads.values.expand((messages) => messages),
].firstWhereOrNull( ].firstWhereOrNull((it) => it.id == messageId);
(it) => it.id == messageId,
);
if (message == null) { if (message == null) {
throw const StreamChatError('Error, Message not found'); throw const StreamChatError('Error, Message not found');
@@ -409,11 +407,15 @@ class Channel {
client.logger.info('Found ${attachments.length} attachments'); client.logger.info('Found ${attachments.length} attachments');
void updateAttachment(Attachment attachment) { void updateAttachment(Attachment attachment) {
final index = final index = message!.attachments.indexWhere(
message.attachments.indexWhere((it) => it.id == attachment.id); (it) => it.id == attachment.id,
);
if (index != -1) { if (index != -1) {
message.attachments[index] = attachment; final newAttachments = [...message!.attachments]..[index] = attachment;
state?.addMessage(message); final updatedMessage = message!.copyWith(attachments: newAttachments);
state?.addMessage(updatedMessage);
// updating original message for next iteration
message = message!.merge(updatedMessage);
} }
} }
@@ -476,7 +478,7 @@ class Channel {
_cancelableAttachmentUploadRequest.remove(it.id); _cancelableAttachmentUploadRequest.remove(it.id);
}); });
})).whenComplete(() { })).whenComplete(() {
if (message.attachments.every((it) => it.uploadState.isSuccess)) { if (message!.attachments.every((it) => it.uploadState.isSuccess)) {
_messageAttachmentsUploadCompleter.remove(messageId)?.complete(message); _messageAttachmentsUploadCompleter.remove(messageId)?.complete(message);
} }
}); });
@@ -1076,33 +1078,7 @@ class Channel {
} else { } else {
// remove the passed message if response does // remove the passed message if response does
// not contain message // not contain message
final oldIndex = state!.messages.indexWhere((m) => m.id == messageId); state!.removeMessage(message);
// remove regular message if present
if (oldIndex != -1) {
final oldMessage = state!.messages[oldIndex];
state!.updateChannelState(state!._channelState.copyWith(
messages: state?.messages?..remove(oldMessage),
channel: state?._channelState.channel,
));
} else {
// remove thread message if present
// also reduces total reply count
final oldMessage = state!.threads.values
.expand((messages) => messages)
.firstWhereOrNull((m) => m.id == messageId);
if (oldMessage?.parentId != null) {
final parentMessage = state!.messages.firstWhereOrNull(
(element) => element.id == oldMessage!.parentId,
);
if (parentMessage != null) {
state!.addMessage(parentMessage.copyWith(
replyCount: parentMessage.replyCount! - 1));
}
state!.updateThreadInfo(oldMessage!.parentId!,
state!.threads[oldMessage.parentId!]!..remove(oldMessage));
}
}
await _client.chatPersistenceClient?.deleteMessageById(messageId); await _client.chatPersistenceClient?.deleteMessageById(messageId);
} }
return res; return res;
@@ -1704,7 +1680,7 @@ class ChannelClientState {
})); }));
} }
/// Add a message to this channel. /// Add a [message] to this [channelState].
void addMessage(Message message) { void addMessage(Message message) {
if (message.parentId == null || message.showInChannel == true) { if (message.parentId == null || message.showInChannel == true) {
final newMessages = List<Message>.from(_channelState.messages); final newMessages = List<Message>.from(_channelState.messages);
@@ -1735,6 +1711,35 @@ class ChannelClientState {
} }
} }
/// Remove a [message] from this [channelState].
void removeMessage(Message message) {
final parentId = message.parentId;
// i.e. it's a thread message
// 1. Remove the thread message
// 2. Reduce total reply count of parent message
if (parentId != null) {
final allMessages = [...messages];
final parentMessage = allMessages.firstWhereOrNull(
(it) => it.id == parentId,
);
// return if message not available in the memory
if (parentMessage == null) return;
final replyCount = parentMessage.replyCount;
// return if reply count is null or zero
if (replyCount == null || replyCount == 0) return;
addMessage(parentMessage.copyWith(replyCount: replyCount - 1));
updateThreadInfo(parentId, threads[parentId]!..remove(message));
} else {
// Remove regular message
final allMessages = [...messages];
if (allMessages.remove(message)) {
_channelState = _channelState.copyWith(messages: allMessages);
}
}
}
void _listenReadEvents() { void _listenReadEvents() {
if (_channelState.channel?.config.readEvents == false) { if (_channelState.channel?.config.readEvents == false) {
return; return;
@@ -1862,15 +1867,11 @@ class ChannelClientState {
if (newThreads.containsKey(parentId)) { if (newThreads.containsKey(parentId)) {
newThreads[parentId] = [ newThreads[parentId] = [
...newThreads[parentId]
?.where(
(newMessage) => !messages.any((m) => m.id == newMessage.id))
.toList() ??
[],
...messages, ...messages,
]; ...newThreads[parentId]!.where(
(newMessage) => !messages.any((m) => m.id == newMessage.id),
newThreads[parentId]!.sort(_sortByCreatedAt); ),
]..sort(_sortByCreatedAt);
} else { } else {
newThreads[parentId] = messages; newThreads[parentId] = messages;
} }
@@ -334,20 +334,87 @@ void main() {
expectLater( expectLater(
// skipping first seed message list -> [] messages // skipping first seed message list -> [] messages
channel.state?.messagesStream.skip(1), channel.state?.messagesStream.skip(1),
emitsInOrder([ emitsInOrder(
[ [
isSameMessageAs( // preparing attachments to upload
message.copyWith(status: MessageSendingStatus.sending), [
matchSendingStatus: true, isSameMessageAs(
), message.copyWith(
status: MessageSendingStatus.sending,
attachments: [
...attachments.map((it) => it.copyWith(
uploadState: const UploadState.preparing()))
],
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
// 0th attachment is successfully uploaded
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.sending,
attachments: [...attachments]..[0] =
attachments[0].copyWith(
uploadState: const UploadState.success(),
),
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
// 0th and 1st attachment is successfully uploaded
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.sending,
attachments: [...attachments]
..[0] = attachments[0].copyWith(
uploadState: const UploadState.success(),
)
..[1] = attachments[1].copyWith(
uploadState: const UploadState.success(),
),
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
// all the attachments are successfully uploaded
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.sending,
attachments: [
...attachments.map((it) =>
it.copyWith(uploadState: const UploadState.success()))
],
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.sent,
attachments: [
...attachments.map((it) =>
it.copyWith(uploadState: const UploadState.success()))
],
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
], ],
[ ),
isSameMessageAs(
message.copyWith(status: MessageSendingStatus.sent),
matchSendingStatus: true,
),
],
]),
); );
final res = await channel.sendMessage(message); final res = await channel.sendMessage(message);
@@ -472,20 +539,87 @@ void main() {
expectLater( expectLater(
// skipping first seed message list -> [] messages // skipping first seed message list -> [] messages
channel.state?.messagesStream.skip(1), channel.state?.messagesStream.skip(1),
emitsInOrder([ emitsInOrder(
[ [
isSameMessageAs( // preparing attachments to upload
message.copyWith(status: MessageSendingStatus.updating), [
matchSendingStatus: true, isSameMessageAs(
), message.copyWith(
status: MessageSendingStatus.updating,
attachments: [
...attachments.map((it) => it.copyWith(
uploadState: const UploadState.preparing()))
],
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
// 0th attachment is successfully uploaded
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.updating,
attachments: [...attachments]..[0] =
attachments[0].copyWith(
uploadState: const UploadState.success(),
),
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
// 0th and 1st attachment is successfully uploaded
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.updating,
attachments: [...attachments]
..[0] = attachments[0].copyWith(
uploadState: const UploadState.success(),
)
..[1] = attachments[1].copyWith(
uploadState: const UploadState.success(),
),
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
// all the attachments are successfully uploaded
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.updating,
attachments: [
...attachments.map((it) =>
it.copyWith(uploadState: const UploadState.success()))
],
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
[
isSameMessageAs(
message.copyWith(
status: MessageSendingStatus.sent,
attachments: [
...attachments.map((it) =>
it.copyWith(uploadState: const UploadState.success()))
],
),
matchSendingStatus: true,
matchAttachments: true,
matchAttachmentsUploadState: true,
),
],
], ],
[ ),
isSameMessageAs(
message.copyWith(status: MessageSendingStatus.sent),
matchSendingStatus: true,
),
],
]),
); );
final res = await channel.updateMessage(message); final res = await channel.updateMessage(message);
@@ -1,6 +1,7 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:dio/dio.dart' show MultipartFile; import 'package:dio/dio.dart' show MultipartFile;
import 'package:stream_chat/src/client/channel.dart'; import 'package:stream_chat/src/client/channel.dart';
import 'package:stream_chat/src/core/models/attachment.dart';
import 'package:stream_chat/src/core/models/channel_state.dart'; import 'package:stream_chat/src/core/models/channel_state.dart';
import 'package:stream_chat/src/core/models/event.dart'; import 'package:stream_chat/src/core/models/event.dart';
import 'package:stream_chat/src/core/models/message.dart'; import 'package:stream_chat/src/core/models/message.dart';
@@ -46,12 +47,16 @@ Matcher isSameMessageAs(
bool matchText = false, bool matchText = false,
bool matchReactions = false, bool matchReactions = false,
bool matchSendingStatus = false, bool matchSendingStatus = false,
bool matchAttachments = false,
bool matchAttachmentsUploadState = false,
}) => }) =>
_IsSameMessageAs( _IsSameMessageAs(
targetMessage: targetMessage, targetMessage: targetMessage,
matchText: matchText, matchText: matchText,
matchReactions: matchReactions, matchReactions: matchReactions,
matchSendingStatus: matchSendingStatus, matchSendingStatus: matchSendingStatus,
matchAttachments: matchAttachments,
matchAttachmentsUploadState: matchAttachmentsUploadState,
); );
class _IsSameMessageAs extends Matcher { class _IsSameMessageAs extends Matcher {
@@ -60,12 +65,16 @@ class _IsSameMessageAs extends Matcher {
this.matchText = false, this.matchText = false,
this.matchReactions = false, this.matchReactions = false,
this.matchSendingStatus = false, this.matchSendingStatus = false,
this.matchAttachments = false,
this.matchAttachmentsUploadState = false,
}); });
final Message targetMessage; final Message targetMessage;
final bool matchText; final bool matchText;
final bool matchReactions; final bool matchReactions;
final bool matchSendingStatus; final bool matchSendingStatus;
final bool matchAttachments;
final bool matchAttachmentsUploadState;
@override @override
Description describe(Description description) => Description describe(Description description) =>
@@ -96,6 +105,56 @@ class _IsSameMessageAs extends Matcher {
?.map((it) => '${it.type}-${it.messageId}') ?.map((it) => '${it.type}-${it.messageId}')
.toList()); .toList());
} }
if (matchAttachments) {
bool matchAttachments() {
final attachments = message.attachments;
final targetAttachments = targetMessage.attachments;
if (identical(attachments, targetAttachments)) return true;
final length = attachments.length;
if (length != targetAttachments.length) return false;
for (var i = 0; i < length; i++) {
if (!isSameAttachmentAs(
attachments[i],
matchUploadState: matchAttachmentsUploadState,
).matches(targetAttachments[i], matchState)) return false;
}
return true;
}
matches &= matchAttachments();
}
return matches;
}
}
Matcher isSameAttachmentAs(
Attachment targetAttachment, {
bool matchUploadState = false,
}) =>
_IsSameAttachmentAs(
targetAttachment: targetAttachment,
matchUploadState: matchUploadState,
);
class _IsSameAttachmentAs extends Matcher {
const _IsSameAttachmentAs({
required this.targetAttachment,
this.matchUploadState = false,
});
final Attachment targetAttachment;
final bool matchUploadState;
@override
Description describe(Description description) =>
description.add('is same attachment as $targetAttachment');
@override
bool matches(covariant Attachment attachment, Map matchState) {
var matches = attachment.id == targetAttachment.id;
if (matchUploadState) {
matches &= attachment.uploadState == targetAttachment.uploadState;
}
return matches; return matches;
} }
} }
+18
View File
@@ -1,3 +1,16 @@
## Upcoming
✅ Added
- Added `MessageInput.customOverlays` property to add custom overlays to the message input.
- Added `MessageInput.mentionAllAppUsers` property to mention all app users in the message input.
- The `MessageInput` now supports local search for channels with less than 100 members.
⚠️ Deprecated
- `MessageInput.mentionsTileBuilder` is now deprecated in favor of `MessageInput.userMentionsTileBuilder`.
- `MentionTile` is now deprecated in favor of `UserMentionsTile`.
## 3.0.0 ## 3.0.0
- Updated `stream_chat_flutter_core` dependency to [`3.0.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog). - Updated `stream_chat_flutter_core` dependency to [`3.0.0`](https://pub.dev/packages/stream_chat_flutter_core/changelog).
@@ -11,6 +24,7 @@
- [[#668]](https://github.com/GetStream/stream-chat-flutter/issues/668): Fix `MessageInput` rendering errors in case - [[#668]](https://github.com/GetStream/stream-chat-flutter/issues/668): Fix `MessageInput` rendering errors in case
there are no actions available to show. there are no actions available to show.
- [[#349]](https://github.com/GetStream/stream-chat-flutter/issues/349): Fix `MessageInput` attachment render overflow error. - [[#349]](https://github.com/GetStream/stream-chat-flutter/issues/349): Fix `MessageInput` attachment render overflow error.
- `MessageInput` overlays now follow the `MessageInput` focus.
- [[#674]](https://github.com/GetStream/stream-chat-flutter/issues/674): Check scrollController is attached before calling jump in MessageListView. - [[#674]](https://github.com/GetStream/stream-chat-flutter/issues/674): Check scrollController is attached before calling jump in MessageListView.
- Fixed `MessageListView` header and footer when `reverse: false`. - Fixed `MessageListView` header and footer when `reverse: false`.
@@ -19,6 +33,10 @@
- Animation curves changed from default `Curves.linear` to `Curves.easeOut` and `Curves.easeIn` for attachment controls. - Animation curves changed from default `Curves.linear` to `Curves.easeOut` and `Curves.easeIn` for attachment controls.
- Removed default padding in `DateDivider` in `MessageListView` - Removed default padding in `DateDivider` in `MessageListView`
✅ Added
- Added `MessageInput.customPortalOptions` property to add custom overlays to the `MessageInput`.
## 2.2.1 ## 2.2.1
⚠️ Deprecated ⚠️ Deprecated
@@ -21,6 +21,6 @@
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1.0</string> <string>1.0</string>
<key>MinimumOSVersion</key> <key>MinimumOSVersion</key>
<string>8.0</string> <string>9.0</string>
</dict> </dict>
</plist> </plist>
@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Overlay for displaying commands that can be used
class CommandsOverlay extends StatelessWidget {
/// Constructor for creating a [CommandsOverlay]
const CommandsOverlay({
required this.text,
required this.onCommandResult,
required this.size,
required this.channel,
Key? key,
}) : super(key: key);
/// The size of the overlay
final Size size;
/// Query for searching commands
final String text;
/// The channel to search for users
final Channel channel;
/// Callback called when a command is selected
final ValueChanged<Command> onCommandResult;
@override
Widget build(BuildContext context) {
final _streamChatTheme = StreamChatTheme.of(context);
final commands = channel.config?.commands
.where((c) => c.name.contains(text.replaceFirst('/', '')))
.toList() ??
[];
if (commands.isEmpty) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.all(4),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
color: _streamChatTheme.colorTheme.barsBg,
clipBehavior: Clip.hardEdge,
child: Container(
constraints: BoxConstraints.loose(size),
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.barsBg,
borderRadius: BorderRadius.circular(8)),
child: ListView(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
children: [
if (commands.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: StreamSvgIcon.lightning(
color: _streamChatTheme.colorTheme.accentPrimary,
),
),
Text(
context.translations.instantCommandsLabel,
style: TextStyle(
color: _streamChatTheme.colorTheme.textHighEmphasis
.withOpacity(.5),
),
)
],
),
),
const SizedBox(
height: 10,
),
...commands
.map(
(c) => InkWell(
onTap: () {
onCommandResult(c);
},
child: SizedBox(
height: 40,
child: Row(
children: [
const SizedBox(
width: 16,
),
_buildCommandIcon(_streamChatTheme, c.name),
const SizedBox(
width: 8,
),
Text.rich(
TextSpan(
text: c.name.capitalize(),
style: const TextStyle(
fontWeight: FontWeight.bold),
children: [
TextSpan(
text: ' /${c.name} ${c.args}',
style: _streamChatTheme.textTheme.body
.copyWith(
// ignore: lines_longer_than_80_chars
color: _streamChatTheme
// ignore: lines_longer_than_80_chars
.colorTheme
.textLowEmphasis,
),
),
],
),
),
],
),
),
),
)
.toList(),
],
),
),
),
);
}
Widget _buildCommandIcon(
StreamChatThemeData _streamChatTheme, String iconType) {
switch (iconType) {
case 'giphy':
return CircleAvatar(
radius: 12,
child: StreamSvgIcon.giphyIcon(
size: 24,
),
);
case 'ban':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.iconUserDelete(
size: 16,
color: Colors.white,
),
);
case 'flag':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.flag(
size: 14,
color: Colors.white,
),
);
case 'imgur':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: ClipOval(
child: StreamSvgIcon.imgur(
size: 24,
),
),
);
case 'mute':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.mute(
size: 16,
color: Colors.white,
),
);
case 'unban':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.userAdd(
size: 16,
color: Colors.white,
),
);
case 'unmute':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.volumeUp(
size: 16,
color: Colors.white,
),
);
default:
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.lightning(
size: 16,
color: Colors.white,
),
);
}
}
}
@@ -0,0 +1,118 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/emoji/emoji.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:substring_highlight/substring_highlight.dart';
/// Overlay for displaying emoji that can be used
class EmojiOverlay extends StatelessWidget {
/// Constructor for creating a [EmojiOverlay]
const EmojiOverlay({
required this.query,
required this.onEmojiResult,
required this.size,
Key? key,
}) : super(key: key);
/// The size of the overlay
final Size size;
/// Query for searching emoji
final String query;
/// Callback called when an emoji is selected
final ValueChanged<Emoji> onEmojiResult;
@override
Widget build(BuildContext context) {
final _streamChatTheme = StreamChatTheme.of(context);
final _emojiNames =
Emoji.all().where((it) => it.name != null).map((e) => e.name!);
final emojis = _emojiNames
.where((e) => e.contains(query))
.map(Emoji.byName)
.where((e) => e != null);
if (emojis.isEmpty) {
return const SizedBox();
}
return Card(
margin: const EdgeInsets.all(8),
elevation: 2,
color: _streamChatTheme.colorTheme.barsBg,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.hardEdge,
child: Container(
constraints: BoxConstraints.loose(size),
decoration: BoxDecoration(
boxShadow: const [
BoxShadow(
spreadRadius: -8,
blurRadius: 5,
offset: Offset(0, -4),
),
],
color: _streamChatTheme.colorTheme.barsBg,
),
child: ListView.builder(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
itemCount: emojis.length + 1,
itemBuilder: (context, i) {
if (i == 0) {
return Padding(
padding: const EdgeInsets.only(left: 8, top: 8),
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: StreamSvgIcon.smile(
color: _streamChatTheme.colorTheme.accentPrimary,
),
),
Flexible(
child: Text(
context.translations.emojiMatchingQueryText(
query,
),
style: TextStyle(
color: _streamChatTheme.colorTheme.textHighEmphasis
.withOpacity(.5),
),
),
)
],
),
);
}
final emoji = emojis.elementAt(i - 1)!;
final themeData = Theme.of(context);
return ListTile(
title: SubstringHighlight(
text:
// ignore: lines_longer_than_80_chars
"${emoji.char} ${emoji.name!.replaceAll('_', ' ')}",
term: query,
textStyleHighlight: themeData.textTheme.headline6!.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.bold,
),
textStyle: themeData.textTheme.headline6!.copyWith(
fontSize: 14.5,
),
),
onTap: () {
onEmojiResult(emoji);
},
);
},
),
),
);
}
}
@@ -1,4 +1,5 @@
import 'package:characters/characters.dart'; import 'package:characters/characters.dart';
import 'package:diacritic/diacritic.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/emoji/emoji.dart'; import 'package:stream_chat_flutter/src/emoji/emoji.dart';
@@ -24,6 +25,12 @@ extension StringExtension on String {
final characters = trim().characters; final characters = trim().characters;
return characters.every(_emojiChars.contains); return characters.every(_emojiChars.contains);
} }
/// Removes accents and diacritics from the given String.
String get diacriticsInsensitive => removeDiacritics(this);
/// Levenshtein distance between this and [t].
int levenshteinDistance(String t) => levenshtein(this, t);
} }
/// List extension /// List extension
@@ -170,3 +177,49 @@ extension IconButtonX on IconButton {
icon: icon ?? this.icon, icon: icon ?? this.icon,
); );
} }
/// Extensions on List<User>
extension UserListX on List<User> {
/// It does an search on a list of [User] and returns users with
/// `id` or `name` containing the [query].
///
/// Results are returned sorted by their edit distance from the
/// searched string, distance is calculated using the [levenshtein] algorithm.
List<User> search(String query) {
String normalize(String input) => input.toLowerCase().diacriticsInsensitive;
final normalizedQuery = normalize(query);
final matchingUsers = <User, int>{}; // User:lDistance
for (final user in this) {
final normalizedId = normalize(user.id);
final normalizedUserName = normalize(user.name);
final lDistance = normalizedUserName.levenshteinDistance(normalizedQuery);
final containsId = normalizedId.contains(normalizedQuery);
final containsName = normalizedUserName.contains(normalizedQuery);
if (lDistance < 3 || containsId || containsName) {
matchingUsers[user] = lDistance;
}
}
final entries = matchingUsers.entries.toList(growable: false)
..sort((prev, curr) {
bool containsQuery(User user) =>
normalize(user.id).contains(normalizedQuery) ||
normalize(user.name).contains(normalizedQuery);
final containsInPrev = containsQuery(prev.key);
final containsInCurr = containsQuery(curr.key);
if (containsInPrev && !containsInCurr) {
return -1;
} else if (!containsInPrev && containsInCurr) {
return 1;
}
return prev.value.compareTo(curr.value);
});
return entries.map((e) => e.key).toList(growable: false);
}
}
@@ -4,6 +4,7 @@ import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// This widget is used for showing user tiles for mentions /// This widget is used for showing user tiles for mentions
/// Use [title], [subtitle], [leading], [trailing] for /// Use [title], [subtitle], [leading], [trailing] for
/// substituting widgets in respective positions /// substituting widgets in respective positions
@Deprecated('Use `UserMentionTile` instead. Will be removed in future release')
class MentionTile extends StatelessWidget { class MentionTile extends StatelessWidget {
/// Constructor for creating a [MentionTile] widget /// Constructor for creating a [MentionTile] widget
const MentionTile( const MentionTile(
@@ -2,27 +2,30 @@ import 'dart:async';
import 'dart:math'; import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:collection/collection.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:shimmer/shimmer.dart'; import 'package:shimmer/shimmer.dart';
import 'package:stream_chat_flutter/src/commands_overlay.dart';
import 'package:stream_chat_flutter/src/emoji/emoji.dart'; import 'package:stream_chat_flutter/src/emoji/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/extension.dart';
import 'package:stream_chat_flutter/src/media_list_view.dart'; import 'package:stream_chat_flutter/src/media_list_view.dart';
import 'package:stream_chat_flutter/src/message_list_view.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart';
import 'package:stream_chat_flutter/src/overlays.dart';
import 'package:stream_chat_flutter/src/quoted_message_widget.dart'; import 'package:stream_chat_flutter/src/quoted_message_widget.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/user_mentions_overlay.dart';
import 'package:stream_chat_flutter/src/video_service.dart'; import 'package:stream_chat_flutter/src/video_service.dart';
import 'package:stream_chat_flutter/src/video_thumbnail_image.dart'; import 'package:stream_chat_flutter/src/video_thumbnail_image.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:substring_highlight/substring_highlight.dart';
import 'package:video_compress/video_compress.dart'; import 'package:video_compress/video_compress.dart';
export 'package:video_compress/video_compress.dart' show VideoQuality; export 'package:video_compress/video_compress.dart' show VideoQuality;
@@ -53,13 +56,20 @@ typedef AttachmentThumbnailBuilder = Widget Function(
Attachment, Attachment,
); );
/// Builder function for building a mention tile /// Builder function for building a mention tile.
/// Use [MentionTile] for the default implementation
typedef MentionTileBuilder = Widget Function( typedef MentionTileBuilder = Widget Function(
BuildContext context, BuildContext context,
Member member, Member member,
); );
/// Builder function for building a user mention tile.
///
/// Use [UserMentionTile] for the default implementation.
typedef UserMentionTileBuilder = Widget Function(
BuildContext context,
User user,
);
/// Widget builder for action button. /// Widget builder for action button.
/// ///
/// [defaultActionButton] is the default [IconButton] configuration, /// [defaultActionButton] is the default [IconButton] configuration,
@@ -169,7 +179,7 @@ class MessageInput extends StatefulWidget {
this.disableAttachments = false, this.disableAttachments = false,
this.initialMessage, this.initialMessage,
this.textEditingController, this.textEditingController,
this.actions, this.actions = const [],
this.actionsLocation = ActionsLocation.left, this.actionsLocation = ActionsLocation.left,
this.attachmentThumbnailBuilders, this.attachmentThumbnailBuilders,
this.focusNode, this.focusNode,
@@ -181,7 +191,9 @@ class MessageInput extends StatefulWidget {
this.idleSendButton, this.idleSendButton,
this.activeSendButton, this.activeSendButton,
this.showCommandsButton = true, this.showCommandsButton = true,
this.mentionsTileBuilder, @Deprecated('''Use `userMentionsTileBuilder` instead. Will be removed in future release''')
this.mentionsTileBuilder,
this.userMentionsTileBuilder,
this.maxAttachmentSize = _kDefaultMaxAttachmentSize, this.maxAttachmentSize = _kDefaultMaxAttachmentSize,
this.compressedVideoQuality = VideoQuality.DefaultQuality, this.compressedVideoQuality = VideoQuality.DefaultQuality,
this.compressedVideoFrameRate = 30, this.compressedVideoFrameRate = 30,
@@ -190,12 +202,17 @@ class MessageInput extends StatefulWidget {
this.onAttachmentLimitExceed, this.onAttachmentLimitExceed,
this.attachmentButtonBuilder, this.attachmentButtonBuilder,
this.commandButtonBuilder, this.commandButtonBuilder,
this.customOverlays = const [],
this.mentionAllAppUsers = false,
}) : assert( }) : assert(
initialMessage == null || editMessage == null, initialMessage == null || editMessage == null,
"Can't provide both `initialMessage` and `editMessage`", "Can't provide both `initialMessage` and `editMessage`",
), ),
super(key: key); super(key: key);
/// List of options for showing overlays
final List<OverlayOptions> customOverlays;
/// Message to edit /// Message to edit
final Message? editMessage; final Message? editMessage;
@@ -242,7 +259,7 @@ class MessageInput extends StatefulWidget {
final TextEditingController? textEditingController; final TextEditingController? textEditingController;
/// List of action widgets /// List of action widgets
final List<Widget>? actions; final List<Widget> actions;
/// The location of the custom actions /// The location of the custom actions
final ActionsLocation actionsLocation; final ActionsLocation actionsLocation;
@@ -271,9 +288,12 @@ class MessageInput extends StatefulWidget {
/// Send button widget in an active state /// Send button widget in an active state
final Widget? activeSendButton; final Widget? activeSendButton;
/// Customize the tile for the mentions overlay /// Customize the tile for the mentions overlay.
final MentionTileBuilder? mentionsTileBuilder; final MentionTileBuilder? mentionsTileBuilder;
/// Customize the tile for the mentions overlay.
final UserMentionTileBuilder? userMentionsTileBuilder;
/// A callback for error reporting /// A callback for error reporting
final ErrorListener? onError; final ErrorListener? onError;
@@ -297,6 +317,11 @@ class MessageInput extends StatefulWidget {
/// calling `.copyWith`. /// calling `.copyWith`.
final ActionButtonBuilder? commandButtonBuilder; final ActionButtonBuilder? commandButtonBuilder;
/// When enabled mentions search users across the entire app.
///
/// Defaults to false.
final bool mentionAllAppUsers;
@override @override
MessageInputState createState() => MessageInputState(); MessageInputState createState() => MessageInputState();
@@ -318,11 +343,11 @@ class MessageInputState extends State<MessageInput> {
final List<User> _mentionedUsers = []; final List<User> _mentionedUsers = [];
final _imagePicker = ImagePicker(); final _imagePicker = ImagePicker();
late final FocusNode _focusNode; late final _focusNode = widget.focusNode ?? FocusNode();
bool _inputEnabled = true; bool _inputEnabled = true;
bool _commandEnabled = false; bool _commandEnabled = false;
OverlayEntry? _commandsOverlay, _mentionsOverlay, _emojiOverlay; bool _showCommandsOverlay = false;
late Iterable<String> _emojiNames; bool _showMentionsOverlay = false;
Command? _chosenCommand; Command? _chosenCommand;
bool _actionsShrunk = false; bool _actionsShrunk = false;
@@ -330,10 +355,9 @@ class MessageInputState extends State<MessageInput> {
bool _openFilePickerSection = false; bool _openFilePickerSection = false;
int _filePickerIndex = 0; int _filePickerIndex = 0;
final _keyboardVisibilityController = KeyboardVisibilityController();
/// The editing controller passed to the input TextField /// The editing controller passed to the input TextField
late final TextEditingController textEditingController; late final TextEditingController textEditingController =
widget.textEditingController ?? TextEditingController();
late StreamChatThemeData _streamChatTheme; late StreamChatThemeData _streamChatTheme;
late MessageInputThemeData _messageInputTheme; late MessageInputThemeData _messageInputTheme;
@@ -345,29 +369,10 @@ class MessageInputState extends State<MessageInput> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_focusNode = widget.focusNode ?? FocusNode();
_emojiNames =
Emoji.all().where((it) => it.name != null).map((e) => e.name!);
if (!kIsWeb) {
_keyboardListener =
_keyboardVisibilityController.onChange.listen((visible) {
if (_focusNode.hasFocus) {
_onChanged(context, textEditingController.text);
}
});
}
textEditingController =
widget.textEditingController ?? TextEditingController();
if (widget.editMessage != null || widget.initialMessage != null) { if (widget.editMessage != null || widget.initialMessage != null) {
_parseExistingMessage(widget.editMessage ?? widget.initialMessage!); _parseExistingMessage(widget.editMessage ?? widget.initialMessage!);
} }
textEditingController.addListener(_onChangedDebounced);
textEditingController.addListener(() {
_onChanged(context, textEditingController.text);
});
_focusNode.addListener(() { _focusNode.addListener(() {
if (_focusNode.hasFocus) { if (_focusNode.hasFocus) {
_openFilePickerSection = false; _openFilePickerSection = false;
@@ -472,7 +477,35 @@ class MessageInputState extends State<MessageInput> {
child: child, child: child,
); );
} }
return child;
return MultiOverlay(
childAnchor: Alignment.topCenter,
overlayAnchor: Alignment.bottomCenter,
overlayOptions: [
OverlayOptions(
visible: _showCommandsOverlay,
widget: _buildCommandsOverlayEntry(),
),
OverlayOptions(
visible: _focusNode.hasFocus &&
textEditingController.text.isNotEmpty &&
textEditingController.selection.baseOffset > 0 &&
textEditingController.text
.substring(
0,
textEditingController.selection.baseOffset,
)
.contains(':'),
widget: _buildEmojiOverlay(),
),
OverlayOptions(
visible: _showMentionsOverlay,
widget: _buildMentionsOverlayEntry(),
),
...widget.customOverlays,
],
child: child,
);
} }
Flex _buildTextField(BuildContext context) => Flex( Flex _buildTextField(BuildContext context) => Flex(
@@ -604,7 +637,7 @@ class MessageInputState extends State<MessageInput> {
), ),
secondChild: widget.disableAttachments && secondChild: widget.disableAttachments &&
!widget.showCommandsButton && !widget.showCommandsButton &&
widget.actions?.isNotEmpty != true widget.actions.isNotEmpty != true
? const Offstage() ? const Offstage()
: Wrap( : Wrap(
children: <Widget>[ children: <Widget>[
@@ -615,7 +648,7 @@ class MessageInputState extends State<MessageInput> {
channel.state != null && channel.state != null &&
channel.config?.commands.isNotEmpty == true) channel.config?.commands.isNotEmpty == true)
_buildCommandButton(context), _buildCommandButton(context),
...widget.actions ?? [], ...widget.actions,
].insertBetween(const SizedBox(width: 8)), ].insertBetween(const SizedBox(width: 8)),
), ),
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@@ -783,51 +816,32 @@ class MessageInputState extends State<MessageInput> {
).merge(passedDecoration); ).merge(passedDecoration);
} }
Timer? _debounce; late final _onChangedDebounced = debounce(
() {
var value = textEditingController.text;
if (!mounted) return;
value = value.trim();
String? _previousValue; final channel = StreamChannel.of(context).channel;
if (value.isNotEmpty) {
channel.keyStroke(widget.parentMessage?.id).catchError((e) {});
}
void _onChanged(BuildContext context, String s) { var actionsLength = widget.actions.length;
if (s == _previousValue) { if (widget.showCommandsButton) actionsLength += 1;
return; if (!widget.disableAttachments) actionsLength += 1;
}
_previousValue = s;
if (_debounce?.isActive == true) _debounce!.cancel(); setState(() {
_debounce = Timer( _actionsShrunk = value.isNotEmpty && actionsLength > 1;
const Duration(milliseconds: 350), });
() {
if (!mounted) {
return;
}
StreamChannel.of(context)
.channel
.keyStroke(widget.parentMessage?.id)
.catchError((e) {});
setState(() { _checkCommands(value, context);
_actionsShrunk = s.trim().isNotEmpty && _checkMentions(value, context);
((widget.actions?.length ?? 0) + _checkEmoji(value, context);
(widget.showCommandsButton ? 1 : 0) + },
(widget.disableAttachments ? 0 : 1) > const Duration(milliseconds: 350),
1); leading: true,
}); );
_commandsOverlay?.remove();
_commandsOverlay = null;
_mentionsOverlay?.remove();
_mentionsOverlay = null;
_emojiOverlay?.remove();
_emojiOverlay = null;
_checkCommands(s.trim(), context);
_checkMentions(s, context);
_checkEmoji(s, context);
},
);
}
String _getHint(BuildContext context) { String _getHint(BuildContext context) {
if (_commandEnabled && _chosenCommand!.name == 'giphy') { if (_commandEnabled && _chosenCommand!.name == 'giphy') {
@@ -847,10 +861,7 @@ class MessageInputState extends State<MessageInput> {
if (s.isNotEmpty && if (s.isNotEmpty &&
textEditingController.selection.baseOffset > 0 && textEditingController.selection.baseOffset > 0 &&
textEditingController.text textEditingController.text
.substring( .substring(0, textEditingController.selection.baseOffset)
0,
textEditingController.selection.baseOffset,
)
.contains(':')) { .contains(':')) {
final textToSelection = textEditingController.text final textToSelection = textEditingController.text
.substring(0, textEditingController.value.selection.start); .substring(0, textEditingController.value.selection.start);
@@ -860,12 +871,6 @@ class MessageInputState extends State<MessageInput> {
if (textToSelection.endsWith(':') && emoji != null) { if (textToSelection.endsWith(':') && emoji != null) {
_chooseEmoji(splits.sublist(0, splits.length - 1), emoji); _chooseEmoji(splits.sublist(0, splits.length - 1), emoji);
} else {
_emojiOverlay = _buildEmojiOverlay();
if (_emojiOverlay != null) {
Overlay.of(context)!.insert(_emojiOverlay!);
}
} }
} }
} }
@@ -878,166 +883,50 @@ class MessageInputState extends State<MessageInput> {
.split(' ') .split(' ')
.last .last
.contains('@')) { .contains('@')) {
_mentionsOverlay = _buildMentionsOverlayEntry(); if (!_showMentionsOverlay) {
if (_mentionsOverlay != null) { setState(() {
Overlay.of(context)!.insert(_mentionsOverlay!); _showMentionsOverlay = true;
});
} }
} else if (_showMentionsOverlay) {
setState(() {
_showMentionsOverlay = false;
});
} }
} }
void _checkCommands(String s, BuildContext context) { void _checkCommands(String s, BuildContext context) {
if (s.startsWith('/')) { if (s.startsWith('/')) {
final matchedCommandsList = StreamChannel.of(context) final allCommands = StreamChannel.of(context).channel.config?.commands;
.channel final command =
.config allCommands?.firstWhereOrNull((it) => it.name == s.substring(1));
?.commands if (command != null) {
.where((element) => element.name == s.substring(1)) return _setCommand(command);
.toList() ?? } else if (!_showCommandsOverlay) {
[];
if (matchedCommandsList.length == 1) {
_chosenCommand = matchedCommandsList[0];
textEditingController.clear();
setState(() { setState(() {
_commandEnabled = true; _showCommandsOverlay = true;
}); });
_commandsOverlay?.remove();
_commandsOverlay = null;
} else {
_commandsOverlay = _buildCommandsOverlayEntry();
if (_commandsOverlay != null) {
Overlay.of(context)!.insert(_commandsOverlay!);
}
} }
} else if (_showCommandsOverlay) {
setState(() {
_showCommandsOverlay = false;
});
} }
} }
OverlayEntry? _buildCommandsOverlayEntry() { Widget _buildCommandsOverlayEntry() {
final text = textEditingController.text.trimLeft(); final text = textEditingController.text.trimLeft();
final commands = StreamChannel.of(context)
.channel
.config
?.commands
.where((c) => c.name.contains(text.replaceFirst('/', '')))
.toList() ??
[];
if (commands.isEmpty) { final renderObject = context.findRenderObject() as RenderBox?;
return null; if (renderObject == null) {
return const Offstage();
} }
return CommandsOverlay(
// ignore: cast_nullable_to_non_nullable channel: StreamChannel.of(context).channel,
final renderBox = context.findRenderObject() as RenderBox; size: Size(renderObject.size.width - 16, 400),
final size = renderBox.size; text: text,
onCommandResult: _setCommand,
final child = Padding(
padding: const EdgeInsets.all(8),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
color: _streamChatTheme.colorTheme.barsBg,
clipBehavior: Clip.hardEdge,
child: Container(
constraints: BoxConstraints.loose(const Size.fromHeight(400)),
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.barsBg,
borderRadius: BorderRadius.circular(8)),
child: ListView(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
children: [
if (commands.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
),
child: StreamSvgIcon.lightning(
color: _streamChatTheme.colorTheme.accentPrimary,
),
),
Text(
context.translations.instantCommandsLabel,
style: TextStyle(
color: _streamChatTheme.colorTheme.textHighEmphasis
.withOpacity(.5),
),
)
],
),
),
const SizedBox(
height: 10,
),
...commands
.map(
(c) => InkWell(
onTap: () {
_setCommand(c);
},
child: SizedBox(
height: 40,
child: Row(
children: [
const SizedBox(
width: 16,
),
_buildCommandIcon(c.name),
const SizedBox(
width: 8,
),
Text.rich(
TextSpan(
text: c.name.capitalize(),
style: const TextStyle(
fontWeight: FontWeight.bold),
children: [
TextSpan(
text: ' /${c.name} ${c.args}',
style: _streamChatTheme.textTheme.body
.copyWith(
// ignore: lines_longer_than_80_chars
color: _streamChatTheme
// ignore: lines_longer_than_80_chars
.colorTheme
.textLowEmphasis,
),
),
],
),
),
],
),
),
),
)
.toList(),
],
),
),
),
); );
return OverlayEntry(
builder: (context) => Positioned(
bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutExpo,
builder: (context, val, child) => Transform.scale(
scale: val,
child: child,
),
child: child,
),
));
} }
Widget _buildFilePickerSection() { Widget _buildFilePickerSection() {
@@ -1087,7 +976,9 @@ class MessageInputState extends State<MessageInput> {
} }
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: _openFilePickerSection
? const Duration(milliseconds: 300)
: const Duration(),
curve: Curves.easeOut, curve: Curves.easeOut,
height: _openFilePickerSection ? _kMinMediaPickerSize : 0, height: _openFilePickerSection ? _kMinMediaPickerSize : 0,
child: SingleChildScrollView( child: SingleChildScrollView(
@@ -1261,300 +1152,78 @@ class MessageInputState extends State<MessageInput> {
}); });
} }
Widget _buildCommandIcon(String iconType) { Widget _buildMentionsOverlayEntry() {
switch (iconType) { if (textEditingController.value.selection.start < 0) {
case 'giphy': return const Offstage();
return CircleAvatar(
radius: 12,
child: StreamSvgIcon.giphyIcon(
size: 24,
),
);
case 'ban':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.iconUserDelete(
size: 16,
color: Colors.white,
),
);
case 'flag':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.flag(
size: 14,
color: Colors.white,
),
);
case 'imgur':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: ClipOval(
child: StreamSvgIcon.imgur(
size: 24,
),
),
);
case 'mute':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.mute(
size: 16,
color: Colors.white,
),
);
case 'unban':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.userAdd(
size: 16,
color: Colors.white,
),
);
case 'unmute':
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.volumeUp(
size: 16,
color: Colors.white,
),
);
default:
return CircleAvatar(
backgroundColor: _streamChatTheme.colorTheme.accentPrimary,
radius: 12,
child: StreamSvgIcon.lightning(
size: 16,
color: Colors.white,
),
);
} }
}
OverlayEntry? _buildMentionsOverlayEntry() {
final splits = textEditingController.text final splits = textEditingController.text
.substring(0, textEditingController.value.selection.start) .substring(0, textEditingController.value.selection.start)
.split('@'); .split('@');
final query = splits.last.toLowerCase(); final query = splits.last.toLowerCase();
Future<List<Member>>? queryMembers;
final channelState = StreamChannel.of(context);
if (query.isNotEmpty) {
queryMembers = channelState.channel
.queryMembers(filter: Filter.autoComplete('name', query))
.then((res) => res.members);
}
final members = channelState.channel.state?.members
.where((m) => m.user?.name.toLowerCase().contains(query) == true)
.toList() ??
[];
if (members.isEmpty) {
return null;
}
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
final renderBox = context.findRenderObject() as RenderBox; final renderObject = context.findRenderObject() as RenderBox;
final size = renderBox.size;
final child = Card(
margin: const EdgeInsets.all(8),
elevation: 2,
color: _streamChatTheme.colorTheme.barsBg,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.hardEdge,
child: Container(
constraints: BoxConstraints.loose(const Size.fromHeight(240)),
decoration: BoxDecoration(
color: _streamChatTheme.colorTheme.barsBg,
),
child: FutureBuilder<List<Member>>(
future: queryMembers ?? Future.value(members),
initialData: members,
builder: (context, snapshot) => ListView(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
children: [
const SizedBox(
height: 8,
),
...snapshot.data!
.where((it) => it.user != null)
.map(
(m) => Material(
color: _streamChatTheme.colorTheme.barsBg,
child: InkWell(
onTap: () {
if (m.user != null) {
_mentionedUsers.add(m.user!);
}
splits[splits.length - 1] = m.user!.name; var tileBuilder = widget.userMentionsTileBuilder;
final rejoin = splits.join('@'); if (tileBuilder == null && widget.mentionsTileBuilder != null) {
tileBuilder = (context, user) {
final member = Member(
user: user,
userId: user.id,
createdAt: user.createdAt,
updatedAt: user.updatedAt,
);
return widget.mentionsTileBuilder!(context, member);
};
}
textEditingController.value = TextEditingValue( return UserMentionsOverlay(
text: rejoin + query: query,
textEditingController.text.substring( mentionAllAppUsers: widget.mentionAllAppUsers,
textEditingController.selection.start), client: StreamChat.of(context).client,
selection: TextSelection.collapsed( channel: StreamChannel.of(context).channel,
offset: rejoin.length, size: Size(renderObject.size.width - 16, 400),
), mentionsTileBuilder: tileBuilder,
); onMentionUserTap: (user) {
_debounce!.cancel(); _mentionedUsers.add(user);
_mentionsOverlay?.remove(); splits[splits.length - 1] = user.name;
_mentionsOverlay = null; final rejoin = splits.join('@');
},
child: widget.mentionsTileBuilder != null textEditingController.value = TextEditingValue(
? widget.mentionsTileBuilder!(context, m) text: rejoin +
: MentionTile(m), textEditingController.text
), .substring(textEditingController.selection.start),
), selection: TextSelection.collapsed(
) offset: rejoin.length,
.toList(),
const SizedBox(
height: 8,
),
],
), ),
), );
), _onChangedDebounced.cancel();
); setState(() => _showMentionsOverlay = false);
return OverlayEntry( },
builder: (context) => Positioned(
bottom: size.height + MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOutExpo,
builder: (context, val, child) => Transform.scale(
scale: val,
child: child,
),
child: child,
),
),
); );
} }
OverlayEntry? _buildEmojiOverlay() { Widget _buildEmojiOverlay() {
if (textEditingController.value.selection.baseOffset < 0) {
return const Offstage();
}
final splits = textEditingController.text final splits = textEditingController.text
.substring(0, textEditingController.value.selection.start) .substring(0, textEditingController.value.selection.baseOffset)
.split(':'); .split(':');
final query = splits.last.toLowerCase(); final query = splits.last.toLowerCase();
if (query.isEmpty) {
return null;
}
final emojis = _emojiNames
.where((e) => e.contains(query))
.map(Emoji.byName)
.where((e) => e != null);
if (emojis.isEmpty) {
return null;
}
// ignore: cast_nullable_to_non_nullable // ignore: cast_nullable_to_non_nullable
final renderBox = context.findRenderObject() as RenderBox; final renderObject = context.findRenderObject() as RenderBox;
final size = renderBox.size;
return OverlayEntry( return EmojiOverlay(
builder: (context) => Positioned( size: Size(renderObject.size.width - 16, 200),
bottom: size.height + MediaQuery.of(context).viewInsets.bottom, query: query,
left: 0, onEmojiResult: (emoji) {
right: 0, _chooseEmoji(splits, emoji);
child: Card( },
margin: const EdgeInsets.all(8), );
elevation: 2,
color: _streamChatTheme.colorTheme.barsBg,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.hardEdge,
child: Container(
constraints: BoxConstraints.loose(const Size.fromHeight(200)),
decoration: BoxDecoration(
boxShadow: const [
BoxShadow(
spreadRadius: -8,
blurRadius: 5,
offset: Offset(0, -4),
),
],
color: _streamChatTheme.colorTheme.barsBg,
),
child: ListView.builder(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
itemCount: emojis.length + 1,
itemBuilder: (context, i) {
if (i == 0) {
return Padding(
padding: const EdgeInsets.only(left: 8, top: 8),
child: Row(
children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8),
child: StreamSvgIcon.smile(
color: _streamChatTheme
.colorTheme.accentPrimary,
),
),
Flexible(
child: Text(
context.translations.emojiMatchingQueryText(
query,
),
style: TextStyle(
color: _streamChatTheme
.colorTheme.textHighEmphasis
.withOpacity(.5),
),
),
)
],
),
);
}
final emoji = emojis.elementAt(i - 1)!;
final themeData = Theme.of(context);
return ListTile(
title: SubstringHighlight(
text:
// ignore: lines_longer_than_80_chars
"${emoji.char} ${emoji.name!.replaceAll('_', ' ')}",
term: query,
textStyleHighlight:
themeData.textTheme.headline6!.copyWith(
fontSize: 14.5,
fontWeight: FontWeight.bold,
),
textStyle: themeData.textTheme.headline6!.copyWith(
fontSize: 14.5,
),
),
onTap: () {
_chooseEmoji(splits, emoji);
},
);
}),
),
),
));
} }
void _chooseEmoji(List<String> splits, Emoji emoji) { void _chooseEmoji(List<String> splits, Emoji emoji) {
@@ -1568,9 +1237,6 @@ class MessageInputState extends State<MessageInput> {
offset: rejoin.length, offset: rejoin.length,
), ),
); );
_emojiOverlay?.remove();
_emojiOverlay = null;
} }
void _setCommand(Command c) { void _setCommand(Command c) {
@@ -1578,9 +1244,8 @@ class MessageInputState extends State<MessageInput> {
setState(() { setState(() {
_chosenCommand = c; _chosenCommand = c;
_commandEnabled = true; _commandEnabled = true;
_showCommandsOverlay = false;
}); });
_commandsOverlay?.remove();
_commandsOverlay = null;
} }
Widget _buildReplyToMessage() { Widget _buildReplyToMessage() {
@@ -1773,7 +1438,7 @@ class MessageInputState extends State<MessageInput> {
icon: StreamSvgIcon.lightning( icon: StreamSvgIcon.lightning(
color: s.isNotEmpty color: s.isNotEmpty
? _streamChatTheme.colorTheme.disabled ? _streamChatTheme.colorTheme.disabled
: (_commandsOverlay != null : (_showCommandsOverlay
? _messageInputTheme.actionButtonColor ? _messageInputTheme.actionButtonColor
: _messageInputTheme.actionButtonIdleColor), : _messageInputTheme.actionButtonIdleColor),
), ),
@@ -1789,19 +1454,9 @@ class MessageInputState extends State<MessageInput> {
await Future.delayed(const Duration(milliseconds: 300)); await Future.delayed(const Duration(milliseconds: 300));
} }
if (_commandsOverlay == null) { setState(() {
setState(() { _showCommandsOverlay = !_showCommandsOverlay;
_commandsOverlay = _buildCommandsOverlayEntry(); });
if (_commandsOverlay != null) {
Overlay.of(context)!.insert(_commandsOverlay!);
}
});
} else {
setState(() {
_commandsOverlay?.remove();
_commandsOverlay = null;
});
}
}, },
); );
@@ -1823,12 +1478,8 @@ class MessageInputState extends State<MessageInput> {
), ),
splashRadius: 24, splashRadius: 24,
onPressed: () async { onPressed: () async {
_emojiOverlay?.remove(); _showCommandsOverlay = false;
_emojiOverlay = null; _showMentionsOverlay = false;
_commandsOverlay?.remove();
_commandsOverlay = null;
_mentionsOverlay?.remove();
_mentionsOverlay = null;
if (_openFilePickerSection) { if (_openFilePickerSection) {
setState(() => _openFilePickerSection = false); setState(() => _openFilePickerSection = false);
@@ -2114,11 +1765,6 @@ class MessageInputState extends State<MessageInput> {
_commandEnabled = false; _commandEnabled = false;
}); });
_commandsOverlay?.remove();
_commandsOverlay = null;
_mentionsOverlay?.remove();
_mentionsOverlay = null;
Message message; Message message;
if (widget.editMessage != null) { if (widget.editMessage != null) {
message = widget.editMessage!.copyWith( message = widget.editMessage!.copyWith(
@@ -2185,8 +1831,6 @@ class MessageInputState extends State<MessageInput> {
} }
} }
StreamSubscription? _keyboardListener;
void _showErrorAlert(String description) { void _showErrorAlert(String description) {
showModalBottomSheet( showModalBottomSheet(
backgroundColor: _streamChatTheme.colorTheme.barsBg, backgroundColor: _streamChatTheme.colorTheme.barsBg,
@@ -2260,12 +1904,9 @@ class MessageInputState extends State<MessageInput> {
@override @override
void dispose() { void dispose() {
_commandsOverlay?.remove();
_emojiOverlay?.remove();
_mentionsOverlay?.remove();
_keyboardListener?.cancel();
textEditingController.dispose(); textEditingController.dispose();
_stopSlowMode(); _stopSlowMode();
_onChangedDebounced.cancel();
super.dispose(); super.dispose();
} }
@@ -580,11 +580,12 @@ class _MessageWidgetState extends State<MessageWidget>
bool get isOnlyEmoji => widget.message.text?.isOnlyEmoji == true; bool get isOnlyEmoji => widget.message.text?.isOnlyEmoji == true;
bool get hasNonUrlAttachments => bool get hasNonUrlAttachments => widget.message.attachments
widget.message.attachments.where((it) => it.titleLink == null).isNotEmpty; .where((it) => it.titleLink == null || it.type == 'giphy')
.isNotEmpty;
bool get hasUrlAttachments => bool get hasUrlAttachments => widget.message.attachments
widget.message.attachments.any((it) => it.titleLink != null) == true; .any((it) => it.titleLink != null && it.type != 'giphy');
bool get showBottomRow => bool get showBottomRow =>
showThreadReplyIndicator || showThreadReplyIndicator ||
@@ -1156,7 +1157,9 @@ class _MessageWidgetState extends State<MessageWidget>
final attachmentGroups = <String, List<Attachment>>{}; final attachmentGroups = <String, List<Attachment>>{};
widget.message.attachments widget.message.attachments
.where((element) => element.titleLink == null && element.type != null) .where((element) =>
(element.titleLink == null && element.type != null) ||
element.type == 'giphy')
.forEach((e) { .forEach((e) {
if (attachmentGroups[e.type] == null) { if (attachmentGroups[e.type] == null) {
attachmentGroups[e.type!] = []; attachmentGroups[e.type!] = [];
@@ -0,0 +1,63 @@
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_portal/flutter_portal.dart';
/// Class that contains the parameters for building an overlay entry
class OverlayOptions {
/// Constructs a new overlay options object
/// [visible] - the visibility of the overlay
/// [widget] - the widget to be displayed
OverlayOptions({
required this.visible,
required this.widget,
});
/// the visibility of the overlay
final bool visible;
/// the widget to be displayed
final Widget widget;
}
/// Widget that renders a single overlay widget from a list of [overlayOptions]
/// It shows the first one that is visible
class MultiOverlay extends StatelessWidget {
/// Constructs a new MultiOverlay widget
/// [overlayOptions] - the list of overlay options
/// [overlayAnchor] - the anchor relative to the overlay
/// [childAnchor] - the anchor relative to the child
/// [child] - the child widget
const MultiOverlay({
Key? key,
required this.overlayOptions,
required this.child,
required this.overlayAnchor,
required this.childAnchor,
}) : super(key: key);
/// The list of overlay options
final List<OverlayOptions> overlayOptions;
/// The child widget
final Widget child;
/// The anchor relative to the overlay
final Alignment? overlayAnchor;
/// The anchor relative to the child
final Alignment? childAnchor;
@override
Widget build(BuildContext context) {
final visibleOverlay =
overlayOptions.firstWhereOrNull((element) => element.visible);
return PortalEntry(
childAnchor: childAnchor,
portalAnchor: overlayAnchor,
visible: visibleOverlay != null,
portal: visibleOverlay?.widget,
child: child,
);
}
}
@@ -66,6 +66,12 @@ class UserAvatar extends StatelessWidget {
final placeholder = final placeholder =
this.placeholder ?? streamChatTheme.placeholderUserImage; this.placeholder ?? streamChatTheme.placeholderUserImage;
final backupGradientAvatar = ClipRRect(
borderRadius: borderRadius ??
streamChatTheme.ownMessageTheme.avatarTheme?.borderRadius,
child: streamChatTheme.defaultUserImage(context, user),
);
Widget avatar = FittedBox( Widget avatar = FittedBox(
fit: BoxFit.cover, fit: BoxFit.cover,
child: Container( child: Container(
@@ -76,8 +82,7 @@ class UserAvatar extends StatelessWidget {
fit: BoxFit.cover, fit: BoxFit.cover,
filterQuality: FilterQuality.high, filterQuality: FilterQuality.high,
imageUrl: user.image!, imageUrl: user.image!,
errorWidget: (context, __, ___) => errorWidget: (context, __, ___) => backupGradientAvatar,
streamChatTheme.defaultUserImage(context, user),
placeholder: placeholder != null placeholder: placeholder != null
? (context, __) => placeholder(context, user) ? (context, __) => placeholder(context, user)
: null, : null,
@@ -91,11 +96,7 @@ class UserAvatar extends StatelessWidget {
), ),
), ),
) )
: ClipRRect( : backupGradientAvatar,
borderRadius: borderRadius ??
streamChatTheme.ownMessageTheme.avatarTheme?.borderRadius,
child: streamChatTheme.defaultUserImage(context, user),
),
), ),
); );
@@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// This widget is used for showing user tiles for mentions
/// Use [title], [subtitle], [leading], [trailing] for
/// substituting widgets in respective positions
class UserMentionTile extends StatelessWidget {
/// Constructor for creating a [UserMentionTile] widget
const UserMentionTile(
this.user, {
Key? key,
this.title,
this.subtitle,
this.leading,
this.trailing,
}) : super(key: key);
/// User to display in the tile
final User user;
/// Widget to display as title
final Widget? title;
/// Widget to display below [title]
final Widget? subtitle;
/// Widget at the start of the tile
final Widget? leading;
/// Widget at the end of tile
final Widget? trailing;
@override
Widget build(BuildContext context) {
final chatThemeData = StreamChatTheme.of(context);
return SizedBox(
height: 56,
child: Row(
children: [
const SizedBox(
width: 16,
),
leading ??
UserAvatar(
user: user,
constraints: BoxConstraints.tight(const Size(40, 40)),
),
const SizedBox(width: 8),
Expanded(
child: Align(
alignment: Alignment.centerLeft,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title ??
Text(
user.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: chatThemeData.textTheme.bodyBold,
),
const SizedBox(height: 2),
subtitle ??
Text(
'@${user.id}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: chatThemeData.textTheme.footnoteBold.copyWith(
color: chatThemeData.colorTheme.textLowEmphasis,
),
),
],
),
),
),
trailing ??
Padding(
padding: const EdgeInsets.only(
right: 18,
left: 8,
),
child: StreamSvgIcon.mentions(
color: chatThemeData.colorTheme.accentPrimary,
),
),
],
),
);
}
}
@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/stream_chat_theme.dart';
import 'package:stream_chat_flutter/src/user_mention_tile.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// Builder function for building a mention tile.
///
/// Use [UserMentionTile] for the default implementation.
typedef MentionTileBuilder = Widget Function(
BuildContext context,
User user,
);
/// Overlay for displaying users that can be mentioned.
class UserMentionsOverlay extends StatefulWidget {
/// Constructor for creating a [UserMentionsOverlay].
UserMentionsOverlay({
Key? key,
required this.query,
required this.channel,
required this.size,
this.client,
this.limit = 10,
this.mentionAllAppUsers = false,
this.mentionsTileBuilder,
this.onMentionUserTap,
}) : assert(
channel.state != null,
'Channel ${channel.cid} is not yet initialized',
),
assert(
!mentionAllAppUsers || (mentionAllAppUsers && client != null),
'StreamChatClient is required in order to use mentionAllAppUsers',
),
super(key: key);
/// Query for searching users.
final String query;
/// Limit applied on user search results.
final int limit;
/// The size of the overlay.
final Size size;
/// The channel to search for users.
final Channel channel;
/// The client to search for users in case [mentionAllAppUsers] is True.
final StreamChatClient? client;
/// When enabled mentions search users across the entire app.
///
/// Defaults to false.
final bool mentionAllAppUsers;
/// Customize the tile for the mentions overlay.
final MentionTileBuilder? mentionsTileBuilder;
/// Callback called when a user is selected.
final void Function(User user)? onMentionUserTap;
@override
_UserMentionsOverlayState createState() => _UserMentionsOverlayState();
}
class _UserMentionsOverlayState extends State<UserMentionsOverlay> {
late Future<List<User>> userMentionsFuture;
@override
void initState() {
super.initState();
userMentionsFuture = queryMentions(widget.query);
}
@override
void didUpdateWidget(covariant UserMentionsOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.channel != oldWidget.channel ||
widget.query != oldWidget.query ||
widget.mentionAllAppUsers != oldWidget.mentionAllAppUsers ||
widget.limit != oldWidget.limit) {
userMentionsFuture = queryMentions(widget.query);
}
}
@override
Widget build(BuildContext context) {
final theme = StreamChatTheme.of(context);
return Card(
margin: const EdgeInsets.all(8),
elevation: 2,
color: theme.colorTheme.barsBg,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.hardEdge,
child: Container(
constraints: BoxConstraints.loose(widget.size),
decoration: BoxDecoration(color: theme.colorTheme.barsBg),
child: FutureBuilder<List<User>>(
future: userMentionsFuture,
builder: (context, snapshot) {
if (snapshot.hasError) return const Offstage();
if (!snapshot.hasData) return const Offstage();
final users = snapshot.data!;
return ListView.builder(
padding: const EdgeInsets.all(0),
shrinkWrap: true,
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return Material(
color: theme.colorTheme.barsBg,
child: InkWell(
onTap: () => widget.onMentionUserTap?.call(user),
child: widget.mentionsTileBuilder?.call(context, user) ??
UserMentionTile(user),
),
);
},
);
},
),
),
);
}
List<User> get membersAndWatchers {
final state = widget.channel.state!;
return {
...state.watchers,
...state.members.map((it) => it.user),
}.whereType<User>().toList(growable: false);
}
Future<List<User>> queryMentions(String query) async {
if (widget.mentionAllAppUsers) {
return _queryUsers(query);
}
var channelState = widget.channel.state;
channelState = channelState!;
final members = channelState.members;
// By default, we return maximum 100 members via queryChannels api call.
// Thus it is safe to assume, that if number of members in channel.state
// is < 100, then all the members are already available on client side
// and we don't need to make any api call to queryMembers endpoint.
if (members.length < 100) {
final matchingUsers = membersAndWatchers.search(query);
return matchingUsers.toList(growable: false);
}
final result = await _queryMembers(query);
return result
.map((it) => it.user)
.whereType<User>()
.toList(growable: false);
}
Future<List<Member>> _queryMembers(String query) async {
final response = await widget.channel.queryMembers(
pagination: PaginationParams(limit: widget.limit),
filter: query.isEmpty
? const Filter.empty()
: Filter.autoComplete('name', query),
);
return response.members;
}
Future<List<User>> _queryUsers(String query) async {
assert(
widget.client != null,
'StreamChatClient is required in order to query all app users',
);
final response = await widget.client!.queryUsers(
pagination: PaginationParams(limit: widget.limit),
filter: query.isEmpty
? const Filter.empty()
: Filter.or([
Filter.autoComplete('id', query),
Filter.autoComplete('name', query),
]),
sort: [const SortOption('id', direction: SortOption.ASC)],
);
return response.users;
}
}
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart'; import 'package:stream_chat_flutter/src/extension.dart';
@@ -388,3 +389,39 @@ class Tuple2<T1, T2> {
@override @override
int get hashCode => item1.hashCode ^ item2.hashCode; int get hashCode => item1.hashCode ^ item2.hashCode;
} }
/// Levenshtein algorithm implementation based on:
/// http://en.wikipedia.org/wiki/Levenshtein_distance#Iterative_with_two_matrix_rows
int levenshtein(String s, String t, {bool caseSensitive = true}) {
if (!caseSensitive) {
// ignore: parameter_assignments
s = s.toLowerCase();
// ignore: parameter_assignments
t = t.toLowerCase();
}
if (s == t) return 0;
if (s.isEmpty) return t.length;
if (t.isEmpty) return s.length;
final v0 = List<int>.filled(t.length + 1, 0);
final v1 = List<int>.filled(t.length + 1, 0);
for (var i = 0; i < t.length + 1; i < i++) {
v0[i] = i;
}
for (var i = 0; i < s.length; i++) {
v1[0] = i + 1;
for (var j = 0; j < t.length; j++) {
final cost = (s[i] == t[j]) ? 0 : 1;
v1[j + 1] = math.min(v1[j] + 1, math.min(v0[j + 1] + 1, v0[j] + cost));
}
for (var j = 0; j < t.length + 1; j++) {
v0[j] = v1[j];
}
}
return v1[t.length];
}
@@ -44,5 +44,6 @@ export 'src/unread_indicator.dart';
export 'src/user_avatar.dart'; export 'src/user_avatar.dart';
export 'src/user_item.dart'; export 'src/user_item.dart';
export 'src/user_list_view.dart'; export 'src/user_list_view.dart';
export 'src/user_mention_tile.dart';
export 'src/utils.dart'; export 'src/utils.dart';
export 'src/visible_footnote.dart'; export 'src/visible_footnote.dart';
+1 -1
View File
@@ -14,12 +14,12 @@ dependencies:
characters: ^1.1.0 characters: ^1.1.0
chewie: ^1.2.0 chewie: ^1.2.0
collection: ^1.15.0 collection: ^1.15.0
diacritic: ^0.1.3
dio: ^4.0.0 dio: ^4.0.0
ezanimation: ^0.5.0 ezanimation: ^0.5.0
file_picker: ^3.0.1 file_picker: ^3.0.1
flutter: flutter:
sdk: flutter sdk: flutter
flutter_keyboard_visibility: ^5.0.1
flutter_markdown: ^0.6.1 flutter_markdown: ^0.6.1
flutter_portal: ^0.4.0 flutter_portal: ^0.4.0
flutter_slidable: ^0.6.0 flutter_slidable: ^0.6.0
@@ -0,0 +1,70 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
void main() {
group('List<User>.search', () {
test('should work fine', () {
final tommaso = User(id: 'tommaso', name: 'Tommaso');
final thierry = User(id: 'thierry', name: 'Thierry');
final users = [tommaso, thierry];
expect(users.search('Tom'), [tommaso]);
expect(users.search('Thier'), [thierry]);
});
test('should search using UpperCased', () {
final tommaso = User(id: 'tommaso', name: 'Tommaso');
final thierry = User(id: 'thierry', name: 'Thierry');
final users = [tommaso, thierry];
expect(users.search('tom'), [tommaso]);
expect(users.search('thier'), [thierry]);
});
test('should search by .id or .name', () {
final user1 = User(id: 'searchingThis');
final user2 = User(id: 'x', name: 'searchingThis');
expect([user1].search('sear'), [user1]);
expect([user2].search('sear'), [user2]);
});
test('should search transliterated', () {
final tommaso = User(id: 'tommaso', name: 'Tommaso');
final thierry = User(id: 'thierry', name: 'Thierry');
final users = [tommaso, thierry];
expect(users.search('tóm'), [tommaso]);
expect(users.search('thíer'), [thierry]);
});
test('search and sorted by distance', () {
final tommaso = User(id: 'tommaso', name: 'Tommaso');
final tomas = User(id: 'tomas', name: 'Tomas');
final users = [tommaso, tomas];
expect(users.search('tom'), [tomas, tommaso]);
});
test('should work fine with cyrillic diacritics', () {
final petyo = User(id: '42', name: 'Петьо');
final anastasia = User(id: '13', name: 'Анастасiя');
final dmitriy = User(id: '99', name: 'Дмитрий');
final users = [petyo, anastasia, dmitriy];
expect(users.search('petyo'), []);
expect(users.search('Пе'), [petyo]);
expect(users.search('Ана'), [anastasia]);
expect(users.search('Дмитри'), [dmitriy]);
expect(users.search('Дмитрии'), [dmitriy]);
});
test('should work fine with french diacritics', () {
final user = User(id: 'fra', name: 'françois');
expect([user].search('françois'), [user]);
expect([user].search('franc'), [user]);
});
});
}
@@ -263,9 +263,11 @@ void main() {
final client = MockClient(); final client = MockClient();
final clientState = MockClientState(); final clientState = MockClientState();
final channel = MockChannel(); final channel = MockChannel();
final channelState = MockChannelState();
when(() => client.state).thenReturn(clientState); when(() => client.state).thenReturn(clientState);
when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id')); when(() => clientState.currentUser).thenReturn(OwnUser(id: 'user-id'));
when(() => channel.state).thenReturn(channelState);
final themeData = ThemeData(); final themeData = ThemeData();
final streamTheme = StreamChatThemeData.fromTheme(themeData); final streamTheme = StreamChatThemeData.fromTheme(themeData);
@@ -152,13 +152,9 @@ class StreamChannelState extends State<StreamChannel> {
int limit = 20, int limit = 20,
}) { }) {
if (direction == QueryDirection.top) { if (direction == QueryDirection.top) {
return _queryTopMessages( return _queryTopMessages(limit: limit);
limit: limit,
);
} }
return _queryBottomMessages( return _queryBottomMessages(limit: limit);
limit: limit,
);
} }
/// Calls [channel.getReplies] updating [queryMessage] stream /// Calls [channel.getReplies] updating [queryMessage] stream
@@ -311,6 +307,20 @@ class StreamChannelState extends State<StreamChannel> {
return message; return message;
} }
/// Query channel members.
Future<List<Member>> queryMembers({
Filter? filter,
List<SortOption>? sort,
PaginationParams? pagination,
}) async {
final response = await channel.queryMembers(
filter: filter,
sort: sort,
pagination: pagination,
);
return response.members;
}
/// Reloads the channel with latest message /// Reloads the channel with latest message
Future<void> reloadChannel() => _queryAtMessage(before: 30); Future<void> reloadChannel() => _queryAtMessage(before: 30);