scrolled_pagination 1.6.0 copy "scrolled_pagination: ^1.6.0" to clipboard
scrolled_pagination: ^1.6.0 copied to clipboard

A reusable, MVC-style infinite-scroll pagination toolkit for Flutter — one controller, many providers (Future/Stream/sync), page or cursor based, with eight builders, hasReachedEnd, optional static le [...]

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:scrolled_pagination/scrolled_pagination.dart';

import 'chrome.dart';
import 'code_reference.dart';
import 'data.dart';
import 'frames.dart';
import 'theme.dart';
import 'widgets.dart';

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

class DemoApp extends StatelessWidget {
  const DemoApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'scrolled_pagination',
      debugShowCheckedModeBanner: false,
      theme: CA.theme(),
      home: const Workbench(),
    );
  }
}

class Workbench extends StatefulWidget {
  const Workbench({super.key});
  @override
  State<Workbench> createState() => _WorkbenchState();
}

class _WorkbenchState extends State<Workbench> {
  String dataset = 'feed';
  String mode = 'page';
  String kind = 'future';
  String layout = 'list';
  String sortMode = 'none';
  bool emptyOn = false;
  bool reachEndOn = true;
  bool fabsOn = true;
  String lastEvent = '—';

  SortManager<Object>? _sort;

  // Stable GlobalKeys so the preview views keep their scroll position + state
  // even when they are reparented across breakpoints while the window resizes.
  final GlobalKey _phoneViewKey = GlobalKey();
  final GlobalKey _browserViewKey = GlobalKey();

  late PaginationController<Object> _controller = _make();

  int _keyOf(Object o) => o is Post ? o.id : (o as Member).id;

  PaginationController<Object> _make() {
    _sort = switch (sortMode) {
      'asc' => SortManager<Object>.byIndex(_keyOf, enabled: true, order: SortOrder.ascending),
      'desc' => SortManager<Object>.byIndex(_keyOf, enabled: true, order: SortOrder.descending),
      _ => null,
    };
    return PaginationController<Object>(
      pageSize: 12,
      initialPageParam: mode == 'cursor' ? null : 1,
      getKey: _keyOf,
      openFocusManager: true,
      sortManager: _sort,
      provider: makeProvider(dataset: dataset, mode: mode, kind: kind),
      // Reach-end is inferred from the page's hasMore flag / a short page.
      // To override, pass: detectReachEnd: (page, items) => page.items.isEmpty
      onItemInserted: (i, _) => _log('onItemInserted #$i'),
      onItemUpdated: (i, _) => _log('onItemUpdated #$i'),
      onItemDeleted: (i, _) => _log('onItemDeleted #$i'),
      onItemReplaced: (i, _) => _log('onItemReplaced #$i'),
      onItemMoved: (a, b) => _log('onItemMoved $a→$b'),
      onFocusChanged: (i, _) => _log('onFocusChanged #$i'),
      onJumbFailed: (f) => _log('onJumbFailed: ${f.reason}'),
      onIndexOutOfRange: (i) => _log('onIndexOutOfRange #$i'),
    );
  }

  void _log(String e) => setState(() => lastEvent = e);

  void _reconfigure() {
    final old = _controller;
    _controller = _make();
    setState(() {});
    WidgetsBinding.instance.addPostFrameCallback((_) => old.dispose());
  }

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

  Object? get _first => _controller.items.isEmpty ? null : _controller.items.first;

  Object _synthetic({String? title}) {
    final id = DateTime.now().microsecondsSinceEpoch;
    if (dataset == 'members') {
      return Member(id: id, name: 'New Member', role: 'Member', club: 'Madina IT', online: true, tasks: 0, since: 2026);
    }
    return Post(
      id: id,
      kind: 'news',
      club: 'Madina IT',
      author: 'You',
      when: 'Just now',
      title: title ?? 'New post added via controller',
      excerpt: 'This item was inserted without rebuilding the rest of the list.',
      likes: 0,
      comments: 0,
    );
  }

  PaginationLayout get _layout => switch (layout) {
        'grid' => PaginationLayout.grid,
        'separated' => PaginationLayout.separated,
        _ => PaginationLayout.list,
      };
  bool get _reverse => layout == 'reverse';
  bool get _grid => layout == 'grid';

