refactor: centralize conversation cache management

- Introduced a new method `refreshConversationsCache` to streamline the invalidation of the conversations provider and optionally the folders provider.
- Updated various components to utilize the new cache management method, enhancing code clarity and reducing redundancy.
- This refactor improves the efficiency of conversation and folder synchronization across the application.
This commit is contained in:
cogwheel0
2025-10-02 00:30:14 +05:30
parent 73ccb14b20
commit ff02af1e89
7 changed files with 122 additions and 68 deletions

View File

@@ -661,6 +661,17 @@ class _ConversationsCacheTimestamp extends _$ConversationsCacheTimestamp {
void set(DateTime? timestamp) => state = timestamp; void set(DateTime? timestamp) => state = timestamp;
} }
/// Clears the in-memory timestamp cache and invalidates the conversations
/// provider so the next read forces a refetch. Optionally invalidates the
/// folders provider when folder metadata must stay in sync with conversations.
void refreshConversationsCache(dynamic ref, {bool includeFolders = false}) {
ref.read(_conversationsCacheTimestampProvider.notifier).set(null);
ref.invalidate(conversationsProvider);
if (includeFolders) {
ref.invalidate(foldersProvider);
}
}
// Conversation providers - Now using correct OpenWebUI API with caching // Conversation providers - Now using correct OpenWebUI API with caching
// keepAlive to maintain cache during authenticated session // keepAlive to maintain cache during authenticated session
@Riverpod(keepAlive: true) @Riverpod(keepAlive: true)

View File

