Files
plezy/lib/utils/continuation_pagination_coordinator.dart
T

166 lines
5.4 KiB
Dart

/// 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<T> {
const ContinuationPage({required this.items, required this.totalCount, required this.consumedCount});
final List<T> items;
final int totalCount;
final int consumedCount;
}
enum ContinuationLoadStatus { completed, failed, stale, idle }
typedef ContinuationPageLoader<T> = Future<ContinuationPage<T>> Function(int startIndex);
typedef ContinuationPageHandler<T> = void Function(ContinuationPage<T> 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<T> {
ContinuationPaginationCoordinator({required this.loadPage, required this.onPage, this.onStateChanged, this.onError});
final ContinuationPageLoader<T> loadPage;
final ContinuationPageHandler<T> onPage;
final void Function()? onStateChanged;
final void Function(Object error, StackTrace stackTrace)? onError;
int _generation = 0;
int? _nextStartIndex;
int? _totalCount;
Future<ContinuationLoadStatus>? _inFlight;
int? _inFlightGeneration;
bool _disposed = false;
bool _isLoading = false;
Object? _error;
StackTrace? _errorStackTrace;
int? get nextStartIndex => _nextStartIndex;
int? get totalCount => _totalCount;
bool get hasMore => _nextStartIndex != null;
bool get isLoading => _isLoading;
Object? get error => _error;
StackTrace? get errorStackTrace => _errorStackTrace;
/// Invalidates all prior work, runs [request], and reports whether its result
/// still belongs to the current generation.
Future<bool> runNewGeneration(Future<void> 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;
_errorStackTrace = 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<ContinuationLoadStatus> 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<ContinuationLoadStatus> 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;
_errorStackTrace = null;
}
int _beginGeneration() {
_generation++;
_nextStartIndex = null;
_totalCount = null;
_inFlight = null;
_inFlightGeneration = null;
_isLoading = false;
_error = null;
_errorStackTrace = null;
if (!_disposed) onStateChanged?.call();
return _generation;
}
bool _isCurrent(int generation) => !_disposed && generation == _generation;
Future<ContinuationLoadStatus> _loadRemaining(int generation) async {
_isLoading = true;
_error = null;
_errorStackTrace = 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;
_errorStackTrace = stackTrace;
onError?.call(exception, stackTrace);
return ContinuationLoadStatus.failed;
} finally {
if (_isCurrent(generation)) {
_isLoading = false;
onStateChanged?.call();
}
if (_inFlightGeneration == generation) {
_inFlight = null;
_inFlightGeneration = null;
}
}
}
}