feat(cache): Add lightweight in-memory cache with TTL and LRU eviction

This commit is contained in:
cogwheel0
2025-11-22 21:53:14 +05:30
parent 8ed75f8f14
commit c4a36bb51c
14 changed files with 1298 additions and 242 deletions

View File

@@ -0,0 +1,121 @@
/// Lightweight in-memory cache with TTL enforcement and LRU eviction.
///
/// Centralizes cache handling so services can avoid duplicating map and
/// timestamp bookkeeping. Entries expire after [defaultTtl] and the cache is
/// trimmed to [maxEntries] using least-recently-used eviction.
class CacheManager {
CacheManager({
Duration defaultTtl = const Duration(minutes: 5),
int maxEntries = 64,
}) : _defaultTtl = defaultTtl,
_maxEntries = maxEntries;
final Duration _defaultTtl;
final int _maxEntries;
final Map<String, _CacheRecord> _entries = {};
/// Reads a cached value and returns whether the lookup was a hit.
({bool hit, T? value}) lookup<T>(String key) {
final record = _getRecord(key);
if (record == null) return (hit: false, value: null);
return (hit: true, value: record.value as T?);
}
/// Stores [value] with an optional [ttl] override.
void write<T>(String key, T? value, {Duration? ttl}) {
final now = DateTime.now();
_entries[key] = _CacheRecord(
value: value,
ttl: ttl ?? _defaultTtl,
createdAt: now,
lastAccessed: now,
);
_enforceLimits(now);
}
/// Removes a single cached entry.
void invalidate(String key) {
_entries.remove(key);
}
/// Removes entries that match [predicate].
void invalidateMatching(bool Function(String key) predicate) {
_entries.removeWhere((key, _) => predicate(key));
}
/// Clears all cached entries.
void clear() {
_entries.clear();
}
/// Current cache statistics for debugging and health checks.
Map<String, dynamic> stats() {
final now = DateTime.now();
return {
'size': _entries.length,
'maxEntries': _maxEntries,
'defaultTtlSeconds': _defaultTtl.inSeconds,
'entries': _entries.map((key, record) {
final age = now.difference(record.createdAt);
final idle = now.difference(record.lastAccessed);
return MapEntry(key, {
'ageSeconds': age.inSeconds,
'idleSeconds': idle.inSeconds,
'ttlSeconds': record.ttl.inSeconds,
});
}),
};
}
_CacheRecord? _getRecord(String key) {
final record = _entries[key];
if (record == null) return null;
final now = DateTime.now();
if (record.isExpired(now)) {
_entries.remove(key);
return null;
}
record.touch(now);
return record;
}
void _enforceLimits(DateTime now) {
_removeExpired(now);
if (_entries.length <= _maxEntries) return;
final oldestFirst = _entries.entries.toList()
..sort((a, b) => a.value.lastAccessed.compareTo(b.value.lastAccessed));
final overflow = oldestFirst.length - _maxEntries;
for (var i = 0; i < overflow; i++) {
_entries.remove(oldestFirst[i].key);
}
}
void _removeExpired(DateTime now) {
_entries.removeWhere((_, record) => record.isExpired(now));
}
}
class _CacheRecord {
_CacheRecord({
required this.value,
required this.ttl,
required this.createdAt,
required this.lastAccessed,
});
final Object? value;
final Duration ttl;
final DateTime createdAt;
DateTime lastAccessed;
bool isExpired(DateTime now) {
return now.difference(createdAt) > ttl;
}
void touch(DateTime now) {
lastAccessed = now;
}
}

View File

