levee 0.6.0 copy "levee: ^0.6.0" to clipboard
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 Logo

pub package License: BSD-3-Clause Flutter

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 #

  • Generic Page Keys (K): Use int, 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 FilterQuery system with 13+ operations
  • Deterministic Cache Keys: Query parameters + filters create stable cache identities
  • Headless & UI Modes: LeveeBuilder for custom UI, LeveeCollectionView for plug-and-play infinite scroll
  • State Management: Built on ChangeNotifier for seamless Flutter integration
  • TTL Support: Time-based cache expiration in MemoryCacheStore
  • Type-Safe: Full generic support with PageData<T,K> and DataSource<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 retryIf to 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, notEquals
  • greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual
  • isIn, isNotIn
  • arrayContains, arrayContainsAny
  • isNull, isNotNull
  • like
  • custom(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 state
  • DataSource<T,K>: Contract for fetching pages (implement for your backend)
  • CacheStore<T,K>: Contract for caching (use MemoryCacheStore or implement custom)
  • PageData<T,K>: Immutable page representation with items and metadata
  • PageQuery<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 #

  1. Generic by Nature: Single generic K for page keys supports any pagination scheme
  2. Cache-First: Default to fast, offline-capable experiences
  3. Dependency-Free: Core logic has zero external dependencies
  4. Framework-Agnostic Core: Contracts can be implemented in non-Flutter contexts
  5. Deterministic Caching: Query parameters + filters = stable cache keys
  6. Fail-Safe: Retry logic and cache fallbacks prevent silent failures
  7. 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 #


Levee - Build pagination that scales from prototypes to production.

0
likes
160
points
121
downloads

Publisher

verified publishercircuids.com

Weekly Downloads

A lean, backend-agnostic pagination engine for Flutter with cache-first support, filtering, sorting, and retry policies.

Repository (GitHub)
View/report issues

Documentation

API reference

License

BSD-3-Clause (license)

Dependencies

collection, flutter

More

Packages that depend on levee