feat(cache): Add lightweight in-memory cache with TTL and LRU eviction
This commit is contained in:
@@ -5,9 +5,11 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
// Types are used through app_providers.dart
|
||||
import '../providers/app_providers.dart';
|
||||
import '../models/user.dart';
|
||||
import '../services/optimized_storage_service.dart';
|
||||
import 'token_validator.dart';
|
||||
import 'auth_cache_manager.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
import '../utils/user_avatar_utils.dart';
|
||||
|
||||
part 'auth_state_manager.g.dart';
|
||||
|
||||
@@ -97,12 +99,63 @@ class AuthStateManager extends _$AuthStateManager {
|
||||
state.asData?.value ?? const AuthState(status: AuthStatus.initial);
|
||||
|
||||
void _set(AuthState next, {bool cache = false}) {
|
||||
final storage = ref.read(optimizedStorageServiceProvider);
|
||||
if (next.user != null && next.isAuthenticated) {
|
||||
// Persist user and avatar asynchronously without blocking state update
|
||||
unawaited(_persistUserWithAvatar(next, storage));
|
||||
} else if (!next.isAuthenticated) {
|
||||
unawaited(storage.saveLocalUser(null).onError((error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to clear local user on logout',
|
||||
scope: 'auth/persistence',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}));
|
||||
unawaited(storage.saveLocalUserAvatar(null).onError((error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to clear local user avatar on logout',
|
||||
scope: 'auth/persistence',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}));
|
||||
}
|
||||
state = AsyncValue.data(next);
|
||||
if (cache) {
|
||||
_cacheManager.cacheAuthState(next);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistUserWithAvatar(
|
||||
AuthState authState,
|
||||
OptimizedStorageService storage,
|
||||
) async {
|
||||
try {
|
||||
final api = ref.read(apiServiceProvider);
|
||||
final user = authState.user!;
|
||||
final resolvedAvatar = resolveUserProfileImageUrl(
|
||||
api,
|
||||
deriveUserProfileImage(user),
|
||||
);
|
||||
final userWithAvatar =
|
||||
resolvedAvatar != null && resolvedAvatar != user.profileImage
|
||||
? user.copyWith(profileImage: resolvedAvatar)
|
||||
: user;
|
||||
await storage.saveLocalUser(userWithAvatar);
|
||||
if (resolvedAvatar != null) {
|
||||
await storage.saveLocalUserAvatar(resolvedAvatar);
|
||||
}
|
||||
} catch (error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to persist user with avatar',
|
||||
scope: 'auth/persistence',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _update(
|
||||
AuthState Function(AuthState current) transform, {
|
||||
bool cache = false,
|
||||
@@ -143,6 +196,25 @@ class AuthStateManager extends _$AuthStateManager {
|
||||
cache: true,
|
||||
);
|
||||
|
||||
try {
|
||||
final cachedUser = await storage.getLocalUser();
|
||||
if (cachedUser != null) {
|
||||
// Restore cached avatar as well
|
||||
final cachedAvatar = await storage.getLocalUserAvatar();
|
||||
final userWithAvatar =
|
||||
cachedAvatar != null &&
|
||||
cachedAvatar.isNotEmpty &&
|
||||
cachedUser.profileImage != cachedAvatar
|
||||
? cachedUser.copyWith(profileImage: cachedAvatar)
|
||||
: cachedUser;
|
||||
_update(
|
||||
(current) => current.copyWith(user: userWithAvatar),
|
||||
cache: true,
|
||||
);
|
||||
DebugLogger.auth('Restored user from cache');
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Update API service with token and kick off dependent background work
|
||||
_updateApiServiceToken(token);
|
||||
_preloadDefaultModel();
|
||||
@@ -706,11 +778,11 @@ class AuthStateManager extends _$AuthStateManager {
|
||||
|
||||
// Clear active server to force return to server connection page
|
||||
await storage.setActiveServerId(null);
|
||||
|
||||
|
||||
// Invalidate all auth-related providers to clear cached data
|
||||
ref.invalidate(activeServerProvider);
|
||||
ref.invalidate(serverConfigsProvider);
|
||||
|
||||
|
||||
// Clear auth cache manager
|
||||
_cacheManager.clearAuthCache();
|
||||
|
||||
@@ -725,7 +797,9 @@ class AuthStateManager extends _$AuthStateManager {
|
||||
),
|
||||
);
|
||||
|
||||
DebugLogger.auth('Logout complete - all data cleared including server configs and custom headers');
|
||||
DebugLogger.auth(
|
||||
'Logout complete - all data cleared including server configs and custom headers',
|
||||
);
|
||||
} catch (e, stack) {
|
||||
DebugLogger.error(
|
||||
'logout-failed',
|
||||
|
||||
@@ -35,16 +35,27 @@ class BackendConfig {
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{'enable_websocket': enableWebsocket};
|
||||
return <String, dynamic>{
|
||||
'enable_websocket': enableWebsocket,
|
||||
};
|
||||
}
|
||||
|
||||
static BackendConfig fromJson(Map<String, dynamic> json) {
|
||||
bool? enableWebsocket;
|
||||
final features = json['features'];
|
||||
if (features is Map<String, dynamic>) {
|
||||
final value = features['enable_websocket'];
|
||||
if (value is bool) {
|
||||
enableWebsocket = value;
|
||||
// Try canonical format first
|
||||
final value = json['enable_websocket'];
|
||||
if (value is bool) {
|
||||
enableWebsocket = value;
|
||||
}
|
||||
|
||||
// Fallback to nested format for backwards compatibility
|
||||
if (enableWebsocket == null) {
|
||||
final features = json['features'];
|
||||
if (features is Map<String, dynamic>) {
|
||||
final nestedValue = features['enable_websocket'];
|
||||
if (nestedValue is bool) {
|
||||
enableWebsocket = nestedValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,18 @@ sealed class Model with _$Model {
|
||||
}) = _Model;
|
||||
|
||||
factory Model.fromJson(Map<String, dynamic> json) {
|
||||
final cachedIsMultimodal = switch (json['isMultimodal']) {
|
||||
final bool value => value,
|
||||
_ => json['is_multimodal'] is bool ? json['is_multimodal'] as bool : null,
|
||||
};
|
||||
final cachedSupportsStreaming = switch (json['supportsStreaming']) {
|
||||
final bool value => value,
|
||||
_ =>
|
||||
json['supports_streaming'] is bool
|
||||
? json['supports_streaming'] as bool
|
||||
: null,
|
||||
};
|
||||
|
||||
// Handle different response formats from OpenWebUI
|
||||
|
||||
// Extract architecture info for capabilities
|
||||
@@ -29,8 +41,9 @@ sealed class Model with _$Model {
|
||||
|
||||
// Determine if multimodal based on architecture
|
||||
final isMultimodal =
|
||||
modality?.contains('image') == true ||
|
||||
inputModalities?.contains('image') == true;
|
||||
cachedIsMultimodal ??
|
||||
(modality?.contains('image') == true ||
|
||||
inputModalities?.contains('image') == true);
|
||||
|
||||
// Extract supported parameters robustly (top-level or nested under provider keys)
|
||||
List? supportedParams =
|
||||
@@ -63,7 +76,8 @@ sealed class Model with _$Model {
|
||||
}
|
||||
|
||||
// Determine streaming support from supported parameters if known
|
||||
final supportsStreaming = supportedParams?.contains('stream') ?? true;
|
||||
final supportsStreaming =
|
||||
cachedSupportsStreaming ?? supportedParams?.contains('stream') ?? true;
|
||||
|
||||
// Convert supported parameters to List<String> if present
|
||||
final supportedParamsList = supportedParams
|
||||
@@ -154,4 +168,22 @@ sealed class Model with _$Model {
|
||||
toolIds: toolIds,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final data = <String, dynamic>{
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'isMultimodal': isMultimodal,
|
||||
'supportsStreaming': supportsStreaming,
|
||||
'supportsRAG': supportsRAG,
|
||||
'supported_parameters': supportedParameters,
|
||||
'capabilities': capabilities,
|
||||
'metadata': metadata,
|
||||
'architecture': capabilities?['architecture'],
|
||||
'toolIds': toolIds,
|
||||
};
|
||||
data.removeWhere((_, value) => value == null);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
23
lib/core/models/socket_transport_availability.dart
Normal file
23
lib/core/models/socket_transport_availability.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
class SocketTransportAvailability {
|
||||
const SocketTransportAvailability({
|
||||
required this.allowPolling,
|
||||
required this.allowWebsocketOnly,
|
||||
});
|
||||
|
||||
final bool allowPolling;
|
||||
final bool allowWebsocketOnly;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'allowPolling': allowPolling,
|
||||
'allowWebsocketOnly': allowWebsocketOnly,
|
||||
};
|
||||
}
|
||||
|
||||
factory SocketTransportAvailability.fromJson(Map<String, dynamic> json) {
|
||||
return SocketTransportAvailability(
|
||||
allowPolling: json['allowPolling'] == true,
|
||||
allowWebsocketOnly: json['allowWebsocketOnly'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,4 +23,14 @@ sealed class Tool with _$Tool {
|
||||
meta: json['meta'] as Map<String, dynamic>?,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'description': description,
|
||||
'user_id': userId,
|
||||
'meta': meta,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,4 +30,16 @@ sealed class User with _$User {
|
||||
isActive: json['is_active'] as bool? ?? json['isActive'] as bool? ?? true,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'username': username,
|
||||
'email': email,
|
||||
'name': name,
|
||||
'profile_image_url': profileImage,
|
||||
'role': role,
|
||||
'is_active': isActive,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,13 @@ final class HiveStoreKeys {
|
||||
|
||||
// Cache entries
|
||||
static const String localConversations = 'local_conversations';
|
||||
static const String localUser = 'local_user';
|
||||
static const String localUserAvatar = 'local_user_avatar';
|
||||
static const String localBackendConfig = 'local_backend_config';
|
||||
static const String localTransportOptions = 'local_transport_options';
|
||||
static const String localTools = 'local_tools';
|
||||
static const String localDefaultModel = 'local_default_model';
|
||||
static const String localModels = 'local_models';
|
||||
static const String localFolders = 'local_folders';
|
||||
static const String attachmentQueueEntries = 'attachment_queue_entries';
|
||||
static const String taskQueue = 'outbound_task_queue_v1';
|
||||
|
||||
@@ -3,9 +3,7 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../persistence/persistence_providers.dart';
|
||||
import '../services/api_service.dart';
|
||||
import '../auth/auth_state_manager.dart';
|
||||
import '../../features/auth/providers/unified_auth_providers.dart';
|
||||
@@ -30,38 +28,13 @@ import '../services/worker_manager.dart';
|
||||
import '../../shared/theme/tweakcn_themes.dart';
|
||||
import '../../shared/theme/app_theme.dart';
|
||||
import '../../features/tools/providers/tools_providers.dart';
|
||||
import '../models/socket_transport_availability.dart';
|
||||
import 'storage_providers.dart';
|
||||
|
||||
export 'storage_providers.dart';
|
||||
|
||||
part 'app_providers.g.dart';
|
||||
|
||||
// Storage providers
|
||||
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
|
||||
// Single, shared instance with explicit platform options
|
||||
return const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
sharedPreferencesName: 'conduit_secure_prefs',
|
||||
preferencesKeyPrefix: 'conduit_',
|
||||
// Avoid auto-wipe on transient errors; we handle errors in code
|
||||
resetOnError: false,
|
||||
),
|
||||
iOptions: IOSOptions(
|
||||
accountName: 'conduit_secure_storage',
|
||||
synchronizable: false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// Optimized storage service provider
|
||||
final optimizedStorageServiceProvider = Provider<OptimizedStorageService>((
|
||||
ref,
|
||||
) {
|
||||
return OptimizedStorageService(
|
||||
secureStorage: ref.watch(secureStorageProvider),
|
||||
boxes: ref.watch(hiveBoxesProvider),
|
||||
workerManager: ref.watch(workerManagerProvider),
|
||||
);
|
||||
});
|
||||
|
||||
// Theme provider
|
||||
@Riverpod(keepAlive: true)
|
||||
class AppThemeMode extends _$AppThemeMode {
|
||||
@@ -175,7 +148,37 @@ final serverConnectionStateProvider = Provider<bool>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
final backendConfigProvider = FutureProvider<BackendConfig?>((ref) async {
|
||||
@Riverpod(keepAlive: true)
|
||||
class BackendConfigNotifier extends _$BackendConfigNotifier {
|
||||
late final OptimizedStorageService _storage;
|
||||
|
||||
@override
|
||||
Future<BackendConfig?> build() async {
|
||||
_storage = ref.watch(optimizedStorageServiceProvider);
|
||||
final cached = await _storage.getLocalBackendConfig();
|
||||
unawaited(_refreshBackendConfig());
|
||||
return cached;
|
||||
}
|
||||
|
||||
Future<void> refresh() => _refreshBackendConfig();
|
||||
|
||||
Future<void> _refreshBackendConfig() async {
|
||||
final fresh = await _loadBackendConfig(ref);
|
||||
if (fresh == null || !ref.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
state = AsyncData(fresh);
|
||||
await _storage.saveLocalBackendConfig(fresh);
|
||||
|
||||
// Persist resolved transport options based on backend config
|
||||
if (!ref.mounted) return;
|
||||
final options = _resolveTransportAvailability(fresh);
|
||||
await _storage.saveLocalTransportOptions(options);
|
||||
}
|
||||
}
|
||||
|
||||
Future<BackendConfig?> _loadBackendConfig(Ref ref) async {
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) {
|
||||
return null;
|
||||
@@ -205,21 +208,22 @@ final backendConfigProvider = FutureProvider<BackendConfig?>((ref) async {
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
class SocketTransportAvailability {
|
||||
const SocketTransportAvailability({
|
||||
required this.allowPolling,
|
||||
required this.allowWebsocketOnly,
|
||||
});
|
||||
|
||||
final bool allowPolling;
|
||||
final bool allowWebsocketOnly;
|
||||
}
|
||||
|
||||
/// Provides resolved socket transport options based on backend configuration.
|
||||
///
|
||||
/// This is a synchronous provider that:
|
||||
/// - Returns cached transport options when backend config is not yet loaded
|
||||
/// - Derives transport options from backend config once available
|
||||
/// - Does NOT perform side effects (persistence is handled by BackendConfigNotifier)
|
||||
///
|
||||
/// The persistence of resolved options happens asynchronously when the
|
||||
/// backend config is refreshed, ensuring the sync provider remains pure.
|
||||
final socketTransportOptionsProvider = Provider<SocketTransportAvailability>((
|
||||
ref,
|
||||
) {
|
||||
final storage = ref.watch(optimizedStorageServiceProvider);
|
||||
// Watch async backend config for proper invalidation
|
||||
final backendConfigAsync = ref.watch(backendConfigProvider);
|
||||
final config = backendConfigAsync.maybeWhen(
|
||||
data: (value) => value,
|
||||
@@ -227,30 +231,16 @@ final socketTransportOptionsProvider = Provider<SocketTransportAvailability>((
|
||||
);
|
||||
|
||||
if (config == null) {
|
||||
return const SocketTransportAvailability(
|
||||
allowPolling: true,
|
||||
allowWebsocketOnly: true,
|
||||
);
|
||||
// Return cached value or defaults when config not available
|
||||
return storage.getLocalTransportOptionsSync() ??
|
||||
const SocketTransportAvailability(
|
||||
allowPolling: true,
|
||||
allowWebsocketOnly: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.websocketOnly) {
|
||||
return const SocketTransportAvailability(
|
||||
allowPolling: false,
|
||||
allowWebsocketOnly: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.pollingOnly) {
|
||||
return const SocketTransportAvailability(
|
||||
allowPolling: true,
|
||||
allowWebsocketOnly: false,
|
||||
);
|
||||
}
|
||||
|
||||
return const SocketTransportAvailability(
|
||||
allowPolling: true,
|
||||
allowWebsocketOnly: true,
|
||||
);
|
||||
// Determine transport availability from backend config
|
||||
return _resolveTransportAvailability(config);
|
||||
});
|
||||
|
||||
// API Service provider with unified auth integration
|
||||
@@ -551,52 +541,146 @@ final refreshAuthStateProvider = Provider<void>((ref) {
|
||||
|
||||
// Model providers
|
||||
@Riverpod(keepAlive: true)
|
||||
Future<List<Model>> models(Ref ref) async {
|
||||
// Reviewer mode returns mock models
|
||||
final reviewerMode = ref.watch(reviewerModeProvider);
|
||||
if (reviewerMode) {
|
||||
return [
|
||||
const Model(
|
||||
id: 'demo/gemma-2-mini',
|
||||
name: 'Gemma 2 Mini (Demo)',
|
||||
description: 'Demo model for reviewer mode',
|
||||
isMultimodal: true,
|
||||
supportsStreaming: true,
|
||||
supportedParameters: ['max_tokens', 'stream'],
|
||||
),
|
||||
const Model(
|
||||
id: 'demo/llama-3-8b',
|
||||
name: 'Llama 3 8B (Demo)',
|
||||
description: 'Fast text model for demo',
|
||||
isMultimodal: false,
|
||||
supportsStreaming: true,
|
||||
supportedParameters: ['max_tokens', 'stream'],
|
||||
),
|
||||
];
|
||||
}
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) return [];
|
||||
|
||||
try {
|
||||
DebugLogger.log('fetch-start', scope: 'models');
|
||||
final models = await api.getModels();
|
||||
DebugLogger.log(
|
||||
'fetch-ok',
|
||||
scope: 'models',
|
||||
data: {'count': models.length},
|
||||
);
|
||||
return models;
|
||||
} catch (e) {
|
||||
DebugLogger.error('fetch-failed', scope: 'models', error: e);
|
||||
|
||||
// If models endpoint returns 403, this should now clear auth token
|
||||
// and redirect user to login since it's marked as a core endpoint
|
||||
if (e.toString().contains('403')) {
|
||||
DebugLogger.warning('endpoint-403', scope: 'models');
|
||||
class Models extends _$Models {
|
||||
@override
|
||||
Future<List<Model>> build() async {
|
||||
// Reviewer mode returns mock models
|
||||
if (ref.watch(reviewerModeProvider)) {
|
||||
return _demoModels();
|
||||
}
|
||||
|
||||
return [];
|
||||
if (!ref.watch(isAuthenticatedProvider2)) {
|
||||
DebugLogger.log('skip-unauthed', scope: 'models');
|
||||
_persistModelsAsync(const <Model>[]);
|
||||
return const [];
|
||||
}
|
||||
|
||||
final storage = ref.watch(optimizedStorageServiceProvider);
|
||||
try {
|
||||
final cached = await storage.getLocalModels();
|
||||
if (cached.isNotEmpty) {
|
||||
DebugLogger.log(
|
||||
'cache-restored',
|
||||
scope: 'models/cache',
|
||||
data: {'count': cached.length},
|
||||
);
|
||||
Future.microtask(() async {
|
||||
try {
|
||||
await refresh();
|
||||
} catch (error, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'warm-refresh-failed',
|
||||
scope: 'models/cache',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
});
|
||||
return cached;
|
||||
}
|
||||
DebugLogger.log('cache-empty', scope: 'models/cache');
|
||||
} catch (error, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'cache-load-failed',
|
||||
scope: 'models/cache',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
|
||||
final api = ref.watch(apiServiceProvider);
|
||||
if (api == null) {
|
||||
DebugLogger.warning('api-missing', scope: 'models');
|
||||
_persistModelsAsync(const <Model>[]);
|
||||
return const [];
|
||||
}
|
||||
|
||||
final fresh = await _load(api);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
if (ref.read(reviewerModeProvider)) {
|
||||
state = AsyncData<List<Model>>(_demoModels());
|
||||
return;
|
||||
}
|
||||
if (!ref.read(isAuthenticatedProvider2)) {
|
||||
state = const AsyncData<List<Model>>(<Model>[]);
|
||||
_persistModelsAsync(const <Model>[]);
|
||||
return;
|
||||
}
|
||||
final api = ref.read(apiServiceProvider);
|
||||
if (api == null) {
|
||||
state = const AsyncData<List<Model>>(<Model>[]);
|
||||
_persistModelsAsync(const <Model>[]);
|
||||
return;
|
||||
}
|
||||
final result = await AsyncValue.guard(() => _load(api));
|
||||
if (!ref.mounted) return;
|
||||
state = result;
|
||||
}
|
||||
|
||||
Future<List<Model>> _load(ApiService api) async {
|
||||
try {
|
||||
DebugLogger.log('fetch-start', scope: 'models');
|
||||
final models = await api.getModels();
|
||||
DebugLogger.log(
|
||||
'fetch-ok',
|
||||
scope: 'models',
|
||||
data: {'count': models.length},
|
||||
);
|
||||
_persistModelsAsync(models);
|
||||
return models;
|
||||
} catch (e, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'fetch-failed',
|
||||
scope: 'models',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
|
||||
// If models endpoint returns 403, this should now clear auth token
|
||||
// and redirect user to login since it's marked as a core endpoint
|
||||
if (e.toString().contains('403')) {
|
||||
DebugLogger.warning('endpoint-403', scope: 'models');
|
||||
}
|
||||
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
void _persistModelsAsync(List<Model> models) {
|
||||
final storage = ref.read(optimizedStorageServiceProvider);
|
||||
unawaited(
|
||||
storage.saveLocalModels(models).onError((error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to persist models to cache',
|
||||
scope: 'models/cache',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
List<Model> _demoModels() => const [
|
||||
Model(
|
||||
id: 'demo/gemma-2-mini',
|
||||
name: 'Gemma 2 Mini (Demo)',
|
||||
description: 'Demo model for reviewer mode',
|
||||
isMultimodal: true,
|
||||
supportsStreaming: true,
|
||||
supportedParameters: ['max_tokens', 'stream'],
|
||||
),
|
||||
Model(
|
||||
id: 'demo/llama-3-8b',
|
||||
name: 'Llama 3 8B (Demo)',
|
||||
description: 'Fast text model for demo',
|
||||
isMultimodal: false,
|
||||
supportsStreaming: true,
|
||||
supportedParameters: ['max_tokens', 'stream'],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
@@ -1243,6 +1327,7 @@ Future<Model?> defaultModel(Ref ref) async {
|
||||
|
||||
// Initialize the settings watcher (side-effect only)
|
||||
ref.read(_settingsWatcherProvider);
|
||||
final storage = ref.read(optimizedStorageServiceProvider);
|
||||
// Read settings without subscribing to rebuilds to avoid watch/await hazards
|
||||
final reviewerMode = ref.read(reviewerModeProvider);
|
||||
if (reviewerMode) {
|
||||
@@ -1287,6 +1372,20 @@ Future<Model?> defaultModel(Ref ref) async {
|
||||
DebugLogger.log('api-available', scope: 'models/default');
|
||||
|
||||
try {
|
||||
// Warm restore from cached resolved default model
|
||||
try {
|
||||
final cached = await storage.getLocalDefaultModel();
|
||||
if (cached != null && !ref.read(isManualModelSelectionProvider)) {
|
||||
ref.read(selectedModelProvider.notifier).set(cached);
|
||||
DebugLogger.log(
|
||||
'cached-default',
|
||||
scope: 'models/default',
|
||||
data: {'name': cached.name},
|
||||
);
|
||||
return cached;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// Respect manual selection if present
|
||||
if (ref.read(isManualModelSelectionProvider)) {
|
||||
final current = ref.read(selectedModelProvider);
|
||||
@@ -1298,18 +1397,46 @@ Future<Model?> defaultModel(Ref ref) async {
|
||||
final storedDefaultId = await SettingsService.getDefaultModel();
|
||||
if (storedDefaultId != null && storedDefaultId.isNotEmpty) {
|
||||
if (!ref.read(isManualModelSelectionProvider)) {
|
||||
final placeholder = Model(
|
||||
id: storedDefaultId,
|
||||
name: storedDefaultId,
|
||||
supportsStreaming: true,
|
||||
);
|
||||
ref.read(selectedModelProvider.notifier).set(placeholder);
|
||||
final cachedMatch = await selectCachedModel(storage, storedDefaultId);
|
||||
if (cachedMatch != null) {
|
||||
ref.read(selectedModelProvider.notifier).set(cachedMatch);
|
||||
unawaited(
|
||||
storage.saveLocalDefaultModel(cachedMatch).onError((
|
||||
error,
|
||||
stack,
|
||||
) {
|
||||
DebugLogger.error(
|
||||
'Failed to save default model to cache',
|
||||
scope: 'models/default',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}),
|
||||
);
|
||||
DebugLogger.log(
|
||||
'cache-select',
|
||||
scope: 'models/default',
|
||||
data: {'name': cachedMatch.name, 'source': 'cache'},
|
||||
);
|
||||
} else {
|
||||
DebugLogger.log(
|
||||
'cache-skip-missing',
|
||||
scope: 'models/default',
|
||||
data: {'id': storedDefaultId},
|
||||
);
|
||||
}
|
||||
}
|
||||
// Reconcile against real models in background
|
||||
Future.microtask(() async {
|
||||
try {
|
||||
if (!ref.mounted) return;
|
||||
final models = await ref.read(modelsProvider.future);
|
||||
List<Model> models;
|
||||
final modelsAsync = ref.read(modelsProvider);
|
||||
if (modelsAsync.hasValue) {
|
||||
models = modelsAsync.value ?? const <Model>[];
|
||||
} else {
|
||||
models = await ref.read(modelsProvider.future);
|
||||
}
|
||||
if (!ref.mounted) return;
|
||||
|
||||
Model? resolved;
|
||||
@@ -1326,6 +1453,16 @@ Future<Model?> defaultModel(Ref ref) async {
|
||||
if (!ref.mounted) return;
|
||||
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
|
||||
ref.read(selectedModelProvider.notifier).set(resolved);
|
||||
unawaited(
|
||||
storage.saveLocalDefaultModel(resolved).onError((error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to save default model to cache',
|
||||
scope: 'models/default',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}),
|
||||
);
|
||||
DebugLogger.log(
|
||||
'reconcile',
|
||||
scope: 'models/default',
|
||||
@@ -1355,6 +1492,16 @@ Future<Model?> defaultModel(Ref ref) async {
|
||||
supportsStreaming: true,
|
||||
);
|
||||
ref.read(selectedModelProvider.notifier).set(placeholder);
|
||||
unawaited(
|
||||
storage.saveLocalDefaultModel(placeholder).onError((error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to save placeholder model to cache',
|
||||
scope: 'models/default',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Reconcile against real models in background
|
||||
Future.microtask(() async {
|
||||
@@ -1377,6 +1524,16 @@ Future<Model?> defaultModel(Ref ref) async {
|
||||
if (!ref.mounted) return;
|
||||
if (resolved != null && !ref.read(isManualModelSelectionProvider)) {
|
||||
ref.read(selectedModelProvider.notifier).set(resolved);
|
||||
unawaited(
|
||||
storage.saveLocalDefaultModel(resolved).onError((error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to save default model to cache',
|
||||
scope: 'models/default',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}),
|
||||
);
|
||||
DebugLogger.log(
|
||||
'reconcile',
|
||||
scope: 'models/default',
|
||||
@@ -1410,6 +1567,16 @@ Future<Model?> defaultModel(Ref ref) async {
|
||||
final selectedModel = models.first;
|
||||
if (!ref.read(isManualModelSelectionProvider)) {
|
||||
ref.read(selectedModelProvider.notifier).set(selectedModel);
|
||||
unawaited(
|
||||
storage.saveLocalDefaultModel(selectedModel).onError((error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to save default model to cache',
|
||||
scope: 'models/default',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}),
|
||||
);
|
||||
DebugLogger.log(
|
||||
'fallback-selected',
|
||||
scope: 'models/default',
|
||||
@@ -2158,3 +2325,66 @@ Future<List<Map<String, dynamic>>> imageModels(Ref ref) async {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to select cached model based on settings and available models.
|
||||
/// Used by both chat page and defaultModel provider to ensure consistent behavior.
|
||||
/// Returns a cached model if available, otherwise returns null.
|
||||
Future<Model?> selectCachedModel(
|
||||
OptimizedStorageService storage,
|
||||
String? desiredModelId,
|
||||
) async {
|
||||
try {
|
||||
final cachedModels = await storage.getLocalModels();
|
||||
if (cachedModels.isEmpty) return null;
|
||||
|
||||
Model? match;
|
||||
if (desiredModelId != null && desiredModelId.isNotEmpty) {
|
||||
try {
|
||||
match = cachedModels.firstWhere(
|
||||
(model) =>
|
||||
model.id == desiredModelId ||
|
||||
model.name.trim() == desiredModelId.trim(),
|
||||
);
|
||||
} catch (_) {
|
||||
match = null;
|
||||
}
|
||||
}
|
||||
|
||||
return match ?? cachedModels.first;
|
||||
} catch (error, stackTrace) {
|
||||
DebugLogger.error(
|
||||
'cache-select-failed',
|
||||
scope: 'models/cache',
|
||||
error: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves socket transport availability from backend configuration.
|
||||
///
|
||||
/// Used by both the sync [socketTransportOptionsProvider] and the
|
||||
/// [BackendConfigNotifier] to ensure consistent resolution logic.
|
||||
SocketTransportAvailability _resolveTransportAvailability(
|
||||
BackendConfig config,
|
||||
) {
|
||||
if (config.websocketOnly) {
|
||||
return const SocketTransportAvailability(
|
||||
allowPolling: false,
|
||||
allowWebsocketOnly: true,
|
||||
);
|
||||
}
|
||||
|
||||
if (config.pollingOnly) {
|
||||
return const SocketTransportAvailability(
|
||||
allowPolling: true,
|
||||
allowWebsocketOnly: false,
|
||||
);
|
||||
}
|
||||
|
||||
return const SocketTransportAvailability(
|
||||
allowPolling: true,
|
||||
allowWebsocketOnly: true,
|
||||
);
|
||||
}
|
||||
|
||||
35
lib/core/providers/storage_providers.dart
Normal file
35
lib/core/providers/storage_providers.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../persistence/persistence_providers.dart';
|
||||
import '../services/optimized_storage_service.dart';
|
||||
import '../services/worker_manager.dart';
|
||||
|
||||
/// Provides a shared [FlutterSecureStorage] instance with platform-specific
|
||||
/// configuration.
|
||||
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
|
||||
return const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
sharedPreferencesName: 'conduit_secure_prefs',
|
||||
preferencesKeyPrefix: 'conduit_',
|
||||
// Avoid auto-wipe on transient errors; handled at call sites instead.
|
||||
resetOnError: false,
|
||||
),
|
||||
iOptions: IOSOptions(
|
||||
accountName: 'conduit_secure_storage',
|
||||
synchronizable: false,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
/// Optimized storage service backed by Hive plus secure storage.
|
||||
final optimizedStorageServiceProvider = Provider<OptimizedStorageService>((
|
||||
ref,
|
||||
) {
|
||||
return OptimizedStorageService(
|
||||
secureStorage: ref.watch(secureStorageProvider),
|
||||
boxes: ref.watch(hiveBoxesProvider),
|
||||
workerManager: ref.watch(workerManagerProvider),
|
||||
);
|
||||
});
|
||||
121
lib/core/services/cache_manager.dart
Normal file
121
lib/core/services/cache_manager.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user