flutter_paginatrix 1.0.0
flutter_paginatrix: ^1.0.0 copied to clipboard
A simple, backend-agnostic pagination engine with UI widgets for Flutter
π Flutter Paginatrix #
A backend-agnostic pagination engine for Flutter
A production-ready, type-safe pagination library that works with any backend API. Built with clean architecture, comprehensive error handling, and beautiful UI components.
β¨ Features #
Core Capabilities #
- π― Backend-Agnostic - Works with any API format (REST, GraphQL, custom)
- π Multiple Pagination Strategies - Page-based, offset-based, and cursor-based
- π¨ UI Components - Sliver-based ListView and GridView with skeleton loaders
- π Type-Safe - Full generics support with compile-time safety
- π§© DI Flexible - Use any DI solution (
get_it,provider,riverpod) or create instances directly
Performance & Reliability #
- β‘ LRU Caching - Metadata caching prevents redundant parsing
- π‘οΈ Race Condition Protection - Generation guards prevent stale responses
- π« Request Cancellation - Automatic cleanup of in-flight requests
- π Automatic Retry - Exponential backoff retry (1s β 2s β 4s β 8s)
- β±οΈ Smart Debouncing - Search (400ms) and refresh (300ms) debouncing
Developer Experience #
- π Search & Filtering - Built-in support with type-safe access
- π 6 Error Types - Network, parse, cancelled, rate-limited, circuit breaker, unknown
- π± Web Support - Page selector widget with multiple styles
- π¨ Customizable UI - Custom builders for empty states, errors, and loaders
- π§ͺ Well-Tested - Comprehensive test suite covering unit, integration, and widget tests
π― Why Flutter Paginatrix? #
- Controller-Based API - Clean state management using Cubit (flutter_bloc)
- Zero Boilerplate - Minimal configuration with sensible defaults
- Production-Ready - Comprehensive error handling and race condition protection
- Flexible Meta Parsing - Configurable parsers for any API response structure
- Performance First - LRU caching, debouncing, efficient Sliver rendering
π¦ Installation #
Add to your pubspec.yaml:
dependencies:
flutter_paginatrix: ^1.0.0
Then run:
flutter pub get
Requirements #
- Flutter: >=3.22.0
- Dart: >=3.2.0 <4.0.0
π Quick Start #
1. Create a Controller #
import 'package:flutter_paginatrix/flutter_paginatrix.dart';
import 'package:dio/dio.dart';
final controller = PaginatrixController<User>(
loader: ({
int? page,
int? perPage,
CancelToken? cancelToken,
QueryCriteria? query,
}) async {
final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
final searchTerm = query?.searchTerm;
final response = await dio.get('/users', queryParameters: {
'page': page ?? 1,
'per_page': perPage ?? 20,
if (searchTerm != null && searchTerm.isNotEmpty) 'q': searchTerm,
}, cancelToken: cancelToken);
return response.data; // {data: [...], meta: {...}}
},
itemDecoder: (json) => User.fromJson(json),
metaParser: ConfigMetaParser(MetaConfig.nestedMeta),
);
2. Use the Widget #
PaginatrixListView<User>(
controller: controller,
itemBuilder: (context, user, index) {
return ListTile(
title: Text(user.name),
subtitle: Text(user.email),
);
},
)
3. Load Data #
@override
void initState() {
super.initState();
controller.loadFirstPage(); // Don't forget!
}
@override
void dispose() {
controller.close();
super.dispose();
}
The widget automatically handles loading states, errors, empty states, pagination on scroll, pull-to-refresh, and append loading indicators.
π Basic Usage #
Complete Example #
import 'package:flutter/material.dart';
import 'package:flutter_paginatrix/flutter_paginatrix.dart';
import 'package:dio/dio.dart';
class UsersPage extends StatefulWidget {
const UsersPage({super.key});
@override
State<UsersPage> createState() => _UsersPageState();
}
class _UsersPageState extends State<UsersPage> {
late final PaginatrixController<User> _controller;
final _dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
@override
void initState() {
super.initState();
_controller = PaginatrixController<User>(
loader: _loadUsers,
itemDecoder: (json) => User.fromJson(json),
metaParser: ConfigMetaParser(MetaConfig.nestedMeta),
);
_controller.loadFirstPage();
}
Future<Map<String, dynamic>> _loadUsers({
int? page,
int? perPage,
CancelToken? cancelToken,
QueryCriteria? query,
}) async {
final searchTerm = query?.searchTerm;
final response = await _dio.get(
'/users',
queryParameters: {
'page': page ?? 1,
'per_page': perPage ?? 20,
if (searchTerm != null && searchTerm.isNotEmpty) 'q': searchTerm,
},
cancelToken: cancelToken,
);
return response.data;
}
@override
void dispose() {
_controller.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Users')),
body: PaginatrixListView<User>(
controller: _controller,
itemBuilder: (context, user, index) {
return ListTile(
leading: CircleAvatar(child: Text(user.name[0])),
title: Text(user.name),
subtitle: Text(user.email),
);
},
),
);
}
}
π¨ Advanced Usage #
Search #
// Update search term (automatically debounced, 400ms default)
_controller.updateSearchTerm('john');
Filters #
// Add a filter (triggers immediate reload)
_controller.updateFilter('status', 'active');
// Add multiple filters
_controller.updateFilters({
'status': 'active',
'role': 'admin',
});
// Clear all filters
_controller.clearFilters();
Sorting #
// Set sorting (triggers immediate reload)
_controller.updateSorting('name', sortDesc: false);
// Clear sorting
_controller.updateSorting(null);
Custom Meta Parser #
For APIs with non-standard response formats:
final controller = PaginatrixController<Product>(
loader: _loadProducts,
itemDecoder: (json) => Product.fromJson(json),
metaParser: CustomMetaParser(
(data) {
return {
'items': data['products'] as List,
'meta': {
'page': data['page'],
'perPage': data['limit'],
'total': data['total_count'],
'hasMore': data['has_next'],
},
};
},
),
);
Pull-to-Refresh #
PaginatrixListView<User>(
controller: _controller,
itemBuilder: (context, user, index) => UserTile(user: user),
onPullToRefresh: () async {
await _controller.refresh();
},
)
Custom Error Handling #
PaginatrixListView<User>(
controller: _controller,
itemBuilder: (context, user, index) => UserTile(user: user),
errorBuilder: (context, error, onRetry) {
return PaginatrixErrorView(
error: error,
onRetry: onRetry,
);
},
)
GridView #
PaginatrixGridView<Product>(
controller: _controller,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemBuilder: (context, product, index) {
return ProductCard(product: product);
},
)
Web Page Selector #
PageSelector(
currentPage: _controller.state.meta?.page ?? 1,
totalPages: _controller.state.meta?.lastPage ?? 1,
onPageSelected: (page) {
_controller.loadFirstPage(); // Reset and load page
},
style: PageSelectorStyle.buttons,
)
Configuration Options #
final controller = PaginatrixController<User>(
loader: _loadUsers,
itemDecoder: (json) => User.fromJson(json),
metaParser: ConfigMetaParser(MetaConfig.nestedMeta),
options: const PaginationOptions(
defaultPageSize: 20,
maxPageSize: 100,
maxRetries: 5,
initialBackoff: Duration(milliseconds: 500),
refreshDebounceDuration: Duration(milliseconds: 300),
searchDebounceDuration: Duration(milliseconds: 400),
enableDebugLogging: false,
),
);
Dependency Injection #
The core pagination classes do NOT require any specific DI solution. They accept dependencies through constructors, allowing you to use any DI approach.
Using get_it
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
void setupDependencies() {
getIt.registerSingleton<Dio>(Dio(BaseOptions(
baseUrl: 'https://api.example.com',
)));
}
// Use in controller
final controller = PaginatrixController<User>(
loader: getIt<UsersRepository>().loadUsers,
itemDecoder: (json) => User.fromJson(json),
metaParser: getIt<MetaParser>(),
);
Using provider
MultiProvider(
providers: [
Provider(create: (_) => Dio(BaseOptions(baseUrl: 'https://api.example.com'))),
Provider(create: (_) => ConfigMetaParser(MetaConfig.nestedMeta)),
],
child: MyApp(),
)
π API Overview #
Core Classes #
PaginatrixController<T>
Main controller for managing paginated data. Type alias for PaginatrixCubit<T>.
Key Methods:
loadFirstPage()- Load the first page (resets list)loadNextPage()- Load the next page (appends to list)refresh()- Refresh current data (debounced)updateSearchTerm(String term)- Update search term (debounced)updateFilter(String key, dynamic value)- Add/update a filterupdateFilters(Map<String, dynamic> filters)- Add/update multiple filtersclearFilters()- Clear all filtersupdateSorting(String? sortBy, {bool sortDesc})- Set sortingclearAllQuery()- Clear all search, filters, and sortingretry()- Retry failed operationcancel()- Cancel in-flight requestsclear()- Clear all data and resetclose()- Dispose resources
Key Properties:
state: PaginationState<T>- Current statecanLoadMore: bool- Whether more data can be loadedisLoading: bool- Whether loading is in progress
PaginationState<T>
Immutable state object containing:
status: PaginationStatus- Current statusitems: List<T>- Loaded itemsmeta: PageMeta?- Pagination metadataerror: PaginationError?- Current error (if any)query: QueryCriteria- Current search/filter criteria
Extension Methods:
hasData: bool- Whether items existisLoading: bool- Whether in loading statecanLoadMore: bool- Whether more pages availablecurrentQuery: QueryCriteria- Current query criteria
PageMeta
Pagination metadata:
page: int?- Current page numberperPage: int?- Items per pagetotal: int?- Total itemslastPage: int?- Last page numberhasMore: bool- Whether more pages availablenextCursor: String?- Cursor for cursor-based paginationoffset: int?/limit: int?- For offset-based pagination
QueryCriteria
Immutable value object for search and filter criteria:
searchTerm: String- Search termfilters: Map<String, dynamic>- Filter key-value pairssortBy: String?- Field to sort bysortDesc: bool- Sort direction
Methods:
withFilter(String key, dynamic value)- Add/update filterwithFilters(Map<String, dynamic> filters)- Add/update multiple filtersremoveFilter(String key)- Remove filterclearSearch()- Clear searchclearFilters()- Clear filtersclearSorting()- Clear sortingclearAll()- Clear everything
Widgets #
PaginatrixListView<T>
ListView widget with built-in pagination.
Key Parameters:
controllerorcubit- Pagination controller (required)itemBuilder- Function to build each item (required)keyBuilder- Optional key generatorprefetchThreshold- Items from end to trigger loademptyBuilder- Custom empty stateerrorBuilder- Custom error stateappendErrorBuilder- Custom append error stateappendLoaderBuilder- Custom append loaderonPullToRefresh- Pull-to-refresh callbackonRetryInitial- Retry initial load callbackonRetryAppend- Retry append callback
PaginatrixGridView<T>
GridView widget with built-in pagination. Same parameters as PaginatrixListView plus:
gridDelegate- Grid layout configuration (required)
AppendLoader
Loading indicator with multiple animation types:
LoaderType.bouncingDots- Bouncing dotsLoaderType.wave- Wave animationLoaderType.rotatingSquares- Rotating squaresLoaderType.pulse- Pulse animationLoaderType.skeleton- Skeleton effectLoaderType.traditional- Traditional spinner
PaginatrixErrorView
Error display widget with retry functionality.
PaginatrixAppendErrorView
Inline error view for append failures.
PaginatrixEmptyView
Base empty state widget. Variants:
PaginatrixGenericEmptyView- Generic empty statePaginatrixSearchEmptyView- Search empty statePaginatrixNetworkEmptyView- Network empty state
PaginatrixSkeletonizer
Skeleton loading effect widget with customizable item builders.
PageSelector
Page selection widget for web with styles:
PageSelectorStyle.buttons- Button-based paginationPageSelectorStyle.dropdown- Dropdown selectorPageSelectorStyle.compact- Compact display
Meta Parsers #
ConfigMetaParser
Pre-configured parser for common API formats with automatic LRU caching.
Pre-configured Configs:
MetaConfig.nestedMeta-{data: [], meta: {current_page, per_page, ...}}MetaConfig.resultsFormat-{results: [], count, page, per_page, ...}MetaConfig.pageBased- Simple page-based formatMetaConfig.cursorBased- Cursor-based formatMetaConfig.offsetBased- Offset/limit format
Custom Config:
final config = MetaConfig(
itemsPath: 'data',
pagePath: 'meta.current_page',
perPagePath: 'meta.per_page',
totalPath: 'meta.total',
lastPagePath: 'meta.last_page',
hasMorePath: 'meta.has_more',
);
final parser = ConfigMetaParser(config);
CustomMetaParser
Flexible parser for custom API structures:
CustomMetaParser(
(data) {
return {
'items': data['products'] as List,
'meta': {
'page': data['page'],
'perPage': data['limit'],
'total': data['total_count'],
'hasMore': data['has_next'],
},
};
},
)
Enums #
PaginationStatus
Union type for pagination status:
initial()- Initial stateloading()- Loading datasuccess()- Successfully loadedempty()- Empty stateerror()- Error occurredrefreshing()- Refreshing dataappending()- Loading next pageappendError()- Error during append
PaginationError
Union type for error types:
network()- Network errorsparse()- Parse errorscancelled()- Cancellation errorsrateLimited()- Rate limit errorscircuitBreaker()- Circuit breaker errorsunknown()- Unknown errors
Properties:
isRetryable: bool- Whether error can be retriedisUserVisible: bool- Whether to show to useruserMessage: String- User-friendly message
LoaderType
Types of loaders for pagination UI:
bouncingDots- Bouncing dots animationwave- Wave animationrotatingSquares- Rotating squares animationpulse- Pulse animationskeleton- Skeleton loading effecttraditional- Traditional spinner
π Example Projects #
The package includes comprehensive examples:
| Example | Description |
|---|---|
list_view |
Basic ListView pagination |
grid_view |
GridView pagination |
bloc_pattern |
BLoC pattern integration |
cubit_direct |
Direct PaginatrixCubit usage |
search_basic |
Basic search with debouncing |
search_advanced |
Advanced search with filters and sorting |
web_infinite_scroll |
Web infinite scroll pagination |
web_page_selector |
Web page selector pagination |
Run any example:
cd examples/list_view
flutter run
β οΈ Common Pitfalls #
1. Not Disposing Controllers #
β Wrong:
final controller = PaginatrixController<User>(...);
// Controller not disposed - memory leak!
β Correct:
@override
void dispose() {
_controller.close();
super.dispose();
}
2. Forgetting to Call loadFirstPage() #
β Wrong:
@override
void initState() {
super.initState();
_controller = PaginatrixController<User>(...);
// Missing loadFirstPage() - no data will load!
}
β Correct:
@override
void initState() {
super.initState();
_controller = PaginatrixController<User>(...);
_controller.loadFirstPage(); // Don't forget!
}
3. Incorrect Meta Parser Configuration #
β Wrong:
// Paths don't match API structure
metaParser: ConfigMetaParser(MetaConfig.nestedMeta), // But API uses 'results' not 'data'
β Correct:
// Match your API structure
metaParser: ConfigMetaParser(MetaConfig.resultsFormat), // Or use CustomMetaParser
4. Not Handling Errors #
β Wrong:
PaginatrixListView<User>(
controller: _controller,
itemBuilder: (context, user, index) => UserTile(user: user),
// No error handling - users see nothing on error
)
β Correct:
PaginatrixListView<User>(
controller: _controller,
itemBuilder: (context, user, index) => UserTile(user: user),
errorBuilder: (context, error, onRetry) {
return PaginatrixErrorView(error: error, onRetry: onRetry);
},
)
5. Search vs Filter Behavior #
- Search (
updateSearchTerm) - Debounced (400ms default), triggers reload after delay - Filters (
updateFilter,updateFilters) - Immediate, triggers reload right away
6. Dependency Injection #
β Wrong:
// Creating new instances everywhere
final dio1 = Dio();
final dio2 = Dio(); // Different instance!
β Correct:
// Use DI for shared dependencies (example with get_it)
final getIt = GetIt.instance;
getIt.registerSingleton<Dio>(Dio());
final dio = getIt<Dio>(); // Same instance everywhere
π§ͺ Testing #
The package includes comprehensive test coverage. Example test:
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_paginatrix/flutter_paginatrix.dart';
void main() {
test('should load first page', () async {
final controller = PaginatrixController<User>(
loader: mockLoader,
itemDecoder: (json) => User.fromJson(json),
metaParser: ConfigMetaParser(MetaConfig.nestedMeta),
);
await controller.loadFirstPage();
expect(controller.state.items.length, greaterThan(0));
expect(controller.state.status, PaginationStatus.success());
});
test('should handle search', () async {
final controller = PaginatrixController<User>(...);
await controller.loadFirstPage();
controller.updateSearchTerm('john');
await Future.delayed(const Duration(milliseconds: 500)); // Wait for debounce
expect(controller.state.currentQuery.searchTerm, equals('john'));
});
}
See the test/ directory for more examples including integration tests and performance tests.
π License #
This project is licensed under the MIT License - see the LICENSE file for details.
Copyright (c) 2025 Mhamad Hwidi
π Support & Links #
Package Links #
- π¦ Pub.dev Package
- π GitHub Repository
- π Issue Tracker
- π API Documentation
- π Full Documentation
Support the Project #
If you find this package useful, please consider:
- β Starring the repository - Help others discover this package
- π Reporting bugs - Help improve the package
- π‘ Suggesting features - Share your ideas
- π Improving documentation - Help others learn
Made with β€οΈ for the Flutter community