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