appcraft_list_loading_flutter 1.0.0 copy "appcraft_list_loading_flutter: ^1.0.0" to clipboard
appcraft_list_loading_flutter: ^1.0.0 copied to clipboard

Dispatcher for paginated list loading with offset/cursor support, debounced search and pluggable cancellation strategies.

appcraft_list_loading_flutter #

Pub Version License: MIT

A single-purpose Flutter package for paginated list loading. It provides two self-contained dispatchers — ACListDispatcher for a plain List<T> response and ACPageDispatcher for a page-model (DTO) response — each encapsulating the loading lifecycle (reload / loadMore / cancel / dispose). It also ships parameter mixins for offset- and cursor-based pagination, the ACPage page mixin, and ready-to-use strategies for debounced search and load cancellation. Suitable for any list with pagination, a search field and pull-to-refresh — without imposing any specific state-management library (both dispatchers extend ChangeNotifier).

Features #

  • Offset pagination via ACListDispatcher and ACOffsetParamsMixin.
  • Page-model (DTO) responses with explicit hasMore via ACPageDispatcher + any DTO with the ACPage mixin.
  • Cursor pagination via a custom cursor field on your params class + ACPageDispatcher + an ACPage DTO. Use dispatcher.lastResult?.<your_cursor_field> to feed the next loadMore.
  • Debounced search with minLength via the default ACSearchDebouncer (composed on top of ACDebouncer), or your own ACSearchStrategy.
  • A standalone, action-neutral ACDebouncer for debouncing any action (read-tracking, autosave, analytics throttling, ...) — no manual Timer.
  • Cancellation strategies: the ACCancelStrategy contract and a ready ACOperationCancelStrategy implementation on top of package:async.
  • Integration with ChangeNotifier — subscribe via ListenableBuilder, AnimatedBuilder or addListener.
  • External list control via mutate (realtime/optimistic/seed) with a manual hasMore setter, on both dispatchers.
  • Bidirectional, anchor-based pagination for chat/feed via ACAnchoredDispatcher (a composition of two ACPageDispatchers): loadAround / loadOlder / loadNewer, separate itemsOlder / itemsNewer for a jump-free CustomScrollView(center:) plus a merged items, fully per-side state and realtime mutateOlder / mutateNewer.

Removed in 1.0.0: the parser-based ACDispatcher, ACCustomDispatcher, ACParser, ACResultParser and the ACResult model have been removed. Use the self-contained ACListDispatcher / ACPageDispatcher and the ACPage mixin instead. See the API Reference. Upgrading from 0.2.0 or 0.3.0? Follow the step-by-step migration guide.

Installation #

flutter pub add appcraft_list_loading_flutter

Usage #

1. Basic — ACListDispatcher #

The simplest scenario: the loader returns a plain List<T>, offset-based pagination, no search. hasMore is computed as result.length >= params.limit. ACListDispatcher is the recommended dispatcher for this case.

Migration note: the older ACDefaultDispatcher / ACDefaultParser classes were removed in 1.0.0. Their public contract was identical to ACListDispatcher — migration is just renaming the class. See the migration guide.

import 'package:appcraft_list_loading_flutter/appcraft_list_loading_flutter.dart';

final class UserListParams
    with ACParamsMixin, ACOffsetParamsMixin {
  const UserListParams({this.offset, this.limit, this.query});

  @override
  final int? offset;
  @override
  final int? limit;
  @override
  final String? query;
}

final dispatcher = ACListDispatcher<UserListParams, User>();

await dispatcher.reload(
  params: const UserListParams(offset: 0, limit: 20),
  load: (p) => api.fetchUsers(offset: p.offset, limit: p.limit),
);

// Load the next page:
await dispatcher.loadMore(
  params: UserListParams(offset: dispatcher.items.length, limit: 20),
  load: (p) => api.fetchUsers(offset: p.offset, limit: p.limit),
);

Before the first load — hasMore == false

A fresh dispatcher reports hasMore == false until the first load happens, so hasMore alone already means "there is a loaded page and it may have more":

