2026-03-29 20:26:30 +08:00
|
|
|
import 'dart:async';
|
|
|
|
|
import 'dart:convert';
|
|
|
|
|
|
|
|
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
|
|
2026-03-30 09:06:24 +08:00
|
|
|
import 'cache_scope.dart';
|
|
|
|
|
|
2026-03-29 20:26:30 +08:00
|
|
|
class CacheEntry<T> {
|
|
|
|
|
const CacheEntry({required this.value, required this.fetchedAt});
|
|
|
|
|
|
|
|
|
|
final T value;
|
|
|
|
|
final DateTime fetchedAt;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-20 15:23:56 +08:00
|
|
|
abstract class CacheStore {
|
|
|
|
|
Future<T?> read<T>(String key);
|
|
|
|
|
Future<void> write<T>(String key, T value);
|
|
|
|
|
Future<void> remove(String key);
|
|
|
|
|
}
|
2026-03-29 20:26:30 +08:00
|
|
|
|
|
|
|
|
class MemoryCacheStore implements CacheStore {
|
|
|
|
|
final Map<String, Object?> _values = <String, Object?>{};
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<T?> read<T>(String key) async {
|
|
|
|
|
final value = _values[key];
|
|
|
|
|
if (value is T) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> write<T>(String key, T value) async {
|
|
|
|
|
_values[key] = value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> remove(String key) async {
|
|
|
|
|
_values.remove(key);
|
|
|
|
|
}
|
2026-03-30 18:36:57 +08:00
|
|
|
|
|
|
|
|
Future<void> removeByPrefix(String prefix) async {
|
|
|
|
|
_values.removeWhere((key, _) => key.startsWith(prefix));
|
|
|
|
|
}
|
2026-03-29 20:26:30 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class SharedPrefsCacheStore implements CacheStore {
|
|
|
|
|
SharedPrefsCacheStore({required SharedPreferences prefs}) : _prefs = prefs;
|
|
|
|
|
|
|
|
|
|
final SharedPreferences _prefs;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<T?> read<T>(String key) async {
|
|
|
|
|
final raw = _prefs.getString(key);
|
|
|
|
|
if (raw == null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
return CacheCodec.decode<T>(raw);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
await _prefs.remove(key);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> write<T>(String key, T value) async {
|
|
|
|
|
final encoded = CacheCodec.encode<T>(value);
|
|
|
|
|
await _prefs.setString(key, encoded);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> remove(String key) {
|
|
|
|
|
return _prefs.remove(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class PersistentCacheStore implements CacheStore {
|
|
|
|
|
SharedPreferences? _prefs;
|
|
|
|
|
Future<SharedPreferences>? _prefsFuture;
|
|
|
|
|
final Map<String, Object?> _fallbackValues = <String, Object?>{};
|
|
|
|
|
|
|
|
|
|
PersistentCacheStore({SharedPreferences? prefs}) : _prefs = prefs;
|
|
|
|
|
|
|
|
|
|
Future<SharedPreferences?> _getPrefs() {
|
|
|
|
|
if (_prefs != null) {
|
|
|
|
|
return Future<SharedPreferences?>.value(_prefs);
|
|
|
|
|
}
|
|
|
|
|
final inFlight = _prefsFuture;
|
|
|
|
|
if (inFlight != null) {
|
|
|
|
|
return inFlight.then<SharedPreferences?>((prefs) => prefs);
|
|
|
|
|
}
|
|
|
|
|
final created = SharedPreferences.getInstance();
|
|
|
|
|
_prefsFuture = created;
|
|
|
|
|
return created
|
|
|
|
|
.then<SharedPreferences?>((prefs) {
|
|
|
|
|
_prefs = prefs;
|
|
|
|
|
return prefs;
|
|
|
|
|
})
|
|
|
|
|
.catchError((_) {
|
|
|
|
|
_prefsFuture = null;
|
|
|
|
|
return null;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<T?> read<T>(String key) async {
|
|
|
|
|
final prefs = await _getPrefs();
|
|
|
|
|
if (prefs == null) {
|
|
|
|
|
final value = _fallbackValues[key];
|
|
|
|
|
if (value is T) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
final store = SharedPrefsCacheStore(prefs: prefs);
|
|
|
|
|
return store.read<T>(key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> write<T>(String key, T value) async {
|
|
|
|
|
final prefs = await _getPrefs();
|
|
|
|
|
if (prefs == null) {
|
|
|
|
|
_fallbackValues[key] = value;
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final store = SharedPrefsCacheStore(prefs: prefs);
|
|
|
|
|
await store.write<T>(key, value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Future<void> remove(String key) async {
|
|
|
|
|
final prefs = await _getPrefs();
|
|
|
|
|
if (prefs == null) {
|
|
|
|
|
_fallbackValues.remove(key);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final store = SharedPrefsCacheStore(prefs: prefs);
|
|
|
|
|
await store.remove(key);
|
|
|
|
|
}
|
2026-03-30 18:36:57 +08:00
|
|
|
|
|
|
|
|
Future<void> removeByPrefix(String prefix) async {
|
|
|
|
|
final prefs = await _getPrefs();
|
|
|
|
|
if (prefs == null) {
|
|
|
|
|
_fallbackValues.removeWhere((key, _) => key.startsWith(prefix));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
final targets = prefs.getKeys().where((key) => key.startsWith(prefix));
|
|
|
|
|
for (final key in targets) {
|
|
|
|
|
await prefs.remove(key);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-29 20:26:30 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class HybridCacheStore {
|
|
|
|
|
final CacheStore memory;
|
|
|
|
|
final CacheStore persistent;
|
|
|
|
|
final Map<String, Future<dynamic>> _inflight = <String, Future<dynamic>>{};
|
|
|
|
|
|
|
|
|
|
HybridCacheStore({required this.memory, required this.persistent});
|
|
|
|
|
|
|
|
|
|
Future<T?> read<T>(String key) async {
|
|
|
|
|
final memoryValue = await memory.read<T>(key);
|
|
|
|
|
if (memoryValue != null) {
|
|
|
|
|
return memoryValue;
|
|
|
|
|
}
|
|
|
|
|
final persistentValue = await persistent.read<T>(key);
|
|
|
|
|
if (persistentValue != null) {
|
|
|
|
|
await memory.write(key, persistentValue);
|
|
|
|
|
}
|
|
|
|
|
return persistentValue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> write<T>(String key, T value) async {
|
|
|
|
|
await memory.write<T>(key, value);
|
|
|
|
|
await persistent.write<T>(key, value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> remove(String key) async {
|
|
|
|
|
await memory.remove(key);
|
|
|
|
|
await persistent.remove(key);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-30 18:36:57 +08:00
|
|
|
Future<void> clearByPrefix(String prefix) async {
|
|
|
|
|
if (memory is MemoryCacheStore) {
|
|
|
|
|
await (memory as MemoryCacheStore).removeByPrefix(prefix);
|
|
|
|
|
}
|
|
|
|
|
if (persistent is PersistentCacheStore) {
|
|
|
|
|
await (persistent as PersistentCacheStore).removeByPrefix(prefix);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-29 20:26:30 +08:00
|
|
|
Future<T> getOrLoad<T>(String key, {required Future<T> Function() loader}) {
|
|
|
|
|
final running = _inflight[key];
|
|
|
|
|
if (running != null) {
|
|
|
|
|
return running.then((value) => value as T);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final future = () async {
|
|
|
|
|
final cached = await read<T>(key);
|
|
|
|
|
if (cached != null) {
|
|
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final loaded = await loader();
|
|
|
|
|
await write<T>(key, loaded);
|
|
|
|
|
return loaded;
|
|
|
|
|
}();
|
|
|
|
|
|
|
|
|
|
_inflight[key] = future;
|
|
|
|
|
return future.whenComplete(() {
|
|
|
|
|
_inflight.remove(key);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class CacheInvalidator {
|
|
|
|
|
final HybridCacheStore? _store;
|
|
|
|
|
|
|
|
|
|
CacheInvalidator({HybridCacheStore? store}) : _store = store;
|
|
|
|
|
|
|
|
|
|
void invalidate(String key) {
|
|
|
|
|
final store = _store;
|
|
|
|
|
if (store != null) {
|
2026-03-30 09:06:24 +08:00
|
|
|
unawaited(store.remove(CacheScope.scopedKey(key)));
|
2026-03-29 20:26:30 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void invalidateCalendarDay(DateTime date) {
|
|
|
|
|
final month = '${date.year}-${date.month.toString().padLeft(2, '0')}';
|
|
|
|
|
final day = '$month-${date.day.toString().padLeft(2, '0')}';
|
|
|
|
|
invalidate('calendar:day:$day');
|
|
|
|
|
invalidate('calendar:month:$month');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class CacheCodec {
|
|
|
|
|
static String encode<T>(T value) {
|
|
|
|
|
final payload = <String, Object?>{'type': '$T'};
|
|
|
|
|
if (value is CacheEntry) {
|
|
|
|
|
payload['entryType'] = '${value.value.runtimeType}';
|
|
|
|
|
payload['fetchedAt'] = value.fetchedAt.toIso8601String();
|
|
|
|
|
payload['value'] = _encodeValue(value.value);
|
|
|
|
|
} else {
|
|
|
|
|
payload['value'] = _encodeValue(value);
|
|
|
|
|
}
|
|
|
|
|
return jsonEncode(payload);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static T decode<T>(String raw) {
|
|
|
|
|
final decoded = jsonDecode(raw);
|
|
|
|
|
if (decoded is! Map<String, dynamic>) {
|
|
|
|
|
throw const FormatException('Invalid cache payload');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final value = decoded['value'];
|
|
|
|
|
|
|
|
|
|
if (T.toString().startsWith('CacheEntry<')) {
|
|
|
|
|
final fetchedAtRaw = decoded['fetchedAt'];
|
|
|
|
|
final fetchedAt = DateTime.parse(fetchedAtRaw as String);
|
|
|
|
|
final entryType = (decoded['entryType'] as String?) ?? 'Object?';
|
|
|
|
|
final decodedValue = _decodeValue(entryType, value);
|
|
|
|
|
|
|
|
|
|
switch (entryType) {
|
|
|
|
|
case 'String':
|
|
|
|
|
return CacheEntry<String>(
|
|
|
|
|
value: decodedValue as String,
|
|
|
|
|
fetchedAt: fetchedAt,
|
|
|
|
|
)
|
|
|
|
|
as T;
|
|
|
|
|
case 'int':
|
|
|
|
|
return CacheEntry<int>(
|
|
|
|
|
value: decodedValue as int,
|
|
|
|
|
fetchedAt: fetchedAt,
|
|
|
|
|
)
|
|
|
|
|
as T;
|
|
|
|
|
case 'double':
|
|
|
|
|
return CacheEntry<double>(
|
|
|
|
|
value: decodedValue as double,
|
|
|
|
|
fetchedAt: fetchedAt,
|
|
|
|
|
)
|
|
|
|
|
as T;
|
|
|
|
|
case 'bool':
|
|
|
|
|
return CacheEntry<bool>(
|
|
|
|
|
value: decodedValue as bool,
|
|
|
|
|
fetchedAt: fetchedAt,
|
|
|
|
|
)
|
|
|
|
|
as T;
|
|
|
|
|
default:
|
|
|
|
|
return CacheEntry<Object?>(value: decodedValue, fetchedAt: fetchedAt)
|
|
|
|
|
as T;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final type = (decoded['type'] as String?) ?? '$T';
|
|
|
|
|
return _decodeValue(type, value) as T;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static Object? _encodeValue(Object? value) {
|
|
|
|
|
if (value == null || value is String || value is num || value is bool) {
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
if (value is DateTime) {
|
|
|
|
|
return value.toIso8601String();
|
|
|
|
|
}
|
|
|
|
|
if (value is List) {
|
|
|
|
|
return value.map(_encodeValue).toList();
|
|
|
|
|
}
|
|
|
|
|
if (value is Map) {
|
|
|
|
|
return value.map((k, v) => MapEntry(k.toString(), _encodeValue(v)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
throw StateError('Unsupported cached value type: ${value.runtimeType}');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static Object? _decodeValue(String type, Object? raw) {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case 'String':
|
|
|
|
|
return raw as String? ?? '';
|
|
|
|
|
case 'int':
|
|
|
|
|
if (raw is int) {
|
|
|
|
|
return raw;
|
|
|
|
|
}
|
|
|
|
|
if (raw is num) {
|
|
|
|
|
return raw.toInt();
|
|
|
|
|
}
|
|
|
|
|
throw const FormatException('Invalid int cache payload');
|
|
|
|
|
case 'double':
|
|
|
|
|
if (raw is double) {
|
|
|
|
|
return raw;
|
|
|
|
|
}
|
|
|
|
|
if (raw is num) {
|
|
|
|
|
return raw.toDouble();
|
|
|
|
|
}
|
|
|
|
|
throw const FormatException('Invalid double cache payload');
|
|
|
|
|
case 'bool':
|
|
|
|
|
return raw as bool? ?? false;
|
|
|
|
|
case 'DateTime':
|
|
|
|
|
return DateTime.parse(raw as String);
|
|
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final listType = _extractListType(type);
|
|
|
|
|
if (listType != null) {
|
|
|
|
|
if (raw is! List) {
|
|
|
|
|
throw FormatException('Invalid list cache payload for type $type');
|
|
|
|
|
}
|
|
|
|
|
return raw
|
|
|
|
|
.map((item) => _decodeValue(listType, item))
|
|
|
|
|
.toList(growable: false);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (raw is Map) {
|
|
|
|
|
return Map<String, dynamic>.from(raw);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return raw;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static String? _extractListType(String type) {
|
|
|
|
|
final listMatch = RegExp(r'^List<(.+)>$').firstMatch(type);
|
|
|
|
|
if (listMatch == null) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return listMatch.group(1);
|
|
|
|
|
}
|
|
|
|
|
}
|