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 _instances = {}; // Single shared cache across all subclasses so writes from one service are // visible to reads from another without per-instance cache divergence. static Future? _cacheFuture; 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 initializeInstance(T Function() constructor) async { if (_instances[T] == null) { final instance = constructor(); _instances[T] = instance; instance._cache = await sharedCache(); await instance.onInit(); } return _instances[T] as 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 sharedCache() { return _cacheFuture ??= () 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()); }(); } /// 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() { _instances.clear(); _cacheFuture = null; } /// 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 readStringList(String key, {List 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 writeBool(String key, bool value) => _cache.setBool(key, value); Future writeInt(String key, int value) => _cache.setInt(key, value); Future writeDouble(String key, double value) => _cache.setDouble(key, value); Future writeString(String key, String value) => _cache.setString(key, value); Future writeStringList(String key, List 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 decodeJsonStringToMap(String jsonString, {bool legacyStringOk = false}) { try { return json.decode(jsonString) as Map; } catch (e) { if (legacyStringOk) { return {'key': jsonString, 'descending': false}; } return {}; } } /// Read a value typed by [pref]; falls back to its `defaultValue`. T read(Pref 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 write(Pref pref, T value) async { await pref.writeTo(this, value); final n = _listenables[pref.key]; if (n != null) (n as ValueNotifier).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> _listenables = {}; final Map> _listenablePrefs = {}; ValueNotifier listenable(Pref 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 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 onInit() async {} } /// Typed preference declaration. Pair with [BaseSharedPreferencesService.read] /// and [BaseSharedPreferencesService.write] to remove per-setting `?? default` /// boilerplate. abstract class Pref { final String key; const Pref(this.key); /// Implementation hook — call [BaseSharedPreferencesService.read] instead. T readFrom(BaseSharedPreferencesService svc); /// Implementation hook — call [BaseSharedPreferencesService.write] instead. Future 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` reference (used by /// [BaseSharedPreferencesService.listenableOf]). ValueNotifier bindListenable(BaseSharedPreferencesService svc) { final existing = svc._listenables[key]; svc._listenablePrefs[key] = this; if (existing != null) return existing as ValueNotifier; final notifier = ValueNotifier(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).value = readFrom(svc); } } class BoolPref extends Pref { final bool defaultValue; final void Function(bool)? onWrite; const BoolPref(super.key, {this.defaultValue = false, this.onWrite}); @override bool readFrom(BaseSharedPreferencesService svc) => svc.readBool(key, defaultValue: defaultValue); @override Future writeTo(BaseSharedPreferencesService svc, bool value) async { await svc.writeBool(key, value); onWrite?.call(value); } } class IntPref extends Pref { 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 writeTo(BaseSharedPreferencesService svc, int value) => svc.writeInt(key, transform == null ? value : transform!(value)); } class DoublePref extends Pref { 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 writeTo(BaseSharedPreferencesService svc, double value) => svc.writeDouble(key, transform == null ? value : transform!(value)); } class StringPref extends Pref { final String defaultValue; const StringPref(super.key, {this.defaultValue = ''}); @override String readFrom(BaseSharedPreferencesService svc) => svc.readString(key, defaultValue: defaultValue); @override Future 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 { final String? Function(String?)? transform; const NullableStringPref(super.key, {this.transform}); @override String? readFrom(BaseSharedPreferencesService svc) => svc.prefs.getString(key); @override Future 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> { final List defaultValue; const StringListPref(super.key, {this.defaultValue = const []}); @override List readFrom(BaseSharedPreferencesService svc) => svc.readStringList(key, defaultValue: defaultValue); @override Future writeTo(BaseSharedPreferencesService svc, List value) => svc.writeStringList(key, value); } /// Stores an enum by its [Enum.name]; falls back to [defaultValue] when the /// stored string doesn't match any value in [values]. class EnumPref extends Pref { final List values; final T defaultValue; const EnumPref(super.key, {required this.values, required this.defaultValue}); @override T readFrom(BaseSharedPreferencesService svc) { final stored = svc.prefs.getString(key); if (stored == null) return defaultValue; return values.firstWhere((v) => v.name == stored, orElse: () => defaultValue); } @override Future 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 extends Pref { 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 writeTo(BaseSharedPreferencesService svc, T value) => svc.writeString(key, encode(value)); }