chore: integrate Hive for local storage management
- Added `hive_ce` and `hive_ce_flutter` dependencies for enhanced local storage capabilities. - Refactored the main application to initialize Hive and migrate existing data. - Updated storage service implementations to utilize Hive for managing application settings and task queues. - Removed the deprecated `StorageService` class to streamline the codebase and improve maintainability.
This commit is contained in:
46
lib/core/persistence/hive_bootstrap.dart
Normal file
46
lib/core/persistence/hive_bootstrap.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:hive_ce_flutter/hive_flutter.dart';
|
||||
|
||||
import 'hive_boxes.dart';
|
||||
|
||||
/// Sets up Hive and exposes lazily opened boxes used across the app.
|
||||
class HiveBootstrap {
|
||||
HiveBootstrap._();
|
||||
|
||||
static final HiveBootstrap instance = HiveBootstrap._();
|
||||
|
||||
HiveBoxes? _boxes;
|
||||
|
||||
/// Ensures Hive is initialized and all required boxes are open.
|
||||
Future<HiveBoxes> ensureInitialized() async {
|
||||
if (_boxes != null) {
|
||||
return _boxes!;
|
||||
}
|
||||
|
||||
await Hive.initFlutter('conduit_hive');
|
||||
|
||||
final preferences = await Hive.openBox<dynamic>(HiveBoxNames.preferences);
|
||||
final caches = await Hive.openBox<dynamic>(HiveBoxNames.caches);
|
||||
final attachmentQueue = await Hive.openBox<dynamic>(
|
||||
HiveBoxNames.attachmentQueue,
|
||||
);
|
||||
final metadata = await Hive.openBox<dynamic>(HiveBoxNames.metadata);
|
||||
|
||||
_boxes = HiveBoxes(
|
||||
preferences: preferences,
|
||||
caches: caches,
|
||||
attachmentQueue: attachmentQueue,
|
||||
metadata: metadata,
|
||||
);
|
||||
|
||||
return _boxes!;
|
||||
}
|
||||
|
||||
/// Access the cached boxes after [ensureInitialized] has completed.
|
||||
HiveBoxes get boxes {
|
||||
final cached = _boxes;
|
||||
if (cached == null) {
|
||||
throw StateError('HiveBootstrap.ensureInitialized must run first.');
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
35
lib/core/persistence/hive_boxes.dart
Normal file
35
lib/core/persistence/hive_boxes.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:hive_ce/hive.dart';
|
||||
|
||||
/// Logical names for Hive boxes used by the app.
|
||||
final class HiveBoxNames {
|
||||
static const String preferences = 'preferences_v1';
|
||||
static const String caches = 'caches_v1';
|
||||
static const String attachmentQueue = 'attachment_queue_v1';
|
||||
static const String metadata = 'metadata_v1';
|
||||
}
|
||||
|
||||
/// Well-known keys stored inside Hive boxes.
|
||||
final class HiveStoreKeys {
|
||||
// Metadata
|
||||
static const String migrationVersion = 'migration_version';
|
||||
|
||||
// Cache entries
|
||||
static const String localConversations = 'local_conversations';
|
||||
static const String attachmentQueueEntries = 'attachment_queue_entries';
|
||||
static const String taskQueue = 'outbound_task_queue_v1';
|
||||
}
|
||||
|
||||
/// Grouped Hive boxes that remain open for the app lifecycle.
|
||||
class HiveBoxes {
|
||||
HiveBoxes({
|
||||
required this.preferences,
|
||||
required this.caches,
|
||||
required this.attachmentQueue,
|
||||
required this.metadata,
|
||||
});
|
||||
|
||||
final Box<dynamic> preferences;
|
||||
final Box<dynamic> caches;
|
||||
final Box<dynamic> attachmentQueue;
|
||||
final Box<dynamic> metadata;
|
||||
}
|
||||
29
lib/core/persistence/persistence_keys.dart
Normal file
29
lib/core/persistence/persistence_keys.dart
Normal file
@@ -0,0 +1,29 @@
|
||||
/// Keys previously stored in SharedPreferences. Centralized so Hive-based
|
||||
/// storage and migration logic stay aligned.
|
||||
final class PreferenceKeys {
|
||||
static const String reduceMotion = 'reduce_motion';
|
||||
static const String animationSpeed = 'animation_speed';
|
||||
static const String hapticFeedback = 'haptic_feedback';
|
||||
static const String highContrast = 'high_contrast';
|
||||
static const String largeText = 'large_text';
|
||||
static const String darkMode = 'dark_mode';
|
||||
static const String defaultModel = 'default_model';
|
||||
static const String omitProviderInModelName = 'omit_provider_in_model_name';
|
||||
static const String voiceLocaleId = 'voice_locale_id';
|
||||
static const String voiceHoldToTalk = 'voice_hold_to_talk';
|
||||
static const String voiceAutoSendFinal = 'voice_auto_send_final';
|
||||
static const String socketTransportMode = 'socket_transport_mode';
|
||||
static const String quickPills = 'quick_pills';
|
||||
static const String sendOnEnterKey = 'send_on_enter';
|
||||
static const String rememberCredentials = 'remember_credentials';
|
||||
static const String activeServerId = 'active_server_id';
|
||||
static const String themeMode = 'theme_mode';
|
||||
static const String localeCode = 'locale_code_v1';
|
||||
static const String onboardingSeen = 'onboarding_seen_v1';
|
||||
static const String reviewerMode = 'reviewer_mode_v1';
|
||||
}
|
||||
|
||||
final class LegacyPreferenceKeys {
|
||||
static const String attachmentUploadQueue = 'attachment_upload_queue';
|
||||
static const String taskQueue = 'outbound_task_queue_v1';
|
||||
}
|
||||
210
lib/core/persistence/persistence_migrator.dart
Normal file
210
lib/core/persistence/persistence_migrator.dart
Normal file
@@ -0,0 +1,210 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../utils/debug_logger.dart';
|
||||
import 'hive_boxes.dart';
|
||||
import 'persistence_keys.dart';
|
||||
|
||||
/// Handles one-time migration from SharedPreferences to Hive-backed storage.
|
||||
class PersistenceMigrator {
|
||||
PersistenceMigrator({required HiveBoxes hiveBoxes}) : _boxes = hiveBoxes;
|
||||
|
||||
static const int _targetVersion = 1;
|
||||
|
||||
final HiveBoxes _boxes;
|
||||
|
||||
Future<void> migrateIfNeeded() async {
|
||||
final currentVersion =
|
||||
_boxes.metadata.get(HiveStoreKeys.migrationVersion) as int?;
|
||||
if (currentVersion != null && currentVersion >= _targetVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
DebugLogger.log(
|
||||
'Starting SharedPreferences → Hive migration',
|
||||
scope: 'persistence/migration',
|
||||
);
|
||||
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await _migratePreferences(prefs);
|
||||
await _migrateCaches(prefs);
|
||||
await _migrateAttachmentQueue(prefs);
|
||||
await _migrateTaskQueue(prefs);
|
||||
|
||||
await _boxes.metadata.put(HiveStoreKeys.migrationVersion, _targetVersion);
|
||||
|
||||
await _cleanupLegacyKeys(prefs);
|
||||
DebugLogger.log('Migration completed', scope: 'persistence/migration');
|
||||
} catch (error, stack) {
|
||||
DebugLogger.error(
|
||||
'Migration failed',
|
||||
scope: 'persistence/migration',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migratePreferences(SharedPreferences prefs) async {
|
||||
final updates = <String, Object?>{};
|
||||
|
||||
void copyBool(String key) {
|
||||
final value = prefs.getBool(key);
|
||||
if (value != null) updates[key] = value;
|
||||
}
|
||||
|
||||
void copyDouble(String key) {
|
||||
final value = prefs.getDouble(key);
|
||||
if (value != null) updates[key] = value;
|
||||
}
|
||||
|
||||
void copyString(String key) {
|
||||
final value = prefs.getString(key);
|
||||
if (value != null && value.isNotEmpty) updates[key] = value;
|
||||
}
|
||||
|
||||
void copyStringList(String key) {
|
||||
final value = prefs.getStringList(key);
|
||||
if (value != null && value.isNotEmpty) {
|
||||
updates[key] = List<String>.from(value);
|
||||
}
|
||||
}
|
||||
|
||||
copyBool(PreferenceKeys.reduceMotion);
|
||||
copyDouble(PreferenceKeys.animationSpeed);
|
||||
copyBool(PreferenceKeys.hapticFeedback);
|
||||
copyBool(PreferenceKeys.highContrast);
|
||||
copyBool(PreferenceKeys.largeText);
|
||||
copyBool(PreferenceKeys.darkMode);
|
||||
copyString(PreferenceKeys.defaultModel);
|
||||
copyBool(PreferenceKeys.omitProviderInModelName);
|
||||
copyString(PreferenceKeys.voiceLocaleId);
|
||||
copyBool(PreferenceKeys.voiceHoldToTalk);
|
||||
copyBool(PreferenceKeys.voiceAutoSendFinal);
|
||||
copyString(PreferenceKeys.socketTransportMode);
|
||||
copyStringList(PreferenceKeys.quickPills);
|
||||
copyBool(PreferenceKeys.sendOnEnterKey);
|
||||
copyBool(PreferenceKeys.rememberCredentials);
|
||||
copyString(PreferenceKeys.activeServerId);
|
||||
copyString(PreferenceKeys.themeMode);
|
||||
copyString(PreferenceKeys.localeCode);
|
||||
copyBool(PreferenceKeys.onboardingSeen);
|
||||
copyBool(PreferenceKeys.reviewerMode);
|
||||
|
||||
if (updates.isNotEmpty) {
|
||||
await _boxes.preferences.putAll(updates);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateCaches(SharedPreferences prefs) async {
|
||||
final jsonString = prefs.getString(HiveStoreKeys.localConversations);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is List) {
|
||||
final list = decoded
|
||||
.map((entry) => Map<String, dynamic>.from(entry as Map))
|
||||
.toList(growable: false);
|
||||
await _boxes.caches.put(HiveStoreKeys.localConversations, list);
|
||||
}
|
||||
} catch (error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to migrate local conversations',
|
||||
scope: 'persistence/migration',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateAttachmentQueue(SharedPreferences prefs) async {
|
||||
final jsonString = prefs.getString(
|
||||
LegacyPreferenceKeys.attachmentUploadQueue,
|
||||
);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is List) {
|
||||
final list = decoded
|
||||
.map((entry) => Map<String, dynamic>.from(entry as Map))
|
||||
.toList(growable: false);
|
||||
await _boxes.attachmentQueue.put(
|
||||
HiveStoreKeys.attachmentQueueEntries,
|
||||
list,
|
||||
);
|
||||
}
|
||||
} catch (error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to migrate attachment queue',
|
||||
scope: 'persistence/migration',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _migrateTaskQueue(SharedPreferences prefs) async {
|
||||
final jsonString = prefs.getString(LegacyPreferenceKeys.taskQueue);
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is List) {
|
||||
final list = decoded
|
||||
.map((entry) => Map<String, dynamic>.from(entry as Map))
|
||||
.toList(growable: false);
|
||||
await _boxes.caches.put(HiveStoreKeys.taskQueue, list);
|
||||
}
|
||||
} catch (error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to migrate outbound task queue',
|
||||
scope: 'persistence/migration',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cleanupLegacyKeys(SharedPreferences prefs) async {
|
||||
final keysToRemove = <String>[
|
||||
PreferenceKeys.reduceMotion,
|
||||
PreferenceKeys.animationSpeed,
|
||||
PreferenceKeys.hapticFeedback,
|
||||
PreferenceKeys.highContrast,
|
||||
PreferenceKeys.largeText,
|
||||
PreferenceKeys.darkMode,
|
||||
PreferenceKeys.defaultModel,
|
||||
PreferenceKeys.omitProviderInModelName,
|
||||
PreferenceKeys.voiceLocaleId,
|
||||
PreferenceKeys.voiceHoldToTalk,
|
||||
PreferenceKeys.voiceAutoSendFinal,
|
||||
PreferenceKeys.socketTransportMode,
|
||||
PreferenceKeys.quickPills,
|
||||
PreferenceKeys.sendOnEnterKey,
|
||||
PreferenceKeys.rememberCredentials,
|
||||
PreferenceKeys.activeServerId,
|
||||
PreferenceKeys.themeMode,
|
||||
PreferenceKeys.localeCode,
|
||||
PreferenceKeys.onboardingSeen,
|
||||
PreferenceKeys.reviewerMode,
|
||||
HiveStoreKeys.localConversations,
|
||||
HiveStoreKeys.attachmentQueueEntries,
|
||||
LegacyPreferenceKeys.attachmentUploadQueue,
|
||||
LegacyPreferenceKeys.taskQueue,
|
||||
];
|
||||
|
||||
for (final key in keysToRemove) {
|
||||
await prefs.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
lib/core/persistence/persistence_providers.dart
Normal file
10
lib/core/persistence/persistence_providers.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
import 'hive_boxes.dart';
|
||||
|
||||
part 'persistence_providers.g.dart';
|
||||
|
||||
/// Provides access to eagerly opened Hive boxes. Must be overridden in [main].
|
||||
@Riverpod(keepAlive: true)
|
||||
HiveBoxes hiveBoxes(Ref ref) =>
|
||||
throw UnimplementedError('Hive boxes must be provided during bootstrap.');
|
||||
@@ -4,9 +4,7 @@ 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 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../services/storage_service.dart';
|
||||
// (removed duplicate) import '../services/optimized_storage_service.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';
|
||||
@@ -29,10 +27,6 @@ import '../models/socket_event.dart';
|
||||
part 'app_providers.g.dart';
|
||||
|
||||
// Storage providers
|
||||
final sharedPreferencesProvider = Provider<SharedPreferences>((ref) {
|
||||
throw UnimplementedError();
|
||||
});
|
||||
|
||||
final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
|
||||
// Single, shared instance with explicit platform options
|
||||
return const FlutterSecureStorage(
|
||||
@@ -50,20 +44,13 @@ final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
|
||||
);
|
||||
});
|
||||
|
||||
final storageServiceProvider = Provider<StorageService>((ref) {
|
||||
return StorageService(
|
||||
secureStorage: ref.watch(secureStorageProvider),
|
||||
prefs: ref.watch(sharedPreferencesProvider),
|
||||
);
|
||||
});
|
||||
|
||||
// Optimized storage service provider
|
||||
final optimizedStorageServiceProvider = Provider<OptimizedStorageService>((
|
||||
ref,
|
||||
) {
|
||||
return OptimizedStorageService(
|
||||
secureStorage: ref.watch(secureStorageProvider),
|
||||
prefs: ref.watch(sharedPreferencesProvider),
|
||||
boxes: ref.watch(hiveBoxesProvider),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import '../persistence/hive_boxes.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
|
||||
/// Status of a queued attachment upload
|
||||
@@ -110,12 +111,12 @@ class AttachmentUploadQueue {
|
||||
factory AttachmentUploadQueue() => _instance;
|
||||
AttachmentUploadQueue._internal();
|
||||
|
||||
static const String _prefsKey = 'attachment_upload_queue';
|
||||
static const int _maxRetries = 4;
|
||||
static const Duration _baseRetryDelay = Duration(seconds: 5);
|
||||
static const Duration _maxRetryDelay = Duration(minutes: 5);
|
||||
|
||||
SharedPreferences? _prefs;
|
||||
late final Box<dynamic> _queueBox;
|
||||
bool _initialized = false;
|
||||
final List<QueuedAttachment> _queue = [];
|
||||
Timer? _retryTimer;
|
||||
bool _isProcessing = false;
|
||||
@@ -136,11 +137,14 @@ class AttachmentUploadQueue {
|
||||
}) async {
|
||||
_onUpload = onUpload;
|
||||
_onQueueChanged = onQueueChanged;
|
||||
_prefs ??= await SharedPreferences.getInstance();
|
||||
if (!_initialized) {
|
||||
_queueBox = Hive.box<dynamic>(HiveBoxNames.attachmentQueue);
|
||||
_initialized = true;
|
||||
}
|
||||
await _load();
|
||||
_startPeriodicProcessing();
|
||||
DebugLogger.log(
|
||||
'AttachmentUploadQueue initialized with ${_queue.length} items',
|
||||
'AttachmentUploadQueue initialized with \${_queue.length} items',
|
||||
scope: 'attachments/queue',
|
||||
);
|
||||
}
|
||||
@@ -328,20 +332,33 @@ class AttachmentUploadQueue {
|
||||
|
||||
// Utilities
|
||||
Future<void> _load() async {
|
||||
final jsonStr = (_prefs ?? await SharedPreferences.getInstance()).getString(
|
||||
_prefsKey,
|
||||
);
|
||||
if (jsonStr == null || jsonStr.isEmpty) return;
|
||||
final list = (jsonDecode(jsonStr) as List).cast<Map<String, dynamic>>();
|
||||
final stored = _queueBox.get(HiveStoreKeys.attachmentQueueEntries);
|
||||
if (stored == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
List<dynamic> rawList;
|
||||
if (stored is String && stored.isNotEmpty) {
|
||||
rawList = (jsonDecode(stored) as List<dynamic>);
|
||||
} else if (stored is List) {
|
||||
rawList = stored;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
_queue
|
||||
..clear()
|
||||
..addAll(list.map(QueuedAttachment.fromJson));
|
||||
..addAll(
|
||||
rawList.map(
|
||||
(item) =>
|
||||
QueuedAttachment.fromJson(Map<String, dynamic>.from(item as Map)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final prefs = _prefs ?? await SharedPreferences.getInstance();
|
||||
final list = _queue.map((e) => e.toJson()).toList(growable: false);
|
||||
await prefs.setString(_prefsKey, jsonEncode(list));
|
||||
await _queueBox.put(HiveStoreKeys.attachmentQueueEntries, list);
|
||||
}
|
||||
|
||||
void _notify() {
|
||||
|
||||
@@ -1,41 +1,52 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'secure_credential_storage.dart';
|
||||
import '../models/server_config.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
|
||||
import '../models/conversation.dart';
|
||||
import '../models/server_config.dart';
|
||||
import '../persistence/hive_boxes.dart';
|
||||
import '../persistence/persistence_keys.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
import 'secure_credential_storage.dart';
|
||||
|
||||
/// Optimized storage service with single secure storage implementation
|
||||
/// Eliminates dual storage overhead and improves performance
|
||||
/// Optimized storage service backed by Hive for non-sensitive data and
|
||||
/// FlutterSecureStorage for credentials.
|
||||
class OptimizedStorageService {
|
||||
final SharedPreferences _prefs;
|
||||
final SecureCredentialStorage _secureCredentialStorage;
|
||||
|
||||
OptimizedStorageService({
|
||||
required FlutterSecureStorage secureStorage,
|
||||
required SharedPreferences prefs,
|
||||
}) : _prefs = prefs,
|
||||
required HiveBoxes boxes,
|
||||
}) : _preferencesBox = boxes.preferences,
|
||||
_cachesBox = boxes.caches,
|
||||
_attachmentQueueBox = boxes.attachmentQueue,
|
||||
_metadataBox = boxes.metadata,
|
||||
_secureCredentialStorage = SecureCredentialStorage(
|
||||
instance: secureStorage,
|
||||
);
|
||||
|
||||
// Optimized key names with versioning
|
||||
final Box<dynamic> _preferencesBox;
|
||||
final Box<dynamic> _cachesBox;
|
||||
final Box<dynamic> _attachmentQueueBox;
|
||||
final Box<dynamic> _metadataBox;
|
||||
final SecureCredentialStorage _secureCredentialStorage;
|
||||
|
||||
static const String _authTokenKey = 'auth_token_v3';
|
||||
static const String _activeServerIdKey = 'active_server_id';
|
||||
static const String _rememberCredentialsKey = 'remember_credentials';
|
||||
static const String _themeModeKey = 'theme_mode';
|
||||
static const String _localeCodeKey = 'locale_code_v1';
|
||||
static const String _localConversationsKey = 'local_conversations';
|
||||
static const String _onboardingSeenKey = 'onboarding_seen_v1';
|
||||
static const String _reviewerModeKey = 'reviewer_mode_v1';
|
||||
static const String _activeServerIdKey = PreferenceKeys.activeServerId;
|
||||
static const String _rememberCredentialsKey =
|
||||
PreferenceKeys.rememberCredentials;
|
||||
static const String _themeModeKey = PreferenceKeys.themeMode;
|
||||
static const String _localeCodeKey = PreferenceKeys.localeCode;
|
||||
static const String _localConversationsKey = HiveStoreKeys.localConversations;
|
||||
static const String _onboardingSeenKey = PreferenceKeys.onboardingSeen;
|
||||
static const String _reviewerModeKey = PreferenceKeys.reviewerMode;
|
||||
|
||||
// Cache for frequently accessed data
|
||||
final Map<String, dynamic> _cache = {};
|
||||
static const Duration _cacheTimeout = Duration(minutes: 5);
|
||||
final Map<String, DateTime> _cacheTimestamps = {};
|
||||
static const Duration _cacheTimeout = Duration(minutes: 5);
|
||||
|
||||
/// Auth Token Management (Optimized with caching)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auth token APIs (secure storage + in-memory cache)
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> saveAuthToken(String token) async {
|
||||
try {
|
||||
await _secureCredentialStorage.saveAuthToken(token);
|
||||
@@ -45,9 +56,9 @@ class OptimizedStorageService {
|
||||
'Auth token saved and cached',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (error) {
|
||||
DebugLogger.log(
|
||||
'Failed to save auth token: $e',
|
||||
'Failed to save auth token: $error',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
rethrow;
|
||||
@@ -55,12 +66,11 @@ class OptimizedStorageService {
|
||||
}
|
||||
|
||||
Future<String?> getAuthToken() async {
|
||||
// Check cache first
|
||||
if (_isCacheValid(_authTokenKey)) {
|
||||
final cachedToken = _cache[_authTokenKey] as String?;
|
||||
if (cachedToken != null) {
|
||||
final cached = _cache[_authTokenKey] as String?;
|
||||
if (cached != null) {
|
||||
DebugLogger.log('Using cached auth token', scope: 'storage/optimized');
|
||||
return cachedToken;
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,9 +81,9 @@ class OptimizedStorageService {
|
||||
_cacheTimestamps[_authTokenKey] = DateTime.now();
|
||||
}
|
||||
return token;
|
||||
} catch (e) {
|
||||
} catch (error) {
|
||||
DebugLogger.log(
|
||||
'Failed to retrieve auth token: $e',
|
||||
'Failed to retrieve auth token: $error',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
return null;
|
||||
@@ -89,15 +99,17 @@ class OptimizedStorageService {
|
||||
'Auth token deleted and cache cleared',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (error) {
|
||||
DebugLogger.log(
|
||||
'Failed to delete auth token: $e',
|
||||
'Failed to delete auth token: $error',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Credential Management (Single storage implementation)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Credential APIs (secure storage only)
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> saveCredentials({
|
||||
required String serverId,
|
||||
required String username,
|
||||
@@ -110,7 +122,6 @@ class OptimizedStorageService {
|
||||
password: password,
|
||||
);
|
||||
|
||||
// Cache the fact that credentials exist (not the credentials themselves)
|
||||
_cache['has_credentials'] = true;
|
||||
_cacheTimestamps['has_credentials'] = DateTime.now();
|
||||
|
||||
@@ -118,9 +129,9 @@ class OptimizedStorageService {
|
||||
'Credentials saved via optimized storage',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (error) {
|
||||
DebugLogger.log(
|
||||
'Failed to save credentials: $e',
|
||||
'Failed to save credentials: $error',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
rethrow;
|
||||
@@ -129,17 +140,13 @@ class OptimizedStorageService {
|
||||
|
||||
Future<Map<String, String>?> getSavedCredentials() async {
|
||||
try {
|
||||
// Use single storage implementation - no fallback needed
|
||||
final credentials = await _secureCredentialStorage.getSavedCredentials();
|
||||
|
||||
// Update cache flag
|
||||
_cache['has_credentials'] = credentials != null;
|
||||
_cacheTimestamps['has_credentials'] = DateTime.now();
|
||||
|
||||
return credentials;
|
||||
} catch (e) {
|
||||
} catch (error) {
|
||||
DebugLogger.log(
|
||||
'Failed to retrieve credentials: $e',
|
||||
'Failed to retrieve credentials: $error',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
return null;
|
||||
@@ -155,50 +162,46 @@ class OptimizedStorageService {
|
||||
'Credentials deleted via optimized storage',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (error) {
|
||||
DebugLogger.log(
|
||||
'Failed to delete credentials: $e',
|
||||
'Failed to delete credentials: $error',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Quick check if credentials exist (uses cache)
|
||||
Future<bool> hasCredentials() async {
|
||||
if (_isCacheValid('has_credentials')) {
|
||||
return _cache['has_credentials'] == true;
|
||||
}
|
||||
|
||||
final credentials = await getSavedCredentials();
|
||||
return credentials != null;
|
||||
}
|
||||
|
||||
/// Remember Credentials Flag
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preference helpers (Hive-backed)
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> setRememberCredentials(bool remember) async {
|
||||
await _prefs.setBool(_rememberCredentialsKey, remember);
|
||||
await _preferencesBox.put(_rememberCredentialsKey, remember);
|
||||
}
|
||||
|
||||
bool getRememberCredentials() {
|
||||
return _prefs.getBool(_rememberCredentialsKey) ?? false;
|
||||
return (_preferencesBox.get(_rememberCredentialsKey) as bool?) ?? false;
|
||||
}
|
||||
|
||||
/// Server Configuration (Optimized)
|
||||
Future<void> saveServerConfigs(List<ServerConfig> configs) async {
|
||||
try {
|
||||
final jsonString = jsonEncode(configs.map((c) => c.toJson()).toList());
|
||||
await _secureCredentialStorage.saveServerConfigs(jsonString);
|
||||
|
||||
// Cache config count for quick checks
|
||||
_cache['server_config_count'] = configs.length;
|
||||
_cacheTimestamps['server_config_count'] = DateTime.now();
|
||||
|
||||
DebugLogger.log(
|
||||
'Server configs saved (${configs.length} configs)',
|
||||
'Server configs saved (${configs.length} entries)',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (error) {
|
||||
DebugLogger.log(
|
||||
'Failed to save server configs: $e',
|
||||
'Failed to save server configs: $error',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
rethrow;
|
||||
@@ -211,162 +214,151 @@ class OptimizedStorageService {
|
||||
if (jsonString == null || jsonString.isEmpty) {
|
||||
_cache['server_config_count'] = 0;
|
||||
_cacheTimestamps['server_config_count'] = DateTime.now();
|
||||
return [];
|
||||
return const [];
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(jsonString) as List<dynamic>;
|
||||
final configs = decoded
|
||||
.map((item) => ServerConfig.fromJson(item))
|
||||
.toList();
|
||||
|
||||
// Update cache
|
||||
_cache['server_config_count'] = configs.length;
|
||||
_cacheTimestamps['server_config_count'] = DateTime.now();
|
||||
|
||||
return configs;
|
||||
} catch (e) {
|
||||
} catch (error) {
|
||||
DebugLogger.log(
|
||||
'Failed to retrieve server configs: $e',
|
||||
'Failed to retrieve server configs: $error',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
return [];
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Active Server Management
|
||||
Future<void> setActiveServerId(String? serverId) async {
|
||||
if (serverId != null) {
|
||||
await _prefs.setString(_activeServerIdKey, serverId);
|
||||
await _preferencesBox.put(_activeServerIdKey, serverId);
|
||||
} else {
|
||||
await _prefs.remove(_activeServerIdKey);
|
||||
await _preferencesBox.delete(_activeServerIdKey);
|
||||
}
|
||||
|
||||
// Update cache
|
||||
_cache[_activeServerIdKey] = serverId;
|
||||
_cacheTimestamps[_activeServerIdKey] = DateTime.now();
|
||||
}
|
||||
|
||||
Future<String?> getActiveServerId() async {
|
||||
// Check cache first
|
||||
if (_isCacheValid(_activeServerIdKey)) {
|
||||
return _cache[_activeServerIdKey] as String?;
|
||||
}
|
||||
|
||||
final serverId = _prefs.getString(_activeServerIdKey);
|
||||
final serverId = _preferencesBox.get(_activeServerIdKey) as String?;
|
||||
_cache[_activeServerIdKey] = serverId;
|
||||
_cacheTimestamps[_activeServerIdKey] = DateTime.now();
|
||||
|
||||
return serverId;
|
||||
}
|
||||
|
||||
/// Theme Management
|
||||
String? getThemeMode() {
|
||||
return _prefs.getString(_themeModeKey);
|
||||
return _preferencesBox.get(_themeModeKey) as String?;
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(String mode) async {
|
||||
await _prefs.setString(_themeModeKey, mode);
|
||||
await _preferencesBox.put(_themeModeKey, mode);
|
||||
}
|
||||
|
||||
/// Locale Management
|
||||
String? getLocaleCode() {
|
||||
// Returns a locale code like 'en', 'de', 'fr', 'it'. Null means system.
|
||||
return _prefs.getString(_localeCodeKey);
|
||||
return _preferencesBox.get(_localeCodeKey) as String?;
|
||||
}
|
||||
|
||||
Future<void> setLocaleCode(String? code) async {
|
||||
if (code == null || code.isEmpty) {
|
||||
await _prefs.remove(_localeCodeKey);
|
||||
await _preferencesBox.delete(_localeCodeKey);
|
||||
} else {
|
||||
await _prefs.setString(_localeCodeKey, code);
|
||||
await _preferencesBox.put(_localeCodeKey, code);
|
||||
}
|
||||
}
|
||||
|
||||
/// Onboarding
|
||||
Future<bool> getOnboardingSeen() async {
|
||||
return _prefs.getBool(_onboardingSeenKey) ?? false;
|
||||
return (_preferencesBox.get(_onboardingSeenKey) as bool?) ?? false;
|
||||
}
|
||||
|
||||
Future<void> setOnboardingSeen(bool seen) async {
|
||||
await _prefs.setBool(_onboardingSeenKey, seen);
|
||||
await _preferencesBox.put(_onboardingSeenKey, seen);
|
||||
}
|
||||
|
||||
/// Reviewer mode (persisted)
|
||||
Future<bool> getReviewerMode() async {
|
||||
return _prefs.getBool(_reviewerModeKey) ?? false;
|
||||
return (_preferencesBox.get(_reviewerModeKey) as bool?) ?? false;
|
||||
}
|
||||
|
||||
Future<void> setReviewerMode(bool enabled) async {
|
||||
await _prefs.setBool(_reviewerModeKey, enabled);
|
||||
await _preferencesBox.put(_reviewerModeKey, enabled);
|
||||
}
|
||||
|
||||
/// Local Conversations (Optimized with compression)
|
||||
Future<List<Conversation>> getLocalConversations() async {
|
||||
try {
|
||||
final jsonString = _prefs.getString(_localConversationsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) return [];
|
||||
|
||||
final decoded = jsonDecode(jsonString) as List<dynamic>;
|
||||
return decoded.map((item) => Conversation.fromJson(item)).toList();
|
||||
} catch (e) {
|
||||
DebugLogger.log(
|
||||
'Failed to retrieve local conversations: $e',
|
||||
final stored = _cachesBox.get(_localConversationsKey);
|
||||
if (stored == null) {
|
||||
return const [];
|
||||
}
|
||||
if (stored is String) {
|
||||
final decoded = jsonDecode(stored) as List<dynamic>;
|
||||
return decoded.map((item) => Conversation.fromJson(item)).toList();
|
||||
}
|
||||
if (stored is List) {
|
||||
return stored
|
||||
.map(
|
||||
(item) =>
|
||||
Conversation.fromJson(Map<String, dynamic>.from(item as Map)),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
return const [];
|
||||
} catch (error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to retrieve local conversations',
|
||||
scope: 'storage/optimized',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
return [];
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveLocalConversations(List<Conversation> conversations) async {
|
||||
try {
|
||||
// Only save essential data to reduce storage size
|
||||
final lightweightConversations = conversations
|
||||
.map(
|
||||
(conv) => {
|
||||
'id': conv.id,
|
||||
'title': conv.title,
|
||||
'updatedAt': conv.updatedAt.toIso8601String(),
|
||||
'messageCount': conv.messages.length,
|
||||
// Don't save full message content locally
|
||||
},
|
||||
)
|
||||
final serialized = conversations
|
||||
.map((conversation) => conversation.toJson())
|
||||
.toList();
|
||||
|
||||
final jsonString = jsonEncode(lightweightConversations);
|
||||
await _prefs.setString(_localConversationsKey, jsonString);
|
||||
|
||||
await _cachesBox.put(_localConversationsKey, serialized);
|
||||
DebugLogger.log(
|
||||
'Saved ${conversations.length} local conversations (lightweight)',
|
||||
'Saved ${conversations.length} local conversations',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLogger.log(
|
||||
'Failed to save local conversations: $e',
|
||||
} catch (error, stack) {
|
||||
DebugLogger.error(
|
||||
'Failed to save local conversations',
|
||||
scope: 'storage/optimized',
|
||||
error: error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch Operations for Performance
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch operations
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> clearAuthData() async {
|
||||
try {
|
||||
// Clear auth-related data in batch
|
||||
await Future.wait([
|
||||
deleteAuthToken(),
|
||||
deleteSavedCredentials(),
|
||||
_prefs.remove(_rememberCredentialsKey),
|
||||
_prefs.remove(_activeServerIdKey),
|
||||
_preferencesBox.delete(_rememberCredentialsKey),
|
||||
_preferencesBox.delete(_activeServerIdKey),
|
||||
]);
|
||||
|
||||
// Clear related cache entries
|
||||
_cache.removeWhere(
|
||||
(key, value) =>
|
||||
(key, _) =>
|
||||
key.contains('auth') ||
|
||||
key.contains('credentials') ||
|
||||
key.contains('server'),
|
||||
);
|
||||
_cacheTimestamps.removeWhere(
|
||||
(key, value) =>
|
||||
(key, _) =>
|
||||
key.contains('auth') ||
|
||||
key.contains('credentials') ||
|
||||
key.contains('server'),
|
||||
@@ -376,9 +368,9 @@ class OptimizedStorageService {
|
||||
'Auth data cleared in batch operation',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (error) {
|
||||
DebugLogger.log(
|
||||
'Failed to clear auth data: $e',
|
||||
'Failed to clear auth data: $error',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
}
|
||||
@@ -386,30 +378,48 @@ class OptimizedStorageService {
|
||||
|
||||
Future<void> clearAll() async {
|
||||
try {
|
||||
await Future.wait([_secureCredentialStorage.clearAll(), _prefs.clear()]);
|
||||
await Future.wait([
|
||||
_secureCredentialStorage.clearAll(),
|
||||
_preferencesBox.clear(),
|
||||
_cachesBox.clear(),
|
||||
_attachmentQueueBox.clear(),
|
||||
]);
|
||||
|
||||
_cache.clear();
|
||||
_cacheTimestamps.clear();
|
||||
|
||||
// Preserve migration metadata
|
||||
final migrationVersion =
|
||||
_metadataBox.get(HiveStoreKeys.migrationVersion) as int?;
|
||||
await _metadataBox.clear();
|
||||
if (migrationVersion != null) {
|
||||
await _metadataBox.put(
|
||||
HiveStoreKeys.migrationVersion,
|
||||
migrationVersion,
|
||||
);
|
||||
}
|
||||
|
||||
DebugLogger.log('All storage cleared', scope: 'storage/optimized');
|
||||
} catch (e) {
|
||||
} catch (error) {
|
||||
DebugLogger.log(
|
||||
'Failed to clear all storage: $e',
|
||||
'Failed to clear all storage: $error',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Storage Health Check
|
||||
Future<bool> isSecureStorageAvailable() async {
|
||||
return await _secureCredentialStorage.isSecureStorageAvailable();
|
||||
return _secureCredentialStorage.isSecureStorageAvailable();
|
||||
}
|
||||
|
||||
/// Cache Management Utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cache helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
bool _isCacheValid(String key) {
|
||||
final timestamp = _cacheTimestamps[key];
|
||||
if (timestamp == null) return false;
|
||||
|
||||
if (timestamp == null) {
|
||||
return false;
|
||||
}
|
||||
return DateTime.now().difference(timestamp) < _cacheTimeout;
|
||||
}
|
||||
|
||||
@@ -419,37 +429,33 @@ class OptimizedStorageService {
|
||||
DebugLogger.log('Storage cache cleared', scope: 'storage/optimized');
|
||||
}
|
||||
|
||||
/// Migration from old storage service (one-time operation)
|
||||
// ---------------------------------------------------------------------------
|
||||
// Legacy migration hooks (no-op)
|
||||
// ---------------------------------------------------------------------------
|
||||
Future<void> migrateFromLegacyStorage() async {
|
||||
try {
|
||||
DebugLogger.log(
|
||||
'Starting migration from legacy storage',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
|
||||
// This would be called once during app upgrade
|
||||
// Implementation would depend on the specific migration needs
|
||||
// For now, the SecureCredentialStorage already handles legacy migration
|
||||
|
||||
DebugLogger.log(
|
||||
'Legacy storage migration completed',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
} catch (e) {
|
||||
} catch (error) {
|
||||
DebugLogger.log(
|
||||
'Legacy storage migration failed: $e',
|
||||
'Legacy storage migration failed: $error',
|
||||
scope: 'storage/optimized',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Performance Monitoring
|
||||
Map<String, dynamic> getStorageStats() {
|
||||
return {
|
||||
'cacheSize': _cache.length,
|
||||
'cachedKeys': _cache.keys.toList(),
|
||||
'lastAccess': _cacheTimestamps.entries
|
||||
.map((e) => '${e.key}: ${e.value}')
|
||||
.map((entry) => '${entry.key}: ${entry.value}')
|
||||
.toList(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,246 +1,260 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:hive_ce/hive.dart';
|
||||
import '../persistence/hive_boxes.dart';
|
||||
import '../persistence/persistence_keys.dart';
|
||||
import 'animation_service.dart';
|
||||
|
||||
part 'settings_service.g.dart';
|
||||
|
||||
/// Service for managing app-wide settings including accessibility preferences
|
||||
class SettingsService {
|
||||
static const String _reduceMotionKey = 'reduce_motion';
|
||||
static const String _animationSpeedKey = 'animation_speed';
|
||||
static const String _hapticFeedbackKey = 'haptic_feedback';
|
||||
static const String _highContrastKey = 'high_contrast';
|
||||
static const String _largeTextKey = 'large_text';
|
||||
static const String _darkModeKey = 'dark_mode';
|
||||
static const String _defaultModelKey = 'default_model';
|
||||
static const String _reduceMotionKey = PreferenceKeys.reduceMotion;
|
||||
static const String _animationSpeedKey = PreferenceKeys.animationSpeed;
|
||||
static const String _hapticFeedbackKey = PreferenceKeys.hapticFeedback;
|
||||
static const String _highContrastKey = PreferenceKeys.highContrast;
|
||||
static const String _largeTextKey = PreferenceKeys.largeText;
|
||||
static const String _darkModeKey = PreferenceKeys.darkMode;
|
||||
static const String _defaultModelKey = PreferenceKeys.defaultModel;
|
||||
// Model name formatting
|
||||
static const String _omitProviderInModelNameKey =
|
||||
'omit_provider_in_model_name';
|
||||
PreferenceKeys.omitProviderInModelName;
|
||||
// Voice input settings
|
||||
static const String _voiceLocaleKey = 'voice_locale_id';
|
||||
static const String _voiceHoldToTalkKey = 'voice_hold_to_talk';
|
||||
static const String _voiceAutoSendKey = 'voice_auto_send_final';
|
||||
static const String _voiceLocaleKey = PreferenceKeys.voiceLocaleId;
|
||||
static const String _voiceHoldToTalkKey = PreferenceKeys.voiceHoldToTalk;
|
||||
static const String _voiceAutoSendKey = PreferenceKeys.voiceAutoSendFinal;
|
||||
// Realtime transport preference
|
||||
static const String _socketTransportModeKey =
|
||||
'socket_transport_mode'; // 'auto' or 'ws'
|
||||
PreferenceKeys.socketTransportMode; // 'auto' or 'ws'
|
||||
// Quick pill visibility selections (max 2)
|
||||
static const String _quickPillsKey =
|
||||
'quick_pills'; // StringList of identifiers e.g. ['web','image','tools']
|
||||
static const String _quickPillsKey = PreferenceKeys
|
||||
.quickPills; // StringList of identifiers e.g. ['web','image','tools']
|
||||
// Chat input behavior
|
||||
static const String _sendOnEnterKey = 'send_on_enter';
|
||||
static const String _sendOnEnterKey = PreferenceKeys.sendOnEnterKey;
|
||||
|
||||
static Box<dynamic> _preferencesBox() =>
|
||||
Hive.box<dynamic>(HiveBoxNames.preferences);
|
||||
|
||||
/// Get reduced motion preference
|
||||
static Future<bool> getReduceMotion() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_reduceMotionKey) ?? false;
|
||||
static Future<bool> getReduceMotion() {
|
||||
final value = _preferencesBox().get(_reduceMotionKey) as bool?;
|
||||
return Future.value(value ?? false);
|
||||
}
|
||||
|
||||
/// Set reduced motion preference
|
||||
static Future<void> setReduceMotion(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_reduceMotionKey, value);
|
||||
static Future<void> setReduceMotion(bool value) {
|
||||
return _preferencesBox().put(_reduceMotionKey, value);
|
||||
}
|
||||
|
||||
/// Get animation speed multiplier (0.5 - 2.0)
|
||||
static Future<double> getAnimationSpeed() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getDouble(_animationSpeedKey) ?? 1.0;
|
||||
static Future<double> getAnimationSpeed() {
|
||||
final value = _preferencesBox().get(_animationSpeedKey) as num?;
|
||||
return Future.value((value?.toDouble() ?? 1.0).clamp(0.5, 2.0));
|
||||
}
|
||||
|
||||
/// Set animation speed multiplier
|
||||
static Future<void> setAnimationSpeed(double value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setDouble(_animationSpeedKey, value.clamp(0.5, 2.0));
|
||||
static Future<void> setAnimationSpeed(double value) {
|
||||
final sanitized = value.clamp(0.5, 2.0).toDouble();
|
||||
return _preferencesBox().put(_animationSpeedKey, sanitized);
|
||||
}
|
||||
|
||||
/// Get haptic feedback preference
|
||||
static Future<bool> getHapticFeedback() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_hapticFeedbackKey) ?? true;
|
||||
static Future<bool> getHapticFeedback() {
|
||||
final value = _preferencesBox().get(_hapticFeedbackKey) as bool?;
|
||||
return Future.value(value ?? true);
|
||||
}
|
||||
|
||||
/// Set haptic feedback preference
|
||||
static Future<void> setHapticFeedback(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_hapticFeedbackKey, value);
|
||||
static Future<void> setHapticFeedback(bool value) {
|
||||
return _preferencesBox().put(_hapticFeedbackKey, value);
|
||||
}
|
||||
|
||||
/// Get high contrast preference
|
||||
static Future<bool> getHighContrast() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_highContrastKey) ?? false;
|
||||
static Future<bool> getHighContrast() {
|
||||
final value = _preferencesBox().get(_highContrastKey) as bool?;
|
||||
return Future.value(value ?? false);
|
||||
}
|
||||
|
||||
/// Set high contrast preference
|
||||
static Future<void> setHighContrast(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_highContrastKey, value);
|
||||
static Future<void> setHighContrast(bool value) {
|
||||
return _preferencesBox().put(_highContrastKey, value);
|
||||
}
|
||||
|
||||
/// Get large text preference
|
||||
static Future<bool> getLargeText() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_largeTextKey) ?? false;
|
||||
static Future<bool> getLargeText() {
|
||||
final value = _preferencesBox().get(_largeTextKey) as bool?;
|
||||
return Future.value(value ?? false);
|
||||
}
|
||||
|
||||
/// Set large text preference
|
||||
static Future<void> setLargeText(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_largeTextKey, value);
|
||||
static Future<void> setLargeText(bool value) {
|
||||
return _preferencesBox().put(_largeTextKey, value);
|
||||
}
|
||||
|
||||
/// Get dark mode preference
|
||||
static Future<bool> getDarkMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_darkModeKey) ?? true; // Default to dark
|
||||
static Future<bool> getDarkMode() {
|
||||
final value = _preferencesBox().get(_darkModeKey) as bool?;
|
||||
return Future.value(value ?? true);
|
||||
}
|
||||
|
||||
/// Set dark mode preference
|
||||
static Future<void> setDarkMode(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_darkModeKey, value);
|
||||
static Future<void> setDarkMode(bool value) {
|
||||
return _preferencesBox().put(_darkModeKey, value);
|
||||
}
|
||||
|
||||
/// Get default model preference
|
||||
static Future<String?> getDefaultModel() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_defaultModelKey);
|
||||
static Future<String?> getDefaultModel() {
|
||||
final value = _preferencesBox().get(_defaultModelKey) as String?;
|
||||
return Future.value(value);
|
||||
}
|
||||
|
||||
/// Set default model preference
|
||||
static Future<void> setDefaultModel(String? modelId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
static Future<void> setDefaultModel(String? modelId) {
|
||||
final box = _preferencesBox();
|
||||
if (modelId != null) {
|
||||
await prefs.setString(_defaultModelKey, modelId);
|
||||
} else {
|
||||
await prefs.remove(_defaultModelKey);
|
||||
return box.put(_defaultModelKey, modelId);
|
||||
}
|
||||
return box.delete(_defaultModelKey);
|
||||
}
|
||||
|
||||
/// Whether to omit the provider prefix when displaying model names
|
||||
static Future<bool> getOmitProviderInModelName() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_omitProviderInModelNameKey) ?? true; // default: omit
|
||||
static Future<bool> getOmitProviderInModelName() {
|
||||
final value = _preferencesBox().get(_omitProviderInModelNameKey) as bool?;
|
||||
return Future.value(value ?? true);
|
||||
}
|
||||
|
||||
static Future<void> setOmitProviderInModelName(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_omitProviderInModelNameKey, value);
|
||||
static Future<void> setOmitProviderInModelName(bool value) {
|
||||
return _preferencesBox().put(_omitProviderInModelNameKey, value);
|
||||
}
|
||||
|
||||
/// Load all settings
|
||||
static Future<AppSettings> loadSettings() async {
|
||||
return AppSettings(
|
||||
reduceMotion: await getReduceMotion(),
|
||||
animationSpeed: await getAnimationSpeed(),
|
||||
hapticFeedback: await getHapticFeedback(),
|
||||
highContrast: await getHighContrast(),
|
||||
largeText: await getLargeText(),
|
||||
darkMode: await getDarkMode(),
|
||||
defaultModel: await getDefaultModel(),
|
||||
omitProviderInModelName: await getOmitProviderInModelName(),
|
||||
voiceLocaleId: await getVoiceLocaleId(),
|
||||
voiceHoldToTalk: await getVoiceHoldToTalk(),
|
||||
voiceAutoSendFinal: await getVoiceAutoSendFinal(),
|
||||
socketTransportMode: await getSocketTransportMode(),
|
||||
quickPills: await getQuickPills(),
|
||||
sendOnEnter: await getSendOnEnter(),
|
||||
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?,
|
||||
omitProviderInModelName:
|
||||
(box.get(_omitProviderInModelNameKey) as bool?) ?? true,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Save all settings
|
||||
static Future<void> saveSettings(AppSettings settings) async {
|
||||
await Future.wait([
|
||||
setReduceMotion(settings.reduceMotion),
|
||||
setAnimationSpeed(settings.animationSpeed),
|
||||
setHapticFeedback(settings.hapticFeedback),
|
||||
setHighContrast(settings.highContrast),
|
||||
setLargeText(settings.largeText),
|
||||
setDarkMode(settings.darkMode),
|
||||
setDefaultModel(settings.defaultModel),
|
||||
setOmitProviderInModelName(settings.omitProviderInModelName),
|
||||
setVoiceLocaleId(settings.voiceLocaleId),
|
||||
setVoiceHoldToTalk(settings.voiceHoldToTalk),
|
||||
setVoiceAutoSendFinal(settings.voiceAutoSendFinal),
|
||||
setSocketTransportMode(settings.socketTransportMode),
|
||||
setQuickPills(settings.quickPills),
|
||||
setSendOnEnter(settings.sendOnEnter),
|
||||
]);
|
||||
}
|
||||
final box = _preferencesBox();
|
||||
final updates = <String, Object?>{
|
||||
_reduceMotionKey: settings.reduceMotion,
|
||||
_animationSpeedKey: settings.animationSpeed,
|
||||
_hapticFeedbackKey: settings.hapticFeedback,
|
||||
_highContrastKey: settings.highContrast,
|
||||
_largeTextKey: settings.largeText,
|
||||
_darkModeKey: settings.darkMode,
|
||||
_omitProviderInModelNameKey: settings.omitProviderInModelName,
|
||||
_voiceHoldToTalkKey: settings.voiceHoldToTalk,
|
||||
_voiceAutoSendKey: settings.voiceAutoSendFinal,
|
||||
_socketTransportModeKey: settings.socketTransportMode,
|
||||
_quickPillsKey: settings.quickPills.take(2).toList(),
|
||||
_sendOnEnterKey: settings.sendOnEnter,
|
||||
};
|
||||
|
||||
// Voice input specific settings
|
||||
static Future<String?> getVoiceLocaleId() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_voiceLocaleKey);
|
||||
}
|
||||
await box.putAll(updates);
|
||||
|
||||
static Future<void> setVoiceLocaleId(String? localeId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (localeId == null || localeId.isEmpty) {
|
||||
await prefs.remove(_voiceLocaleKey);
|
||||
if (settings.defaultModel != null) {
|
||||
await box.put(_defaultModelKey, settings.defaultModel);
|
||||
} else {
|
||||
await prefs.setString(_voiceLocaleKey, localeId);
|
||||
await box.delete(_defaultModelKey);
|
||||
}
|
||||
|
||||
if (settings.voiceLocaleId != null && settings.voiceLocaleId!.isNotEmpty) {
|
||||
await box.put(_voiceLocaleKey, settings.voiceLocaleId);
|
||||
} else {
|
||||
await box.delete(_voiceLocaleKey);
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> getVoiceHoldToTalk() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_voiceHoldToTalkKey) ?? false;
|
||||
// Voice input specific settings
|
||||
static Future<String?> getVoiceLocaleId() {
|
||||
final value = _preferencesBox().get(_voiceLocaleKey) as String?;
|
||||
return Future.value(value);
|
||||
}
|
||||
|
||||
static Future<void> setVoiceHoldToTalk(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_voiceHoldToTalkKey, value);
|
||||
static Future<void> setVoiceLocaleId(String? localeId) {
|
||||
final box = _preferencesBox();
|
||||
if (localeId == null || localeId.isEmpty) {
|
||||
return box.delete(_voiceLocaleKey);
|
||||
}
|
||||
return box.put(_voiceLocaleKey, localeId);
|
||||
}
|
||||
|
||||
static Future<bool> getVoiceAutoSendFinal() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_voiceAutoSendKey) ?? false;
|
||||
static Future<bool> getVoiceHoldToTalk() {
|
||||
final value = _preferencesBox().get(_voiceHoldToTalkKey) as bool?;
|
||||
return Future.value(value ?? false);
|
||||
}
|
||||
|
||||
static Future<void> setVoiceAutoSendFinal(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_voiceAutoSendKey, value);
|
||||
static Future<void> setVoiceHoldToTalk(bool value) {
|
||||
return _preferencesBox().put(_voiceHoldToTalkKey, value);
|
||||
}
|
||||
|
||||
static Future<bool> getVoiceAutoSendFinal() {
|
||||
final value = _preferencesBox().get(_voiceAutoSendKey) as bool?;
|
||||
return Future.value(value ?? false);
|
||||
}
|
||||
|
||||
static Future<void> setVoiceAutoSendFinal(bool value) {
|
||||
return _preferencesBox().put(_voiceAutoSendKey, value);
|
||||
}
|
||||
|
||||
/// Transport mode: 'auto' (polling+websocket) or 'ws' (websocket only)
|
||||
static Future<String> getSocketTransportMode() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(_socketTransportModeKey) ?? 'ws';
|
||||
static Future<String> getSocketTransportMode() {
|
||||
final value = _preferencesBox().get(_socketTransportModeKey) as String?;
|
||||
return Future.value(value ?? 'ws');
|
||||
}
|
||||
|
||||
static Future<void> setSocketTransportMode(String mode) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mode != 'auto' && mode != 'ws') mode = 'auto';
|
||||
await prefs.setString(_socketTransportModeKey, mode);
|
||||
static Future<void> setSocketTransportMode(String mode) {
|
||||
if (mode != 'auto' && mode != 'ws') {
|
||||
mode = 'auto';
|
||||
}
|
||||
return _preferencesBox().put(_socketTransportModeKey, mode);
|
||||
}
|
||||
|
||||
// Quick Pills (visibility)
|
||||
static Future<List<String>> getQuickPills() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = prefs.getStringList(_quickPillsKey);
|
||||
// Default: none selected
|
||||
if (list == null) return const [];
|
||||
// Enforce max 2; accept arbitrary tool IDs plus 'web' and 'image'
|
||||
return list.take(2).toList();
|
||||
static Future<List<String>> getQuickPills() {
|
||||
final stored = _preferencesBox().get(_quickPillsKey) as List<dynamic>?;
|
||||
if (stored == null) {
|
||||
return Future.value(const []);
|
||||
}
|
||||
return Future.value(List<String>.from(stored.take(2)));
|
||||
}
|
||||
|
||||
static Future<void> setQuickPills(List<String> pills) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setStringList(_quickPillsKey, pills.take(2).toList());
|
||||
static Future<void> setQuickPills(List<String> pills) {
|
||||
return _preferencesBox().put(_quickPillsKey, pills.take(2).toList());
|
||||
}
|
||||
|
||||
// Chat input behavior
|
||||
static Future<bool> getSendOnEnter() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_sendOnEnterKey) ?? false;
|
||||
static Future<bool> getSendOnEnter() {
|
||||
final value = _preferencesBox().get(_sendOnEnterKey) as bool?;
|
||||
return Future.value(value ?? false);
|
||||
}
|
||||
|
||||
static Future<void> setSendOnEnter(bool value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_sendOnEnterKey, value);
|
||||
static Future<void> setSendOnEnter(bool value) {
|
||||
return _preferencesBox().put(_sendOnEnterKey, value);
|
||||
}
|
||||
|
||||
/// Get effective animation duration considering all settings
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/server_config.dart';
|
||||
import '../models/conversation.dart';
|
||||
import 'secure_credential_storage.dart';
|
||||
import '../utils/debug_logger.dart';
|
||||
|
||||
class StorageService {
|
||||
final FlutterSecureStorage _secureStorage;
|
||||
final SharedPreferences _prefs;
|
||||
final SecureCredentialStorage _secureCredentialStorage;
|
||||
|
||||
StorageService({
|
||||
required FlutterSecureStorage secureStorage,
|
||||
required SharedPreferences prefs,
|
||||
}) : _secureStorage = secureStorage,
|
||||
_prefs = prefs,
|
||||
_secureCredentialStorage = SecureCredentialStorage(
|
||||
instance: secureStorage,
|
||||
);
|
||||
|
||||
// Secure storage keys
|
||||
static const String _authTokenKey = 'auth_token';
|
||||
static const String _serverConfigsKey = 'server_configs';
|
||||
static const String _activeServerIdKey = 'active_server_id';
|
||||
static const String _credentialsKey = 'saved_credentials';
|
||||
static const String _rememberCredentialsKey = 'remember_credentials';
|
||||
|
||||
// Shared preferences keys
|
||||
static const String _themeModeKey = 'theme_mode';
|
||||
static const String _localConversationsKey = 'local_conversations';
|
||||
|
||||
// Auth token management - using enhanced secure storage
|
||||
Future<void> saveAuthToken(String token) async {
|
||||
// Try enhanced secure storage first, fallback to legacy if needed
|
||||
try {
|
||||
await _secureCredentialStorage.saveAuthToken(token);
|
||||
} catch (e) {
|
||||
DebugLogger.log(
|
||||
'Enhanced secure storage failed, using fallback: $e',
|
||||
scope: 'storage',
|
||||
);
|
||||
await _secureStorage.write(key: _authTokenKey, value: token);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getAuthToken() async {
|
||||
// Try enhanced secure storage first, fallback to legacy if needed
|
||||
try {
|
||||
final token = await _secureCredentialStorage.getAuthToken();
|
||||
if (token != null) return token;
|
||||
} catch (e) {
|
||||
DebugLogger.log(
|
||||
'Enhanced secure storage failed, using fallback: $e',
|
||||
scope: 'storage',
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to legacy storage
|
||||
return await _secureStorage.read(key: _authTokenKey);
|
||||
}
|
||||
|
||||
Future<void> deleteAuthToken() async {
|
||||
// Clear from both storages to ensure complete cleanup
|
||||
try {
|
||||
await _secureCredentialStorage.deleteAuthToken();
|
||||
} catch (e) {
|
||||
DebugLogger.log(
|
||||
'Failed to delete from enhanced storage: $e',
|
||||
scope: 'storage',
|
||||
);
|
||||
}
|
||||
|
||||
await _secureStorage.delete(key: _authTokenKey);
|
||||
}
|
||||
|
||||
// Credential management for auto-login - using enhanced secure storage
|
||||
Future<void> saveCredentials({
|
||||
required String serverId,
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
// Try enhanced secure storage first, fallback to legacy if needed
|
||||
try {
|
||||
// Check if enhanced secure storage is available
|
||||
final isSecureAvailable = await _secureCredentialStorage
|
||||
.isSecureStorageAvailable();
|
||||
if (!isSecureAvailable) {
|
||||
DebugLogger.log(
|
||||
'Enhanced secure storage not available, using legacy storage',
|
||||
scope: 'storage',
|
||||
);
|
||||
throw Exception('Enhanced secure storage not available');
|
||||
}
|
||||
|
||||
await _secureCredentialStorage.saveCredentials(
|
||||
serverId: serverId,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
DebugLogger.log(
|
||||
'Credentials saved using enhanced secure storage',
|
||||
scope: 'storage',
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLogger.log(
|
||||
'Enhanced secure storage failed, using fallback: $e',
|
||||
scope: 'storage',
|
||||
);
|
||||
|
||||
// Fallback to legacy storage
|
||||
try {
|
||||
final credentials = {
|
||||
'serverId': serverId,
|
||||
'username': username,
|
||||
'password': password,
|
||||
'savedAt': DateTime.now().toIso8601String(),
|
||||
};
|
||||
|
||||
await _secureStorage.write(
|
||||
key: _credentialsKey,
|
||||
value: jsonEncode(credentials),
|
||||
);
|
||||
|
||||
// Verify the fallback save
|
||||
final verifyData = await _secureStorage.read(key: _credentialsKey);
|
||||
if (verifyData == null || verifyData.isEmpty) {
|
||||
throw Exception(
|
||||
'Failed to save credentials even with fallback storage',
|
||||
);
|
||||
}
|
||||
|
||||
DebugLogger.log(
|
||||
'Credentials saved using fallback storage',
|
||||
scope: 'storage',
|
||||
);
|
||||
} catch (fallbackError) {
|
||||
DebugLogger.log(
|
||||
'Both enhanced and fallback credential storage failed: $fallbackError',
|
||||
scope: 'storage',
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, String>?> getSavedCredentials() async {
|
||||
// Try enhanced secure storage first
|
||||
try {
|
||||
final credentials = await _secureCredentialStorage.getSavedCredentials();
|
||||
if (credentials != null) {
|
||||
return credentials;
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLogger.log(
|
||||
'Enhanced secure storage failed, using fallback: $e',
|
||||
scope: 'storage',
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback to legacy storage and migrate if found
|
||||
try {
|
||||
final jsonString = await _secureStorage.read(key: _credentialsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) return null;
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
|
||||
// Validate that credentials have required fields
|
||||
if (!decoded.containsKey('serverId') ||
|
||||
!decoded.containsKey('username') ||
|
||||
!decoded.containsKey('password')) {
|
||||
DebugLogger.log('Invalid saved credentials format', scope: 'storage');
|
||||
await deleteSavedCredentials();
|
||||
return null;
|
||||
}
|
||||
|
||||
final legacyCredentials = {
|
||||
'serverId': decoded['serverId']?.toString() ?? '',
|
||||
'username': decoded['username']?.toString() ?? '',
|
||||
'password': decoded['password']?.toString() ?? '',
|
||||
'savedAt': decoded['savedAt']?.toString() ?? '',
|
||||
};
|
||||
|
||||
// Attempt to migrate to enhanced storage
|
||||
try {
|
||||
await _secureCredentialStorage.migrateFromOldStorage(legacyCredentials);
|
||||
// If migration successful, clean up legacy storage
|
||||
await _secureStorage.delete(key: _credentialsKey);
|
||||
DebugLogger.log(
|
||||
'Successfully migrated credentials to enhanced storage',
|
||||
scope: 'storage',
|
||||
);
|
||||
} catch (e) {
|
||||
DebugLogger.log('Failed to migrate credentials: $e', scope: 'storage');
|
||||
}
|
||||
|
||||
return legacyCredentials;
|
||||
} catch (e) {
|
||||
DebugLogger.log('Error loading saved credentials: $e', scope: 'storage');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSavedCredentials() async {
|
||||
// Clear from both storages to ensure complete cleanup
|
||||
try {
|
||||
await _secureCredentialStorage.deleteSavedCredentials();
|
||||
} catch (e) {
|
||||
DebugLogger.log(
|
||||
'Failed to delete from enhanced storage: $e',
|
||||
scope: 'storage',
|
||||
);
|
||||
}
|
||||
|
||||
await _secureStorage.delete(key: _credentialsKey);
|
||||
await setRememberCredentials(false);
|
||||
}
|
||||
|
||||
// Remember credentials preference
|
||||
Future<void> setRememberCredentials(bool remember) async {
|
||||
await _prefs.setBool(_rememberCredentialsKey, remember);
|
||||
}
|
||||
|
||||
bool getRememberCredentials() {
|
||||
return _prefs.getBool(_rememberCredentialsKey) ?? false;
|
||||
}
|
||||
|
||||
// Server configuration management
|
||||
Future<void> saveServerConfigs(List<ServerConfig> configs) async {
|
||||
final json = configs.map((c) => c.toJson()).toList();
|
||||
await _secureStorage.write(key: _serverConfigsKey, value: jsonEncode(json));
|
||||
}
|
||||
|
||||
Future<List<ServerConfig>> getServerConfigs() async {
|
||||
try {
|
||||
final jsonString = await _secureStorage.read(key: _serverConfigsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) return [];
|
||||
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! List) {
|
||||
DebugLogger.log(
|
||||
'Server configs data is not a list, resetting',
|
||||
scope: 'storage',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
final configs = <ServerConfig>[];
|
||||
for (final item in decoded) {
|
||||
try {
|
||||
if (item is Map<String, dynamic>) {
|
||||
// Validate required fields before parsing
|
||||
if (item.containsKey('id') &&
|
||||
item.containsKey('name') &&
|
||||
item.containsKey('url')) {
|
||||
configs.add(ServerConfig.fromJson(item));
|
||||
} else {
|
||||
DebugLogger.log(
|
||||
'Skipping invalid server config: missing required fields',
|
||||
scope: 'storage',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLogger.log(
|
||||
'Failed to parse server config: $e',
|
||||
scope: 'storage',
|
||||
);
|
||||
// Continue with other configs
|
||||
}
|
||||
}
|
||||
|
||||
return configs;
|
||||
} catch (e) {
|
||||
DebugLogger.log('Error loading server configs: $e', scope: 'storage');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setActiveServerId(String? serverId) async {
|
||||
if (serverId == null) {
|
||||
await _secureStorage.delete(key: _activeServerIdKey);
|
||||
} else {
|
||||
await _secureStorage.write(key: _activeServerIdKey, value: serverId);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getActiveServerId() async {
|
||||
return await _secureStorage.read(key: _activeServerIdKey);
|
||||
}
|
||||
|
||||
// Theme management
|
||||
String? getThemeMode() {
|
||||
return _prefs.getString(_themeModeKey);
|
||||
}
|
||||
|
||||
Future<void> setThemeMode(String mode) async {
|
||||
await _prefs.setString(_themeModeKey, mode);
|
||||
}
|
||||
|
||||
// Local conversation management
|
||||
Future<List<Conversation>> getLocalConversations() async {
|
||||
final jsonString = _prefs.getString(_localConversationsKey);
|
||||
if (jsonString == null || jsonString.isEmpty) return [];
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(jsonString);
|
||||
if (decoded is! List) {
|
||||
DebugLogger.log(
|
||||
'Local conversations data is not a list, resetting',
|
||||
scope: 'storage',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
final conversations = <Conversation>[];
|
||||
for (final item in decoded) {
|
||||
try {
|
||||
if (item is Map<String, dynamic>) {
|
||||
// Validate required fields before parsing
|
||||
if (item.containsKey('id') &&
|
||||
item.containsKey('title') &&
|
||||
item.containsKey('createdAt') &&
|
||||
item.containsKey('updatedAt')) {
|
||||
conversations.add(Conversation.fromJson(item));
|
||||
} else {
|
||||
DebugLogger.log(
|
||||
'Skipping invalid conversation: missing required fields',
|
||||
scope: 'storage',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLogger.log('Failed to parse conversation: $e', scope: 'storage');
|
||||
// Continue with other conversations
|
||||
}
|
||||
}
|
||||
|
||||
return conversations;
|
||||
} catch (e) {
|
||||
DebugLogger.log(
|
||||
'Error parsing local conversations: $e',
|
||||
scope: 'storage',
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveLocalConversations(List<Conversation> conversations) async {
|
||||
try {
|
||||
final json = conversations.map((c) => c.toJson()).toList();
|
||||
await _prefs.setString(_localConversationsKey, jsonEncode(json));
|
||||
} catch (e) {
|
||||
DebugLogger.log('Error saving local conversations: $e', scope: 'storage');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addLocalConversation(Conversation conversation) async {
|
||||
final conversations = await getLocalConversations();
|
||||
conversations.add(conversation);
|
||||
await saveLocalConversations(conversations);
|
||||
}
|
||||
|
||||
Future<void> updateLocalConversation(Conversation conversation) async {
|
||||
final conversations = await getLocalConversations();
|
||||
final index = conversations.indexWhere((c) => c.id == conversation.id);
|
||||
if (index != -1) {
|
||||
conversations[index] = conversation;
|
||||
await saveLocalConversations(conversations);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteLocalConversation(String conversationId) async {
|
||||
final conversations = await getLocalConversations();
|
||||
conversations.removeWhere((c) => c.id == conversationId);
|
||||
await saveLocalConversations(conversations);
|
||||
}
|
||||
|
||||
// Clear all data
|
||||
Future<void> clearAll() async {
|
||||
// Clear enhanced secure storage
|
||||
try {
|
||||
await _secureCredentialStorage.clearAll();
|
||||
} catch (e) {
|
||||
DebugLogger.log('Failed to clear enhanced storage: $e', scope: 'storage');
|
||||
}
|
||||
|
||||
// Clear legacy storage
|
||||
await _secureStorage.deleteAll();
|
||||
await _prefs.clear();
|
||||
|
||||
DebugLogger.log('All storage cleared', scope: 'storage');
|
||||
}
|
||||
|
||||
// Clear only auth-related data (keeping server configs and other settings)
|
||||
Future<void> clearAuthData() async {
|
||||
await deleteAuthToken();
|
||||
await deleteSavedCredentials();
|
||||
DebugLogger.log('Auth data cleared', scope: 'storage');
|
||||
}
|
||||
|
||||
/// Check if enhanced secure storage is available
|
||||
Future<bool> isEnhancedSecureStorageAvailable() async {
|
||||
try {
|
||||
return await _secureCredentialStorage.isSecureStorageAvailable();
|
||||
} catch (e) {
|
||||
DebugLogger.log(
|
||||
'Failed to check enhanced storage availability: $e',
|
||||
scope: 'storage',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'core/widgets/error_boundary.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'core/providers/app_providers.dart';
|
||||
import 'core/persistence/hive_bootstrap.dart';
|
||||
import 'core/persistence/persistence_migrator.dart';
|
||||
import 'core/persistence/persistence_providers.dart';
|
||||
import 'core/router/app_router.dart';
|
||||
import 'shared/theme/app_theme.dart';
|
||||
import 'shared/widgets/offline_indicator.dart';
|
||||
@@ -62,8 +64,6 @@ void main() {
|
||||
_startupTimeline?.instant('edge_to_edge_enabled');
|
||||
});
|
||||
|
||||
final sharedPrefs = await SharedPreferences.getInstance();
|
||||
_startupTimeline!.instant('shared_prefs_ready');
|
||||
const secureStorage = FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(
|
||||
encryptedSharedPreferences: true,
|
||||
@@ -78,6 +78,13 @@ void main() {
|
||||
);
|
||||
_startupTimeline!.instant('secure_storage_ready');
|
||||
|
||||
final hiveBoxes = await HiveBootstrap.instance.ensureInitialized();
|
||||
_startupTimeline!.instant('hive_ready');
|
||||
|
||||
final migrator = PersistenceMigrator(hiveBoxes: hiveBoxes);
|
||||
await migrator.migrateIfNeeded();
|
||||
_startupTimeline!.instant('migration_complete');
|
||||
|
||||
// Finish timeline after first frame paints
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_startupTimeline?.instant('first_frame_rendered');
|
||||
@@ -88,8 +95,8 @@ void main() {
|
||||
runApp(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
sharedPreferencesProvider.overrideWithValue(sharedPrefs),
|
||||
secureStorageProvider.overrideWithValue(secureStorage),
|
||||
hiveBoxesProvider.overrideWithValue(hiveBoxes),
|
||||
],
|
||||
child: const ConduitApp(),
|
||||
),
|
||||
|
||||
@@ -4,7 +4,8 @@ import 'dart:convert';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../core/providers/app_providers.dart';
|
||||
import '../../../core/persistence/persistence_providers.dart';
|
||||
import '../../../core/persistence/hive_boxes.dart';
|
||||
import 'outbound_task.dart';
|
||||
import 'task_worker.dart';
|
||||
import '../../../core/utils/debug_logger.dart';
|
||||
@@ -15,7 +16,7 @@ final taskQueueProvider =
|
||||
);
|
||||
|
||||
class TaskQueueNotifier extends Notifier<List<OutboundTask>> {
|
||||
static const _prefsKey = 'outbound_task_queue_v1';
|
||||
static const _storageKey = HiveStoreKeys.taskQueue;
|
||||
final _uuid = const Uuid();
|
||||
bool _bootstrapScheduled = false;
|
||||
|
||||
@@ -34,10 +35,20 @@ class TaskQueueNotifier extends Notifier<List<OutboundTask>> {
|
||||
|
||||
Future<void> _load() async {
|
||||
try {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
final jsonStr = prefs.getString(_prefsKey);
|
||||
if (jsonStr == null || jsonStr.isEmpty) return;
|
||||
final raw = (jsonDecode(jsonStr) as List).cast<Map<String, dynamic>>();
|
||||
final boxes = ref.read(hiveBoxesProvider);
|
||||
final stored = boxes.caches.get(_storageKey);
|
||||
if (stored == null) return;
|
||||
|
||||
List<Map<String, dynamic>> raw;
|
||||
if (stored is String && stored.isNotEmpty) {
|
||||
raw = (jsonDecode(stored) as List).cast<Map<String, dynamic>>();
|
||||
} else if (stored is List) {
|
||||
raw = stored
|
||||
.map((entry) => Map<String, dynamic>.from(entry as Map))
|
||||
.toList(growable: false);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
final tasks = raw.map(OutboundTask.fromJson).toList();
|
||||
// Only restore non-completed tasks
|
||||
state = tasks
|
||||
@@ -62,7 +73,7 @@ class TaskQueueNotifier extends Notifier<List<OutboundTask>> {
|
||||
|
||||
Future<void> _save() async {
|
||||
try {
|
||||
final prefs = ref.read(sharedPreferencesProvider);
|
||||
final boxes = ref.read(hiveBoxesProvider);
|
||||
final retained = [
|
||||
for (final task in state)
|
||||
if (task.status == TaskStatus.queued ||
|
||||
@@ -76,7 +87,7 @@ class TaskQueueNotifier extends Notifier<List<OutboundTask>> {
|
||||
}
|
||||
|
||||
final raw = retained.map((t) => t.toJson()).toList(growable: false);
|
||||
await prefs.setString(_prefsKey, jsonEncode(raw));
|
||||
await boxes.caches.put(_storageKey, raw);
|
||||
} catch (e) {
|
||||
DebugLogger.log('Failed to persist task queue: $e', scope: 'tasks/queue');
|
||||
}
|
||||
|
||||
24
pubspec.lock
24
pubspec.lock
@@ -597,6 +597,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
hive_ce:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hive_ce
|
||||
sha256: d678b1b2e315c18cd7ed8fd79eda25d70a1f3852d6988bfe5461cffe260c60aa
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.14.0"
|
||||
hive_ce_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: hive_ce_flutter
|
||||
sha256: f5bd57fda84402bca7557fedb8c629c96c8ea10fab4a542968d7b60864ca02cc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
hotreloader:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -725,6 +741,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
isolate_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: isolate_channel
|
||||
sha256: f3d36f783b301e6b312c3450eeb2656b0e7d1db81331af2a151d9083a3f6b18d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2+1"
|
||||
js:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -25,6 +25,8 @@ dependencies:
|
||||
|
||||
# Storage
|
||||
flutter_secure_storage: ^9.2.2
|
||||
hive_ce: ^2.14.0
|
||||
hive_ce_flutter: ^2.3.2
|
||||
shared_preferences: ^2.3.2
|
||||
|
||||
# UI Components - Enhanced Markdown
|
||||
|
||||
Reference in New Issue
Block a user