feat(ui, core): add remaining v4 list-views

Signed-off-by: xsahil03x <[email protected]>
This commit is contained in:
Sahil Kumar
2022-04-19 12:38:21 +05:30
committed by xsahil03x
parent 2323c59ed4
commit f074bccdc5
20 changed files with 2227 additions and 259 deletions
@@ -115,9 +115,11 @@ class _ChannelListPageState extends State<ChannelListPage> {
Widget _channelPreviewBuilder(
BuildContext context,
Channel channel,
List<Channel> channels,
int index,
StreamChannelListTile defaultTile,
) {
final channel = channels[index];
final lastMessage = channel.state?.messages.reversed.firstWhereOrNull(
(message) => !message.isDeleted,
);
@@ -2,23 +2,20 @@ import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// {@macro message_search_item}
@Deprecated("Use 'StreamMessageSearchItem' instead")
typedef MessageSearchItem = StreamMessageSearchItem;
/// {@template message_search_item}
/// It shows the current [Message] preview.
///
/// Usually you don't use this widget as it's the default item used by
/// [StreamMessageSearchListView].
/// [MessageSearchListView].
///
/// The widget renders the ui based on the first ancestor of type
/// [StreamChatTheme].
/// Modify it to change the widget appearance.
/// {@endtemplate}
class StreamMessageSearchItem extends StatelessWidget {
@Deprecated("Use 'StreamMessageSearchItem' instead")
class MessageSearchItem extends StatelessWidget {
/// Instantiate a new MessageSearchItem
const StreamMessageSearchItem({
const MessageSearchItem({
Key? key,
required this.getMessageResponse,
this.onTap,
@@ -31,7 +28,7 @@ class StreamMessageSearchItem extends StatelessWidget {
/// Function called when tapping this widget
final VoidCallback? onTap;
/// If true the [StreamMessageSearchItem] will show the current online Status
/// If true the [MessageSearchItem] will show the current online Status
final bool showOnlineStatus;
@override
@@ -11,16 +11,12 @@ typedef MessageSearchItemBuilder = Widget Function(
GetMessageResponse,
);
/// Builder used when [StreamMessageSearchListView] is empty
/// Builder used when [MessageSearchListView] is empty
typedef EmptyMessageSearchBuilder = Widget Function(
BuildContext context,
String searchQuery,
);
/// {@macro message_search_list_view}
@Deprecated("Use 'StreamMessageSearchListView' instead")
typedef MessageSearchListView = StreamMessageSearchListView;
/// {@template message_search_list_view}
/// It shows the list of searched messages.
///
@@ -52,9 +48,10 @@ typedef MessageSearchListView = StreamMessageSearchListView;
/// [StreamChatTheme].
/// Modify it to change the widget appearance.
/// {@endtemplate}
class StreamMessageSearchListView extends StatefulWidget {
@Deprecated("Use 'StreamMessageSearchListView' instead")
class MessageSearchListView extends StatefulWidget {
/// Instantiate a new MessageSearchListView
const StreamMessageSearchListView({
const MessageSearchListView({
Key? key,
required this.filters,
this.messageQuery,
@@ -101,7 +98,7 @@ class StreamMessageSearchListView extends StatefulWidget {
/// Builder used to create a custom item preview
final MessageSearchItemBuilder? itemBuilder;
/// Function called when tapping on a [StreamMessageSearchItem]
/// Function called when tapping on a [MessageSearchItem]
final MessageSearchItemTapCallback? onItemTap;
/// Builder used to create a custom item separator
@@ -135,12 +132,10 @@ class StreamMessageSearchListView extends StatefulWidget {
final MessageSearchListController? messageSearchListController;
@override
_StreamMessageSearchListViewState createState() =>
_StreamMessageSearchListViewState();
_MessageSearchListViewState createState() => _MessageSearchListViewState();
}
class _StreamMessageSearchListViewState
extends State<StreamMessageSearchListView> {
class _MessageSearchListViewState extends State<MessageSearchListView> {
late final _defaultController = MessageSearchListController();
MessageSearchListController get _messageSearchListController =>
@@ -226,7 +221,7 @@ class _StreamMessageSearchListViewState
if (widget.itemBuilder != null) {
return widget.itemBuilder!(context, getMessageResponse);
}
return StreamMessageSearchItem(
return MessageSearchItem(
getMessageResponse: getMessageResponse,
onTap: () => widget.onItemTap!(getMessageResponse),
);
@@ -414,7 +414,7 @@ class StreamChatThemeData {
/// Theme configuration for the [StreamUserListView] widget.
final StreamUserListViewThemeData userListViewTheme;
/// Theme configuration for the [StreamMessageSearchListView] widget.
/// Theme configuration for the [MessageSearchListView] widget.
final StreamMessageSearchListViewThemeData messageSearchListViewTheme;
/// Creates a copy of [StreamChatThemeData] with specified attributes
@@ -8,10 +8,6 @@ typedef UserTapCallback = void Function(User, Widget?);
/// Builder used to create a custom [ListUserItem] from a [User]
typedef UserItemBuilder = Widget Function(BuildContext, User, bool);
/// {@macro user_list_view}
@Deprecated("Use 'StreamUserListView' instead")
typedef UserListView = StreamUserListView;
/// {@template user_list_view}
/// It shows the list of current users.
///
@@ -47,9 +43,10 @@ typedef UserListView = StreamUserListView;
/// type [StreamChatTheme].
/// Modify it to change the widget appearance.
/// {@endtemplate}
class StreamUserListView extends StatefulWidget {
@Deprecated("Use 'StreamUserListView' instead")
class UserListView extends StatefulWidget {
/// Instantiate a new UserListView
StreamUserListView({
UserListView({
Key? key,
this.filter = const Filter.empty(),
this.sort,
@@ -165,10 +162,10 @@ class StreamUserListView extends StatefulWidget {
final UserListController? userListController;
@override
_StreamUserListViewState createState() => _StreamUserListViewState();
_UserListViewState createState() => _UserListViewState();
}
class _StreamUserListViewState extends State<StreamUserListView>
class _UserListViewState extends State<UserListView>
with WidgetsBindingObserver {
bool get _isListView => widget.crossAxisCount == 1;
@@ -1,6 +1,7 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/v4/stream_message_preview_text.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// A widget that displays a channel preview.
@@ -351,95 +352,11 @@ class ChannelLastMessageText extends StatelessWidget {
if (lastMessage == null) return const Offstage();
final lastMessageText = lastMessage
.translate(channel.client.state.currentUser?.language ?? 'en')
.replaceMentions(linkify: false)
.text;
final lastMessageAttachments = lastMessage.attachments;
final lastMessageMentionedUsers = lastMessage.mentionedUsers;
final mentionedUsersRegex = RegExp(
lastMessageMentionedUsers.map((it) => '@${it.name}').join('|'),
caseSensitive: false,
);
final messageTextParts = [
...lastMessageAttachments.map((it) {
if (it.type == 'image') {
return '📷';
} else if (it.type == 'video') {
return '🎬';
} else if (it.type == 'giphy') {
return '[GIF]';
}
return it == lastMessage.attachments.last
? (it.title ?? 'File')
: '${it.title ?? 'File'} , ';
}),
if (lastMessageText != null)
if (lastMessageMentionedUsers.isNotEmpty)
...mentionedUsersRegex.allMatchesWithSep(lastMessageText)
else
lastMessageText,
];
final fontStyle = (lastMessage.isSystem || lastMessage.isDeleted)
? FontStyle.italic
: FontStyle.normal;
final regularTextStyle = textStyle?.copyWith(fontStyle: fontStyle);
final mentionsTextStyle = textStyle?.copyWith(
fontStyle: fontStyle,
fontWeight: FontWeight.bold,
);
final spans = [
for (final part in messageTextParts)
if (lastMessageMentionedUsers.isNotEmpty &&
lastMessageMentionedUsers.any((it) => '@${it.name}' == part))
TextSpan(
text: part,
style: mentionsTextStyle,
)
else if (lastMessageAttachments.isNotEmpty &&
lastMessageAttachments
.where((it) => it.title != null)
.any((it) => it.title == part))
TextSpan(
text: part,
style: regularTextStyle?.copyWith(
fontStyle: FontStyle.italic,
),
)
else
TextSpan(
text: part,
style: regularTextStyle,
),
];
return Text.rich(
TextSpan(children: spans),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.start,
return StreamMessagePreviewText(
message: lastMessage,
textStyle: textStyle,
language: channel.client.state.currentUser?.language,
);
},
);
}
extension _RegExpX on RegExp {
List<String> allMatchesWithSep(String input, [int start = 0]) {
final result = <String>[];
for (final match in allMatches(input, start)) {
result.add(input.substring(start, match.start));
// ignore: cascade_invocations
result.add(match[0]!);
// ignore: parameter_assignments
start = match.end;
}
result.add(input.substring(start));
return result;
}
}
@@ -5,19 +5,21 @@ 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/v4/channel_list_view/stream_channel_list_loading_tile.dart';
import 'package:stream_chat_flutter/src/v4/channel_list_view/stream_channel_list_tile.dart';
import 'package:stream_chat_flutter/src/v4/stream_list_view_indexed_widget_builder.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
/// Default separator builder for [StreamChannelListView].
Widget defaultSeparatorBuilder(BuildContext context, int index) =>
Widget defaultChannelListViewSeparatorBuilder(
BuildContext context,
List<Channel> items,
int index,
) =>
const StreamChannelListSeparator();
/// Signature for the item builder that creates the children of the
/// [StreamChannelListView].
typedef StreamChannelListViewItemBuilder = Widget Function(
BuildContext context,
Channel channel,
StreamChannelListTile defaultWidget,
);
typedef StreamChannelListViewIndexedWidgetBuilder
= StreamListViewIndexedWidgetBuilder<Channel, StreamChannelListTile>;
/// A [ListView] that shows a list of [Channel]s,
/// it uses [StreamChannelListTile] as a default item.
@@ -42,18 +44,19 @@ typedef StreamChannelListViewItemBuilder = Widget Function(
/// See also:
/// * [StreamChannelListTile]
/// * [StreamChannelListController]
class StreamChannelListView extends StatefulWidget {
class StreamChannelListView extends StatelessWidget {
/// Creates a new instance of [StreamChannelListView].
const StreamChannelListView({
Key? key,
required this.controller,
this.itemBuilder,
this.separatorBuilder = defaultSeparatorBuilder,
this.separatorBuilder = defaultChannelListViewSeparatorBuilder,
this.emptyBuilder,
this.loadingBuilder,
this.errorBuilder,
this.onChannelTap,
this.onChannelLongPress,
this.loadMoreTriggerIndex = 3,
this.padding,
this.physics,
this.reverse = false,
@@ -75,14 +78,14 @@ class StreamChannelListView extends StatefulWidget {
/// The `channel` parameter is the [Channel] at this position in the list
/// and the `defaultWidget` is the default widget used
/// i.e: [StreamChannelListTile].
final StreamChannelListViewItemBuilder? itemBuilder;
final StreamChannelListViewIndexedWidgetBuilder? itemBuilder;
/// A builder that is called to build the list separator.
final IndexedWidgetBuilder separatorBuilder;
final PagedValueListViewIndexedWidgetBuilder<Channel> separatorBuilder;
/// A builder that is called to build the empty state of the list.
///
/// If not provider, [StreamChannelListEmptyWidget] will be used.
/// If not provided, [StreamChannelListEmptyWidget] will be used.
final WidgetBuilder? emptyBuilder;
/// A builder that is called to build the loading state of the list.
@@ -101,6 +104,9 @@ class StreamChannelListView extends StatefulWidget {
/// Called when the user long-presses on this list tile.
final void Function(Channel)? onChannelLongPress;
/// The index to take into account when triggering [controller.loadMore].
final int loadMoreTriggerIndex;
/// The amount of space by which to inset the children.
final EdgeInsetsGeometry? padding;
@@ -235,135 +241,72 @@ class StreamChannelListView extends StatefulWidget {
final String? restorationId;
@override
_StreamChannelListViewState createState() => _StreamChannelListViewState();
}
Widget build(BuildContext context) => PagedValueListView<int, Channel>(
padding: padding,
physics: physics,
reverse: reverse,
controller: controller,
primary: primary,
shrinkWrap: shrinkWrap,
keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
dragStartBehavior: dragStartBehavior,
cacheExtent: cacheExtent,
loadMoreTriggerIndex: loadMoreTriggerIndex,
separatorBuilder: separatorBuilder,
itemBuilder: (context, channels, index) {
final channel = channels[index];
final onTap = onChannelTap;
final onLongPress = onChannelLongPress;
class _StreamChannelListViewState extends State<StreamChannelListView> {
StreamChannelListController get _controller => widget.controller;
final streamChannelListTile = StreamChannelListTile(
channel: channel,
onTap: onTap == null ? null : () => onTap(channel),
onLongPress:
onLongPress == null ? null : () => onLongPress(channel),
);
// Avoids duplicate requests on rebuilds.
bool _hasRequestedNextPage = false;
@override
void initState() {
super.initState();
_controller.doInitialLoad();
}
@override
void didUpdateWidget(covariant StreamChannelListView oldWidget) {
super.didUpdateWidget(oldWidget);
if (_controller != oldWidget.controller) {
// reset duplicate requests flag
_hasRequestedNextPage = false;
_controller.doInitialLoad();
}
}
@override
Widget build(BuildContext context) =>
PagedValueListenableBuilder<int, Channel>(
valueListenable: widget.controller,
builder: (context, value, _) => value.when(
(channels, nextPageKey, error) {
if (channels.isEmpty) {
return widget.emptyBuilder?.call(context) ??
const Center(
child: Padding(
padding: EdgeInsets.all(8),
child: StreamChannelListEmptyWidget(),
),
);
}
return ListView.separated(
padding: widget.padding,
physics: widget.physics,
reverse: widget.reverse,
controller: widget.scrollController,
primary: widget.primary,
shrinkWrap: widget.shrinkWrap,
keyboardDismissBehavior: widget.keyboardDismissBehavior,
restorationId: widget.restorationId,
dragStartBehavior: widget.dragStartBehavior,
cacheExtent: widget.cacheExtent,
itemCount: value.itemCount,
separatorBuilder: widget.separatorBuilder,
itemBuilder: (context, index) {
if (!_hasRequestedNextPage) {
final newPageRequestTriggerIndex = channels.length - 3;
final isBuildingTriggerIndexItem =
index == newPageRequestTriggerIndex;
if (nextPageKey != null && isBuildingTriggerIndexItem) {
// Schedules the request for the end of this frame.
WidgetsBinding.instance?.addPostFrameCallback((_) async {
if (error == null) {
await _controller.loadMore(nextPageKey);
}
_hasRequestedNextPage = false;
});
_hasRequestedNextPage = true;
}
}
if (index == channels.length) {
if (error != null) {
return StreamChannelListLoadMoreError(
onTap: _controller.retry,
);
}
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: StreamChannelListLoadMoreIndicator(),
),
);
}
final channel = channels[index];
final onTap = widget.onChannelTap;
final onLongPress = widget.onChannelLongPress;
final streamChannelListTile = StreamChannelListTile(
channel: channel,
onTap: onTap == null ? null : () => onTap(channel),
onLongPress:
onLongPress == null ? null : () => onLongPress(channel),
);
final itemBuilder = widget.itemBuilder;
if (itemBuilder != null) {
return itemBuilder(
context,
channel,
streamChannelListTile,
);
}
return streamChannelListTile;
},
);
},
loading: () =>
widget.loadingBuilder?.call(context) ??
ListView.separated(
padding: widget.padding,
physics: widget.physics,
reverse: widget.reverse,
itemCount: 25,
separatorBuilder: widget.separatorBuilder,
itemBuilder: (_, __) => const StreamChannelListLoadingTile(),
),
error: (error) =>
widget.errorBuilder?.call(context, error) ??
Center(
child: StreamChannelListErrorWidget(
onPressed: _controller.refresh,
),
return itemBuilder?.call(
context,
channels,
index,
streamChannelListTile,
) ??
streamChannelListTile;
},
emptyBuilder: (context) =>
emptyBuilder?.call(context) ??
const Center(
child: Padding(
padding: EdgeInsets.all(8),
child: StreamChannelListEmptyWidget(),
),
),
loadMoreErrorBuilder: (context, error) =>
StreamChannelListLoadMoreError(onTap: controller.retry),
loadMoreIndicatorBuilder: (context) => const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: StreamChannelListLoadMoreIndicator(),
),
),
loadingBuilder: (context) =>
loadingBuilder?.call(context) ??
ListView.separated(
padding: padding,
physics: physics,
reverse: reverse,
itemCount: 25,
separatorBuilder: (_, __) => const StreamChannelListSeparator(),
itemBuilder: (_, __) => const StreamChannelListLoadingTile(),
),
errorBuilder: (context, error) =>
errorBuilder?.call(context, error) ??
Center(
child: StreamChannelListErrorWidget(
onPressed: controller.refresh,
),
),
);
}
@@ -0,0 +1,235 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/v4/stream_message_preview_text.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// A widget that displays a message search item.
///
/// This widget is intended to be used as a
/// Tile in [StreamMessageSearchListView].
///
/// It displays the message's text, channel, sender, and timestamp.
///
/// See also:
/// * [StreamMessageSearchListView]
/// * [StreamUserAvatar]
class StreamMessageSearchListTile extends StatelessWidget {
/// Creates a new instance of [StreamMessageSearchListTile].
const StreamMessageSearchListTile({
Key? key,
required this.messageResponse,
this.leading,
this.title,
this.subtitle,
this.trailing,
this.onTap,
this.onLongPress,
this.tileColor,
this.visualDensity = VisualDensity.compact,
this.contentPadding = const EdgeInsets.symmetric(horizontal: 8),
}) : super(key: key);
/// The message response to display.
final GetMessageResponse messageResponse;
/// A widget to display before the title.
final Widget? leading;
/// The primary content of the list tile.
final Widget? title;
/// Additional content displayed below the title.
final Widget? subtitle;
/// A widget to display at the end of tile.
final Widget? trailing;
/// Called when the user taps this list tile.
final GestureTapCallback? onTap;
/// Called when the user long-presses on this list tile.
final GestureLongPressCallback? onLongPress;
/// {@template flutter.material.ListTile.tileColor}
/// Defines the background color of `ListTile`.
///
/// When the value is null,
/// the `tileColor` is set to [ListTileTheme.tileColor]
/// if it's not null and to [Colors.transparent] if it's null.
/// {@endtemplate}
final Color? tileColor;
/// Defines how compact the list tile's layout will be.
///
/// {@macro flutter.material.themedata.visualDensity}
///
/// See also:
///
/// * [ThemeData.visualDensity], which specifies the [visualDensity] for all
/// widgets within a [Theme].
final VisualDensity visualDensity;
/// The tile's internal padding.
///
/// Insets a [ListTile]'s contents: its [leading], [title], [subtitle],
/// and [trailing] widgets.
///
/// If null, `EdgeInsets.symmetric(horizontal: 16.0)` is used.
final EdgeInsetsGeometry contentPadding;
/// Creates a copy of this tile but with the given fields replaced with
/// the new values.
StreamMessageSearchListTile copyWith({
Key? key,
GetMessageResponse? messageResponse,
Widget? leading,
Widget? title,
Widget? subtitle,
Widget? trailing,
GestureTapCallback? onTap,
GestureLongPressCallback? onLongPress,
Color? tileColor,
VisualDensity? visualDensity,
EdgeInsetsGeometry? contentPadding,
}) =>
StreamMessageSearchListTile(
key: key ?? this.key,
messageResponse: messageResponse ?? this.messageResponse,
leading: leading ?? this.leading,
title: title ?? this.title,
subtitle: subtitle ?? this.subtitle,
trailing: trailing ?? this.trailing,
onTap: onTap ?? this.onTap,
onLongPress: onLongPress ?? this.onLongPress,
tileColor: tileColor ?? this.tileColor,
visualDensity: visualDensity ?? this.visualDensity,
contentPadding: contentPadding ?? this.contentPadding,
);
@override
Widget build(BuildContext context) {
final message = messageResponse.message;
final user = message.user!;
final channelPreviewTheme = StreamChannelPreviewTheme.of(context);
final leading = this.leading ??
StreamUserAvatar(
user: user,
constraints: const BoxConstraints.tightFor(
height: 40,
width: 40,
),
);
final title = this.title ??
MessageSearchListTileTitle(
messageResponse: messageResponse,
textStyle: channelPreviewTheme.titleStyle,
);
final subtitle = this.subtitle ??
Row(
children: [
Expanded(
child: StreamMessagePreviewText(
message: message,
textStyle: channelPreviewTheme.subtitleStyle,
),
),
const SizedBox(width: 16),
MessageSearchTileMessageDate(
message: message,
textStyle: channelPreviewTheme.lastMessageAtStyle,
),
],
);
return ListTile(
onTap: onTap,
onLongPress: onLongPress,
visualDensity: visualDensity,
contentPadding: contentPadding,
tileColor: tileColor,
leading: leading,
trailing: trailing,
title: title,
subtitle: subtitle,
);
}
}
class MessageSearchListTileTitle extends StatelessWidget {
const MessageSearchListTileTitle({
Key? key,
required this.messageResponse,
this.textStyle,
}) : super(key: key);
final GetMessageResponse messageResponse;
final TextStyle? textStyle;
@override
Widget build(BuildContext context) {
final user = messageResponse.message.user!;
final channel = messageResponse.channel;
final channelName = channel?.extraData['name'];
return Row(
children: [
Text(
user.id == StreamChat.of(context).currentUser?.id
? context.translations.youText
: user.name,
style: textStyle,
),
if (channelName != null) ...[
Text(
' ${context.translations.inText} ',
style: textStyle?.copyWith(
fontWeight: FontWeight.normal,
),
),
Text(
channelName as String,
style: textStyle,
),
],
],
);
}
}
class MessageSearchTileMessageDate extends StatelessWidget {
/// Creates a new instance of [MessageSearchTileMessageDate].
const MessageSearchTileMessageDate({
Key? key,
required this.message,
this.textStyle,
}) : super(key: key);
/// The searched message response.
final Message message;
/// The text style to use for the date.
final TextStyle? textStyle;
@override
Widget build(BuildContext context) {
final createdAt = message.createdAt;
String stringDate;
final now = DateTime.now();
if (now.year != createdAt.year ||
now.month != createdAt.month ||
now.day != createdAt.day) {
stringDate = Jiffy(createdAt.toLocal()).yMd;
} else {
stringDate = Jiffy(createdAt.toLocal()).jm;
}
return Text(
stringDate,
style: textStyle,
);
}
}
@@ -0,0 +1,446 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/v4/stream_list_view_indexed_widget_builder.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Default separator builder for [StreamMessageSearchListView].
Widget defaultMessageSearchListViewSeparatorBuilder(
BuildContext context,
List<GetMessageResponse> responses,
int index,
) =>
const StreamMessageSearchListSeparator();
/// Signature for the item builder that creates the children of the
/// [StreamMessageSearchListView].
typedef StreamMessageSearchListViewIndexedWidgetBuilder
= StreamListViewIndexedWidgetBuilder<GetMessageResponse,
StreamMessageSearchListTile>;
/// A [ListView] that shows a list of [GetMessageResponse]s,
/// it uses [StreamMessageSearchListTile] as a default item.
///
/// This is the new version of [MessageSearchListView] that uses
/// [StreamMessageSearchListController].
///
/// Example:
///
/// ```dart
/// StreamMessageSearchListView(
/// controller: controller,
/// onMessageTap: (user) {
/// // Handle user tap event
/// },
/// onMessageLongPress: (user) {
/// // Handle user long press event
/// },
/// )
/// ```
///
/// See also:
/// * [StreamMessageSearchListTile]
/// * [StreamMessageSearchListController]
class StreamMessageSearchListView extends StatelessWidget {
/// Creates a new instance of [StreamMessageSearchListView].
const StreamMessageSearchListView({
Key? key,
required this.controller,
this.itemBuilder,
this.separatorBuilder = defaultMessageSearchListViewSeparatorBuilder,
this.emptyBuilder,
this.loadingBuilder,
this.errorBuilder,
this.onMessageTap,
this.onMessageLongPress,
this.loadMoreTriggerIndex = 3,
this.padding,
this.physics,
this.reverse = false,
this.scrollController,
this.primary,
this.scrollBehavior,
this.shrinkWrap = false,
this.cacheExtent,
this.dragStartBehavior = DragStartBehavior.start,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
this.restorationId,
}) : super(key: key);
/// The [StreamUserListController] used to control the list of
/// searched messages.
final StreamMessageSearchListController controller;
/// A builder that is called to build items in the [ListView].
///
/// The `messageResponse` parameter is the [GetMessageResponse] at this
/// position in the list and the `defaultWidget` is the default widget used
/// i.e: [StreamMessageSearchListTile].
final StreamMessageSearchListViewIndexedWidgetBuilder? itemBuilder;
/// A builder that is called to build the list separator.
final PagedValueListViewIndexedWidgetBuilder<GetMessageResponse>
separatorBuilder;
/// A builder that is called to build the empty state of the list.
///
/// If not provided, [StreamMessageSearchListEmptyWidget] will be used.
final WidgetBuilder? emptyBuilder;
/// A builder that is called to build the loading state of the list.
///
/// If not provided, [StreamMessageSearchListLoadingTile] will be used.
final WidgetBuilder? loadingBuilder;
/// A builder that is called to build the error state of the list.
///
/// If not provided, [StreamMessageSearchListErrorWidget] will be used.
final Widget Function(BuildContext, StreamChatError)? errorBuilder;
/// Called when the user taps this list tile.
final void Function(GetMessageResponse)? onMessageTap;
/// Called when the user long-presses on this list tile.
final void Function(GetMessageResponse)? onMessageLongPress;
/// The index to take into account when triggering [controller.loadMore].
final int loadMoreTriggerIndex;
/// The amount of space by which to inset the children.
final EdgeInsetsGeometry? padding;
/// {@template flutter.widgets.scroll_view.reverse}
/// Whether the scroll view scrolls in the reading direction.
///
/// For example, if [scrollDirection] is [Axis.vertical], then the scroll view
/// scrolls from top to bottom when [reverse] is false and from bottom to top
/// when [reverse] is true.
///
/// Defaults to false.
/// {@endtemplate}
final bool reverse;
/// {@template flutter.widgets.scroll_view.controller}
/// An object that can be used to control the position to which this scroll
/// view is scrolled.
///
/// Must be null if [primary] is true.
///
/// A [ScrollController] serves several purposes. It can be used to control
/// the initial scroll position (see [ScrollController.initialScrollOffset]).
/// It can be used to control whether the scroll view should automatically
/// save and restore its scroll position in the [PageStorage] (see
/// [ScrollController.keepScrollOffset]). It can be used to read the current
/// scroll position (see [ScrollController.offset]), or change it (see
/// [ScrollController.animateTo]).
/// {@endtemplate}
final ScrollController? scrollController;
/// {@template flutter.widgets.scroll_view.primary}
/// Whether this is the primary scroll view associated with the parent
/// [PrimaryScrollController].
///
/// When this is true, the scroll view is scrollable even if it does not have
/// sufficient content to actually scroll. Otherwise, by default the user can
/// only scroll the view if it has sufficient content. See [physics].
///
/// Also when true, the scroll view is used for default [ScrollAction]s. If a
/// ScrollAction is not handled by an otherwise focused part of the
/// application, the ScrollAction will be evaluated using this scroll view,
/// for example, when executing [Shortcuts] key events like page up and down.
///
/// On iOS, this also identifies the scroll view that will scroll to top in
/// response to a tap in the status bar.
/// {@endtemplate}
///
/// Defaults to true when [scrollController] is null.
final bool? primary;
/// {@macro flutter.widgets.shadow.scrollBehavior}
///
/// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit
/// [ScrollPhysics] is provided in [physics], it will take precedence,
/// followed by [scrollBehavior], and then the inherited ancestor
/// [ScrollBehavior].
final ScrollBehavior? scrollBehavior;
/// {@template flutter.widgets.scroll_view.shrinkWrap}
/// Whether the extent of the scroll view in the [scrollDirection] should be
/// determined by the contents being viewed.
///
/// If the scroll view does not shrink wrap, then the scroll view will expand
/// to the maximum allowed size in the [scrollDirection]. If the scroll view
/// has unbounded constraints in the [scrollDirection], then [shrinkWrap] must
/// be true.
///
/// Shrink wrapping the content of the scroll view is significantly more
/// expensive than expanding to the maximum allowed size because the content
/// can expand and contract during scrolling, which means the size of the
/// scroll view needs to be recomputed whenever the scroll position changes.
///
/// Defaults to false.
/// {@endtemplate}
final bool shrinkWrap;
/// {@template flutter.widgets.scroll_view.physics}
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// Defaults to matching platform conventions. Furthermore, if [primary] is
/// false, then the user cannot scroll if there is insufficient content to
/// scroll, while if [primary] is true, they can always attempt to scroll.
///
/// To force the scroll view to always be scrollable even if there is
/// insufficient content, as if [primary] was true but without necessarily
/// setting it to true, provide an [AlwaysScrollableScrollPhysics] physics
/// object, as in:
///
/// ```dart
/// physics: const AlwaysScrollableScrollPhysics(),
/// ```
///
/// To force the scroll view to use the default platform conventions and not
/// be scrollable if there is insufficient content, regardless of the value of
/// [primary], provide an explicit [ScrollPhysics] object, as in:
///
/// ```dart
/// physics: const ScrollPhysics(),
/// ```
///
/// The physics can be changed dynamically (by providing a new object in a
/// subsequent build), but new physics will only take effect if the _class_ of
/// the provided object changes. Merely constructing a new instance with a
/// different configuration is insufficient to cause the physics to be
/// reapplied. (This is because the final object used is generated
/// dynamically, which can be relatively expensive, and it would be
/// inefficient to speculatively create this object each frame to see if the
/// physics should be updated.)
/// {@endtemplate}
///
/// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the
/// [ScrollPhysics] provided by that behavior will take precedence after
/// [physics].
final ScrollPhysics? physics;
/// {@macro flutter.rendering.RenderViewportBase.cacheExtent}
final double? cacheExtent;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
/// {@template flutter.widgets.scroll_view.keyboardDismissBehavior}
/// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will
/// dismiss the keyboard automatically.
/// {@endtemplate}
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
/// {@macro flutter.widgets.scrollable.restorationId}
final String? restorationId;
@override
Widget build(BuildContext context) =>
PagedValueListView<String, GetMessageResponse>(
padding: padding,
physics: physics,
reverse: reverse,
controller: controller,
primary: primary,
shrinkWrap: shrinkWrap,
keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
dragStartBehavior: dragStartBehavior,
cacheExtent: cacheExtent,
loadMoreTriggerIndex: loadMoreTriggerIndex,
separatorBuilder: separatorBuilder,
itemBuilder: (context, messageResponses, index) {
final messageResponse = messageResponses[index];
final onTap = onMessageTap;
final onLongPress = onMessageLongPress;
final streamUserListTile = StreamMessageSearchListTile(
messageResponse: messageResponse,
onTap: onTap == null ? null : () => onTap(messageResponse),
onLongPress:
onLongPress == null ? null : () => onLongPress(messageResponse),
);
return itemBuilder?.call(
context,
messageResponses,
index,
streamUserListTile,
) ??
streamUserListTile;
},
emptyBuilder: (context) =>
emptyBuilder?.call(context) ??
const Center(
child: Padding(
padding: EdgeInsets.all(8),
child: StreamMessageSearchListEmptyWidget(),
),
),
loadMoreErrorBuilder: (context, error) =>
StreamMessageSearchListLoadMoreError(onTap: controller.retry),
loadMoreIndicatorBuilder: (context) => const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: StreamMessageSearchListLoadMoreIndicator(),
),
),
loadingBuilder: (context) =>
loadingBuilder?.call(context) ??
ListView.separated(
padding: padding,
physics: physics,
reverse: reverse,
itemCount: 25,
separatorBuilder: (_, __) =>
const StreamMessageSearchListSeparator(),
itemBuilder: (_, __) => const StreamChannelListLoadingTile(),
),
errorBuilder: (context, error) =>
errorBuilder?.call(context, error) ??
Center(
child: StreamMessageSearchListErrorWidget(
onPressed: controller.refresh,
),
),
);
}
/// A [StreamMessageSearchListTile] that can be used in a [ListView] to show a
/// loading tile while waiting for the [StreamMessageSearchListController] to
/// load more messages.
class StreamMessageSearchListLoadMoreIndicator extends StatelessWidget {
/// Creates a new instance of [StreamMessageSearchListLoadMoreIndicator].
const StreamMessageSearchListLoadMoreIndicator({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) => const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator.adaptive(),
);
}
/// A [StreamMessageSearchListTile] that is used to display the error indicator
/// when loading more messages fails.
class StreamMessageSearchListLoadMoreError extends StatelessWidget {
/// Creates a new instance of [StreamMessageSearchListLoadMoreError].
const StreamMessageSearchListLoadMoreError({
Key? key,
this.onTap,
}) : super(key: key);
/// The callback to invoke when the user taps on the error indicator.
final GestureTapCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = StreamChatTheme.of(context);
return InkWell(
onTap: onTap,
child: Container(
color: theme.colorTheme.textLowEmphasis.withOpacity(0.9),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.translations.loadingChannelsError,
style: theme.textTheme.body.copyWith(
color: Colors.white,
),
),
StreamSvgIcon.retry(color: Colors.white),
],
),
),
),
);
}
}
/// A widget that is used to display a separator between
/// [StreamMessageSearchListTile] items.
class StreamMessageSearchListSeparator extends StatelessWidget {
/// Creates a new instance of [StreamMessageSearchListSeparator].
const StreamMessageSearchListSeparator({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final effect = StreamChatTheme.of(context).colorTheme.borderBottom;
return Container(
height: 1,
color: effect.color!.withOpacity(effect.alpha ?? 1.0),
);
}
}
/// A widget that is used to display an error screen
/// when [StreamMessageSearchListController] fails to load initial messages.
class StreamMessageSearchListErrorWidget extends StatelessWidget {
/// Creates a new instance of [StreamMessageSearchListErrorWidget] widget.
const StreamMessageSearchListErrorWidget({
Key? key,
this.onPressed,
}) : super(key: key);
/// The callback to invoke when the user taps on the retry button.
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text.rich(
TextSpan(
children: [
const WidgetSpan(
child: Padding(
padding: EdgeInsets.only(right: 2),
child: Icon(Icons.error_outline),
),
),
TextSpan(text: context.translations.loadingChannelsError),
],
),
style: Theme.of(context).textTheme.headline6,
),
TextButton(
onPressed: onPressed,
child: Text(context.translations.retryLabel),
),
],
);
}
/// A widget that is used to display an empty state when
/// [StreamMessageSearchListController] loads zero messages.
class StreamMessageSearchListEmptyWidget extends StatelessWidget {
/// Creates a new instance of [StreamMessageSearchListEmptyWidget] widget.
const StreamMessageSearchListEmptyWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final chatThemeData = StreamChatTheme.of(context);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
StreamSvgIcon.message(
size: 148,
color: chatThemeData.colorTheme.disabled,
),
const SizedBox(height: 28),
Text(
context.translations.letsStartChattingLabel,
style: chatThemeData.textTheme.headline,
),
],
);
}
}
@@ -0,0 +1,9 @@
import 'package:flutter/material.dart';
typedef StreamListViewIndexedWidgetBuilder<ItemType, WidgetType extends Widget>
= Widget Function(
BuildContext context,
List<ItemType> items,
int index,
WidgetType defaultWidget,
);
@@ -0,0 +1,116 @@
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// A widget that renders a preview of the message text.
class StreamMessagePreviewText extends StatelessWidget {
/// Creates a new instance of [StreamMessagePreviewText].
const StreamMessagePreviewText({
Key? key,
required this.message,
this.language,
this.textStyle,
}) : super(key: key);
/// The message to display.
final Message message;
/// The language to use for translations.
final String? language;
/// The style to use for the text.
final TextStyle? textStyle;
@override
Widget build(BuildContext context) {
final messageText = message
.translate(language ?? 'en')
.replaceMentions(linkify: false)
.text;
final messageAttachments = message.attachments;
final messageMentionedUsers = message.mentionedUsers;
final mentionedUsersRegex = RegExp(
messageMentionedUsers.map((it) => '@${it.name}').join('|'),
caseSensitive: false,
);
final messageTextParts = [
...messageAttachments.map((it) {
if (it.type == 'image') {
return '📷';
} else if (it.type == 'video') {
return '🎬';
} else if (it.type == 'giphy') {
return '[GIF]';
}
return it == message.attachments.last
? (it.title ?? 'File')
: '${it.title ?? 'File'} , ';
}),
if (messageText != null)
if (messageMentionedUsers.isNotEmpty)
...mentionedUsersRegex.allMatchesWithSep(messageText)
else
messageText,
];
final fontStyle = (message.isSystem || message.isDeleted)
? FontStyle.italic
: FontStyle.normal;
final regularTextStyle = textStyle?.copyWith(fontStyle: fontStyle);
final mentionsTextStyle = textStyle?.copyWith(
fontStyle: fontStyle,
fontWeight: FontWeight.bold,
);
final spans = [
for (final part in messageTextParts)
if (messageMentionedUsers.isNotEmpty &&
messageMentionedUsers.any((it) => '@${it.name}' == part))
TextSpan(
text: part,
style: mentionsTextStyle,
)
else if (messageAttachments.isNotEmpty &&
messageAttachments
.where((it) => it.title != null)
.any((it) => it.title == part))
TextSpan(
text: part,
style: regularTextStyle?.copyWith(
fontStyle: FontStyle.italic,
),
)
else
TextSpan(
text: part,
style: regularTextStyle,
),
];
return Text.rich(
TextSpan(children: spans),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.start,
);
}
}
extension _RegExpX on RegExp {
List<String> allMatchesWithSep(String input, [int start = 0]) {
final result = <String>[];
for (final match in allMatches(input, start)) {
result.add(input.substring(start, match.start));
// ignore: cascade_invocations
result.add(match[0]!);
// ignore: parameter_assignments
start = match.end;
}
result.add(input.substring(start));
return result;
}
}
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:jiffy/jiffy.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/stream_svg_icon.dart';
import 'package:stream_chat_flutter/src/user_avatar.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'
show User;
/// A widget that displays a user.
///
/// This widget is intended to be used as a Tile in [StreamUserListView]
///
/// It shows the user's avatar, name and last message.
///
/// See also:
/// * [StreamUserListView]
/// * [StreamUserAvatar]
class StreamUserListTile extends StatelessWidget {
/// Creates a new instance of [StreamUserListTile].
const StreamUserListTile({
Key? key,
required this.user,
this.leading,
this.title,
this.subtitle,
this.selected = false,
this.selectedWidget,
this.onTap,
this.onLongPress,
this.tileColor,
this.visualDensity = VisualDensity.compact,
this.contentPadding = const EdgeInsets.symmetric(horizontal: 8),
}) : super(key: key);
/// The user to display.
final User user;
/// A widget to display before the title.
final Widget? leading;
/// The primary content of the list tile.
final Widget? title;
/// Additional content displayed below the title.
final Widget? subtitle;
/// A widget to display at the end of tile.
final Widget? selectedWidget;
/// If this tile is also [enabled] then icons and text are rendered with the same color.
///
/// By default the selected color is the theme's primary color. The selected color
/// can be overridden with a [ListTileTheme].
///
/// {@tool dartpad}
/// Here is an example of using a [StatefulWidget] to keep track of the
/// selected index, and using that to set the `selected` property on the
/// corresponding [ListTile].
///
/// ** See code in examples/api/lib/material/list_tile/list_tile.selected.0.dart **
/// {@end-tool}
final bool selected;
/// Called when the user taps this list tile.
final GestureTapCallback? onTap;
/// Called when the user long-presses on this list tile.
final GestureLongPressCallback? onLongPress;
/// {@template flutter.material.ListTile.tileColor}
/// Defines the background color of `ListTile`.
///
/// When the value is null,
/// the `tileColor` is set to [ListTileTheme.tileColor]
/// if it's not null and to [Colors.transparent] if it's null.
/// {@endtemplate}
final Color? tileColor;
/// Defines how compact the list tile's layout will be.
///
/// {@macro flutter.material.themedata.visualDensity}
///
/// See also:
///
/// * [ThemeData.visualDensity], which specifies the [visualDensity] for all
/// widgets within a [Theme].
final VisualDensity visualDensity;
/// The tile's internal padding.
///
/// Insets a [ListTile]'s contents: its [leading], [title], [subtitle],
/// and [trailing] widgets.
///
/// If null, `EdgeInsets.symmetric(horizontal: 16.0)` is used.
final EdgeInsetsGeometry contentPadding;
/// Creates a copy of this tile but with the given fields replaced with
/// the new values.
StreamUserListTile copyWith({
Key? key,
User? user,
Widget? leading,
Widget? title,
Widget? subtitle,
Widget? selectedWidget,
bool? selected,
GestureTapCallback? onTap,
GestureLongPressCallback? onLongPress,
Color? tileColor,
VisualDensity? visualDensity,
EdgeInsetsGeometry? contentPadding,
}) =>
StreamUserListTile(
key: key ?? this.key,
user: user ?? this.user,
leading: leading ?? this.leading,
title: title ?? this.title,
subtitle: subtitle ?? this.subtitle,
selectedWidget: selectedWidget ?? this.selectedWidget,
selected: selected ?? this.selected,
onTap: onTap ?? this.onTap,
onLongPress: onLongPress ?? this.onLongPress,
tileColor: tileColor ?? this.tileColor,
visualDensity: visualDensity ?? this.visualDensity,
contentPadding: contentPadding ?? this.contentPadding,
);
@override
Widget build(BuildContext context) {
final chatThemeData = StreamChatTheme.of(context);
final leading = this.leading ??
StreamUserAvatar(
user: user,
constraints: const BoxConstraints.tightFor(
height: 40,
width: 40,
),
);
final title = this.title ??
Text(
user.name,
style: chatThemeData.textTheme.bodyBold,
);
final subtitle = this.subtitle ??
UserLastActive(
user: user,
);
final selectedWidget = this.selectedWidget ??
StreamSvgIcon.checkSend(
color: chatThemeData.colorTheme.accentPrimary,
);
return ListTile(
onTap: onTap,
onLongPress: onLongPress,
leading: leading,
trailing: selected ? selectedWidget : null,
title: title,
subtitle: subtitle,
);
}
}
/// A widget that displays a user's last active time.
class UserLastActive extends StatelessWidget {
/// Creates a new instance of the [UserLastActive] widget.
const UserLastActive({
Key? key,
required this.user,
}) : super(key: key);
/// The user whose last active time is displayed.
final User user;
@override
Widget build(BuildContext context) {
final chatTheme = StreamChatTheme.of(context);
return Text(
user.online
? context.translations.userOnlineText
: '${context.translations.userLastOnlineText} '
'${Jiffy(user.lastActive).fromNow()}',
style: chatTheme.textTheme.footnote.copyWith(
color: chatTheme.colorTheme.textHighEmphasis.withOpacity(0.5),
),
);
}
}
@@ -0,0 +1,441 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/src/extension.dart';
import 'package:stream_chat_flutter/src/v4/stream_list_view_indexed_widget_builder.dart';
import 'package:stream_chat_flutter/src/v4/user_list_view/stream_user_list_tile.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
/// Default separator builder for [StreamUserListView].
Widget defaultUserListViewSeparatorBuilder(
BuildContext context,
List<User> users,
int index,
) =>
const StreamUserListSeparator();
/// Signature for the item builder that creates the children of the
/// [StreamUserListView].
typedef StreamUserListViewIndexedWidgetBuilder
= StreamListViewIndexedWidgetBuilder<User, StreamUserListTile>;
/// A [ListView] that shows a list of [User]s,
/// it uses [StreamUserListTile] as a default item.
///
/// This is the new version of [UserListView] that uses
/// [StreamUserListController].
///
/// Example:
///
/// ```dart
/// StreamUserListView(
/// controller: controller,
/// onUserTap: (user) {
/// // Handle user tap event
/// },
/// onUserLongPress: (user) {
/// // Handle user long press event
/// },
/// )
/// ```
///
/// See also:
/// * [StreamUserListTile]
/// * [StreamUserListController]
class StreamUserListView extends StatelessWidget {
/// Creates a new instance of [StreamUserListView].
const StreamUserListView({
Key? key,
required this.controller,
this.itemBuilder,
this.separatorBuilder = defaultUserListViewSeparatorBuilder,
this.emptyBuilder,
this.loadingBuilder,
this.errorBuilder,
this.onUserTap,
this.onUserLongPress,
this.loadMoreTriggerIndex = 3,
this.padding,
this.physics,
this.reverse = false,
this.scrollController,
this.primary,
this.scrollBehavior,
this.shrinkWrap = false,
this.cacheExtent,
this.dragStartBehavior = DragStartBehavior.start,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
this.restorationId,
}) : super(key: key);
/// The [StreamUserListController] used to control the list of users.
final StreamUserListController controller;
/// A builder that is called to build items in the [ListView].
///
/// The `user` parameter is the [User] at this position in the list
/// and the `defaultWidget` is the default widget used
/// i.e: [StreamUserListTile].
final StreamUserListViewIndexedWidgetBuilder? itemBuilder;
/// A builder that is called to build the list separator.
final PagedValueListViewIndexedWidgetBuilder<User> separatorBuilder;
/// A builder that is called to build the empty state of the list.
///
/// If not provided, [StreamUserListEmptyWidget] will be used.
final WidgetBuilder? emptyBuilder;
/// A builder that is called to build the loading state of the list.
///
/// If not provided, [StreamUserListLoadingTile] will be used.
final WidgetBuilder? loadingBuilder;
/// A builder that is called to build the error state of the list.
///
/// If not provided, [StreamUserListErrorWidget] will be used.
final Widget Function(BuildContext, StreamChatError)? errorBuilder;
/// Called when the user taps this list tile.
final void Function(User)? onUserTap;
/// Called when the user long-presses on this list tile.
final void Function(User)? onUserLongPress;
/// The index to take into account when triggering [controller.loadMore].
final int loadMoreTriggerIndex;
/// The amount of space by which to inset the children.
final EdgeInsetsGeometry? padding;
/// {@template flutter.widgets.scroll_view.reverse}
/// Whether the scroll view scrolls in the reading direction.
///
/// For example, if [scrollDirection] is [Axis.vertical], then the scroll view
/// scrolls from top to bottom when [reverse] is false and from bottom to top
/// when [reverse] is true.
///
/// Defaults to false.
/// {@endtemplate}
final bool reverse;
/// {@template flutter.widgets.scroll_view.controller}
/// An object that can be used to control the position to which this scroll
/// view is scrolled.
///
/// Must be null if [primary] is true.
///
/// A [ScrollController] serves several purposes. It can be used to control
/// the initial scroll position (see [ScrollController.initialScrollOffset]).
/// It can be used to control whether the scroll view should automatically
/// save and restore its scroll position in the [PageStorage] (see
/// [ScrollController.keepScrollOffset]). It can be used to read the current
/// scroll position (see [ScrollController.offset]), or change it (see
/// [ScrollController.animateTo]).
/// {@endtemplate}
final ScrollController? scrollController;
/// {@template flutter.widgets.scroll_view.primary}
/// Whether this is the primary scroll view associated with the parent
/// [PrimaryScrollController].
///
/// When this is true, the scroll view is scrollable even if it does not have
/// sufficient content to actually scroll. Otherwise, by default the user can
/// only scroll the view if it has sufficient content. See [physics].
///
/// Also when true, the scroll view is used for default [ScrollAction]s. If a
/// ScrollAction is not handled by an otherwise focused part of the
/// application, the ScrollAction will be evaluated using this scroll view,
/// for example, when executing [Shortcuts] key events like page up and down.
///
/// On iOS, this also identifies the scroll view that will scroll to top in
/// response to a tap in the status bar.
/// {@endtemplate}
///
/// Defaults to true when [scrollController] is null.
final bool? primary;
/// {@macro flutter.widgets.shadow.scrollBehavior}
///
/// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit
/// [ScrollPhysics] is provided in [physics], it will take precedence,
/// followed by [scrollBehavior], and then the inherited ancestor
/// [ScrollBehavior].
final ScrollBehavior? scrollBehavior;
/// {@template flutter.widgets.scroll_view.shrinkWrap}
/// Whether the extent of the scroll view in the [scrollDirection] should be
/// determined by the contents being viewed.
///
/// If the scroll view does not shrink wrap, then the scroll view will expand
/// to the maximum allowed size in the [scrollDirection]. If the scroll view
/// has unbounded constraints in the [scrollDirection], then [shrinkWrap] must
/// be true.
///
/// Shrink wrapping the content of the scroll view is significantly more
/// expensive than expanding to the maximum allowed size because the content
/// can expand and contract during scrolling, which means the size of the
/// scroll view needs to be recomputed whenever the scroll position changes.
///
/// Defaults to false.
/// {@endtemplate}
final bool shrinkWrap;
/// {@template flutter.widgets.scroll_view.physics}
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// Defaults to matching platform conventions. Furthermore, if [primary] is
/// false, then the user cannot scroll if there is insufficient content to
/// scroll, while if [primary] is true, they can always attempt to scroll.
///
/// To force the scroll view to always be scrollable even if there is
/// insufficient content, as if [primary] was true but without necessarily
/// setting it to true, provide an [AlwaysScrollableScrollPhysics] physics
/// object, as in:
///
/// ```dart
/// physics: const AlwaysScrollableScrollPhysics(),
/// ```
///
/// To force the scroll view to use the default platform conventions and not
/// be scrollable if there is insufficient content, regardless of the value of
/// [primary], provide an explicit [ScrollPhysics] object, as in:
///
/// ```dart
/// physics: const ScrollPhysics(),
/// ```
///
/// The physics can be changed dynamically (by providing a new object in a
/// subsequent build), but new physics will only take effect if the _class_ of
/// the provided object changes. Merely constructing a new instance with a
/// different configuration is insufficient to cause the physics to be
/// reapplied. (This is because the final object used is generated
/// dynamically, which can be relatively expensive, and it would be
/// inefficient to speculatively create this object each frame to see if the
/// physics should be updated.)
/// {@endtemplate}
///
/// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the
/// [ScrollPhysics] provided by that behavior will take precedence after
/// [physics].
final ScrollPhysics? physics;
/// {@macro flutter.rendering.RenderViewportBase.cacheExtent}
final double? cacheExtent;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
/// {@template flutter.widgets.scroll_view.keyboardDismissBehavior}
/// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will
/// dismiss the keyboard automatically.
/// {@endtemplate}
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
/// {@macro flutter.widgets.scrollable.restorationId}
final String? restorationId;
@override
Widget build(BuildContext context) => PagedValueListView<int, User>(
padding: padding,
physics: physics,
reverse: reverse,
controller: controller,
primary: primary,
shrinkWrap: shrinkWrap,
keyboardDismissBehavior: keyboardDismissBehavior,
restorationId: restorationId,
dragStartBehavior: dragStartBehavior,
cacheExtent: cacheExtent,
loadMoreTriggerIndex: loadMoreTriggerIndex,
separatorBuilder: separatorBuilder,
itemBuilder: (context, users, index) {
final user = users[index];
final onTap = onUserTap;
final onLongPress = onUserLongPress;
final streamUserListTile = StreamUserListTile(
user: user,
onTap: onTap == null ? null : () => onTap(user),
onLongPress: onLongPress == null ? null : () => onLongPress(user),
);
return itemBuilder?.call(
context,
users,
index,
streamUserListTile,
) ??
streamUserListTile;
},
loadMoreErrorBuilder: (context, error) =>
StreamUserListLoadMoreError(onTap: controller.retry),
loadMoreIndicatorBuilder: (context) => const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: StreamUserListLoadMoreIndicator(),
),
),
emptyBuilder: (context) =>
emptyBuilder?.call(context) ??
const Center(
child: Padding(
padding: EdgeInsets.all(8),
child: StreamUserListEmptyWidget(),
),
),
loadingBuilder: (context) =>
loadingBuilder?.call(context) ??
ListView.separated(
padding: padding,
physics: physics,
reverse: reverse,
itemCount: 25,
separatorBuilder: (_, __) => const StreamUserListSeparator(),
itemBuilder: (_, __) => const StreamChannelListLoadingTile(),
),
errorBuilder: (context, error) =>
errorBuilder?.call(context, error) ??
Center(
child: StreamUserListErrorWidget(
onPressed: controller.refresh,
),
),
);
}
/// A [StreamUserListTile] that can be used in a [ListView] to show a
/// loading tile while waiting for the [StreamUserListController] to load
/// more channels.
class StreamUserListLoadMoreIndicator extends StatelessWidget {
/// Creates a new instance of [StreamUserListLoadMoreIndicator].
const StreamUserListLoadMoreIndicator({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) => const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator.adaptive(),
);
}
/// A [StreamUserListTile] that is used to display the error indicator when
/// loading more users fails.
class StreamUserListLoadMoreError extends StatelessWidget {
/// Creates a new instance of [StreamUserListLoadMoreError].
const StreamUserListLoadMoreError({
Key? key,
this.onTap,
}) : super(key: key);
/// The callback to invoke when the user taps on the error indicator.
final GestureTapCallback? onTap;
@override
Widget build(BuildContext context) {
final theme = StreamChatTheme.of(context);
return InkWell(
onTap: onTap,
child: Container(
color: theme.colorTheme.textLowEmphasis.withOpacity(0.9),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.translations.loadingChannelsError,
style: theme.textTheme.body.copyWith(
color: Colors.white,
),
),
StreamSvgIcon.retry(color: Colors.white),
],
),
),
),
);
}
}
/// A widget that is used to display a separator between
/// [StreamUserListTile] items.
class StreamUserListSeparator extends StatelessWidget {
/// Creates a new instance of [StreamUserListSeparator].
const StreamUserListSeparator({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final effect = StreamChatTheme.of(context).colorTheme.borderBottom;
return Container(
height: 1,
color: effect.color!.withOpacity(effect.alpha ?? 1.0),
);
}
}
/// A widget that is used to display an error screen
/// when [StreamUserListController] fails to load initial users.
class StreamUserListErrorWidget extends StatelessWidget {
/// Creates a new instance of [StreamUserListErrorWidget] widget.
const StreamUserListErrorWidget({
Key? key,
this.onPressed,
}) : super(key: key);
/// The callback to invoke when the user taps on the retry button.
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text.rich(
TextSpan(
children: [
const WidgetSpan(
child: Padding(
padding: EdgeInsets.only(right: 2),
child: Icon(Icons.error_outline),
),
),
TextSpan(text: context.translations.loadingChannelsError),
],
),
style: Theme.of(context).textTheme.headline6,
),
TextButton(
onPressed: onPressed,
child: Text(context.translations.retryLabel),
),
],
);
}
/// A widget that is used to display an empty state when
/// [StreamUserListController] loads zero users.
class StreamUserListEmptyWidget extends StatelessWidget {
/// Creates a new instance of [StreamUserListEmptyWidget] widget.
const StreamUserListEmptyWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final chatThemeData = StreamChatTheme.of(context);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
StreamSvgIcon.message(
size: 148,
color: chatThemeData.colorTheme.disabled,
),
const SizedBox(height: 28),
Text(
context.translations.letsStartChattingLabel,
style: chatThemeData.textTheme.headline,
),
],
);
}
}
@@ -48,6 +48,7 @@ export 'src/user_item.dart';
export 'src/user_list_view.dart';
export 'src/user_mention_tile.dart';
export 'src/utils.dart';
// v4
export 'src/v4/channel_list_view/stream_channel_list_loading_tile.dart';
export 'src/v4/channel_list_view/stream_channel_list_tile.dart';
@@ -57,7 +58,13 @@ export 'src/v4/message_input/stream_attachment_picker.dart';
export 'src/v4/message_input/stream_message_input.dart';
export 'src/v4/message_input/stream_message_send_button.dart';
export 'src/v4/message_input/stream_message_text_field.dart';
export 'src/v4/message_search_list_view/stream_message_search_list_tile.dart';
export 'src/v4/message_search_list_view/stream_message_search_list_view.dart';
export 'src/v4/stream_channel_avatar.dart';
export 'src/v4/stream_channel_info_bottom_sheet.dart';
export 'src/v4/stream_channel_name.dart';
export 'src/v4/stream_list_view_indexed_widget_builder.dart';
export 'src/v4/stream_message_preview_text.dart';
export 'src/v4/user_list_view/stream_user_list_tile.dart';
export 'src/v4/user_list_view/stream_user_list_view.dart';
export 'src/visible_footnote.dart';
@@ -67,7 +67,7 @@ void main() {
_context = context;
return Scaffold(
body: MessageSearchBloc(
child: StreamMessageSearchListView(
child: MessageSearchListView(
filters: Filter.in_('members', const ['test_id']),
messageQuery: 'test query',
),
@@ -100,7 +100,7 @@ void main() {
_context = context;
return Scaffold(
body: MessageSearchBloc(
child: StreamMessageSearchListView(
child: MessageSearchListView(
filters: Filter.in_('members', const ['test_id']),
messageQuery: 'test query',
),
@@ -60,7 +60,7 @@ void main() {
_context = context;
return Scaffold(
body: UsersBloc(
child: StreamUserListView(),
child: UserListView(),
),
);
},
@@ -89,7 +89,7 @@ void main() {
_context = context;
return Scaffold(
body: UsersBloc(
child: StreamUserListView(),
child: UserListView(),
),
);
},
@@ -0,0 +1,297 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:stream_chat/stream_chat.dart';
import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart';
/// Signature for a function that creates a widget for a given index, e.g., in a
/// [PagedValueListView].
typedef PagedValueListViewIndexedWidgetBuilder<T> = Widget Function(
BuildContext context,
List<T> values,
int index,
);
/// Signature for the item builder that creates the children of the
/// [PagedValueListView].
typedef PagedValueListViewLoadMoreErrorBuilder = Widget Function(
BuildContext context,
StreamChatError error,
);
/// A [ListView] that loads more pages when the user scrolls to the end of the
/// list.
///
/// Use [loadMoreTriggerIndex] to set the index of the item that triggers the
/// loading of the next page.
class PagedValueListView<K, V> extends StatefulWidget {
/// Creates a new instance of [PagedValueListView] widget.
const PagedValueListView({
Key? key,
required this.controller,
required this.itemBuilder,
required this.separatorBuilder,
required this.emptyBuilder,
required this.loadMoreErrorBuilder,
required this.loadMoreIndicatorBuilder,
required this.loadingBuilder,
required this.errorBuilder,
this.loadMoreTriggerIndex = 3,
this.padding,
this.physics,
this.reverse = false,
this.scrollController,
this.primary,
this.scrollBehavior,
this.shrinkWrap = false,
this.cacheExtent,
this.dragStartBehavior = DragStartBehavior.start,
this.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual,
this.restorationId,
}) : super(key: key);
/// The [PagedValueNotifier] used to control the list of items.
final PagedValueNotifier<K, V> controller;
/// A builder that is called to build items in the [ListView].
///
/// The `value` parameter is the [V] at this position in the list.
final PagedValueListViewIndexedWidgetBuilder<V> itemBuilder;
/// A builder that is called to build the list separator.
final PagedValueListViewIndexedWidgetBuilder<V> separatorBuilder;
/// A builder that is called to build the empty state of the list.
final WidgetBuilder emptyBuilder;
/// A builder that is called to build the load more error state of the list.
final PagedValueListViewLoadMoreErrorBuilder loadMoreErrorBuilder;
/// A builder that is called to build the load more indicator of the list.
final WidgetBuilder loadMoreIndicatorBuilder;
/// A builder that is called to build the loading state of the list.
final WidgetBuilder loadingBuilder;
/// A builder that is called to build the error state of the list.
final Widget Function(BuildContext, StreamChatError) errorBuilder;
/// The index to take into account when triggering [controller.loadMore].
final int loadMoreTriggerIndex;
/// The amount of space by which to inset the children.
final EdgeInsetsGeometry? padding;
/// {@template flutter.widgets.scroll_view.reverse}
/// Whether the scroll view scrolls in the reading direction.
///
/// For example, if [scrollDirection] is [Axis.vertical], then the scroll view
/// scrolls from top to bottom when [reverse] is false and from bottom to top
/// when [reverse] is true.
///
/// Defaults to false.
/// {@endtemplate}
final bool reverse;
/// {@template flutter.widgets.scroll_view.controller}
/// An object that can be used to control the position to which this scroll
/// view is scrolled.
///
/// Must be null if [primary] is true.
///
/// A [ScrollController] serves several purposes. It can be used to control
/// the initial scroll position (see [ScrollController.initialScrollOffset]).
/// It can be used to control whether the scroll view should automatically
/// save and restore its scroll position in the [PageStorage] (see
/// [ScrollController.keepScrollOffset]). It can be used to read the current
/// scroll position (see [ScrollController.offset]), or change it (see
/// [ScrollController.animateTo]).
/// {@endtemplate}
final ScrollController? scrollController;
/// {@template flutter.widgets.scroll_view.primary}
/// Whether this is the primary scroll view associated with the parent
/// [PrimaryScrollController].
///
/// When this is true, the scroll view is scrollable even if it does not have
/// sufficient content to actually scroll. Otherwise, by default the user can
/// only scroll the view if it has sufficient content. See [physics].
///
/// Also when true, the scroll view is used for default [ScrollAction]s. If a
/// ScrollAction is not handled by an otherwise focused part of the
/// application, the ScrollAction will be evaluated using this scroll view,
/// for example, when executing [Shortcuts] key events like page up and down.
///
/// On iOS, this also identifies the scroll view that will scroll to top in
/// response to a tap in the status bar.
/// {@endtemplate}
///
/// Defaults to true when [scrollController] is null.
final bool? primary;
/// {@macro flutter.widgets.shadow.scrollBehavior}
///
/// [ScrollBehavior]s also provide [ScrollPhysics]. If an explicit
/// [ScrollPhysics] is provided in [physics], it will take precedence,
/// followed by [scrollBehavior], and then the inherited ancestor
/// [ScrollBehavior].
final ScrollBehavior? scrollBehavior;
/// {@template flutter.widgets.scroll_view.shrinkWrap}
/// Whether the extent of the scroll view in the [scrollDirection] should be
/// determined by the contents being viewed.
///
/// If the scroll view does not shrink wrap, then the scroll view will expand
/// to the maximum allowed size in the [scrollDirection]. If the scroll view
/// has unbounded constraints in the [scrollDirection], then [shrinkWrap] must
/// be true.
///
/// Shrink wrapping the content of the scroll view is significantly more
/// expensive than expanding to the maximum allowed size because the content
/// can expand and contract during scrolling, which means the size of the
/// scroll view needs to be recomputed whenever the scroll position changes.
///
/// Defaults to false.
/// {@endtemplate}
final bool shrinkWrap;
/// {@template flutter.widgets.scroll_view.physics}
/// How the scroll view should respond to user input.
///
/// For example, determines how the scroll view continues to animate after the
/// user stops dragging the scroll view.
///
/// Defaults to matching platform conventions. Furthermore, if [primary] is
/// false, then the user cannot scroll if there is insufficient content to
/// scroll, while if [primary] is true, they can always attempt to scroll.
///
/// To force the scroll view to always be scrollable even if there is
/// insufficient content, as if [primary] was true but without necessarily
/// setting it to true, provide an [AlwaysScrollableScrollPhysics] physics
/// object, as in:
///
/// ```dart
/// physics: const AlwaysScrollableScrollPhysics(),
/// ```
///
/// To force the scroll view to use the default platform conventions and not
/// be scrollable if there is insufficient content, regardless of the value of
/// [primary], provide an explicit [ScrollPhysics] object, as in:
///
/// ```dart
/// physics: const ScrollPhysics(),
/// ```
///
/// The physics can be changed dynamically (by providing a new object in a
/// subsequent build), but new physics will only take effect if the _class_ of
/// the provided object changes. Merely constructing a new instance with a
/// different configuration is insufficient to cause the physics to be
/// reapplied. (This is because the final object used is generated
/// dynamically, which can be relatively expensive, and it would be
/// inefficient to speculatively create this object each frame to see if the
/// physics should be updated.)
/// {@endtemplate}
///
/// If an explicit [ScrollBehavior] is provided to [scrollBehavior], the
/// [ScrollPhysics] provided by that behavior will take precedence after
/// [physics].
final ScrollPhysics? physics;
/// {@macro flutter.rendering.RenderViewportBase.cacheExtent}
final double? cacheExtent;
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
final DragStartBehavior dragStartBehavior;
/// {@template flutter.widgets.scroll_view.keyboardDismissBehavior}
/// [ScrollViewKeyboardDismissBehavior] the defines how this [ScrollView] will
/// dismiss the keyboard automatically.
/// {@endtemplate}
final ScrollViewKeyboardDismissBehavior keyboardDismissBehavior;
/// {@macro flutter.widgets.scrollable.restorationId}
final String? restorationId;
@override
State<PagedValueListView<K, V>> createState() =>
_PagedValueListViewState<K, V>();
}
class _PagedValueListViewState<K, V> extends State<PagedValueListView<K, V>> {
PagedValueNotifier<K, V> get _controller => widget.controller;
// Avoids duplicate requests on rebuilds.
bool _hasRequestedNextPage = false;
@override
void initState() {
super.initState();
_controller.doInitialLoad();
}
@override
void didUpdateWidget(covariant PagedValueListView<K, V> oldWidget) {
super.didUpdateWidget(oldWidget);
if (_controller != oldWidget.controller) {
// reset duplicate requests flag
_hasRequestedNextPage = false;
_controller.doInitialLoad();
}
}
@override
Widget build(BuildContext context) => PagedValueListenableBuilder<K, V>(
valueListenable: _controller,
builder: (context, value, _) => value.when(
(items, nextPageKey, error) {
if (items.isEmpty) {
return widget.emptyBuilder(context);
}
return ListView.separated(
padding: widget.padding,
physics: widget.physics,
reverse: widget.reverse,
controller: widget.scrollController,
primary: widget.primary,
shrinkWrap: widget.shrinkWrap,
keyboardDismissBehavior: widget.keyboardDismissBehavior,
restorationId: widget.restorationId,
dragStartBehavior: widget.dragStartBehavior,
cacheExtent: widget.cacheExtent,
itemCount: value.itemCount,
separatorBuilder: (context, index) =>
widget.separatorBuilder(context, items, index),
itemBuilder: (context, index) {
if (!_hasRequestedNextPage) {
final newPageRequestTriggerIndex =
items.length - widget.loadMoreTriggerIndex;
final isBuildingTriggerIndexItem =
index == newPageRequestTriggerIndex;
if (nextPageKey != null && isBuildingTriggerIndexItem) {
// Schedules the request for the end of this frame.
WidgetsBinding.instance?.addPostFrameCallback((_) async {
if (error == null) {
await _controller.loadMore(nextPageKey);
}
_hasRequestedNextPage = false;
});
_hasRequestedNextPage = true;
}
}
if (index == items.length) {
if (error != null) {
return widget.loadMoreErrorBuilder(context, error);
}
return widget.loadMoreIndicatorBuilder(context);
}
return widget.itemBuilder(context, items, index);
},
);
},
loading: () => widget.loadingBuilder(context),
error: (error) => widget.errorBuilder(context, error),
),
);
}
@@ -0,0 +1,205 @@
import 'dart:async';
import 'dart:math';
import 'package:stream_chat/stream_chat.dart' hide Success;
import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart';
/// The default channel page limit to load.
const defaultMessageSearchPagedLimit = 10;
const _kDefaultBackendPaginationLimit = 30;
/// A controller for a user list.
///
/// This class lets you perform tasks such as:
/// * Load initial data.
/// * Load more data using [loadMore].
/// * Replace the previously loaded users.
class StreamMessageSearchListController
extends PagedValueNotifier<String, GetMessageResponse> {
/// Creates a Stream user list controller.
///
/// * `client` is the Stream chat client to use for the channels list.
///
/// * `filter` is the query filters to use.
///
/// * `sort` is the sorting used for the users matching the filters.
///
/// * `presence` sets whether you'll receive user presence updates via the
/// websocket events.
///
/// * `limit` is the limit to apply to the user list.
StreamMessageSearchListController({
required this.client,
required this.filter,
this.messageFilter,
this.searchQuery,
this.sort,
this.limit = defaultMessageSearchPagedLimit,
}) : assert(
messageFilter != null || searchQuery != null,
'Either messageFilter or searchQuery must be provided',
),
assert(
messageFilter == null || searchQuery == null,
'Only one of messageFilter or searchQuery can be provided',
),
_activeFilter = filter,
_activeMessageFilter = messageFilter,
_activeSearchQuery = searchQuery,
_activeSort = sort,
super(const PagedValue.loading());
/// Creates a [StreamUserListController] from the passed [value].
StreamMessageSearchListController.fromValue(
PagedValue<String, GetMessageResponse> value, {
required this.client,
required this.filter,
this.messageFilter,
this.searchQuery,
this.sort,
this.limit = defaultMessageSearchPagedLimit,
}) : assert(
messageFilter != null || searchQuery != null,
'Either messageFilter or searchQuery must be provided',
),
assert(
messageFilter == null || searchQuery == null,
'Only one of messageFilter or searchQuery can be provided',
),
_activeFilter = filter,
_activeMessageFilter = messageFilter,
_activeSearchQuery = searchQuery,
_activeSort = sort,
super(value);
/// The client to use for the channels list.
final StreamChatClient client;
/// The query filters to use.
///
/// You can query on any of the custom fields you've defined on the [User].
///
/// You can also filter other built-in channel fields.
final Filter filter;
Filter _activeFilter;
/// The message query filters to use.
///
/// You can query on any of the custom fields you've defined on the [Channel].
///
/// You can also filter other built-in channel fields.
final Filter? messageFilter;
Filter? _activeMessageFilter;
/// Message String to search on.
final String? searchQuery;
String? _activeSearchQuery;
/// The sorting used for the users matching the filters.
///
/// Sorting is based on field and direction, multiple sorting options
/// can be provided.
///
/// Direction can be ascending or descending.
final List<SortOption>? sort;
List<SortOption>? _activeSort;
/// The limit to apply to the user list. The default is set to
/// [defaultUserPagedLimit].
final int limit;
/// Allows for the change of filters used for user queries.
///
/// Use this if you need to support runtime filter changes,
/// through custom filters UI.
set filter(Filter value) => _activeFilter = value;
/// Allows for the change of message filters used for user queries.
///
/// Use this if you need to support runtime filter changes,
/// through custom filters UI.
set messageFilter(Filter? value) => _activeMessageFilter = value;
/// Allows for the change of filters used for user queries.
///
/// Use this if you need to support runtime filter changes,
/// through custom filters UI.
set searchQuery(String? value) => _activeSearchQuery = value;
/// Allows for the change of the query sort used for user queries.
///
/// Use this if you need to support runtime sort changes,
/// through custom sort UI.
set sort(List<SortOption>? value) => _activeSort = value;
@override
Future<void> doInitialLoad() async {
final limit = min(
this.limit * defaultInitialPagedLimitMultiplier,
_kDefaultBackendPaginationLimit,
);
try {
final response = await client.search(
_activeFilter,
sort: _activeSort,
query: _activeSearchQuery,
messageFilters: _activeMessageFilter,
paginationParams: PaginationParams(limit: limit),
);
final results = response.results;
final nextKey = response.next;
value = PagedValue(
items: results,
nextPageKey: nextKey,
);
} on StreamChatError catch (error) {
value = PagedValue.error(error);
} catch (error) {
final chatError = StreamChatError(error.toString());
value = PagedValue.error(chatError);
}
}
@override
Future<void> loadMore(String nextPageKey) async {
final previousValue = value.asSuccess;
try {
final response = await client.search(
_activeFilter,
sort: _activeSort,
query: _activeSearchQuery,
messageFilters: _activeMessageFilter,
paginationParams: PaginationParams(limit: limit, next: nextPageKey),
);
final results = response.results;
final previousItems = previousValue.items;
final newItems = previousItems + results;
final next = response.next;
final nextKey = next != null && next.isNotEmpty ? next : null;
value = PagedValue(
items: newItems,
nextPageKey: nextKey,
);
} on StreamChatError catch (error) {
value = previousValue.copyWith(error: error);
} catch (error) {
final chatError = StreamChatError(error.toString());
value = previousValue.copyWith(error: chatError);
}
}
@override
Future<void> refresh({bool resetValue = true}) {
if (resetValue) {
_activeFilter = filter;
_activeMessageFilter = messageFilter;
_activeSearchQuery = searchQuery;
_activeSort = sort;
}
return super.refresh(resetValue: resetValue);
}
}
@@ -0,0 +1,165 @@
import 'dart:async';
import 'dart:math';
import 'package:stream_chat/stream_chat.dart' hide Success;
import 'package:stream_chat_flutter_core/src/paged_value_notifier.dart';
/// The default channel page limit to load.
const defaultUserPagedLimit = 10;
const _kDefaultBackendPaginationLimit = 30;
/// A controller for a user list.
///
/// This class lets you perform tasks such as:
/// * Load initial data.
/// * Load more data using [loadMore].
/// * Replace the previously loaded users.
class StreamUserListController extends PagedValueNotifier<int, User> {
/// Creates a Stream user list controller.
///
/// * `client` is the Stream chat client to use for the channels list.
///
/// * `filter` is the query filters to use.
///
/// * `sort` is the sorting used for the users matching the filters.
///
/// * `presence` sets whether you'll receive user presence updates via the
/// websocket events.
///
/// * `limit` is the limit to apply to the user list.
StreamUserListController({
required this.client,
this.filter,
this.sort,
this.presence = true,
this.limit = defaultUserPagedLimit,
}) : _activeFilter = filter,
_activeSort = sort,
super(const PagedValue.loading());
/// Creates a [StreamUserListController] from the passed [value].
StreamUserListController.fromValue(
PagedValue<int, User> value, {
required this.client,
this.filter,
this.sort,
this.presence = true,
this.limit = defaultUserPagedLimit,
}) : _activeFilter = filter,
_activeSort = sort,
super(value);
/// The client to use for the channels list.
final StreamChatClient client;
/// The query filters to use.
///
/// You can query on any of the custom fields you've defined on the [User].
///
/// You can also filter other built-in channel fields.
final Filter? filter;
Filter? _activeFilter;
/// The sorting used for the users matching the filters.
///
/// Sorting is based on field and direction, multiple sorting options
/// can be provided.
///
/// Direction can be ascending or descending.
final List<SortOption>? sort;
List<SortOption>? _activeSort;
/// If true youll receive user presence updates via the websocket events
final bool presence;
/// The limit to apply to the user list. The default is set to
/// [defaultUserPagedLimit].
final int limit;
/// Allows for the change of filters used for user queries.
///
/// Use this if you need to support runtime filter changes,
/// through custom filters UI.
set filter(Filter? value) => _activeFilter = value;
/// Allows for the change of the query sort used for user queries.
///
/// Use this if you need to support runtime sort changes,
/// through custom sort UI.
set sort(List<SortOption>? value) => _activeSort = value;
@override
Future<void> doInitialLoad() async {
final limit = min(
this.limit * defaultInitialPagedLimitMultiplier,
_kDefaultBackendPaginationLimit,
);
try {
final userResponse = await client.queryUsers(
filter: _activeFilter,
sort: _activeSort,
presence: presence,
pagination: PaginationParams(limit: limit),
);
final users = userResponse.users;
final nextKey = users.length < limit ? null : users.length;
value = PagedValue(
items: users,
nextPageKey: nextKey,
);
} on StreamChatError catch (error) {
value = PagedValue.error(error);
} catch (error) {
final chatError = StreamChatError(error.toString());
value = PagedValue.error(chatError);
}
}
@override
Future<void> loadMore(int nextPageKey) async {
final previousValue = value.asSuccess;
try {
final userResponse = await client.queryUsers(
filter: _activeFilter,
sort: _activeSort,
presence: presence,
pagination: PaginationParams(limit: limit, offset: nextPageKey),
);
final users = userResponse.users;
final previousItems = previousValue.items;
final newItems = previousItems + users;
final nextKey = users.length < limit ? null : newItems.length;
value = PagedValue(
items: newItems,
nextPageKey: nextKey,
);
} on StreamChatError catch (error) {
value = previousValue.copyWith(error: error);
} catch (error) {
final chatError = StreamChatError(error.toString());
value = previousValue.copyWith(error: chatError);
}
}
@override
Future<void> refresh({bool resetValue = true}) {
if (resetValue) {
_activeFilter = filter;
_activeSort = sort;
}
return super.refresh(resetValue: resetValue);
}
/// Replaces the previously loaded users with [users] and updates
/// the nextPageKey.
set users(List<User> users) {
value = PagedValue(
items: users,
nextPageKey: users.length,
);
}
}
@@ -12,11 +12,14 @@ export 'src/message_list_core.dart' hide MessageListCoreState;
export 'src/message_search_bloc.dart';
export 'src/message_search_list_core.dart' hide MessageSearchListCoreState;
export 'src/message_text_field_controller.dart';
export 'src/paged_value_list_view.dart';
export 'src/paged_value_notifier.dart' show PagedValueListenableBuilder;
export 'src/stream_channel.dart';
export 'src/stream_channel_list_controller.dart';
export 'src/stream_channel_list_event_handler.dart';
export 'src/stream_chat_core.dart';
export 'src/stream_message_search_list_controller.dart';
export 'src/stream_user_list_controller.dart';
export 'src/typedef.dart';
export 'src/user_list_core.dart' hide UserListCoreState;
export 'src/users_bloc.dart';