diff --git a/lib/core/models/folder.dart b/lib/core/models/folder.dart index 6ddd66a..c4ac6f6 100644 --- a/lib/core/models/folder.dart +++ b/lib/core/models/folder.dart @@ -19,25 +19,38 @@ sealed class Folder with _$Folder { }) = _Folder; factory Folder.fromJson(Map json) { - // Extract conversation IDs from items.chats if available - final items = json['items'] as Map?; - final chats = items?['chats'] as List?; + List extractConversationIds(dynamic source) { + if (source is! List) { + return const []; + } + final ids = []; + for (final entry in source) { + String value = ''; + if (entry is String) { + value = entry; + } else if (entry is Map) { + final id = entry['id']; + if (id is String) { + value = id; + } else if (id != null) { + value = id.toString(); + } + } else if (entry != null) { + value = entry.toString(); + } - // Handle both string IDs and conversation objects - final conversationIds = - chats - ?.map((chat) { - if (chat is String) { - return chat; - } else if (chat is Map) { - return chat['id'] as String? ?? ''; - } - return ''; - }) - .where((id) => id.isNotEmpty) - .toList() - .cast() ?? - []; + if (value.isNotEmpty) { + ids.add(value); + } + } + return ids; + } + + final items = json['items'] as Map?; + final chats = items?['chats']; + final explicitIds = extractConversationIds(json['conversation_ids']); + final implicitIds = extractConversationIds(chats); + final conversationIds = explicitIds.isNotEmpty ? explicitIds : implicitIds; // Handle Unix timestamp conversion DateTime? parseTimestamp(dynamic timestamp) { @@ -67,3 +80,29 @@ sealed class Folder with _$Folder { ); } } + +extension FolderJsonExtension on Folder { + Map toJson() { + Map? normalizedItems; + if (items != null) { + normalizedItems = Map.from(items!); + } else if (conversationIds.isNotEmpty) { + normalizedItems = {'chats': List.from(conversationIds)}; + } + + return { + 'id': id, + 'name': name, + if (parentId != null) 'parent_id': parentId, + if (userId != null) 'user_id': userId, + if (createdAt != null) 'created_at': createdAt!.toIso8601String(), + if (updatedAt != null) 'updated_at': updatedAt!.toIso8601String(), + 'is_expanded': isExpanded, + if (normalizedItems != null) 'items': normalizedItems, + if (meta != null) 'meta': Map.from(meta!), + if (data != null) 'data': Map.from(data!), + if (conversationIds.isNotEmpty) + 'conversation_ids': List.from(conversationIds), + }; + } +} diff --git a/lib/core/persistence/hive_boxes.dart b/lib/core/persistence/hive_boxes.dart index 28b461f..ceb821b 100644 --- a/lib/core/persistence/hive_boxes.dart +++ b/lib/core/persistence/hive_boxes.dart @@ -15,6 +15,7 @@ final class HiveStoreKeys { // Cache entries static const String localConversations = 'local_conversations'; + static const String localFolders = 'local_folders'; static const String attachmentQueueEntries = 'attachment_queue_entries'; static const String taskQueue = 'outbound_task_queue_v1'; } diff --git a/lib/core/persistence/persistence_migrator.dart b/lib/core/persistence/persistence_migrator.dart index d6a278c..d0f3946 100644 --- a/lib/core/persistence/persistence_migrator.dart +++ b/lib/core/persistence/persistence_migrator.dart @@ -107,7 +107,24 @@ class PersistenceMigrator { } Future _migrateCaches(SharedPreferences prefs) async { - final jsonString = prefs.getString(HiveStoreKeys.localConversations); + await _migrateJsonListCache( + prefs, + HiveStoreKeys.localConversations, + logLabel: 'local conversations', + ); + await _migrateJsonListCache( + prefs, + HiveStoreKeys.localFolders, + logLabel: 'local folders', + ); + } + + Future _migrateJsonListCache( + SharedPreferences prefs, + String key, { + required String logLabel, + }) async { + final jsonString = prefs.getString(key); if (jsonString == null || jsonString.isEmpty) { return; } @@ -118,11 +135,11 @@ class PersistenceMigrator { final list = decoded .map((entry) => Map.from(entry as Map)) .toList(growable: false); - await _boxes.caches.put(HiveStoreKeys.localConversations, list); + await _boxes.caches.put(key, list); } } catch (error, stack) { DebugLogger.error( - 'Failed to migrate local conversations', + 'Failed to migrate $logLabel', scope: 'persistence/migration', error: error, stackTrace: stack, @@ -206,6 +223,7 @@ class PersistenceMigrator { PreferenceKeys.onboardingSeen, PreferenceKeys.reviewerMode, HiveStoreKeys.localConversations, + HiveStoreKeys.localFolders, HiveStoreKeys.attachmentQueueEntries, LegacyPreferenceKeys.attachmentUploadQueue, LegacyPreferenceKeys.taskQueue, diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index ca03ecb..c39dfb6 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -1801,24 +1801,53 @@ class Folders extends _$Folders { Future> build() async { if (!ref.watch(isAuthenticatedProvider2)) { DebugLogger.log('skip-unauthed', scope: 'folders'); + _persistFoldersAsync(const []); return const []; } + + final storage = ref.watch(optimizedStorageServiceProvider); + final cached = await storage.getLocalFolders(); + if (cached.isNotEmpty) { + DebugLogger.log( + 'cache-restored', + scope: 'folders/cache', + data: {'count': cached.length}, + ); + Future.microtask(() async { + try { + await refresh(); + } catch (error, stackTrace) { + DebugLogger.error( + 'warm-refresh-failed', + scope: 'folders/cache', + error: error, + stackTrace: stackTrace, + ); + } + }); + return _sort(cached); + } + + DebugLogger.log('cache-empty', scope: 'folders/cache'); final api = ref.watch(apiServiceProvider); if (api == null) { DebugLogger.warning('api-missing', scope: 'folders'); return const []; } - return _load(api); + final fresh = await _load(api); + return fresh; } Future refresh() async { if (!ref.read(isAuthenticatedProvider2)) { state = const AsyncData>([]); + _persistFoldersAsync(const []); return; } final api = ref.read(apiServiceProvider); if (api == null) { state = const AsyncData>([]); + _persistFoldersAsync(const []); return; } final result = await AsyncValue.guard(() => _load(api)); @@ -1835,7 +1864,9 @@ class Folders extends _$Folders { } else { updated.add(folder); } - state = AsyncData>(_sort(updated)); + final sorted = _sort(updated); + state = AsyncData>(sorted); + _persistFoldersAsync(sorted); } void updateFolder(String id, Folder Function(Folder folder) transform) { @@ -1845,7 +1876,9 @@ class Folders extends _$Folders { if (index < 0) return; final updated = [...current]; updated[index] = transform(updated[index]); - state = AsyncData>(_sort(updated)); + final sorted = _sort(updated); + state = AsyncData>(sorted); + _persistFoldersAsync(sorted); } void removeFolder(String id) { @@ -1854,7 +1887,9 @@ class Folders extends _$Folders { final updated = current .where((folder) => folder.id != id) .toList(growable: true); - state = AsyncData>(_sort(updated)); + final sorted = _sort(updated); + state = AsyncData>(sorted); + _persistFoldersAsync(sorted); } Future> _load(ApiService api) async { @@ -1868,7 +1903,9 @@ class Folders extends _$Folders { scope: 'folders', data: {'count': folders.length}, ); - return _sort(folders); + final sorted = _sort(folders); + _persistFoldersAsync(sorted); + return sorted; } catch (e, stackTrace) { DebugLogger.error( 'fetch-failed', @@ -1880,6 +1917,11 @@ class Folders extends _$Folders { } } + void _persistFoldersAsync(List folders) { + final storage = ref.read(optimizedStorageServiceProvider); + unawaited(storage.saveLocalFolders(folders)); + } + List _sort(List input) { final sorted = [...input]; sorted.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); diff --git a/lib/core/services/optimized_storage_service.dart b/lib/core/services/optimized_storage_service.dart index 9b44b5a..bfb3fd1 100644 --- a/lib/core/services/optimized_storage_service.dart +++ b/lib/core/services/optimized_storage_service.dart @@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hive_ce/hive.dart'; import '../models/conversation.dart'; +import '../models/folder.dart'; import '../models/server_config.dart'; import '../persistence/hive_boxes.dart'; import '../persistence/persistence_keys.dart'; @@ -40,6 +41,7 @@ class OptimizedStorageService { static const String _themePaletteKey = PreferenceKeys.themePalette; static const String _localeCodeKey = PreferenceKeys.localeCode; static const String _localConversationsKey = HiveStoreKeys.localConversations; + static const String _localFoldersKey = HiveStoreKeys.localFolders; static const String _onboardingSeenKey = PreferenceKeys.onboardingSeen; static const String _reviewerModeKey = PreferenceKeys.reviewerMode; @@ -304,7 +306,7 @@ class OptimizedStorageService { } final parsed = await _workerManager .schedule, List>>( - _decodeStoredConversationsWorker, + _decodeStoredJsonListWorker, {'stored': stored}, debugLabel: 'decode_local_conversations', ); @@ -326,8 +328,8 @@ class OptimizedStorageService { .map((conversation) => conversation.toJson()) .toList(); final serialized = await _workerManager - .schedule, String>(_encodeConversationsWorker, { - 'conversations': jsonReady, + .schedule, String>(_encodeJsonListWorker, { + 'items': jsonReady, }, debugLabel: 'encode_local_conversations'); await _cachesBox.put(_localConversationsKey, serialized); DebugLogger.log( @@ -344,6 +346,52 @@ class OptimizedStorageService { } } + Future> getLocalFolders() async { + try { + final stored = _cachesBox.get(_localFoldersKey); + if (stored == null) { + return const []; + } + final parsed = await _workerManager + .schedule, List>>( + _decodeStoredJsonListWorker, + {'stored': stored}, + debugLabel: 'decode_local_folders', + ); + return parsed.map(Folder.fromJson).toList(growable: false); + } catch (error, stack) { + DebugLogger.error( + 'Failed to retrieve local folders', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + return const []; + } + } + + Future saveLocalFolders(List folders) async { + try { + final jsonReady = folders.map((folder) => folder.toJson()).toList(); + final serialized = await _workerManager + .schedule, String>(_encodeJsonListWorker, { + 'items': jsonReady, + }, debugLabel: 'encode_local_folders'); + await _cachesBox.put(_localFoldersKey, serialized); + DebugLogger.log( + 'Saved ${folders.length} local folders', + scope: 'storage/optimized', + ); + } catch (error, stack) { + DebugLogger.error( + 'Failed to save local folders', + scope: 'storage/optimized', + error: error, + stackTrace: stack, + ); + } + } + // --------------------------------------------------------------------------- // Batch operations // --------------------------------------------------------------------------- @@ -458,7 +506,7 @@ class OptimizedStorageService { } } -List> _decodeStoredConversationsWorker( +List> _decodeStoredJsonListWorker( Map payload, ) { final stored = payload['stored']; @@ -483,8 +531,8 @@ List> _decodeStoredConversationsWorker( return >[]; } -String _encodeConversationsWorker(Map payload) { - final raw = payload['conversations']; +String _encodeJsonListWorker(Map payload) { + final raw = payload['items'] ?? payload['conversations']; if (raw is List) { return jsonEncode(raw); }