diff --git a/.gitignore b/.gitignore index cbc2fdb..617b760 100644 --- a/.gitignore +++ b/.gitignore @@ -76,4 +76,4 @@ build/ !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages # flutter library -/pubspec.lock \ No newline at end of file +/pubspec.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 78c0f07..140034b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,28 @@ +## UNRELEASED +### Added +- `PagingListener` widget to connect a `PagingController` to a `PagedLayoutBuilder`. + +### Changed +- `PagingController` no longer has `addPageRequestListener` method and `firstPageKey` parameter. Use the `fetchPage` parameter of the constructor instead. +- `PagingController` no longer has the `itemList`, `error`, and `nextPageKey` getters and setters. All values are now stored in `PagingState`. +- `PagingController` no longer has the `appendPage` and `appendLastPage` methods. Use the `copyWith` method of `PagingState` to update its `pages`, `keys`, and `hasNextPage` fields. +- `PagingController` no longer has the `retryLastFailedRequest` method. You can simply call `fetchNextPage` to try again. +- `PagingController` no longer has the `invisibleItemsThreshold` field. It is now configured in `PagedChildBuilderDelegate`. +- `PagingController` now features getters matching the fields of `PagingState` as well as `mapItems` to modify the items. +- `PagedLayoutBuilder` no longer accepts `pagingController` as a parameter. It now takes `PagingState` and `fetchNextPage` instead. +- `PagingState` now uses `pages` (`List>`) instead of `itemList` (`List`). A new extension getter `items` is provided for flattening. +- `PagingState` now features `keys`, a list storing all fetched keys, and `hasNextPage` replacing `nextPageKey`. +- `PagingState` now includes `isLoading`, which tracks whether a request is in progress. +- `PagingState` now provides `error` as type `Object?` instead of `dynamic`. +- `PagingState` now includes `mapItems` and `filterItems` extension methods for modifying items conveniently. + +### Fixed +- `PagingController` now deduplicates requests. +- `PagingController` refresh operations now cancel previous requests. +- Off-by-one error in `invisibleItemsThreshold` calculation. +- Failure to trigger page request when `invisibleItemsThreshold` is too large. +- Animating between states with `animateTransitions`. + ## 4.1.0 - 2024-11-09 ### Added - [PagedSliverMasonryGrid](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverMasonryGrid-class.html). @@ -8,7 +33,7 @@ ### Removed - `pubspec.lock` from version control. -## [4.0.0] - 2023-08-17 +## 4.0.0 - 2023-08-17 ### Added - [PagedMasonryGridView](https://pub.dev/documentation/infinite_scroll_pagination/4.0.0/infinite_scroll_pagination/PagedMasonryGridView-class.html). - [PagedPageView](https://pub.dev/documentation/infinite_scroll_pagination/4.0.0/infinite_scroll_pagination/PagedPageView-class.html). @@ -17,19 +42,19 @@ ### Changed - Renames `PagedSliverBuilder` to [PagedLayoutBuilder](https://pub.dev/documentation/infinite_scroll_pagination/4.0.0/infinite_scroll_pagination/PagedLayoutBuilder-class.html). -## [3.2.0] - 2022-05-23 +## 3.2.0 - 2022-05-23 ### Changed - Migrates to Flutter 3. -## [3.1.0] - 2021-07-04 +## 3.1.0 - 2021-07-04 ### Added - [animated status transitions](https://pub.dev/packages/infinite_scroll_pagination/example#animating-status-transitions). -## [3.0.1+1] - 2021-05-23 +## 3.0.1+1 - 2021-05-23 ### Added - [Flutter Favorite](https://flutter.dev/docs/development/packages-and-plugins/favorites) status to the README. -## [3.0.1] - 2021-03-08 +## 3.0.1 - 2021-03-08 ### Added - New unit tests. @@ -39,17 +64,17 @@ ### Fixed - Code formatting in `ListenableListener`. -## [3.0.0] - 2021-03-04 +## 3.0.0 - 2021-03-04 ### Changed - Promotes null safety to stable release. - Migrates example project to null safety. - Migrates code samples to null safety. -## [3.0.0-nullsafety.0] - 2021-02-06 +## 3.0.0-nullsafety.0 - 2021-02-06 ### Changed - Migrates to null safety. -## [2.3.0] - 2021-01-15 +## 2.3.0 - 2021-01-15 ### Added - [alternative constructor](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagingController/PagingController.fromValue.html) to [PagingController](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagingController-class.html) receiving an initial [PagingState](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagingState-class.html). @@ -57,19 +82,19 @@ - Cookbook file name. - LICENSE file. -## [2.2.4] - 2021-01-08 +## 2.2.4 - 2021-01-08 ### Fixed - New page requests happening before the end of the current frame. -## [2.2.3] - 2020-12-14 +## 2.2.3 - 2020-12-14 ### Fixed - Bug in which manually resetting to a previous page would stop requesting subsequent pages. -## [2.2.2] - 2020-11-04 +## 2.2.2 - 2020-11-04 ### Added - Condition to avoid requesting the first page when there are preloaded items. -## [2.2.1] - 2020-10-21 +## 2.2.1 - 2020-10-21 ### Added - `shrinkWrapFirstPageIndicators` property to [PagedSliverList](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverList-class.html), [PagedSliverGrid](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverGrid-class.html), and [PagedSliverBuilder](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverBuilder-class.html). @@ -79,73 +104,50 @@ ### Fixed - Separator being displayed on completed lists. -## [2.2.0+1] - 2020-10-19 +## 2.2.0+1 - 2020-10-19 ### Changed - Constraints the Flutter SDK dependency to a minimum version of 1.22.0. -## [2.2.0] - 2020-10-18 +## 2.2.0 - 2020-10-18 ### Added - New constructor parameters from [ScrollView](https://api.flutter.dev/flutter/widgets/ScrollView-class.html) to [PagedListView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedListView-class.html) and [PagedGridView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedGridView-class.html). -## [2.1.0+1] - 2020-10-13 +## 2.1.0+1 - 2020-10-13 ### Added - Link to [raywenderlich.com tutorial](https://www.raywenderlich.com/265121/infinite-scrolling-pagination-in-flutter). ### Changed - Examples to async/await. -## [2.1.0] - 2020-10-10 +## 2.1.0 - 2020-10-10 ### Added - [noMoreItemsIndicatorBuilder](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedChildBuilderDelegate/noMoreItemsIndicatorBuilder.html) to [PagedChildBuilderDelegate](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedChildBuilderDelegate-class.html). - Properties to both grid widgets to let you choose whether to display the progress, error, and completed listing indicators as grid items or below the grid, as in the list widgets. -## [2.0.1] - 2020-10-03 +## 2.0.1 - 2020-10-03 ### Fixed - [PagingController](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagingController-class.html) not calling its status listeners. -## [2.0.0] - 2020-10-02 +## 2.0.0 - 2020-10-02 ### Changed - **BREAKING CHANGE**: Replaces [PagedDataSource](https://pub.dev/documentation/infinite_scroll_pagination/1.1.1/infinite_scroll_pagination/PagedDataSource-class.html) and [PagedStateChangeListener](https://pub.dev/documentation/infinite_scroll_pagination/1.1.1/infinite_scroll_pagination/PagedStateChangeListener-class.html) with [PagingController](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagingController-class.html), favoring composition over inheritance. -## [1.1.1] - 2020-09-23 +## 1.1.1 - 2020-09-23 ### Removed - Scroll from first page progress indicator, first page error indicator, and no items found indicator. -## [1.1.0] - 2020-09-18 +## 1.1.0 - 2020-09-18 ### Added - [PagedStateChangeListener](https://pub.dev/documentation/infinite_scroll_pagination/1.1.0/infinite_scroll_pagination/PagedStateChangeListener-class.html). -## [1.0.0+2] - 2020-08-22 +## 1.0.0+2 - 2020-08-22 ### Added - Documentation to `PagedDataSource` properties. ### Changed - README images reference URL. -## [1.0.0+1] - 2020-08-22 +## 1.0.0+1 - 2020-08-22 ### Added - Images to README.md. - Initial release. - -[4.0.0]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/3.2.0..4.0.0 -[3.2.0]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/3.1.0..3.2.0 -[3.1.0]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/3.0.1+1..3.1.0 -[3.0.1+1]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/3.0.1..3.0.1+1 -[3.0.1]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/3.0.0..3.0.1 -[3.0.0]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/3.0.0-nullsafety.0..3.0.0 -[3.0.0-nullsafety.0]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/2.3.0..3.0.0-nullsafety.0 -[2.3.0]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/2.2.4..2.3.0 -[2.2.4]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/2.2.3..2.2.4 -[2.2.3]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/2.2.2..2.2.3 -[2.2.2]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/2.2.1..2.2.2 -[2.2.1]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/2.2.0+1..2.2.1 -[2.2.0+1]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/2.2.0..2.2.0+1 -[2.2.0]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/2.1.0+1..2.2.0 -[2.1.0+1]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/2.1.0..2.1.0+1 -[2.1.0]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/2.0.1..2.1.0 -[2.0.1]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/2.0.0..2.0.1 -[2.0.0]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/1.1.1..2.0.0 -[1.1.1]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/1.1.0..1.1.1 -[1.1.0]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/1.0.0+2..1.1.0 -[1.0.0+2]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/1.0.0+1..1.0.0+2 -[1.0.0+1]: https://github.com/EdsonBueno/infinite_scroll_pagination/compare/1.0.0..1.0.0+1 diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..fbd3f05 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,134 @@ +# From v4 to v5 + +In v5, the package decouples the PagingController from PagedLayoutBuilder and its descendants, to allow greater freedom in how a PagingState is managed. +This is a large breaking change and will require refactoring in your code. + +## Dependencies + +The package was upgraded to a newer modern flutter major version. + +- Newly requires `dart: ">=3.4.0"` and `flutter: ">=3.0.0"` for modern language features. +- Newly depends on `collection: ">=1.15.0"` for deep collection equality on `PagingState`. +- Newly depends on `meta: ">=1.8.0"` for annotations on `PagingState` extension methods. + +## PagingController + +Since PagingController is now optional, it was changed to be more opinionated and easier to use. + +Instead of adding `PageRequestListener` to your PagingController, like so: + +```dart +final pagingController = PagingController(firstPageKey: 1); +pagingController.addPageRequestListener(fetchPage); +``` + +and manually updating the next page key: + +```dart +pagingController.appendPage(newItems, nextPageKey); +``` + +PagingController now directly takes and controls the fetching process: + +```dart +late final pagingController = PagingController( + getNextPageKey: (state) => (state.keys?.last ?? 0) + 1, + fetchPage: (pageKey) => fetchPage(pageKey), +); +``` + +This fixes several issues of the past: + +- Requests will now be actively deduplicated +- Refresh can now cancel previous requests + +The PagingController can also be arbitrarily extended to include additional functionality that you might require. +The source code explains how to structure new code. + +Lastly, the various getter and setter methods previously featured to modify the state have been removed. +New getters have been added, however, setters have been left out since it should not be necessary to modify the state often. +One exception is the newly provided `mapItems` extension method, which can be used to modify the items in a convenient way, while retaining their page structure. + +### API Changes + +- `itemList` and `nextPageKey` properties have been removed. +- `pages`, `items`, `keys`, `error`, `hasNextPage` and `isLoading` extension getters as well as `mapItems` to modify the items have been added. +- `addPageRequestListener` was removed. Use the `fetchPage` parameter of the constructor instead. +- `appendPage` and `appendLastPage` have been removed. Use the `copyWith` method of the `PagingState` to update the `pages`, `keys` and `hasNextPage` fields. +- `retryLastFailedRequest` was removed. You can simply call `fetchNextPage` to try again. +- `invisibleItemsThreshold` parameter has been removed. To configure the `invisibleItemsThreshold` of a layout, use the corresponding parameter of its `PagedChildBuilderDelegate`. + +## PagedLayoutBuilder + +Because the PagingController is now independant, PagedLayoutBuilder and its subclasses no longer take a controller as a parameter like so: + +```dart +PagedListView.builder( + pagingController: pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item), + ), +), +``` + +Instead, it is more agnostic: + +```dart +PagedListView.builder( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item), + ), +), +``` + +Taking in a `PagingState` and a `fetchNextPage` function. `fetchNextPage` is a void function, and does not receive a page key. + +This new design can be used in combination with any state management solution much more easily. A PagingController is no longer required. +To continue using a PagingController for its convenience, you can connect it to any number of Paged Layouts via the PagingListener: + +```dart +PagingListener( + controller: pagingController, + builder: (context, state, fetchNextPage) => + PagedListView.builder( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item), + ), + ), +), +``` + +It is highly recommended to directly store a PagingState inside of your preferred state management solution, +instead of storing a PagingController, should you not wish to use the PagingController directly. + +Examples of using a custom state management solution can be found in the example project. + +### API Changes + +- No longer features `pagingController` parameter. Use the `state` and `fetchNextPage` parameters instead. +- Now uses `invisibleItemsThreshold` from `PagedChildBuilderDelegate` instead of `PagingController`. + +## PagingState + +The PagingState has been updated to be more flexible: + +- It now includes a List of all keys, `keys`, that have been fetched, each index corresponding to a page of items. +- Instead of storing the next page key, it now includes a boolean `hasNextPage` to indicate if there are more pages to fetch. +- Lastly it now also includes a loading state, in `isLoading`. + +Because Items are now stored within pages, it is more difficult to modify the items directly. +To make this easier, a `mapItems` extension method has been added to modify the items by iterating over them. +Additionally, a `filterItems` extension method has been added to filter the items. This is useful for creating locally filtered computed states. + +### API Changes + +- `itemList` has been replaced by `pages`, which is List> instead of List. An extension `items` getter is provided to flatten the list. +- `keys` is a new field that stores all keys that have been fetched, each index corresponding to a page of items. +- `error` is now type Object? instead of dynamic. +- `nextPageKey` was removed. You can use the `keys` field to compute the next page and `hasNextPage` to determine if there are more pages. +- `isLoading` is a new field that indicates if a request is currently in progress. +- `mapItems` and `filterItems` have been added to modify the items in a convenient way. diff --git a/README.md b/README.md index d0cfda0..7eb7479 100644 --- a/README.md +++ b/README.md @@ -31,59 +31,36 @@ Designed to feel like part of the Flutter framework. ## Usage ```dart -class BeerListView extends StatefulWidget { - @override - _BeerListViewState createState() => _BeerListViewState(); -} - -class _BeerListViewState extends State { - static const _pageSize = 20; - - final PagingController _pagingController = - PagingController(firstPageKey: 0); +class ListViewScreen extends StatefulWidget { + const ListViewScreen({super.key}); @override - void initState() { - super.initState(); - _pagingController.addPageRequestListener((pageKey) { - _fetchPage(pageKey); - }); - } - - Future _fetchPage(int pageKey) async { - try { - final newItems = await RemoteApi.getBeerList(pageKey, _pageSize); - final isLastPage = newItems.length < _pageSize; - if (isLastPage) { - _pagingController.appendLastPage(newItems); - } else { - final nextPageKey = pageKey + newItems.length; - _pagingController.appendPage(newItems, nextPageKey); - } - } catch (error) { - _pagingController.error = error; - } - } + State createState() => _ListViewScreenState(); +} - @override - Widget build(BuildContext context) => - // Don't worry about displaying progress or error indicators on screen; the - // package takes care of that. If you want to customize them, use the - // [PagedChildBuilderDelegate] properties. - PagedListView( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => BeerListItem( - beer: item, - ), - ), - ); +class _ListViewScreenState extends State { + late final _pagingController = PagingController( + getNextPageKey: (state) => (state.keys?.last ?? 0) + 1, + fetchPage: (pageKey) => RemoteApi.getPhotos(pageKey), + ); @override void dispose() { _pagingController.dispose(); super.dispose(); } + + @override + Widget build(BuildContext context) => PagingListener( + controller: _pagingController, + builder: (context, state, fetchNextPage) => PagedListView( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item: item), + ), + ), + ); } ``` @@ -103,6 +80,10 @@ For more usage examples, please take a look at our [cookbook](https://pub.dev/pa - **Listen to state changes**: In addition to displaying widgets to inform the current status, such as progress and error indicators, you can also [use a listener](https://pub.dev/packages/infinite_scroll_pagination/example#listening-to-status-changes) to display dialogs/snackbars/toasts or execute any other action. +## Migration + +if you are upgrading the package, please check the [migration guide](https://github.com/EdsonBueno/infinite_scroll_pagination/tree/master/MIGRATION.md) for instructions on how to update your code. + ## API Overview