final dispatcher = ACListDispatcher<UserListParams, User>();
print(dispatcher.hasMore); // false (before any reload)

// No extra gate is needed — this used to require `lastResult != null && hasMore`:
if (dispatcher.hasMore) const BottomLoader(); // won't show before the first load

Consequently loadMore() before the first reload is a no-op (the hasMore == false guard). Use reload to perform the first load, or loadMore(force: true) to bypass the guard explicitly:

await dispatcher.loadMore(params: p, load: api.fetch);              // no-op (hasMore == false)
await dispatcher.loadMore(params: p, load: api.fetch, force: true); // loads (bypasses the guard)

After the first reload the value is computed as before (offset rule / page.hasMore) — the post-load computation is unchanged.

Forcing a load past the end — loadMore(force: true)

Reached the end (hasMore == false) but new items may have appeared upstream (for example after posting a message) and you want to pull them in without a full reload and without losing the accumulated list? Pass force: true:

if (dispatcher.hasMore) {
  await dispatcher.loadMore(params: nextParams, load: api.fetch);              // as usual
} else {
  await dispatcher.loadMore(params: nextParams, load: api.fetch, force: true); // pull anyway
}
  • force: true bypasses only the hasMore == false check; it is one-shot — after a successful load hasMore is recomputed as usual.
  • The other guards are kept: during an active load and after dispose it is a no-op, and isLoadingMore is true while it runs (like a normal loadMore).
  • Without force (or force: false) the behaviour is unchanged. It lives in the base ACLoadingDispatcher, so both dispatchers and subclasses inherit it.

Manual list control — mutate & hasMore

For live feeds (realtime append, optimistic update, seed) you can change the accumulated list from outside via mutate. The callback receives the mutable list; a single notifyListeners() fires on success (batched — many operations, one notification). The items getter stays unmodifiable, so mutate is the only sanctioned write path.

// realtime: an incoming item over WebSocket
socket.onMessage((msg) => dispatcher.mutate((items) => items.add(msg)));

// optimistic: show "sending", then replace with the server object
dispatcher.mutate((items) => items.add(pending));
final sent = await api.send(text);
dispatcher.mutate((items) => items          // two ops → one notification
  ..removeWhere((m) => m.localId == pending.localId)
  ..add(sent));

// remove
dispatcher.mutate((items) => items.removeWhere((m) => m.id == deletedId));

// seed from cache and control pagination manually
dispatcher.mutate((items) => items.addAll(cached));
dispatcher.hasMore = true;   // enable loadMore; set false to stop it

Notes: mutate is a no-op after dispose; a callback exception propagates without notifying; mutate/hasMore = do not cancel an active load (a concurrent reload will overwrite manual changes — call cancel() first to seed safely). The hasMore setter does not notify listeners. Direct dispatcher.items.add(...) throws UnsupportedError — use mutate.

2. DTO with ACPageACPageDispatcher #

If the backend returns a DTO with an explicit hasMore flag (and/or a cursor), the DTO mixes in ACPage<T> and ACPageDispatcher reads items and hasMore from it automatically — no separate parser is needed. Any extra fields (cursor, metadata) are read back through dispatcher.lastResult.

Migration note: the older ACCustomDispatcher / ACResultParser / ACParser / ACDispatcher classes and the ACResult model were removed in 1.0.0. Migration is mechanical: change the DTO mixin ACResult<T>ACPage<T> (the members are identical) and the dispatcher class ACCustomDispatcherACPageDispatcher. Method signatures, getters and behaviour are unchanged; mutate and the hasMore setter are added on top. See the migration guide.

import 'package:appcraft_list_loading_flutter/appcraft_list_loading_flutter.dart';

final class UserPageDto with ACPage<User> {
  const UserPageDto({
    required this.items,
    required this.hasMore,
    this.nextCursor,
  });

  @override
  final List<User> items;
  @override
  final bool hasMore;
  final String? nextCursor;
}