  // --- item builders ----------------------------------------------------------
  Widget _phoneItem(BuildContext context, Object item, int index) {
    if (item is Member) return _grid ? MemberCardW(member: item) : MemberRowW(member: item);
    final p = item as Post;
    return _grid ? FeedCardV(post: p) : FeedCardH(post: p);
  }

  Widget _browserItem(BuildContext context, Object item, int index) {
    if (item is Member) return _grid ? MemberCardW(member: item) : MemberRowW(member: item);
    final p = item as Post;
    return _grid ? FeedCardV(post: p) : FeedRowWide(post: p);
  }

  // --- shared builder slots ---------------------------------------------------
  Widget _firstLoading(BuildContext _) => SkeletonList(grid: _grid);
  Widget _empty(BuildContext _, Future<void> Function() refresh) => _Empty(onRefresh: refresh, dataset: dataset);
  Widget _error(BuildContext _, Object error, Future<void> Function() retry) => _Error(error: error, onRetry: retry);

  PaginatedScrollView<Object> _view({
    required Key key,
    required PaginatedItemBuilder<Object> itemBuilder,
    required EdgeInsets padding,
    required double gap,
    required int crossAxisCount,
    required double childAspectRatio,
    required bool pullToRefresh,
  }) {
    return PaginatedScrollView<Object>(
      key: key,
      controller: _controller,
      layout: _layout,
      reverse: _reverse,
      loadMoreThreshold: 280,
      padding: padding,
      gap: gap,
      crossAxisCount: crossAxisCount,
      childAspectRatio: childAspectRatio,
      pullToRefresh: pullToRefresh,
      itemBuilder: itemBuilder,
      separatorBuilder: (_, __) => const Divider(height: 20, color: CA.ink100),
      firstPageLoadingBuilder: _firstLoading,
      loadMoreBuilder: (_) => const _LoadMore(),
      // reachEndBuilder (optional) wins over noMoreBuilder when toggled on;
      // pass null and nothing renders at the end.
      reachEndBuilder: reachEndOn ? (_) => const _ReachEnd() : null,
      noMoreBuilder: (_) => const _NoMore(),
      emptyBuilder: _empty,
      errorBuilder: _error,
      // Optional scroll-navigation FABs — independent of the item list.
      // Auto-hide at each edge; tap to animate to the first / last item.
      floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
      scrollToFirstFloatingButton: fabsOn
          ? const ScrollEdgeFab(
              type: ScrollFabType.small,
              tooltip: 'Back to top',
              icon: Icon(Icons.arrow_upward_rounded),
              backgroundColor: CA.violet,
              foregroundColor: Colors.white,
            )
          : null,
      scrollToLastFloatingButton: fabsOn
          ? const ScrollEdgeFab(
              type: ScrollFabType.small,
              tooltip: 'Jump to end',
              icon: Icon(Icons.arrow_downward_rounded),
              backgroundColor: CA.violet,
              foregroundColor: Colors.white,
            )
          : null,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: CA.canvas,
      body: SafeArea(
        child: LayoutBuilder(
          builder: (context, constraints) {
            final size = screenSizeOf(constraints.maxWidth);
            final gutter = size == ScreenSize.mobile ? 16.0 : 24.0;
            return SingleChildScrollView(
              padding: EdgeInsets.fromLTRB(gutter, 18, gutter, 32),
              child: _body(size),
            );
          },
        ),
      ),
    );
  }

