347 lines
14 KiB
Dart
347 lines
14 KiB
Dart
import 'dart:convert';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:shared_preferences/util/legacy_to_async_migration_util.dart';
|
|
|
|
/// Base class for services that use SharedPreferences singleton pattern.
|
|
///
|
|
/// This class handles the boilerplate for singleton initialization and
|
|
/// SharedPreferences lifecycle management. Subclasses should:
|
|
/// 1. Create a private named constructor (e.g., SettingsService._())
|
|
/// 2. Implement their own getInstance() method that calls BaseSharedPreferencesService.initializeInstance()
|
|
/// 3. Optionally override onInit() for post-initialization setup
|
|
abstract class BaseSharedPreferencesService {
|
|
static final Map<Type, BaseSharedPreferencesService> _instances = {};
|
|
static final Map<Type, Future<BaseSharedPreferencesService>> _initializations = {};
|
|
static int _resetGeneration = 0;
|
|
// Single shared cache across all subclasses so writes from one service are
|
|
// visible to reads from another without per-instance cache divergence.
|
|
static Future<SharedPreferencesWithCache>? _cacheFuture;
|
|
static Future<SharedPreferencesWithCache> Function() _cacheLoader = _loadSharedCache;
|
|
|
|
late SharedPreferencesWithCache _cache;
|
|
|
|
BaseSharedPreferencesService();
|
|
|
|
SharedPreferencesWithCache get prefs => _cache;
|
|
|
|
/// Initialize the preferences instance.
|
|
///
|
|
/// This method handles:
|
|
/// - Singleton instance management
|
|
/// - One-time migration from the legacy SharedPreferences API to the
|
|
/// SharedPreferencesAsync-backed cache (idempotent across launches)
|
|
/// - Calling onInit() hook for subclass-specific setup
|
|
static Future<T> initializeInstance<T extends BaseSharedPreferencesService>(T Function() constructor) {
|
|
final initialized = _instances[T];
|
|
if (initialized != null) return Future<T>.value(initialized as T);
|
|
|
|
final inFlight = _initializations[T];
|
|
if (inFlight != null) return inFlight.then((instance) => instance as T);
|
|
|
|
final generation = _resetGeneration;
|
|
final initialization = () async {
|
|
final instance = constructor();
|
|
instance._cache = await sharedCache();
|
|
await instance.onInit();
|
|
if (generation != _resetGeneration) {
|
|
return initializeInstance<T>(constructor);
|
|
}
|
|
_instances[T] = instance;
|
|
return instance;
|
|
}();
|
|
_initializations[T] = initialization;
|
|
return initialization.whenComplete(() {
|
|
if (identical(_initializations[T], initialization)) {
|
|
_initializations.remove(T);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Shared preferences cache used app-wide. Runs the legacy → async
|
|
/// migration on first call; subsequent calls return the same future.
|
|
/// Use this from services that don't extend [BaseSharedPreferencesService].
|
|
static Future<SharedPreferencesWithCache> sharedCache() {
|
|
final cached = _cacheFuture;
|
|
if (cached != null) return cached;
|
|
|
|
late final Future<SharedPreferencesWithCache> loading;
|
|
loading = _cacheLoader().then(
|
|
(cache) => cache,
|
|
onError: (Object error, StackTrace stackTrace) {
|
|
// Do not poison every later startup with one transient plugin/storage
|
|
// failure. Identity keeps a superseding/reset load intact while all
|
|
// concurrent callers continue to share this attempt.
|
|
if (identical(_cacheFuture, loading)) _cacheFuture = null;
|
|
Error.throwWithStackTrace(error, stackTrace);
|
|
},
|
|
);
|
|
_cacheFuture = loading;
|
|
return loading;
|
|
}
|
|
|
|
static Future<SharedPreferencesWithCache> _loadSharedCache() async {
|
|
final legacy = await SharedPreferences.getInstance();
|
|
await migrateLegacySharedPreferencesToSharedPreferencesAsyncIfNecessary(
|
|
legacySharedPreferencesInstance: legacy,
|
|
sharedPreferencesAsyncOptions: const SharedPreferencesOptions(),
|
|
migrationCompletedKey: 'plezy_legacy_prefs_migrated_v1',
|
|
);
|
|
return SharedPreferencesWithCache.create(cacheOptions: const SharedPreferencesWithCacheOptions());
|
|
}
|
|
|
|
@visibleForTesting
|
|
static void setCacheLoaderForTesting(Future<SharedPreferencesWithCache> Function() loader) {
|
|
_cacheFuture = null;
|
|
_cacheLoader = loader;
|
|
}
|
|
|
|
/// Drop all cached singleton instances and the shared cache future so the
|
|
/// next `getInstance()` call rebuilds against the current
|
|
/// `SharedPreferences.setMockInitialValues(...)`. Test-only.
|
|
@visibleForTesting
|
|
static void resetForTesting() {
|
|
_resetGeneration++;
|
|
_initializations.clear();
|
|
_instances.clear();
|
|
_cacheFuture = null;
|
|
_cacheLoader = _loadSharedCache;
|
|
}
|
|
|
|
/// Typed read helpers — return the stored value or [defaultValue] when missing.
|
|
bool readBool(String key, {bool defaultValue = false}) => _cache.getBool(key) ?? defaultValue;
|
|
int readInt(String key, {int defaultValue = 0}) => _cache.getInt(key) ?? defaultValue;
|
|
double readDouble(String key, {double defaultValue = 0.0}) => _cache.getDouble(key) ?? defaultValue;
|
|
String readString(String key, {String defaultValue = ''}) => _cache.getString(key) ?? defaultValue;
|
|
List<String> readStringList(String key, {List<String> defaultValue = const []}) =>
|
|
_cache.getStringList(key) ?? defaultValue;
|
|
|
|
/// Typed write helpers — symmetric with the read helpers above; use these
|
|
/// instead of `prefs.setX(...)` so call sites stay terse.
|
|
Future<void> writeBool(String key, bool value) => _cache.setBool(key, value);
|
|
Future<void> writeInt(String key, int value) => _cache.setInt(key, value);
|
|
Future<void> writeDouble(String key, double value) => _cache.setDouble(key, value);
|
|
Future<void> writeString(String key, String value) => _cache.setString(key, value);
|
|
Future<void> writeStringList(String key, List<String> value) => _cache.setStringList(key, value);
|
|
|
|
/// Decode a JSON string to a Map with error handling.
|
|
///
|
|
/// If [legacyStringOk] is true and the value is a plain string (not valid
|
|
/// JSON), returns `{'key': jsonString, 'descending': false}` for legacy
|
|
/// library sort compatibility.
|
|
Map<String, dynamic> decodeJsonStringToMap(String jsonString, {bool legacyStringOk = false}) {
|
|
try {
|
|
return json.decode(jsonString) as Map<String, dynamic>;
|
|
} catch (e) {
|
|
if (legacyStringOk) {
|
|
return {'key': jsonString, 'descending': false};
|
|
}
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/// Read a value typed by [pref]; falls back to its `defaultValue`.
|
|
T read<T>(Pref<T> pref) => pref.readFrom(this);
|
|
|
|
/// Write a value typed by [pref]. Pushes the post-transform value into any
|
|
/// listenable previously vended for this key so widgets rebuild automatically.
|
|
Future<void> write<T>(Pref<T> pref, T value) async {
|
|
await pref.writeTo(this, value);
|
|
final n = _listenables[pref.key];
|
|
if (n != null) (n as ValueNotifier<T>).value = read(pref);
|
|
}
|
|
|
|
/// Lazy per-key [ValueNotifier]. Use with [ValueListenableBuilder] to rebuild
|
|
/// when the value changes. Notifiers live for the app lifetime — do not
|
|
/// dispose them.
|
|
final Map<String, ValueNotifier<dynamic>> _listenables = {};
|
|
final Map<String, Pref<dynamic>> _listenablePrefs = {};
|
|
|
|
ValueNotifier<T> listenable<T>(Pref<T> pref) => pref.bindListenable(this);
|
|
|
|
/// Type-erased [Listenable] accessor for combining multiple prefs into a
|
|
/// `Listenable.merge`. Dispatches through [Pref.bindListenable] so the
|
|
/// underlying notifier is created with the pref's concrete type.
|
|
Listenable listenableOf(Pref<Object?> pref) => pref.bindListenable(this);
|
|
|
|
/// Push current stored values into every active listenable. Used after bulk
|
|
/// operations that bypass [write] (reset/import/direct SharedPreferences writes).
|
|
void refreshActiveListenables() {
|
|
for (final pref in _listenablePrefs.values.toList(growable: false)) {
|
|
pref.refreshListenable(this);
|
|
}
|
|
}
|
|
|
|
/// Hook for subclass-specific initialization after SharedPreferences is ready.
|
|
///
|
|
/// Override this method to perform any setup that requires access to
|
|
/// SharedPreferences (e.g., registering values with other services).
|
|
Future<void> onInit() async {}
|
|
}
|
|
|
|
/// Typed preference declaration. Pair with [BaseSharedPreferencesService.read]
|
|
/// and [BaseSharedPreferencesService.write] to remove per-setting `?? default`
|
|
/// boilerplate.
|
|
abstract class Pref<T> {
|
|
final String key;
|
|
const Pref(this.key);
|
|
|
|
/// Implementation hook — call [BaseSharedPreferencesService.read] instead.
|
|
T readFrom(BaseSharedPreferencesService svc);
|
|
|
|
/// Implementation hook — call [BaseSharedPreferencesService.write] instead.
|
|
Future<void> writeTo(BaseSharedPreferencesService svc, T value);
|
|
|
|
/// Get-or-create the [ValueNotifier] for this pref. Virtual-dispatched via
|
|
/// the runtime [Pref] subclass so the notifier carries the concrete `T`,
|
|
/// even when called through a `Pref<Object?>` reference (used by
|
|
/// [BaseSharedPreferencesService.listenableOf]).
|
|
ValueNotifier<T> bindListenable(BaseSharedPreferencesService svc) {
|
|
final existing = svc._listenables[key];
|
|
svc._listenablePrefs[key] = this;
|
|
if (existing != null) return existing as ValueNotifier<T>;
|
|
final notifier = ValueNotifier<T>(readFrom(svc));
|
|
svc._listenables[key] = notifier;
|
|
return notifier;
|
|
}
|
|
|
|
/// If a listenable exists for this key, push the current stored value into
|
|
/// it. Used after bulk operations (reset, import) that bypass [writeTo].
|
|
/// No-op when no listener has been registered.
|
|
void refreshListenable(BaseSharedPreferencesService svc) {
|
|
final n = svc._listenables[key];
|
|
if (n != null) (n as ValueNotifier<T>).value = readFrom(svc);
|
|
}
|
|
}
|
|
|
|
class BoolPref extends Pref<bool> {
|
|
final bool defaultValue;
|
|
|
|
/// Lazily-resolved default for values that depend on state unavailable at
|
|
/// static-init time (e.g. async TV detection). Wins over [defaultValue].
|
|
final bool Function()? defaultValueProvider;
|
|
final void Function(bool)? onWrite;
|
|
const BoolPref(super.key, {this.defaultValue = false, this.defaultValueProvider, this.onWrite});
|
|
@override
|
|
bool readFrom(BaseSharedPreferencesService svc) =>
|
|
svc.readBool(key, defaultValue: defaultValueProvider?.call() ?? defaultValue);
|
|
@override
|
|
Future<void> writeTo(BaseSharedPreferencesService svc, bool value) async {
|
|
await svc.writeBool(key, value);
|
|
onWrite?.call(value);
|
|
}
|
|
}
|
|
|
|
class IntPref extends Pref<int> {
|
|
final int defaultValue;
|
|
final int Function(int)? transform;
|
|
const IntPref(super.key, {this.defaultValue = 0, this.transform});
|
|
@override
|
|
int readFrom(BaseSharedPreferencesService svc) {
|
|
final raw = svc.readInt(key, defaultValue: defaultValue);
|
|
return transform == null ? raw : transform!(raw);
|
|
}
|
|
|
|
@override
|
|
Future<void> writeTo(BaseSharedPreferencesService svc, int value) =>
|
|
svc.writeInt(key, transform == null ? value : transform!(value));
|
|
}
|
|
|
|
class DoublePref extends Pref<double> {
|
|
final double defaultValue;
|
|
final double Function(double)? transform;
|
|
const DoublePref(super.key, {this.defaultValue = 0.0, this.transform});
|
|
@override
|
|
double readFrom(BaseSharedPreferencesService svc) {
|
|
final raw = svc.readDouble(key, defaultValue: defaultValue);
|
|
return transform == null ? raw : transform!(raw);
|
|
}
|
|
|
|
@override
|
|
Future<void> writeTo(BaseSharedPreferencesService svc, double value) =>
|
|
svc.writeDouble(key, transform == null ? value : transform!(value));
|
|
}
|
|
|
|
class StringPref extends Pref<String> {
|
|
final String defaultValue;
|
|
const StringPref(super.key, {this.defaultValue = ''});
|
|
@override
|
|
String readFrom(BaseSharedPreferencesService svc) => svc.readString(key, defaultValue: defaultValue);
|
|
@override
|
|
Future<void> writeTo(BaseSharedPreferencesService svc, String value) => svc.writeString(key, value);
|
|
}
|
|
|
|
/// Like [StringPref] but null = key absent. [transform] runs on write; if it
|
|
/// returns null the key is removed.
|
|
class NullableStringPref extends Pref<String?> {
|
|
final String? Function(String?)? transform;
|
|
const NullableStringPref(super.key, {this.transform});
|
|
@override
|
|
String? readFrom(BaseSharedPreferencesService svc) => svc.prefs.getString(key);
|
|
@override
|
|
Future<void> writeTo(BaseSharedPreferencesService svc, String? value) async {
|
|
final normalized = transform == null ? value : transform!(value);
|
|
if (normalized == null) {
|
|
await svc.prefs.remove(key);
|
|
} else {
|
|
await svc.writeString(key, normalized);
|
|
}
|
|
}
|
|
}
|
|
|
|
class StringListPref extends Pref<List<String>> {
|
|
final List<String> defaultValue;
|
|
const StringListPref(super.key, {this.defaultValue = const []});
|
|
@override
|
|
List<String> readFrom(BaseSharedPreferencesService svc) => svc.readStringList(key, defaultValue: defaultValue);
|
|
@override
|
|
Future<void> writeTo(BaseSharedPreferencesService svc, List<String> value) => svc.writeStringList(key, value);
|
|
}
|
|
|
|
/// Stores an enum by its [Enum.name]; falls back to the default when the
|
|
/// stored string doesn't match any value in [values].
|
|
///
|
|
/// Exactly one of [defaultValue] / [defaultValueProvider] must be given; the
|
|
/// provider form resolves at read time, for defaults that depend on state
|
|
/// unavailable at static-init time (e.g. async TV detection).
|
|
class EnumPref<T extends Enum> extends Pref<T> {
|
|
final List<T> values;
|
|
final T? defaultValue;
|
|
final T Function()? defaultValueProvider;
|
|
const EnumPref(super.key, {required this.values, this.defaultValue, this.defaultValueProvider})
|
|
: assert((defaultValue != null) != (defaultValueProvider != null));
|
|
T get _default => defaultValueProvider?.call() ?? defaultValue!;
|
|
@override
|
|
T readFrom(BaseSharedPreferencesService svc) {
|
|
final stored = svc.prefs.getString(key);
|
|
if (stored == null) return _default;
|
|
return values.firstWhere((v) => v.name == stored, orElse: () => _default);
|
|
}
|
|
|
|
@override
|
|
Future<void> writeTo(BaseSharedPreferencesService svc, T value) => svc.writeString(key, value.name);
|
|
}
|
|
|
|
/// Stores an arbitrary value as a JSON-encoded string. Decode failures and
|
|
/// missing keys both fall back to [defaultValue].
|
|
class JsonPref<T> extends Pref<T> {
|
|
final T defaultValue;
|
|
final String Function(T) encode;
|
|
final T Function(dynamic) decode;
|
|
JsonPref(super.key, {required this.defaultValue, required this.encode, required this.decode});
|
|
|
|
@override
|
|
T readFrom(BaseSharedPreferencesService svc) {
|
|
final s = svc.prefs.getString(key);
|
|
if (s == null) return defaultValue;
|
|
try {
|
|
return decode(json.decode(s));
|
|
} catch (_) {
|
|
return defaultValue;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> writeTo(BaseSharedPreferencesService svc, T value) => svc.writeString(key, encode(value));
|
|
}
|