final class UserCursorParams with ACParamsMixin {
  const UserCursorParams({this.limit, this.cursor, this.query});

  @override
  final int? limit;
  final String? cursor;
  @override
  final String? query;
}

final dispatcher =
    ACPageDispatcher<UserCursorParams, UserPageDto, User>();

await dispatcher.reload(
  params: const UserCursorParams(limit: 20),
  load: (p) => api.fetchUsersPage(cursor: p.cursor, limit: p.limit),
);

// Carry the next-page cursor through the dispatcher's lastResult getter:
await dispatcher.loadMore(
  params: UserCursorParams(
    limit: 20,
    cursor: dispatcher.lastResult?.nextCursor,
  ),
  load: (p) => api.fetchUsersPage(cursor: p.cursor, limit: p.limit),
);

ACPageDispatcher also supports external list control — mutate for realtime / optimistic / seed writes (a single batched notification) and a manual hasMore setter — with the same rules as ACListDispatcher. See Manual list control.

3. Bidirectional chat/feed pagination — ACAnchoredDispatcher #

For a chat or a feed that opens around an anchor (first unread message, a deep-linked item) and then grows in both directions — back in time (older) and forward in time (newer) — use ACAnchoredDispatcher. It is built as a composition of two ACPageDispatchers (one per side), so each side reuses the full loading engine (cancellation, staleness guards, isLoading, lastResult, error channels, retry, mutate); this class only orchestrates the anchor load and exposes a merged list view. It extends ChangeNotifier.

The around-load returns an ACAnchoredPage<T> — an around-page model that carries both directional flags at once (hasMoreOlder / hasMoreNewer), unlike the per-side ACPage whose hasMore means "is there more on this side". loadOlder / loadNewer keep using a plain ACPage DTO.

import 'package:appcraft_list_loading_flutter/appcraft_list_loading_flutter.dart';

// around-page (for loadAround) — carries both flags
final class ChatAround with ACAnchoredPage<Msg> {
  const ChatAround({
    required this.items,
    required this.hasMoreOlder,
    required this.hasMoreNewer,
    this.olderCursor,
    this.newerCursor,
  });

  @override
  final List<Msg> items;
  @override
  final bool hasMoreOlder;
  @override
  final bool hasMoreNewer;
  final String? olderCursor; // initial cursors — read back via lastAround
  final String? newerCursor;
}

// side-page (for loadOlder/loadNewer) — hasMore = "more on THIS side"
final class ChatPage with ACPage<Msg> {
  const ChatPage({required this.items, required this.hasMore, this.cursor});

  @override
  final List<Msg> items;
  @override
  final bool hasMore;
  final String? cursor;
}

Start around the anchor — loadAround

loadAround runs its own orchestration: it cancels any previous around-load and both sides, seeds the central page into the newer side, clears the older side and applies both hasMore flags. The central page is stored in lastAround. A failing loader still throws (kept in lastErrorAround).

final d = ACAnchoredDispatcher<ChatParams, ChatPage, Msg>();

await d.loadAround(
  params: ChatParams(anchorId: firstUnreadId),
  load: (p) => api.fetchAround(p.anchorId), // -> ChatAround
);
// d.itemsNewer == the centred window; d.itemsOlder == [];
// d.hasMoreOlder / d.hasMoreNewer come from the response.

Grow up/down — loadOlder / loadNewer

Each side delegates to its dispatcher's loadMore (the !hasMore / isLoading guards and one-shot force apply). Cursors are managed manually: the initial ones come from lastAround, subsequent ones from lastResultOlder / lastResultNewer.

if (d.hasMoreOlder) {
  await d.loadOlder(
    params: ChatParams(
      cursor: d.lastResultOlder?.cursor ??
          (d.lastAround as ChatAround?)?.olderCursor,
    ),
    load: (p) => api.fetchOlder(p.cursor), // hasMore = "more older"
  );
}
if (d.hasMoreNewer) {
  await d.loadNewer(
    params: ChatParams(
      cursor: d.lastResultNewer?.cursor ??
          (d.lastAround as ChatAround?)?.newerCursor,
    ),
    load: (p) => api.fetchNewer(p.cursor), // hasMore = "more newer"
  );
}