  // Mobile: single column (collapsible controls above the preview).
  // Tablet / desktop: two columns (a controls rail + the preview).
  Widget _body(ScreenSize size) {
    final frameH = (MediaQuery.of(context).size.height * 0.72).clamp(440.0, 760.0);
    final preview = _preview(size, frameH);

    if (size == ScreenSize.mobile) {
      return Column(
        crossAxisAlignment: CrossAxisAlignment.stretch,
        children: [
          _header(size),
          const SizedBox(height: 16),
          _controlsPanel(size),
          const SizedBox(height: 16),
          preview,
          const SizedBox(height: 16),
          const StatesLegend(),
        ],
      );
    }

    final railWidth = size == ScreenSize.desktop ? 340.0 : 300.0;
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        _header(size),
        const SizedBox(height: 16),
        Row(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            SizedBox(width: railWidth, child: _controlsPanel(size)),
            const SizedBox(width: 20),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.stretch,
                children: [
                  preview,
                  const SizedBox(height: 16),
                  const StatesLegend(),
                ],
              ),
            ),
          ],
        ),
      ],
    );
  }

  // --- header -----------------------------------------------------------------
  Widget _header(ScreenSize size) {
    final brand = Row(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Container(
          width: 46,
          height: 46,
          decoration: BoxDecoration(
            borderRadius: BorderRadius.circular(13),
            gradient: const LinearGradient(
                colors: [CA.violet, CA.violet700],
                begin: Alignment.topLeft,
                end: Alignment.bottomRight),
          ),
          alignment: Alignment.center,
          child: const Text('SP', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 18)),
        ),
        const SizedBox(width: 14),
        const Expanded(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('Scrolled Pagination',
                  style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: CA.ink900)),
              SizedBox(height: 2),
              Text('One controller · many providers · eight builders — a reusable infinite-scroll component for ClubApp',
                  style: TextStyle(fontSize: 13, color: CA.ink500)),
            ],
          ),
        ),
      ],
    );
    final status = StatusPill(controller: _controller, lastEvent: lastEvent);
    if (size == ScreenSize.desktop) {
      return Row(
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [Expanded(child: brand), const SizedBox(width: 24), Flexible(child: status)],
      );
    }
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [brand, const SizedBox(height: 14), status],
    );
  }

  // --- controls: configuration + grouped, collapsible action sections ---------
  Widget _controlsPanel(ScreenSize size) {
    Widget gap() => const SizedBox(height: 12);
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        // primary actions — always visible
        Container(
          decoration: BoxDecoration(
            color: CA.surface,
            borderRadius: BorderRadius.circular(16),
            boxShadow: CA.cardShadow,
          ),
          padding: const EdgeInsets.all(12),
          child: ActionGrid(children: [
            ActionButton('Refresh', tone: ActTone.accent, icon: Icons.refresh, onTap: _controller.refresh),
            ActionButton('Load more', onTap: _controller.loadMore),
            ActionButton('+ Prepend', onTap: () => _controller.addItem(_synthetic(), prepend: true)),
            ActionButton('Clear', onTap: _controller.clear),
          ]),
        ),
        gap(),
        CollapsibleCard(
          title: 'Configuration',
          initiallyOpen: true,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Segmented<String>(
                label: 'Data source',
                value: dataset,
                options: const [(value: 'feed', label: 'Feed posts'), (value: 'members', label: 'Members')],
                onChanged: (v) { dataset = v; _reconfigure(); },
              ),
              const SizedBox(height: 14),
              Segmented<String>(
                label: 'Pagination',
                value: mode,
                options: const [(value: 'page', label: 'Page-based'), (value: 'cursor', label: 'Cursor-based')],
                onChanged: (v) { mode = v; _reconfigure(); },
              ),
              const SizedBox(height: 14),
              Segmented<String>(
                label: 'Provider',
                value: kind,
                options: const [(value: 'future', label: 'Future'), (value: 'stream', label: 'Stream'), (value: 'sync', label: 'Sync')],
                onChanged: (v) { kind = v; _reconfigure(); },
              ),
              const SizedBox(height: 14),
              Segmented<String>(
                label: 'Layout',
                value: layout,
                options: const [(value: 'list', label: 'List'), (value: 'grid', label: 'Grid'), (value: 'separated', label: 'Separated'), (value: 'reverse', label: 'Reverse')],
                onChanged: (v) => setState(() => layout = v),
              ),
              const SizedBox(height: 14),
              Segmented<String>(
                label: 'Sort',
                value: sortMode,
                options: const [(value: 'none', label: 'Insertion'), (value: 'asc', label: 'Id ↑'), (value: 'desc', label: 'Id ↓')],
                onChanged: (v) { sortMode = v; _reconfigure(); },
              ),
            ],
          ),
        ),
        gap(),
        CollapsibleCard(title: 'Add & insert', child: ActionGrid(children: [
          ActionButton('+ Append', onTap: () => _controller.addItem(_synthetic())),
          ActionButton('insertItem @2', onTap: () => _controller.insertItem(2, _synthetic())),
          ActionButton('insertItems ×3 @0', onTap: () => _controller.insertItems(0, [_synthetic(), _synthetic(), _synthetic()])),
        ])),
        gap(),
        CollapsibleCard(title: 'Update', child: ActionGrid(children: [
          ActionButton('updateItem first', onTap: _updateFirst),
          ActionButton('updateWhere', onTap: _updateWhere),
          ActionButton('updateFirstWhere', onTap: _updateFirstWhere),
          ActionButton('updateAll', onTap: _updateAll),
        ])),
        gap(),
        CollapsibleCard(title: 'Replace', child: ActionGrid(children: [
          ActionButton('replaceAt @1', onTap: () {
            if (_controller.count > 1) _controller.replaceAt(1, _synthetic(title: 'Replaced via replaceAt(1)'));
          }),
          ActionButton('replaceFirstWhere', onTap: _replaceFirstWhere),
          ActionButton('replaceWhere', onTap: _replaceWhere),
          ActionButton('replaceAll (shuffle)', onTap: () {
            final next = List<Object>.of(_controller.items)..shuffle();
            _controller.replaceAll(next);
          }),
        ])),
        gap(),
        CollapsibleCard(title: 'Remove', child: ActionGrid(children: [
          ActionButton('– Remove first', tone: ActTone.danger, onTap: () {
            final f = _first;
            if (f != null) _controller.removeItem(_keyOf(f));
          }),
          ActionButton('deleteWhere', tone: ActTone.danger, onTap: _deleteWhere),
          ActionButton('deleteFirstWhere', tone: ActTone.danger, onTap: _deleteFirstWhere),
        ])),
        gap(),
        CollapsibleCard(title: 'Reorder', child: ActionGrid(children: [
          ActionButton('moveItem 0→5', onTap: () => _controller.moveItem(0, 5)),
          ActionButton('moveToFirst 4', onTap: () => _controller.moveToFirst(4)),
          ActionButton('moveToLast 0', onTap: () => _controller.moveToLast(0)),
          ActionButton('swapItems 0↔3', onTap: () => _controller.swapItems(0, 3)),
        ])),
        gap(),
        CollapsibleCard(title: 'Batch', hint: 'one notify each', child: ActionGrid(children: [
          ActionButton('insertItemsBatch', onTap: () =>
              _controller.insertItemsBatch([BatchInsert<Object>(0, _synthetic()), BatchInsert<Object>(5, _synthetic())])),
          ActionButton('replaceItemsBatch', onTap: _replaceBatch),
          ActionButton('deleteItemsBatch', tone: ActTone.danger, onTap: _deleteBatch),
          ActionButton('moveItemsBatch', onTap: () => _controller.moveItemsBatch(const [BatchMove(0, 5), BatchMove(2, 0)])),
          ActionButton('transaction', tone: ActTone.accent, onTap: _txDemo),
        ])),
        gap(),
        CollapsibleCard(title: 'Sort', child: ActionGrid(children: [
          ActionButton('Flip order + resort', icon: Icons.swap_vert, onTap: _flipSort),
        ])),
        gap(),
        CollapsibleCard(title: 'Select', child: ActionGrid(children: [
          ActionButton('selectAll', onTap: _controller.selectAll),
          ActionButton('toggle first 3', onTap: () {
            for (final o in _controller.items.take(3)) {
              _controller.toggleSelected(_keyOf(o));
            }
            _log('selected ${_controller.selectedKeys.length}');
          }),
          ActionButton('clearSelection', onTap: _controller.clearSelection),
        ])),
        gap(),
        CollapsibleCard(title: 'Scroll', child: ActionGrid(children: [
          ActionButton('jumbToFirst', onTap: () => _controller.jumbToFirst()),
          ActionButton('jumbToLast', onTap: () => _controller.jumbToLast()),
          ActionButton('jumbTo 20', onTap: () => _controller.jumbTo(20)),
          ActionButton('jumbWhere last', onTap: _jumbWhereLast),
          ActionButton('animateToFirst', onTap: () => _controller.animateToFirst()),
          ActionButton('animateToLast', onTap: () => _controller.animateToLast()),
          ActionButton('animateTo 12', tone: ActTone.accent, onTap: () => _controller.animateTo(12, options: const ScrollOptions(alignment: 0.5))),
          ActionButton('moveTo 30', onTap: () => _controller.moveTo(30)),
          ActionButton('ensureVisibleAt 8', onTap: () => _controller.ensureVisibleAt(8, options: const ScrollOptions(alignment: 0.5))),
          ActionButton('ensureVisibleWhere', onTap: _ensureVisibleWhere),
        ])),
        gap(),
        CollapsibleCard(title: 'Focus', child: ActionGrid(children: [
          ActionButton('focusFirst', onTap: () => _controller.focusFirst()),
          ActionButton('focusLast', onTap: () => _controller.focusLast()),
          ActionButton('focusAt 3', onTap: () => _controller.focusAt(3)),
          ActionButton('focusWhere', onTap: _focusWhere),
          ActionButton('‹ focusPrev', onTap: () => _controller.focusPrevious()),
          ActionButton('focusNext ›', tone: ActTone.accent, onTap: () => _controller.focusNext()),
          ActionButton('clearFocus', onTap: _controller.clearFocus),
        ])),
        gap(),
        CollapsibleCard(title: 'States & simulation', child: ActionGrid(children: [
          ActionButton('Retry', onTap: _controller.retry),
          ActionButton('Force error', tone: ActTone.danger, onTap: () {
            Sim.failRate = 1;
            _controller.refresh().whenComplete(() => Sim.failRate = 0);
          }),
          ActionButton(emptyOn ? 'Empty: on' : 'Empty state', tone: emptyOn ? ActTone.danger : ActTone.normal, onTap: () {
            setState(() => emptyOn = !emptyOn);
            Sim.emptyMode = emptyOn;
            _controller.refresh();
          }),
          ActionButton(reachEndOn ? 'reachEndBuilder: on' : 'reachEndBuilder: off',
              tone: reachEndOn ? ActTone.accent : ActTone.normal,
              onTap: () => setState(() => reachEndOn = !reachEndOn)),
          ActionButton(fabsOn ? 'Scroll FABs: on' : 'Scroll FABs: off',
              tone: fabsOn ? ActTone.accent : ActTone.normal,
              onTap: () => setState(() => fabsOn = !fabsOn)),
        ])),
      ],
    );
  }

  // --- action rows ------------------------------------------------------------
  Object? _at(int i) => (i >= 0 && i < _controller.count) ? _controller.items[i] : null;

  // --- preview: phone + browser frames + code reference -----------------------
  Widget _preview(ScreenSize size, double frameH) {
    final feed = dataset != 'members';
    final phoneTitle = feed ? 'News & articles' : 'Members';
    final phoneSub = '${mode == 'cursor' ? 'cursor-based' : 'page-based'} · $kind';
    final phoneView = _view(
      key: _phoneViewKey,
      itemBuilder: _phoneItem,
      padding: const EdgeInsets.all(14),
      gap: 12,
      crossAxisCount: 2,
      childAspectRatio: feed ? 0.74 : 0.85,
      pullToRefresh: true,
    );
    final browserView = _view(
      key: _browserViewKey,
      itemBuilder: _browserItem,
      padding: feed && size != ScreenSize.mobile
          ? const EdgeInsets.symmetric(horizontal: 20)
          : const EdgeInsets.all(16),
      gap: feed ? 0 : 12,
      crossAxisCount: size == ScreenSize.desktop ? 3 : 2,
      childAspectRatio: feed ? 0.9 : 1.0,
      // pull-to-refresh on touch surfaces (mobile/tablet); off on desktop mouse
      pullToRefresh: size != ScreenSize.desktop,
    );
    final codeH = (MediaQuery.of(context).size.height * 0.3).clamp(200.0, 300.0);
    return Column(
      crossAxisAlignment: CrossAxisAlignment.stretch,
      children: [
        PreviewArea(
          size: size,
          title: phoneTitle,
          subtitle: phoneSub,
          url: 'app.clubapp.com / ${feed ? 'feed' : 'members'}',
          frameHeight: frameH,
          phoneChild: phoneView,
          browserChild: browserView,
        ),
        const SizedBox(height: 18),
        CodeReferencePanel(height: codeH),
      ],
    );
  }

  void _updateFirst() {
    final f = _first;
    if (f is Post) {
      _controller.updateItem(f.id, (o) {
        final p = o as Post;
        return p.copyWith(liked: !p.liked, likes: p.liked ? p.likes - 1 : p.likes + 1);
      });
    } else if (f is Member) {
      _controller.updateItem(f.id, (o) {
        final m = o as Member;
        return m.copyWith(online: !m.online);
      });
    }
  }

  void _updateFirstWhere() {
    if (dataset == 'members') {
      _controller.updateFirstWhere(
        (o) => o is Member,
        (o) {
          final m = o as Member;
          return m.copyWith(online: !m.online);
        },
      );
    } else {
      _controller.updateFirstWhere(
        (o) => o is Post,
        (o) {
          final p = o as Post;
          return p.copyWith(liked: !p.liked, likes: p.liked ? p.likes - 1 : p.likes + 1);
        },
      );
    }
  }

  void _updateAll() {
    if (dataset == 'members') {
      _controller.updateAll((o) => (o as Member).copyWith(online: true));
    } else {
      _controller.updateAll((o) {
        final p = o as Post;
        return p.copyWith(liked: true);
      });
    }
  }

  void _updateWhere() {
    if (dataset == 'members') {
      _controller.updateWhere(
        (o) => o is Member && o.role == 'Member',
        (o) {
          final m = o as Member;
          return m.copyWith(role: 'Moderator');
        },
      );
    } else {
      _controller.updateWhere(
        (o) => o is Post && o.club == 'Madina IT',
        (o) {
          final p = o as Post;
          return p.copyWith(liked: true, likes: p.likes + 1);
        },
      );
    }
  }

  void _replaceFirstWhere() {
    final f = _first;
    if (f == null) return;
    _controller.replaceFirstWhere(
      (o) => _keyOf(o) == _keyOf(f),
      _synthetic(title: 'Replaced via replaceFirstWhere'),
    );
  }

  void _replaceWhere() {
    if (dataset == 'members') {
      _controller.replaceWhere(
        (o) => o is Member && o.role == 'Guest',
        (o) => (o as Member).copyWith(role: 'Member'),
      );
    } else {
      _controller.replaceWhere(
        (o) => o is Post && o.kind == 'article',
        (o) => (o as Post).copyWith(title: 'Re-published article'),
      );
    }
  }

  void _deleteWhere() {
    if (dataset == 'members') {
      _controller.deleteWhere((o) => o is Member && !o.online);
    } else {
      _controller.deleteWhere((o) => o is Post && o.kind == 'article');
    }
  }

  void _deleteFirstWhere() {
    if (dataset == 'members') {
      _controller.deleteFirstWhere((o) => o is Member && !o.online);
    } else {
      _controller.deleteFirstWhere((o) => o is Post && o.kind == 'article');
    }
  }

  void _replaceBatch() {
    final ks = _controller.items.take(2).map(_keyOf).toList();
    if (ks.length < 2) return;
    _controller.replaceItemsBatch([
      BatchReplace<Object>(ks[0], _synthetic(title: 'Batch-replaced #1')),
      BatchReplace<Object>(ks[1], _synthetic(title: 'Batch-replaced #2')),
    ]);
  }

  void _deleteBatch() {
    final ks = _controller.items.take(3).map(_keyOf).toList();
    if (ks.isNotEmpty) _controller.deleteItemsBatch(ks);
  }

  void _txDemo() {
    _controller.transaction(() {
      _controller.addItem(_synthetic(title: 'Added in a transaction'), prepend: true);
      final fifth = _at(4);
      if (fifth != null) _controller.removeItem(_keyOf(fifth));
      _controller.moveItem(0, 2);
    });
    _log('transaction → one notify');
  }

  void _flipSort() {
    final s = _sort;
    if (s == null) {
      _log('enable Sort first');
      return;
    }
    s.order = s.order == SortOrder.ascending ? SortOrder.descending : SortOrder.ascending;
    _controller.resort();
    _log('resort → ${s.order.name}');
  }

  void _jumbWhereLast() {
    if (_controller.items.isEmpty) return;
    final last = _controller.items.last;
    _controller.jumbWhere((o) => _keyOf(o) == _keyOf(last));
  }

  void _ensureVisibleWhere() {
    final target = _at(10);
    if (target == null) return;
    _controller.ensureVisibleWhere((o) => _keyOf(o) == _keyOf(target));
  }

  void _focusWhere() {
    final target = _at(5);
    if (target == null) return;
    _controller.focusWhere((o) => _keyOf(o) == _keyOf(target));
  }
}

