Offline-first cached repository
Stale-while-revalidate repository that emits cached JSON instantly, refreshes from the network and survives being offline.
FlutterDatabasefluttercacheofflinerepository
Code
import 'dart:async';
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
typedef Fetcher = Future<Object?> Function(); // returns decoded JSON
class Cached<T> {
const Cached(this.value, {required this.fromCache, required this.savedAt});
final T value;
final bool fromCache; // true while showing stored data
final DateTime savedAt;
}
class CachedRepository<T> {
CachedRepository({
required this.key,
required this.fetch,
required this.parse,
this.maxAge = const Duration(minutes: 10),
});
final String key;
final Fetcher fetch;
final T Function(Object? json) parse;
final Duration maxAge;
/// Emits the stored copy first (if any), then the fresh copy when it arrives.
Stream<Cached<T>> watch({bool force = false}) async* {
final prefs = await SharedPreferences.getInstance();
DateTime? savedAt;
final raw = prefs.getString('cache:$key');
if (raw != null) {
final env = jsonDecode(raw) as Map<String, dynamic>;
savedAt = DateTime.fromMillisecondsSinceEpoch(env['t'] as int);
yield Cached(parse(env['d']), fromCache: true, savedAt: savedAt);
final fresh = DateTime.now().difference(savedAt) < maxAge;
if (fresh && !force) return; // still valid, skip the network
}
try {
final json = await fetch();
final now = DateTime.now();
await prefs.setString('cache:$key', jsonEncode({'t': now.millisecondsSinceEpoch, 'd': json}));
yield Cached(parse(json), fromCache: false, savedAt: now);
} catch (e) {
// Offline is fine when something was already shown; otherwise surface it.
if (savedAt == null) rethrow;
}
}
Future<void> invalidate() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove('cache:$key');
}
}
Needs: shared_preferences@^2.3.0
How it works
What it solves
Shops and listings should open instantly and stay usable in a lift or on a train. Showing a spinner until the network answers, or an error screen when it cannot, makes the app feel fragile.
How it works
CachedRepository.watch is an async* stream with up to two events. If a stored envelope exists, it is emitted immediately with fromCache: true. When the copy is younger than maxAge, the stream ends without touching the network; otherwise it fetches, writes a new envelope containing a timestamp and the raw JSON, and emits the fresh value.
Network errors are swallowed only when something was already shown. With no cache at all the exception propagates, so the UI can render a real error with a retry button. The envelope stores decoded JSON, not model objects, so model changes only need an updated parse.
Adapting it
Bind the stream to StreamBuilder and show a thin banner when fromCache is true. Replace shared_preferences with a file or database store for large payloads. Call invalidate after mutations such as placing an order, or call watch(force: true) on pull-to-refresh.
Pitfalls
shared_preferencesis meant for small data; move big lists to a database.- Bump the key (for example
catalog:v2) when the JSON shape changes incompatibly. - Cached data may contain user specific content, so clear keys on logout.
How to use it
Add shared_preferences to pubspec.yaml and place the file in lib/data/.
final repo = CachedRepository<List<Product>>(
key: 'products', fetch: api.getProducts,
parse: (j) => (j as List).map((e) => Product.fromJson(e)).toList(),
);
StreamBuilder(stream: repo.watch(), builder: ...)
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.