Two lists for CustomScrollView(center:) — no scroll jumps

itemsOlder (closest-older → oldest) and itemsNewer (anchor → newest) are exposed separately so they feed two slivers around a center key — growing the older side does not shift the visible position. A merged, read-only items == reverse(itemsOlder) ++ itemsNewer is also available for consumers that prefer a single list.

CustomScrollView(
  center: centerKey,
  anchor: 0.5,
  slivers: [
    if (d.isLoadingOlder) const SliverToBoxAdapter(child: TopSpinner()),
    SliverList.builder(                          // TOP: older, grows up, no jumps
      itemCount: d.itemsOlder.length,
      itemBuilder: (_, i) => MsgTile(d.itemsOlder[i]),
    ),
    SliverList.builder(                          // BOTTOM: anchor + newer
      key: centerKey,
      itemCount: d.itemsNewer.length,
      itemBuilder: (_, i) => MsgTile(d.itemsNewer[i]),
    ),
    if (d.isLoadingNewer) const SliverToBoxAdapter(child: BottomSpinner()),
  ],
);

Realtime — mutateNewer / mutateOlder

mutateOlder / mutateNewer delegate to the respective side's mutate — the only sanctioned write path (the list getters stay unmodifiable). A single batched notifyListeners() fires per call; a no-op after dispose.

socket.onMessage((m) => d.mutateNewer((items) => items.add(m))); // incoming — down
d.mutateNewer((items) => items                                   // optimistic — one notify
  ..removeWhere((x) => x.localId == pending.localId)
  ..add(sent));

Per-side indicators / errors / retry

Loading, error and retry state is fully per side and independent — a load or a failure on one side never touches the other: isLoadingOlder / isLoadingNewer / isLoadingAround, loadingOlderListenable / loadingNewerListenable / loadingAroundListenable, lastErrorOlder / lastErrorNewer / lastErrorAround (with matching errorOlderListenable / errorNewerListenable / errorAroundListenable), retryOlder() / retryNewer(), and lastResultOlder / lastResultNewer / lastAround.

ValueListenableBuilder<bool>(
  valueListenable: d.loadingOlderListenable,      // spinner on top
  builder: (_, loading, __) => loading ? const TopSpinner() : const SizedBox(),
);
if (d.lastErrorNewer != null) RetryButton(onTap: d.retryNewer); // error at the bottom

cancel() cancels the around-load and both sides; dispose() releases both sides and is idempotent (any method after dispose is a no-op). The per-side searchStrategy is not applied — the dispatcher only uses loadMore.

4. Debounced search — ACSearchDebouncer #

ACSearchDebouncer is the default search strategy on both dispatchers (a fresh dispatcher already debounces search with debounce: 300ms, minLength: 3), so you only pass one to tweak the timing or gating. It is built on top of the general-purpose ACDebouncer (see below) but its search behaviour is unchanged from before.

The search strategy applies only in reload: for a query shorter than minLength items are cleared, for a changed query loading starts after debounce. In loadMore the search strategy is ignored.

import 'package:appcraft_list_loading_flutter/appcraft_list_loading_flutter.dart';

final dispatcher = ACListDispatcher<UserListParams, User>(
  searchStrategy: ACSearchDebouncer(
    debounce: const Duration(milliseconds: 400),
    minLength: 2,
  ),
);

// Every text change triggers a reload — the strategy will collapse
// frequent calls into a single one.
void onQueryChanged(String query) {
  dispatcher.reload(
    params: UserListParams(offset: 0, limit: 20, query: query),
    load: (p) => api.searchUsers(query: p.query, offset: p.offset, limit: p.limit),
  );
}