// ---------------------------------------------------------------------------
// Footer / empty / error builders (ClubApp-styled)
// ---------------------------------------------------------------------------
class _LoadMore extends StatelessWidget {
  const _LoadMore();
  @override
  Widget build(BuildContext context) => const Padding(
        padding: EdgeInsets.all(18),
        child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
          SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2.4, color: CA.violet)),
          SizedBox(width: 10),
          Text('Loading more…', style: TextStyle(fontSize: 12, color: CA.ink500)),
        ]),
      );
}

class _NoMore extends StatelessWidget {
  const _NoMore();
  @override
  Widget build(BuildContext context) => Padding(
        padding: const EdgeInsets.all(18),
        child: Row(children: const [
          Expanded(child: Divider(color: CA.ink100)),
          Padding(
            padding: EdgeInsets.symmetric(horizontal: 10),
            child: Row(mainAxisSize: MainAxisSize.min, children: [
              Icon(Icons.check_rounded, size: 14, color: CA.success),
              SizedBox(width: 6),
              Text('You are all caught up', style: TextStyle(fontSize: 11, color: CA.ink400)),
            ]),
          ),
          Expanded(child: Divider(color: CA.ink100)),
        ]),
      );
}

/// Optional reach-end footer (shown when `hasReachedEnd` is true and the
/// `reachEndBuilder` slot is wired). Takes precedence over [_NoMore].
class _ReachEnd extends StatelessWidget {
  const _ReachEnd();
  @override
  Widget build(BuildContext context) => Padding(
        padding: const EdgeInsets.fromLTRB(18, 20, 18, 12),
        child: Center(
          child: Container(
            padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
            decoration: BoxDecoration(
              color: CA.violetSoft,
              borderRadius: BorderRadius.circular(999),
            ),
            child: Row(mainAxisSize: MainAxisSize.min, children: const [
              Icon(Icons.flag_rounded, size: 14, color: CA.violet),
              SizedBox(width: 7),
              Text('No more items',
                  style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: CA.violet)),
            ]),
          ),
        ),
      );
}

