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.
- Features
- Quick Start
- Cache Policies
- Retry Logic
- Filtering & Sorting
- DataSource Examples
- Architecture
- API Reference
- Design Philosophy
- Contributing
- License
- Support
- 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>
dependencies:
levee: ^0.6.0import '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(),
};
}
}final paginator = Paginator<User, int>(
source: UserDataSource('https://api.example.com'),
cache: MemoryCacheStore<User, int>(),
pageSize: 20,
cachePolicy: CachePolicy.cacheFirst,
retryPolicy: RetryPolicy(maxAttempts: 3),
);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'),
],
),
),
)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);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
Update the paginated list instantly without refetching from the backend. Perfect for Firestore or when you already have the updated data in hand.
Update an existing item in the list:
// After updating Firestore
await postDoc.update({'likes': likes + 1});
paginator.updateItem(
post.copyWith(likes: likes + 1),
(p) => p.id == post.id,
);
// UI updates instantly, no network call neededRemove an item from the list:
// After deleting from Firestore
await postDoc.delete();
paginator.removeItem((post) => post.id == deletedPostId);
// Item disappears from UI immediatelyInsert a new item into the list:
// After creating in Firestore
final newPost = await postsCollection.add(postData);
paginator.insertItem(
Post.fromFirestore(newPost),
position: 0, // Add to top (default)
);
// New item appears instantlyWhy use mutations?
- ⚡ Instant UI updates - No waiting for network calls
- 💰 Save money - Avoid expensive Firestore reads after mutations
- 🎯 Better UX - Immediate feedback for user actions
- 🧠 Smart - You already have the data after create/update/delete
Note: These methods only update the local list. They don't sync with the backend—you should call them after your backend operation succeeds.
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
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)Levee's DataSource interface is simple yet powerful—implement one method to connect any backend. Here are production-ready examples:
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,
);
}
}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,
);
}
}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,
);
}
}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,
);
}
}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,
);
}
}┌─────────────────────┐
│ 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
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();
}abstract class DataSource<T, K> {
Future<PageData<T, K>> fetchPage(PageQuery<K> query);
}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();
}class PageData<T, K> {
final List<T> items;
final PageQuery<K> query;
final K? nextKey;
final PageStatus status;
final Object? error;
final DateTime? cachedAt;
}class PageQuery<K> {
final K key;
final int pageSize;
final FilterQuery? filters;
PageQuery({
required this.key,
required this.pageSize,
this.filters,
});
}class FilterQuery {
final List<FilterField> fields;
final List<SortField> sorts;
FilterQuery({
required this.fields,
this.sorts = const [],
});
}- 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
Contributions welcome! Fork the repo, create a feature branch, add tests, ensure flutter test passes, and submit a PR.
BSD-3-Clause License. Copyright (c) 2025 Circuids. See LICENSE for details.
- Issues: GitHub Issues
- Discussions: GitHub Discussions
Levee - Build pagination that scales from prototypes to production.
