Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/dcc_toolkit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export 'common/mixins/refresh_stream_mixin.dart';
export 'common/result/result.dart';
export 'common/type_defs.dart';
export 'logger/bolt_logger.dart';
export 'pagination/paginated_scroll_view.dart';
export 'pagination/pagination_interface.dart';
export 'pagination/pagination_mixin.dart';
export 'pagination/pagination_state.dart';
export 'style/style.dart';
export 'test_util/devices_sizes.dart';
export 'test_util/presentation_event_catcher.dart';
Expand Down
77 changes: 77 additions & 0 deletions lib/pagination/paginated_scroll_view.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import 'package:dcc_toolkit/dcc_toolkit.dart';
import 'package:flutter/material.dart';

/// A widget that displays a list of items with pagination.
///
/// This widget is a [CustomScrollView] that displays a paginated list of items.
/// It uses a [PaginationState] to manage the behavior of the widget.
/// It also uses a [NotificationListener] to listen for scroll events and load more items when the user scrolls to the bottom of the list.
class PaginatedScrollView<T> extends StatelessWidget {
/// Creates a new [PaginatedScrollView].
const PaginatedScrollView({
required this.state,
required this.itemBuilder,
this.onLoadMore,
this.topWidget,
this.bottomWidget,
super.key,
});

/// The state of the pagination.
final PaginationState<T> state;

/// The builder for the items.
final Widget Function(BuildContext, T) itemBuilder;

/// The function to call when the user scrolls to the bottom of the list, to load more items.
final void Function()? onLoadMore;

/// Optional widget to display at the top of the list.
final Widget? topWidget;

/// Optional widget to display at the bottom of the list.
final Widget? bottomWidget;

int get _itemCount => state.items.length;

T _fetchItem(int index) {
return state.items[index];
}

@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification:
onLoadMore != null
? (notification) {
final metrics = notification.metrics;
if (metrics.extentAfter == metrics.minScrollExtent) {
// We don't trigger loading if no next page is available or while we are already fetching more
if (state.hasNextPage && !state.isLoading) {
onLoadMore?.call();
return true;
}
}
return false;
}
: null,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
if (topWidget != null) SliverToBoxAdapter(child: topWidget),
SliverList.builder(
itemCount: _itemCount,
itemBuilder: (context, index) => itemBuilder(context, _fetchItem(index)),
),
if (state.hasNextPage)
const SliverToBoxAdapter(
child: Padding(padding: EdgeInsets.all(Sizes.m), child: Center(child: CircularProgressIndicator())),
),
if (bottomWidget != null) SliverToBoxAdapter(child: bottomWidget),
//Bottom insets to be able to scroll the entire content above the FloatingActionButton
const SliverPadding(padding: Paddings.vertical48),
],
),
);
}
}
7 changes: 7 additions & 0 deletions lib/pagination/pagination_interface.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import 'package:dcc_toolkit/pagination/pagination_state.dart';

/// Interface for pagination which is used on your bloc state.
abstract interface class PaginationInterface<T> {
/// The current pagination state.
PaginationState<T> get paginationState;
}
55 changes: 55 additions & 0 deletions lib/pagination/pagination_mixin.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import 'package:dcc_toolkit/pagination/pagination_interface.dart';
import 'package:dcc_toolkit/pagination/pagination_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

