levee 0.6.0
levee: ^0.6.0 copied to clipboard
A lean, backend-agnostic pagination engine for Flutter with cache-first support, filtering, sorting, and retry policies.
Levee is a lightweight, high-performance, dependency-free pagination engine for Flutter that brings cache-first architecture and generic page key support to your applications. Whether you're paginating REST APIs with offset/limit, Firestore with cursors, or custom pagination schemes, Levee provides a unified, flexible foundation.
Table of Contents #
- Features
- Quick Start
- Cache Policies
- Retry Logic
- Filtering & Sorting
- DataSource Examples
- Architecture
- API Reference
- Design Philosophy
- Contributing
- License
- Support
Features #
- Generic Page Keys (
K): Useint,String,DocumentSnapshot, or custom types as page keys - Dependency-Free Core: Zero external dependencies beyond Flutter SDK
- Cache-First Architecture: Four cache policies (CacheFirst, NetworkFirst, CacheOnly, NetworkOnly)
- Automatic Retry Logic: Exponential backoff with configurable max attempts
- Advanced Filtering & Sorting: Comprehensive
FilterQuerysystem with 13+ operations - Deterministic Cache Keys: Query parameters + filters create stable cache identities
- Headless & UI Modes:
LeveeBuilderfor custom UI,LeveeCollectionViewfor plug-and-play infinite scroll - State Management: Built on
ChangeNotifierfor seamless Flutter integration - TTL Support: Time-based cache expiration in
MemoryCacheStore - Type-Safe: Full generic support with
PageData<T,K>andDataSource<T,K>
Quick Start π #
1. Add to pubspec.yaml #
dependencies:
levee: ^0.6.0
2. Define Your Data Source #
import 'package:levee/levee.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
class UserDataSource implements DataSource<User, int> {
final String baseUrl;
UserDataSource(this.baseUrl);
@override
Future<PageData<User, int>> fetchPage(PageQuery<int> query) async {
// Build URL with query parameters
final url = Uri.parse('$baseUrl/users').replace(queryParameters: {
'page': query.key.toString(),
'limit': query.pageSize.toString(),
if (query.filters != null) ...buildFilterParams(query.filters!),
});
final response = await http.get(url);
final data = json.decode(response.body);
return PageData<User, int>(
items: (data['users'] as List).map((json) => User.fromJson(json)).toList(),
query: query,
nextKey: data['hasMore'] ? query.key + 1 : null,
status: PageStatus.success,
);
}
Map<String, String> buildFilterParams(FilterQuery filters) {
// Convert filters to API params
return {
for (var field in filters.fields)
field.fieldName: field.value.toString(),
};
}
}
3. Initialize Paginator #
final paginator = Paginator<User, int>(
source: UserDataSource('https://api.example.com'),
cache: MemoryCacheStore<User, int>(),
pageSize: 20,
cachePolicy: CachePolicy.cacheFirst,
retryPolicy: RetryPolicy(maxAttempts: 3),
);
4. Build Your UI #
Option A: Headless with LeveeBuilder
class UserListScreen extends StatelessWidget {
final Paginator<User, int> paginator;
UserListScreen(this.paginator);
@override
Widget build(BuildContext context) {
return LeveeBuilder<User, int>(
paginator: paginator,
builder: (context, state) {
if (state.pages.isEmpty && state.isLoading) {
return Center(child: CircularProgressIndicator());
}
final allUsers = state.pages.expand((p) => p.items).toList();
return ListView.builder(
itemCount: allUsers.length + (state.hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == allUsers.length) {
paginator.loadNextPage();
return Center(child: CircularProgressIndicator());
}
return UserTile(user: allUsers[index]);
},
);
},
);
}
}
Option B: Full-Featured with LeveeCollectionView
LeveeCollectionView<User, int>(
paginator: paginator,
itemBuilder: (context, user) => ListTile(
leading: CircleAvatar(child: Text(user.name[0])),
title: Text(user.name),
subtitle: Text(user.email),
trailing: Icon(Icons.chevron_right),
),
loadingBuilder: (context) => Center(
child: CircularProgressIndicator(),
),
errorBuilder: (context, error) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 48, color: Colors.red),
SizedBox(height: 16),
Text('Error: $error'),
ElevatedButton(
onPressed: () => paginator.refresh(),
child: Text('Retry'),
),
],
),
),
emptyBuilder: (context) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 48, color: Colors.grey),
SizedBox(height: 16),
Text('No users found'),
],
),
),
)
Cache Policies #
Levee supports four cache policies to match your data freshness requirements:
| Policy | Description | Use Case |
|---|---|---|
CacheFirst |
Check cache first, fetch on miss | Default, balances speed and freshness |
NetworkFirst |
Always fetch fresh, fall back to cache on error | Real-time data with offline fallback |
CacheOnly |
Only return cached data | Offline-first, testing |
NetworkOnly |
Always fetch fresh, ignore cache | Critical data requiring latest state |
// Example: Switch to NetworkFirst for real-time updates
paginator.updateCachePolicy(CachePolicy.networkFirst);
Retry Logic π #
Levee includes exponential backoff retry for transient failures:
final paginator = Paginator<User, int>(
source: userDataSource,
retryPolicy: RetryPolicy(
maxAttempts: 3,
delay: Duration(seconds: 1),
maxDelay: Duration(seconds: 30),
),
);
Retry Behavior:
- Attempts:
maxAttempts(default: 3) - Delays: Exponential backoff (1s, 2s, 4s, ...)
- Max delay: Capped at
maxDelay(default: 30s) - Conditional: Use
retryIfto retry only on specific errors
Filtering & Sorting #
Filter Operations #
Levee provides 13 predefined operations plus custom support:
final filters = FilterQuery(
fields: [
FilterField(
fieldName: 'status',
value: 'active',
operation: FilterOperation.equals,
),
FilterField(
fieldName: 'age',
value: 18,
operation: FilterOperation.greaterThan,
),
FilterField(
fieldName: 'tags',
value: 'flutter',
operation: FilterOperation.arrayContains,
),
],
sorts: [
SortField(fieldName: 'createdAt', descending: true),
],
);
final query = PageQuery<int>(
key: 1,
pageSize: 20,
filters: filters,
);
Available Operations:
equals,notEqualsgreaterThan,greaterThanOrEqual,lessThan,lessThanOrEqualisIn,isNotInarrayContains,arrayContainsAnyisNull,isNotNulllikecustom(String code)- For provider-specific operations
Deterministic Cache Keys #
Filters and sorts are part of the cache key calculation, ensuring:
PageQuery(key: 1, filters: FilterQuery(...))
// Generates different cache key than:
PageQuery(key: 1, filters: null)
DataSource Examples #
Levee's DataSource interface is simple yet powerfulβimplement one method to connect any backend. Here are production-ready examples:
REST API with Offset Pagination #
class RestDataSource implements DataSource<Product, int> {
final String baseUrl;
final http.Client client;
RestDataSource(this.baseUrl, this.client);
@override
Future<PageData<Product, int>> fetchPage(PageQuery<int> query) async {
final offset = (query.key - 1) * query.pageSize;
final url = Uri.parse('$baseUrl/products').replace(queryParameters: {
'offset': offset.toString(),
'limit': query.pageSize.toString(),
});
final response = await client.get(url);
if (response.statusCode != 200) throw Exception('Failed to load products');
final data = json.decode(response.body);
return PageData<Product, int>(
items: (data['products'] as List).map((j) => Product.fromJson(j)).toList(),
query: query,
nextKey: data['hasMore'] ? query.key + 1 : null,
status: PageStatus.success,
);
}
}
Firestore with Cursor Pagination #
class FirestoreDataSource implements DataSource<Post, DocumentSnapshot?> {
final FirebaseFirestore firestore;
final String collection;
FirestoreDataSource(this.firestore, this.collection);
@override
Future<PageData<Post, DocumentSnapshot?>> fetchPage(
PageQuery<DocumentSnapshot?> query,
) async {
var firestoreQuery = firestore
.collection(collection)
.orderBy('createdAt', descending: true)
.limit(query.pageSize);
if (query.key != null) {
firestoreQuery = firestoreQuery.startAfterDocument(query.key!);
}
final snapshot = await firestoreQuery.get();
return PageData<Post, DocumentSnapshot?>(
items: snapshot.docs.map((doc) => Post.fromFirestore(doc)).toList(),
query: query,
nextKey: snapshot.docs.isNotEmpty ? snapshot.docs.last : null,
status: PageStatus.success,
);
}
}
GraphQL with Cursor Pagination #
class GraphQLDataSource implements DataSource<User, String?> {
final GraphQLClient client;
GraphQLDataSource(this.client);
@override
Future<PageData<User, String?>> fetchPage(PageQuery<String?> query) async {
final result = await client.query(QueryOptions(
document: gql('''
query GetUsers(\$first: Int!, \$after: String) {
users(first: \$first, after: \$after) {
edges { node { id name email } cursor }
pageInfo { hasNextPage endCursor }
}
}
'''),
variables: {'first': query.pageSize, 'after': query.key},
));
if (result.hasException) throw result.exception!;
final edges = result.data!['users']['edges'] as List;
final pageInfo = result.data!['users']['pageInfo'];
return PageData<User, String?>(
items: edges.map((e) => User.fromJson(e['node'])).toList(),
query: query,
nextKey: pageInfo['hasNextPage'] ? pageInfo['endCursor'] : null,
status: PageStatus.success,
);
}
}
Supabase with Range Pagination #
class SupabaseDataSource implements DataSource<Todo, int> {
final SupabaseClient supabase;
final String table;
SupabaseDataSource(this.supabase, this.table);
@override
Future<PageData<Todo, int>> fetchPage(PageQuery<int> query) async {
final from = query.key;
final to = from + query.pageSize - 1;
final response = await supabase
.from(table)
.select()
.range(from, to)
.order('created_at', ascending: false);
final todos = (response as List).map((json) => Todo.fromJson(json)).toList();
return PageData<Todo, int>(
items: todos,
query: query,
nextKey: todos.length == query.pageSize ? to + 1 : null,
status: PageStatus.success,
);
}
}
Local SQLite with Offset Pagination #
class SQLiteDataSource implements DataSource<Note, int> {
final Database database;
SQLiteDataSource(this.database);
@override
Future<PageData<Note, int>> fetchPage(PageQuery<int> query) async {
final offset = query.key;
final results = await database.query(
'notes',
orderBy: 'created_at DESC',
limit: query.pageSize,
offset: offset,
);
final notes = results.map((row) => Note.fromMap(row)).toList();
return PageData<Note, int>(
items: notes,
query: query,
nextKey: notes.length == query.pageSize ? offset + query.pageSize : null,
status: PageStatus.success,
);
}
}
Architecture #
βββββββββββββββββββββββ
β UI Layer β
β LeveeBuilder / ββββββ ChangeNotifier updates
β CollectionView β
ββββββββββββ¬βββββββββββ
β
βΌ
βββββββββββββββββββββββ
β Paginator<T,K> β
β - Cache Policy β
β - Retry Logic β
β - State Management β
ββββββββ¬βββββββββββ¬ββββ
β β
βΌ βΌ
βββββββββββββββ ββββββββββββββββ
β CacheStore β β DataSource β
β <T,K> β β <T,K> β
βββββββββββββββ ββββββββββββββββ
Key Components:
Paginator<T,K>: Core engine managing cache, network, and stateDataSource<T,K>: Contract for fetching pages (implement for your backend)CacheStore<T,K>: Contract for caching (useMemoryCacheStoreor implement custom)PageData<T,K>: Immutable page representation with items and metadataPageQuery<K>: Query specification (key, size, filters, sorts)FilterQuery: Declarative filtering/sorting system
API Reference #
Core Classes #
Paginator<T, K>
class Paginator<T, K> extends ChangeNotifier {
Paginator({
required DataSource<T, K> source,
PageQuery<K> initialQuery,
CacheStore<T, K>? cache,
int pageSize = 20,
CachePolicy cachePolicy = CachePolicy.cacheFirst,
RetryPolicy? retryPolicy,
FilterQuery? initialFilter,
});
// State
PageState<T> get state;
// Actions
Future<void> loadInitial();
Future<void> loadNext();
Future<void> refresh({bool clearCache = true});
Future<void> updateFilter(FilterQuery? filter);
// List Mutations
void updateItem(T item, bool Function(T) predicate);
void removeItem(bool Function(T) predicate);
void insertItem(T item, {int position = 0});
void dispose();
}
DataSource<T, K>
abstract class DataSource<T, K> {
Future<PageData<T, K>> fetchPage(PageQuery<K> query);
}
CacheStore<T, K>
abstract class CacheStore<T, K> {
Future<PageData<T, K>?> get(PageQuery<K> query);
Future<void> put(PageData<T, K> page);
Future<void> remove(PageQuery<K> query);
Future<void> clear();
}
Data Structures #
PageData<T, K>
class PageData<T, K> {
final List<T> items;
final PageQuery<K> query;
final K? nextKey;
final PageStatus status;
final Object? error;
final DateTime? cachedAt;
}
PageQuery<K>
class PageQuery<K> {
final K key;
final int pageSize;
final FilterQuery? filters;
PageQuery({
required this.key,
required this.pageSize,
this.filters,
});
}
FilterQuery
class FilterQuery {
final List<FilterField> fields;
final List<SortField> sorts;
FilterQuery({
required this.fields,
this.sorts = const [],
});
}
Design Philosophy #
- Generic by Nature: Single generic
Kfor page keys supports any pagination scheme - Cache-First: Default to fast, offline-capable experiences
- Dependency-Free: Core logic has zero external dependencies
- Framework-Agnostic Core: Contracts can be implemented in non-Flutter contexts
- Deterministic Caching: Query parameters + filters = stable cache keys
- Fail-Safe: Retry logic and cache fallbacks prevent silent failures
- Developer Ergonomics: Simple APIs with escape hatches for complexity
Contributing #
Contributions welcome! Fork the repo, create a feature branch, add tests, ensure flutter test passes, and submit a PR.
License #
BSD-3-Clause License. Copyright (c) 2025 Circuids. See LICENSE for details.
Support #
- Issues: GitHub Issues
- Discussions: GitHub Discussions
Levee - Build pagination that scales from prototypes to production.