diff --git a/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart b/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart index 122c62a3..7d994ae1 100644 --- a/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart +++ b/packages/stream_chat_flutter/example/lib/tutorial_part_3.dart @@ -115,9 +115,11 @@ class _ChannelListPageState extends State { Widget _channelPreviewBuilder( BuildContext context, - Channel channel, + List channels, + int index, StreamChannelListTile defaultTile, ) { + final channel = channels[index]; final lastMessage = channel.state?.messages.reversed.firstWhereOrNull( (message) => !message.isDeleted, ); diff --git a/packages/stream_chat_flutter/lib/src/message_search_item.dart b/packages/stream_chat_flutter/lib/src/message_search_item.dart index fe6d02b7..f8721d13 100644 --- a/packages/stream_chat_flutter/lib/src/message_search_item.dart +++ b/packages/stream_chat_flutter/lib/src/message_search_item.dart @@ -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 diff --git a/packages/stream_chat_flutter/lib/src/message_search_list_view.dart b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart index 380b6545..2554b242 100644 --- a/packages/stream_chat_flutter/lib/src/message_search_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart @@ -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 { +class _MessageSearchListViewState extends State { 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), ); diff --git a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart index 32b4830f..4ece0bf2 100644 --- a/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart +++ b/packages/stream_chat_flutter/lib/src/stream_chat_theme.dart @@ -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 diff --git a/packages/stream_chat_flutter/lib/src/user_list_view.dart b/packages/stream_chat_flutter/lib/src/user_list_view.dart index a604c611..6f1804e7 100644 --- a/packages/stream_chat_flutter/lib/src/user_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/user_list_view.dart @@ -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 +class _UserListViewState extends State with WidgetsBindingObserver { bool get _isListView => widget.crossAxisCount == 1; diff --git a/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_tile.dart b/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_tile.dart index f54c6db9..8c5acea6 100644 --- a/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_tile.dart +++ b/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_tile.dart @@ -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 allMatchesWithSep(String input, [int start = 0]) { - final result = []; - 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; - } -} diff --git a/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_view.dart b/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_view.dart index 1d88f354..f0a32f7d 100644 --- a/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/v4/channel_list_view/stream_channel_list_view.dart @@ -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 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; /// 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 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( + 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 { - 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( - 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, + ), + ), ); } diff --git a/packages/stream_chat_flutter/lib/src/v4/message_search_list_view/stream_message_search_list_tile.dart b/packages/stream_chat_flutter/lib/src/v4/message_search_list_view/stream_message_search_list_tile.dart new file mode 100644 index 00000000..f67597f0 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/v4/message_search_list_view/stream_message_search_list_tile.dart @@ -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, + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/v4/message_search_list_view/stream_message_search_list_view.dart b/packages/stream_chat_flutter/lib/src/v4/message_search_list_view/stream_message_search_list_view.dart new file mode 100644 index 00000000..d7423ef5 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/v4/message_search_list_view/stream_message_search_list_view.dart @@ -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 responses, + int index, +) => + const StreamMessageSearchListSeparator(); + +/// Signature for the item builder that creates the children of the +/// [StreamMessageSearchListView]. +typedef StreamMessageSearchListViewIndexedWidgetBuilder + = StreamListViewIndexedWidgetBuilder; + +/// 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 + 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( + 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: [ + 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, + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/v4/stream_list_view_indexed_widget_builder.dart b/packages/stream_chat_flutter/lib/src/v4/stream_list_view_indexed_widget_builder.dart new file mode 100644 index 00000000..e235e36a --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/v4/stream_list_view_indexed_widget_builder.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +typedef StreamListViewIndexedWidgetBuilder + = Widget Function( + BuildContext context, + List items, + int index, + WidgetType defaultWidget, +); diff --git a/packages/stream_chat_flutter/lib/src/v4/stream_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/v4/stream_message_preview_text.dart new file mode 100644 index 00000000..53d260a2 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/v4/stream_message_preview_text.dart @@ -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 allMatchesWithSep(String input, [int start = 0]) { + final result = []; + 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; + } +} diff --git a/packages/stream_chat_flutter/lib/src/v4/user_list_view/stream_user_list_tile.dart b/packages/stream_chat_flutter/lib/src/v4/user_list_view/stream_user_list_tile.dart new file mode 100644 index 00000000..0d6c79ba --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/v4/user_list_view/stream_user_list_tile.dart @@ -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), + ), + ); + } +} diff --git a/packages/stream_chat_flutter/lib/src/v4/user_list_view/stream_user_list_view.dart b/packages/stream_chat_flutter/lib/src/v4/user_list_view/stream_user_list_view.dart new file mode 100644 index 00000000..3f715193 --- /dev/null +++ b/packages/stream_chat_flutter/lib/src/v4/user_list_view/stream_user_list_view.dart @@ -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 users, + int index, +) => + const StreamUserListSeparator(); + +/// Signature for the item builder that creates the children of the +/// [StreamUserListView]. +typedef StreamUserListViewIndexedWidgetBuilder + = StreamListViewIndexedWidgetBuilder; + +/// 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 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( + 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: [ + 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, + ), + ], + ); + } +} diff --git a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart index 50f39721..0c2fc44b 100644 --- a/packages/stream_chat_flutter/lib/stream_chat_flutter.dart +++ b/packages/stream_chat_flutter/lib/stream_chat_flutter.dart @@ -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'; diff --git a/packages/stream_chat_flutter/test/src/theme/message_search_list_view_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/message_search_list_view_theme_test.dart index bbb27463..556f8109 100644 --- a/packages/stream_chat_flutter/test/src/theme/message_search_list_view_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/message_search_list_view_theme_test.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', ), diff --git a/packages/stream_chat_flutter/test/src/theme/user_list_view_theme_test.dart b/packages/stream_chat_flutter/test/src/theme/user_list_view_theme_test.dart index 6937f4b9..27ef0ea7 100644 --- a/packages/stream_chat_flutter/test/src/theme/user_list_view_theme_test.dart +++ b/packages/stream_chat_flutter/test/src/theme/user_list_view_theme_test.dart @@ -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(), ), ); }, diff --git a/packages/stream_chat_flutter_core/lib/src/paged_value_list_view.dart b/packages/stream_chat_flutter_core/lib/src/paged_value_list_view.dart new file mode 100644 index 00000000..75708589 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/paged_value_list_view.dart @@ -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 = Widget Function( + BuildContext context, + List 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 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 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 itemBuilder; + + /// A builder that is called to build the list separator. + final PagedValueListViewIndexedWidgetBuilder 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> createState() => + _PagedValueListViewState(); +} + +class _PagedValueListViewState extends State> { + PagedValueNotifier get _controller => widget.controller; + + // Avoids duplicate requests on rebuilds. + bool _hasRequestedNextPage = false; + + @override + void initState() { + super.initState(); + _controller.doInitialLoad(); + } + + @override + void didUpdateWidget(covariant PagedValueListView oldWidget) { + super.didUpdateWidget(oldWidget); + if (_controller != oldWidget.controller) { + // reset duplicate requests flag + _hasRequestedNextPage = false; + _controller.doInitialLoad(); + } + } + + @override + Widget build(BuildContext context) => PagedValueListenableBuilder( + 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), + ), + ); +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_message_search_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_message_search_list_controller.dart new file mode 100644 index 00000000..d5b8a2f3 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_message_search_list_controller.dart @@ -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 { + /// 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 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? sort; + List? _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? value) => _activeSort = value; + + @override + Future 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 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 refresh({bool resetValue = true}) { + if (resetValue) { + _activeFilter = filter; + _activeMessageFilter = messageFilter; + _activeSearchQuery = searchQuery; + _activeSort = sort; + } + return super.refresh(resetValue: resetValue); + } +} diff --git a/packages/stream_chat_flutter_core/lib/src/stream_user_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_user_list_controller.dart new file mode 100644 index 00000000..1881e868 --- /dev/null +++ b/packages/stream_chat_flutter_core/lib/src/stream_user_list_controller.dart @@ -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 { + /// 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 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? sort; + List? _activeSort; + + /// If true you’ll 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? value) => _activeSort = value; + + @override + Future 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 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 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 users) { + value = PagedValue( + items: users, + nextPageKey: users.length, + ); + } +} diff --git a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart index 78d82d8b..b5e61d7c 100644 --- a/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart +++ b/packages/stream_chat_flutter_core/lib/stream_chat_flutter_core.dart @@ -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';