import 'dart:async'; import 'dart:collection'; import 'exceptions.dart'; import 'settings_store.dart'; import 'setting.dart'; /// A comprehensive settings group that manages related settings with persistence, /// initialization, and type-safe access. /// /// [SettingsGroup] extends [UnmodifiableMapBase] to provide convenient /// map-like access to settings while managing their persistence and validation. /// Each group has a unique key namespace and handles its own initialization. /// /// Usage pattern: /// ```dart /// // 1. Define your settings group /// final gameSettings = SettingsGroup( /// key: 'game', /// items: [ /// BoolSetting(key: 'soundEnabled', defaultValue: true), /// DoubleSetting(key: 'volume', defaultValue: 0.8), /// ], /// ); /// /// // 2. Register with the global settings manager /// Settings.register(gameSettings); /// /// // 3. Wait for initialization /// await gameSettings.readyFuture; /// /// // 4. Use the settings /// bool soundEnabled = gameSettings.get('soundEnabled'); /// await gameSettings.setValue('volume', 0.5); /// ``` class SettingsGroup extends UnmodifiableMapBase { /// Reference to the singleton settings store for persistence. /// /// This store handles the actual reading and writing of values /// to SharedPreferences with caching for performance. late final SettingsStore _store; /// Unique identifier for this settings group. /// /// This key is used as a namespace prefix for all settings in this group. /// For example, if key is 'game' and a setting key is 'volume', /// the stored key becomes 'game.volume'. /// /// Should be descriptive and unique across your application. final String key; /// Immutable set of all settings contained in this group. /// /// This set is created during construction and cannot be modified afterward. /// It contains all the setting objects that belong to this group. late final Set> items; /// Internal cache of setting keys for efficient lookups. /// /// This set contains the string keys of all settings in the group, /// providing O(1) key existence checks and fast iteration. late final Set _keys; /// Internal flag tracking initialization status. bool _ready = false; /// Public property indicating whether this settings group is ready for use. /// /// When false, accessing setting values will throw [SettingsNotReadyException]. /// When true, all settings have been loaded and are available synchronously. bool get ready => _ready; /// Internal completer that completes when initialization finishes. late Completer _readyCompleter; /// Future that completes when all settings in this group are initialized. /// /// Await this future before accessing setting values to ensure they've /// been loaded from storage. The future completes with true on success /// or throws an exception if initialization fails. /// /// Example: /// ```dart /// await gameSettings.readyFuture; /// // Now safe to access settings synchronously /// bool soundEnabled = gameSettings.get('soundEnabled'); /// ``` Future get readyFuture => _readyCompleter.future; /// Creates a new settings group with the given key and settings. /// /// The provided [items] are converted to an immutable set, and their /// keys are extracted for efficient access. Duplicate keys within /// the same group are not allowed and will cause undefined behavior. /// /// Parameters: /// - [key]: Unique identifier for this settings group /// - [items]: Collection of settings to include in this group /// - [forceRegularSharedPreferences]: Whether to force regular SharedPreferences (for testing) /// /// Example: /// ```dart /// final group = SettingsGroup( /// key: 'game', /// items: [ /// BoolSetting(key: 'notifications', defaultValue: true), /// IntSetting(key: 'timeout', defaultValue: 30), /// ], /// ); /// ``` SettingsGroup({ required this.key, required Iterable items, bool forceRegularSharedPreferences = false, }) { this.items = Set.from(items); _keys = items.map((item) => item.key).toSet(); _store = SettingsStore( forceRegularSharedPreferences: forceRegularSharedPreferences, ); _readyCompleter = Completer(); // Initialize the settings in the storage if they haven't been set yet. _init(); } /// Creates a new settings group optimized for testing. /// This constructor forces the use of regular SharedPreferences instead /// of SharedPreferencesWithCache to avoid test compatibility issues. SettingsGroup.forTesting({ required this.key, required Iterable items, }) { this.items = Set.from(items); _keys = items.map((item) => item.key).toSet(); _store = SettingsStore(forceRegularSharedPreferences: true); _readyCompleter = Completer(); // Initialize the settings in the storage if they haven't been set yet. _init(); } /// Retrieves a setting by its key. /// /// This operator provides map-like access to settings within the group. /// The return type is [Setting] to accommodate different setting types. /// /// Parameters: /// - [key]: The string key of the setting to retrieve /// /// Returns: The setting object with the specified key or null if not found. /// /// Example: /// ```dart /// Setting volumeSetting = audioGroup['volume']; /// BoolSetting enabledSetting = audioGroup['enabled'] as BoolSetting; /// ``` @override Setting? operator [](Object? key) { try { return items.firstWhere((item) => item.key == key); } catch (_) { return null; } } /// Returns an iterable of all setting keys in this group. /// /// This property provides the keys needed for map-like iteration /// and key existence checking. /// /// Returns: Iterable containing all setting keys as strings @override Iterable get keys => _keys; /// Returns the number of settings in this group. /// /// This count includes all settings regardless of their type /// or configurability status. /// /// Returns: Integer count of settings in the group @override int get length => _keys.length; /// Initializes the settings by checking if they are set in the storage. /// If not, it sets them with their default values. /// This is called in the constructor to ensure settings are ready to use. /// It waits for the store to be ready before proceeding, but there is no /// guarantee that the settings are initialized before the first access. /// If you need to ensure settings are initialized before use, you should /// await the [readyFuture] before accessing any settings. Future _init() async { try { if (!_store.ready) { await _store.readyFuture; } for (final Setting setting in items) { final storageKey = _storageKey(setting.key); if (!_store.prefs.containsKey(storageKey)) { // If the setting is not set, initialize it with the default value. await _set(storageKey, setting, null, force: true); } else { // Validate existing value and reset to default if invalid try { final currentValue = _get(setting); if (setting.validator != null && !setting.validate(currentValue)) { await _set(storageKey, setting, null, force: true); } } catch (e) { // If there's an error reading the current value, reset to default await _set(storageKey, setting, null, force: true); } } } _ready = true; _readyCompleter.complete(true); } catch (error) { _ready = false; _readyCompleter.completeError(error); rethrow; } } /// Sets the value of a setting by its key. Future setValue(String key, T value) async { await _waitUntilReady(); final setting = this[key]; if (setting == null) { throw SettingNotFoundException( 'No setting in ${this.key} found for key: $key', ); } final storageKey = _storageKey(setting.key); if (!setting.userConfigurable) { throw SettingNotConfigurableException( 'Setting $storageKey is not user configurable', ); } // Validate the value if a validator is provided if (setting is Setting && !setting.validate(value)) { throw SettingValidationException( 'Invalid value for setting $storageKey: $value', ); } await _set(storageKey, setting, value); // Notify change listeners if (setting is Setting) { setting.notifyChange(value); } } /// Convenience method to get a typed value of a setting by its key. /// Throws an error if the setting is not found or if the type does not match. T get(String key) { _readySync(); if (T == dynamic) { return getValue(key); } final setting = this[key]; if (setting == null) { throw SettingNotFoundException( 'No setting in ${this.key} found for key: $key', ); } if (setting is! Setting) { throw ArgumentError( 'Setting $key is not of type ${T.runtimeType}, but ${setting.type}', ); } return _get(setting); } /// Gets the value of a setting by its key. dynamic getValue(String key) { _readySync(); final setting = this[key]; if (setting == null) { throw SettingNotFoundException( 'No setting in ${this.key} found for key: $key', ); } return _get(setting); } /// Ensures that the settings are ready before accessing them. /// Throws a [SettingsNotReadyException] if the settings are not ready. void _readySync() { if (!_ready) { throw SettingsNotReadyException( 'Settings are not ready. Please await readyFuture.', ); } } /// Waits until the settings are ready. /// This is useful for asynchronous operations that need to ensure /// settings are initialized. Future _waitUntilReady() async { if (!_ready) { await _readyCompleter.future; } } /// Constructs a storage key for the given key in this settings group. /// This is used to namespace the settings keys to avoid conflicts. /// For example, if the group key is "game" and the setting key is /// "fullscreen", the storage key will be "game.fullscreen". String _storageKey(String key) { return "${this.key}.$key"; } T _validateOrDefault(Setting setting, T? value) { if (value == null) return setting.defaultValue; if (setting.validator != null && !setting.validate(value)) { // return default value if validation fails return setting.defaultValue; } return value; } /// Gets the value of a setting by its key and type. /// Throws an error if the setting is not found or if the type does not match. /// This method is used internally to retrieve the value of a setting. T _get(Setting setting) { final storageKey = _storageKey(setting.key); if (!_store.prefs.containsKey(storageKey)) { // If not found in storage, return default value return setting.defaultValue; } try { switch (T) { case const (bool): final value = _store.prefs.getBool(storageKey); // validate the value if a validator is provided return _validateOrDefault(setting, value as T); case const (int): final value = _store.prefs.getInt(storageKey); return _validateOrDefault(setting, value as T); case const (double): final value = _store.prefs.getDouble(storageKey); return _validateOrDefault(setting, value as T); case const (String): final value = _store.prefs.getString(storageKey); return _validateOrDefault(setting, value as T); default: throw ArgumentError('Unsupported setting type: ${T.runtimeType}'); } } catch (e) { // If there's a type mismatch or other error, return default value return setting.defaultValue; } } /// Sets the value of a setting by its key and type. /// Throws an error if the setting is not found or if the type does not match. /// This method is used internally to set the value of a setting. /// If [force] is true, it will set the value even if the setting is /// not user configurable. /// If [value] is null, it will use the default value of the setting. /// If the setting is not user configurable and [force] is false, /// it will throw an error. Future _set( String storageKey, Setting setting, T? value, { bool force = false, }) async { if (!force && !setting.userConfigurable) { throw SettingNotConfigurableException( 'Setting $storageKey is not user configurable', ); } if (!force && !_store.prefs.containsKey(storageKey)) { throw SettingNotFoundException('No setting found for: $storageKey'); } switch (T) { case const (bool): value ??= setting.defaultValue; return _setBool(storageKey, value as bool); case const (int): value ??= setting.defaultValue; return _setInt(storageKey, value as int); case const (double): value ??= setting.defaultValue; return _setDouble(storageKey, value as double); case const (String): value ??= setting.defaultValue; return _setString(storageKey, value as String); case const (dynamic): // If the type is dynamic, we can return any value. // This is a fallback for when the type is not known at compile time. // it is less efficient, but let's face it, you probably should not be // updating settings 1000s of times per second. value ??= setting.defaultValue; switch (setting.type) { case SettingType.bool: return _setBool(storageKey, value as bool); case SettingType.int: return _setInt(storageKey, value as int); case SettingType.double: return _setDouble(storageKey, value as double); case SettingType.string: return _setString(storageKey, value as String); } default: throw ArgumentError('Unsupported setting type: ${T.runtimeType}'); } } /// Sets a boolean value for the given storage key. Future _setBool(String storageKey, bool value) async { await _store.prefs.setBool(storageKey, value); } /// Sets an integer value for the given storage key. Future _setInt(String storageKey, int value) async { await _store.prefs.setInt(storageKey, value); } /// Sets a double value for the given storage key. Future _setDouble(String storageKey, double value) async { await _store.prefs.setDouble(storageKey, value); } /// Sets a string value for the given storage key. Future _setString(String storageKey, String value) async { await _store.prefs.setString(storageKey, value); } /// Reset a setting to its default value. Future reset(String key) async { await _waitUntilReady(); final setting = this[key]; if (setting == null) { throw SettingNotFoundException( 'No setting in ${this.key} found for key: $key', ); } final storageKey = _storageKey(setting.key); await _set(storageKey, setting, null, force: true); // Notify change listeners setting.notifyChange(setting.defaultValue); } /// Reset all settings in this group to their default values. Future resetAll() async { await _waitUntilReady(); for (final setting in items) { final storageKey = _storageKey(setting.key); await _set(storageKey, setting, null, force: true); setting.notifyChange(setting.defaultValue); } } /// Dispose all stream controllers for settings in this group. void dispose() { for (final setting in items) { setting.dispose(); } } }