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.

example/lib/main.dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:levee/levee.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Levee Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const PostsListScreen(),
    );
  }
}

// Model
class Post {
  final int id;
  final int userId;
  final String title;
  final String body;

  Post({
    required this.id,
    required this.userId,
    required this.title,
    required this.body,
  });

  factory Post.fromJson(Map<String, dynamic> json) {
    return Post(
      id: json['id'] as int,
      userId: json['userId'] as int,
      title: json['title'] as String,
      body: json['body'] as String,
    );
  }

  Post copyWith({int? id, int? userId, String? title, String? body}) {
    return Post(
      id: id ?? this.id,
      userId: userId ?? this.userId,
      title: title ?? this.title,
      body: body ?? this.body,
    );
  }
}

// DataSource implementation for JSONPlaceholder API
class PostsDataSource implements DataSource<Post, int> {
  final http.Client client;

  PostsDataSource(this.client);

  @override
  Future<PageData<Post, int>> fetch(PageQuery<int> query) async {
    final page = query.pageKey ?? 1;
    final url = Uri.parse(
      'https://jsonplaceholder.typicode.com/posts?_page=$page&_limit=${query.pageSize}',
    );

    final response = await client.get(url);

    if (response.statusCode != 200) {
      throw Exception('Failed to load posts');
    }

    final List<dynamic> jsonList = json.decode(response.body);
    final posts = jsonList.map((json) => Post.fromJson(json)).toList();

    // JSONPlaceholder has 100 posts total
    final hasMore = page * query.pageSize < 100;

    return PageData<Post, int>(
      items: posts,
      nextPageKey: hasMore ? page + 1 : null,
      isLastPage: !hasMore,
    );
  }
}

// Main screen demonstrating Levee pagination
class PostsListScreen extends StatefulWidget {
  const PostsListScreen({super.key});

  @override
  State<PostsListScreen> createState() => _PostsListScreenState();
}

class _PostsListScreenState extends State<PostsListScreen> {
  late final Paginator<Post, int> _paginator;

  @override
  void initState() {
    super.initState();
    _paginator = Paginator<Post, int>(
      source: PostsDataSource(http.Client()),
      cache: MemoryCacheStore<Post, int>(),
      pageSize: 10,
      cachePolicy: CachePolicy.cacheFirst,
      retryPolicy: RetryPolicy(maxAttempts: 3),
    );
    _paginator.loadInitial();
  }

  @override
  void dispose() {
    _paginator.dispose();
    super.dispose();
  }

  void _addNewPost() {
    // Simulate creating a new post
    final newPost = Post(
      id: DateTime.now().millisecondsSinceEpoch,
      userId: 1,
      title: 'New Post',
      body: 'This is a new post created at ${DateTime.now()}',
    );

    // Add to top of list using mutation
    _paginator.insertItem(newPost, position: 0);

    ScaffoldMessenger.of(
      context,
    ).showSnackBar(const SnackBar(content: Text('Post added!')));
  }

  void _updatePost(Post post) {
    // Simulate updating a post
    final updatedPost = post.copyWith(title: '${post.title} (Updated)');

    // Update in list using mutation
    _paginator.updateItem(updatedPost, (p) => p.id == post.id);

    ScaffoldMessenger.of(
      context,
    ).showSnackBar(const SnackBar(content: Text('Post updated!')));
  }

  void _deletePost(Post post) {
    // Remove from list using mutation
    _paginator.removeItem((p) => p.id == post.id);

    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: const Text('Post deleted!'),
        action: SnackBarAction(
          label: 'Undo',
          onPressed: () {
            // Re-insert the post
            _paginator.insertItem(post, position: 0);
          },
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Levee Example - Posts'),
        actions: [
          IconButton(
            icon: const Icon(Icons.refresh),
            onPressed: () => _paginator.refresh(),
            tooltip: 'Refresh',
          ),
        ],
      ),
      body: LeveeBuilder<Post, int>(
        paginator: _paginator,
        builder: (context, state) {
          // Loading first page
          if (state.status == PageStatus.loading && state.items.isEmpty) {
            return const Center(child: CircularProgressIndicator());
          }

          // Error with no cached data
          if (state.status == PageStatus.error && state.items.isEmpty) {
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  const Icon(Icons.error_outline, size: 48, color: Colors.red),
                  const SizedBox(height: 16),
                  Text('Error: ${state.error}'),
                  const SizedBox(height: 16),
                  ElevatedButton(
                    onPressed: () => _paginator.loadInitial(),
                    child: const Text('Retry'),
                  ),
                ],
              ),
            );
          }

          // Empty state
          if (state.items.isEmpty) {
            return const Center(child: Text('No posts found'));
          }

          // List with items
          return RefreshIndicator(
            onRefresh: () => _paginator.refresh(),
            child: ListView.builder(
              itemCount: state.items.length + (state.hasMore ? 1 : 0),
              itemBuilder: (context, index) {
                // Loading indicator at bottom
                if (index == state.items.length) {
                  // Trigger next page load
                  if (state.status != PageStatus.loading) {
                    _paginator.loadNext();
                  }
                  return const Padding(
                    padding: EdgeInsets.all(16.0),
                    child: Center(child: CircularProgressIndicator()),
                  );
                }

                final post = state.items[index];
                return PostCard(
                  post: post,
                  onUpdate: () => _updatePost(post),
                  onDelete: () => _deletePost(post),
                );
              },
            ),
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _addNewPost,
        tooltip: 'Add Post',
        child: const Icon(Icons.add),
      ),
    );
  }
}

class PostCard extends StatelessWidget {
  final Post post;
  final VoidCallback onUpdate;
  final VoidCallback onDelete;

  const PostCard({
    super.key,
    required this.post,
    required this.onUpdate,
    required this.onDelete,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Row(
              children: [
                CircleAvatar(child: Text('${post.userId}')),
                const SizedBox(width: 12),
                Expanded(
                  child: Text(
                    post.title,
                    style: Theme.of(context).textTheme.titleMedium?.copyWith(
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
                PopupMenuButton(
                  itemBuilder: (context) => [
                    const PopupMenuItem(
                      value: 'update',
                      child: Row(
                        children: [
                          Icon(Icons.edit, size: 20),
                          SizedBox(width: 8),
                          Text('Update'),
                        ],
                      ),
                    ),
                    const PopupMenuItem(
                      value: 'delete',
                      child: Row(
                        children: [
                          Icon(Icons.delete, size: 20, color: Colors.red),
                          SizedBox(width: 8),
                          Text('Delete', style: TextStyle(color: Colors.red)),
                        ],
                      ),
                    ),
                  ],
                  onSelected: (value) {
                    if (value == 'update') {
                      onUpdate();
                    } else if (value == 'delete') {
                      onDelete();
                    }
                  },
                ),
              ],
            ),
            const SizedBox(height: 8),
            Text(post.body, style: Theme.of(context).textTheme.bodyMedium),
            const SizedBox(height: 8),
            Text(
              'Post ID: ${post.id}',
              style: Theme.of(
                context,
              ).textTheme.bodySmall?.copyWith(color: Colors.grey),
            ),
          ],
        ),
      ),
    );
  }
}
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