class _Empty extends StatelessWidget {
  const _Empty({required this.onRefresh, required this.dataset});
  final Future<void> Function() onRefresh;
  final String dataset;
  @override
  Widget build(BuildContext context) {
    final label = dataset == 'members' ? 'members' : 'posts';
    return Center(
      child: Column(mainAxisSize: MainAxisSize.min, children: [
        Container(
          width: 92,
          height: 92,
          decoration: BoxDecoration(color: CA.violet.withOpacity(0.08), borderRadius: BorderRadius.circular(26)),
          alignment: Alignment.center,
          child: Container(
            width: 54,
            height: 54,
            decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(18), boxShadow: CA.cardShadow),
            child: const Icon(Icons.inbox_outlined, color: CA.violet),
          ),
        ),
        const SizedBox(height: 16),
        Text('No $label found yet', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: CA.ink900)),
        const SizedBox(height: 6),
        SizedBox(
          width: 240,
          child: Text('Looks like you do not have any $label yet. You can refresh anytime.',
              textAlign: TextAlign.center, style: const TextStyle(fontSize: 13, color: CA.ink500)),
        ),
        const SizedBox(height: 16),
        FilledButton(style: FilledButton.styleFrom(backgroundColor: CA.violet), onPressed: onRefresh, child: const Text('Refresh')),
      ]),
    );
  }
}