@@ -103,7 +103,7 @@ void _scheduleConversationWarmup(Ref ref, {bool force = false}) {
return; return;
} }
if (existing.hasError) { if (existing.hasError) {
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
} }
final conversations = await ref.read(conversationsProvider.future); final conversations = await ref.read(conversationsProvider.future);
statusController.set(_ConversationWarmupStatus.complete); statusController.set(_ConversationWarmupStatus.complete);
@@ -368,7 +368,7 @@ class _ForegroundRefreshObserver extends WidgetsBindingObserver {
// Schedule to avoid side-effects during build frames // Schedule to avoid side-effects during build frames
Future.microtask(() { Future.microtask(() {
try { try {
_ref.invalidate(conversationsProvider); refreshConversationsCache(_ref);
_ref _ref
.read(_conversationWarmupStatusProvider.notifier) .read(_conversationWarmupStatusProvider.notifier)
.set(_ConversationWarmupStatus.idle); .set(_ConversationWarmupStatus.idle);

View File

@@ -647,7 +647,7 @@ class ChatMessagesNotifier extends Notifier<List<ChatMessage>> {
// can pick up updated titles and ordering once streaming completes. // can pick up updated titles and ordering once streaming completes.
// Best-effort: ignore if ref lifecycle/context prevents invalidation. // Best-effort: ignore if ref lifecycle/context prevents invalidation.
try { try {
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
} catch (_) {} } catch (_) {}
} }
} }
@@ -1373,10 +1373,10 @@ Future<void> regenerateMessage(
.read(activeConversationProvider.notifier) .read(activeConversationProvider.notifier)
.set(active.copyWith(title: newTitle)); .set(active.copyWith(title: newTitle));
} }
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
}, },
onChatTagsUpdated: () { onChatTagsUpdated: () {
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
final active = ref.read(activeConversationProvider); final active = ref.read(activeConversationProvider);
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
if (active != null && api != null) { if (active != null && api != null) {
@@ -1523,7 +1523,7 @@ Future<void> _sendMessageInternal(
try { try {
// Guard against using ref after widget disposal // Guard against using ref after widget disposal
if (ref.mounted == true) { if (ref.mounted == true) {
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
} }
} catch (_) { } catch (_) {
// If ref doesn't support mounted or is disposed, skip // If ref doesn't support mounted or is disposed, skip
@@ -1920,10 +1920,10 @@ Future<void> _sendMessageInternal(
.read(activeConversationProvider.notifier) .read(activeConversationProvider.notifier)
.set(active.copyWith(title: newTitle)); .set(active.copyWith(title: newTitle));
} }
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
}, },
onChatTagsUpdated: () { onChatTagsUpdated: () {
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
final active = ref.read(activeConversationProvider); final active = ref.read(activeConversationProvider);
final api = ref.read(apiServiceProvider); final api = ref.read(apiServiceProvider);
if (active != null && api != null) { if (active != null && api != null) {
@@ -2057,7 +2057,7 @@ Future<void> _saveConversationLocally(dynamic ref) async {
await storage.setString('conversations', jsonEncode(conversations)); await storage.setString('conversations', jsonEncode(conversations));
ref.read(activeConversationProvider.notifier).set(updatedConversation); ref.read(activeConversationProvider.notifier).set(updatedConversation);
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
} catch (e) { } catch (e) {
// Handle local storage errors silently // Handle local storage errors silently
} }
@@ -2095,7 +2095,7 @@ Future<void> pinConversation(
await api.pinConversation(conversationId, pinned); await api.pinConversation(conversationId, pinned);
// Refresh conversations list to reflect the change // Refresh conversations list to reflect the change
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
// Update active conversation if it's the one being pinned // Update active conversation if it's the one being pinned
final activeConversation = ref.read(activeConversationProvider); final activeConversation = ref.read(activeConversationProvider);
@@ -2134,7 +2134,7 @@ Future<void> archiveConversation(
await api.archiveConversation(conversationId, archived); await api.archiveConversation(conversationId, archived);
// Refresh conversations list to reflect the change // Refresh conversations list to reflect the change
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
} catch (e) { } catch (e) {
DebugLogger.log( DebugLogger.log(
'Error ${archived ? 'archiving' : 'unarchiving'} conversation: $e', 'Error ${archived ? 'archiving' : 'unarchiving'} conversation: $e',
@@ -2160,7 +2160,7 @@ Future<String?> shareConversation(WidgetRef ref, String conversationId) async {
final shareId = await api.shareConversation(conversationId); final shareId = await api.shareConversation(conversationId);
// Refresh conversations list to reflect the change // Refresh conversations list to reflect the change
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
return shareId; return shareId;
} catch (e) { } catch (e) {
@@ -2183,7 +2183,7 @@ Future<void> cloneConversation(WidgetRef ref, String conversationId) async {
// The ChatMessagesNotifier will automatically load messages when activeConversation changes // The ChatMessagesNotifier will automatically load messages when activeConversation changes
// Refresh conversations list to show the new conversation // Refresh conversations list to show the new conversation
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
} catch (e) { } catch (e) {
DebugLogger.log('Error cloning conversation: $e', scope: 'chat/providers'); DebugLogger.log('Error cloning conversation: $e', scope: 'chat/providers');
rethrow; rethrow;

View File

@@ -236,7 +236,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Force refresh conversations provider to ensure we get the demo conversations // Force refresh conversations provider to ensure we get the demo conversations
if (!mounted) return; if (!mounted) return;
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
// Try to load demo conversation // Try to load demo conversation
for (int i = 0; i < 10; i++) { for (int i = 0; i < 10; i++) {
@@ -1486,7 +1486,7 @@ class _ChatPageState extends ConsumerState<ChatPage> {
// Also refresh the conversations list to reconcile missed events // Also refresh the conversations list to reconcile missed events
// and keep timestamps/order in sync with the server. // and keep timestamps/order in sync with the server.
try { try {
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
// Best-effort await to stabilize UI; ignore errors. // Best-effort await to stabilize UI; ignore errors.
await ref.read(conversationsProvider.future); await ref.read(conversationsProvider.future);
} catch (_) {} } catch (_) {}

View File

@@ -1112,7 +1112,7 @@ class _ModernChatInputState extends ConsumerState<ModernChatInput>
activeColor = null; activeColor = null;
} }
const double iconSize = IconSize.xl; const double iconSize = IconSize.large;
final Color iconColor = !enabled final Color iconColor = !enabled
? context.conduitTheme.textPrimary.withValues(alpha: Alpha.disabled) ? context.conduitTheme.textPrimary.withValues(alpha: Alpha.disabled)

View File

@@ -11,7 +11,7 @@ import '../../../core/providers/app_providers.dart';
import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/theme/theme_extensions.dart';
import '../../chat/providers/chat_providers.dart' as chat; import '../../chat/providers/chat_providers.dart' as chat;
// import '../../files/views/files_page.dart'; // import '../../files/views/files_page.dart';
import '../../../shared/utils/ui_utils.dart'; import '../../../core/utils/debug_logger.dart';
import '../../../core/services/navigation_service.dart'; import '../../../core/services/navigation_service.dart';
import '../../../shared/widgets/themed_dialogs.dart'; import '../../../shared/widgets/themed_dialogs.dart';
import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/l10n/app_localizations.dart';
@@ -55,12 +55,11 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
Future<void> _refreshChats() async { Future<void> _refreshChats() async {
try { try {
// Always refresh folders // Always refresh folders and conversations cache
ref.invalidate(foldersProvider); refreshConversationsCache(ref, includeFolders: true);
if (_query.trim().isEmpty) { if (_query.trim().isEmpty) {
// Refresh main conversations list // Refresh main conversations list
ref.invalidate(conversationsProvider);
try { try {
await ref.read(conversationsProvider.future); await ref.read(conversationsProvider.future);
} catch (_) {} } catch (_) {}
@@ -629,15 +628,17 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
if (api == null) throw Exception('No API service'); if (api == null) throw Exception('No API service');
await api.createFolder(name: name); await api.createFolder(name: name);
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
ref.invalidate(foldersProvider); refreshConversationsCache(ref, includeFolders: true);
} catch (e, stackTrace) {
if (!mounted) return; if (!mounted) return;
UiUtils.showMessage(context, AppLocalizations.of(context)!.folderCreated); DebugLogger.error(
} catch (e) { 'create-folder-failed',
if (!mounted) return; scope: 'drawer',
UiUtils.showMessage( error: e,
context, stackTrace: stackTrace,
);
await _showDrawerError(
AppLocalizations.of(context)!.failedToCreateFolder, AppLocalizations.of(context)!.failedToCreateFolder,
isError: true,
); );
} }
} }
@@ -668,22 +669,17 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
if (api == null) throw Exception('No API service'); if (api == null) throw Exception('No API service');
await api.moveConversationToFolder(details.data.id, folderId); await api.moveConversationToFolder(details.data.id, folderId);
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
ref.invalidate(conversationsProvider); refreshConversationsCache(ref, includeFolders: true);
ref.invalidate(foldersProvider); } catch (e, stackTrace) {
DebugLogger.error(
'move-conversation-failed',
scope: 'drawer',
error: e,
stackTrace: stackTrace,
);
if (mounted) { if (mounted) {
UiUtils.showMessage( await _showDrawerError(
context,
AppLocalizations.of(
context,
)!.movedChatToFolder(details.data.title, name),
);
}
} catch (_) {
if (mounted) {
UiUtils.showMessage(
context,
AppLocalizations.of(context)!.failedToMoveChat, AppLocalizations.of(context)!.failedToMoveChat,
isError: true,
); );
} }
} }
@@ -868,6 +864,28 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
return null; return null;
} }
Future<void> _showDrawerError(String message) async {
if (!mounted) return;
final l10n = AppLocalizations.of(context)!;
final theme = context.conduitTheme;
await ThemedDialogs.show<void>(
context,
title: l10n.errorMessage,
content: Text(
message,
style: AppTypography.bodyMediumStyle.copyWith(
color: theme.textSecondary,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.ok),
),
],
);
}
void _showFolderContextMenu( void _showFolderContextMenu(
BuildContext context, BuildContext context,
String folderId, String folderId,
@@ -923,14 +941,16 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
if (api == null) throw Exception('No API service'); if (api == null) throw Exception('No API service');
await api.updateFolder(folderId, name: newName); await api.updateFolder(folderId, name: newName);
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
ref.invalidate(foldersProvider); refreshConversationsCache(ref, includeFolders: true);
} catch (_) { } catch (e, stackTrace) {
if (!mounted) return; if (!mounted) return;
UiUtils.showMessage( DebugLogger.error(
this.context, 'rename-folder-failed',
'Failed to rename folder', scope: 'drawer',
isError: true, error: e,
stackTrace: stackTrace,
); );
await _showDrawerError('Failed to rename folder');
} }
} }
@@ -956,11 +976,16 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
if (api == null) throw Exception('No API service'); if (api == null) throw Exception('No API service');
await api.deleteFolder(folderId); await api.deleteFolder(folderId);
HapticFeedback.mediumImpact(); HapticFeedback.mediumImpact();
ref.invalidate(foldersProvider); refreshConversationsCache(ref, includeFolders: true);
ref.invalidate(conversationsProvider); } catch (e, stackTrace) {
} catch (_) {
if (!mounted) return; if (!mounted) return;
UiUtils.showMessage(this.context, deleteFolderError, isError: true); DebugLogger.error(
'delete-folder-failed',
scope: 'drawer',
error: e,
stackTrace: stackTrace,
);
await _showDrawerError(deleteFolderError);
} }
} }
@@ -984,17 +1009,16 @@ class _ChatsDrawerState extends ConsumerState<ChatsDrawer> {
if (api == null) throw Exception('No API service'); if (api == null) throw Exception('No API service');
await api.moveConversationToFolder(details.data.id, null); await api.moveConversationToFolder(details.data.id, null);
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
ref.invalidate(conversationsProvider); refreshConversationsCache(ref, includeFolders: true);
ref.invalidate(foldersProvider); } catch (e, stackTrace) {
DebugLogger.error(
'unfile-conversation-failed',
scope: 'drawer',
error: e,
stackTrace: stackTrace,
);
if (mounted) { if (mounted) {
UiUtils.showMessage( await _showDrawerError(l10n.failedToMoveChat);
context,
'Removed "${details.data.title}" from folder',
);
}
} catch (_) {
if (mounted) {
UiUtils.showMessage(context, l10n.failedToMoveChat, isError: true);
} }
} }
}, },

View File

@@ -3,7 +3,6 @@ import 'dart:io' show Platform;
import 'package:conduit/core/providers/app_providers.dart'; import 'package:conduit/core/providers/app_providers.dart';
import 'package:conduit/l10n/app_localizations.dart'; import 'package:conduit/l10n/app_localizations.dart';
import 'package:conduit/shared/theme/theme_extensions.dart'; import 'package:conduit/shared/theme/theme_extensions.dart';
import 'package:conduit/shared/utils/ui_utils.dart';
import 'package:conduit/shared/widgets/conduit_components.dart'; import 'package:conduit/shared/widgets/conduit_components.dart';
import 'package:conduit/shared/widgets/modal_safe_area.dart'; import 'package:conduit/shared/widgets/modal_safe_area.dart';
import 'package:conduit/shared/widgets/sheet_handle.dart'; import 'package:conduit/shared/widgets/sheet_handle.dart';
@@ -123,7 +122,7 @@ Future<void> showConversationContextMenu({
await chat.pinConversation(ref, conversation.id, !isPinned); await chat.pinConversation(ref, conversation.id, !isPinned);
} catch (_) { } catch (_) {
if (!context.mounted) return; if (!context.mounted) return;
UiUtils.showMessage(context, errorMessage, isError: true); await _showConversationError(context, errorMessage);
} }
} }
@@ -133,7 +132,7 @@ Future<void> showConversationContextMenu({
await chat.archiveConversation(ref, conversation.id, !isArchived); await chat.archiveConversation(ref, conversation.id, !isArchived);
} catch (_) { } catch (_) {
if (!context.mounted) return; if (!context.mounted) return;
UiUtils.showMessage(context, errorMessage, isError: true); await _showConversationError(context, errorMessage);
} }
} }
@@ -221,7 +220,7 @@ Future<void> _renameConversation(
if (api == null) throw Exception('No API service'); if (api == null) throw Exception('No API service');
await api.updateConversation(conversationId, title: newName); await api.updateConversation(conversationId, title: newName);
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
final active = ref.read(activeConversationProvider); final active = ref.read(activeConversationProvider);
if (active?.id == conversationId) { if (active?.id == conversationId) {
ref ref
@@ -230,7 +229,7 @@ Future<void> _renameConversation(
} }
} catch (_) { } catch (_) {
if (!context.mounted) return; if (!context.mounted) return;
UiUtils.showMessage(context, renameError, isError: true); await _showConversationError(context, renameError);
} }
} }
@@ -262,9 +261,29 @@ Future<void> _confirmAndDeleteConversation(
ref.read(activeConversationProvider.notifier).clear(); ref.read(activeConversationProvider.notifier).clear();
ref.read(chat.chatMessagesProvider.notifier).clearMessages(); ref.read(chat.chatMessagesProvider.notifier).clearMessages();
} }
ref.invalidate(conversationsProvider); refreshConversationsCache(ref);
} catch (_) { } catch (_) {
if (!context.mounted) return; if (!context.mounted) return;
UiUtils.showMessage(context, deleteError, isError: true); await _showConversationError(context, deleteError);
} }
} }
Future<void> _showConversationError(
BuildContext context,
String message,
) async {
if (!context.mounted) return;
final l10n = AppLocalizations.of(context)!;
final theme = context.conduitTheme;
await ThemedDialogs.show<void>(
context,
title: l10n.errorMessage,
content: Text(message, style: TextStyle(color: theme.textSecondary)),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.ok),
),
],
);
}