diff --git a/lib/dcc_toolkit.dart b/lib/dcc_toolkit.dart index 53a744c..3dc4ce6 100644 --- a/lib/dcc_toolkit.dart +++ b/lib/dcc_toolkit.dart @@ -8,6 +8,10 @@ export 'common/mixins/refresh_stream_mixin.dart'; export 'common/result/result.dart'; export 'common/type_defs.dart'; export 'logger/bolt_logger.dart'; +export 'pagination/paginated_scroll_view.dart'; +export 'pagination/pagination_interface.dart'; +export 'pagination/pagination_mixin.dart'; +export 'pagination/pagination_state.dart'; export 'style/style.dart'; export 'test_util/devices_sizes.dart'; export 'test_util/presentation_event_catcher.dart'; diff --git a/lib/pagination/paginated_scroll_view.dart b/lib/pagination/paginated_scroll_view.dart new file mode 100644 index 0000000..359e42f --- /dev/null +++ b/lib/pagination/paginated_scroll_view.dart @@ -0,0 +1,77 @@ +import 'package:dcc_toolkit/dcc_toolkit.dart'; +import 'package:flutter/material.dart'; + +/// A widget that displays a list of items with pagination. +/// +/// This widget is a [CustomScrollView] that displays a paginated list of items. +/// It uses a [PaginationState] to manage the behavior of the widget. +/// It also uses a [NotificationListener] to listen for scroll events and load more items when the user scrolls to the bottom of the list. +class PaginatedScrollView extends StatelessWidget { + /// Creates a new [PaginatedScrollView]. + const PaginatedScrollView({ + required this.state, + required this.itemBuilder, + this.onLoadMore, + this.topWidget, + this.bottomWidget, + super.key, + }); + + /// The state of the pagination. + final PaginationState state; + + /// The builder for the items. + final Widget Function(BuildContext, T) itemBuilder; + + /// The function to call when the user scrolls to the bottom of the list, to load more items. + final void Function()? onLoadMore; + + /// Optional widget to display at the top of the list. + final Widget? topWidget; + + /// Optional widget to display at the bottom of the list. + final Widget? bottomWidget; + + int get _itemCount => state.items.length; + + T _fetchItem(int index) { + return state.items[index]; + } + + @override + Widget build(BuildContext context) { + return NotificationListener( + onNotification: + onLoadMore != null + ? (notification) { + final metrics = notification.metrics; + if (metrics.extentAfter == metrics.minScrollExtent) { + // We don't trigger loading if no next page is available or while we are already fetching more + if (state.hasNextPage && !state.isLoading) { + onLoadMore?.call(); + return true; + } + } + return false; + } + : null, + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + if (topWidget != null) SliverToBoxAdapter(child: topWidget), + SliverList.builder( + itemCount: _itemCount, + itemBuilder: (context, index) => itemBuilder(context, _fetchItem(index)), + ), + if (state.hasNextPage) + const SliverToBoxAdapter( + child: Padding(padding: EdgeInsets.all(Sizes.m), child: Center(child: CircularProgressIndicator())), + ), + if (bottomWidget != null) SliverToBoxAdapter(child: bottomWidget), + //Bottom insets to be able to scroll the entire content above the FloatingActionButton + const SliverPadding(padding: Paddings.vertical48), + ], + ), + ); + } +} diff --git a/lib/pagination/pagination_interface.dart b/lib/pagination/pagination_interface.dart new file mode 100644 index 0000000..2d916c6 --- /dev/null +++ b/lib/pagination/pagination_interface.dart @@ -0,0 +1,7 @@ +import 'package:dcc_toolkit/pagination/pagination_state.dart'; + +/// Interface for pagination which is used on your bloc state. +abstract interface class PaginationInterface { + /// The current pagination state. + PaginationState get paginationState; +} diff --git a/lib/pagination/pagination_mixin.dart b/lib/pagination/pagination_mixin.dart new file mode 100644 index 0000000..84b6bf1 --- /dev/null +++ b/lib/pagination/pagination_mixin.dart @@ -0,0 +1,55 @@ +import 'package:dcc_toolkit/pagination/pagination_interface.dart'; +import 'package:dcc_toolkit/pagination/pagination_state.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Mixin for pagination. +/// +/// This mixin is used to handle pagination in a bloc. +/// It provides a method to fetch the items for a given page and a method to initialize the pagination state. +/// It also provides a method to load the next page. +/// +/// Example: +/// ```dart +/// class MyBloc extends Bloc with PaginationMixin { +/// @override +/// Future?> fetchPageItems(int page, String? searchQuery) async { +/// // Fetch items logic +/// } +/// +/// @override +/// Future initializeState({String? searchQuery}) async { +/// // Initialize state logic +/// } +/// +/// @override +/// Future loadNextPage(void Function(PaginationState) emitState) async { +/// // Load next page logic +/// } +/// } +/// ``` +mixin PaginationMixin> on Cubit { + /// Fetches the items for the given page + Future?> fetchPageItems({required int page, String? searchQuery}); + + /// Initializes the pagination state + Future initializeState({String? searchQuery}); + + /// Loads the next page + Future loadNextPage(void Function(PaginationState) emitState) async { + final paginationState = state.paginationState; + if (paginationState.currentPage < paginationState.lastPage) { + emitState(paginationState.copyWith(isLoading: true)); + final nextPage = paginationState.currentPage + 1; + final nextItems = await fetchPageItems(page: nextPage, searchQuery: paginationState.searchQuery); + if (nextItems?.isNotEmpty ?? false) { + emitState( + paginationState.copyWith( + items: [...paginationState.items, ...nextItems!], + currentPage: nextPage, + isLoading: false, + ), + ); + } + } + } +} diff --git a/lib/pagination/pagination_state.dart b/lib/pagination/pagination_state.dart new file mode 100644 index 0000000..f0369b5 --- /dev/null +++ b/lib/pagination/pagination_state.dart @@ -0,0 +1,64 @@ +/// State for pagination. +class PaginationState { + /// Creates a new [PaginationState] with the given values. + PaginationState({ + this.items = const [], + this.currentPage = 1, + this.lastPage = 1, + this.isLoading = false, + this.loadingInitialPage = true, + this.hasError = false, + this.total = 0, + this.searchQuery, + }); + + /// All items fetched so far for the loaded pages. + final List items; + + /// The current page in the pagination process. + final int currentPage; + + /// The last page in the pagination process. + final int lastPage; + + /// Whether the current page is being loaded. + final bool isLoading; + + /// Whether the initial page is being loaded. + final bool loadingInitialPage; + + /// Whether there is an error loading the current page. + final bool hasError; + + /// The total number of items. + final int total; + + /// The search query to filter the items. + final String? searchQuery; + + /// Checks if there is a next page to load for the current [PaginationState]. + bool get hasNextPage => currentPage < lastPage; + + /// Copies the current state with the given values. + PaginationState copyWith({ + List? items, + int? currentPage, + int? lastPage, + bool? isLoading, + bool? loadingInitialPage, + bool? hasError, + int? total, + String? searchQuery, + }) { + return PaginationState( + items: items ?? this.items, + currentPage: currentPage ?? this.currentPage, + lastPage: lastPage ?? this.lastPage, + isLoading: isLoading ?? this.isLoading, + loadingInitialPage: loadingInitialPage ?? this.loadingInitialPage, + hasError: hasError ?? this.hasError, + total: total ?? this.total, + searchQuery: searchQuery ?? this.searchQuery, + ); + } +} diff --git a/test/pagination/pagination_mixin_test.dart b/test/pagination/pagination_mixin_test.dart new file mode 100644 index 0000000..96b0148 --- /dev/null +++ b/test/pagination/pagination_mixin_test.dart @@ -0,0 +1,100 @@ +// Ignored because its more descriptive to specify the arguments in the test. +// Immutable warning is ignored for testing purposes. +//ignore_for_file: avoid_redundant_argument_values, avoid_equals_and_hash_code_on_mutable_classes +import 'package:dcc_toolkit/pagination/pagination_interface.dart'; +import 'package:dcc_toolkit/pagination/pagination_mixin.dart'; +import 'package:dcc_toolkit/pagination/pagination_state.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:parameterized_test/parameterized_test.dart'; + +void main() { + group('pagination_state tests', () { + parameterizedTest( + 'hasNextPage', + [ + [1, 1, false], + [2, 2, false], + [2, 1, false], + [1, -1, false], + [-1, 1, true], + [1, 2, true], + ], + (int currentPage, int lastPage, bool expected) => + expect(PaginationState(items: [], currentPage: currentPage, lastPage: lastPage).hasNextPage, expected), + ); + }); + + group('PaginationMixin tests', () { + late _TestCubit cubit; + + setUp(() { + cubit = _TestCubit(); + }); + + test('loadNextPage emits new state with next page items when current page is less than last page', () async { + final paginationState = PaginationState(items: [1, 2, 3], currentPage: 1, lastPage: 3); + cubit.emit(_TestState(paginationState)); + + final nextPaginationStates = ?>[]; + await cubit.loadNextPage(nextPaginationStates.add); + + expect(nextPaginationStates.first!.isLoading, isTrue); + expect(nextPaginationStates.length, 2); + expect(nextPaginationStates.last!.items, equals([1, 2, 3, 1, 2, 3])); + expect(nextPaginationStates.last!.currentPage, equals(2)); + expect(nextPaginationStates.last!.isLoading, isFalse); + }); + + test('loadNextPage does not emit new state when current page is equal to last page', () async { + final paginationState = PaginationState(items: [1, 2, 3], currentPage: 3, lastPage: 3); + cubit.emit(_TestState(paginationState)); + + final nextPaginationStates = ?>[]; + await cubit.loadNextPage(nextPaginationStates.add); + + expect(nextPaginationStates, isEmpty); + }); + + test('loadNextPage does not emit new state when next page items are empty', () async { + final paginationState = PaginationState(items: [1, 2, 3], currentPage: 1, lastPage: 3); + cubit + ..emit(_TestState(paginationState)) + ..returnPages = []; + + final nextPaginationStates = ?>[]; + await cubit.loadNextPage(nextPaginationStates.add); + + expect(nextPaginationStates.first!.isLoading, isTrue); + expect(nextPaginationStates.length, 1); + }); + }); +} + +class _TestState implements PaginationInterface { + _TestState(this.paginationState); + + @override + final PaginationState paginationState; + + @override + int get hashCode => paginationState.hashCode; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is _TestState && other.paginationState == paginationState; + } +} + +class _TestCubit extends Cubit<_TestState> with PaginationMixin { + _TestCubit() : super(_TestState(PaginationState())); + + List returnPages = [1, 2, 3]; + + @override + Future?> fetchPageItems({required int page, String? searchQuery}) async => returnPages; + + @override + Future initializeState({String? searchQuery}) async {} +}