Skip to main content

bloc_paginated_view_builder 0.1.0 copy "bloc_paginated_view_builder: ^0.1.0" to clipboard
bloc_paginated_view_builder: ^0.1.0 copied to clipboard

A reusable BLoC-powered paginated list and grid with debounced search, refresh, and item updates.

example/lib/main.dart

import 'dart:async';
import 'dart:math' as math;

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:bloc_paginated_view_builder/bloc_paginated_view_builder.dart';

void main() => runApp(const ExampleApp());

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Paginated View Builder',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xff345b4c)),
        useMaterial3: true,
      ),
      home: const CatalogPage(),
    );
  }
}

class CatalogPage extends StatefulWidget {
  const CatalogPage({super.key});

  @override
  State<CatalogPage> createState() => _CatalogPageState();
}

class _CatalogPageState extends State<CatalogPage> {
  final _bloc = CatalogBloc();
  final _searchController = TextEditingController();
  final _refreshController = PaginatedListRefreshController();
  bool _isGrid = false;

  @override
  void dispose() {
    _searchController.dispose();
    unawaited(_bloc.close());
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Field notes'),
        actions: [
          IconButton(
            tooltip: 'Refresh the current search',
            onPressed: () async {
              await _refreshController.refresh();
            },
            icon: const Icon(Icons.refresh),
          ),
        ],
      ),
      body: Center(
        child: ConstrainedBox(
          constraints: const BoxConstraints(maxWidth: 900),
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                const Padding(
                  padding: EdgeInsets.symmetric(vertical: 12),
                  child: Text(
                    'Scroll for more, search the collection, or pull down to '
                    'refresh. All data is generated locally.',
                  ),
                ),
                TextField(
                  controller: _searchController,
                  decoration: InputDecoration(
                    labelText: 'Search notes',
                    hintText: 'Try “forest” or “12”',
                    prefixIcon: const Icon(Icons.search),
                    suffixIcon: IconButton(
                      tooltip: 'Clear search',
                      onPressed: _searchController.clear,
                      icon: const Icon(Icons.clear),
                    ),
                    border: const OutlineInputBorder(),
                  ),
                ),
                Padding(
                  padding: const EdgeInsets.symmetric(vertical: 16),
                  child: Align(
                    alignment: Alignment.centerRight,
                    child: SegmentedButton<bool>(
                      segments: const [
                        ButtonSegment(
                          value: false,
                          label: Text('List'),
                          icon: Icon(Icons.view_list_outlined),
                        ),
                        ButtonSegment(
                          value: true,
                          label: Text('Grid'),
                          icon: Icon(Icons.grid_view),
                        ),
                      ],
                      selected: {_isGrid},
                      onSelectionChanged: (selection) {
                        setState(() => _isGrid = selection.single);
                      },
                    ),
                  ),
                ),
                Expanded(
                  child: PaginatedViewBuilder<
                      CatalogItem,
                      CatalogBloc,
                      LoadCatalog,
                      CatalogState>(
                    bloc: _bloc,
                    loadEvent: (query, page) => LoadCatalog(query, page),
                    loadMoreEventBuilder: (query, page) =>
                        LoadCatalog(query, page),
                    onSearchChanged: (query, page) => LoadCatalog(query, page),
                    paginatedResponseSelector: (state) => state.response,
                    loadingSelector: (state) => state.isLoading,
                    errorSelector: (state) => state.error,
                    searchController: _searchController,
                    searchDebounceTime: const Duration(milliseconds: 350),
                    refreshController: _refreshController,
                    enablePullToRefresh: true,
                    isGrid: _isGrid,
                    gridDelegate:
                        const SliverGridDelegateWithMaxCrossAxisExtent(
                      maxCrossAxisExtent: 300,
                      mainAxisExtent: 154,
                      crossAxisSpacing: 12,
                      mainAxisSpacing: 12,
                    ),
                    padding: const EdgeInsets.only(bottom: 24),
                    separatorBuilder: (_, _) => const SizedBox(height: 8),
                    emptyWidget: const Center(
                      child: Text('No matching notes. Try another search.'),
                    ),
                    errorWidget: (error) => Center(child: Text(error)),
                    itemBuilder: (context, item, index, items) {
                      return Card(
                        margin: EdgeInsets.zero,
                        child: Padding(
                          padding: const EdgeInsets.all(16),
                          child: Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            mainAxisSize: MainAxisSize.min,
                            children: [
                              Text(
                                item.title,
                                maxLines: 1,
                                overflow: TextOverflow.ellipsis,
                                style: Theme.of(context).textTheme.titleMedium,
                              ),
                              const SizedBox(height: 8),
                              Text(
                                item.description,
                                maxLines: 2,
                                overflow: TextOverflow.ellipsis,
                              ),
                              const SizedBox(height: 8),
                              Text(
                                'Note ${item.id} · ${index + 1} of '
                                '${items.length} loaded',
                                style: Theme.of(context).textTheme.labelSmall,
                              ),
                            ],
                          ),
                        ),
                      );
                    },
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class CatalogItem {
  const CatalogItem({
    required this.id,
    required this.title,
    required this.description,
  });

  final int id;
  final String title;
  final String description;
}

class LoadCatalog {
  const LoadCatalog(this.query, this.page);

  final String? query;
  final int page;
}

class CatalogState {
  const CatalogState({this.isLoading = false, this.response, this.error});

  final bool isLoading;
  final PaginatedResponse<CatalogItem>? response;
  final String? error;
}

class CatalogBloc extends Bloc<LoadCatalog, CatalogState> {
  CatalogBloc() : super(const CatalogState()) {
    on<LoadCatalog>(_load);
  }

  static const _pageSize = 12;
  static const _categories = ['Forest', 'Coast', 'Mountain', 'Desert'];
  static final _catalog = List<CatalogItem>.generate(120, (index) {
    final category = _categories[index % _categories.length];
    return CatalogItem(
      id: index + 1,
      title: '$category journal ${index + 1}',
      description: 'Observations, routes, and small discoveries from the '
          '${category.toLowerCase()}.',
    );
  });

  int _requestVersion = 0;

  Future<void> _load(
    LoadCatalog event,
    Emitter<CatalogState> emit,
  ) async {
    // A later search or refresh invalidates any earlier in-flight request.
    final requestVersion = ++_requestVersion;
    emit(const CatalogState(isLoading: true));
    await Future<void>.delayed(const Duration(milliseconds: 450));
    if (emit.isDone || requestVersion != _requestVersion) {
      return;
    }

    final query = event.query?.trim().toLowerCase() ?? '';
    final matches = _catalog
        .where((item) => item.title.toLowerCase().contains(query))
        .toList();
    final totalPages = math.max(1, (matches.length / _pageSize).ceil());
    final items = matches
        .skip((event.page - 1) * _pageSize)
        .take(_pageSize)
        .toList();

    emit(
      CatalogState(
        response: PaginatedResponse<CatalogItem>(
          data: items,
          pagination: Pagination(
            currentPage: event.page,
            totalPages: totalPages,
          ),
        ),
      ),
    );
  }
}
2
likes
160
points
345
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A reusable BLoC-powered paginated list and grid with debounced search, refresh, and item updates.

Repository (GitHub)
View/report issues

License

Apache-2.0 (license)

Dependencies

flutter, flutter_bloc

More

Packages that depend on bloc_paginated_view_builder