/// Mixin for pagination.
///
/// This mixin is used to handle pagination in a bloc.
/// It provides a method to fetch the items for a given page and a method to initialize the pagination state.
/// It also provides a method to load the next page.
///
/// Example:
/// ```dart
/// class MyBloc extends Bloc<MyEvent, MyState> with PaginationMixin<MyItem, MyState> {
/// @override
/// Future<List<MyItem>?> fetchPageItems(int page, String? searchQuery) async {
/// // Fetch items logic
/// }
///
/// @override
/// Future<void> initializeState({String? searchQuery}) async {
/// // Initialize state logic
/// }
///
/// @override
/// Future<void> loadNextPage(void Function(PaginationState<T>) emitState) async {
/// // Load next page logic
/// }
/// }
/// ```
mixin PaginationMixin<T, S extends PaginationInterface<T>> on Cubit<S> {
/// Fetches the items for the given page
Future<List<T>?> fetchPageItems({required int page, String? searchQuery});

/// Initializes the pagination state
Future<void> initializeState({String? searchQuery});

/// Loads the next page
Future<void> loadNextPage(void Function(PaginationState<T>) emitState) async {
final paginationState = state.paginationState;
if (paginationState.currentPage < paginationState.lastPage) {
emitState(paginationState.copyWith(isLoading: true));
final nextPage = paginationState.currentPage + 1;
final nextItems = await fetchPageItems(page: nextPage, searchQuery: paginationState.searchQuery);
if (nextItems?.isNotEmpty ?? false) {
emitState(
paginationState.copyWith(
items: [...paginationState.items, ...nextItems!],
currentPage: nextPage,
isLoading: false,
),
);
}
}
}
}
64 changes: 64 additions & 0 deletions lib/pagination/pagination_state.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/// State for pagination.
class PaginationState<T> {
/// Creates a new [PaginationState] with the given values.
PaginationState({
this.items = const [],
this.currentPage = 1,
this.lastPage = 1,
this.isLoading = false,
this.loadingInitialPage = true,
this.hasError = false,
this.total = 0,
this.searchQuery,
});

/// All items fetched so far for the loaded pages.
final List<T> items;

/// The current page in the pagination process.
final int currentPage;

/// The last page in the pagination process.
final int lastPage;

/// Whether the current page is being loaded.
final bool isLoading;

/// Whether the initial page is being loaded.
final bool loadingInitialPage;

/// Whether there is an error loading the current page.
final bool hasError;

/// The total number of items.
final int total;

/// The search query to filter the items.
final String? searchQuery;

/// Checks if there is a next page to load for the current [PaginationState].
bool get hasNextPage => currentPage < lastPage;

/// Copies the current state with the given values.
PaginationState<T> copyWith({
List<T>? items,
int? currentPage,
int? lastPage,
bool? isLoading,
bool? loadingInitialPage,
bool? hasError,
int? total,
String? searchQuery,
}) {
return PaginationState<T>(
items: items ?? this.items,
currentPage: currentPage ?? this.currentPage,
lastPage: lastPage ?? this.lastPage,
isLoading: isLoading ?? this.isLoading,
loadingInitialPage: loadingInitialPage ?? this.loadingInitialPage,
hasError: hasError ?? this.hasError,
total: total ?? this.total,
searchQuery: searchQuery ?? this.searchQuery,
);
}
}
100 changes: 100 additions & 0 deletions test/pagination/pagination_mixin_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
// Ignored because its more descriptive to specify the arguments in the test.
// Immutable warning is ignored for testing purposes.
//ignore_for_file: avoid_redundant_argument_values, avoid_equals_and_hash_code_on_mutable_classes
import 'package:dcc_toolkit/pagination/pagination_interface.dart';
import 'package:dcc_toolkit/pagination/pagination_mixin.dart';
import 'package:dcc_toolkit/pagination/pagination_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:parameterized_test/parameterized_test.dart';

void main() {
group('pagination_state tests', () {
parameterizedTest(
'hasNextPage',
[
[1, 1, false],
[2, 2, false],
[2, 1, false],
[1, -1, false],
[-1, 1, true],
[1, 2, true],
],
(int currentPage, int lastPage, bool expected) =>
expect(PaginationState<int>(items: [], currentPage: currentPage, lastPage: lastPage).hasNextPage, expected),
);
});

group('PaginationMixin tests', () {
late _TestCubit cubit;

setUp(() {
cubit = _TestCubit();
});

test('loadNextPage emits new state with next page items when current page is less than last page', () async {
final paginationState = PaginationState<int>(items: [1, 2, 3], currentPage: 1, lastPage: 3);
cubit.emit(_TestState(paginationState));

final nextPaginationStates = <PaginationState<int>?>[];
await cubit.loadNextPage(nextPaginationStates.add);

expect(nextPaginationStates.first!.isLoading, isTrue);
expect(nextPaginationStates.length, 2);
expect(nextPaginationStates.last!.items, equals([1, 2, 3, 1, 2, 3]));
expect(nextPaginationStates.last!.currentPage, equals(2));
expect(nextPaginationStates.last!.isLoading, isFalse);
});

test('loadNextPage does not emit new state when current page is equal to last page', () async {
final paginationState = PaginationState<int>(items: [1, 2, 3], currentPage: 3, lastPage: 3);
cubit.emit(_TestState(paginationState));

final nextPaginationStates = <PaginationState<int>?>[];
await cubit.loadNextPage(nextPaginationStates.add);

expect(nextPaginationStates, isEmpty);
});

test('loadNextPage does not emit new state when next page items are empty', () async {
final paginationState = PaginationState<int>(items: [1, 2, 3], currentPage: 1, lastPage: 3);
cubit
..emit(_TestState(paginationState))
..returnPages = [];

final nextPaginationStates = <PaginationState<int>?>[];
await cubit.loadNextPage(nextPaginationStates.add);

expect(nextPaginationStates.first!.isLoading, isTrue);
expect(nextPaginationStates.length, 1);
});
});
}

class _TestState implements PaginationInterface<int> {
_TestState(this.paginationState);

@override
final PaginationState<int> paginationState;

@override
int get hashCode => paginationState.hashCode;

@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is _TestState && other.paginationState == paginationState;
}
}

class _TestCubit extends Cubit<_TestState> with PaginationMixin<int, _TestState> {
_TestCubit() : super(_TestState(PaginationState()));

List<int> returnPages = [1, 2, 3];

@override
Future<List<int>?> fetchPageItems({required int page, String? searchQuery}) async => returnPages;

@override
Future<void> initializeState({String? searchQuery}) async {}
}