diff --git a/assets/api-diagram.drawio b/assets/api-diagram.drawio index d29b316..993462e 100644 --- a/assets/api-diagram.drawio +++ b/assets/api-diagram.drawio @@ -1,168 +1,115 @@ - + - + - - + + - - + + - - + + - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + - - + + - - + + - - + + - + - + - - + + - - - - - + + + + + - - + + - - + + - - + + - + - + - - - - - - - - + + - - - - - - - - - - - + + + + + - - + + - - + + - - + + - + - + - - + + - + - - + + - - - - - + + - - - - - - - - - - + + - - + + - + - + - - - - - - - - - - - + + - - + + diff --git a/assets/api-diagram.png b/assets/api-diagram.png index 72b517c..298be94 100644 Binary files a/assets/api-diagram.png and b/assets/api-diagram.png differ diff --git a/example/.metadata b/example/.metadata index cc3fb11..ea59ad0 100644 --- a/example/.metadata +++ b/example/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "300451adae589accbece3490f4396f10bdf15e6e" + revision: "2663184aa79047d0a33a14a3b607954f8fdd8730" channel: "stable" project_type: app @@ -13,23 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 300451adae589accbece3490f4396f10bdf15e6e - base_revision: 300451adae589accbece3490f4396f10bdf15e6e - - platform: android - create_revision: 300451adae589accbece3490f4396f10bdf15e6e - base_revision: 300451adae589accbece3490f4396f10bdf15e6e - - platform: ios - create_revision: 300451adae589accbece3490f4396f10bdf15e6e - base_revision: 300451adae589accbece3490f4396f10bdf15e6e - - platform: linux - create_revision: 300451adae589accbece3490f4396f10bdf15e6e - base_revision: 300451adae589accbece3490f4396f10bdf15e6e - - platform: web - create_revision: 300451adae589accbece3490f4396f10bdf15e6e - base_revision: 300451adae589accbece3490f4396f10bdf15e6e + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 - platform: windows - create_revision: 300451adae589accbece3490f4396f10bdf15e6e - base_revision: 300451adae589accbece3490f4396f10bdf15e6e + create_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 + base_revision: 2663184aa79047d0a33a14a3b607954f8fdd8730 # User provided section diff --git a/example/README.md b/example/README.md index 54d0501..a639b86 100644 --- a/example/README.md +++ b/example/README.md @@ -1,471 +1,11 @@ -# Cookbook +# Example -All the snippets are from the [example project](https://github.com/EdsonBueno/infinite_scroll_pagination/tree/master/example). +To run the example project, make sure to enable the corresponding platform, with -## Simple Usage - -```dart -class BeerListView extends StatefulWidget { - @override - _BeerListViewState createState() => _BeerListViewState(); -} - -class _BeerListViewState extends State { - static const _pageSize = 20; - - final PagingController _pagingController = - PagingController(firstPageKey: 0); - - @override - void initState() { - super.initState(); - _pagingController.addPageRequestListener((pageKey) { - _fetchPage(pageKey); - }); - } - - Future _fetchPage(int pageKey) async { - try { - final newItems = await RemoteApi.getBeerList(pageKey, _pageSize); - final isLastPage = newItems.length < _pageSize; - if (isLastPage) { - _pagingController.appendLastPage(newItems); - } else { - final nextPageKey = pageKey + newItems.length; - _pagingController.appendPage(newItems, nextPageKey); - } - } catch (error) { - _pagingController.error = error; - } - } - - @override - Widget build(BuildContext context) => - PagedListView( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => BeerListItem( - beer: item, - ), - ), - ); - - @override - void dispose() { - _pagingController.dispose(); - super.dispose(); - } -} -``` - -## Customizing Indicators - -```dart -@override -Widget build(BuildContext context) => - PagedListView( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => BeerListItem( - beer: item, - ), - firstPageErrorIndicatorBuilder: (_) => FirstPageErrorIndicator( - error: _pagingController.error, - onTryAgain: () => _pagingController.refresh(), - ), - newPageErrorIndicatorBuilder: (_) => NewPageErrorIndicator( - error: _pagingController.error, - onTryAgain: () => _pagingController.retryLastFailedRequest(), - ), - firstPageProgressIndicatorBuilder: (_) => FirstPageProgressIndicator(), - newPageProgressIndicatorBuilder: (_) => NewPageProgressIndicator(), - noItemsFoundIndicatorBuilder: (_) => NoItemsFoundIndicator(), - noMoreItemsIndicatorBuilder: (_) => NoMoreItemsIndicator(), - ), - ); -``` - -## Animating Status Transitions - -```dart -@override -Widget build(BuildContext context) => - PagedListView( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - animateTransitions: true, - // [transitionDuration] has a default value of 250 milliseconds. - transitionDuration: const Duration(milliseconds: 500), - itemBuilder: (context, item, index) => BeerListItem( - beer: item, - ), - ), - ); -``` - -## Separators - -```dart -@override -Widget build(BuildContext context) => - PagedListView.separated( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => BeerListItem( - beer: item, - ), - ), - separatorBuilder: (context, index) => const Divider(), - ); -``` - -Works for both [PagedListView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedListView-class.html) and [PagedSliverList](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverList-class.html). - -## Pull-to-Refresh - -Wrap your [PagedListView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedListView-class.html), [PagedGridView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedGridView-class.html) or [CustomScrollView](https://api.flutter.dev/flutter/widgets/CustomScrollView-class.html) with a [RefreshIndicator](https://api.flutter.dev/flutter/material/RefreshIndicator-class.html) (from the [material library](https://api.flutter.dev/flutter/material/material-library.html)) and inside [onRefresh](https://api.flutter.dev/flutter/material/RefreshIndicator/onRefresh.html), call `refresh` on your [PagingController](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagingController-class.html): - -```dart -@override -Widget build(BuildContext context) => - RefreshIndicator( - onRefresh: () => Future.sync( - () => _pagingController.refresh(), - ), - child: PagedListView( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => BeerListItem( - beer: item, - ), - ), - ), - ); -``` - -## Preceding/Following Items - -If you need to place some widgets before or after your list, and expect them to scroll along with the list items, such as a header, footer, search or filter bar, you should use our [Sliver](https://flutter.dev/docs/development/ui/advanced/slivers) widgets. - -**Infinite Scroll Pagination** comes with [PagedSliverList](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverList-class.html) and [PagedSliverGrid](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverGrid-class.html), which works almost the same as [PagedListView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedListView-class.html) or [PagedGridView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedGridView-class.html), except that they need to be wrapped by a [CustomScrollView](https://api.flutter.dev/flutter/widgets/CustomScrollView-class.html). That allows you to give them siblings, for example: - -```dart -@override -Widget build(BuildContext context) => - CustomScrollView( - slivers: [ - BeerSearchInputSliver( - onChanged: _updateSearchTerm, - ), - PagedSliverList( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => BeerListItem( - beer: item, - ), - ), - ), - ], - ); -``` - -Notice that your preceding/following widgets should also be [Sliver](https://flutter.dev/docs/development/ui/advanced/slivers)s. `BeerSearchInputSliver`, for example, is nothing but a [TextField](https://api.flutter.dev/flutter/material/TextField-class.html) wrapped by a [SliverToBoxAdapter](https://api.flutter.dev/flutter/widgets/SliverToBoxAdapter-class.html). - -## Searching/Filtering/Sorting - -There are many ways to integrate searching/filtering/sorting with this package. The best one depends on you state management approach. Below you can see a simple example for a vanilla approach: - -```dart -class BeerSliverList extends StatefulWidget { - @override - _BeerSliverListState createState() => _BeerSliverListState(); -} - -class _BeerSliverListState extends State { - static const _pageSize = 17; - - final PagingController _pagingController = - PagingController(firstPageKey: 0); - - String? _searchTerm; - - @override - void initState() { - super.initState(); - _pagingController.addPageRequestListener((pageKey) { - _fetchPage(pageKey); - }); - } - - Future _fetchPage(pageKey) async { - try { - final newItems = await RemoteApi.getBeerList( - pageKey, - _pageSize, - searchTerm: _searchTerm, - ); - - final isLastPage = newItems.length < _pageSize; - if (isLastPage) { - _pagingController.appendLastPage(newItems); - } else { - final nextPageKey = pageKey + newItems.length; - _pagingController.appendPage(newItems, nextPageKey); - } - } catch (error) { - _pagingController.error = error; - } - } - - @override - Widget build(BuildContext context) => - CustomScrollView( - slivers: [ - BeerSearchInputSliver( - onChanged: _updateSearchTerm, - ), - PagedSliverList( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => BeerListItem( - beer: item, - ), - ), - ), - ], - ); - - void _updateSearchTerm(String searchTerm) { - _searchTerm = searchTerm; - _pagingController.refresh(); - } - - @override - void dispose() { - _pagingController.dispose(); - super.dispose(); - } -} -``` - -The same structure can be applied to all kinds of filtering and sorting and works with any layout (not just Slivers). - -## Positioning Grid's Status Indicators - -By default, all our paged grid widgets show your indicators as one of the grid children, respecting the same configurations you set for your items on the `gridDelegate`. -If you want to change that, and instead display the items _below_ the grid, as is in the list widgets, you can do so by using these boolean properties: - -```dart -@override -Widget build(BuildContext context) => - PagedGridView( - showNewPageProgressIndicatorAsGridChild: false, - showNewPageErrorIndicatorAsGridChild: false, - showNoMoreItemsIndicatorAsGridChild: false, - pagingController: _pagingController, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - childAspectRatio: 100 / 150, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - crossAxisCount: 3, - ), - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => BeerGridItem( - beer: item, - ), - ), - ); +```sh +flutter create ./example --platforms= ``` -## Listening to Status Changes - -If you need to execute some custom action when the list status changes, such as displaying a dialog/snackbar/toast, or sending a custom event to a BLoC or so, add a status listener to your [PagingController](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagingController-class.html). For example: - -```dart -@override -void initState() { - super.initState(); - - _pagingController.addPageRequestListener((pageKey) { - _fetchPage(pageKey); - }); - - _pagingController.addStatusListener((status) { - if (status == PagingStatus.subsequentPageError) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text( - 'Something went wrong while fetching a new page.', - ), - action: SnackBarAction( - label: 'Retry', - onPressed: () => _pagingController.retryLastFailedRequest(), - ), - ), - ); - } - }); -} -``` - -## Changing the Invisible Items Threshold - -By default, the package asks a new page when there are 3 invisible items left while the user is scrolling. You can change that number in the [PagingController](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagingController-class.html)'s constructor. - -```dart -final PagingController _pagingController = - PagingController(firstPageKey: 0, invisibleItemsThreshold: 5); -``` - -## BLoC - -**Infinite Scroll Pagination** is designed to work with any state management approach you prefer in any way you'd like. Because of that, for each approach, there's not only one, but several ways in which you could work with this package. -Below you can see one of the possible ways to integrate it with BLoCs: - -```dart -class BeerSliverGrid extends StatefulWidget { - @override - _BeerSliverGridState createState() => _BeerSliverGridState(); -} - -class _BeerSliverGridState extends State { - final BeerSliverGridBloc _bloc = BeerSliverGridBloc(); - final PagingController _pagingController = - PagingController(firstPageKey: 0); - late StreamSubscription _blocListingStateSubscription; - - @override - void initState() { - super.initState(); - - _pagingController.addPageRequestListener((pageKey) { - _bloc.onPageRequestSink.add(pageKey); - }); - - // We could've used StreamBuilder, but that would unnecessarily recreate - // the entire [PagedSliverGrid] every time the state changes. - // Instead, handling the subscription ourselves and updating only the - // _pagingController is more efficient. - _blocListingStateSubscription = - _bloc.onNewListingState.listen((listingState) { - _pagingController.value = PagingState( - nextPageKey: listingState.nextPageKey, - error: listingState.error, - itemList: listingState.itemList, - ); - }); - } - - @override - Widget build(BuildContext context) => - CustomScrollView( - slivers: [ - BeerSearchInputSliver( - onChanged: (searchTerm) => - _bloc.onSearchInputChangedSink.add(searchTerm), - ), - PagedSliverGrid( - pagingController: _pagingController, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - childAspectRatio: 100 / 150, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - crossAxisCount: 3, - ), - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => BeerGridItem( - beer: item, - ), - ), - ), - ], - ); - - @override - void dispose() { - _pagingController.dispose(); - _blocListingStateSubscription.cancel(); - super.dispose(); - } -} -``` - -Check out the [example project](https://github.com/EdsonBueno/infinite_scroll_pagination/tree/master/example) for the complete source code. - -## Custom Layout - -In case [PagedListView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedListView-class.html), [PagedSliverList](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverList-class.html), [PagedGridView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedGridView-class.html) and [PagedSliverGrid](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverGrid-class.html) doesn't work for you, you should create a new paged layout. - -Creating a new layout is just a matter of using [PagedLayoutBuilder](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedLayoutBuilder-class.html) and provide it builders for the completed, in progress with error and in progress with loading layouts. For example, take a look at how [PagedSliverGrid](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverGrid-class.html) is built: - -```dart -@override - @override - Widget build(BuildContext context) => - PagedLayoutBuilder( - layoutProtocol: PagedLayoutProtocol.sliver, - pagingController: pagingController, - builderDelegate: builderDelegate, - completedListingBuilder: ( - context, - itemBuilder, - itemCount, - noMoreItemsIndicatorBuilder, - ) => - AppendedSliverGrid( - sliverGridBuilder: (_, delegate) => SliverGrid( - delegate: delegate, - gridDelegate: gridDelegate, - ), - itemBuilder: itemBuilder, - itemCount: itemCount, - appendixBuilder: noMoreItemsIndicatorBuilder, - showAppendixAsGridChild: showNoMoreItemsIndicatorAsGridChild, - addAutomaticKeepAlives: addAutomaticKeepAlives, - addSemanticIndexes: addSemanticIndexes, - addRepaintBoundaries: addRepaintBoundaries, - ), - loadingListingBuilder: ( - context, - itemBuilder, - itemCount, - progressIndicatorBuilder, - ) => - AppendedSliverGrid( - sliverGridBuilder: (_, delegate) => SliverGrid( - delegate: delegate, - gridDelegate: gridDelegate, - ), - itemBuilder: itemBuilder, - itemCount: itemCount, - appendixBuilder: progressIndicatorBuilder, - showAppendixAsGridChild: showNewPageProgressIndicatorAsGridChild, - addAutomaticKeepAlives: addAutomaticKeepAlives, - addSemanticIndexes: addSemanticIndexes, - addRepaintBoundaries: addRepaintBoundaries, - ), - errorListingBuilder: ( - context, - itemBuilder, - itemCount, - errorIndicatorBuilder, - ) => - AppendedSliverGrid( - sliverGridBuilder: (_, delegate) => SliverGrid( - delegate: delegate, - gridDelegate: gridDelegate, - ), - itemBuilder: itemBuilder, - itemCount: itemCount, - appendixBuilder: errorIndicatorBuilder, - showAppendixAsGridChild: showNewPageErrorIndicatorAsGridChild, - addAutomaticKeepAlives: addAutomaticKeepAlives, - addSemanticIndexes: addSemanticIndexes, - addRepaintBoundaries: addRepaintBoundaries, - ), - shrinkWrapFirstPageIndicators: shrinkWrapFirstPageIndicators, - ); -``` +## Cookbook -Note the usage of [PagedLayoutProtocol.sliver](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedLayoutProtocol/sliver.html) which tells the package that the layout is a [Sliver](https://flutter.dev/docs/development/ui/advanced/slivers). -For widgets which have no Sliver variant, such as a [PagedPageView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedPageView-class.html), you should use [PagedLayoutProtocol.box](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedLayoutProtocol/box.html) instead. +The cookbook file is can be found here: [./example.md](./example.md) diff --git a/example/example.md b/example/example.md new file mode 100644 index 0000000..abdcbce --- /dev/null +++ b/example/example.md @@ -0,0 +1,425 @@ +# Cookbook + +More extensive examples can be found in the [example project](https://github.com/EdsonBueno/infinite_scroll_pagination/tree/master/example). + +## Using PagingController + +PagingController is the out-of-the-box solution that comes with the package for managing the PagingState. Using a PagingListener, we connect it to the Paged Widget and we're good to go. You can extend the class to add features you might need, such as filtering, sorting, etc. It can also be connected to multiple Paged Widgets at the same time. + +```dart +class _ExampleScreenState extends State { + late final _pagingController = PagingController( + getNextPageKey: (state) => (state.keys?.last ?? 0) + 1, + fetchPage: (pageKey) => RemoteApi.getPhotos(pageKey), + ); + + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => PagingListener( + controller: _pagingController, + builder: (context, state, fetchNextPage) => PagedListView( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item: item), + ), + ), + ); +} +``` + +## Using setState + +You can manually manage the PagingState using setState. This is more straightforward when you require more control over your state. + +```dart +class _ExampleScreenState extends State { + PagingState _state = PagingState(); + + void _fetchNextPage() async { + if (_state.isLoading) return; + + await Future.value(); + + setState(() { + _state = _state.copyWith(isLoading: true, error: null); + }); + + try { + final newKey = (_state.keys?.last ?? 0) + 1; + final newItems = await RemoteApi.getPhotos(newKey); + final isLastPage = newItems.isEmpty; + + setState(() { + _state = _state.copyWith( + pages: [...?_state.pages, newItems], + keys: [...?_state.keys, newKey], + hasNextPage: !isLastPage, + isLoading: false, + ); + }); + } catch (error) { + setState(() { + _state = _state.copyWith( + error: error, + isLoading: false, + ); + }); + } + } + + @override + Widget build(BuildContext context) => PagedListView( + state: _state, + fetchNextPage: _fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item: item), + ), + ); +} +``` + +## Using a custom State Management + +You can use any state managment approach you prefer. +The only requirements for the Paged Widget to work are that you provide a PagingState and a function to fetch the next page. +Here is an example in flutter_bloc: + +```dart +sealed class PhotoEvent {} + +final class FetchNextPhotoPage extends PhotoEvent {} + +class PhotoBoc extends Bloc> { + PhotoBoc() : super(PagingState()) { + on((event, emit) { + final state = state; + if (state.isLoading) return; + + emit(state.copyWith(isLoading: true, error: null)); + + try { + final newKey = (state.keys?.last ?? 0) + 1; + final newItems = await RemoteApi.getPhotos(newKey); + final isLastPage = newItems.isEmpty; + + emit(state.copyWith( + pages: [...?state.pages, newItems], + keys: [...?state.keys, newKey], + hasNextPage: !isLastPage, + isLoading: false, + )); + } catch (error) { + emit(state.copyWith( + error: error, + isLoading: false, + )); + } + }, + ); + } +} +``` + +and then in your screen: + +```dart +class _ExampleScreenState extends State { + final _bloc = PhotoBloc(); + + @override + void dispose() { + _bloc.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => BlocBuilder>( + bloc: _bloc, + builder: (context, state) => PagedListView( + state: state, + fetchNextPage: _bloc.fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item: item), + ), + ), + ); +} +``` + +## Customizing Indicators + +You can customize the indicators by providing your own widgets to the builderDelegate. The package comes with default indicators in english. + +```dart +PagedListView( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item: item), + firstPageErrorIndicatorBuilder: (_) => FirstPageErrorIndicator( + error: state.error, + onTryAgain: () => fetchNextPage(), + ), + newPageErrorIndicatorBuilder: (_) => NewPageErrorIndicator( + error: state.error, + onTryAgain: () => fetchNextPage(), + ), + firstPageProgressIndicatorBuilder: (_) => FirstPageProgressIndicator(), + newPageProgressIndicatorBuilder: (_) => NewPageProgressIndicator(), + noItemsFoundIndicatorBuilder: (_) => NoItemsFoundIndicator(), + noMoreItemsIndicatorBuilder: (_) => NoMoreItemsIndicator(), + ), +); +``` + +## Animating Status Transitions + +```dart +PagedListView( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item: item), + animateTransitions: true, + // [transitionDuration] has a default value of 250 milliseconds. + transitionDuration: const Duration(milliseconds: 500), + ), +); +``` + +## Separators + +```dart +PagedListView.separated( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item: item), + ), + separatorBuilder: (context, index) => const Divider(), +); +``` + +Works for both [PagedListView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedListView-class.html) and [PagedSliverList](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverList-class.html). + +## Pull-to-Refresh + +Wrap your [PagedListView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedListView-class.html), [PagedGridView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedGridView-class.html) or [CustomScrollView](https://api.flutter.dev/flutter/widgets/CustomScrollView-class.html) with a [RefreshIndicator](https://api.flutter.dev/flutter/material/RefreshIndicator-class.html) (from the [material library](https://api.flutter.dev/flutter/material/material-library.html)) and inside [onRefresh](https://api.flutter.dev/flutter/material/RefreshIndicator/onRefresh.html), call `refresh` on your [PagingController](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagingController-class.html): + +```dart +RefreshIndicator( + onRefresh: () => Future.sync( + () => refresh(), + ), + child: PagedListView( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item: item), + ), + ), +); +``` + +## Preceding/Following Items + +If you need to place some widgets before or after your list, and expect them to scroll along with the list items, such as a header, footer, search or filter bar, you should use our [Sliver](https://flutter.dev/docs/development/ui/advanced/slivers) widgets. + +**Infinite Scroll Pagination** comes with [PagedSliverList](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverList-class.html) and [PagedSliverGrid](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverGrid-class.html), which works almost the same as [PagedListView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedListView-class.html) or [PagedGridView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedGridView-class.html), except that they need to be wrapped by a [CustomScrollView](https://api.flutter.dev/flutter/widgets/CustomScrollView-class.html). That allows you to give them siblings, for example: + +```dart +CustomScrollView( + slivers: [ + SearchInputSliver( + onChanged: updateSearchTerm, + ), + PagedSliverList( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item: item), + ), + ), + ], +); +``` + +Notice that your preceding/following widgets should also be [Sliver](https://flutter.dev/docs/development/ui/advanced/slivers)s. `SearchInputSliver`, for example, is nothing but a [TextField](https://api.flutter.dev/flutter/material/TextField-class.html) wrapped by a [SliverToBoxAdapter](https://api.flutter.dev/flutter/widgets/SliverToBoxAdapter-class.html). + +## Searching/Filtering/Sorting + +There are many ways to integrate searching/filtering/sorting with this package. The best one depends on you state management approach. +Below you can see a very simple example: + +```dart +class _ExampleScreenState extends State { + String? _searchTerm; + + late final _pagingController = PagingController( + getNextPageKey: (state) => (state.keys?.last ?? 0) + 1, + fetchPage: (pageKey) { + final results = RemoteApi.getPhotos(pageKey); + + return _searchTerm == null + ? results + : results.where((photo) => photo.title.contains(_searchTerm!)).toList(); + }, + ); + + void _updateSearchTerm(String searchTerm) { + _searchTerm = searchTerm; + _pagingController.refresh(); + } + + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => + CustomScrollView( + slivers: [ + SearchInputSliver( + onChanged: _updateSearchTerm, + ), + PagedSliverList( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item: item), + ), + ), + ], + ); + + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } +} +``` + +The same structure can be applied to all kinds of filtering and sorting and works with any layout (not just Slivers). + +## Positioning Grid's Status Indicators + +By default, all our paged grid widgets show your indicators as one of the grid children, respecting the same configurations you set for your items on the `gridDelegate`. +If you want to change that, and instead display the items _below_ the grid, as is in the list widgets, you can do so by using these boolean properties: + +```dart +@override +Widget build(BuildContext context) => + PagedGridView( + showNewPageProgressIndicatorAsGridChild: false, + showNewPageErrorIndicatorAsGridChild: false, + showNoMoreItemsIndicatorAsGridChild: false, + pagingController: _pagingController, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + childAspectRatio: 100 / 150, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + crossAxisCount: 3, + ), + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item: item), + ), + ); +``` + +## Changing the Invisible Items Threshold + +By default, the package asks a new page when there are 3 invisible items left while the user is scrolling. You can change that number in the [PagedChildBuilderDelegate](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedChildBuilderDelegate-class.html). + +```dart +PagedListView( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => ImageListTile(item: item), + invisibleItemsThreshold: 5, + ), +); +``` + +## Custom Layout + +In case [PagedListView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedListView-class.html), [PagedSliverList](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverList-class.html), [PagedGridView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedGridView-class.html) and [PagedSliverGrid](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverGrid-class.html) doesn't work for you, you should create a new paged layout. + +Creating a new layout is just a matter of using [PagedLayoutBuilder](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedLayoutBuilder-class.html) and provide it builders for the completed, in progress with error and in progress with loading layouts. For example, take a look at how [PagedSliverGrid](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedSliverGrid-class.html) is built: + +```dart +PagedLayoutBuilder( + layoutProtocol: PagedLayoutProtocol.sliver, + pagingController: pagingController, + builderDelegate: builderDelegate, + completedListingBuilder: ( + context, + itemBuilder, + itemCount, + noMoreItemsIndicatorBuilder, + ) => + AppendedSliverGrid( + sliverGridBuilder: (_, delegate) => SliverGrid( + delegate: delegate, + gridDelegate: gridDelegate, + ), + itemBuilder: itemBuilder, + itemCount: itemCount, + appendixBuilder: noMoreItemsIndicatorBuilder, + showAppendixAsGridChild: showNoMoreItemsIndicatorAsGridChild, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addSemanticIndexes: addSemanticIndexes, + addRepaintBoundaries: addRepaintBoundaries, + ), + loadingListingBuilder: ( + context, + itemBuilder, + itemCount, + progressIndicatorBuilder, + ) => + AppendedSliverGrid( + sliverGridBuilder: (_, delegate) => SliverGrid( + delegate: delegate, + gridDelegate: gridDelegate, + ), + itemBuilder: itemBuilder, + itemCount: itemCount, + appendixBuilder: progressIndicatorBuilder, + showAppendixAsGridChild: showNewPageProgressIndicatorAsGridChild, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addSemanticIndexes: addSemanticIndexes, + addRepaintBoundaries: addRepaintBoundaries, + ), + errorListingBuilder: ( + context, + itemBuilder, + itemCount, + errorIndicatorBuilder, + ) => + AppendedSliverGrid( + sliverGridBuilder: (_, delegate) => SliverGrid( + delegate: delegate, + gridDelegate: gridDelegate, + ), + itemBuilder: itemBuilder, + itemCount: itemCount, + appendixBuilder: errorIndicatorBuilder, + showAppendixAsGridChild: showNewPageErrorIndicatorAsGridChild, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addSemanticIndexes: addSemanticIndexes, + addRepaintBoundaries: addRepaintBoundaries, + ), + shrinkWrapFirstPageIndicators: shrinkWrapFirstPageIndicators, +); +``` + +Note the usage of [PagedLayoutProtocol.sliver](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedLayoutProtocol/sliver.html) which tells the package that the layout is a [Sliver](https://flutter.dev/docs/development/ui/advanced/slivers). +For widgets which have no Sliver variant, such as a [PagedPageView](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedPageView-class.html), you should use [PagedLayoutProtocol.box](https://pub.dev/documentation/infinite_scroll_pagination/latest/infinite_scroll_pagination/PagedLayoutProtocol/box.html) instead. diff --git a/example/lib/common/error.dart b/example/lib/common/error.dart index 8db2110..e7aedb8 100644 --- a/example/lib/common/error.dart +++ b/example/lib/common/error.dart @@ -11,41 +11,44 @@ class CustomFirstPageError extends StatelessWidget { @override Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Something went wrong :(', - textAlign: TextAlign.center, - style: Theme.of(context).textTheme.titleLarge, - ), - if (pagingController.error != null) ...[ - const SizedBox( - height: 16, - ), + return PagingListener( + controller: pagingController, + builder: (context, state, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ Text( - pagingController.error.toString(), + 'Something went wrong :(', textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleLarge, ), - ], - const SizedBox( - height: 48, - ), - SizedBox( - width: 200, - child: ElevatedButton.icon( - onPressed: pagingController.refresh, - icon: const Icon(Icons.refresh), - label: const Text( - 'Try Again', - style: TextStyle( - fontSize: 16, + if (state.error != null) ...[ + const SizedBox( + height: 16, + ), + Text( + state.error.toString(), + textAlign: TextAlign.center, + ), + ], + const SizedBox( + height: 48, + ), + SizedBox( + width: 200, + child: ElevatedButton.icon( + onPressed: pagingController.refresh, + icon: const Icon(Icons.refresh), + label: const Text( + 'Try Again', + style: TextStyle( + fontSize: 16, + ), ), ), ), - ), - ], + ], + ), ), ); } @@ -62,7 +65,7 @@ class CustomNewPageError extends StatelessWidget { @override Widget build(BuildContext context) { return InkWell( - onTap: pagingController.retryLastFailedRequest, + onTap: pagingController.fetchNextPage, child: Padding( padding: const EdgeInsets.all(20), child: Column( diff --git a/example/lib/common/listing_bloc.dart b/example/lib/common/listing_bloc.dart index 693b4da..c57de2c 100644 --- a/example/lib/common/listing_bloc.dart +++ b/example/lib/common/listing_bloc.dart @@ -2,87 +2,84 @@ import 'dart:async'; import 'package:infinite_example/remote/item.dart'; import 'package:infinite_example/remote/api.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:rxdart/rxdart.dart'; -class ListingState { - ListingState({ - this.itemList, - this.error, - this.nextPageKey = 1, - }); - - final List? itemList; - final dynamic error; - final int? nextPageKey; -} - -class ListingBloc { - ListingBloc() { +class PhotoPagesBloc { + PhotoPagesBloc() { _onPageRequest.stream - .flatMap(_fetch) - .listen(_onNewListingStateController.add) + .flatMap((_) => _fetch((_stateController.value.keys?.last ?? 0) + 1)) + .listen(_stateController.add) .addTo(_subscriptions); - _onSearchInputChangedSubject.stream + _onSearchChanged.stream + .distinct() .flatMap((_) => _refresh()) - .listen(_onNewListingStateController.add) + .listen(_stateController.add) .addTo(_subscriptions); } final _subscriptions = CompositeSubscription(); - final _onNewListingStateController = BehaviorSubject.seeded( - ListingState(), + final _stateController = BehaviorSubject>.seeded( + PagingState(), ); - Stream get onNewListingState => - _onNewListingStateController.stream; + PagingState get state => _stateController.value; - final _onPageRequest = StreamController(); + Stream> get onState => _stateController.stream; - Sink get onPageRequestSink => _onPageRequest.sink; + final _onPageRequest = StreamController(); - final _onSearchInputChangedSubject = BehaviorSubject.seeded(null); + void fetchNextPage() => _onPageRequest.add(null); - Sink get onSearchInputChangedSink => - _onSearchInputChangedSubject.sink; + final _onSearchChanged = BehaviorSubject.seeded(null); - String? get _searchInputValue => _onSearchInputChangedSubject.value; + void changeSearch(String? value) => _onSearchChanged.add(value); - Stream _refresh() async* { - yield ListingState(); + String? get _searchInputValue => _onSearchChanged.value; + + Stream> _refresh() async* { + yield _stateController.value.reset(); yield* _fetch(1); } - Stream _fetch(int pageKey) async* { - final lastListingState = _onNewListingStateController.value; + Stream> _fetch(int pageKey) async* { + final lastListingState = _stateController.value; + yield lastListingState.copyWith( + error: null, + isLoading: true, + ); try { final newItems = await RemoteApi.getPhotos( pageKey, search: _searchInputValue, ); final isLastPage = newItems.isEmpty; - final nextPageKey = isLastPage ? null : pageKey + 1; - yield ListingState( + yield lastListingState.copyWith( error: null, - nextPageKey: nextPageKey, - itemList: [ - ...lastListingState.itemList ?? [], - ...newItems, + isLoading: false, + hasNextPage: !isLastPage, + pages: [ + ...lastListingState.pages ?? [], + newItems, + ], + keys: [ + ...lastListingState.keys ?? [], + pageKey, ], ); } catch (e) { - yield ListingState( + yield lastListingState.copyWith( error: e, - nextPageKey: lastListingState.nextPageKey, - itemList: lastListingState.itemList, + isLoading: false, ); } } void dispose() { - _onSearchInputChangedSubject.close(); - _onNewListingStateController.close(); + _onSearchChanged.close(); + _stateController.close(); _subscriptions.dispose(); _onPageRequest.close(); } diff --git a/example/lib/common/search_input.dart b/example/lib/common/search_input.dart index c09d315..25d0ad8 100644 --- a/example/lib/common/search_input.dart +++ b/example/lib/common/search_input.dart @@ -8,9 +8,12 @@ class SearchInputSliver extends StatefulWidget { super.key, this.onChanged, this.debounceTime, + required this.getSuggestions, }); + final ValueChanged? onChanged; final Duration? debounceTime; + final List Function(String) getSuggestions; @override State createState() => _SearchInputSliverState(); @@ -21,6 +24,8 @@ class _SearchInputSliverState extends State { StreamController(); late StreamSubscription _textChangesSubscription; + final SearchController _searchController = SearchController(); + @override void initState() { super.initState(); @@ -32,28 +37,50 @@ class _SearchInputSliverState extends State { ), ) .distinct() - .listen((text) { - final onChanged = widget.onChanged; - if (onChanged != null) { - onChanged(text); - } - }); + .listen(widget.onChanged?.call); } @override Widget build(BuildContext context) => SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all( - 16, - ), - child: TextField( - decoration: const InputDecoration( - prefixIcon: Icon( - Icons.search, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.all(8), + child: SearchAnchor( + searchController: _searchController, + viewOnSubmitted: (value) { + widget.onChanged?.call(value); + _searchController.closeView(value); + }, + suggestionsBuilder: (context, controller) => + widget.getSuggestions(controller.text).map( + (suggestion) => ListTile( + title: Text(suggestion), + onTap: () { + controller.closeView(suggestion); + widget.onChanged?.call(suggestion); + }, + ), + ), + viewShape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + builder: (context, controller) => SearchBar( + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + controller: controller, + hintText: 'Search...', + onTap: () { + controller.openView(); + }, + onChanged: (_) { + controller.openView(); + }, + leading: const Icon(Icons.search), ), - hintText: 'Search...', ), - onChanged: _textChangeStreamController.add, ), ), ); diff --git a/example/lib/remote/api.dart b/example/lib/remote/api.dart index b84e38d..120dc72 100644 --- a/example/lib/remote/api.dart +++ b/example/lib/remote/api.dart @@ -16,16 +16,19 @@ class RemoteApi { throw RandomChanceException(); } - return http - .get( - _ApiUrlBuilder.photos(page, limit, search), - ) - .mapFromResponse, List>( - (jsonArray) => _parseItemListFromJsonArray( - jsonArray, - Photo.fromPlaceholderJson, + return Future.delayed( + const Duration(seconds: 0), + () => http + .get( + _ApiUrlBuilder.photos(page, limit, search), + ) + .mapFromResponse, List>( + (jsonArray) => _parseItemListFromJsonArray( + jsonArray, + Photo.fromPlaceholderJson, + ), ), - ); + ); } static List _parseItemListFromJsonArray( diff --git a/example/lib/samples/list_view.dart b/example/lib/samples/list_view.dart index 609f702..37a631e 100644 --- a/example/lib/samples/list_view.dart +++ b/example/lib/samples/list_view.dart @@ -14,36 +14,28 @@ class ListViewScreen extends StatefulWidget { } class _ListViewScreenState extends State { - final PagingController _pagingController = - PagingController(firstPageKey: 1); - String? _searchTerm; + /// This example uses a [PagingController] to manage the paging state. + /// + /// This is a robust inbuilt way to store your pagination state. + /// The controller can also be used in multiple Paged layouts simultaneously, + /// to share their state. + late final _pagingController = PagingController( + getNextPageKey: (state) => (state.keys?.last ?? 0) + 1, + fetchPage: (pageKey) => RemoteApi.getPhotos(pageKey, search: _searchTerm), + ); + @override void initState() { super.initState(); - _pagingController.addPageRequestListener(_fetchPage); - _pagingController.addStatusListener(_showError); - } - - Future _fetchPage(int pageKey) async { - try { - final newItems = await RemoteApi.getPhotos(pageKey, search: _searchTerm); - - final isLastPage = newItems.isEmpty; - if (isLastPage) { - _pagingController.appendLastPage(newItems); - } else { - final nextPageKey = pageKey + 1; - _pagingController.appendPage(newItems, nextPageKey); - } - } catch (error) { - _pagingController.error = error; - } + _pagingController.addListener(_showError); } - Future _showError(PagingStatus status) async { - if (status == PagingStatus.subsequentPageError) { + /// This method listens to notifications from the [_pagingController] and + /// shows a [SnackBar] when an error occurs. + Future _showError() async { + if (_pagingController.value.status == PagingStatus.subsequentPageError) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text( @@ -51,18 +43,21 @@ class _ListViewScreenState extends State { ), action: SnackBarAction( label: 'Retry', - onPressed: () => _pagingController.retryLastFailedRequest(), + onPressed: () => _pagingController.fetchNextPage(), ), ), ); } } + /// When the search term changes, the controller is refreshed. + /// The refresh will remove all existing items and fetch the first page again. void _updateSearchTerm(String searchTerm) { setState(() => _searchTerm = searchTerm); _pagingController.refresh(); } + /// The controller needs to be disposed when the widget is removed. @override void dispose() { _pagingController.dispose(); @@ -77,19 +72,32 @@ class _ListViewScreenState extends State { ), body: RefreshIndicator( onRefresh: () async => _pagingController.refresh(), - child: PagedListView.separated( - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - animateTransitions: true, - itemBuilder: (context, item, index) => ImageListTile( - item: item, + + /// The [PagingListener] is a widget that listens to the controller and + /// rebuilds the UI based on the state of the controller. + /// Its the easiest way to bind your controller to a Paged layout. + child: PagingListener( + controller: _pagingController, + builder: (context, state, fetchNextPage) => + + /// Paged layouts rely on a [PagingState] and a [fetchNextPage] function. + PagedListView.separated( + state: state, + fetchNextPage: fetchNextPage, + itemExtent: 48, + builderDelegate: PagedChildBuilderDelegate( + animateTransitions: true, + itemBuilder: (context, item, index) => ImageListTile( + key: ValueKey(item.id), + item: item, + ), + firstPageErrorIndicatorBuilder: (context) => + CustomFirstPageError(pagingController: _pagingController), + newPageErrorIndicatorBuilder: (context) => + CustomNewPageError(pagingController: _pagingController), ), - firstPageErrorIndicatorBuilder: (context) => - CustomFirstPageError(pagingController: _pagingController), - newPageErrorIndicatorBuilder: (context) => - CustomNewPageError(pagingController: _pagingController), + separatorBuilder: (context, index) => const Divider(), ), - separatorBuilder: (context, index) => const Divider(), ), ), ); diff --git a/example/lib/samples/page_view.dart b/example/lib/samples/page_view.dart index 4db1cec..3a37927 100644 --- a/example/lib/samples/page_view.dart +++ b/example/lib/samples/page_view.dart @@ -3,6 +3,8 @@ import 'package:infinite_example/remote/api.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'list_view.dart'; +import 'sliver_grid.dart'; class PageViewScreen extends StatefulWidget { const PageViewScreen({super.key}); @@ -12,85 +14,107 @@ class PageViewScreen extends StatefulWidget { } class _PageViewScreenState extends State { - final PagingController _pagingController = PagingController( - firstPageKey: 1, - ); final PageController _pageController = PageController(); - @override - void initState() { - super.initState(); - _pagingController.addPageRequestListener(_fetchPage); - } + /// This example uses a [PagingState] and [setState] directly to manage the paging state. + /// + /// This is the most direct way to use the package. + /// For a more managed approach, see [ListViewScreen]. + /// For managing your [PagingState] inside your own controller, see [SliverGridScreen]. + PagingState _state = PagingState(); - Future _fetchPage(int pageKey) async { - try { - final newItems = await RemoteApi.getPhotos(pageKey); + void fetchNextPage() async { + if (_state.isLoading) return; + setState(() { + // set loading to true and remove any previous error + _state = _state.copyWith(isLoading: true, error: null); + }); + + try { + // in our simple setup, keys are sequential numbers + final newKey = (_state.keys?.last ?? 0) + 1; + // we fetch the next page of items + final newItems = await RemoteApi.getPhotos(newKey); + // if the new page is empty, we reached the end final isLastPage = newItems.isEmpty; - if (isLastPage) { - _pagingController.appendLastPage(newItems); - } else { - final nextPageKey = pageKey + 1; - _pagingController.appendPage(newItems, nextPageKey); - } + + setState(() { + _state = _state.copyWith( + // append our new page to the existing pages + pages: [ + ...?_state.pages, + newItems, + ], + // append the new key to the existing keys + keys: [ + ...?_state.keys, + newKey, + ], + // signal if we reached the end + hasNextPage: !isLastPage, + // set loading back to false + isLoading: false, + ); + }); } catch (error) { - _pagingController.error = error; + setState(() { + _state = _state.copyWith( + // in case of an error, we store it in the state + error: error, + isLoading: false, + ); + }); } } @override - void dispose() { - _pagingController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => Stack( - fit: StackFit.passthrough, - children: [ - PagedPageView( - pageController: _pageController, - pagingController: _pagingController, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => CachedNetworkImage( - imageUrl: item.thumbnail, - ), + Widget build(BuildContext context) { + return Stack( + fit: StackFit.passthrough, + children: [ + PagedPageView( + pageController: _pageController, + state: _state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => CachedNetworkImage( + imageUrl: item.thumbnail, ), ), - Positioned( - right: 0, - left: 0, - bottom: 16, - child: Row( + ), + Positioned( + right: 0, + left: 0, + bottom: 16, + child: ListenableBuilder( + listenable: _pageController, + builder: (context, _) => Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Material( - borderRadius: BorderRadius.circular(4), - color: Colors.black38, - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - child: ListenableBuilder( - listenable: _pageController, - builder: (context, _) { - if (_pageController.positions.isEmpty) { - return const SizedBox.shrink(); - } - return Text( - '${_pageController.page?.round()} / ${_pagingController.itemList?.length ?? 0}', - style: - Theme.of(context).textTheme.titleLarge?.copyWith( - color: Colors.white, - ), - ); - }, + if (_pageController.hasClients) + Material( + borderRadius: BorderRadius.circular(4), + color: Colors.black38, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + child: Text( + '${(_pageController.page ?? 0).round()} / ${_state.items?.length ?? 0}', + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: Colors.white), + ), ), - ), - ) + ) ], ), ), - ], - ); + ), + ], + ); + } } diff --git a/example/lib/samples/sliver_grid.dart b/example/lib/samples/sliver_grid.dart index 7a38aa0..a1e0606 100644 --- a/example/lib/samples/sliver_grid.dart +++ b/example/lib/samples/sliver_grid.dart @@ -1,4 +1,4 @@ -import 'dart:async'; +import 'dart:math'; import 'package:infinite_example/remote/item.dart'; import 'package:infinite_example/common/listing_bloc.dart'; @@ -21,117 +21,138 @@ class SliverGridScreen extends StatefulWidget { } class _SliverGridScreenState extends State { - final ListingBloc _bloc = ListingBloc(); - final PagingController _pagingController = - PagingController(firstPageKey: 1); - late StreamSubscription _blocListingStateSubscription; - _GridType _gridType = _GridType.square; - - @override - void initState() { - super.initState(); - _pagingController.addPageRequestListener((pageKey) { - _bloc.onPageRequestSink.add(pageKey); - }); + /// This example uses a [PhotoPagesBloc] to manage the paging state. + /// + /// The [PhotoPagesBloc] is a custom class we wrote to manage the paging state. + /// Paged layouts are not limited to using a [PagingController]. + /// You can use any state management solution you prefer. + /// + /// In this case, [PhotoPagesBloc] is a bloc class built with RxDart. + final PhotoPagesBloc _bloc = PhotoPagesBloc(); - _blocListingStateSubscription = - _bloc.onNewListingState.listen((listingState) { - _pagingController.value = PagingState( - nextPageKey: listingState.nextPageKey, - error: listingState.error, - itemList: listingState.itemList, - ); - }); - } + _GridType _gridType = _GridType.square; @override void dispose() { - _pagingController.dispose(); - _blocListingStateSubscription.cancel(); _bloc.dispose(); super.dispose(); } @override - Widget build(BuildContext context) => SafeArea( - child: CustomScrollView( - slivers: [ - SearchInputSliver( - onChanged: (searchTerm) => _bloc.onSearchInputChangedSink.add( - searchTerm, - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(8), - child: SingleChildScrollView( - child: Row( - children: [ - for (final gridType in _GridType.values) ...[ - ChoiceChip( - selected: _gridType == gridType, - onSelected: (value) => - setState(() => _gridType = gridType), - label: Text( - gridType.name.split('').first.toUpperCase() + - gridType.name.substring(1)), - ), - const SizedBox(width: 8), - ], - ], + Widget build(BuildContext context) => StreamBuilder>( + stream: _bloc.onState, + initialData: _bloc.state, + builder: (context, snapshot) => LayoutBuilder( + builder: (context, constraints) => SafeArea( + child: CustomScrollView( + slivers: [ + SearchInputSliver( + onChanged: (searchTerm) => _bloc.changeSearch( + searchTerm, ), + getSuggestions: (searchTerm) => (_bloc.state.items + ?.expand((photo) => photo.title.split(' ')) + .where((e) => e.contains(searchTerm)) + .toSet() + .toList() ?? + []), ), - ), - ), - switch (_gridType) { - _GridType.square => PagedSliverGrid( - pagingController: _pagingController, - gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( - childAspectRatio: 1 / 1.2, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - maxCrossAxisExtent: 200, - ), - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => CachedNetworkImage( - imageUrl: item.thumbnail, - fit: BoxFit.cover, + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8), + child: SingleChildScrollView( + child: Row( + children: [ + for (final gridType in _GridType.values) ...[ + ChoiceChip( + selected: _gridType == gridType, + onSelected: (value) => + setState(() => _gridType = gridType), + label: Text( + gridType.name.split('').first.toUpperCase() + + gridType.name.substring(1), + ), + ), + const SizedBox(width: 8), + ], + ], + ), ), ), ), - _GridType.masonry => PagedSliverMasonryGrid.extent( - pagingController: _pagingController, - maxCrossAxisExtent: 200, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => AspectRatio( - aspectRatio: item.width / item.height, - child: CachedNetworkImage( - imageUrl: item.thumbnail, + switch (_gridType) { + _GridType.square => PagedSliverGrid( + state: snapshot.data!, + fetchNextPage: _bloc.fetchNextPage, + gridDelegate: + const SliverGridDelegateWithMaxCrossAxisExtent( + childAspectRatio: 1 / 1.2, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + maxCrossAxisExtent: 200, + ), + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => + CachedNetworkImage( + imageUrl: item.thumbnail, + fit: BoxFit.cover, + ), ), ), - ), - ), - _GridType.aligned => PagedSliverAlignedGrid.extent( - pagingController: _pagingController, - maxCrossAxisExtent: 200, - crossAxisSpacing: 10, - mainAxisSpacing: 10, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => AspectRatio( - aspectRatio: item.width / item.height, - child: CachedNetworkImage( - imageUrl: item.thumbnail, + _GridType.masonry => + PagedSliverMasonryGrid.extent( + state: snapshot.data!, + fetchNextPage: _bloc.fetchNextPage, + maxCrossAxisExtent: 200, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => AspectRatio( + aspectRatio: item.width / item.height, + child: CachedNetworkImage( + imageUrl: item.thumbnail, + ), + ), ), ), - ), - showNewPageErrorIndicatorAsGridChild: false, - showNewPageProgressIndicatorAsGridChild: false, - showNoMoreItemsIndicatorAsGridChild: false, - ), - }, - ], + _GridType.aligned => + PagedSliverAlignedGrid.extent( + state: snapshot.data!, + fetchNextPage: _bloc.fetchNextPage, + maxCrossAxisExtent: 200, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + final rowCount = constraints.maxWidth ~/ 200; + final rowItems = snapshot.data!.items!.sublist( + max(0, index - index % rowCount), + min(snapshot.data!.items!.length, + index - index % rowCount + rowCount), + ); + final averageRatio = rowItems + .map((e) => e.width / e.height) + .reduce((a, b) => a + b) / + rowItems.length; + + return SizedBox( + // find out which row this item is in, then alculate the average height of the row + height: min(200 / averageRatio, 500), + child: CachedNetworkImage( + imageUrl: item.thumbnail, + fit: BoxFit.cover, + ), + ); + }, + ), + showNewPageErrorIndicatorAsGridChild: false, + showNewPageProgressIndicatorAsGridChild: false, + showNoMoreItemsIndicatorAsGridChild: false, + ), + }, + ], + ), + ), ), ); } diff --git a/lib/infinite_scroll_pagination.dart b/lib/infinite_scroll_pagination.dart index c896f2b..ccd0827 100644 --- a/lib/infinite_scroll_pagination.dart +++ b/lib/infinite_scroll_pagination.dart @@ -1,14 +1,23 @@ -export 'src/core/paged_child_builder_delegate.dart'; +export 'src/base/paged_child_builder_delegate.dart'; +export 'src/base/paged_layout_builder.dart'; +export 'src/base/paging_listener.dart'; + export 'src/core/paging_controller.dart'; -export 'src/model/paging_state.dart'; -export 'src/model/paging_status.dart'; -export 'src/widgets/helpers/paged_layout_builder.dart'; -export 'src/widgets/layouts/paged_grid_view.dart'; -export 'src/widgets/layouts/paged_list_view.dart'; -export 'src/widgets/layouts/paged_masonry_grid_view.dart'; -export 'src/widgets/layouts/paged_aligned_grid_view.dart'; -export 'src/widgets/layouts/paged_page_view.dart'; -export 'src/widgets/layouts/paged_sliver_grid.dart'; -export 'src/widgets/layouts/paged_sliver_list.dart'; -export 'src/widgets/layouts/paged_sliver_masonry_grid.dart'; -export 'src/widgets/layouts/paged_sliver_aligned_grid.dart'; +export 'src/core/paging_state.dart'; +export 'src/core/paging_state_base.dart'; +export 'src/core/paging_status.dart'; +export 'src/core/extensions.dart'; + +export 'src/helpers/appended_sliver_child_builder_delegate.dart'; +export 'src/helpers/appended_sliver_grid.dart'; +export 'src/helpers/flutter_staggered_grid_view.dart'; + +export 'src/layouts/paged_aligned_grid_view.dart'; +export 'src/layouts/paged_grid_view.dart'; +export 'src/layouts/paged_list_view.dart'; +export 'src/layouts/paged_masonry_grid_view.dart'; +export 'src/layouts/paged_page_view.dart'; +export 'src/layouts/paged_sliver_aligned_grid.dart'; +export 'src/layouts/paged_sliver_grid.dart'; +export 'src/layouts/paged_sliver_list.dart'; +export 'src/layouts/paged_sliver_masonry_grid.dart'; diff --git a/lib/src/core/paged_child_builder_delegate.dart b/lib/src/base/paged_child_builder_delegate.dart similarity index 88% rename from lib/src/core/paged_child_builder_delegate.dart rename to lib/src/base/paged_child_builder_delegate.dart index 1ca3002..f1fa732 100644 --- a/lib/src/core/paged_child_builder_delegate.dart +++ b/lib/src/base/paged_child_builder_delegate.dart @@ -10,7 +10,7 @@ typedef ItemWidgetBuilder = Widget Function( /// /// The generic type [ItemType] must be specified in order to properly identify /// the list item's type. -class PagedChildBuilderDelegate { +class PagedChildBuilderDelegate { const PagedChildBuilderDelegate({ required this.itemBuilder, this.firstPageErrorIndicatorBuilder, @@ -21,6 +21,7 @@ class PagedChildBuilderDelegate { this.noMoreItemsIndicatorBuilder, this.animateTransitions = false, this.transitionDuration = const Duration(milliseconds: 250), + this.invisibleItemsThreshold = 3, }); /// The builder for list items. @@ -49,4 +50,7 @@ class PagedChildBuilderDelegate { /// The duration of animated transitions when [animateTransitions] is `true`. final Duration transitionDuration; + + /// The number of remaining invisible items that should trigger a new fetch. + final int invisibleItemsThreshold; } diff --git a/lib/src/base/paged_layout_builder.dart b/lib/src/base/paged_layout_builder.dart new file mode 100644 index 0000000..636cce1 --- /dev/null +++ b/lib/src/base/paged_layout_builder.dart @@ -0,0 +1,330 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_child_builder_delegate.dart'; +import 'package:infinite_scroll_pagination/src/core/extensions.dart'; + +import 'package:infinite_scroll_pagination/src/defaults/first_page_error_indicator.dart'; +import 'package:infinite_scroll_pagination/src/defaults/first_page_progress_indicator.dart'; +import 'package:infinite_scroll_pagination/src/defaults/new_page_error_indicator.dart'; +import 'package:infinite_scroll_pagination/src/defaults/new_page_progress_indicator.dart'; +import 'package:infinite_scroll_pagination/src/defaults/no_items_found_indicator.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_status.dart'; +import 'package:sliver_tools/sliver_tools.dart'; + +/// Called to request a new page of data. +typedef NextPageCallback = VoidCallback; + +typedef CompletedListingBuilder = Widget Function( + BuildContext context, + IndexedWidgetBuilder itemWidgetBuilder, + int itemCount, + WidgetBuilder? noMoreItemsIndicatorBuilder, +); + +typedef ErrorListingBuilder = Widget Function( + BuildContext context, + IndexedWidgetBuilder itemWidgetBuilder, + int itemCount, + WidgetBuilder newPageErrorIndicatorBuilder, +); + +typedef LoadingListingBuilder = Widget Function( + BuildContext context, + IndexedWidgetBuilder itemWidgetBuilder, + int itemCount, + WidgetBuilder newPageProgressIndicatorBuilder, +); + +/// The Flutter layout protocols supported by [PagedLayoutBuilder]. +enum PagedLayoutProtocol { sliver, box } + +/// Facilitates creating infinitely scrolled paged layouts. +/// +/// Combines a [PagingController] with a +/// [PagedChildBuilderDelegate] and calls the supplied +/// [loadingListingBuilder], [errorListingBuilder] or +/// [completedListingBuilder] for filling in the gaps. +/// +/// For ordinary cases, this widget shouldn't be used directly. Instead, take a +/// look at [PagedSliverList], [PagedSliverGrid], [PagedListView], +/// [PagedGridView], [PagedMasonryGridView], or [PagedPageView]. +class PagedLayoutBuilder + extends StatefulWidget { + const PagedLayoutBuilder({ + required this.state, + required this.fetchNextPage, + required this.builderDelegate, + required this.loadingListingBuilder, + required this.errorListingBuilder, + required this.completedListingBuilder, + required this.layoutProtocol, + this.shrinkWrapFirstPageIndicators = false, + super.key, + }); + + /// The paging state for this layout. + final PagingState state; + + /// A callback function that is triggered to request a new page of data. + final NextPageCallback fetchNextPage; + + /// The delegate for building the UI pieces of scrolling paged listings. + final PagedChildBuilderDelegate builderDelegate; + + /// The builder for an in-progress listing. + final LoadingListingBuilder loadingListingBuilder; + + /// The builder for an in-progress listing with a failed request. + final ErrorListingBuilder errorListingBuilder; + + /// The builder for a completed listing. + final CompletedListingBuilder completedListingBuilder; + + /// Whether the extent of the first page indicators should be determined by + /// the contents being viewed. + /// + /// If the paged layout builder does not shrink wrap, then the first page + /// indicators will expand to the maximum allowed size. If the paged layout + /// builder has unbounded constraints, then [shrinkWrapFirstPageIndicators] + /// must be true. + /// + /// Defaults to false. + final bool shrinkWrapFirstPageIndicators; + + /// The layout protocol of the widget you're using this to build. + /// + /// For example, if [PagedLayoutProtocol.sliver] is specified, then + /// [loadingListingBuilder], [errorListingBuilder], and + /// [completedListingBuilder] have to return a Sliver widget. + final PagedLayoutProtocol layoutProtocol; + + @override + State> createState() => + _PagedLayoutBuilderState(); +} + +class _PagedLayoutBuilderState + extends State> { + PagingState get _state => widget.state; + + NextPageCallback get _fetchNextPage => + // We make sure to only schedule the fetch after the current build is done. + // This is important to prevent recursive builds. + () => WidgetsBinding.instance + .addPostFrameCallback((_) => widget.fetchNextPage()); + + PagedChildBuilderDelegate get _builderDelegate => + widget.builderDelegate; + + bool get _shrinkWrapFirstPageIndicators => + widget.shrinkWrapFirstPageIndicators; + + PagedLayoutProtocol get _layoutProtocol => widget.layoutProtocol; + + WidgetBuilder get _firstPageErrorIndicatorBuilder => + _builderDelegate.firstPageErrorIndicatorBuilder ?? + (_) => FirstPageErrorIndicator( + onTryAgain: _fetchNextPage, + ); + + WidgetBuilder get _newPageErrorIndicatorBuilder => + _builderDelegate.newPageErrorIndicatorBuilder ?? + (_) => NewPageErrorIndicator( + onTap: _fetchNextPage, + ); + + WidgetBuilder get _firstPageProgressIndicatorBuilder => + _builderDelegate.firstPageProgressIndicatorBuilder ?? + (_) => const FirstPageProgressIndicator(); + + WidgetBuilder get _newPageProgressIndicatorBuilder => + _builderDelegate.newPageProgressIndicatorBuilder ?? + (_) => const NewPageProgressIndicator(); + + WidgetBuilder get _noItemsFoundIndicatorBuilder => + _builderDelegate.noItemsFoundIndicatorBuilder ?? + (_) => const NoItemsFoundIndicator(); + + WidgetBuilder? get _noMoreItemsIndicatorBuilder => + _builderDelegate.noMoreItemsIndicatorBuilder; + + int get _invisibleItemsThreshold => _builderDelegate.invisibleItemsThreshold; + + int get _itemCount => _state.items?.length ?? 0; + + bool get _hasNextPage => _state.hasNextPage; + + /// Avoids duplicate requests on rebuilds. + bool _hasRequestedNextPage = false; + + @override + void initState() { + super.initState(); + if (_state.status == PagingStatus.loadingFirstPage) { + _fetchNextPage(); + } + } + + @override + void didUpdateWidget( + covariant PagedLayoutBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.state != widget.state) { + if (_state.status == PagingStatus.loadingFirstPage) { + _fetchNextPage(); + } else if (_state.status == PagingStatus.ongoing) { + _hasRequestedNextPage = false; + } + } + } + + @override + Widget build(BuildContext context) { + return _PagedLayoutAnimator( + animateTransitions: _builderDelegate.animateTransitions, + transitionDuration: _builderDelegate.transitionDuration, + layoutProtocol: _layoutProtocol, + child: switch (_state.status) { + PagingStatus.loadingFirstPage => _FirstPageStatusIndicatorBuilder( + key: const ValueKey(PagingStatus.loadingFirstPage), + builder: _firstPageProgressIndicatorBuilder, + shrinkWrap: _shrinkWrapFirstPageIndicators, + layoutProtocol: _layoutProtocol, + ), + PagingStatus.firstPageError => _FirstPageStatusIndicatorBuilder( + key: const ValueKey(PagingStatus.firstPageError), + builder: _firstPageErrorIndicatorBuilder, + shrinkWrap: _shrinkWrapFirstPageIndicators, + layoutProtocol: _layoutProtocol, + ), + PagingStatus.noItemsFound => _FirstPageStatusIndicatorBuilder( + key: const ValueKey(PagingStatus.noItemsFound), + builder: _noItemsFoundIndicatorBuilder, + shrinkWrap: _shrinkWrapFirstPageIndicators, + layoutProtocol: _layoutProtocol, + ), + PagingStatus.ongoing => widget.loadingListingBuilder( + context, + // We must create this closure to close over the [itemList] + // value. That way, we are safe if [itemList] value changes + // while Flutter rebuilds the widget (due to animations, for + // example.) + (context, index) => _buildListItemWidget( + context, + index, + _state.items!, + ), + _itemCount, + _newPageProgressIndicatorBuilder, + ), + PagingStatus.subsequentPageError => widget.errorListingBuilder( + context, + (context, index) => _buildListItemWidget( + context, + index, + _state.items!, + ), + _itemCount, + (context) => _newPageErrorIndicatorBuilder(context), + ), + PagingStatus.completed => widget.completedListingBuilder( + context, + (context, index) => _buildListItemWidget( + context, + index, + _state.items!, + ), + _itemCount, + _noMoreItemsIndicatorBuilder, + ), + }, + ); + } + + /// Connects the [_pagingController] with the [_builderDelegate] in order to + /// create a list item widget and request more items if needed. + Widget _buildListItemWidget( + BuildContext context, + int index, + List itemList, + ) { + if (!_hasRequestedNextPage) { + final maxIndex = max(0, _itemCount - 1); + final triggerIndex = max(0, maxIndex - _invisibleItemsThreshold); + + // It is important to check whether we are past the trigger, not just at it. + // This is because otherwise, large tresholds will place the trigger behind the user, + // Leading to the refresh never being triggered. + // This behaviour is okay because we make sure not to excessively request pages. + final hasPassedTrigger = index >= triggerIndex; + + if (_hasNextPage && hasPassedTrigger) { + _hasRequestedNextPage = true; + _fetchNextPage(); + } + } + + final item = itemList[index]; + return _builderDelegate.itemBuilder(context, item, index); + } +} + +class _PagedLayoutAnimator extends StatelessWidget { + const _PagedLayoutAnimator({ + required this.child, + required this.animateTransitions, + required this.transitionDuration, + required this.layoutProtocol, + }); + + final Widget child; + final bool animateTransitions; + final Duration transitionDuration; + final PagedLayoutProtocol layoutProtocol; + + @override + Widget build(BuildContext context) { + if (!animateTransitions) return child; + return switch (layoutProtocol) { + PagedLayoutProtocol.sliver => SliverAnimatedSwitcher( + duration: transitionDuration, + child: child, + ), + PagedLayoutProtocol.box => AnimatedSwitcher( + duration: transitionDuration, + child: child, + ), + }; + } +} + +class _FirstPageStatusIndicatorBuilder extends StatelessWidget { + const _FirstPageStatusIndicatorBuilder({ + super.key, + required this.builder, + required this.layoutProtocol, + this.shrinkWrap = false, + }); + + final WidgetBuilder builder; + final bool shrinkWrap; + final PagedLayoutProtocol layoutProtocol; + + @override + Widget build(BuildContext context) { + return switch (layoutProtocol) { + PagedLayoutProtocol.sliver => shrinkWrap + ? SliverToBoxAdapter(child: builder(context)) + : SliverFillRemaining( + hasScrollBody: false, + child: builder(context), + ), + PagedLayoutProtocol.box => + shrinkWrap ? builder(context) : Center(child: builder(context)), + }; + } +} diff --git a/lib/src/base/paging_listener.dart b/lib/src/base/paging_listener.dart new file mode 100644 index 0000000..dc9c34b --- /dev/null +++ b/lib/src/base/paging_listener.dart @@ -0,0 +1,33 @@ +import 'package:flutter/widgets.dart'; + +import 'package:infinite_scroll_pagination/src/core/paging_controller.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_layout_builder.dart'; + +class PagingListener + extends StatelessWidget { + const PagingListener({ + super.key, + required this.controller, + required this.builder, + }); + + final PagingController controller; + final Widget Function( + BuildContext context, + PagingState state, + NextPageCallback fetchNextPage, + ) builder; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: controller, + builder: (context, state, _) => builder( + context, + state, + controller.fetchNextPage, + ), + ); + } +} diff --git a/lib/src/core/extensions.dart b/lib/src/core/extensions.dart new file mode 100644 index 0000000..8d97289 --- /dev/null +++ b/lib/src/core/extensions.dart @@ -0,0 +1,70 @@ +import 'package:infinite_scroll_pagination/src/core/paging_controller.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_status.dart'; +import 'package:meta/meta.dart'; + +extension PagingStateExtension on PagingState { + /// The list of items fetched so far. A flattened version of [pages]. + List? get items => + pages != null ? List.unmodifiable(pages!.expand((e) => e)) : null; + + /// Convenience method to update the items of the state by applying a mapper function to each item. + /// + /// The result of this method is a new [PagingState] with the same properties as the original state + /// except for the items, which are replaced by the mapped items. + @UseResult('Use the returned value as new state.') + PagingState mapItems( + ItemType Function(ItemType item) mapper, + ) => + copyWith( + pages: pages?.map((page) => page.map(mapper).toList()).toList(), + ); + + /// Convenience method to filter the items of the state by applying a predicate function to each item. + /// + /// The result of this method is a new [PagingState] with the same properties as the original state + /// except for the items, which are replaced by the filtered items. + /// + /// It is not recommended to reassign the result of this method back to a state variable, because + /// the filtered items will be lost. Instead, use the returned value as computed state only. + /// This extension is absent from the [PagingController] extension for this reason. + @UseResult('Use the returned value as computed state.') + PagingState filterItems( + bool Function(ItemType item) predicate, + ) => + copyWith( + pages: pages?.map((page) => page.where(predicate).toList()).toList(), + ); +} + +/// Helper extensions to quickly access the state of a [PagingController]. +extension PagingControllerExtension on PagingController { + /// The pages fetched so far. + List>? get pages => value.pages; + + /// The items fetched so far. A flattened version of [pages]. + List? get items => value.items; + + /// Convenience method to update the items of the state. + /// + /// Items cannot be directly assigned, because they are backed by a list of pages. + void mapItems(ItemType Function(ItemType item) mapper) => + value = value.mapItems(mapper); + + /// The keys of the pages fetched so far. + List? get keys => value.keys; + + /// The last error that occurred while fetching a page. + Object? get error => value.error; + + /// Will be `true` if there is a next page to be fetched. + bool get hasNextPage => value.hasNextPage; + + /// Will be `true` if a page is currently being fetched. + bool get isLoading => value.isLoading; + + /// The paging status. + PagingStatus get status => value.status; +} diff --git a/lib/src/core/paging_controller.dart b/lib/src/core/paging_controller.dart index bf26707..e907878 100644 --- a/lib/src/core/paging_controller.dart +++ b/lib/src/core/paging_controller.dart @@ -1,226 +1,131 @@ +import 'dart:async'; + import 'package:flutter/foundation.dart'; -import 'package:infinite_scroll_pagination/src/model/paging_state.dart'; -import 'package:infinite_scroll_pagination/src/model/paging_status.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; -typedef PageRequestListener = void Function( - PageKeyType pageKey, -); +/// A callback to get the next page key. +/// If this function returns `null`, it indicates that there are no more pages to load. +typedef NextPageKeyCallback + = PageKeyType? Function(PagingState state); -typedef PagingStatusListener = void Function( - PagingStatus status, -); +/// A callback to fetch a page. +typedef FetchPageCallback + = FutureOr> Function(PageKeyType pageKey); -/// A controller for a paged widget. -/// -/// If you modify the [itemList], [error] or [nextPageKey] properties, the -/// paged widget will be notified and will update itself appropriately. +/// A controller to handle a [PagingState]. /// -/// The [itemList], [error] or [nextPageKey] properties can be set from within -/// a listener added to this controller. If more than one property need to be -/// changed then the controller's [value] should be set instead. +/// This is an unopinionated controller implemented through vanilla Flutter's [ValueNotifier]. +/// The controller acts as a mutex to prevent multiple fetches at the same time. /// -/// This object should generally have a lifetime longer than the widgets -/// itself; it should be reused each time a paged widget constructor is called. -class PagingController +/// Note that for convenience, fetch operations are not atomic. +/// The state may be updated during a fetch operation. This should be done fully synchronously, +/// as otherwise, the state may become desynchronized. +class PagingController extends ValueNotifier> { PagingController({ - required this.firstPageKey, - this.invisibleItemsThreshold, - }) : super( - PagingState(nextPageKey: firstPageKey), + PagingState? value, + required NextPageKeyCallback getNextPageKey, + required FetchPageCallback fetchPage, + }) : _getNextPageKey = getNextPageKey, + _fetchPage = fetchPage, + super( + value ?? PagingState(), ); - /// Creates a controller from an existing [PagingState]. - /// - /// [firstPageKey] is the key to be used in case of a [refresh]. - PagingController.fromValue( - PagingState value, { - required this.firstPageKey, - this.invisibleItemsThreshold, - }) : super(value); - - ObserverList? _statusListeners = - ObserverList(); - - ObserverList>? _pageRequestListeners = - ObserverList>(); - - /// The number of remaining invisible items that should trigger a new fetch. - final int? invisibleItemsThreshold; - - /// The key for the first page to be fetched. - final PageKeyType firstPageKey; + /// The function to get the next page key. + /// If this function returns `null`, it indicates that there are no more pages to load. + final NextPageKeyCallback _getNextPageKey; - /// List with all items loaded so far. Initially `null`. - List? get itemList => value.itemList; + /// The function to fetch a page. + final FetchPageCallback _fetchPage; - set itemList(List? newItemList) { - value = PagingState( - error: error, - itemList: newItemList, - nextPageKey: nextPageKey, - ); - } - - /// The current error, if any. Initially `null`. - dynamic get error => value.error; - - set error(dynamic newError) { - value = PagingState( - error: newError, - itemList: itemList, - nextPageKey: nextPageKey, - ); - } - - /// The key for the next page to be fetched. + /// Keeps track of the current operation. + /// If the operation changes during its execution, the operation is cancelled. /// - /// Initialized with the same value as [firstPageKey], received in the - /// constructor. - PageKeyType? get nextPageKey => value.nextPageKey; - - set nextPageKey(PageKeyType? newNextPageKey) { - value = PagingState( - error: error, - itemList: itemList, - nextPageKey: newNextPageKey, - ); - } + /// Instead of using this property directly, use [fetchNextPage], [refresh], or [cancel]. + /// If you are extending this class, check and set this property before and after the fetch operation. + @protected + @visibleForTesting + Object? operation; - /// Corresponding to [ValueNotifier.value]. - @override - set value(PagingState newValue) { - if (value.status != newValue.status) { - notifyStatusListeners(newValue.status); - } + /// Fetches the next page. + /// + /// If called while a page is fetching or no more pages are available, this method does nothing. + void fetchNextPage() async { + // We are already loading a new page. + if (this.operation != null) return; - super.value = newValue; - } + final operation = this.operation = Object(); - /// Appends [newItems] to the previously loaded ones and replaces - /// the next page's key. - void appendPage(List newItems, PageKeyType? nextPageKey) { - final previousItems = value.itemList ?? []; - final itemList = previousItems + newItems; - value = PagingState( - itemList: itemList, + value = value.copyWith( + isLoading: true, error: null, - nextPageKey: nextPageKey, ); - } - /// Appends [newItems] to the previously loaded ones and sets the next page - /// key to `null`. - void appendLastPage(List newItems) => appendPage(newItems, null); + // we use a local copy of value, + // so that we only send one notification now and at the end of the method. + PagingState state = value; - /// Erases the current error. - void retryLastFailedRequest() { - error = null; - } + try { + // There are no more pages to load. + if (!state.hasNextPage) return; - /// Resets [value] to its initial state. - void refresh() { - value = PagingState( - nextPageKey: firstPageKey, - error: null, - itemList: null, - ); - } + final nextPageKey = _getNextPageKey(state); - bool _debugAssertNotDisposed() { - assert(() { - if (_pageRequestListeners == null || _statusListeners == null) { - throw Exception( - 'A PagingController was used after being disposed.\nOnce you have ' - 'called dispose() on a PagingController, it can no longer be ' - 'used.\nIf you’re using a Future, it probably completed after ' - 'the disposal of the owning widget.\nMake sure dispose() has not ' - 'been called yet before using the PagingController.', - ); + // We are at the end of the list. + if (nextPageKey == null) { + state = state.copyWith(hasNextPage: false); + return; } - return true; - }()); - return true; - } - - /// Calls listener every time the status of the pagination changes. - /// - /// Listeners can be removed with [removeStatusListener]. - void addStatusListener(PagingStatusListener listener) { - assert(_debugAssertNotDisposed()); - _statusListeners?.add(listener); - } - - /// Stops calling the listener every time the status of the pagination - /// changes. - /// - /// Listeners can be added with [addStatusListener]. - void removeStatusListener(PagingStatusListener listener) { - assert(_debugAssertNotDisposed()); - _statusListeners?.remove(listener); - } - /// Calls all the status listeners. - /// - /// If listeners are added or removed during this function, the modifications - /// will not change which listeners are called during this iteration. - void notifyStatusListeners(PagingStatus status) { - assert(_debugAssertNotDisposed()); + final fetchResult = _fetchPage(nextPageKey); + List newItems; - if (_statusListeners?.isEmpty ?? true) { - return; - } + // If the result is synchronous, we can directly assign it in the same tick. + if (fetchResult is Future) { + newItems = await fetchResult; + } else { + newItems = fetchResult; + } - final localListeners = List.from(_statusListeners!); - for (final listener in localListeners) { - if (_statusListeners!.contains(listener)) { - listener(status); + // Update our state in case it was modified during the fetch operation. + // This beaks atomicity, but is necessary to allow users to modify the state during a fetch. + state = value; + + state = state.copyWith( + pages: [...?state.pages, newItems], + keys: [...?state.keys, nextPageKey], + ); + } catch (error) { + state = state.copyWith(error: error); + + if (error is! Exception) { + // Errors which are not exceptions indicate that something + // went unexpectedly wrong. These errors are rethrown + // so they can be logged and investigated. + rethrow; + } + } finally { + if (operation == this.operation) { + value = state.copyWith(isLoading: false); + this.operation = null; } } } - /// Calls listener every time new items are needed. + /// Restarts the pagination process. /// - /// Listeners can be removed with [removePageRequestListener]. - void addPageRequestListener(PageRequestListener listener) { - assert(_debugAssertNotDisposed()); - _pageRequestListeners?.add(listener); - } - - /// Stops calling the listener every time new items are needed. - /// - /// Listeners can be added with [addPageRequestListener]. - void removePageRequestListener(PageRequestListener listener) { - assert(_debugAssertNotDisposed()); - _pageRequestListeners?.remove(listener); + /// This cancels the current fetch operation and resets the state. + void refresh() { + operation = null; + value = value.reset(); } - /// Calls all the page request listeners. + /// Cancels the current fetch operation. /// - /// If listeners are added or removed during this function, the modifications - /// will not change which listeners are called during this iteration. - void notifyPageRequestListeners(PageKeyType pageKey) { - assert(_debugAssertNotDisposed()); - - if (_pageRequestListeners?.isEmpty ?? true) { - return; - } - - final localListeners = - List>.from(_pageRequestListeners!); - - for (final listener in localListeners) { - if (_pageRequestListeners!.contains(listener)) { - listener(pageKey); - } - } - } - - @override - void dispose() { - assert(_debugAssertNotDisposed()); - _statusListeners = null; - _pageRequestListeners = null; - super.dispose(); + /// This can be called right before a call to [fetchNextPage] to force a new fetch. + void cancel() { + operation = null; + value = value.copyWith(isLoading: false); } } diff --git a/lib/src/core/paging_state.dart b/lib/src/core/paging_state.dart new file mode 100644 index 0000000..420e167 --- /dev/null +++ b/lib/src/core/paging_state.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state_base.dart'; + +/// Represents the state of a paginated layout. +@immutable +abstract class PagingState { + /// Creates a [PagingState] with the given parameters. + factory PagingState({ + List>? pages, + List? keys, + Object? error, + bool hasNextPage, + bool isLoading, + }) = PagingStateBase; + + /// The pages fetched so far. + /// + /// This contains all pages fetched so far. + /// The corresponding key for each page is at the same index in [keys]. + List>? get pages; + + /// The keys of the pages fetched so far. + /// + /// This contains all keys used to fetch pages so far. + /// The corresponding page for each key is at the same index in [pages]. + List? get keys; + + /// The last error that occurred while fetching a page. + /// This is null if no error occurred. + Object? get error; + + /// Will be `true` if there is a next page to be fetched. + bool get hasNextPage; + + /// Will be `true` if a page is currently being fetched. + bool get isLoading; + + /// Creates a copy of this [PagingState] but with the given fields replaced by the new values. + /// If a field is not provided, it will default to the current value. + /// + /// While this implementation technically accepts Futures, passing a Future is invalid. + /// The Defaulted type is used to allow for the Omit sentinel value, + /// which is required to distinguish between a parameter being omitted and a parameter being set to null. + // copyWith a la Remi Rousselet: https://github.com/dart-lang/language/issues/137#issuecomment-583783054 + PagingState copyWith({ + Defaulted>?>? pages = const Omit(), + Defaulted?>? keys = const Omit(), + Defaulted? error = const Omit(), + Defaulted? hasNextPage = const Omit(), + Defaulted? isLoading = const Omit(), + }); + + /// Returns a copy this [PagingState] but + /// all fields are reset to their initial values. + /// + /// If you are implementing a custom [PagingState], you should override this method + /// to reset custom fields as well. + /// + /// The reason we use this instead of creating a new instance is so that + /// a custom [PagingState] can be reset without losing its type. + PagingState reset(); +} + +typedef Defaulted = FutureOr; + +/// Sentinel value to omit a parameter from a copyWith call. +/// This is used to distinguish between a parameter being omitted and a parameter +/// being set to null. +/// See https://github.com/dart-lang/language/issues/140 for why this is necessary. +final class Omit implements Future { + const Omit(); + + // coverage:ignore-start + @override + noSuchMethod(Invocation invocation) => throw UnsupportedError( + 'It is an error to attempt to use a Omit as a Future.', + ); + // coverage:ignore-end +} diff --git a/lib/src/core/paging_state_base.dart b/lib/src/core/paging_state_base.dart new file mode 100644 index 0000000..a2c34fe --- /dev/null +++ b/lib/src/core/paging_state_base.dart @@ -0,0 +1,99 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; + +/// The default implementation of [PagingState]. +/// +/// This class is equal to another instance of [PagingStateBase] if +/// all of its fields are deeply equal. +base class PagingStateBase + implements PagingState { + /// Creates a [PagingStateBase] with the given parameters. + /// + /// Ensures that [pages] and [keys] are unmodifiable lists. + PagingStateBase({ + List>? pages, + List? keys, + this.error, + this.hasNextPage = true, + this.isLoading = false, + }) : assert( + pages?.length == keys?.length, + 'The length of pages and keys must be equal.', + ), + pages = switch (pages) { + null => null, + _ => List.unmodifiable(pages), + }, + keys = switch (keys) { + null => null, + _ => List.unmodifiable(keys), + }; + + @override + final List>? pages; + + @override + final List? keys; + + @override + final Object? error; + + @override + final bool hasNextPage; + + @override + final bool isLoading; + + @override + PagingState copyWith({ + Defaulted>?>? pages = const Omit(), + Defaulted?>? keys = const Omit(), + Defaulted? error = const Omit(), + Defaulted? hasNextPage = const Omit(), + Defaulted? isLoading = const Omit(), + }) => + PagingStateBase( + pages: pages is Omit ? this.pages : pages as List>?, + keys: keys is Omit ? this.keys : keys as List?, + error: error is Omit ? this.error : error as Object?, + hasNextPage: + hasNextPage is Omit ? this.hasNextPage : hasNextPage as bool, + isLoading: isLoading is Omit ? this.isLoading : isLoading as bool, + ); + + @override + PagingState reset() => PagingStateBase( + pages: null, + keys: null, + error: null, + hasNextPage: true, + isLoading: false, + ); + + @override + String toString() => '${objectRuntimeType(this, 'PagingStateBase')}' + '(pages: $pages, keys: $keys, error: $error, hasNextPage: $hasNextPage, ' + 'isLoading: $isLoading)'; + + static const _equality = DeepCollectionEquality(); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other is PagingState && + _equality.equals(other.pages, pages) && + _equality.equals(other.keys, keys) && + other.error == error && + other.hasNextPage == hasNextPage && + other.isLoading == isLoading); + } + + @override + int get hashCode => Object.hash( + _equality.hash(pages), + _equality.hash(keys), + error, + hasNextPage, + ); +} diff --git a/lib/src/core/paging_status.dart b/lib/src/core/paging_status.dart new file mode 100644 index 0000000..ab29dd7 --- /dev/null +++ b/lib/src/core/paging_status.dart @@ -0,0 +1,51 @@ +import 'package:infinite_scroll_pagination/src/core/extensions.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; + +/// All possible status for a pagination. +enum PagingStatus { + noItemsFound, + loadingFirstPage, + firstPageError, + ongoing, + subsequentPageError, + completed, +} + +/// Extension methods for [PagingState] to determine the current status. +extension PagingStatusExtension on PagingState { + int? get _itemCount => items?.length; + + bool get _hasItems { + final itemCount = _itemCount; + return itemCount != null && itemCount > 0; + } + + bool get _hasError => error != null; + + bool get _isLoadingFirstPage => _itemCount == null && !_hasError; + + bool get _hasFirstPageError => !_hasItems && _hasError; + + bool get _isListingUnfinished => _hasItems && hasNextPage; + + bool get _isOngoing => _isListingUnfinished && !_hasError; + + bool get _isCompleted => _hasItems && !hasNextPage; + + bool get _hasSubsequentPageError => _isListingUnfinished && _hasError; + + bool get _isEmpty => _itemCount != null && _itemCount == 0; + + /// The current pagination status. + PagingStatus get status { + if (_isLoadingFirstPage) return PagingStatus.loadingFirstPage; + if (_hasFirstPageError) return PagingStatus.firstPageError; + if (_isEmpty) return PagingStatus.noItemsFound; + if (_isOngoing) return PagingStatus.ongoing; + if (_hasSubsequentPageError) return PagingStatus.subsequentPageError; + if (_isCompleted) return PagingStatus.completed; + // coverage:ignore-start // This can never happen under normal circumstances. + throw StateError('Unknown status; Did you forget to implement a case?'); + // coverage:ignore-end + } +} diff --git a/lib/src/widgets/helpers/default_status_indicators/first_page_error_indicator.dart b/lib/src/defaults/first_page_error_indicator.dart similarity index 73% rename from lib/src/widgets/helpers/default_status_indicators/first_page_error_indicator.dart rename to lib/src/defaults/first_page_error_indicator.dart index 045c674..7fb879a 100644 --- a/lib/src/widgets/helpers/default_status_indicators/first_page_error_indicator.dart +++ b/lib/src/defaults/first_page_error_indicator.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/first_page_exception_indicator.dart'; +import 'package:infinite_scroll_pagination/src/defaults/first_page_exception_indicator.dart'; class FirstPageErrorIndicator extends StatelessWidget { const FirstPageErrorIndicator({ this.onTryAgain, - Key? key, - }) : super(key: key); + super.key, + }); final VoidCallback? onTryAgain; diff --git a/lib/src/widgets/helpers/default_status_indicators/first_page_exception_indicator.dart b/lib/src/defaults/first_page_exception_indicator.dart similarity index 97% rename from lib/src/widgets/helpers/default_status_indicators/first_page_exception_indicator.dart rename to lib/src/defaults/first_page_exception_indicator.dart index f12538a..54fc686 100644 --- a/lib/src/widgets/helpers/default_status_indicators/first_page_exception_indicator.dart +++ b/lib/src/defaults/first_page_exception_indicator.dart @@ -6,8 +6,8 @@ class FirstPageExceptionIndicator extends StatelessWidget { required this.title, this.message, this.onTryAgain, - Key? key, - }) : super(key: key); + super.key, + }); final String title; final String? message; diff --git a/lib/src/widgets/helpers/default_status_indicators/first_page_progress_indicator.dart b/lib/src/defaults/first_page_progress_indicator.dart similarity index 81% rename from lib/src/widgets/helpers/default_status_indicators/first_page_progress_indicator.dart rename to lib/src/defaults/first_page_progress_indicator.dart index 3ce4476..f2645b2 100644 --- a/lib/src/widgets/helpers/default_status_indicators/first_page_progress_indicator.dart +++ b/lib/src/defaults/first_page_progress_indicator.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; class FirstPageProgressIndicator extends StatelessWidget { - const FirstPageProgressIndicator({Key? key}) : super(key: key); + const FirstPageProgressIndicator({super.key}); @override Widget build(BuildContext context) => const Padding( diff --git a/lib/src/widgets/helpers/default_status_indicators/footer_tile.dart b/lib/src/defaults/footer_tile.dart similarity index 90% rename from lib/src/widgets/helpers/default_status_indicators/footer_tile.dart rename to lib/src/defaults/footer_tile.dart index 4d49f07..83dbeb1 100644 --- a/lib/src/widgets/helpers/default_status_indicators/footer_tile.dart +++ b/lib/src/defaults/footer_tile.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; class FooterTile extends StatelessWidget { const FooterTile({ required this.child, - Key? key, - }) : super(key: key); + super.key, + }); final Widget child; diff --git a/lib/src/widgets/helpers/default_status_indicators/new_page_error_indicator.dart b/lib/src/defaults/new_page_error_indicator.dart similarity index 84% rename from lib/src/widgets/helpers/default_status_indicators/new_page_error_indicator.dart rename to lib/src/defaults/new_page_error_indicator.dart index 8fcc597..124c9e0 100644 --- a/lib/src/widgets/helpers/default_status_indicators/new_page_error_indicator.dart +++ b/lib/src/defaults/new_page_error_indicator.dart @@ -1,11 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/footer_tile.dart'; +import 'package:infinite_scroll_pagination/src/defaults/footer_tile.dart'; class NewPageErrorIndicator extends StatelessWidget { const NewPageErrorIndicator({ - Key? key, + super.key, this.onTap, - }) : super(key: key); + }); + final VoidCallback? onTap; @override diff --git a/lib/src/widgets/helpers/default_status_indicators/new_page_progress_indicator.dart b/lib/src/defaults/new_page_progress_indicator.dart similarity index 55% rename from lib/src/widgets/helpers/default_status_indicators/new_page_progress_indicator.dart rename to lib/src/defaults/new_page_progress_indicator.dart index bd24b07..98fbe0d 100644 --- a/lib/src/widgets/helpers/default_status_indicators/new_page_progress_indicator.dart +++ b/lib/src/defaults/new_page_progress_indicator.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/footer_tile.dart'; +import 'package:infinite_scroll_pagination/src/defaults/footer_tile.dart'; class NewPageProgressIndicator extends StatelessWidget { - const NewPageProgressIndicator({ - Key? key, - }) : super(key: key); + const NewPageProgressIndicator({super.key}); @override Widget build(BuildContext context) => const FooterTile( diff --git a/lib/src/widgets/helpers/default_status_indicators/no_items_found_indicator.dart b/lib/src/defaults/no_items_found_indicator.dart similarity index 62% rename from lib/src/widgets/helpers/default_status_indicators/no_items_found_indicator.dart rename to lib/src/defaults/no_items_found_indicator.dart index 9c7b12d..7dd52f8 100644 --- a/lib/src/widgets/helpers/default_status_indicators/no_items_found_indicator.dart +++ b/lib/src/defaults/no_items_found_indicator.dart @@ -1,9 +1,9 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/first_page_exception_indicator.dart'; +import 'package:infinite_scroll_pagination/src/defaults/first_page_exception_indicator.dart'; class NoItemsFoundIndicator extends StatelessWidget { - const NoItemsFoundIndicator({Key? key}) : super(key: key); + const NoItemsFoundIndicator({super.key}); @override Widget build(BuildContext context) => const FirstPageExceptionIndicator( diff --git a/lib/src/utils/appended_sliver_child_builder_delegate.dart b/lib/src/helpers/appended_sliver_child_builder_delegate.dart similarity index 100% rename from lib/src/utils/appended_sliver_child_builder_delegate.dart rename to lib/src/helpers/appended_sliver_child_builder_delegate.dart diff --git a/lib/src/utils/appended_sliver_grid.dart b/lib/src/helpers/appended_sliver_grid.dart similarity index 74% rename from lib/src/utils/appended_sliver_grid.dart rename to lib/src/helpers/appended_sliver_grid.dart index c06e103..8c28a40 100644 --- a/lib/src/utils/appended_sliver_grid.dart +++ b/lib/src/helpers/appended_sliver_grid.dart @@ -18,8 +18,8 @@ class AppendedSliverGrid extends StatelessWidget { this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, this.addSemanticIndexes = true, - Key? key, - }) : super(key: key); + super.key, + }); final IndexedWidgetBuilder itemBuilder; final int itemCount; @@ -34,12 +34,24 @@ class AppendedSliverGrid extends StatelessWidget { Widget build(BuildContext context) { final appendixBuilder = this.appendixBuilder; + SliverChildBuilderDelegate buildSliverDelegate({ + WidgetBuilder? appendixBuilder, + }) => + AppendedSliverChildBuilderDelegate( + builder: itemBuilder, + childCount: itemCount, + appendixBuilder: appendixBuilder, + addAutomaticKeepAlives: addAutomaticKeepAlives, + addRepaintBoundaries: addRepaintBoundaries, + addSemanticIndexes: addSemanticIndexes, + ); + return SliverMainAxisGroup( slivers: [ sliverGridBuilder( itemCount + (showAppendixAsGridChild && appendixBuilder != null ? 1 : 0), - _buildSliverDelegate( + buildSliverDelegate( appendixBuilder: showAppendixAsGridChild ? appendixBuilder : null, ), ), @@ -50,16 +62,4 @@ class AppendedSliverGrid extends StatelessWidget { ], ); } - - SliverChildBuilderDelegate _buildSliverDelegate({ - WidgetBuilder? appendixBuilder, - }) => - AppendedSliverChildBuilderDelegate( - builder: itemBuilder, - childCount: itemCount, - appendixBuilder: appendixBuilder, - addAutomaticKeepAlives: addAutomaticKeepAlives, - addRepaintBoundaries: addRepaintBoundaries, - addSemanticIndexes: addSemanticIndexes, - ); } diff --git a/lib/src/helpers/flutter_staggered_grid_view.dart b/lib/src/helpers/flutter_staggered_grid_view.dart new file mode 100644 index 0000000..70d10b7 --- /dev/null +++ b/lib/src/helpers/flutter_staggered_grid_view.dart @@ -0,0 +1,7 @@ +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; + +export 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; + +typedef SliverSimpleGridDelegateBuilder = SliverSimpleGridDelegate Function( + int childCount, +); diff --git a/lib/src/widgets/layouts/paged_aligned_grid_view.dart b/lib/src/layouts/paged_aligned_grid_view.dart similarity index 65% rename from lib/src/widgets/layouts/paged_aligned_grid_view.dart rename to lib/src/layouts/paged_aligned_grid_view.dart index 51717af..a112c37 100644 --- a/lib/src/widgets/layouts/paged_aligned_grid_view.dart +++ b/lib/src/layouts/paged_aligned_grid_view.dart @@ -1,7 +1,9 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_child_builder_delegate.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_layout_builder.dart'; +import 'package:infinite_scroll_pagination/src/helpers/flutter_staggered_grid_view.dart'; +import 'package:infinite_scroll_pagination/src/layouts/paged_sliver_aligned_grid.dart'; /// A [AlignedGridView] with pagination capabilities. /// @@ -12,179 +14,147 @@ import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; /// from the [flutter_staggered_grid_view](https://pub.dev/packages/flutter_staggered_grid_view) package. /// For more info on how to build staggered grids, check out the /// referred package's documentation and examples. -class PagedAlignedGridView extends BoxScrollView { +class PagedAlignedGridView + extends BoxScrollView { const PagedAlignedGridView({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required this.gridDelegateBuilder, // Matches [ScrollView.scrollDirection]. - Axis scrollDirection = Axis.vertical, + super.scrollDirection, // Matches [ScrollView.reverse]. - bool reverse = false, + super.reverse, // Matches [ScrollView.primary]. - bool? primary, + super.primary, // Matches [ScrollView.physics]. - ScrollPhysics? physics, + super.physics, this.scrollController, this.mainAxisSpacing = 0.0, this.crossAxisSpacing = 0.0, // Matches [ScrollView.cacheExtent]. - double? cacheExtent, + super.cacheExtent, this.showNewPageProgressIndicatorAsGridChild = true, this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, // Matches [ScrollView.dragStartBehavior]. - DragStartBehavior dragStartBehavior = DragStartBehavior.start, + super.dragStartBehavior, // Matches [ScrollView.keyboardDismissBehavior]. - ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = - ScrollViewKeyboardDismissBehavior.manual, + super.keyboardDismissBehavior, // Matches [ScrollView.restorationId]. - String? restorationId, + super.restorationId, // Matches [ScrollView.clipBehavior]. - Clip clipBehavior = Clip.hardEdge, + super.clipBehavior, // Matches [ScrollView.shrinkWrap]. - bool shrinkWrap = false, + super.shrinkWrap, // Matches [BoxScrollView.padding]. - EdgeInsetsGeometry? padding, + super.padding, this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, this.addSemanticIndexes = true, - Key? key, + super.key, }) : _shrinkWrapFirstPageIndicators = shrinkWrap, super( - key: key, - scrollDirection: scrollDirection, - reverse: reverse, controller: scrollController, - primary: primary, - physics: physics, - shrinkWrap: shrinkWrap, - padding: padding, - cacheExtent: cacheExtent, - dragStartBehavior: dragStartBehavior, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - clipBehavior: clipBehavior, ); /// Equivalent to [MasonryGridView.count]. PagedAlignedGridView.count({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required int crossAxisCount, - Axis scrollDirection = Axis.vertical, + super.scrollDirection, // Matches [ScrollView.reverse]. - bool reverse = false, + super.reverse, // Matches [ScrollView.primary]. - bool? primary, + super.primary, // Matches [ScrollView.physics]. - ScrollPhysics? physics, + super.physics, this.scrollController, this.mainAxisSpacing = 0.0, this.crossAxisSpacing = 0.0, // Matches [ScrollView.cacheExtent]. - double? cacheExtent, + super.cacheExtent, this.showNewPageProgressIndicatorAsGridChild = true, this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, // Matches [ScrollView.dragStartBehavior]. - DragStartBehavior dragStartBehavior = DragStartBehavior.start, + super.dragStartBehavior, // Matches [ScrollView.keyboardDismissBehavior]. - ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = - ScrollViewKeyboardDismissBehavior.manual, + super.keyboardDismissBehavior, // Matches [ScrollView.restorationId]. - String? restorationId, + super.restorationId, // Matches [ScrollView.clipBehavior]. - Clip clipBehavior = Clip.hardEdge, + super.clipBehavior, // Matches [ScrollView.shrinkWrap]. - bool shrinkWrap = false, + super.shrinkWrap, // Matches [BoxScrollView.padding]. - EdgeInsetsGeometry? padding, + super.padding, this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, this.addSemanticIndexes = true, - Key? key, + super.key, }) : _shrinkWrapFirstPageIndicators = shrinkWrap, gridDelegateBuilder = ((childCount) => SliverSimpleGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, )), super( - key: key, - scrollDirection: scrollDirection, - reverse: reverse, controller: scrollController, - primary: primary, - physics: physics, - shrinkWrap: shrinkWrap, - padding: padding, - cacheExtent: cacheExtent, - dragStartBehavior: dragStartBehavior, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - clipBehavior: clipBehavior, ); /// Equivalent to [MasonryGridView.extent]. PagedAlignedGridView.extent({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required double maxCrossAxisExtent, - Axis scrollDirection = Axis.vertical, + super.scrollDirection, // Matches [ScrollView.reverse]. - bool reverse = false, + super.reverse, // Matches [ScrollView.primary]. - bool? primary, + super.primary, // Matches [ScrollView.physics]. - ScrollPhysics? physics, + super.physics, this.scrollController, this.mainAxisSpacing = 0.0, this.crossAxisSpacing = 0.0, // Matches [ScrollView.cacheExtent]. - double? cacheExtent, + super.cacheExtent, this.showNewPageProgressIndicatorAsGridChild = true, this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, // Matches [ScrollView.dragStartBehavior]. - DragStartBehavior dragStartBehavior = DragStartBehavior.start, + super.dragStartBehavior, // Matches [ScrollView.keyboardDismissBehavior]. - ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = - ScrollViewKeyboardDismissBehavior.manual, + super.keyboardDismissBehavior, // Matches [ScrollView.restorationId]. - String? restorationId, + super.restorationId, // Matches [ScrollView.clipBehavior]. - Clip clipBehavior = Clip.hardEdge, + super.clipBehavior, // Matches [ScrollView.shrinkWrap]. - bool shrinkWrap = false, + super.shrinkWrap, // Matches [BoxScrollView.padding]. - EdgeInsetsGeometry? padding, + super.padding, this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, this.addSemanticIndexes = true, - Key? key, + super.key, }) : _shrinkWrapFirstPageIndicators = shrinkWrap, gridDelegateBuilder = ((childCount) => SliverSimpleGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: maxCrossAxisExtent, )), super( - key: key, - scrollDirection: scrollDirection, - reverse: reverse, controller: scrollController, - primary: primary, - physics: physics, - shrinkWrap: shrinkWrap, - padding: padding, - cacheExtent: cacheExtent, - dragStartBehavior: dragStartBehavior, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - clipBehavior: clipBehavior, ); - /// Matches [PagedLayoutBuilder.pagingController]. - final PagingController pagingController; + /// Matches [PagedLayoutBuilder.state]. + final PagingState state; + + /// Matches [PagedLayoutBuilder.onPageRequest]. + final NextPageCallback fetchNextPage; /// Matches [PagedLayoutBuilder.builderDelegate]. final PagedChildBuilderDelegate builderDelegate; @@ -225,7 +195,8 @@ class PagedAlignedGridView extends BoxScrollView { Widget buildChildLayout(BuildContext context) => PagedSliverAlignedGrid( builderDelegate: builderDelegate, - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, gridDelegateBuilder: gridDelegateBuilder, mainAxisSpacing: mainAxisSpacing, crossAxisSpacing: crossAxisSpacing, diff --git a/lib/src/widgets/layouts/paged_grid_view.dart b/lib/src/layouts/paged_grid_view.dart similarity index 66% rename from lib/src/widgets/layouts/paged_grid_view.dart rename to lib/src/layouts/paged_grid_view.dart index bafb518..5318c8b 100644 --- a/lib/src/widgets/layouts/paged_grid_view.dart +++ b/lib/src/layouts/paged_grid_view.dart @@ -1,70 +1,61 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; -import 'package:infinite_scroll_pagination/src/core/paged_child_builder_delegate.dart'; -import 'package:infinite_scroll_pagination/src/core/paging_controller.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/paged_layout_builder.dart'; -import 'package:infinite_scroll_pagination/src/widgets/layouts/paged_sliver_grid.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_child_builder_delegate.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_layout_builder.dart'; +import 'package:infinite_scroll_pagination/src/layouts/paged_sliver_grid.dart'; /// A [GridView] with pagination capabilities. /// /// Wraps a [PagedSliverGrid] in a [BoxScrollView] so that it can be /// used without the need for a [CustomScrollView]. Similar to a [GridView]. -class PagedGridView extends BoxScrollView { +class PagedGridView + extends BoxScrollView { const PagedGridView({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required this.gridDelegate, // Matches [ScrollView.controller]. ScrollController? scrollController, // Matches [ScrollView.scrollDirection]. - Axis scrollDirection = Axis.vertical, + super.scrollDirection, // Matches [ScrollView.reverse]. - bool reverse = false, + super.reverse, // Matches [ScrollView.primary]. - bool? primary, + super.primary, // Matches [ScrollView.physics]. - ScrollPhysics? physics, + super.physics, // Matches [ScrollView.shrinkWrap]. - bool shrinkWrap = false, + super.shrinkWrap, // Matches [BoxScrollView.padding]. - EdgeInsetsGeometry? padding, + super.padding, this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, this.addSemanticIndexes = true, // Matches [ScrollView.cacheExtent]. - double? cacheExtent, + super.cacheExtent, this.showNewPageProgressIndicatorAsGridChild = true, this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, // Matches [ScrollView.dragStartBehavior]. - DragStartBehavior dragStartBehavior = DragStartBehavior.start, + super.dragStartBehavior, // Matches [ScrollView.keyboardDismissBehavior]. - ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = - ScrollViewKeyboardDismissBehavior.manual, + super.keyboardDismissBehavior, // Matches [ScrollView.restorationId]. - String? restorationId, + super.restorationId, // Matches [ScrollView.clipBehavior]. - Clip clipBehavior = Clip.hardEdge, - Key? key, + super.clipBehavior, + super.key, }) : _shrinkWrapFirstPageIndicators = shrinkWrap, super( - key: key, - scrollDirection: scrollDirection, - reverse: reverse, controller: scrollController, - primary: primary, - physics: physics, - shrinkWrap: shrinkWrap, - padding: padding, - cacheExtent: cacheExtent, - dragStartBehavior: dragStartBehavior, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - clipBehavior: clipBehavior, ); - /// Matches [PagedLayoutBuilder.pagingController]. - final PagingController pagingController; + /// Matches [PagedLayoutBuilder.state]. + final PagingState state; + + /// Matches [PagedLayoutBuilder.onPageRequest]. + final NextPageCallback fetchNextPage; /// Matches [PagedLayoutBuilder.builderDelegate]. final PagedChildBuilderDelegate builderDelegate; @@ -97,7 +88,8 @@ class PagedGridView extends BoxScrollView { Widget buildChildLayout(BuildContext context) => PagedSliverGrid( builderDelegate: builderDelegate, - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, gridDelegate: gridDelegate, addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries, diff --git a/lib/src/widgets/layouts/paged_list_view.dart b/lib/src/layouts/paged_list_view.dart similarity index 63% rename from lib/src/widgets/layouts/paged_list_view.dart rename to lib/src/layouts/paged_list_view.dart index a6ec35a..4fb9445 100644 --- a/lib/src/widgets/layouts/paged_list_view.dart +++ b/lib/src/layouts/paged_list_view.dart @@ -1,9 +1,8 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; -import 'package:infinite_scroll_pagination/src/core/paged_child_builder_delegate.dart'; -import 'package:infinite_scroll_pagination/src/core/paging_controller.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/paged_layout_builder.dart'; -import 'package:infinite_scroll_pagination/src/widgets/layouts/paged_sliver_list.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_child_builder_delegate.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_layout_builder.dart'; +import 'package:infinite_scroll_pagination/src/layouts/paged_sliver_list.dart'; /// A [ListView] with pagination capabilities. /// @@ -11,41 +10,42 @@ import 'package:infinite_scroll_pagination/src/widgets/layouts/paged_sliver_list /// /// Wraps a [PagedSliverList] in a [BoxScrollView] so that it can be /// used without the need for a [CustomScrollView]. Similar to a [ListView]. -class PagedListView extends BoxScrollView { +class PagedListView + extends BoxScrollView { const PagedListView({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, // Matches [ScrollView.controller]. ScrollController? scrollController, // Matches [ScrollView.scrollDirection]. - Axis scrollDirection = Axis.vertical, + super.scrollDirection, // Matches [ScrollView.reverse]. - bool reverse = false, + super.reverse, // Matches [ScrollView.primary]. - bool? primary, + super.primary, // Matches [ScrollView.physics]. - ScrollPhysics? physics, + super.physics, // Matches [ScrollView.shrinkWrap]. - bool shrinkWrap = false, + super.shrinkWrap, // Matches [BoxScrollView.padding]. - EdgeInsetsGeometry? padding, + super.padding, this.itemExtent, this.prototypeItem, this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, this.addSemanticIndexes = true, // Matches [ScrollView.cacheExtent] - double? cacheExtent, + super.cacheExtent, // Matches [ScrollView.dragStartBehavior] - DragStartBehavior dragStartBehavior = DragStartBehavior.start, + super.dragStartBehavior, // Matches [ScrollView.keyboardDismissBehavior] - ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = - ScrollViewKeyboardDismissBehavior.manual, + super.keyboardDismissBehavior, // Matches [ScrollView.restorationId] - String? restorationId, + super.restorationId, // Matches [ScrollView.clipBehavior] - Clip clipBehavior = Clip.hardEdge, - Key? key, + super.clipBehavior, + super.key, }) : assert( itemExtent == null || prototypeItem == null, 'You can only pass itemExtent or prototypeItem, not both', @@ -53,76 +53,55 @@ class PagedListView extends BoxScrollView { _separatorBuilder = null, _shrinkWrapFirstPageIndicators = shrinkWrap, super( - key: key, - scrollDirection: scrollDirection, - reverse: reverse, controller: scrollController, - primary: primary, - physics: physics, - shrinkWrap: shrinkWrap, - padding: padding, - cacheExtent: cacheExtent, - dragStartBehavior: dragStartBehavior, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - clipBehavior: clipBehavior, ); const PagedListView.separated({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required IndexedWidgetBuilder separatorBuilder, // Matches [ScrollView.controller]. ScrollController? scrollController, // Matches [ScrollView.scrollDirection]. - Axis scrollDirection = Axis.vertical, + super.scrollDirection, // Matches [ScrollView.reverse]. - bool reverse = false, + super.reverse, // Matches [ScrollView.primary]. - bool? primary, + super.primary, // Matches [ScrollView.physics]. - ScrollPhysics? physics, + super.physics, // Matches [ScrollView.shrinkWrap]. - bool shrinkWrap = false, + super.shrinkWrap, // Matches [BoxScrollView.padding]. - EdgeInsetsGeometry? padding, + super.padding, this.itemExtent, this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, this.addSemanticIndexes = true, // Matches [ScrollView.cacheExtent] - double? cacheExtent, + super.cacheExtent, // Matches [ScrollView.dragStartBehavior] - DragStartBehavior dragStartBehavior = DragStartBehavior.start, + super.dragStartBehavior, // Matches [ScrollView.keyboardDismissBehavior] - ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = - ScrollViewKeyboardDismissBehavior.manual, + super.keyboardDismissBehavior, // Matches [ScrollView.restorationId] - String? restorationId, + super.restorationId, // Matches [ScrollView.clipBehavior] - Clip clipBehavior = Clip.hardEdge, - Key? key, + super.clipBehavior, + super.key, }) : prototypeItem = null, _shrinkWrapFirstPageIndicators = shrinkWrap, _separatorBuilder = separatorBuilder, super( - key: key, - scrollDirection: scrollDirection, - reverse: reverse, controller: scrollController, - primary: primary, - physics: physics, - shrinkWrap: shrinkWrap, - padding: padding, - cacheExtent: cacheExtent, - dragStartBehavior: dragStartBehavior, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - clipBehavior: clipBehavior, ); - /// Matches [PagedLayoutBuilder.pagingController]. - final PagingController pagingController; + /// Matches [PagedLayoutBuilder.state]. + final PagingState state; + + /// Matches [PagedLayoutBuilder.onPageRequest]. + final NextPageCallback fetchNextPage; /// Matches [PagedLayoutBuilder.builderDelegate]. final PagedChildBuilderDelegate builderDelegate; @@ -158,7 +137,8 @@ class PagedListView extends BoxScrollView { return separatorBuilder != null ? PagedSliverList.separated( builderDelegate: builderDelegate, - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, separatorBuilder: separatorBuilder, addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries, @@ -168,7 +148,8 @@ class PagedListView extends BoxScrollView { ) : PagedSliverList( builderDelegate: builderDelegate, - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, addAutomaticKeepAlives: addAutomaticKeepAlives, addRepaintBoundaries: addRepaintBoundaries, addSemanticIndexes: addSemanticIndexes, diff --git a/lib/src/widgets/layouts/paged_masonry_grid_view.dart b/lib/src/layouts/paged_masonry_grid_view.dart similarity index 65% rename from lib/src/widgets/layouts/paged_masonry_grid_view.dart rename to lib/src/layouts/paged_masonry_grid_view.dart index f6f41e5..f584144 100644 --- a/lib/src/widgets/layouts/paged_masonry_grid_view.dart +++ b/lib/src/layouts/paged_masonry_grid_view.dart @@ -1,7 +1,9 @@ -import 'package:flutter/gestures.dart'; import 'package:flutter/widgets.dart'; -import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_child_builder_delegate.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_layout_builder.dart'; +import 'package:infinite_scroll_pagination/src/helpers/flutter_staggered_grid_view.dart'; +import 'package:infinite_scroll_pagination/src/layouts/paged_sliver_masonry_grid.dart'; /// A [MasonryGridView] with pagination capabilities. /// @@ -12,179 +14,147 @@ import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; /// from the [flutter_staggered_grid_view](https://pub.dev/packages/flutter_staggered_grid_view) package. /// For more info on how to build staggered grids, check out the /// referred package's documentation and examples. -class PagedMasonryGridView extends BoxScrollView { +class PagedMasonryGridView + extends BoxScrollView { const PagedMasonryGridView({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required this.gridDelegateBuilder, // Matches [ScrollView.scrollDirection]. - Axis scrollDirection = Axis.vertical, + super.scrollDirection, // Matches [ScrollView.reverse]. - bool reverse = false, + super.reverse, // Matches [ScrollView.primary]. - bool? primary, + super.primary, // Matches [ScrollView.physics]. - ScrollPhysics? physics, + super.physics, this.scrollController, this.mainAxisSpacing = 0.0, this.crossAxisSpacing = 0.0, // Matches [ScrollView.cacheExtent]. - double? cacheExtent, + super.cacheExtent, this.showNewPageProgressIndicatorAsGridChild = true, this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, // Matches [ScrollView.dragStartBehavior]. - DragStartBehavior dragStartBehavior = DragStartBehavior.start, + super.dragStartBehavior, // Matches [ScrollView.keyboardDismissBehavior]. - ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = - ScrollViewKeyboardDismissBehavior.manual, + super.keyboardDismissBehavior, // Matches [ScrollView.restorationId]. - String? restorationId, + super.restorationId, // Matches [ScrollView.clipBehavior]. - Clip clipBehavior = Clip.hardEdge, + super.clipBehavior, // Matches [ScrollView.shrinkWrap]. - bool shrinkWrap = false, + super.shrinkWrap, // Matches [BoxScrollView.padding]. - EdgeInsetsGeometry? padding, + super.padding, this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, this.addSemanticIndexes = true, - Key? key, + super.key, }) : _shrinkWrapFirstPageIndicators = shrinkWrap, super( - key: key, - scrollDirection: scrollDirection, - reverse: reverse, controller: scrollController, - primary: primary, - physics: physics, - shrinkWrap: shrinkWrap, - padding: padding, - cacheExtent: cacheExtent, - dragStartBehavior: dragStartBehavior, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - clipBehavior: clipBehavior, ); /// Equivalent to [MasonryGridView.count]. PagedMasonryGridView.count({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required int crossAxisCount, - Axis scrollDirection = Axis.vertical, + super.scrollDirection, // Matches [ScrollView.reverse]. - bool reverse = false, + super.reverse, // Matches [ScrollView.primary]. - bool? primary, + super.primary, // Matches [ScrollView.physics]. - ScrollPhysics? physics, + super.physics, this.scrollController, this.mainAxisSpacing = 0.0, this.crossAxisSpacing = 0.0, // Matches [ScrollView.cacheExtent]. - double? cacheExtent, + super.cacheExtent, this.showNewPageProgressIndicatorAsGridChild = true, this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, // Matches [ScrollView.dragStartBehavior]. - DragStartBehavior dragStartBehavior = DragStartBehavior.start, + super.dragStartBehavior, // Matches [ScrollView.keyboardDismissBehavior]. - ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = - ScrollViewKeyboardDismissBehavior.manual, + super.keyboardDismissBehavior, // Matches [ScrollView.restorationId]. - String? restorationId, + super.restorationId, // Matches [ScrollView.clipBehavior]. - Clip clipBehavior = Clip.hardEdge, + super.clipBehavior, // Matches [ScrollView.shrinkWrap]. - bool shrinkWrap = false, + super.shrinkWrap, // Matches [BoxScrollView.padding]. - EdgeInsetsGeometry? padding, + super.padding, this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, this.addSemanticIndexes = true, - Key? key, + super.key, }) : _shrinkWrapFirstPageIndicators = shrinkWrap, gridDelegateBuilder = ((childCount) => SliverSimpleGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, )), super( - key: key, - scrollDirection: scrollDirection, - reverse: reverse, controller: scrollController, - primary: primary, - physics: physics, - shrinkWrap: shrinkWrap, - padding: padding, - cacheExtent: cacheExtent, - dragStartBehavior: dragStartBehavior, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - clipBehavior: clipBehavior, ); /// Equivalent to [MasonryGridView.extent]. PagedMasonryGridView.extent({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required double maxCrossAxisExtent, - Axis scrollDirection = Axis.vertical, + super.scrollDirection, // Matches [ScrollView.reverse]. - bool reverse = false, + super.reverse, // Matches [ScrollView.primary]. - bool? primary, + super.primary, // Matches [ScrollView.physics]. - ScrollPhysics? physics, + super.physics, this.scrollController, this.mainAxisSpacing = 0.0, this.crossAxisSpacing = 0.0, // Matches [ScrollView.cacheExtent]. - double? cacheExtent, + super.cacheExtent, this.showNewPageProgressIndicatorAsGridChild = true, this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, // Matches [ScrollView.dragStartBehavior]. - DragStartBehavior dragStartBehavior = DragStartBehavior.start, + super.dragStartBehavior, // Matches [ScrollView.keyboardDismissBehavior]. - ScrollViewKeyboardDismissBehavior keyboardDismissBehavior = - ScrollViewKeyboardDismissBehavior.manual, + super.keyboardDismissBehavior, // Matches [ScrollView.restorationId]. - String? restorationId, + super.restorationId, // Matches [ScrollView.clipBehavior]. - Clip clipBehavior = Clip.hardEdge, + super.clipBehavior, // Matches [ScrollView.shrinkWrap]. - bool shrinkWrap = false, + super.shrinkWrap, // Matches [BoxScrollView.padding]. - EdgeInsetsGeometry? padding, + super.padding, this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, this.addSemanticIndexes = true, - Key? key, + super.key, }) : _shrinkWrapFirstPageIndicators = shrinkWrap, gridDelegateBuilder = ((childCount) => SliverSimpleGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: maxCrossAxisExtent, )), super( - key: key, - scrollDirection: scrollDirection, - reverse: reverse, controller: scrollController, - primary: primary, - physics: physics, - shrinkWrap: shrinkWrap, - padding: padding, - cacheExtent: cacheExtent, - dragStartBehavior: dragStartBehavior, - keyboardDismissBehavior: keyboardDismissBehavior, - restorationId: restorationId, - clipBehavior: clipBehavior, ); - /// Matches [PagedLayoutBuilder.pagingController]. - final PagingController pagingController; + /// Matches [PagedLayoutBuilder.state]. + final PagingState state; + + /// Matches [PagedLayoutBuilder.onPageRequest]. + final NextPageCallback fetchNextPage; /// Matches [PagedLayoutBuilder.builderDelegate]. final PagedChildBuilderDelegate builderDelegate; @@ -225,7 +195,8 @@ class PagedMasonryGridView extends BoxScrollView { Widget buildChildLayout(BuildContext context) => PagedSliverMasonryGrid( builderDelegate: builderDelegate, - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, gridDelegateBuilder: gridDelegateBuilder, mainAxisSpacing: mainAxisSpacing, crossAxisSpacing: crossAxisSpacing, diff --git a/lib/src/widgets/layouts/paged_page_view.dart b/lib/src/layouts/paged_page_view.dart similarity index 88% rename from lib/src/widgets/layouts/paged_page_view.dart rename to lib/src/layouts/paged_page_view.dart index f3f171b..a30bd0f 100644 --- a/lib/src/widgets/layouts/paged_page_view.dart +++ b/lib/src/layouts/paged_page_view.dart @@ -1,17 +1,21 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; -import 'package:infinite_scroll_pagination/src/utils/appended_sliver_child_builder_delegate.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_child_builder_delegate.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/helpers/appended_sliver_child_builder_delegate.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_layout_builder.dart'; /// Paged [PageView] with progress and error indicators displayed as the last /// item. /// /// Similar to a [PageView]. /// Useful for combining another paged widget with a page view with details. -class PagedPageView extends StatelessWidget { +class PagedPageView + extends StatelessWidget { const PagedPageView({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, @@ -29,11 +33,14 @@ class PagedPageView extends StatelessWidget { this.pageSnapping = true, this.padEnds = true, this.shrinkWrapFirstPageIndicators = false, - Key? key, - }) : super(key: key); + super.key, + }); - /// Matches [PagedLayoutBuilder.pagingController]. - final PagingController pagingController; + /// Matches [PagedLayoutBuilder.state]. + final PagingState state; + + /// Matches [PagedLayoutBuilder.onPageRequest]. + final NextPageCallback fetchNextPage; /// Matches [PagedLayoutBuilder.builderDelegate]. final PagedChildBuilderDelegate builderDelegate; @@ -90,7 +97,8 @@ class PagedPageView extends StatelessWidget { Widget build(BuildContext context) => PagedLayoutBuilder( layoutProtocol: PagedLayoutProtocol.box, - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, builderDelegate: builderDelegate, shrinkWrapFirstPageIndicators: shrinkWrapFirstPageIndicators, completedListingBuilder: ( diff --git a/lib/src/widgets/layouts/paged_sliver_aligned_grid.dart b/lib/src/layouts/paged_sliver_aligned_grid.dart similarity index 85% rename from lib/src/widgets/layouts/paged_sliver_aligned_grid.dart rename to lib/src/layouts/paged_sliver_aligned_grid.dart index ac2d6d2..c4418d4 100644 --- a/lib/src/widgets/layouts/paged_sliver_aligned_grid.dart +++ b/lib/src/layouts/paged_sliver_aligned_grid.dart @@ -1,7 +1,9 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; -import 'package:infinite_scroll_pagination/src/utils/appended_sliver_grid.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_child_builder_delegate.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/helpers/appended_sliver_grid.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_layout_builder.dart'; +import 'package:infinite_scroll_pagination/src/helpers/flutter_staggered_grid_view.dart'; /// A [SliverAlignedGrid] with pagination capabilities. /// @@ -12,9 +14,11 @@ import 'package:infinite_scroll_pagination/src/utils/appended_sliver_grid.dart'; /// from the [flutter_staggered_grid_view](https://pub.dev/packages/flutter_staggered_grid_view) package. /// For more info on how to build staggered grids, check out the /// referred package's documentation and examples. -class PagedSliverAlignedGrid extends StatelessWidget { +class PagedSliverAlignedGrid extends StatelessWidget { const PagedSliverAlignedGrid({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required this.gridDelegateBuilder, this.mainAxisSpacing = 0, @@ -26,11 +30,12 @@ class PagedSliverAlignedGrid extends StatelessWidget { this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, this.shrinkWrapFirstPageIndicators = false, - Key? key, - }) : super(key: key); + super.key, + }); PagedSliverAlignedGrid.count({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required int crossAxisCount, this.mainAxisSpacing = 0, @@ -42,16 +47,16 @@ class PagedSliverAlignedGrid extends StatelessWidget { this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, this.shrinkWrapFirstPageIndicators = false, - Key? key, - }) : gridDelegateBuilder = + super.key, + }) : gridDelegateBuilder = ((childCount) => SliverSimpleGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, - )), - super(key: key); + )); PagedSliverAlignedGrid.extent({ - Key? key, - required this.pagingController, + super.key, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required double maxCrossAxisExtent, this.mainAxisSpacing = 0, @@ -63,14 +68,16 @@ class PagedSliverAlignedGrid extends StatelessWidget { this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, this.shrinkWrapFirstPageIndicators = false, - }) : gridDelegateBuilder = + }) : gridDelegateBuilder = ((childCount) => SliverSimpleGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: maxCrossAxisExtent, - )), - super(key: key); + )); - /// Matches [PagedLayoutBuilder.pagingController]. - final PagingController pagingController; + /// Matches [PagedLayoutBuilder.state]. + final PagingState state; + + /// Matches [PagedLayoutBuilder.onPageRequest]. + final NextPageCallback fetchNextPage; /// Matches [PagedLayoutBuilder.builderDelegate]. final PagedChildBuilderDelegate builderDelegate; @@ -110,7 +117,8 @@ class PagedSliverAlignedGrid extends StatelessWidget { Widget build(BuildContext context) => PagedLayoutBuilder( layoutProtocol: PagedLayoutProtocol.sliver, - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, builderDelegate: builderDelegate, completedListingBuilder: ( context, diff --git a/lib/src/widgets/layouts/paged_sliver_grid.dart b/lib/src/layouts/paged_sliver_grid.dart similarity index 84% rename from lib/src/widgets/layouts/paged_sliver_grid.dart rename to lib/src/layouts/paged_sliver_grid.dart index 70b11a6..a996b62 100644 --- a/lib/src/widgets/layouts/paged_sliver_grid.dart +++ b/lib/src/layouts/paged_sliver_grid.dart @@ -1,9 +1,9 @@ import 'package:flutter/widgets.dart'; -import 'package:infinite_scroll_pagination/src/core/paged_child_builder_delegate.dart'; -import 'package:infinite_scroll_pagination/src/core/paging_controller.dart'; -import 'package:infinite_scroll_pagination/src/utils/appended_sliver_grid.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/paged_layout_builder.dart'; -import 'package:infinite_scroll_pagination/src/widgets/layouts/paged_grid_view.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_child_builder_delegate.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/helpers/appended_sliver_grid.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_layout_builder.dart'; +import 'package:infinite_scroll_pagination/src/layouts/paged_grid_view.dart'; /// Paged [SliverGrid] with progress and error indicators displayed as the last /// item. @@ -12,9 +12,11 @@ import 'package:infinite_scroll_pagination/src/widgets/layouts/paged_grid_view.d /// [CustomScrollView] when added to the screen. /// Useful for combining multiple scrollable pieces in your UI or if you need /// to add some widgets preceding or following your paged grid. -class PagedSliverGrid extends StatelessWidget { +class PagedSliverGrid + extends StatelessWidget { const PagedSliverGrid({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required this.gridDelegate, this.addAutomaticKeepAlives = true, @@ -24,11 +26,14 @@ class PagedSliverGrid extends StatelessWidget { this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, this.shrinkWrapFirstPageIndicators = false, - Key? key, - }) : super(key: key); + super.key, + }); - /// Matches [PagedLayoutBuilder.pagingController]. - final PagingController pagingController; + /// Matches [PagedLayoutBuilder.state]. + final PagingState state; + + /// Matches [PagedLayoutBuilder.onPageRequest]. + final NextPageCallback fetchNextPage; /// Matches [PagedLayoutBuilder.builderDelegate]. final PagedChildBuilderDelegate builderDelegate; @@ -70,7 +75,8 @@ class PagedSliverGrid extends StatelessWidget { Widget build(BuildContext context) => PagedLayoutBuilder( layoutProtocol: PagedLayoutProtocol.sliver, - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, builderDelegate: builderDelegate, completedListingBuilder: ( context, diff --git a/lib/src/widgets/layouts/paged_sliver_list.dart b/lib/src/layouts/paged_sliver_list.dart similarity index 82% rename from lib/src/widgets/layouts/paged_sliver_list.dart rename to lib/src/layouts/paged_sliver_list.dart index 4f17fe0..4260e96 100644 --- a/lib/src/widgets/layouts/paged_sliver_list.dart +++ b/lib/src/layouts/paged_sliver_list.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -import 'package:infinite_scroll_pagination/src/core/paged_child_builder_delegate.dart'; -import 'package:infinite_scroll_pagination/src/core/paging_controller.dart'; -import 'package:infinite_scroll_pagination/src/utils/appended_sliver_child_builder_delegate.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/paged_layout_builder.dart'; -import 'package:infinite_scroll_pagination/src/widgets/layouts/paged_list_view.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_child_builder_delegate.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/helpers/appended_sliver_child_builder_delegate.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_layout_builder.dart'; +import 'package:infinite_scroll_pagination/src/layouts/paged_list_view.dart'; /// A [SliverList] with pagination capabilities. /// @@ -14,9 +14,11 @@ import 'package:infinite_scroll_pagination/src/widgets/layouts/paged_list_view.d /// [CustomScrollView] when added to the screen. /// Useful for combining multiple scrollable pieces in your UI or if you need /// to add some widgets preceding or following your paged list. -class PagedSliverList extends StatelessWidget { +class PagedSliverList + extends StatelessWidget { const PagedSliverList({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, this.addAutomaticKeepAlives = true, this.addRepaintBoundaries = true, @@ -25,16 +27,16 @@ class PagedSliverList extends StatelessWidget { this.prototypeItem, this.semanticIndexCallback, this.shrinkWrapFirstPageIndicators = false, - Key? key, + super.key, }) : assert( itemExtent == null || prototypeItem == null, 'You can only pass itemExtent or prototypeItem, not both', ), - _separatorBuilder = null, - super(key: key); + _separatorBuilder = null; const PagedSliverList.separated({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required IndexedWidgetBuilder separatorBuilder, this.addAutomaticKeepAlives = true, @@ -43,13 +45,15 @@ class PagedSliverList extends StatelessWidget { this.itemExtent, this.semanticIndexCallback, this.shrinkWrapFirstPageIndicators = false, - Key? key, + super.key, }) : prototypeItem = null, - _separatorBuilder = separatorBuilder, - super(key: key); + _separatorBuilder = separatorBuilder; - /// Matches [PagedLayoutBuilder.pagingController]. - final PagingController pagingController; + /// Matches [PagedLayoutBuilder.state]. + final PagingState state; + + /// Matches [PagedLayoutBuilder.onPageRequest]. + final NextPageCallback fetchNextPage; /// Matches [PagedLayoutBuilder.builderDelegate]. final PagedChildBuilderDelegate builderDelegate; @@ -86,7 +90,8 @@ class PagedSliverList extends StatelessWidget { Widget build(BuildContext context) => PagedLayoutBuilder( layoutProtocol: PagedLayoutProtocol.sliver, - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, builderDelegate: builderDelegate, completedListingBuilder: ( context, @@ -97,7 +102,7 @@ class PagedSliverList extends StatelessWidget { _buildSliverList( itemBuilder, itemCount, - statusIndicatorBuilder: noMoreItemsIndicatorBuilder, + noMoreItemsIndicatorBuilder, ), loadingListingBuilder: ( context, @@ -108,7 +113,7 @@ class PagedSliverList extends StatelessWidget { _buildSliverList( itemBuilder, itemCount, - statusIndicatorBuilder: progressIndicatorBuilder, + progressIndicatorBuilder, ), errorListingBuilder: ( context, @@ -119,16 +124,16 @@ class PagedSliverList extends StatelessWidget { _buildSliverList( itemBuilder, itemCount, - statusIndicatorBuilder: errorIndicatorBuilder, + errorIndicatorBuilder, ), shrinkWrapFirstPageIndicators: shrinkWrapFirstPageIndicators, ); SliverMultiBoxAdaptorWidget _buildSliverList( IndexedWidgetBuilder itemBuilder, - int itemCount, { + int itemCount, WidgetBuilder? statusIndicatorBuilder, - }) { + ) { final delegate = _buildSliverDelegate( itemBuilder, itemCount, diff --git a/lib/src/widgets/layouts/paged_sliver_masonry_grid.dart b/lib/src/layouts/paged_sliver_masonry_grid.dart similarity index 85% rename from lib/src/widgets/layouts/paged_sliver_masonry_grid.dart rename to lib/src/layouts/paged_sliver_masonry_grid.dart index 4404761..2db4362 100644 --- a/lib/src/widgets/layouts/paged_sliver_masonry_grid.dart +++ b/lib/src/layouts/paged_sliver_masonry_grid.dart @@ -1,11 +1,9 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; -import 'package:infinite_scroll_pagination/src/utils/appended_sliver_grid.dart'; - -typedef SliverSimpleGridDelegateBuilder = SliverSimpleGridDelegate Function( - int childCount, -); +import 'package:infinite_scroll_pagination/src/base/paged_child_builder_delegate.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/helpers/appended_sliver_grid.dart'; +import 'package:infinite_scroll_pagination/src/base/paged_layout_builder.dart'; +import 'package:infinite_scroll_pagination/src/helpers/flutter_staggered_grid_view.dart'; /// A [SliverMasonryGrid] with pagination capabilities. /// @@ -16,9 +14,11 @@ typedef SliverSimpleGridDelegateBuilder = SliverSimpleGridDelegate Function( /// from the [flutter_staggered_grid_view](https://pub.dev/packages/flutter_staggered_grid_view) package. /// For more info on how to build staggered grids, check out the /// referred package's documentation and examples. -class PagedSliverMasonryGrid extends StatelessWidget { +class PagedSliverMasonryGrid extends StatelessWidget { const PagedSliverMasonryGrid({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required this.gridDelegateBuilder, this.mainAxisSpacing = 0, @@ -30,14 +30,13 @@ class PagedSliverMasonryGrid extends StatelessWidget { this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, this.shrinkWrapFirstPageIndicators = false, - Key? key, - }) : super( - key: key, - ); + super.key, + }); /// Equivalent to [SliverMasonryGrid.count]. PagedSliverMasonryGrid.count({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required int crossAxisCount, this.mainAxisSpacing = 0, @@ -49,18 +48,16 @@ class PagedSliverMasonryGrid extends StatelessWidget { this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, this.shrinkWrapFirstPageIndicators = false, - Key? key, - }) : gridDelegateBuilder = + super.key, + }) : gridDelegateBuilder = ((childCount) => SliverSimpleGridDelegateWithFixedCrossAxisCount( crossAxisCount: crossAxisCount, - )), - super( - key: key, - ); + )); /// Equivalent to [SliverMasonryGrid.extent]. PagedSliverMasonryGrid.extent({ - required this.pagingController, + required this.state, + required this.fetchNextPage, required this.builderDelegate, required double maxCrossAxisExtent, this.mainAxisSpacing = 0, @@ -72,17 +69,17 @@ class PagedSliverMasonryGrid extends StatelessWidget { this.showNewPageErrorIndicatorAsGridChild = true, this.showNoMoreItemsIndicatorAsGridChild = true, this.shrinkWrapFirstPageIndicators = false, - Key? key, - }) : gridDelegateBuilder = + super.key, + }) : gridDelegateBuilder = ((childCount) => SliverSimpleGridDelegateWithMaxCrossAxisExtent( maxCrossAxisExtent: maxCrossAxisExtent, - )), - super( - key: key, - ); + )); + + /// Matches [PagedLayoutBuilder.state]. + final PagingState state; - /// Matches [PagedLayoutBuilder.pagingController]. - final PagingController pagingController; + /// Matches [PagedLayoutBuilder.onPageRequest]. + final NextPageCallback fetchNextPage; /// Matches [PagedLayoutBuilder.builderDelegate]. final PagedChildBuilderDelegate builderDelegate; @@ -122,7 +119,8 @@ class PagedSliverMasonryGrid extends StatelessWidget { Widget build(BuildContext context) => PagedLayoutBuilder( layoutProtocol: PagedLayoutProtocol.sliver, - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, builderDelegate: builderDelegate, completedListingBuilder: ( context, diff --git a/lib/src/model/paging_state.dart b/lib/src/model/paging_state.dart deleted file mode 100644 index 092148e..0000000 --- a/lib/src/model/paging_state.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:infinite_scroll_pagination/src/model/paging_status.dart'; - -/// The current item's list, error, and next page key state for a paginated -/// widget. -@immutable -class PagingState { - const PagingState({ - this.nextPageKey, - this.itemList, - this.error, - }); - - /// List with all items loaded so far. - final List? itemList; - - /// The current error, if any. - final dynamic error; - - /// The key for the next page to be fetched. - final PageKeyType? nextPageKey; - - /// The current pagination status. - PagingStatus get status { - if (_isOngoing) { - return PagingStatus.ongoing; - } - - if (_isCompleted) { - return PagingStatus.completed; - } - - if (_isLoadingFirstPage) { - return PagingStatus.loadingFirstPage; - } - - if (_hasSubsequentPageError) { - return PagingStatus.subsequentPageError; - } - - if (_isEmpty) { - return PagingStatus.noItemsFound; - } else { - return PagingStatus.firstPageError; - } - } - - @override - String toString() => - '${objectRuntimeType(this, 'PagingState')}(itemList: \u2524' - '$itemList\u251C, error: $error, nextPageKey: $nextPageKey)'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) { - return true; - } - return other is PagingState && - other.itemList == itemList && - other.error == error && - other.nextPageKey == nextPageKey; - } - - @override - int get hashCode => Object.hash( - itemList.hashCode, - error.hashCode, - nextPageKey.hashCode, - ); - - int? get _itemCount => itemList?.length; - - bool get _hasNextPage => nextPageKey != null; - - bool get _hasItems { - final itemCount = _itemCount; - return itemCount != null && itemCount > 0; - } - - bool get _hasError => error != null; - - bool get _isListingUnfinished => _hasItems && _hasNextPage; - - bool get _isOngoing => _isListingUnfinished && !_hasError; - - bool get _isCompleted => _hasItems && !_hasNextPage; - - bool get _isLoadingFirstPage => _itemCount == null && !_hasError; - - bool get _hasSubsequentPageError => _isListingUnfinished && _hasError; - - bool get _isEmpty => _itemCount != null && _itemCount == 0; -} diff --git a/lib/src/model/paging_status.dart b/lib/src/model/paging_status.dart deleted file mode 100644 index ccc279e..0000000 --- a/lib/src/model/paging_status.dart +++ /dev/null @@ -1,9 +0,0 @@ -/// All possible status for a pagination. -enum PagingStatus { - completed, - noItemsFound, - loadingFirstPage, - ongoing, - firstPageError, - subsequentPageError, -} diff --git a/lib/src/utils/listenable_listener.dart b/lib/src/utils/listenable_listener.dart deleted file mode 100644 index c1ba5c5..0000000 --- a/lib/src/utils/listenable_listener.dart +++ /dev/null @@ -1,58 +0,0 @@ -import 'package:flutter/widgets.dart'; - -/// A widget that calls [listener] when the given [Listenable] changes value. -class ListenableListener extends StatefulWidget { - const ListenableListener({ - required this.listenable, - required this.child, - this.listener, - Key? key, - }) : super(key: key); - - /// The [Listenable] to which this widget is listening. - /// - /// Commonly an [Animation] or a [ChangeNotifier]. - final Listenable listenable; - - /// Called every time the [listenable] changes value. - final VoidCallback? listener; - - /// The widget below this widget in the tree. - final Widget child; - - @override - State createState() => _ListenableListenerState(); -} - -class _ListenableListenerState extends State { - Listenable get _listenable => widget.listenable; - - @override - void initState() { - super.initState(); - _listenable.addListener(_handleChange); - _handleChange(); - } - - @override - void didUpdateWidget(ListenableListener oldWidget) { - super.didUpdateWidget(oldWidget); - if (_listenable != oldWidget.listenable) { - oldWidget.listenable.removeListener(_handleChange); - _listenable.addListener(_handleChange); - } - } - - @override - void dispose() { - _listenable.removeListener(_handleChange); - super.dispose(); - } - - void _handleChange() { - widget.listener?.call(); - } - - @override - Widget build(BuildContext context) => widget.child; -} diff --git a/lib/src/widgets/helpers/paged_layout_builder.dart b/lib/src/widgets/helpers/paged_layout_builder.dart deleted file mode 100644 index 168d9f7..0000000 --- a/lib/src/widgets/helpers/paged_layout_builder.dart +++ /dev/null @@ -1,326 +0,0 @@ -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:flutter/widgets.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; -import 'package:infinite_scroll_pagination/src/utils/listenable_listener.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/first_page_error_indicator.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/first_page_progress_indicator.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/new_page_error_indicator.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/new_page_progress_indicator.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/no_items_found_indicator.dart'; -import 'package:sliver_tools/sliver_tools.dart'; - -typedef CompletedListingBuilder = Widget Function( - BuildContext context, - IndexedWidgetBuilder itemWidgetBuilder, - int itemCount, - WidgetBuilder? noMoreItemsIndicatorBuilder, -); - -typedef ErrorListingBuilder = Widget Function( - BuildContext context, - IndexedWidgetBuilder itemWidgetBuilder, - int itemCount, - WidgetBuilder newPageErrorIndicatorBuilder, -); - -typedef LoadingListingBuilder = Widget Function( - BuildContext context, - IndexedWidgetBuilder itemWidgetBuilder, - int itemCount, - WidgetBuilder newPageProgressIndicatorBuilder, -); - -/// The Flutter layout protocols supported by [PagedLayoutBuilder]. -enum PagedLayoutProtocol { sliver, box } - -/// Facilitates creating infinitely scrolled paged layouts. -/// -/// Combines a [PagingController] with a -/// [PagedChildBuilderDelegate] and calls the supplied -/// [loadingListingBuilder], [errorListingBuilder] or -/// [completedListingBuilder] for filling in the gaps. -/// -/// For ordinary cases, this widget shouldn't be used directly. Instead, take a -/// look at [PagedSliverList], [PagedSliverGrid], [PagedListView], -/// [PagedGridView], [PagedMasonryGridView], or [PagedPageView]. -class PagedLayoutBuilder extends StatefulWidget { - const PagedLayoutBuilder({ - required this.pagingController, - required this.builderDelegate, - required this.loadingListingBuilder, - required this.errorListingBuilder, - required this.completedListingBuilder, - required this.layoutProtocol, - this.shrinkWrapFirstPageIndicators = false, - Key? key, - }) : super(key: key); - - /// The controller for paged listings. - /// - /// Informs the current state of the pagination and requests new items from - /// its listeners. - final PagingController pagingController; - - /// The delegate for building the UI pieces of scrolling paged listings. - final PagedChildBuilderDelegate builderDelegate; - - /// The builder for an in-progress listing. - final LoadingListingBuilder loadingListingBuilder; - - /// The builder for an in-progress listing with a failed request. - final ErrorListingBuilder errorListingBuilder; - - /// The builder for a completed listing. - final CompletedListingBuilder completedListingBuilder; - - /// Whether the extent of the first page indicators should be determined by - /// the contents being viewed. - /// - /// If the paged layout builder does not shrink wrap, then the first page - /// indicators will expand to the maximum allowed size. If the paged layout - /// builder has unbounded constraints, then [shrinkWrapFirstPageIndicators] - /// must be true. - /// - /// Defaults to false. - final bool shrinkWrapFirstPageIndicators; - - /// The layout protocol of the widget you're using this to build. - /// - /// For example, if [PagedLayoutProtocol.sliver] is specified, then - /// [loadingListingBuilder], [errorListingBuilder], and - /// [completedListingBuilder] have to return a Sliver widget. - final PagedLayoutProtocol layoutProtocol; - - @override - State> createState() => - _PagedLayoutBuilderState(); -} - -class _PagedLayoutBuilderState - extends State> { - PagingController get _pagingController => - widget.pagingController; - - PagedChildBuilderDelegate get _builderDelegate => - widget.builderDelegate; - - bool get _shrinkWrapFirstPageIndicators => - widget.shrinkWrapFirstPageIndicators; - - PagedLayoutProtocol get _layoutProtocol => widget.layoutProtocol; - - WidgetBuilder get _firstPageErrorIndicatorBuilder => - _builderDelegate.firstPageErrorIndicatorBuilder ?? - (_) => FirstPageErrorIndicator( - onTryAgain: _pagingController.retryLastFailedRequest, - ); - - WidgetBuilder get _newPageErrorIndicatorBuilder => - _builderDelegate.newPageErrorIndicatorBuilder ?? - (_) => NewPageErrorIndicator( - onTap: _pagingController.retryLastFailedRequest, - ); - - WidgetBuilder get _firstPageProgressIndicatorBuilder => - _builderDelegate.firstPageProgressIndicatorBuilder ?? - (_) => const FirstPageProgressIndicator(); - - WidgetBuilder get _newPageProgressIndicatorBuilder => - _builderDelegate.newPageProgressIndicatorBuilder ?? - (_) => const NewPageProgressIndicator(); - - WidgetBuilder get _noItemsFoundIndicatorBuilder => - _builderDelegate.noItemsFoundIndicatorBuilder ?? - (_) => const NoItemsFoundIndicator(); - - WidgetBuilder? get _noMoreItemsIndicatorBuilder => - _builderDelegate.noMoreItemsIndicatorBuilder; - - int get _invisibleItemsThreshold => - _pagingController.invisibleItemsThreshold ?? 3; - - int get _itemCount => _pagingController.itemCount; - - bool get _hasNextPage => _pagingController.hasNextPage; - - PageKeyType? get _nextKey => _pagingController.nextPageKey; - - /// Avoids duplicate requests on rebuilds. - bool _hasRequestedNextPage = false; - - @override - Widget build(BuildContext context) => ListenableListener( - listenable: _pagingController, - listener: () { - final status = _pagingController.value.status; - - if (status == PagingStatus.loadingFirstPage) { - _pagingController.notifyPageRequestListeners( - _pagingController.firstPageKey, - ); - } - - if (status == PagingStatus.ongoing) { - _hasRequestedNextPage = false; - } - }, - child: ValueListenableBuilder>( - valueListenable: _pagingController, - builder: (context, pagingState, _) { - Widget child; - final itemList = _pagingController.itemList; - switch (pagingState.status) { - case PagingStatus.ongoing: - child = widget.loadingListingBuilder( - context, - // We must create this closure to close over the [itemList] - // value. That way, we are safe if [itemList] value changes - // while Flutter rebuilds the widget (due to animations, for - // example.) - (context, index) => _buildListItemWidget( - context, - index, - itemList!, - ), - _itemCount, - _newPageProgressIndicatorBuilder, - ); - break; - case PagingStatus.completed: - child = widget.completedListingBuilder( - context, - (context, index) => _buildListItemWidget( - context, - index, - itemList!, - ), - _itemCount, - _noMoreItemsIndicatorBuilder, - ); - break; - case PagingStatus.loadingFirstPage: - child = _FirstPageStatusIndicatorBuilder( - builder: _firstPageProgressIndicatorBuilder, - shrinkWrap: _shrinkWrapFirstPageIndicators, - layoutProtocol: _layoutProtocol, - ); - break; - case PagingStatus.subsequentPageError: - child = widget.errorListingBuilder( - context, - (context, index) => _buildListItemWidget( - context, - index, - itemList!, - ), - _itemCount, - (context) => _newPageErrorIndicatorBuilder(context), - ); - break; - case PagingStatus.noItemsFound: - child = _FirstPageStatusIndicatorBuilder( - builder: _noItemsFoundIndicatorBuilder, - shrinkWrap: _shrinkWrapFirstPageIndicators, - layoutProtocol: _layoutProtocol, - ); - break; - default: - child = _FirstPageStatusIndicatorBuilder( - builder: _firstPageErrorIndicatorBuilder, - shrinkWrap: _shrinkWrapFirstPageIndicators, - layoutProtocol: _layoutProtocol, - ); - } - - if (_builderDelegate.animateTransitions) { - if (_layoutProtocol == PagedLayoutProtocol.sliver) { - return SliverAnimatedSwitcher( - duration: _builderDelegate.transitionDuration, - child: child, - ); - } else { - return AnimatedSwitcher( - duration: _builderDelegate.transitionDuration, - child: child, - ); - } - } else { - return child; - } - }, - ), - ); - - /// Connects the [_pagingController] with the [_builderDelegate] in order to - /// create a list item widget and request more items if needed. - Widget _buildListItemWidget( - BuildContext context, - int index, - List itemList, - ) { - if (!_hasRequestedNextPage) { - final newPageRequestTriggerIndex = - max(0, _itemCount - _invisibleItemsThreshold); - - final isBuildingTriggerIndexItem = index == newPageRequestTriggerIndex; - - if (_hasNextPage && isBuildingTriggerIndexItem) { - // Schedules the request for the end of this frame. - WidgetsBinding.instance.addPostFrameCallback((_) { - _pagingController.notifyPageRequestListeners(_nextKey as PageKeyType); - }); - _hasRequestedNextPage = true; - } - } - - final item = itemList[index]; - return _builderDelegate.itemBuilder(context, item, index); - } -} - -extension on PagingController { - /// The loaded items count. - int get itemCount => itemList?.length ?? 0; - - /// Tells whether there's a next page to request. - bool get hasNextPage => nextPageKey != null; -} - -class _FirstPageStatusIndicatorBuilder extends StatelessWidget { - const _FirstPageStatusIndicatorBuilder({ - required this.builder, - required this.layoutProtocol, - this.shrinkWrap = false, - Key? key, - }) : super(key: key); - - final WidgetBuilder builder; - final bool shrinkWrap; - final PagedLayoutProtocol layoutProtocol; - - @override - Widget build(BuildContext context) { - if (layoutProtocol == PagedLayoutProtocol.sliver) { - if (shrinkWrap) { - return SliverToBoxAdapter( - child: builder(context), - ); - } else { - return SliverFillRemaining( - hasScrollBody: false, - child: builder(context), - ); - } - } else { - if (shrinkWrap) { - return builder(context); - } else { - return Center( - child: builder(context), - ); - } - } - } -} diff --git a/pubspec.yaml b/pubspec.yaml index 59f8faf..9667f60 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,14 +4,16 @@ version: 4.1.0 homepage: https://github.com/EdsonBueno/infinite_scroll_pagination environment: - sdk: ">=2.14.0 <4.0.0" - flutter: ">=1.22.0" + sdk: ">=3.4.0 <4.0.0" + flutter: ">=3.0.0" dependencies: flutter: sdk: flutter flutter_staggered_grid_view: ^0.7.0 sliver_tools: ^0.2.12 + collection: ">=1.15.0" + meta: ">=1.8.0" dev_dependencies: flutter_test: diff --git a/test/paged_layout_builder_test.dart b/test/base/paged_layout_builder_test.dart similarity index 81% rename from test/paged_layout_builder_test.dart rename to test/base/paged_layout_builder_test.dart index 636fab2..3114371 100644 --- a/test/paged_layout_builder_test.dart +++ b/test/base/paged_layout_builder_test.dart @@ -1,19 +1,22 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/first_page_error_indicator.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/first_page_progress_indicator.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/new_page_error_indicator.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/new_page_progress_indicator.dart'; -import 'package:infinite_scroll_pagination/src/widgets/helpers/default_status_indicators/no_items_found_indicator.dart'; +import 'package:infinite_scroll_pagination/src/defaults/first_page_error_indicator.dart'; +import 'package:infinite_scroll_pagination/src/defaults/first_page_progress_indicator.dart'; +import 'package:infinite_scroll_pagination/src/defaults/new_page_error_indicator.dart'; +import 'package:infinite_scroll_pagination/src/defaults/new_page_progress_indicator.dart'; +import 'package:infinite_scroll_pagination/src/defaults/no_items_found_indicator.dart'; -import 'utils/paging_controller_utils.dart'; +import '../utils/paging_controller_utils.dart'; void main() { group('PagingStatus.loadingFirstPage', () { - late PagingController pagingController; + late PagingState state; + setUp(() { - pagingController = PagingController(firstPageKey: 1); + state = TestPagingState.loadingFirstPage(); }); testWidgets( @@ -27,7 +30,7 @@ void main() { // when await _pumpPagedLayoutBuilder( tester: tester, - pagingController: pagingController, + state: state, builderDelegate: builderDelegate, ); @@ -50,7 +53,7 @@ void main() { // when await _pumpPagedLayoutBuilder( tester: tester, - pagingController: pagingController, + state: state, builderDelegate: builderDelegate, ); @@ -60,12 +63,10 @@ void main() { }); group('PagingStatus.firstPageError', () { - late PagingController pagingController; + late PagingState state; setUp(() { - pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.errorOnFirstPage, - ); + state = TestPagingState.firstPageError(); }); testWidgets( @@ -79,7 +80,7 @@ void main() { // when await _pumpPagedLayoutBuilder( tester: tester, - pagingController: pagingController, + state: state, builderDelegate: builderDelegate, ); @@ -102,7 +103,7 @@ void main() { // when await _pumpPagedLayoutBuilder( tester: tester, - pagingController: pagingController, + state: state, builderDelegate: builderDelegate, ); @@ -112,11 +113,10 @@ void main() { }); group('PagingStatus.noItemsFound', () { - late PagingController pagingController; + late PagingState state; + setUp(() { - pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.noItemsFound, - ); + state = TestPagingState.noItemsFound(); }); testWidgets( @@ -130,7 +130,7 @@ void main() { // when await _pumpPagedLayoutBuilder( tester: tester, - pagingController: pagingController, + state: state, builderDelegate: builderDelegate, ); @@ -153,7 +153,7 @@ void main() { // when await _pumpPagedLayoutBuilder( tester: tester, - pagingController: pagingController, + state: state, builderDelegate: builderDelegate, ); @@ -163,11 +163,10 @@ void main() { }); group('PagingStatus.subsequentPageError', () { - late PagingController pagingController; + late PagingState state; + setUp(() { - pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.errorOnSecondPage, - ); + state = TestPagingState.subsequentPageError(); }); testWidgets( @@ -181,7 +180,7 @@ void main() { // when await _pumpPagedLayoutBuilder( tester: tester, - pagingController: pagingController, + state: state, builderDelegate: builderDelegate, ); @@ -207,7 +206,7 @@ void main() { // when await _pumpPagedLayoutBuilder( tester: tester, - pagingController: pagingController, + state: state, builderDelegate: builderDelegate, ); @@ -219,11 +218,10 @@ void main() { }); group('PagingStatus.ongoing', () { - late PagingController pagingController; + late PagingState state; + setUp(() { - pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithTwoPages, - ); + state = TestPagingState.ongoing(); }); testWidgets( @@ -237,7 +235,7 @@ void main() { // when await _pumpPagedLayoutBuilder( tester: tester, - pagingController: pagingController, + state: state, builderDelegate: builderDelegate, ); @@ -262,7 +260,7 @@ void main() { // when await _pumpPagedLayoutBuilder( tester: tester, - pagingController: pagingController, + state: state, builderDelegate: builderDelegate, ); @@ -274,11 +272,10 @@ void main() { }); group('PagingStatus.completed', () { - late PagingController pagingController; + late PagingState state; + setUp(() { - pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.completedWithOnePage, - ); + state = TestPagingState.completed(); }); testWidgets('Uses the custom no more items indicator when one is provided.', @@ -296,7 +293,7 @@ void main() { // when await _pumpPagedLayoutBuilder( tester: tester, - pagingController: pagingController, + state: state, builderDelegate: builderDelegate, ); @@ -308,7 +305,7 @@ void main() { }); group('First page indicators\' height', () { - final pagingController = PagingController(firstPageKey: 1); + final state = PagingState(); const indicatorHeight = 100.0; late Key indicatorKey; late Widget progressIndicator; @@ -335,7 +332,7 @@ void main() { // when await _pumpPagedLayoutBuilder( tester: tester, - pagingController: pagingController, + state: state, builderDelegate: builderDelegate, shrinkWrapFirstPageIndicators: false, ); @@ -351,7 +348,7 @@ void main() { // when await _pumpPagedLayoutBuilder( tester: tester, - pagingController: pagingController, + state: state, builderDelegate: builderDelegate, shrinkWrapFirstPageIndicators: true, ); @@ -375,14 +372,15 @@ void _expectOneWidgetOfType(Type type) { Future _pumpPagedLayoutBuilder({ required WidgetTester tester, - required PagingController pagingController, + required PagingState state, required PagedChildBuilderDelegate builderDelegate, bool shrinkWrapFirstPageIndicators = false, }) => _pumpSliver( sliver: PagedLayoutBuilder( layoutProtocol: PagedLayoutProtocol.sliver, - pagingController: pagingController, + state: state, + fetchNextPage: () => Completer().future, builderDelegate: builderDelegate, shrinkWrapFirstPageIndicators: shrinkWrapFirstPageIndicators, errorListingBuilder: ( diff --git a/test/core/paging_controller_test.dart b/test/core/paging_controller_test.dart new file mode 100644 index 0000000..f26afe7 --- /dev/null +++ b/test/core/paging_controller_test.dart @@ -0,0 +1,238 @@ +import 'dart:async'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; + +void main() { + group('PagingController', () { + late PagingController pagingController; + late int? nextPageKey; + late bool fetchCalled; + late List fetchedItems; + + setUp(() { + nextPageKey = 1; + fetchCalled = false; + fetchedItems = ['Item 1', 'Item 2']; + + getNextPageKey(state) => nextPageKey; + List fetchPage(int pageKey) { + fetchCalled = true; + return fetchedItems; + } + + pagingController = PagingController( + getNextPageKey: getNextPageKey, + fetchPage: fetchPage, + ); + }); + + group('fetchNextPage', () { + test('requests the next page', () async { + pagingController.fetchNextPage(); + + expect(fetchCalled, isTrue); + expect(pagingController.value.pages, [fetchedItems]); + expect(pagingController.value.keys, [nextPageKey]); + }); + + test('fetches a page synchronously when possible', () async { + pagingController.fetchNextPage(); + + await Future.value(null); + + expect(fetchCalled, isTrue); + expect(pagingController.value.pages, [fetchedItems]); + expect(pagingController.value.keys, [nextPageKey]); + }); + + test('only runs one fetch at a given time', () async { + final completer = Completer>(); + + pagingController = PagingController( + getNextPageKey: (state) => nextPageKey, + fetchPage: (_) => completer.future, + ); + + pagingController.fetchNextPage(); + pagingController.fetchNextPage(); + + await Future.value(null); + + expect(fetchCalled, isFalse); + expect(pagingController.value.isLoading, isTrue); + + completer.complete(fetchedItems); + await Future.delayed(Duration.zero); + + expect(pagingController.value.isLoading, isFalse); + }); + + test('stops if next page key is null', () async { + nextPageKey = null; + pagingController.fetchNextPage(); + + await Future.value(null); + + expect(fetchCalled, isFalse); + expect(pagingController.value.hasNextPage, isFalse); + }); + + test('stops if no more pages are available', () async { + pagingController.value = + pagingController.value.copyWith(hasNextPage: false); + pagingController.fetchNextPage(); + expect(fetchCalled, isFalse); + }); + + // We have intentionally broken atomicity of PagingController. + // This is because we want users to be able to modify their item list even during a fetch. + // It is unclear whether this will come back to bite us. + test('allows modifying state during a fetch', () async { + pagingController = PagingController( + getNextPageKey: (state) => (state.keys?.last ?? 0) + 1, + fetchPage: (page) => Future.value(['Item $page']), + ); + + pagingController.fetchNextPage(); + + await Future.value(null); + + pagingController.fetchNextPage(); + + pagingController.value = pagingController.value.copyWith( + pages: pagingController.value.pages + ?.map( + (a) => a.map((b) => b.toUpperCase()).toList(), + ) + .toList(), + ); + + await Future.value(null); + + expect(pagingController.value.isLoading, isFalse); + expect(pagingController.value.pages, [ + ['ITEM 1'], + ['Item 2'], + ]); + }); + + test('catches Exceptions', () async { + pagingController = PagingController( + getNextPageKey: (state) => nextPageKey, + fetchPage: (_) => throw Exception(), + ); + + pagingController.fetchNextPage(); + + expect(pagingController.value.isLoading, isFalse); + expect(pagingController.value.error, isA()); + }); + + test('rethrows Errors', () async { + pagingController = PagingController( + getNextPageKey: (state) => nextPageKey, + fetchPage: (_) => throw Error(), + ); + + expect(() async => pagingController.fetchNextPage(), + throwsA(isA())); + + expect(pagingController.value.isLoading, isFalse); + expect(pagingController.value.error, isA()); + }); + }); + + group('refresh', () { + test('resets state', () async { + pagingController.value = PagingState( + pages: const [ + ['Item 1'] + ], + keys: const [1], + ); + + pagingController.refresh(); + + expect(pagingController.value.pages, isNull); + expect(pagingController.value.keys, isNull); + expect(pagingController.value.isLoading, isFalse); + expect(pagingController.value.error, isNull); + }); + + test('cancels previous refresh', () async { + bool hasBeenCalled = false; + bool hasFailed = false; + + final completer1 = Completer>(); + final completer2 = Completer>(); + + pagingController = PagingController( + getNextPageKey: (state) => nextPageKey, + fetchPage: (_) { + if (hasBeenCalled) { + return completer2.future; + } else { + hasBeenCalled = true; + return completer1.future; + } + }); + + final wrongItems = ['Wrong Item 1', 'Wrong Item 2']; + + pagingController.addListener(() { + try { + expect(pagingController.value.pages, isNot([wrongItems])); + } catch (e) { + hasFailed = true; + } + }); + + pagingController.fetchNextPage(); + + await Future.value(null); + + pagingController.refresh(); + pagingController.fetchNextPage(); + + await Future.value(null); + + completer1.complete(wrongItems); + completer2.complete(fetchedItems); + + await Future.value(null); + + expect(pagingController.value.isLoading, isFalse); + expect(pagingController.value.pages, [fetchedItems]); + expect(hasFailed, isFalse); + }); + }); + + group('cancel', () { + test('resets state and stops fetch', () async { + pagingController = PagingController( + getNextPageKey: (state) => (state.keys?.last ?? 0) + 1, + fetchPage: (page) => Future.value(['Item $page']), + ); + + pagingController.fetchNextPage(); + + await Future.value(null); + + expect(pagingController.value.pages, [ + ['Item 1'] + ]); + + pagingController.fetchNextPage(); + + pagingController.cancel(); + + await Future.value(null); + + expect(pagingController.value.isLoading, isFalse); + expect(pagingController.value.pages, [ + ['Item 1'] + ]); + }); + }); + }); +} diff --git a/test/core/paging_state_base_test.dart b/test/core/paging_state_base_test.dart new file mode 100644 index 0000000..e6221d9 --- /dev/null +++ b/test/core/paging_state_base_test.dart @@ -0,0 +1,213 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state_base.dart'; + +void main() { + group('PagingStateBase', () { + test('constructs with default values', () { + final state = PagingStateBase(); + + expect(state.pages, isNull); + expect(state.keys, isNull); + expect(state.error, isNull); + expect(state.hasNextPage, isTrue); + expect(state.isLoading, isFalse); + }); + + test('constructs with given values', () { + final state = PagingStateBase( + pages: [ + ['Item 1'] + ], + keys: [1], + error: 'Error message', + hasNextPage: false, + isLoading: true, + ); + + expect(state.pages, [ + ['Item 1'] + ]); + expect(state.keys, [1]); + expect(state.error, 'Error message'); + expect(state.hasNextPage, isFalse); + expect(state.isLoading, isTrue); + }); + + test('creates immutable lists for pages and keys', () { + final state = PagingStateBase( + pages: [ + ['Item 1'] + ], + keys: [1], + ); + + expect(() => (state.pages as List>).add(['Item 2']), + throwsUnsupportedError); + expect(() => (state.keys as List).add(2), throwsUnsupportedError); + }); + + test('copyWith creates a copy with updated values', () { + final state = PagingStateBase( + pages: [ + ['Item 1'] + ], + keys: [1], + hasNextPage: false, + isLoading: true, + ); + + final newState = state.copyWith( + pages: [ + ['Item 2'] + ], + hasNextPage: true, + ); + + expect(newState.pages, [ + ['Item 2'] + ]); + expect(newState.keys, [1]); + expect(newState.hasNextPage, isTrue); + expect(newState.isLoading, isTrue); + }); + + test('copyWith retains values when Omit is passed', () { + final state = PagingStateBase( + pages: [ + ['Item 1'] + ], + keys: [1], + error: 'Initial error', + hasNextPage: false, + isLoading: true, + ); + + final newState = state.copyWith( + pages: const Omit(), + keys: const Omit(), + error: const Omit(), + hasNextPage: const Omit(), + isLoading: const Omit(), + ); + + expect(newState.pages, state.pages); + expect(newState.keys, state.keys); + expect(newState.error, state.error); + expect(newState.hasNextPage, state.hasNextPage); + expect(newState.isLoading, state.isLoading); + }); + + test('reset creates a default state', () { + final state = PagingStateBase( + pages: [ + ['Item 1'] + ], + keys: [1], + error: 'Error message', + hasNextPage: false, + isLoading: true, + ); + + final resetState = state.reset(); + + expect(resetState.pages, isNull); + expect(resetState.keys, isNull); + expect(resetState.error, isNull); + expect(resetState.hasNextPage, isTrue); + expect(resetState.isLoading, isFalse); + }); + + test('toString outputs expected format', () { + final state = PagingStateBase( + pages: [ + ['Item 1'] + ], + keys: [1], + error: 'Error message', + hasNextPage: false, + isLoading: true, + ); + + expect( + state.toString(), + contains('pages: [[Item 1]]'), + ); + expect( + state.toString(), + contains('keys: [1]'), + ); + expect( + state.toString(), + contains('error: Error message'), + ); + expect( + state.toString(), + contains('hasNextPage: false'), + ); + expect( + state.toString(), + contains('isLoading: true'), + ); + }); + + test('equality works correctly', () { + final state1 = PagingStateBase( + pages: [ + ['Item 1'] + ], + keys: [1], + error: 'Error message', + hasNextPage: false, + isLoading: true, + ); + + final state2 = PagingStateBase( + pages: [ + ['Item 1'] + ], + keys: [1], + error: 'Error message', + hasNextPage: false, + isLoading: true, + ); + + final state3 = PagingStateBase( + pages: [ + ['Item 2'] + ], + keys: [2], + error: 'Different error', + hasNextPage: true, + isLoading: false, + ); + + expect(state1, equals(state2)); + expect(state1, isNot(equals(state3))); + }); + + test('hashCode works correctly', () { + final state1 = PagingStateBase( + pages: [ + ['Item 1'] + ], + keys: [1], + error: 'Error message', + hasNextPage: false, + isLoading: true, + ); + + final state2 = PagingStateBase( + pages: [ + ['Item 1'] + ], + keys: [1], + error: 'Error message', + hasNextPage: false, + isLoading: true, + ); + + expect(state1.hashCode, equals(state2.hashCode)); + }); + }); +} diff --git a/test/core/paging_status_test.dart b/test/core/paging_status_test.dart new file mode 100644 index 0000000..4ec9857 --- /dev/null +++ b/test/core/paging_status_test.dart @@ -0,0 +1,73 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_state.dart'; +import 'package:infinite_scroll_pagination/src/core/paging_status.dart'; + +void main() { + group('PagingStatusExtension', () { + late PagingState pagingState; + + test( + 'returns loadingFirstPage status when loading first page with no items and no error', + () { + pagingState = PagingState(); + expect(pagingState.status, PagingStatus.loadingFirstPage); + }); + + test( + 'returns firstPageError status when first page has no items and there is an error', + () { + pagingState = PagingState(error: Exception('Error')); + expect(pagingState.status, PagingStatus.firstPageError); + }); + + test('returns noItemsFound status when there are no items and no error', + () { + pagingState = PagingState( + pages: const [], + keys: const [], + hasNextPage: false, + ); + expect(pagingState.status, PagingStatus.noItemsFound); + }); + + test( + 'returns ongoing status when items exist, there is no error, and more pages are available', + () { + pagingState = PagingState( + pages: const [ + ['Item 1'] + ], + keys: const [1], + hasNextPage: true, + ); + expect(pagingState.status, PagingStatus.ongoing); + }); + + test( + 'returns subsequentPageError status when items exist and there is an error', + () { + pagingState = PagingState( + pages: const [ + ['Item 1'] + ], + keys: const [1], + error: Exception('Error'), + hasNextPage: true, + ); + expect(pagingState.status, PagingStatus.subsequentPageError); + }); + + test( + 'returns completed status when items exist and no more pages are available', + () { + pagingState = PagingState( + pages: const [ + ['Item 1'] + ], + keys: const [1], + hasNextPage: false, + ); + expect(pagingState.status, PagingStatus.completed); + }); + }); +} diff --git a/test/paged_grid_view_test.dart b/test/layouts/paged_grid_view_test.dart similarity index 70% rename from test/paged_grid_view_test.dart rename to test/layouts/paged_grid_view_test.dart index ffcc161..3127cd4 100644 --- a/test/paged_grid_view_test.dart +++ b/test/layouts/paged_grid_view_test.dart @@ -1,34 +1,33 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:mockito/mockito.dart'; -import 'utils/paging_controller_utils.dart'; -import 'utils/screen_size_utils.dart'; +import '../utils/paging_controller_utils.dart'; +import '../utils/screen_size_utils.dart'; double get _itemHeight => (screenSize.height / pageSize) * 2; void main() { group('Page requests', () { - late MockPageRequestListener mockPageRequestListener; + late MockFetchPageRequest mockFetchNextPage; setUp(() { - mockPageRequestListener = MockPageRequestListener(); + mockFetchNextPage = MockFetchPageRequest(); }); testWidgets('Requests first page only once', (tester) async { - final pagingController = PagingController( - firstPageKey: 1, - ); - - pagingController.addPageRequestListener(mockPageRequestListener.call); + final state = TestPagingState.loadingFirstPage(); await _pumpPagedGridView( tester: tester, - pagingController: pagingController, + state: state, + fetchNextPage: mockFetchNextPage.call, ); - verify(mockPageRequestListener(1)).called(1); + verify(mockFetchNextPage()).called(1); }); testWidgets( @@ -36,60 +35,48 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final controllerLoadedWithFirstPage = - buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - - controllerLoadedWithFirstPage.addPageRequestListener( - mockPageRequestListener.call, - ); + final state = TestPagingState.ongoing(n: pageSize ~/ 2); await _pumpPagedGridView( tester: tester, - pagingController: controllerLoadedWithFirstPage, + state: state, + fetchNextPage: mockFetchNextPage.call, ); - verify(mockPageRequestListener(2)).called(1); + verify(mockFetchNextPage()).called(1); }); testWidgets('Doesn\'t request a page unnecessarily', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithTwoPages, - ); - pagingController.addPageRequestListener(mockPageRequestListener.call); + final state = TestPagingState.ongoing(n: pageSize * 2); await _pumpPagedGridView( tester: tester, - pagingController: pagingController, + state: state, + fetchNextPage: () => Completer().future, ); - verifyZeroInteractions(mockPageRequestListener); + verifyZeroInteractions(mockFetchNextPage); }); testWidgets('Requests a new page on scroll', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithTwoPages, - ); - pagingController.addPageRequestListener(mockPageRequestListener.call); + final state = TestPagingState.ongoing(n: pageSize * 2); await _pumpPagedGridView( tester: tester, - pagingController: pagingController, + state: state, + fetchNextPage: mockFetchNextPage.call, ); await tester.scrollUntilVisible( - find.text( - secondPageItemList[5], - ), + find.text('Item ${pageSize * 2}'), _itemHeight, ); - verify(mockPageRequestListener(3)).called(1); + verify(mockFetchNextPage()).called(1); }); group('Displays indicators as grid children', () { @@ -97,9 +84,7 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); + final state = TestPagingState.ongoing(); final customIndicatorKey = UniqueKey(); final customNewPageProgressIndicator = CircularProgressIndicator( @@ -108,7 +93,8 @@ void main() { await _pumpPagedGridView( tester: tester, - pagingController: pagingController, + state: state, + fetchNextPage: () => Completer().future, newPageProgressIndicator: customNewPageProgressIndicator, crossAxisCount: 2, ); @@ -128,9 +114,7 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.errorOnSecondPage, - ); + final state = TestPagingState.subsequentPageError(); final customIndicatorKey = UniqueKey(); final customNewPageErrorIndicator = Text( @@ -140,7 +124,8 @@ void main() { await _pumpPagedGridView( tester: tester, - pagingController: pagingController, + state: state, + fetchNextPage: () => Completer().future, newPageErrorIndicator: customNewPageErrorIndicator, crossAxisCount: 2, ); @@ -160,9 +145,7 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.completedWithOnePage, - ); + final state = TestPagingState.completed(); final customIndicatorKey = UniqueKey(); final customNoMoreItemsIndicator = Text( @@ -172,7 +155,8 @@ void main() { await _pumpPagedGridView( tester: tester, - pagingController: pagingController, + state: state, + fetchNextPage: () => Completer().future, noMoreItemsIndicator: customNoMoreItemsIndicator, crossAxisCount: 2, ); @@ -195,9 +179,7 @@ void main() { '[showNewPageProgressIndicatorAsGridChild] is false', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); + final state = TestPagingState.ongoing(); final customIndicatorKey = UniqueKey(); final customNewPageProgressIndicator = CircularProgressIndicator( @@ -206,7 +188,8 @@ void main() { await _pumpPagedGridView( tester: tester, - pagingController: pagingController, + state: state, + fetchNextPage: () => Completer().future, newPageProgressIndicator: customNewPageProgressIndicator, showNewPageProgressIndicatorAsGridChild: false, ); @@ -222,9 +205,7 @@ void main() { '[showNewPageErrorIndicatorAsGridChild] is false', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.errorOnSecondPage, - ); + final state = TestPagingState.subsequentPageError(); final customIndicatorKey = UniqueKey(); final customNewPageErrorIndicator = Text( @@ -234,7 +215,8 @@ void main() { await _pumpPagedGridView( tester: tester, - pagingController: pagingController, + state: state, + fetchNextPage: () => Completer().future, newPageErrorIndicator: customNewPageErrorIndicator, showNewPageErrorIndicatorAsGridChild: false, ); @@ -250,9 +232,7 @@ void main() { '[showNoMoreItemsIndicatorAsGridChild] is false', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.completedWithOnePage, - ); + final state = TestPagingState.completed(); final customIndicatorKey = UniqueKey(); final customNoMoreItemsIndicator = Text( @@ -262,7 +242,8 @@ void main() { await _pumpPagedGridView( tester: tester, - pagingController: pagingController, + state: state, + fetchNextPage: () => Completer().future, noMoreItemsIndicator: customNoMoreItemsIndicator, showNoMoreItemsIndicatorAsGridChild: false, ); @@ -276,13 +257,14 @@ void main() { }); } -class MockPageRequestListener extends Mock { - void call(int pageKey); +class MockFetchPageRequest extends Mock { + void call(); } Future _pumpPagedGridView({ required WidgetTester tester, - required PagingController pagingController, + required PagingState state, + required NextPageCallback fetchNextPage, int crossAxisCount = 2, Widget? newPageProgressIndicator, Widget? newPageErrorIndicator, @@ -295,9 +277,10 @@ Future _pumpPagedGridView({ MaterialApp( home: Scaffold( body: PagedGridView( - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, builderDelegate: PagedChildBuilderDelegate( - itemBuilder: _buildItem, + itemBuilder: buildTestTile(_itemHeight), newPageProgressIndicatorBuilder: newPageProgressIndicator != null ? (context) => newPageProgressIndicator : null, @@ -323,15 +306,3 @@ Future _pumpPagedGridView({ ), ), ); - -Widget _buildItem( - BuildContext context, - String item, - int index, -) => - SizedBox( - height: _itemHeight, - child: Text( - item, - ), - ); diff --git a/test/paged_list_view_test.dart b/test/layouts/paged_list_view_test.dart similarity index 67% rename from test/paged_list_view_test.dart rename to test/layouts/paged_list_view_test.dart index 67079a1..37ca3b3 100644 --- a/test/paged_list_view_test.dart +++ b/test/layouts/paged_list_view_test.dart @@ -3,12 +3,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:mockito/mockito.dart'; -import 'utils/paging_controller_utils.dart'; -import 'utils/screen_size_utils.dart'; +import '../utils/paging_controller_utils.dart'; +import '../utils/screen_size_utils.dart'; -const _screenSize = Size(200, 500); - -double get _itemHeight => _screenSize.height / pageSize; +double get _itemHeight => screenSize.height / pageSize; void main() { group('Page requests', () { @@ -19,51 +17,34 @@ void main() { }); testWidgets('Requests first page only once', (tester) async { - final pagingController = PagingController( - firstPageKey: 1, - ); - - pagingController.addPageRequestListener(mockPageRequestListener.call); - await _pumpPagedListView( tester: tester, - pagingController: pagingController, + state: TestPagingState.loadingFirstPage(), + fetchNextPage: mockPageRequestListener.call, ); - verify(mockPageRequestListener(1)).called(1); + verify(mockPageRequestListener()).called(1); }); testWidgets( 'Requests second page immediately if the first page isn\'t enough', (tester) async { - final controllerLoadedWithFirstPage = - buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - - controllerLoadedWithFirstPage.addPageRequestListener( - mockPageRequestListener.call, - ); - await _pumpPagedListView( tester: tester, - pagingController: controllerLoadedWithFirstPage, + state: TestPagingState.ongoing(n: pageSize ~/ 2), + fetchNextPage: mockPageRequestListener.call, ); - verify(mockPageRequestListener(2)).called(1); + verify(mockPageRequestListener()).called(1); }); testWidgets('Doesn\'t request a page unnecessarily', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithTwoPages, - ); - pagingController.addPageRequestListener(mockPageRequestListener.call); - await _pumpPagedListView( tester: tester, - pagingController: pagingController, + state: TestPagingState.ongoing(n: pageSize * 2), + fetchNextPage: mockPageRequestListener.call, ); verifyZeroInteractions(mockPageRequestListener); @@ -72,39 +53,30 @@ void main() { testWidgets('Requests a new page on scroll', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithTwoPages, - ); - pagingController.addPageRequestListener(mockPageRequestListener.call); - await _pumpPagedListView( tester: tester, - pagingController: pagingController, + state: TestPagingState.ongoing(n: pageSize * 2), + fetchNextPage: mockPageRequestListener.call, ); await tester.scrollUntilVisible( - find.text( - secondPageItemList[5], - ), + find.text('Item ${pageSize * 2}'), _itemHeight, ); - verify(mockPageRequestListener(3)).called(1); + verify(mockPageRequestListener()).called(1); }); }); testWidgets( 'Inserts separators between items if a [separatorBuilder] is specified', (tester) async { - final controllerLoadedWithFirstPage = - buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); tester.applyPreferredTestScreenSize(); await _pumpPagedListView( tester: tester, - pagingController: controllerLoadedWithFirstPage, + state: TestPagingState.ongoing(), + fetchNextPage: () {}, separatorBuilder: (_, __) => const Divider( height: 1, ), @@ -119,10 +91,6 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - final customIndicatorKey = UniqueKey(); final customNewPageProgressIndicator = CircularProgressIndicator( key: customIndicatorKey, @@ -130,7 +98,8 @@ void main() { await _pumpPagedListView( tester: tester, - pagingController: pagingController, + state: TestPagingState.ongoing(), + fetchNextPage: () {}, newPageProgressIndicator: customNewPageProgressIndicator, ); @@ -149,10 +118,6 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.errorOnSecondPage, - ); - final customIndicatorKey = UniqueKey(); final customNewPageErrorIndicator = Text( 'Error', @@ -161,7 +126,8 @@ void main() { await _pumpPagedListView( tester: tester, - pagingController: pagingController, + state: TestPagingState.subsequentPageError(), + fetchNextPage: () {}, newPageErrorIndicator: customNewPageErrorIndicator, ); @@ -180,10 +146,6 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.completedWithOnePage, - ); - final customIndicatorKey = UniqueKey(); final customNoMoreItemsIndicator = Text( 'No More Items', @@ -192,7 +154,8 @@ void main() { await _pumpPagedListView( tester: tester, - pagingController: pagingController, + state: TestPagingState.completed(), + fetchNextPage: () {}, noMoreItemsIndicator: customNoMoreItemsIndicator, ); @@ -209,13 +172,10 @@ void main() { }); } -class MockPageRequestListener extends Mock { - void call(int pageKey); -} - Future _pumpPagedListView({ required WidgetTester tester, - required PagingController pagingController, + required PagingState state, + required NextPageCallback fetchNextPage, IndexedWidgetBuilder? separatorBuilder, Widget? newPageProgressIndicator, Widget? newPageErrorIndicator, @@ -226,9 +186,10 @@ Future _pumpPagedListView({ home: Scaffold( body: separatorBuilder == null ? PagedListView( - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, builderDelegate: PagedChildBuilderDelegate( - itemBuilder: _buildItem, + itemBuilder: buildTestTile(_itemHeight), newPageProgressIndicatorBuilder: newPageProgressIndicator != null ? (context) => newPageProgressIndicator @@ -242,9 +203,10 @@ Future _pumpPagedListView({ ), ) : PagedListView.separated( - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, builderDelegate: PagedChildBuilderDelegate( - itemBuilder: _buildItem, + itemBuilder: buildTestTile(_itemHeight), newPageProgressIndicatorBuilder: newPageProgressIndicator != null ? (context) => newPageProgressIndicator @@ -261,15 +223,3 @@ Future _pumpPagedListView({ ), ), ); - -Widget _buildItem( - BuildContext context, - String item, - int index, -) => - SizedBox( - height: _itemHeight, - child: Text( - item, - ), - ); diff --git a/test/paged_masonry_grid_view_test.dart b/test/layouts/paged_masonry_grid_view_test.dart similarity index 66% rename from test/paged_masonry_grid_view_test.dart rename to test/layouts/paged_masonry_grid_view_test.dart index 99d9792..97ec7c4 100644 --- a/test/paged_masonry_grid_view_test.dart +++ b/test/layouts/paged_masonry_grid_view_test.dart @@ -3,8 +3,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:mockito/mockito.dart'; -import 'utils/paging_controller_utils.dart'; -import 'utils/screen_size_utils.dart'; +import '../utils/paging_controller_utils.dart'; +import '../utils/screen_size_utils.dart'; double get _itemHeight => (screenSize.height / pageSize) * 2; @@ -17,18 +17,13 @@ void main() { }); testWidgets('Requests first page only once', (tester) async { - final pagingController = PagingController( - firstPageKey: 1, - ); - - pagingController.addPageRequestListener(mockPageRequestListener.call); - await _pumpPagedStaggeredGridView( tester: tester, - pagingController: pagingController, + state: TestPagingState.loadingFirstPage(), + fetchNextPage: mockPageRequestListener.call, ); - verify(mockPageRequestListener(1)).called(1); + verify(mockPageRequestListener()).called(1); }); testWidgets( @@ -36,34 +31,22 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final controllerLoadedWithFirstPage = - buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - - controllerLoadedWithFirstPage.addPageRequestListener( - mockPageRequestListener.call, - ); - await _pumpPagedStaggeredGridView( tester: tester, - pagingController: controllerLoadedWithFirstPage, + state: TestPagingState.ongoing(n: pageSize ~/ 2), + fetchNextPage: mockPageRequestListener.call, ); - verify(mockPageRequestListener(2)).called(1); + verify(mockPageRequestListener()).called(1); }); testWidgets('Doesn\'t request a page unnecessarily', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithTwoPages, - ); - pagingController.addPageRequestListener(mockPageRequestListener.call); - await _pumpPagedStaggeredGridView( tester: tester, - pagingController: pagingController, + state: TestPagingState.ongoing(n: pageSize * 2), + fetchNextPage: mockPageRequestListener.call, ); verifyZeroInteractions(mockPageRequestListener); @@ -72,24 +55,18 @@ void main() { testWidgets('Requests a new page on scroll', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithTwoPages, - ); - pagingController.addPageRequestListener(mockPageRequestListener.call); - await _pumpPagedStaggeredGridView( tester: tester, - pagingController: pagingController, + state: TestPagingState.ongoing(n: pageSize * 2), + fetchNextPage: mockPageRequestListener.call, ); await tester.scrollUntilVisible( - find.text( - secondPageItemList[5], - ), + find.text('Item ${pageSize * 2}'), _itemHeight, ); - verify(mockPageRequestListener(3)).called(1); + verify(mockPageRequestListener()).called(1); }); group('Displays indicators as grid children', () { @@ -97,10 +74,6 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - final customIndicatorKey = UniqueKey(); final customNewPageProgressIndicator = CircularProgressIndicator( key: customIndicatorKey, @@ -108,7 +81,8 @@ void main() { await _pumpPagedStaggeredGridView( tester: tester, - pagingController: pagingController, + state: TestPagingState.ongoing(), + fetchNextPage: mockPageRequestListener.call, newPageProgressIndicator: customNewPageProgressIndicator, crossAxisCount: 2, ); @@ -128,10 +102,6 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.errorOnSecondPage, - ); - final customIndicatorKey = UniqueKey(); final customNewPageErrorIndicator = Text( 'Error', @@ -140,7 +110,8 @@ void main() { await _pumpPagedStaggeredGridView( tester: tester, - pagingController: pagingController, + state: TestPagingState.subsequentPageError(), + fetchNextPage: mockPageRequestListener.call, newPageErrorIndicator: customNewPageErrorIndicator, crossAxisCount: 2, ); @@ -160,10 +131,6 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.completedWithOnePage, - ); - final customIndicatorKey = UniqueKey(); final customNoMoreItemsIndicator = Text( 'No More Items', @@ -172,7 +139,8 @@ void main() { await _pumpPagedStaggeredGridView( tester: tester, - pagingController: pagingController, + state: TestPagingState.completed(), + fetchNextPage: mockPageRequestListener.call, noMoreItemsIndicator: customNoMoreItemsIndicator, crossAxisCount: 2, ); @@ -191,13 +159,10 @@ void main() { }); } -class MockPageRequestListener extends Mock { - void call(int pageKey); -} - Future _pumpPagedStaggeredGridView({ required WidgetTester tester, - required PagingController pagingController, + required PagingState state, + required NextPageCallback fetchNextPage, int crossAxisCount = 2, Widget? newPageProgressIndicator, Widget? newPageErrorIndicator, @@ -207,9 +172,10 @@ Future _pumpPagedStaggeredGridView({ MaterialApp( home: Scaffold( body: PagedMasonryGridView.count( - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, builderDelegate: PagedChildBuilderDelegate( - itemBuilder: _buildItem, + itemBuilder: buildTestTile(_itemHeight), newPageProgressIndicatorBuilder: newPageProgressIndicator != null ? (context) => newPageProgressIndicator : null, @@ -225,15 +191,3 @@ Future _pumpPagedStaggeredGridView({ ), ), ); - -Widget _buildItem( - BuildContext context, - String item, - int index, -) => - SizedBox( - height: _itemHeight, - child: Text( - item, - ), - ); diff --git a/test/paged_page_view_test.dart b/test/layouts/paged_page_view_test.dart similarity index 63% rename from test/paged_page_view_test.dart rename to test/layouts/paged_page_view_test.dart index a424df9..e845285 100644 --- a/test/paged_page_view_test.dart +++ b/test/layouts/paged_page_view_test.dart @@ -3,8 +3,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:mockito/mockito.dart'; -import 'utils/paging_controller_utils.dart'; -import 'utils/screen_size_utils.dart'; +import '../utils/paging_controller_utils.dart'; +import '../utils/screen_size_utils.dart'; double get _itemHeight => screenSize.height; double get _itemWidth => screenSize.width; @@ -18,31 +18,22 @@ void main() { }); testWidgets('Requests first page only once', (tester) async { - final pagingController = PagingController( - firstPageKey: 1, - ); - - pagingController.addPageRequestListener(mockPageRequestListener.call); - await _pumpPagedPageView( tester: tester, - pagingController: pagingController, + state: TestPagingState.loadingFirstPage(), + fetchNextPage: mockPageRequestListener.call, ); - verify(mockPageRequestListener(1)).called(1); + verify(mockPageRequestListener()).called(1); }); testWidgets('Doesn\'t request a page unnecessarily', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithTwoPages, - ); - pagingController.addPageRequestListener(mockPageRequestListener.call); - await _pumpPagedPageView( tester: tester, - pagingController: pagingController, + state: TestPagingState.ongoing(n: pageSize * 2), + fetchNextPage: mockPageRequestListener.call, ); verifyZeroInteractions(mockPageRequestListener); @@ -51,33 +42,23 @@ void main() { testWidgets('Requests a new page on scroll', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - pagingController.addPageRequestListener(mockPageRequestListener.call); - await _pumpPagedPageView( tester: tester, - pagingController: pagingController, + state: TestPagingState.ongoing(n: pageSize * 2), + fetchNextPage: mockPageRequestListener.call, ); await tester.scrollUntilVisible( - find.text( - firstPageItemList[8], - ), + find.text('Item ${pageSize * 2}'), 250, ); - verify(mockPageRequestListener(2)).called(1); + verify(mockPageRequestListener()).called(1); }); testWidgets('Show the new page error indicator', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.errorOnSecondPage, - ); - final customIndicatorKey = UniqueKey(); final customNewPageErrorIndicator = Text( 'Error', @@ -86,7 +67,8 @@ void main() { await _pumpPagedPageView( tester: tester, - pagingController: pagingController, + state: TestPagingState.subsequentPageError(), + fetchNextPage: mockPageRequestListener.call, newPageErrorIndicator: customNewPageErrorIndicator, ); @@ -100,13 +82,10 @@ void main() { }); } -class MockPageRequestListener extends Mock { - void call(int pageKey); -} - Future _pumpPagedPageView({ required WidgetTester tester, - required PagingController pagingController, + required PagingState state, + required NextPageCallback fetchNextPage, Widget? newPageProgressIndicator, Widget? newPageErrorIndicator, Widget? noMoreItemsIndicator, @@ -115,10 +94,11 @@ Future _pumpPagedPageView({ MaterialApp( home: Scaffold( body: PagedPageView( - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, scrollDirection: Axis.vertical, builderDelegate: PagedChildBuilderDelegate( - itemBuilder: _buildItem, + itemBuilder: buildTestTile(_itemHeight), newPageProgressIndicatorBuilder: newPageProgressIndicator != null ? (context) => newPageProgressIndicator : null, @@ -133,15 +113,3 @@ Future _pumpPagedPageView({ ), ), ); - -Widget _buildItem( - BuildContext context, - String item, - int index, -) => - SizedBox( - height: _itemHeight, - child: Text( - item, - ), - ); diff --git a/test/paged_sliver_list_test.dart b/test/layouts/paged_sliver_list_test.dart similarity index 67% rename from test/paged_sliver_list_test.dart rename to test/layouts/paged_sliver_list_test.dart index 70f522e..61939ac 100644 --- a/test/paged_sliver_list_test.dart +++ b/test/layouts/paged_sliver_list_test.dart @@ -3,12 +3,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:mockito/mockito.dart'; -import 'utils/paging_controller_utils.dart'; -import 'utils/screen_size_utils.dart'; +import '../utils/paging_controller_utils.dart'; +import '../utils/screen_size_utils.dart'; -const _screenSize = Size(200, 500); - -double get _itemHeight => _screenSize.height / pageSize; +double get _itemHeight => screenSize.height / pageSize; void main() { group('Page requests', () { @@ -19,51 +17,34 @@ void main() { }); testWidgets('Requests first page only once', (tester) async { - final pagingController = PagingController( - firstPageKey: 1, - ); - - pagingController.addPageRequestListener(mockPageRequestListener.call); - await _pumpPagedSliverList( tester: tester, - pagingController: pagingController, + state: TestPagingState.loadingFirstPage(), + fetchNextPage: mockPageRequestListener.call, ); - verify(mockPageRequestListener(1)).called(1); + verify(mockPageRequestListener()).called(1); }); testWidgets( 'Requests second page immediately if the first page isn\'t enough', (tester) async { - final controllerLoadedWithFirstPage = - buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - - controllerLoadedWithFirstPage.addPageRequestListener( - mockPageRequestListener.call, - ); - await _pumpPagedSliverList( tester: tester, - pagingController: controllerLoadedWithFirstPage, + state: TestPagingState.ongoing(n: pageSize ~/ 2), + fetchNextPage: mockPageRequestListener.call, ); - verify(mockPageRequestListener(2)).called(1); + verify(mockPageRequestListener()).called(1); }); testWidgets('Doesn\'t request a page unnecessarily', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithTwoPages, - ); - pagingController.addPageRequestListener(mockPageRequestListener.call); - await _pumpPagedSliverList( tester: tester, - pagingController: pagingController, + state: TestPagingState.ongoing(n: pageSize * 2), + fetchNextPage: mockPageRequestListener.call, ); verifyZeroInteractions(mockPageRequestListener); @@ -72,39 +53,30 @@ void main() { testWidgets('Requests a new page on scroll', (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithTwoPages, - ); - pagingController.addPageRequestListener(mockPageRequestListener.call); - await _pumpPagedSliverList( tester: tester, - pagingController: pagingController, + state: TestPagingState.ongoing(n: pageSize * 2), + fetchNextPage: mockPageRequestListener.call, ); await tester.scrollUntilVisible( - find.text( - secondPageItemList[5], - ), + find.text('Item ${pageSize * 2}'), _itemHeight, ); - verify(mockPageRequestListener(3)).called(1); + verify(mockPageRequestListener()).called(1); }); }); testWidgets( 'Inserts separators between items if a [separatorBuilder] is specified', (tester) async { - final controllerLoadedWithFirstPage = - buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); tester.applyPreferredTestScreenSize(); await _pumpPagedSliverList( tester: tester, - pagingController: controllerLoadedWithFirstPage, + state: TestPagingState.ongoing(), + fetchNextPage: () {}, separatorBuilder: (_, __) => const Divider( height: 1, ), @@ -119,10 +91,6 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - final customIndicatorKey = UniqueKey(); final customNewPageProgressIndicator = CircularProgressIndicator( key: customIndicatorKey, @@ -130,7 +98,8 @@ void main() { await _pumpPagedSliverList( tester: tester, - pagingController: pagingController, + state: TestPagingState.ongoing(), + fetchNextPage: () {}, newPageProgressIndicator: customNewPageProgressIndicator, ); @@ -149,10 +118,6 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.errorOnSecondPage, - ); - final customIndicatorKey = UniqueKey(); final customNewPageErrorIndicator = Text( 'Error', @@ -161,7 +126,8 @@ void main() { await _pumpPagedSliverList( tester: tester, - pagingController: pagingController, + state: TestPagingState.subsequentPageError(), + fetchNextPage: () {}, newPageErrorIndicator: customNewPageErrorIndicator, ); @@ -180,10 +146,6 @@ void main() { (tester) async { tester.applyPreferredTestScreenSize(); - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.completedWithOnePage, - ); - final customIndicatorKey = UniqueKey(); final customNoMoreItemsIndicator = Text( 'No More Items', @@ -192,7 +154,8 @@ void main() { await _pumpPagedSliverList( tester: tester, - pagingController: pagingController, + state: TestPagingState.completed(), + fetchNextPage: () {}, noMoreItemsIndicator: customNoMoreItemsIndicator, ); @@ -209,13 +172,10 @@ void main() { }); } -class MockPageRequestListener extends Mock { - void call(int pageKey); -} - Future _pumpPagedSliverList({ required WidgetTester tester, - required PagingController pagingController, + required PagingState state, + required NextPageCallback fetchNextPage, IndexedWidgetBuilder? separatorBuilder, Widget? newPageProgressIndicator, Widget? newPageErrorIndicator, @@ -228,9 +188,10 @@ Future _pumpPagedSliverList({ slivers: [ if (separatorBuilder == null) PagedSliverList( - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, builderDelegate: PagedChildBuilderDelegate( - itemBuilder: _buildItem, + itemBuilder: buildTestTile(_itemHeight), newPageProgressIndicatorBuilder: newPageProgressIndicator != null ? (context) => newPageProgressIndicator @@ -245,9 +206,10 @@ Future _pumpPagedSliverList({ ) else PagedSliverList.separated( - pagingController: pagingController, + state: state, + fetchNextPage: fetchNextPage, builderDelegate: PagedChildBuilderDelegate( - itemBuilder: _buildItem, + itemBuilder: buildTestTile(_itemHeight), newPageProgressIndicatorBuilder: newPageProgressIndicator != null ? (context) => newPageProgressIndicator @@ -266,15 +228,3 @@ Future _pumpPagedSliverList({ ), ), ); - -Widget _buildItem( - BuildContext context, - String item, - int index, -) => - SizedBox( - height: _itemHeight, - child: Text( - item, - ), - ); diff --git a/test/paging_controller_test.dart b/test/paging_controller_test.dart deleted file mode 100644 index 59f6a21..0000000 --- a/test/paging_controller_test.dart +++ /dev/null @@ -1,315 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; -import 'package:mockito/mockito.dart'; - -import 'utils/paging_controller_utils.dart'; - -void main() { - group('[appendPage]', () { - test('Appends the new list to [itemList]', () { - // given - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - - // when - pagingController.appendPage(secondPageItemList, 2); - - // then - expect(pagingController.itemList, [ - ...firstPageItemList, - ...secondPageItemList, - ]); - }); - - test('Changes [nextPageKey]', () { - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - const newNextPageKey = 3; - - // when - pagingController.appendPage(secondPageItemList, newNextPageKey); - - // then - expect(pagingController.nextPageKey, newNextPageKey); - }); - - test('Sets [error] to null', () { - // given - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.errorOnSecondPage, - ); - - // when - pagingController.appendPage(secondPageItemList, 3); - - // then - expect(pagingController.error, null); - }); - }); - - group('[appendLastPage]', () { - test('Appends the new list to [itemList]', () { - // given - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - - // when - pagingController.appendLastPage(secondPageItemList); - - // then - expect(pagingController.itemList, [ - ...firstPageItemList, - ...secondPageItemList, - ]); - }); - - test('Sets [nextPageKey] to null', () { - // given - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - - // when - pagingController.appendLastPage(secondPageItemList); - - // then - expect(pagingController.nextPageKey, null); - }); - - test('Sets [error] to null', () { - // given - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.errorOnSecondPage, - ); - - // when - pagingController.appendLastPage(secondPageItemList); - - // then - expect(pagingController.error, null); - }); - }); - - test('[retryLastFailedRequest] sets [error] to null', () { - // given - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.errorOnSecondPage, - ); - - // when - pagingController.retryLastFailedRequest(); - - // then - expect(pagingController.error, null); - }); - - group('[refresh]', () { - test('Sets [itemList] to null', () { - // given - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - - // when - pagingController.refresh(); - - // then - expect(pagingController.itemList, null); - }); - - test('Sets [error] to null', () { - // given - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.errorOnSecondPage, - ); - - // when - pagingController.refresh(); - - // then - expect(pagingController.error, null); - }); - - test('Sets [nextPageKey] back to [firstPageKey]', () { - // given - final pagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - - // when - pagingController.refresh(); - - // then - expect(pagingController.nextPageKey, 1); - }); - }); - - group('[PagingStatusListener]', () { - late PagingController pagingController; - late PagingStatusListener mockStatusListener; - - setUp(() { - pagingController = PagingController(firstPageKey: 1); - mockStatusListener = MockStatusListener().call; - pagingController.addStatusListener(mockStatusListener); - }); - - test('Assigning a new [value] notifies [PagingStatusListener]', () { - // when - pagingController.value = buildPagingStateWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - - // then - verify(mockStatusListener(PagingStatus.ongoing)); - }); - - test('Removed [PagingStatusListener]s aren\'t notified', () { - // when - pagingController.removeStatusListener(mockStatusListener); - pagingController.value = buildPagingStateWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - ); - - // then - verifyNever(mockStatusListener(PagingStatus.ongoing)); - }); - }); - - group('[PageRequestListener]', () { - late PagingController pagingController; - late PageRequestListener mockPageRequestListener; - const requestedPageKey = 2; - - setUp(() { - pagingController = PagingController(firstPageKey: 1); - mockPageRequestListener = MockPageRequestListener().call; - pagingController.addPageRequestListener(mockPageRequestListener); - }); - - test('[PageRequestListener]s are notified', () { - // when - pagingController.notifyPageRequestListeners(requestedPageKey); - - // then - verify(mockPageRequestListener(requestedPageKey)); - }); - - test('Removed [PageRequestListener]s aren\'t notified', () { - // when - pagingController.removePageRequestListener(mockPageRequestListener); - pagingController.notifyPageRequestListeners(requestedPageKey); - - // then - verifyNever(mockPageRequestListener(requestedPageKey)); - }); - }); - - group('[dispose]', () { - late PagingController disposedPagingController; - setUp(() { - disposedPagingController = buildPagingControllerWithPopulatedState( - PopulatedStateOption.ongoingWithOnePage, - )..dispose(); - }); - - test('Can\'t add a [PageRequestListener] to a disposed [PagingController]', - () { - expect( - () => disposedPagingController.addPageRequestListener((pageKey) {}), - throwsException, - ); - }); - - test('Can\'t add a [PagingStatusListener] to a disposed PagingController', - () { - expect( - () => disposedPagingController.addStatusListener((status) {}), - throwsException, - ); - }); - - test( - 'Can\'t remove a [PageRequestListener] from a disposed ' - '[PagingController]', () { - expect( - () => disposedPagingController.removePageRequestListener((pageKey) {}), - throwsException, - ); - }); - - test( - 'Can\'t remove a [PagingStatusListener] from a disposed ' - '[PagingController]', () { - expect( - () => disposedPagingController.removeStatusListener((status) {}), - throwsException, - ); - }); - - test( - 'Can\'t notify [PageRequestListener]s from a disposed ' - '[PagingController]', () { - expect( - () => disposedPagingController.notifyPageRequestListeners(3), - throwsException, - ); - }); - - test( - 'Can\'t notify [PagingStatusListener]s from a disposed ' - '[PagingController]', () { - expect( - () => disposedPagingController.notifyStatusListeners( - PagingStatus.subsequentPageError, - ), - throwsException, - ); - }); - }); - - group('Computed Properties tests', () { - late PagingController pagingController; - - setUp(() { - pagingController = PagingController(firstPageKey: 1); - }); - - test('Assigning to [itemList] changes [value]', () { - // when - pagingController.itemList = firstPageItemList; - - // then - expect(pagingController.value.itemList, firstPageItemList); - }); - - test('Assigning to [nextPageKey] changes [value]', () { - // when - const nextPageKey = 2; - pagingController.nextPageKey = nextPageKey; - - // then - expect(pagingController.value.nextPageKey, nextPageKey); - }); - - test('Assigning to [error] changes [value]', () { - // when - final error = Error(); - pagingController.error = error; - - // then - expect(pagingController.value.error, error); - }); - }); -} - -class MockStatusListener extends Mock { - void call(PagingStatus status); -} - -class MockPageRequestListener extends Mock { - void call(PageKeyType pageKey); -} diff --git a/test/paging_state_test.dart b/test/paging_state_test.dart deleted file mode 100644 index 88fc00e..0000000 --- a/test/paging_state_test.dart +++ /dev/null @@ -1,128 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; - -void main() { - group('[status]', () { - test( - 'When [itemList] isn\'t empty, [nextPageKey] isn\'t null, ' - 'and [error] is null, [status] should be [PagingStatus.ongoing]', () { - const pagingState = PagingState( - nextPageKey: 2, - error: null, - itemList: [1, 2], - ); - - expect(pagingState.status, PagingStatus.ongoing); - }); - - test( - 'When [itemList] isn\'t empty, and [nextPageKey] is null, ' - '[status] should be [PagingStatus.completed]', () { - const pagingState = PagingState( - nextPageKey: null, - error: null, - itemList: [1, 2], - ); - - expect(pagingState.status, PagingStatus.completed); - }); - - test( - 'When both [itemList] and [error] are null, ' - '[status] should be [PagingStatus.loadingFirstPage]', () { - const pagingState = PagingState( - nextPageKey: null, - error: null, - itemList: null, - ); - - expect(pagingState.status, PagingStatus.loadingFirstPage); - }); - - test( - 'When [itemList] isn\'t empty, [nextPageKey] isn\'t null, and ' - '[error] isn\'t null, ' - '[status] should be [PagingStatus.subsequentPageError]', () { - final pagingState = PagingState( - nextPageKey: 1, - error: Error(), - itemList: const [1, 2], - ); - - expect(pagingState.status, PagingStatus.subsequentPageError); - }); - - test( - 'When [itemList] is empty, ' - '[status] should be [PagingStatus.noItemsFound]', () { - const pagingState = PagingState( - nextPageKey: null, - error: null, - itemList: [], - ); - - expect(pagingState.status, PagingStatus.noItemsFound); - }); - }); - - test('Two different instances with equal properties are considered equal', - () { - const pagingState1 = PagingState( - nextPageKey: 2, - itemList: [1, 2], - error: null, - ); - const pagingState2 = PagingState( - nextPageKey: 2, - itemList: [1, 2], - error: null, - ); - expect(pagingState1, pagingState2); - }); - - test('[toString] returns the correct values', () { - const pagingState = PagingState( - nextPageKey: 2, - error: null, - itemList: [1], - ); - - expect( - pagingState.toString(), - 'PagingState(itemList: ┤[1]├, error: null, nextPageKey: 2)', - ); - }); - - group('[hashCode]', () { - test('Equal [PagingState]s have equal [hashCode]s', () { - const pagingState1 = PagingState( - nextPageKey: 2, - itemList: [1, 2], - error: null, - ); - const pagingState2 = PagingState( - nextPageKey: 2, - itemList: [1, 2], - error: null, - ); - - expect(pagingState1.hashCode, pagingState2.hashCode); - }); - - test('Different [PagingState]s have different [hashCode]s', () { - const pagingState1 = PagingState( - nextPageKey: 2, - itemList: [1, 2], - error: null, - ); - - const pagingState2 = PagingState( - nextPageKey: 3, - itemList: [1, 2, 3, 4], - error: null, - ); - - expect(pagingState1.hashCode, isNot(pagingState2.hashCode)); - }); - }); -} diff --git a/test/utils/paging_controller_utils.dart b/test/utils/paging_controller_utils.dart index a13c622..dd90951 100644 --- a/test/utils/paging_controller_utils.dart +++ b/test/utils/paging_controller_utils.dart @@ -1,73 +1,65 @@ +import 'package:flutter/widgets.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:mockito/mockito.dart'; -const firstPageItemList = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']; +class MockPageRequestListener extends Mock { + void call(); +} -const secondPageItemList = [ - '11', - '12', - '13', - '14', - '15', - '16', - '17', - '18', - '19', - '20' -]; +class TestException implements Exception {} -const pageSize = 10; +const int pageSize = 10; -PagingState buildPagingStateWithPopulatedState( - PopulatedStateOption filledStateOption, -) { - switch (filledStateOption) { - case PopulatedStateOption.completedWithOnePage: - return const PagingState( - nextPageKey: null, - itemList: firstPageItemList, - ); - case PopulatedStateOption.errorOnSecondPage: - return PagingState( - nextPageKey: 2, - itemList: firstPageItemList, - error: Error(), - ); - case PopulatedStateOption.ongoingWithOnePage: - return const PagingState( - nextPageKey: 2, - itemList: firstPageItemList, - ); - case PopulatedStateOption.ongoingWithTwoPages: - return const PagingState( - nextPageKey: 3, - itemList: [...firstPageItemList, ...secondPageItemList], - ); - case PopulatedStateOption.errorOnFirstPage: - return PagingState( - error: Error(), +List generateItems(int count) => + List.generate(count, (index) => 'Item ${index + 1}'); + +extension TestPagingState on PagingState { + static PagingState loadingFirstPage() => + PagingState(); + + static PagingState firstPageError() => + PagingState(error: TestException()); + + static PagingState noItemsFound() => PagingState( + pages: const [[]], + keys: const [1], + hasNextPage: false, ); - case PopulatedStateOption.noItemsFound: - return const PagingState( - itemList: [], + + static PagingState ongoing({int n = pageSize}) => + PagingState( + pages: [generateItems(n)], + keys: const [1], + hasNextPage: true, ); - } -} -PagingController buildPagingControllerWithPopulatedState( - PopulatedStateOption filledStateOption, -) { - final state = buildPagingStateWithPopulatedState( - filledStateOption, - ); + static PagingState subsequentPageError({int n = pageSize}) => + PagingState( + pages: [generateItems(n)], + keys: const [1], + error: TestException(), + hasNextPage: true, + ); - return PagingController.fromValue(state, firstPageKey: 1); + static PagingState completed({int n = pageSize}) => + PagingState( + pages: [generateItems(n)], + keys: const [1], + hasNextPage: false, + ); } -enum PopulatedStateOption { - errorOnSecondPage, - completedWithOnePage, - ongoingWithTwoPages, - ongoingWithOnePage, - errorOnFirstPage, - noItemsFound, +Widget Function(BuildContext, String, int) buildTestTile(double itemHeight) { + return ( + BuildContext context, + String item, + int index, + ) { + return SizedBox( + height: itemHeight, + child: Text( + item, + ), + ); + }; }