diff --git a/lib/core/providers/app_providers.dart b/lib/core/providers/app_providers.dart index 70f710f..ba0f3b2 100644 --- a/lib/core/providers/app_providers.dart +++ b/lib/core/providers/app_providers.dart @@ -890,6 +890,10 @@ void refreshConversationsCache(dynamic ref, {bool includeFolders = false}) { ref.read(_conversationsCacheTimestampProvider.notifier).set(null); final notifier = ref.read(conversationsProvider.notifier); unawaited(notifier.refresh(includeFolders: includeFolders)); + if (includeFolders) { + final foldersNotifier = ref.read(foldersProvider.notifier); + unawaited(foldersNotifier.refresh()); + } } // Conversation providers - Now using correct OpenWebUI API with caching and @@ -918,7 +922,7 @@ class Conversations extends _$Conversations { _updateCacheTimestamp(null); state = AsyncData>([]); if (includeFolders) { - ref.invalidate(foldersProvider); + unawaited(ref.read(foldersProvider.notifier).refresh()); } return; } @@ -926,7 +930,7 @@ class Conversations extends _$Conversations { if (ref.read(reviewerModeProvider)) { state = AsyncData>(_demoConversations()); if (includeFolders) { - ref.invalidate(foldersProvider); + unawaited(ref.read(foldersProvider.notifier).refresh()); } return; } @@ -935,7 +939,7 @@ class Conversations extends _$Conversations { if (!ref.mounted) return; state = result; if (includeFolders) { - ref.invalidate(foldersProvider); + unawaited(ref.read(foldersProvider.notifier).refresh()); } } @@ -1823,32 +1827,94 @@ final webSearchAvailableProvider = Provider((ref) { // Folders provider @Riverpod(keepAlive: true) -Future> folders(Ref ref) async { - // Protected: require authentication - if (!ref.read(isAuthenticatedProvider2)) { - DebugLogger.log('skip-unauthed', scope: 'folders'); - return []; - } - final api = ref.watch(apiServiceProvider); - if (api == null) { - DebugLogger.warning('api-missing', scope: 'folders'); - return []; +class Folders extends _$Folders { + @override + Future> build() async { + if (!ref.watch(isAuthenticatedProvider2)) { + DebugLogger.log('skip-unauthed', scope: 'folders'); + return const []; + } + final api = ref.watch(apiServiceProvider); + if (api == null) { + DebugLogger.warning('api-missing', scope: 'folders'); + return const []; + } + return _load(api); } - try { - final foldersData = await api.getFolders(); - final folders = foldersData - .map((folderData) => Folder.fromJson(folderData)) - .toList(); - DebugLogger.log( - 'fetch-ok', - scope: 'folders', - data: {'count': folders.length}, - ); - return folders; - } catch (e) { - DebugLogger.error('fetch-failed', scope: 'folders', error: e); - return []; + Future refresh() async { + if (!ref.read(isAuthenticatedProvider2)) { + state = const AsyncData>([]); + return; + } + final api = ref.read(apiServiceProvider); + if (api == null) { + state = const AsyncData>([]); + return; + } + final result = await AsyncValue.guard(() => _load(api)); + if (!ref.mounted) return; + state = result; + } + + void upsertFolder(Folder folder) { + final current = state.asData?.value ?? const []; + final updated = [...current]; + final index = updated.indexWhere((existing) => existing.id == folder.id); + if (index >= 0) { + updated[index] = folder; + } else { + updated.add(folder); + } + state = AsyncData>(_sort(updated)); + } + + void updateFolder(String id, Folder Function(Folder folder) transform) { + final current = state.asData?.value; + if (current == null) return; + final index = current.indexWhere((folder) => folder.id == id); + if (index < 0) return; + final updated = [...current]; + updated[index] = transform(updated[index]); + state = AsyncData>(_sort(updated)); + } + + void removeFolder(String id) { + final current = state.asData?.value; + if (current == null) return; + final updated = current + .where((folder) => folder.id != id) + .toList(growable: true); + state = AsyncData>(_sort(updated)); + } + + Future> _load(ApiService api) async { + try { + final foldersData = await api.getFolders(); + final folders = foldersData + .map((folderData) => Folder.fromJson(folderData)) + .toList(); + DebugLogger.log( + 'fetch-ok', + scope: 'folders', + data: {'count': folders.length}, + ); + return _sort(folders); + } catch (e, stackTrace) { + DebugLogger.error( + 'fetch-failed', + scope: 'folders', + error: e, + stackTrace: stackTrace, + ); + return const []; + } + } + + List _sort(List input) { + final sorted = [...input]; + sorted.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase())); + return List.unmodifiable(sorted); } } diff --git a/lib/core/services/api_service.dart b/lib/core/services/api_service.dart index b8ab4c8..b588f7b 100644 --- a/lib/core/services/api_service.dart +++ b/lib/core/services/api_service.dart @@ -3003,23 +3003,23 @@ class ApiService { ); final data = response.data; // The endpoint can return a List[ChatTitleIdResponse] or a map. - // Normalize to a List using our safe parser. + // Normalize to a List using our isolate parser. if (data is List) { - return data - .whereType>() - .map((e) => Conversation.fromJson(parseConversationSummary(e))) - .toList(); + return _parseConversationSummaryList( + data, + debugLabel: 'parse_search_direct', + ); } if (data is Map) { final list = (data['conversations'] ?? data['items'] ?? data['results']); if (list is List) { - return list - .whereType>() - .map((e) => Conversation.fromJson(parseConversationSummary(e))) - .toList(); + return _parseConversationSummaryList( + list, + debugLabel: 'parse_search_wrapped', + ); } } - return []; + return const []; } /// Search within messages content (capability-safe) diff --git a/lib/core/services/optimized_storage_service.dart b/lib/core/services/optimized_storage_service.dart index f15327c..9b44b5a 100644 --- a/lib/core/services/optimized_storage_service.dart +++ b/lib/core/services/optimized_storage_service.dart @@ -322,9 +322,13 @@ class OptimizedStorageService { Future saveLocalConversations(List conversations) async { try { - final serialized = conversations + final jsonReady = conversations .map((conversation) => conversation.toJson()) .toList(); + final serialized = await _workerManager + .schedule, String>(_encodeConversationsWorker, { + 'conversations': jsonReady, + }, debugLabel: 'encode_local_conversations'); await _cachesBox.put(_localConversationsKey, serialized); DebugLogger.log( 'Saved ${conversations.length} local conversations', @@ -478,3 +482,15 @@ List> _decodeStoredConversationsWorker( return >[]; } + +String _encodeConversationsWorker(Map payload) { + final raw = payload['conversations']; + if (raw is List) { + return jsonEncode(raw); + } + if (raw is String) { + // Already encoded. + return raw; + } + return jsonEncode([]); +} diff --git a/lib/features/navigation/widgets/chats_drawer.dart b/lib/features/navigation/widgets/chats_drawer.dart index 29ccfb9..b977de7 100644 --- a/lib/features/navigation/widgets/chats_drawer.dart +++ b/lib/features/navigation/widgets/chats_drawer.dart @@ -770,8 +770,10 @@ class _ChatsDrawerState extends ConsumerState { try { final api = ref.read(apiServiceProvider); if (api == null) throw Exception('No API service'); - await api.createFolder(name: name); + final created = await api.createFolder(name: name); + final folder = Folder.fromJson(Map.from(created)); HapticFeedback.lightImpact(); + ref.read(foldersProvider.notifier).upsertFolder(folder); refreshConversationsCache(ref, includeFolders: true); } catch (e, stackTrace) { if (!mounted) return; @@ -1094,6 +1096,13 @@ class _ChatsDrawerState extends ConsumerState { if (api == null) throw Exception('No API service'); await api.updateFolder(folderId, name: newName); HapticFeedback.selectionClick(); + ref + .read(foldersProvider.notifier) + .updateFolder( + folderId, + (folder) => + folder.copyWith(name: newName, updatedAt: DateTime.now()), + ); refreshConversationsCache(ref, includeFolders: true); } catch (e, stackTrace) { if (!mounted) return; @@ -1129,6 +1138,7 @@ class _ChatsDrawerState extends ConsumerState { if (api == null) throw Exception('No API service'); await api.deleteFolder(folderId); HapticFeedback.mediumImpact(); + ref.read(foldersProvider.notifier).removeFolder(folderId); refreshConversationsCache(ref, includeFolders: true); } catch (e, stackTrace) { if (!mounted) return;