Migration note: ACDebouncedSearchStrategy was removed in 1.0.0. Replace it with ACSearchDebouncer — same debounce / minLength parameters and identical behaviour, it is a straight rename. The ACSearchStrategy contract stays for custom strategies. See the migration guide.

For a fully custom search behaviour, implement ACSearchStrategy yourself and pass it as searchStrategy — for example an instant, no-debounce strategy:

final class InstantSearch implements ACSearchStrategy {
  @override
  Future<void>? schedule(String? query) => Future<void>.value();
  @override
  void cancel() {}
  @override
  void dispose() {}
}

final dispatcher =
    ACListDispatcher<UserListParams, User>(searchStrategy: InstantSearch());

5. Debouncing any action — ACDebouncer #

ACSearchDebouncer handles search timing, but the underlying ACDebouncer is exposed as a standalone, action-neutral utility for debouncing any void Function() — read-tracking, resize handling, draft autosave, analytics throttling — without wiring up a manual Timer. It is trailing-edge: each run cancels the previously scheduled action and re-arms, so only the last action within the duration window fires.

import 'package:appcraft_list_loading_flutter/appcraft_list_loading_flutter.dart';

final _readDebouncer = ACDebouncer(const Duration(milliseconds: 400));

// A frequent stream — e.g. "a message scrolled into view":
void onMessageVisible(Message m) =>
    _readDebouncer.run(() => api.markRead(m.id));
// Only the last id within 400ms reaches the API.

@override
void dispose() {
  _readDebouncer.dispose(); // required — otherwise a pending Timer may
  super.dispose();          // fire the action after dispose
}
  • duration defaults to 300ms and must be non-negative; Duration.zero schedules the action on the next event-loop tick.
  • isActive reports whether an action is currently pending; cancel() drops the pending action without running it.
  • dispose() is mandatory — it cancels the pending timer so a deferred action never fires after teardown. It is equivalent to cancel().

6. Custom cancel strategy — ACCancelStrategy #

If you need to integrate with your own cancellation system (for example a Dio CancelToken), implement ACCancelStrategy and pass an instance to reload / loadMore via the cancelStrategy parameter.

import 'package:appcraft_list_loading_flutter/appcraft_list_loading_flutter.dart';
import 'package:dio/dio.dart';

final class DioCancelStrategy implements ACCancelStrategy {
  DioCancelStrategy() : _token = CancelToken();

  final CancelToken _token;
  bool _completed = false;

  CancelToken get token => _token;

  @override
  Future<T?> run<T>(Future<T> future) async {
    try {
      final result = await future;
      _completed = true;
      return result;
    } on DioException catch (e) {
      if (CancelToken.isCancel(e)) return null;
      rethrow;
    }
  }

  @override
  Future<void> cancel() async {
    if (!_completed && !_token.isCancelled) _token.cancel();
  }

  @override
  bool get isActive => !_completed && !_token.isCancelled;
}

await dispatcher.reload(
  params: const UserListParams(offset: 0, limit: 20),
  load: (p) => api.fetchUsers(offset: p.offset, limit: p.limit),
  cancelStrategy: DioCancelStrategy(),
);

7. Reactive loading indicator — loadingListenable #

Both dispatchers extend ChangeNotifier, but notifyListeners() fires only when items change — a change of isLoading alone does not wake listeners. To drive a spinner reactively (without manual setState and without subscribing to the list) use loadingListenable — a ValueListenable<bool> that mirrors the overall isLoading flag, notifies on every false ↔ true transition and deduplicates equal values.

// In a widget — no setState:
ValueListenableBuilder<bool>(
  valueListenable: dispatcher.loadingListenable,
  builder: (_, isLoading, __) => isLoading
      ? const LinearProgressIndicator()
      : const SizedBox.shrink(),
);

// In a Cubit/Bloc — where setState is unavailable:
dispatcher.loadingListenable.addListener(() {
  emit(state.copyWith(isLoading: dispatcher.isLoading));
});

loadingListenable is independent of list changes — you no longer subscribe to the list just to move a spinner.

Telling reload apart from loadMoreisReloading / isLoadingMore

