/// A backend page consumed by [ContinuationPaginationCoordinator]. /// /// [consumedCount] is deliberately separate from [items.length]. A screen may /// filter or otherwise map wire items while the backend cursor must still /// advance by the number of records consumed from the response. class ContinuationPage { const ContinuationPage({required this.items, required this.totalCount, required this.consumedCount}); final List items; final int totalCount; final int consumedCount; } enum ContinuationLoadStatus { completed, failed, stale, idle } typedef ContinuationPageLoader = Future> Function(int startIndex); typedef ContinuationPageHandler = void Function(ContinuationPage page); /// Coordinates an eager, indexed continuation without depending on Flutter. /// /// Screens retain ownership of item mapping and presentation through [onPage]. /// This class owns cursor advancement, generation invalidation, retry state, /// stale-result rejection, and request coalescing. class ContinuationPaginationCoordinator { ContinuationPaginationCoordinator({required this.loadPage, required this.onPage, this.onStateChanged, this.onError}); final ContinuationPageLoader loadPage; final ContinuationPageHandler onPage; final void Function()? onStateChanged; final void Function(Object error, StackTrace stackTrace)? onError; int _generation = 0; int? _nextStartIndex; int? _totalCount; Future? _inFlight; int? _inFlightGeneration; bool _disposed = false; bool _isLoading = false; Object? _error; int? get nextStartIndex => _nextStartIndex; int? get totalCount => _totalCount; bool get hasMore => _nextStartIndex != null; bool get isLoading => _isLoading; Object? get error => _error; /// Invalidates all prior work, runs [request], and reports whether its result /// still belongs to the current generation. Future runNewGeneration(Future Function() request) async { final generation = _beginGeneration(); try { await request(); } catch (_) { if (!_isCurrent(generation)) return false; rethrow; } return _isCurrent(generation); } /// Seeds the continuation cursor from an accepted initial response. void setContinuation({required int startIndex, required int totalCount}) { if (_disposed) return; _totalCount = totalCount; _nextStartIndex = startIndex < totalCount ? startIndex : null; _error = null; onStateChanged?.call(); } /// Loads all remaining pages. Calls made while this generation is loading /// share the same operation and do not issue duplicate backend requests. Future loadRemaining() { final existing = _inFlight; if (existing != null) return existing; if (_disposed || _nextStartIndex == null) { return Future.value(ContinuationLoadStatus.idle); } final generation = _generation; _inFlightGeneration = generation; final operation = _loadRemaining(generation); _inFlight = operation; return operation; } /// Retries from the first cursor that did not complete successfully. Future retry() => loadRemaining(); /// Invalidates pending results and suppresses future callbacks. void dispose() { if (_disposed) return; _disposed = true; _generation++; _nextStartIndex = null; _totalCount = null; _inFlight = null; _inFlightGeneration = null; _isLoading = false; _error = null; } int _beginGeneration() { _generation++; _nextStartIndex = null; _totalCount = null; _inFlight = null; _inFlightGeneration = null; _isLoading = false; _error = null; if (!_disposed) onStateChanged?.call(); return _generation; } bool _isCurrent(int generation) => !_disposed && generation == _generation; Future _loadRemaining(int generation) async { _isLoading = true; _error = null; onStateChanged?.call(); try { while (_isCurrent(generation)) { final startIndex = _nextStartIndex; if (startIndex == null) return ContinuationLoadStatus.completed; final page = await loadPage(startIndex); if (!_isCurrent(generation)) return ContinuationLoadStatus.stale; if (page.consumedCount <= 0) { _totalCount = page.totalCount; _nextStartIndex = null; onStateChanged?.call(); return ContinuationLoadStatus.completed; } onPage(page); _totalCount = page.totalCount; final nextStartIndex = startIndex + page.consumedCount; _nextStartIndex = nextStartIndex < page.totalCount ? nextStartIndex : null; onStateChanged?.call(); } return ContinuationLoadStatus.stale; } catch (exception, stackTrace) { if (!_isCurrent(generation)) return ContinuationLoadStatus.stale; _error = exception; onError?.call(exception, stackTrace); return ContinuationLoadStatus.failed; } finally { if (_isCurrent(generation)) { _isLoading = false; onStateChanged?.call(); } if (_inFlightGeneration == generation) { _inFlight = null; _inFlightGeneration = null; } } } }