diff --git a/packages/stream_chat_flutter/lib/src/message_search_list_view.dart b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart index 0fd45ea1..f80c688f 100644 --- a/packages/stream_chat_flutter/lib/src/message_search_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/message_search_list_view.dart @@ -58,7 +58,7 @@ class MessageSearchListView extends StatefulWidget { required this.filters, this.messageQuery, this.sortOptions, - this.paginationParams, + this.paginationParams = const PaginationParams(limit: 30), this.messageFilters, this.separatorBuilder, this.itemBuilder, @@ -93,7 +93,7 @@ class MessageSearchListView extends StatefulWidget { /// limit: the number of users to return (max is 30) /// offset: the offset (max is 1000) /// message_limit: how many messages should be included to each channel - final PaginationParams? paginationParams; + final PaginationParams paginationParams; /// The message query filters to use. /// You can query on any of the custom fields you've defined on the [Channel]. diff --git a/packages/stream_chat_flutter/lib/src/user_list_view.dart b/packages/stream_chat_flutter/lib/src/user_list_view.dart index eb45f918..4d650192 100644 --- a/packages/stream_chat_flutter/lib/src/user_list_view.dart +++ b/packages/stream_chat_flutter/lib/src/user_list_view.dart @@ -51,7 +51,7 @@ class UserListView extends StatefulWidget { this.filter, this.sort, this.presence, - this.pagination, + this.pagination = const PaginationParams(limit: 30), this.onUserTap, this.onUserLongPress, this.userWidget, @@ -93,7 +93,7 @@ class UserListView extends StatefulWidget { /// limit: the number of users to return (max is 30) /// offset: the offset (max is 1000) /// message_limit: how many messages should be included to each channel - final PaginationParams? pagination; + final PaginationParams pagination; /// Function called when tapping on a channel /// By default it calls [Navigator.push] building a [MaterialPageRoute] diff --git a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart index 6687eef5..25e31ed7 100644 --- a/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/channels_bloc.dart @@ -105,7 +105,8 @@ class ChannelsBlocState extends State }) async { final client = _streamChatCoreState!.client; - final clear = paginationParams.offset == 0; + final clear = + paginationParams.offset == null || paginationParams.offset == 0; if (clear && _paginationEnded) { _paginationEnded = false; } diff --git a/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart b/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart index d9d5933d..c99036a7 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_search_bloc.dart @@ -62,6 +62,8 @@ class MessageSearchBlocState extends State Stream get queryMessagesLoading => _queryMessagesLoadingController.stream; + bool _paginationEnded = false; + /// Calls [StreamChatClient.search] updating /// [messagesStream] and [queryMessagesLoading] stream Future search({ @@ -69,24 +71,31 @@ class MessageSearchBlocState extends State Filter? messageFilter, List? sort, String? query, - PaginationParams? pagination, + PaginationParams pagination = const PaginationParams(limit: 30), }) async { final client = _streamChatCoreState.client; - if (_queryMessagesLoadingController.value == true) return; + var clear = false; + if (sort != null) { + clear |= pagination.next == null; + } else { + final offset = pagination.offset; + clear |= offset == null || offset == 0; + } + + if (clear && _paginationEnded) { + _paginationEnded = false; + } + + if ((!clear && _paginationEnded) || + _queryMessagesLoadingController.value == true) { + return; + } if (_messageResponses.hasValue) { _queryMessagesLoadingController.add(true); } try { - var clear = pagination == null; - if (sort != null) { - clear |= pagination?.next == null; - } else { - final offset = pagination?.offset; - clear |= offset == null || offset == 0; - } - final oldMessages = List.from(messageResponses ?? []); final response = await client.search( @@ -107,15 +116,19 @@ class MessageSearchBlocState extends State ? previous : /*reset previousId if we get nothing*/ null; + final newMessages = response.results; if (clear) { - _messageResponses.add(response.results); + _messageResponses.add(newMessages); } else { - final temp = oldMessages + response.results; + final temp = oldMessages + newMessages; _messageResponses.add(temp); } if (_messageResponses.hasValue && _queryMessagesLoadingController.value) { _queryMessagesLoadingController.add(false); } + if (newMessages.isEmpty || newMessages.length < pagination.limit) { + _paginationEnded = true; + } } catch (e, stk) { // reset loading controller _queryMessagesLoadingController.add(false); diff --git a/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart b/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart index 47ffe28b..6e3d04fb 100644 --- a/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/message_search_list_core.dart @@ -47,7 +47,7 @@ class MessageSearchListCore extends StatefulWidget { required this.filters, this.messageQuery, this.sortOptions, - this.paginationParams, + this.paginationParams = const PaginationParams(limit: 30), this.messageFilters, this.messageSearchListController, }) : assert( @@ -59,8 +59,8 @@ class MessageSearchListCore extends StatefulWidget { "Can't provide both `query` and `messageFilters` at the same time", ), assert( - paginationParams?.offset == null || - paginationParams?.offset == 0 || + paginationParams.offset == null || + paginationParams.offset == 0 || sortOptions == null, 'Cannot specify `offset` with `sortOptions` parameter', ), @@ -90,7 +90,7 @@ class MessageSearchListCore extends StatefulWidget { /// Pagination parameters /// limit: the number of messages to return (max is 30) /// offset: the offset (max is 1000) - final PaginationParams? paginationParams; + final PaginationParams paginationParams; /// The message query filters to use. /// You can query on any of the custom fields you've defined on the [Channel]. @@ -169,13 +169,13 @@ class MessageSearchListCoreState extends State { /// Fetches more messages with updated pagination and updates the widget Future paginateData() { - PaginationParams? pagination; + PaginationParams pagination; if (widget.sortOptions != null) { - pagination = widget.paginationParams?.copyWith( + pagination = widget.paginationParams.copyWith( next: _messageSearchBloc?.nextId, ); } else { - pagination = widget.paginationParams?.copyWith( + pagination = widget.paginationParams.copyWith( offset: _messageSearchBloc?.messageResponses?.length, ); } @@ -196,8 +196,8 @@ class MessageSearchListCoreState extends State { widget.messageQuery?.toString() != oldWidget.messageQuery?.toString() || widget.messageFilters?.toString() != oldWidget.messageFilters?.toString() || - widget.paginationParams?.toJson().toString() != - oldWidget.paginationParams?.toJson().toString()) { + widget.paginationParams.toJson().toString() != + oldWidget.paginationParams.toJson().toString()) { loadData(); } diff --git a/packages/stream_chat_flutter_core/lib/src/user_list_core.dart b/packages/stream_chat_flutter_core/lib/src/user_list_core.dart index 57d61fe6..e403be4e 100644 --- a/packages/stream_chat_flutter_core/lib/src/user_list_core.dart +++ b/packages/stream_chat_flutter_core/lib/src/user_list_core.dart @@ -66,7 +66,7 @@ class UserListCore extends StatefulWidget { this.filter, this.sort, this.presence, - this.pagination, + this.pagination = const PaginationParams(limit: 30), this.groupAlphabetically = false, this.userListController, }) : super(key: key); @@ -106,7 +106,7 @@ class UserListCore extends StatefulWidget { /// limit: the number of users to return (max is 30) /// offset: the offset (max is 1000) /// message_limit: how many messages should be included to each channel - final PaginationParams? pagination; + final PaginationParams pagination; /// Set it to true to group users by their first character /// @@ -201,7 +201,7 @@ class UserListCoreState extends State filter: widget.filter, sort: widget.sort, presence: widget.presence, - pagination: widget.pagination!.copyWith( + pagination: widget.pagination.copyWith( offset: _usersBloc!.users?.length ?? 0, ), ); @@ -212,8 +212,8 @@ class UserListCoreState extends State if (widget.filter?.toString() != oldWidget.filter?.toString() || jsonEncode(widget.sort) != jsonEncode(oldWidget.sort) || widget.presence != oldWidget.presence || - widget.pagination?.toJson().toString() != - oldWidget.pagination?.toJson().toString()) { + widget.pagination.toJson().toString() != + oldWidget.pagination.toJson().toString()) { loadData(); } diff --git a/packages/stream_chat_flutter_core/lib/src/users_bloc.dart b/packages/stream_chat_flutter_core/lib/src/users_bloc.dart index e7916478..c119badc 100644 --- a/packages/stream_chat_flutter_core/lib/src/users_bloc.dart +++ b/packages/stream_chat_flutter_core/lib/src/users_bloc.dart @@ -57,6 +57,8 @@ class UsersBlocState extends State late StreamChatCoreState _streamChatCore; + bool _paginationEnded = false; + /// The Query Users method allows you to search for users and see if they are /// online/offline. /// [API Reference](https://getstream.io/chat/docs/flutter-dart/query_users/?language=dart) @@ -64,19 +66,26 @@ class UsersBlocState extends State Filter? filter, List? sort, bool? presence, - PaginationParams? pagination, + PaginationParams pagination = const PaginationParams(limit: 30), }) async { final client = _streamChatCore.client; - if (_queryUsersLoadingController.value == true) return; + final clear = pagination.offset == null || pagination.offset == 0; + + if (clear && _paginationEnded) { + _paginationEnded = false; + } + + if ((!clear && _paginationEnded) || + _queryUsersLoadingController.value == true) { + return; + } if (_usersController.hasValue) { _queryUsersLoadingController.add(true); } try { - final clear = pagination == null || pagination.offset == 0; - final oldUsers = List.from(users ?? []); final usersResponse = await client.queryUsers( @@ -86,6 +95,7 @@ class UsersBlocState extends State pagination: pagination, ); + final newUsers = usersResponse.users; if (clear) { _usersController.add(usersResponse.users); } else { @@ -95,6 +105,9 @@ class UsersBlocState extends State if (_usersController.hasValue && _queryUsersLoadingController.value) { _queryUsersLoadingController.add(false); } + if (newUsers.isEmpty || newUsers.length < pagination.limit) { + _paginationEnded = true; + } } catch (e, stk) { // reset loading controller _queryUsersLoadingController.add(false); diff --git a/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart b/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart index 979ca5b2..c689d90c 100644 --- a/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart +++ b/packages/stream_chat_flutter_core/test/message_search_bloc_test.dart @@ -31,8 +31,7 @@ void main() { ); testWidgets( - 'messageSearchBlocState.search() should throw if used where ' - 'StreamChat is not present in the widget tree', + '''messageSearchBlocState.search() should throw if used where StreamChat is not present in the widget tree''', (tester) async { const messageSearchBloc = MessageSearchBloc( child: Offstage(), @@ -74,7 +73,10 @@ void main() { messageFilters: any(named: 'messageFilters'), paginationParams: any(named: 'paginationParams'), )).thenAnswer( - (_) async => SearchMessagesResponse()..results = messageResponseList, + (_) async => SearchMessagesResponse() + ..results = messageResponseList + ..next = null + ..previous = null, ); messageSearchBlocState.search(filter: testFilter); @@ -95,8 +97,7 @@ void main() { ); testWidgets( - 'messageSearchBlocState.messagesStream should emit error ' - 'if client.search() throws', + '''messageSearchBlocState.messagesStream should emit error if client.search() throws''', (tester) async { const messageSearchBlocKey = Key('messageSearchBloc'); const childKey = Key('child'); @@ -144,9 +145,7 @@ void main() { ); testWidgets( - 'calling messageSearchBlocState.search() again with an offset ' - 'should emit new data through messagesStream and also emit loading state ' - 'through queryMessagesLoading', + '''calling messageSearchBlocState.search() again with an offset should emit new data through messagesStream and also emit loading state through queryMessagesLoading''', (tester) async { const messageSearchBlocKey = Key('messageSearchBloc'); const childKey = Key('child'); @@ -168,19 +167,23 @@ void main() { find.byKey(messageSearchBlocKey), ); - final messageResponseList = _generateMessages(); + const pagination = PaginationParams(limit: 25); + final messageResponseList = _generateMessages(count: 25); when(() => mockClient.search( testFilter, query: any(named: 'query'), sort: any(named: 'sort'), messageFilters: any(named: 'messageFilters'), - paginationParams: any(named: 'paginationParams'), + paginationParams: pagination, )).thenAnswer( - (_) async => SearchMessagesResponse()..results = messageResponseList, + (_) async => SearchMessagesResponse() + ..results = messageResponseList + ..next = null + ..previous = null, ); - messageSearchBlocState.search(filter: testFilter); + messageSearchBlocState.search(pagination: pagination, filter: testFilter); await expectLater( messageSearchBlocState.messagesStream, @@ -192,22 +195,24 @@ void main() { query: any(named: 'query'), sort: any(named: 'sort'), messageFilters: any(named: 'messageFilters'), - paginationParams: any(named: 'paginationParams'), + paginationParams: pagination, )).called(1); final offset = messageResponseList.length; final paginatedMessageResponseList = _generateMessages(offset: offset); - final pagination = PaginationParams(offset: offset); + final newPagination = pagination.copyWith(offset: offset); when(() => mockClient.search( testFilter, query: any(named: 'query'), sort: any(named: 'sort'), messageFilters: any(named: 'messageFilters'), - paginationParams: pagination, + paginationParams: newPagination, )).thenAnswer( - (_) async => - SearchMessagesResponse()..results = paginatedMessageResponseList, + (_) async => SearchMessagesResponse() + ..results = paginatedMessageResponseList + ..next = null + ..previous = null, ); messageSearchBlocState.search(pagination: pagination, filter: testFilter); @@ -236,9 +241,7 @@ void main() { ); testWidgets( - 'calling messageSearchBlocState.search() again with an offset ' - 'should emit error through queryUsersLoading if ' - 'client.search() throws', + '''calling messageSearchBlocState.search() again with an offset should emit error through queryUsersLoading if client.search() throws''', (tester) async { const messageSearchBlocKey = Key('messageSearchBloc'); const childKey = Key('child'); @@ -260,19 +263,23 @@ void main() { find.byKey(messageSearchBlocKey), ); - final messageResponseList = _generateMessages(); + const pagination = PaginationParams(limit: 25); + final messageResponseList = _generateMessages(count: 25); when(() => mockClient.search( testFilter, query: any(named: 'query'), sort: any(named: 'sort'), messageFilters: any(named: 'messageFilters'), - paginationParams: any(named: 'paginationParams'), + paginationParams: pagination, )).thenAnswer( - (_) async => SearchMessagesResponse()..results = messageResponseList, + (_) async => SearchMessagesResponse() + ..results = messageResponseList + ..next = null + ..previous = null, ); - messageSearchBlocState.search(filter: testFilter); + messageSearchBlocState.search(pagination: pagination, filter: testFilter); await expectLater( messageSearchBlocState.messagesStream, @@ -284,11 +291,11 @@ void main() { query: any(named: 'query'), sort: any(named: 'sort'), messageFilters: any(named: 'messageFilters'), - paginationParams: any(named: 'paginationParams'), + paginationParams: pagination, )).called(1); final offset = messageResponseList.length; - final pagination = PaginationParams(offset: offset); + final newPagination = pagination.copyWith(offset: offset); const error = 'Error! Error! Error!'; when(() => mockClient.search( @@ -296,10 +303,13 @@ void main() { query: any(named: 'query'), sort: any(named: 'sort'), messageFilters: any(named: 'messageFilters'), - paginationParams: pagination, + paginationParams: newPagination, )).thenThrow(error); - messageSearchBlocState.search(pagination: pagination, filter: testFilter); + messageSearchBlocState.search( + pagination: newPagination, + filter: testFilter, + ); await expectLater( messageSearchBlocState.queryMessagesLoading, @@ -311,8 +321,80 @@ void main() { query: any(named: 'query'), sort: any(named: 'sort'), messageFilters: any(named: 'messageFilters'), - paginationParams: pagination, + paginationParams: newPagination, )).called(1); }, ); + + testWidgets( + '''calling messageSearchBlocState.search() again with an offset should do nothing and return if pagination is completed''', + (tester) async { + const messageSearchBlocKey = Key('messageSearchBloc'); + const childKey = Key('child'); + const messageSearchBloc = MessageSearchBloc( + key: messageSearchBlocKey, + child: Offstage(key: childKey), + ); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: messageSearchBloc, + ), + ); + + final messageSearchBlocState = tester.state( + find.byKey(messageSearchBlocKey), + ); + + const pagination = PaginationParams(limit: 25); + + final messageResponseList = _generateMessages(count: 20); + + when(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: pagination, + )).thenAnswer( + (_) async => SearchMessagesResponse() + ..results = messageResponseList + ..next = null + ..previous = null, + ); + + messageSearchBlocState.search(pagination: pagination, filter: testFilter); + + await expectLater( + messageSearchBlocState.messagesStream, + emits(isSameMessageResponseListAs(messageResponseList)), + ); + + verify(() => mockClient.search( + testFilter, + query: any(named: 'query'), + sort: any(named: 'sort'), + messageFilters: any(named: 'messageFilters'), + paginationParams: pagination, + )).called(1); + + final offset = messageResponseList.length; + final newPagination = pagination.copyWith(offset: offset); + + messageSearchBlocState.search( + filter: testFilter, + pagination: newPagination, + ); + + // should emit nothing. + await expectLater( + // skipping the initial data (behaviorSubject). + messageSearchBlocState.messagesStream.skip(1), + emitsInOrder([]), + ); + }, + ); } diff --git a/packages/stream_chat_flutter_core/test/users_bloc_test.dart b/packages/stream_chat_flutter_core/test/users_bloc_test.dart index af088a76..549f9291 100644 --- a/packages/stream_chat_flutter_core/test/users_bloc_test.dart +++ b/packages/stream_chat_flutter_core/test/users_bloc_test.dart @@ -164,16 +164,17 @@ void main() { find.byKey(usersBlocKey), ); - final users = _generateUsers(); + const pagination = PaginationParams(limit: 25); + final users = _generateUsers(count: 25); when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), presence: any(named: 'presence'), - pagination: any(named: 'pagination'), + pagination: pagination, )).thenAnswer((_) async => QueryUsersResponse()..users = users); - usersBlocState.queryUsers(); + usersBlocState.queryUsers(pagination: pagination); await expectLater( usersBlocState.usersStream, @@ -184,23 +185,23 @@ void main() { filter: any(named: 'filter'), sort: any(named: 'sort'), presence: any(named: 'presence'), - pagination: any(named: 'pagination'), + pagination: pagination, )).called(1); final offset = users.length; final paginatedUsers = _generateUsers(offset: offset); - final pagination = PaginationParams(offset: offset); + final newPagination = pagination.copyWith(offset: offset); when(() => mockClient.queryUsers( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - presence: any(named: 'presence'), - pagination: pagination, - )) - .thenAnswer( - (_) async => QueryUsersResponse()..users = paginatedUsers); + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: newPagination, + )).thenAnswer( + (_) async => QueryUsersResponse()..users = paginatedUsers, + ); - usersBlocState.queryUsers(pagination: pagination); + usersBlocState.queryUsers(pagination: newPagination); await Future.wait([ expectLater( @@ -217,7 +218,7 @@ void main() { filter: any(named: 'filter'), sort: any(named: 'sort'), presence: any(named: 'presence'), - pagination: pagination, + pagination: newPagination, )).called(1); }, ); @@ -247,13 +248,89 @@ void main() { find.byKey(usersBlocKey), ); - final users = _generateUsers(); + const pagination = PaginationParams(limit: 25); + final users = _generateUsers(count: 25); when(() => mockClient.queryUsers( filter: any(named: 'filter'), sort: any(named: 'sort'), presence: any(named: 'presence'), - pagination: any(named: 'pagination'), + pagination: pagination, + )).thenAnswer((_) async => QueryUsersResponse()..users = users); + + usersBlocState.queryUsers(pagination: pagination); + + await expectLater( + usersBlocState.usersStream, + emits(isSameUserListAs(users)), + ); + + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: pagination, + )).called(1); + + final offset = users.length; + final newPagination = pagination.copyWith(offset: offset); + + const error = 'Error! Error! Error!'; + + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: newPagination, + )).thenThrow(error); + + usersBlocState.queryUsers(pagination: newPagination); + + await expectLater( + usersBlocState.queryUsersLoading, + emitsError(error), + ); + + verify(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: newPagination, + )).called(1); + }, + ); + + testWidgets( + '''calling usersBlocState.queryUsers() again with an offset should do nothing and return if pagination is completed''', + (tester) async { + const usersBlocKey = Key('usersBloc'); + const childKey = Key('child'); + const usersBloc = UsersBloc( + key: usersBlocKey, + child: Offstage(key: childKey), + ); + + final mockClient = MockClient(); + + await tester.pumpWidget( + StreamChatCore( + client: mockClient, + child: usersBloc, + ), + ); + + final usersBlocState = tester.state( + find.byKey(usersBlocKey), + ); + + const pagination = PaginationParams(limit: 30); + final users = _generateUsers(count: 25); + + when(() => mockClient.queryUsers( + filter: any(named: 'filter'), + sort: any(named: 'sort'), + presence: any(named: 'presence'), + pagination: pagination, )).thenAnswer((_) async => QueryUsersResponse()..users = users); usersBlocState.queryUsers(); @@ -271,30 +348,16 @@ void main() { )).called(1); final offset = users.length; - final pagination = PaginationParams(offset: offset); + final newPagination = pagination.copyWith(offset: offset); - const error = 'Error! Error! Error!'; - - when(() => mockClient.queryUsers( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - presence: any(named: 'presence'), - pagination: pagination, - )).thenThrow(error); - - usersBlocState.queryUsers(pagination: pagination); + usersBlocState.queryUsers(pagination: newPagination); + // should emit nothing. await expectLater( - usersBlocState.queryUsersLoading, - emitsError(error), + // skipping the initial data (behaviorSubject). + usersBlocState.usersStream, + emitsInOrder([]), ); - - verify(() => mockClient.queryUsers( - filter: any(named: 'filter'), - sort: any(named: 'sort'), - presence: any(named: 'presence'), - pagination: pagination, - )).called(1); }, ); }