To render distinct indicators — a full-screen spinner during reload versus a "loading more" spinner at the bottom during loadMore — read the two granular flags synchronously:

Widget build(BuildContext context) {
  return Column(children: [
    if (dispatcher.isReloading) const FullScreenSpinner(),   // reload in flight
    Expanded(child: ListView(/* dispatcher.items */)),
    if (dispatcher.isLoadingMore) const BottomSpinner(),     // loadMore in flight
  ]);
}
  • isReloadingtrue while a reload is in flight.
  • isLoadingMoretrue while a loadMore is in flight.
  • During any active load exactly one granular flag is true; starting a reload while a loadMore runs switches to isReloading without the overall isLoading flickering to false.

Compatibility: isLoading is unchanged — it is now the derived isReloading || isLoadingMore, so existing code keeps working. The granular flags are read synchronously; there is no separate ValueListenable for them (the reactive channel is on the overall isLoading only). Everything lives in the base ACLoadingDispatcher, so ACListDispatcher, ACPageDispatcher and third-party subclasses get it for free.

8. Built-in error state — lastError / errorListenable / retry() #

A failing load still throws — your try/catch around reload / loadMore keeps working exactly as before. On top of that the engine now records the error so a widget can render a "spinner / error / list" state without wiring up its own error plumbing: lastError exposes the last thrown object synchronously and errorListenable (a ValueListenable<Object?>) is the reactive channel. The error clears automatically on the next successful load.

Combine loadingListenable and errorListenable to drive the three states from one builder:

// In a widget — reactive, combining loading + error:
AnimatedBuilder(
  animation: Listenable.merge([dispatcher.loadingListenable, dispatcher.errorListenable]),
  builder: (context, _) {
    if (dispatcher.isLoading) return const CircularProgressIndicator();
    final error = dispatcher.lastError;
    if (error != null) {
      return Column(children: [
        Text('Error: $error'),
        ElevatedButton(onPressed: dispatcher.retry, child: const Text('Retry')),
      ]);
    }
    return MyList(items: dispatcher.items);
  },
);

// In a Cubit/Bloc:
dispatcher.errorListenable.addListener(() {
  emit(state.copyWith(error: dispatcher.lastError));
});
  • lastError — synchronous; errorListenable — reactive, deduplicated by value.
  • The exception is still propagatedlastError / errorListenable are additive, not a replacement for try/catch.
  • Changing lastError does not fire notifyListeners() — the reactive channel is errorListenable only.

Repeating the last operation — retry()

retry() re-runs the last operation (reload or loadMore) through the public methods, with the original params, load and — for loadMore — the original force, so you do not have to remember the arguments at the call site:

try {
  await dispatcher.loadMore(params: p, load: api.fetch, force: true);
} catch (_) {
  // later, on a button tap:
  await dispatcher.retry();   // repeats loadMore with force: true and the same params/load
}
  • A fresh cancelStrategy is used; if you need a custom one, call reload / loadMore directly.
  • With no prior operation, or after dispose, retry() is a no-op.

Introspecting the last operation — lastOperation

lastOperation gives read-only access to the captured operation as a sealed ACDispatcherOperation, so an exhaustive switch needs no default clause:

switch (dispatcher.lastOperation) {
  case null:
    label = 'No operation yet';
  case ACReloadOperation():
    label = 'Retry load';
  case ACLoadMoreOperation(:final force):
    label = force ? 'Retry forced load-more' : 'Load more';
}

The variants are ACReloadOperation and ACLoadMoreOperation (carrying force); both expose the common params and load.

Everything lives in the base ACLoadingDispatcher, so ACListDispatcher, ACPageDispatcher and third-party subclasses inherit it.

Extending the API #

The recommended extension points are the self-contained dispatchers ACListDispatcher and ACPageDispatcher: both are open for extends and implements, so you can customize loading, search or cancellation behaviour without copying the source. The strategy classes are open too.

