import 'dart:async'; import 'dart:convert'; import 'package:localstore/localstore.dart'; import 'api_response.dart'; import 'errors/parse_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; static String collection = 'MarianumMobile'; int maxCacheTime; void Function(T)? onUpdate; /// Called only when [start] finds a cached payload in localstore. 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 { try { final tableData = await Localstore.instance .collection(collection) .doc(document) .get(); if (tableData != null) { final cached = onLocalData(tableData['json'] as String); onUpdate?.call(cached); onCacheData?.call(cached); } final lastUpdate = (tableData?['lastupdate'] as num?) ?? 0; if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < lastUpdate) { if (renew == null || !renew!) return; } try { final newValue = await onLoad(); onUpdate?.call(newValue); onNetworkData?.call(newValue); unawaited( Localstore.instance.collection(collection).doc(document).set({ 'json': jsonEncode(newValue), 'lastupdate': DateTime.now().millisecondsSinceEpoch, }), ); } on Exception catch (e) { onError(e); } } finally { if (!_ready.isCompleted) _ready.complete(); } } T onLocalData(String 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 onLocalData(String json) => _fromJson(jsonDecode(json) as Map); } /// 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; if (latest != null) return latest as T; final err = capturedError; if (err != null) throw err; throw ParseException( technicalDetails: operationName != null ? 'No data and no error from $operationName' : null, ); }