class _Error extends StatelessWidget {
  const _Error({required this.error, required this.onRetry});
  final Object error;
  final Future<void> Function() onRetry;
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(mainAxisSize: MainAxisSize.min, children: [
        Container(
          width: 72,
          height: 72,
          decoration: BoxDecoration(color: CA.danger.withOpacity(0.1), borderRadius: BorderRadius.circular(22)),
          child: const Icon(Icons.wifi_off_rounded, color: CA.danger, size: 28),
        ),
        const SizedBox(height: 16),
        const Text('Something went wrong', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: CA.ink900)),
        const SizedBox(height: 6),
        SizedBox(
          width: 260,
          child: Text('$error'.replaceAll('Exception: ', ''), textAlign: TextAlign.center, style: const TextStyle(fontSize: 13, color: CA.ink500)),
        ),
        const SizedBox(height: 16),
        FilledButton(style: FilledButton.styleFrom(backgroundColor: CA.violet), onPressed: onRetry, child: const Text('Retry')),
      ]),
    );
  }
}
1
likes
0
points
46
downloads

Documentation

Documentation

Publisher

unverified uploader

Weekly Downloads

A reusable, MVC-style infinite-scroll pagination toolkit for Flutter — one controller, many providers (Future/Stream/sync), page or cursor based, with eight builders, hasReachedEnd, optional static leading/trailing widgets and scroll-to-first/last FABs, item + batch ops, an optional SortManager, key-based selection, diff-based row reuse, scroll actions, a focus manager, bloc bindings (PaginationBloc / PaginationCubit) and a multi-section Sliver engine for mixed list + grid feeds.

Repository (GitHub)
View/report issues

License

unknown (license)

Dependencies

bloc, flutter

More

Packages that depend on scrolled_pagination