@@ -3,12 +3,18 @@ import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:hive_ce/hive.dart';
import '../models/backend_config.dart';
import '../models/conversation.dart';
import '../models/folder.dart';
import '../models/model.dart';
import '../models/server_config.dart';
import '../models/user.dart';
import '../models/tool.dart';
import '../models/socket_transport_availability.dart';
import '../persistence/hive_boxes.dart';
import '../persistence/persistence_keys.dart';
import '../utils/debug_logger.dart';
import 'cache_manager.dart';
import 'secure_credential_storage.dart';
import 'worker_manager.dart';
@@ -34,6 +40,7 @@ class OptimizedStorageService {
final Box<dynamic> _metadataBox;
final SecureCredentialStorage _secureCredentialStorage;
final WorkerManager _workerManager;
final CacheManager _cacheManager = CacheManager(maxEntries: 64);
static const String _authTokenKey = 'auth_token_v3';
static const String _activeServerIdKey = PreferenceKeys.activeServerId;
@@ -41,22 +48,25 @@ class OptimizedStorageService {
static const String _themePaletteKey = PreferenceKeys.themePalette;
static const String _localeCodeKey = PreferenceKeys.localeCode;
static const String _localConversationsKey = HiveStoreKeys.localConversations;
static const String _localUserKey = HiveStoreKeys.localUser;
static const String _localUserAvatarKey = HiveStoreKeys.localUserAvatar;
static const String _localBackendConfigKey = HiveStoreKeys.localBackendConfig;
static const String _localTransportOptionsKey =
HiveStoreKeys.localTransportOptions;
static const String _localToolsKey = HiveStoreKeys.localTools;
static const String _localDefaultModelKey = HiveStoreKeys.localDefaultModel;
static const String _localModelsKey = HiveStoreKeys.localModels;
static const String _localFoldersKey = HiveStoreKeys.localFolders;
static const String _onboardingSeenKey = PreferenceKeys.onboardingSeen;
static const String _reviewerModeKey = PreferenceKeys.reviewerMode;
final Map<String, dynamic> _cache = {};
final Map<String, DateTime> _cacheTimestamps = {};
static const Duration _cacheTimeout = Duration(minutes: 5);
// ---------------------------------------------------------------------------
// Auth token APIs (secure storage + in-memory cache)
// ---------------------------------------------------------------------------
Future<void> saveAuthToken(String token) async {
try {
await _secureCredentialStorage.saveAuthToken(token);
_cache[_authTokenKey] = token;
_cacheTimestamps[_authTokenKey] = DateTime.now();
_cacheManager.write(_authTokenKey, token);
DebugLogger.log(
'Auth token saved and cached',
scope: 'storage/optimized',
@@ -71,19 +81,17 @@ class OptimizedStorageService {
}
Future<String?> getAuthToken() async {
if (_isCacheValid(_authTokenKey)) {
final cached = _cache[_authTokenKey] as String?;
if (cached != null) {
DebugLogger.log('Using cached auth token', scope: 'storage/optimized');
return cached;
}
final (hit: hasCachedToken, value: cachedToken) = _cacheManager
.lookup<String>(_authTokenKey);
if (hasCachedToken) {
DebugLogger.log('Using cached auth token', scope: 'storage/optimized');
return cachedToken;
}
try {
final token = await _secureCredentialStorage.getAuthToken();
if (token != null) {
_cache[_authTokenKey] = token;
_cacheTimestamps[_authTokenKey] = DateTime.now();
_cacheManager.write(_authTokenKey, token);
}
return token;
} catch (error) {
@@ -98,8 +106,7 @@ class OptimizedStorageService {
Future<void> deleteAuthToken() async {
try {
await _secureCredentialStorage.deleteAuthToken();
_cache.remove(_authTokenKey);
_cacheTimestamps.remove(_authTokenKey);
_cacheManager.invalidate(_authTokenKey);
DebugLogger.log(
'Auth token deleted and cache cleared',
scope: 'storage/optimized',
@@ -129,8 +136,7 @@ class OptimizedStorageService {
password: password,
);
_cache['has_credentials'] = true;
_cacheTimestamps['has_credentials'] = DateTime.now();
_cacheManager.write('has_credentials', true);
DebugLogger.log(
'Credentials saved via optimized storage',
@@ -148,8 +154,7 @@ class OptimizedStorageService {
Future<Map<String, String>?> getSavedCredentials() async {
try {
final credentials = await _secureCredentialStorage.getSavedCredentials();
_cache['has_credentials'] = credentials != null;
_cacheTimestamps['has_credentials'] = DateTime.now();
_cacheManager.write('has_credentials', credentials != null);
return credentials;
} catch (error) {
DebugLogger.log(
@@ -163,8 +168,7 @@ class OptimizedStorageService {
Future<void> deleteSavedCredentials() async {
try {
await _secureCredentialStorage.deleteSavedCredentials();
_cache.remove('has_credentials');
_cacheTimestamps.remove('has_credentials');
_cacheManager.invalidate('has_credentials');
DebugLogger.log(
'Credentials deleted via optimized storage',
scope: 'storage/optimized',
@@ -180,8 +184,10 @@ class OptimizedStorageService {
}
Future<bool> hasCredentials() async {
if (_isCacheValid('has_credentials')) {
return _cache['has_credentials'] == true;
final (hit: hasCachedValue, value: hasCredentials) = _cacheManager
.lookup<bool>('has_credentials');
if (hasCachedValue) {
return hasCredentials == true;
}
final credentials = await getSavedCredentials();
return credentials != null;
@@ -194,8 +200,7 @@ class OptimizedStorageService {
try {
final jsonString = jsonEncode(configs.map((c) => c.toJson()).toList());
await _secureCredentialStorage.saveServerConfigs(jsonString);
_cache['server_config_count'] = configs.length;
_cacheTimestamps['server_config_count'] = DateTime.now();
_cacheManager.write('server_config_count', configs.length);
DebugLogger.log(
'Server configs saved (${configs.length} entries)',
scope: 'storage/optimized',
@@ -213,8 +218,7 @@ class OptimizedStorageService {
try {
final jsonString = await _secureCredentialStorage.getServerConfigs();
if (jsonString == null || jsonString.isEmpty) {
_cache['server_config_count'] = 0;
_cacheTimestamps['server_config_count'] = DateTime.now();
_cacheManager.write('server_config_count', 0);
return const [];
}
@@ -222,8 +226,7 @@ class OptimizedStorageService {
final configs = decoded
.map((item) => ServerConfig.fromJson(item))
.toList();
_cache['server_config_count'] = configs.length;
_cacheTimestamps['server_config_count'] = DateTime.now();
_cacheManager.write('server_config_count', configs.length);
return configs;
} catch (error) {
DebugLogger.log(
@@ -240,17 +243,18 @@ class OptimizedStorageService {
} else {
await _preferencesBox.delete(_activeServerIdKey);
}
_cache[_activeServerIdKey] = serverId;
_cacheTimestamps[_activeServerIdKey] = DateTime.now();
_cacheManager.write(_activeServerIdKey, serverId);
}
Future<String?> getActiveServerId() async {
if (_isCacheValid(_activeServerIdKey)) {
return _cache[_activeServerIdKey] as String?;
final (hit: hasCachedId, value: cachedId) = _cacheManager.lookup<String>(
_activeServerIdKey,
);
if (hasCachedId) {
return cachedId;
}
final serverId = _preferencesBox.get(_activeServerIdKey) as String?;
_cache[_activeServerIdKey] = serverId;
_cacheTimestamps[_activeServerIdKey] = DateTime.now();
_cacheManager.write(_activeServerIdKey, serverId);
return serverId;
}
@@ -392,6 +396,378 @@ class OptimizedStorageService {
}
}
Future<User?> getLocalUser() async {
try {
final stored = _cachesBox.get(_localUserKey);
if (stored == null) return null;
if (stored is String) {
final decoded = jsonDecode(stored);
if (decoded is Map<String, dynamic>) {
return User.fromJson(decoded);
}
} else if (stored is Map<String, dynamic>) {
return User.fromJson(stored);
}
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local user',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
return null;
}
Future<void> saveLocalUser(User? user) async {
try {
if (user == null) {
await _cachesBox.delete(_localUserKey);
await _cachesBox.delete(_localUserAvatarKey);
return;
}
final serialized = jsonEncode(user.toJson());
await _cachesBox.put(_localUserKey, serialized);
DebugLogger.log('Saved local user profile', scope: 'storage/optimized');
} catch (error, stack) {
DebugLogger.error(
'Failed to save local user',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
Future<String?> getLocalUserAvatar() async {
try {
final stored = _cachesBox.get(_localUserAvatarKey);
if (stored is String && stored.isNotEmpty) {
return stored;
}
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local user avatar',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
return null;
}
Future<void> saveLocalUserAvatar(String? avatarUrl) async {
try {
if (avatarUrl == null || avatarUrl.isEmpty) {
await _cachesBox.delete(_localUserAvatarKey);
return;
}
await _cachesBox.put(_localUserAvatarKey, avatarUrl);
} catch (error, stack) {
DebugLogger.error(
'Failed to save local user avatar',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
Future<BackendConfig?> getLocalBackendConfig() async {
try {
final stored = _cachesBox.get(_localBackendConfigKey);
if (stored == null) return null;
final activeServerId = await getActiveServerId();
final (payload, ownerServerId) = _unwrapServerScoped(stored);
if (!_matchesActiveServer(activeServerId, ownerServerId)) {
return null;
}
if (payload is String) {
final decoded = jsonDecode(payload);
if (decoded is Map<String, dynamic>) {
return BackendConfig.fromJson(decoded);
}
} else if (payload is Map) {
return BackendConfig.fromJson(Map<String, dynamic>.from(payload));
}
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local backend config',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
return null;
}
Future<void> saveLocalBackendConfig(BackendConfig? config) async {
try {
if (config == null) {
await _cachesBox.delete(_localBackendConfigKey);
return;
}
final serialized = jsonEncode(config.toJson());
await _cachesBox.put(
_localBackendConfigKey,
_wrapServerScoped(serialized),
);
} catch (error, stack) {
DebugLogger.error(
'Failed to save local backend config',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
Future<SocketTransportAvailability?> getLocalTransportOptions() async {
try {
final stored = _cachesBox.get(_localTransportOptionsKey);
if (stored == null) return null;
final activeServerId = await getActiveServerId();
final (payload, ownerServerId) = _unwrapServerScoped(stored);
if (!_matchesActiveServer(activeServerId, ownerServerId)) {
return null;
}
if (payload is String) {
final decoded = jsonDecode(payload);
if (decoded is Map<String, dynamic>) {
return _transportFromJson(decoded);
}
} else if (payload is Map) {
return _transportFromJson(Map<String, dynamic>.from(payload));
}
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local transport options',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
return null;
}
Future<void> saveLocalTransportOptions(
SocketTransportAvailability? options,
) async {
try {
if (options == null) {
await _cachesBox.delete(_localTransportOptionsKey);
return;
}
final json = {
'allowPolling': options.allowPolling,
'allowWebsocketOnly': options.allowWebsocketOnly,
};
await _cachesBox.put(
_localTransportOptionsKey,
_wrapServerScoped(jsonEncode(json)),
);
} catch (error, stack) {
DebugLogger.error(
'Failed to save local transport options',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
SocketTransportAvailability? getLocalTransportOptionsSync() {
try {
final stored = _cachesBox.get(_localTransportOptionsKey);
if (stored == null) return null;
final (payload, ownerServerId) = _unwrapServerScoped(stored);
if (!_matchesActiveServer(_readActiveServerIdSync(), ownerServerId)) {
return null;
}
if (payload is String) {
final decoded = jsonDecode(payload);
if (decoded is Map<String, dynamic>) {
return _transportFromJson(decoded);
}
} else if (payload is Map) {
return _transportFromJson(Map<String, dynamic>.from(payload));
}
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local transport options sync',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
return null;
}
Future<List<Model>> getLocalModels() async {
try {
final stored = _cachesBox.get(_localModelsKey);
if (stored == null) {
return const [];
}
final activeServerId = await getActiveServerId();
final (payload, ownerServerId) = _unwrapServerScoped(stored);
if (!_matchesActiveServer(activeServerId, ownerServerId)) {
return const [];
}
if (payload == null) return const [];
final parsed = await _workerManager
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
_decodeStoredJsonListWorker,
{'stored': payload},
debugLabel: 'decode_local_models',
);
return parsed.map(Model.fromJson).toList(growable: false);
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local models',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
return const [];
}
}
Future<void> saveLocalModels(List<Model> models) async {
try {
final jsonReady = models.map((model) => model.toJson()).toList();
final serialized = await _workerManager
.schedule<Map<String, dynamic>, String>(_encodeJsonListWorker, {
'items': jsonReady,
}, debugLabel: 'encode_local_models');
await _cachesBox.put(_localModelsKey, _wrapServerScoped(serialized));
DebugLogger.log(
'Saved ${models.length} local models',
scope: 'storage/optimized',
);
} catch (error, stack) {
DebugLogger.error(
'Failed to save local models',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
Future<List<Tool>> getLocalTools() async {
try {
final stored = _cachesBox.get(_localToolsKey);
if (stored == null) return const [];
final activeServerId = await getActiveServerId();
final (payload, ownerServerId) = _unwrapServerScoped(stored);
if (!_matchesActiveServer(activeServerId, ownerServerId)) {
return const [];
}
if (payload == null) return const [];
final parsed = await _workerManager
.schedule<Map<String, dynamic>, List<Map<String, dynamic>>>(
_decodeStoredJsonListWorker,
{'stored': payload},
debugLabel: 'decode_local_tools',
);
return parsed.map(Tool.fromJson).toList(growable: false);
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local tools',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
return const [];
}
}
Future<void> saveLocalTools(List<Tool> tools) async {
try {
final jsonReady = tools.map((tool) => tool.toJson()).toList();
final serialized = await _workerManager
.schedule<Map<String, dynamic>, String>(_encodeJsonListWorker, {
'items': jsonReady,
}, debugLabel: 'encode_local_tools');
await _cachesBox.put(_localToolsKey, _wrapServerScoped(serialized));
DebugLogger.log(
'Saved ${tools.length} local tools',
scope: 'storage/optimized',
);
} catch (error, stack) {
DebugLogger.error(
'Failed to save local tools',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
Future<Model?> getLocalDefaultModel() async {
try {
final stored = _cachesBox.get(_localDefaultModelKey);
if (stored == null) return null;
final activeServerId = await getActiveServerId();
final (payload, ownerServerId) = _unwrapServerScoped(stored);
if (!_matchesActiveServer(activeServerId, ownerServerId)) {
return null;
}
Model? parsed;
if (payload is String) {
final decoded = jsonDecode(payload);
if (decoded is Map<String, dynamic>) {
parsed = Model.fromJson(decoded);
}
} else if (payload is Map) {
parsed = Model.fromJson(Map<String, dynamic>.from(payload));
}
if (parsed == null) return null;
final parsedModel = parsed;
final cachedModels = await getLocalModels();
final hasMatch = cachedModels.any(
(model) =>
model.id == parsedModel.id ||
model.name.trim() == parsedModel.name.trim(),
);
if (cachedModels.isNotEmpty && !hasMatch) {
return null;
}
return parsedModel;
} catch (error, stack) {
DebugLogger.error(
'Failed to retrieve local default model',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
return null;
}
Future<void> saveLocalDefaultModel(Model? model) async {
try {
if (model == null) {
await _cachesBox.delete(_localDefaultModelKey);
return;
}
final serialized = jsonEncode(model.toJson());
await _cachesBox.put(
_localDefaultModelKey,
_wrapServerScoped(serialized),
);
} catch (error, stack) {
DebugLogger.error(
'Failed to save local default model',
scope: 'storage/optimized',
error: error,
stackTrace: stack,
);
}
}
// ---------------------------------------------------------------------------
// Batch operations
// ---------------------------------------------------------------------------
@@ -402,18 +778,19 @@ class OptimizedStorageService {
deleteAuthToken(),
deleteSavedCredentials(),
_preferencesBox.delete(_activeServerIdKey),
_cachesBox.delete(_localUserKey),
_cachesBox.delete(_localUserAvatarKey),
_cachesBox.delete(_localBackendConfigKey),
_cachesBox.delete(_localTransportOptionsKey),
_cachesBox.delete(_localToolsKey),
_cachesBox.delete(_localDefaultModelKey),
_cachesBox.delete(_localModelsKey),
// Clear server configurations (which include custom headers)
_secureCredentialStorage.clearAll(),
]);
_cache.removeWhere(
(key, _) =>
key.contains('auth') ||
key.contains('credentials') ||
key.contains('server'),
);
_cacheTimestamps.removeWhere(
(key, _) =>
_cacheManager.invalidateMatching(
(key) =>
key.contains('auth') ||
key.contains('credentials') ||
key.contains('server'),
@@ -434,8 +811,7 @@ class OptimizedStorageService {
_attachmentQueueBox.clear(),
]);
_cache.clear();
_cacheTimestamps.clear();
_cacheManager.clear();
// Preserve migration metadata
final migrationVersion =
@@ -462,22 +838,53 @@ class OptimizedStorageService {
}
// ---------------------------------------------------------------------------
// Cache helpers
// Server scoping helpers
// ---------------------------------------------------------------------------
bool _isCacheValid(String key) {
final timestamp = _cacheTimestamps[key];
if (timestamp == null) {
return false;
(Object?, String?) _unwrapServerScoped(Object? stored) {
if (stored is Map && stored.containsKey('data')) {
final serverId = stored['serverId'];
return (stored['data'], serverId is String ? serverId : null);
}
return DateTime.now().difference(timestamp) < _cacheTimeout;
return (stored, null);
}
Map<String, Object?> _wrapServerScoped(Object data) {
return {'data': data, 'serverId': _readActiveServerIdSync()};
}
bool _matchesActiveServer(String? activeServerId, String? ownerServerId) {
if (ownerServerId == null || ownerServerId.isEmpty) {
return activeServerId == null;
}
return activeServerId == ownerServerId;
}
String? _readActiveServerIdSync() {
final (hit: hasCachedId, value: cachedId) = _cacheManager.lookup<String>(
_activeServerIdKey,
);
if (hasCachedId) {
return cachedId;
}
return _preferencesBox.get(_activeServerIdKey) as String?;
}
// ---------------------------------------------------------------------------
// Cache helpers
// ---------------------------------------------------------------------------
void clearCache() {
_cache.clear();
_cacheTimestamps.clear();
_cacheManager.clear();
DebugLogger.log('Storage cache cleared', scope: 'storage/optimized');
}
SocketTransportAvailability? _transportFromJson(Map<String, dynamic> json) {
try {
return SocketTransportAvailability.fromJson(json);
} catch (_) {
return null;
}
}
// ---------------------------------------------------------------------------
// Legacy migration hooks (no-op)
// ---------------------------------------------------------------------------
@@ -500,13 +907,7 @@ class OptimizedStorageService {
}
Map<String, dynamic> getStorageStats() {
return {
'cacheSize': _cache.length,
'cachedKeys': _cache.keys.toList(),
'lastAccess': _cacheTimestamps.entries
.map((entry) => '${entry.key}: ${entry.value}')
.toList(),
};
return _cacheManager.stats();
}
}

View File

@@ -1,7 +1,10 @@
import 'dart:developer' as developer;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:hive_ce/hive.dart';
import '../persistence/hive_bootstrap.dart';
import '../persistence/hive_boxes.dart';
import '../persistence/persistence_keys.dart';
import 'animation_service.dart';
@@ -126,47 +129,7 @@ class SettingsService {
/// Load all settings
static Future<AppSettings> loadSettings() {
final box = _preferencesBox();
return Future.value(
AppSettings(
reduceMotion: (box.get(_reduceMotionKey) as bool?) ?? false,
animationSpeed:
(box.get(_animationSpeedKey) as num?)?.toDouble() ?? 1.0,
hapticFeedback: (box.get(_hapticFeedbackKey) as bool?) ?? true,
highContrast: (box.get(_highContrastKey) as bool?) ?? false,
largeText: (box.get(_largeTextKey) as bool?) ?? false,
darkMode: (box.get(_darkModeKey) as bool?) ?? true,
defaultModel: box.get(_defaultModelKey) as String?,
voiceLocaleId: box.get(_voiceLocaleKey) as String?,
voiceHoldToTalk: (box.get(_voiceHoldToTalkKey) as bool?) ?? false,
voiceAutoSendFinal: (box.get(_voiceAutoSendKey) as bool?) ?? false,
socketTransportMode:
box.get(_socketTransportModeKey, defaultValue: 'ws') as String,
quickPills: List<String>.from(
(box.get(_quickPillsKey) as List<dynamic>?) ?? const <String>[],
),
sendOnEnter: (box.get(_sendOnEnterKey) as bool?) ?? false,
ttsVoice: box.get(PreferenceKeys.ttsVoice) as String?,
ttsSpeechRate:
(box.get(PreferenceKeys.ttsSpeechRate) as num?)?.toDouble() ?? 0.5,
ttsPitch: (box.get(PreferenceKeys.ttsPitch) as num?)?.toDouble() ?? 1.0,
ttsVolume:
(box.get(PreferenceKeys.ttsVolume) as num?)?.toDouble() ?? 1.0,
ttsEngine: _parseTtsEngine(
box.get(PreferenceKeys.ttsEngine) as String?,
),
ttsServerVoiceId: box.get(PreferenceKeys.ttsServerVoiceId) as String?,
ttsServerVoiceName:
box.get(PreferenceKeys.ttsServerVoiceName) as String?,
sttPreference: _parseSttPreference(
box.get(PreferenceKeys.voiceSttPreference) as String?,
),
voiceSilenceDuration:
(box.get(_voiceSilenceDurationKey) as int? ?? 2000).clamp(
300,
3000,
),
),
);
return Future.value(_loadSettingsSync(box));
}
/// Save all settings
@@ -379,6 +342,40 @@ class SettingsService {
// Ensure reasonable bounds
return baseScale.clamp(0.8, 3.0);
}
static AppSettings _loadSettingsSync(Box<dynamic> box) {
return AppSettings(
reduceMotion: (box.get(_reduceMotionKey) as bool?) ?? false,
animationSpeed: (box.get(_animationSpeedKey) as num?)?.toDouble() ?? 1.0,
hapticFeedback: (box.get(_hapticFeedbackKey) as bool?) ?? true,
highContrast: (box.get(_highContrastKey) as bool?) ?? false,
largeText: (box.get(_largeTextKey) as bool?) ?? false,
darkMode: (box.get(_darkModeKey) as bool?) ?? true,
defaultModel: box.get(_defaultModelKey) as String?,
voiceLocaleId: box.get(_voiceLocaleKey) as String?,
voiceHoldToTalk: (box.get(_voiceHoldToTalkKey) as bool?) ?? false,
voiceAutoSendFinal: (box.get(_voiceAutoSendKey) as bool?) ?? false,
socketTransportMode:
box.get(_socketTransportModeKey, defaultValue: 'ws') as String,
quickPills: List<String>.from(
(box.get(_quickPillsKey) as List<dynamic>?) ?? const <String>[],
),
sendOnEnter: (box.get(_sendOnEnterKey) as bool?) ?? false,
ttsVoice: box.get(PreferenceKeys.ttsVoice) as String?,
ttsSpeechRate:
(box.get(PreferenceKeys.ttsSpeechRate) as num?)?.toDouble() ?? 0.5,
ttsPitch: (box.get(PreferenceKeys.ttsPitch) as num?)?.toDouble() ?? 1.0,
ttsVolume: (box.get(PreferenceKeys.ttsVolume) as num?)?.toDouble() ?? 1.0,
ttsEngine: _parseTtsEngine(box.get(PreferenceKeys.ttsEngine) as String?),
ttsServerVoiceId: box.get(PreferenceKeys.ttsServerVoiceId) as String?,
ttsServerVoiceName: box.get(PreferenceKeys.ttsServerVoiceName) as String?,
sttPreference: _parseSttPreference(
box.get(PreferenceKeys.voiceSttPreference) as String?,
),
voiceSilenceDuration: (box.get(_voiceSilenceDurationKey) as int? ?? 2000)
.clamp(300, 3000),
);
}
}
/// Sentinel class to detect when defaultModel parameter is not provided
@@ -562,23 +559,36 @@ bool _listEquals(List<String> a, List<String> b) {
/// Provider for app settings
@Riverpod(keepAlive: true)
class AppSettingsNotifier extends _$AppSettingsNotifier {
bool _initialized = false;
Future<void>? _pendingLoad;
@override
AppSettings build() {
if (!_initialized) {
_initialized = true;
Future.microtask(_loadSettings);
if (Hive.isBoxOpen(HiveBoxNames.preferences)) {
final box = Hive.box<dynamic>(HiveBoxNames.preferences);
return SettingsService._loadSettingsSync(box);
}
_pendingLoad ??= _hydrateFromHive();
return const AppSettings();
}
Future<void> _loadSettings() async {
final settings = await SettingsService.loadSettings();
if (!ref.mounted) {
return;
Future<void> _hydrateFromHive() async {
try {
await HiveBootstrap.instance.ensureInitialized();
if (!ref.mounted) return;
final box = Hive.box<dynamic>(HiveBoxNames.preferences);
state = SettingsService._loadSettingsSync(box);
} catch (error, stackTrace) {
developer.log(
'Failed to hydrate settings',
name: 'AppSettingsNotifier',
level: 1000,
error: error,
stackTrace: stackTrace,
);
} finally {
_pendingLoad = null;
}
state = settings;
}
Future<void> setReduceMotion(bool value) async {