Merge pull request #707 from GetStream/feat/improve-mentions
feat(ui): improve mentions autocomplete and local-search experience
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
|
|
||||||
|
|
||||||
/// Overlay for displaying users that can be mentioned
|
|
||||||
class MentionsOverlay extends StatelessWidget {
|
|
||||||
/// Constructor for creating a [MentionsOverlay]
|
|
||||||
const MentionsOverlay({
|
|
||||||
required this.query,
|
|
||||||
required this.onMentionResult,
|
|
||||||
required this.size,
|
|
||||||
required this.channel,
|
|
||||||
this.mentionsTileBuilder,
|
|
||||||
Key? key,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
/// The size of the overlay
|
|
||||||
final Size size;
|
|
||||||
|
|
||||||
/// Query for searching users
|
|
||||||
final String query;
|
|
||||||
|
|
||||||
/// The channel to search for users
|
|
||||||
final Channel channel;
|
|
||||||
|
|
||||||
/// Callback called when a user is selected
|
|
||||||
final ValueChanged<User?> onMentionResult;
|
|
||||||
|
|
||||||
/// Customize the tile for the mentions overlay
|
|
||||||
final MentionTileBuilder? mentionsTileBuilder;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final _streamChatTheme = StreamChatTheme.of(context);
|
|
||||||
|
|
||||||
Future<List<Member>>? queryMembers;
|
|
||||||
|
|
||||||
if (query.isNotEmpty) {
|
|
||||||
queryMembers = channel
|
|
||||||
.queryMembers(filter: Filter.autoComplete('name', query))
|
|
||||||
.then((res) => res.members);
|
|
||||||
}
|
|
||||||
|
|
||||||
final members = channel.state?.members
|
|
||||||
.where((m) => m.user?.name.toLowerCase().contains(query) == true)
|
|
||||||
.toList() ??
|
|
||||||
[];
|
|
||||||
|
|
||||||
if (members.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(
|
|
||||||
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: () {
|
|
||||||
onMentionResult(m.user);
|
|
||||||
},
|
|
||||||
child: mentionsTileBuilder != null
|
|
||||||
? mentionsTileBuilder!(context, m)
|
|
||||||
: MentionTile(m, key: ValueKey(m.user?.id)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
const SizedBox(
|
|
||||||
height: 8,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,7 @@ import 'package:stream_chat_flutter/src/emoji/emoji.dart';
|
|||||||
import 'package:stream_chat_flutter/src/emoji_overlay.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/mentions_overlay.dart';
|
import 'package:stream_chat_flutter/src/user_mentions_overlay.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/overlays.dart';
|
||||||
import 'package:stream_chat_flutter/src/quoted_message_widget.dart';
|
import 'package:stream_chat_flutter/src/quoted_message_widget.dart';
|
||||||
@@ -194,6 +194,7 @@ class MessageInput extends StatefulWidget {
|
|||||||
this.attachmentButtonBuilder,
|
this.attachmentButtonBuilder,
|
||||||
this.commandButtonBuilder,
|
this.commandButtonBuilder,
|
||||||
this.customOverlays = const [],
|
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`",
|
||||||
@@ -304,6 +305,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();
|
||||||
|
|
||||||
@@ -1147,16 +1153,15 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
final renderObject = context.findRenderObject() as RenderBox;
|
final renderObject = context.findRenderObject() as RenderBox;
|
||||||
|
|
||||||
return MentionsOverlay(
|
return UserMentionsOverlay(
|
||||||
|
query: query,
|
||||||
|
mentionAllAppUsers: widget.mentionAllAppUsers,
|
||||||
|
client: StreamChat.of(context).client,
|
||||||
channel: StreamChannel.of(context).channel,
|
channel: StreamChannel.of(context).channel,
|
||||||
size: Size(renderObject.size.width - 16, 400),
|
size: Size(renderObject.size.width - 16, 400),
|
||||||
query: query,
|
onMentionUserTap: (user) {
|
||||||
onMentionResult: (user) {
|
_mentionedUsers.add(user);
|
||||||
if (user != null) {
|
splits[splits.length - 1] = user.name;
|
||||||
_mentionedUsers.add(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
splits[splits.length - 1] = user!.name;
|
|
||||||
final rejoin = splits.join('@');
|
final rejoin = splits.join('@');
|
||||||
|
|
||||||
textEditingController.value = TextEditingValue(
|
textEditingController.value = TextEditingValue(
|
||||||
@@ -1168,9 +1173,7 @@ class MessageInputState extends State<MessageInput> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
_onChangedDebounced.cancel();
|
_onChangedDebounced.cancel();
|
||||||
setState(() {
|
setState(() => _showMentionsOverlay = false);
|
||||||
_showMentionsOverlay = false;
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +1,11 @@
|
|||||||
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/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:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:stream_chat_flutter/src/extension.dart';
|
|
||||||
|
|
||||||
/// Launch URL
|
/// Launch URL
|
||||||
Future<void> launchURL(BuildContext context, String url) async {
|
Future<void> launchURL(BuildContext context, String url) async {
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
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];
|
||||||
|
|
||||||
|
final a = users.search('Tom');
|
||||||
|
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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user