Open classes:

  • ACListDispatcher
  • ACPageDispatcher
  • ACLoadingDispatcher (abstract — the shared loading engine, see below)
  • ACSearchDebouncer
  • ACOperationCancelStrategy

The former parser-based extension points — the abstract ACDispatcher / ACParser base classes and the ACResultParser implementation — were removed in 1.0.0; custom parsing logic now lives inside a subclass of the relevant dispatcher instead. The ACSearchStrategy, ACCancelStrategy contracts and the mixins remain open.

Example: extending the list dispatcher #

class LoggingDispatcher<P extends ACOffsetParamsMixin, T>
    extends ACListDispatcher<P, T> {
  LoggingDispatcher({super.searchStrategy});

  @override
  void notifyListeners() {
    print('items: ${items.length}, isLoading: $isLoading');
    super.notifyListeners();
  }
}

When extending, respect the parent contract documented in the corresponding class' API docs. In particular, ACListDispatcher extends ChangeNotifier — overrides of dispose() must call super.dispose().

The shared engine — ACLoadingDispatcher #

Both dispatchers are thin subclasses of one abstract engine, ACLoadingDispatcher<Params, T>. The engine owns the whole loading lifecycle — search scheduling, cancellation of a previous load, staleness guards, the isLoading flag (with the granular isReloading / isLoadingMore and the reactive loadingListenable), the built-in error state (lastError / errorListenable) with retry() / lastOperation, and the typed lastResult — behind reload / loadMore / cancel / dispose. A subclass supplies only the collection state and three hooks: hasMore, onLoadSuccess and onLoadRejected.

This is an internal refactor: ACListDispatcher and ACPageDispatcher now share this base, but their public API and behaviour are unchanged — existing code needs no migration.

ACLoadingDispatcher is also a public extension point. If neither offset (List<T>) nor ACPage-model pagination fits your backend, subclass the engine directly and implement your own hasMore rule — the entire lifecycle is inherited:

final class MyDispatcher<P extends ACParamsMixin, T>
    extends ACLoadingDispatcher<P, List<T>> {
  MyDispatcher({super.searchStrategy});

  final List<T> _items = <T>[];
  bool _hasMore = true;

  List<T> get items => List<T>.unmodifiable(_items);

  @override
  bool get hasMore => _hasMore;
  set hasMore(bool value) => _hasMore = value;

  void mutate(void Function(List<T> items) update) {
    if (isDisposed) return; // isDisposed — @protected from the base
    update(_items);
    notifyListeners();
  }

  @override
  void onLoadSuccess(List<T> result, P params, {required bool replace}) {
    replace ? (_items..clear()..addAll(result)) : _items.addAll(result);
    _hasMore = result.isNotEmpty; // your own pagination rule
    notifyListeners();
  }

  @override
  void onLoadRejected() {
    final wasNonEmpty = _items.isNotEmpty;
    _items.clear();
    _hasMore = false;
    if (wasNonEmpty) notifyListeners();
  }
}

reload / loadMore / cancel / dispose / runLoad / isLoading / lastResult are inherited — never override them. Notifications are the subclass' responsibility: the engine never calls notifyListeners(); emit it from the hooks only when the collection actually changes.

