import 'dart:async'; import 'dart:convert'; import '../model/account_data.dart'; import 'api_response.dart'; import 'cache_store.dart'; import 'errors/parse_exception.dart'; import 'errors/stale_session_exception.dart'; abstract class RequestCache { static const int cacheNothing = 0; static const int cacheMinute = 60; static const int cacheHour = 60 * 60; static const int cacheDay = 60 * 60 * 24; int maxCacheTime; void Function(T)? onUpdate; /// Called only when [start] finds a cached payload in the [CacheStore]. Use this /// (instead of [onUpdate]) when callers need to distinguish stale-but-fast /// cache hits from authoritative network responses. void Function(T)? onCacheData; /// Called only when [start] receives a fresh payload from the network. void Function(T)? onNetworkData; void Function(Exception) onError; bool? renew; final Completer _ready = Completer(); /// Resolves when [start] has finished, regardless of whether the network /// call succeeded, failed, or was skipped due to a fresh cache. Callers /// can await this to know when both the cache lookup and the network /// attempt have settled. Future get ready => _ready.future; RequestCache( this.maxCacheTime, this.onUpdate, { this.onError = ignore, this.renew = false, this.onCacheData, this.onNetworkData, }); static void ignore(Exception e) {} Future start(String document) async { final epoch = AccountData().sessionEpoch; try { final entry = await CacheStore.instance.read(document); var lastUpdate = entry?.lastUpdate ?? 0; T? cached; if (entry != null) { try { cached = fromCacheJson(await CacheStore.decode(entry.json)); } on Object { // A payload from an older model version: treat it as a miss. await CacheStore.instance.delete(document); lastUpdate = 0; } } if (cached != null) { onUpdate?.call(cached); onCacheData?.call(cached); } await _load(document, epoch, lastUpdate: lastUpdate); } finally { if (!_ready.isCompleted) _ready.complete(); } } Future _load( String document, int epoch, { required int lastUpdate, }) async { if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < lastUpdate) { if (renew == null || !renew!) return; } try { final newValue = await onLoad(); // The cache is shared, so a late response of a signed-out // account would otherwise be cached for the next one. if (!AccountData().isCurrentSession(epoch)) { onError(const StaleSessionException()); return; } onUpdate?.call(newValue); onNetworkData?.call(newValue); unawaited(CacheStore.instance.write(document, jsonEncode(newValue))); } on Exception catch (e) { onError(e); } } T fromCacheJson(Map json); Future onLoad(); } /// Concrete [RequestCache] that takes the two overrides as constructor /// callbacks instead of requiring a subclass per endpoint. class SimpleCache extends RequestCache { final Future Function() _loader; final T Function(Map json) _fromJson; SimpleCache({ required int cacheTime, required Future Function() loader, required T Function(Map json) fromJson, void Function(T)? onUpdate, void Function(T)? onCacheData, void Function(T)? onNetworkData, void Function(Exception)? onError, bool? renew, }) : _loader = loader, _fromJson = fromJson, super( cacheTime, onUpdate, onError: onError ?? RequestCache.ignore, renew: renew, onCacheData: onCacheData, onNetworkData: onNetworkData, ); @override Future onLoad() => _loader(); @override T fromCacheJson(Map json) => _fromJson(json); } /// Captures the latest cache payload (cached or network) and rethrows the /// captured network error if no payload arrived. Collapses the /// `latest`/`capturedError`/`await ready` boilerplate that DataProviders /// otherwise repeat per endpoint. Future resolveFromCache( RequestCache Function( void Function(T) onUpdate, void Function(Exception) onError, ) build, { void Function(Object)? onError, String? operationName, }) async { T? latest; Object? capturedError; final cache = build((data) => latest = data, (e) { capturedError = e; onError?.call(e); }); await cache.ready; final err = capturedError; // `latest` may still hold the cache hit read before the sign-out. if (err is StaleSessionException) throw err; if (latest != null) return latest as T; if (err != null) throw err; throw ParseException( technicalDetails: operationName != null ? 'No data and no error from $operationName' : null, ); }