API Reference #

  • ACListDispatcher<P, T> — self-contained dispatcher for offset pagination with a plain List<T> response, with reload, loadMore, cancel and dispose methods plus items, isLoading, hasMore and lastResult getters. Also exposes mutate(update) for external list mutation (realtime/optimistic/seed) with a single batched notification, and a hasMore setter for manual pagination control.
  • ACPageDispatcher<P, R, T> — self-contained dispatcher for a page-model (DTO) response R extends ACPage<T>. Same lifecycle, getters, mutate and hasMore setter as ACListDispatcher; items and hasMore are read directly from the returned page model. lastResult exposes the raw R from the most recent successful load (useful for cursor pagination or DTO metadata).
  • ACAnchoredDispatcher<P, R, T> — bidirectional, anchor-centred dispatcher for chat/feed pagination, built as a composition of two ACPageDispatchers. loadAround seeds the window around an anchor; loadOlder / loadNewer grow each side independently. Exposes the separate itemsOlder / itemsNewer (for a CustomScrollView(center:) without scroll jumps) and a merged read-only items, fully per-side state (isLoadingOlder/Newer/Around, hasMoreOlder/Newer, lastError* / error*Listenable, loading*Listenable, lastResultOlder/Newer, lastAround, retryOlder/Newer), realtime mutateOlder / mutateNewer, plus cancel and an idempotent dispose.
  • ACAnchoredPage<T> — around-page contract mixin (items, hasMoreOlder, hasMoreNewer) returned by ACAnchoredDispatcher.loadAround; carries both directional flags at once.
  • ACLoadingDispatcher<Params, T> — the abstract loading engine shared by both dispatchers. Owns the loading lifecycle (reload, loadMore, cancel, dispose, isLoading, lastResult); subclasses provide the collection state and the hasMore / onLoadSuccess / onLoadRejected hooks. Public extension point for non-standard pagination. Also exposes loadingListenable (a ValueListenable<bool> mirroring isLoading for reactive spinners) and the synchronous granular flags isReloading / isLoadingMore, plus the built-in error state — lastError (synchronous), errorListenable (a ValueListenable<Object?>), retry() (repeat the last operation) and lastOperation (introspection) — inherited by both dispatchers.
  • ACDispatcherOperation<Params, T> — sealed model of the last operation captured for retry() / lastOperation, with the variants ACReloadOperation and ACLoadMoreOperation (the latter carrying force); both expose the common params and load.
  • ACPage<T> — page-model contract mixin (items, hasMore).
  • ACParamsMixin — base parameters mixin (limit, query).
  • ACOffsetParamsMixin — offset pagination mixin (offset).
  • ACSearchStrategy — search strategy contract (schedule, cancel, dispose); implement it for a fully custom search behaviour.
  • ACSearchDebouncer — default search strategy implementation with debounce and minLength, composed on top of ACDebouncer.
  • ACDebouncer — standalone, action-neutral debounce utility (run, isActive, cancel, dispose) for deferring any void Function().
  • ACCancelStrategy — cancellation strategy contract (run, cancel, isActive).
  • ACOperationCancelStrategy — cancellation implementation on top of CancelableOperation from package:async.

Removed in 1.0.0 #

The parser-based API deprecated in 0.3.0 was removed in 1.0.0. The list below is kept for historical reference and points to the replacement of each class — see the migration guide for details.

  • ACDispatcher<P, R, T> — parser-composing core dispatcher. Replaced by the self-contained ACListDispatcher / ACPageDispatcher.
  • ACCustomDispatcher<P, R, T> — facade for DTOs. Replaced by ACPageDispatcher.
  • ACResult<T> — DTO contract mixin. Replaced by ACPage<T> (identical members).
  • ACParser<P, R, T> — parser strategy interface. Parsing now lives inside a dispatcher; no direct replacement.
  • ACResultParser<P, R, T> — parser for ACResult DTOs. Folded into ACPageDispatcher.
  • ACDefaultDispatcher<P, T> / ACDefaultParser<P, T> — replaced by ACListDispatcher (identical public contract).
  • ACDebouncedSearchStrategy — replaced by ACSearchDebouncer (same debounce / minLength parameters and behaviour).

Detailed documentation is available in the dartdoc on pub.dev.

Example #

A complete working example is available in the example/ folder. It demonstrates offset pagination, debounced search, pull-to-refresh and infinite scroll on a single screen.

To run it:

cd example
flutter pub get
flutter run

License #

MIT — see LICENSE.

1
likes
160
points
146
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Dispatcher for paginated list loading with offset/cursor support, debounced search and pluggable cancellation strategies.

Repository (GitHub)
View/report issues

Topics

#pagination #loading #list #dispatcher

License

MIT (license)

Dependencies

async, flutter

More

Packages that depend on appcraft_list_loading_flutter