From 6ea268bf58b1b43628ce165707d70a406c3feedf Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 18:33:04 -0400 Subject: [PATCH 1/4] fix: use dialog context in delete confirmation to prevent shell nav pop Navigator.pop(outerContext) inside a showDialog builder resolves to the ShellRoute's nested navigator instead of the root navigator where the dialog lives, popping the conversations route and showing a black screen. Co-Authored-By: Claude Sonnet 4.6 --- lib/screens/chat/conversations_tab_screen.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/screens/chat/conversations_tab_screen.dart b/lib/screens/chat/conversations_tab_screen.dart index a82cb07..6c8f271 100644 --- a/lib/screens/chat/conversations_tab_screen.dart +++ b/lib/screens/chat/conversations_tab_screen.dart @@ -99,15 +99,15 @@ class ConversationsTabScreen extends ConsumerWidget { BuildContext context, WidgetRef ref, int id, String title) async { final ok = await showDialog( context: context, - builder: (_) => AlertDialog( + builder: (dialogContext) => AlertDialog( title: const Text('Delete conversation?'), content: Text('"$title" will be permanently deleted.'), actions: [ TextButton( - onPressed: () => Navigator.pop(context, false), + onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancel')), FilledButton( - onPressed: () => Navigator.pop(context, true), + onPressed: () => Navigator.pop(dialogContext, true), child: const Text('Delete')), ], ), From 946b70ecc409e9894a463c98532ecb65d082c4b2 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 18:35:41 -0400 Subject: [PATCH 2/4] fix: use dialog context in event delete confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same navigator mismatch as the chat delete bug — outer context inside showDialog builder resolves to the wrong navigator ancestor. Co-Authored-By: Claude Sonnet 4.6 --- lib/screens/calendar/event_form_sheet.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/screens/calendar/event_form_sheet.dart b/lib/screens/calendar/event_form_sheet.dart index 4197543..d1460ca 100644 --- a/lib/screens/calendar/event_form_sheet.dart +++ b/lib/screens/calendar/event_form_sheet.dart @@ -143,19 +143,19 @@ class _EventFormSheetState extends ConsumerState { Future _delete() async { final confirmed = await showDialog( context: context, - builder: (_) => AlertDialog( + builder: (dialogContext) => AlertDialog( title: const Text('Delete this event?'), actions: [ TextButton( - onPressed: () => Navigator.of(context).pop(false), + onPressed: () => Navigator.pop(dialogContext, false), child: const Text('Cancel'), ), TextButton( - onPressed: () => Navigator.of(context).pop(true), + onPressed: () => Navigator.pop(dialogContext, true), child: Text( 'Delete', - style: - TextStyle(color: Theme.of(context).colorScheme.error), + style: TextStyle( + color: Theme.of(dialogContext).colorScheme.error), ), ), ], From 4ebc57d2e5e8c50b474a1f425e2473a3eb4dc3aa Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 19:00:33 -0400 Subject: [PATCH 3/4] feat: quick capture sends to chat instead of quick-capture endpoint Replace POST /api/quick-capture with: create a conversation, send the message, and fire-and-forget the SSE generation stream so the assistant processes the request in the background without blocking the UI. The new conversation appears immediately in the chat tab. Co-Authored-By: Claude Sonnet 4.6 --- lib/app.dart | 49 +---------------- .../capture_work_queue_provider.dart | 55 ++++++------------- 2 files changed, 19 insertions(+), 85 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index ddba82b..15164f4 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -5,21 +5,17 @@ import 'package:go_router/go_router.dart'; import 'package:flutter_timezone/flutter_timezone.dart'; import 'core/constants.dart'; -import 'core/exceptions.dart'; import 'core/theme.dart'; import 'providers/api_client_provider.dart'; import 'providers/auth_provider.dart'; -import 'providers/capture_queue_provider.dart'; import 'providers/capture_work_queue_provider.dart'; import 'providers/briefing_provider.dart'; import 'providers/calendar_provider.dart'; import 'providers/chat_provider.dart'; import 'providers/knowledge_provider.dart'; import 'providers/news_provider.dart'; -import 'providers/notes_provider.dart'; import 'providers/settings_provider.dart'; import 'providers/update_provider.dart'; -import 'providers/tasks_provider.dart'; import 'screens/auth/login_screen.dart'; import 'screens/briefing/briefing_screen.dart'; import 'screens/knowledge/knowledge_screen.dart'; @@ -516,12 +512,6 @@ class _QuickCaptureBar extends ConsumerStatefulWidget { class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { final _controller = TextEditingController(); - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue()); - } - @override void dispose() { _controller.dispose(); @@ -537,36 +527,6 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { ref.read(captureWorkQueueProvider.notifier).enqueue(text); } - Future _drainOfflineQueue() async { - if (!mounted) return; - final queue = ref.read(captureQueueProvider); - if (queue.isEmpty) return; - final api = ref.read(quickCaptureApiProvider); - for (final text in List.from(queue)) { - if (!mounted) break; - try { - final result = await api.capture(text); - // Dequeue before the mounted check — SharedPreferences doesn't need - // the widget alive, and skipping this would leave a ghost item. - await ref.read(captureQueueProvider.notifier).dequeue(text); - if (!mounted) break; - switch (result.type) { - case 'note': - ref.invalidate(notesProvider); - case 'task': - case 'todo': - ref.invalidate(tasksProvider); - } - } on NetworkException { - break; - } catch (_) { - // Server error or unexpected failure — drop from queue to prevent - // ghost items that can never be cleared. - await ref.read(captureQueueProvider.notifier).dequeue(text); - } - } - } - Future _toggleCaptureMic() async { final voice = ref.read(voiceProvider); if (voice.voiceModeActive) { @@ -596,10 +556,8 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { @override Widget build(BuildContext context) { final location = GoRouterState.of(context).matchedLocation; - final offlineQueueCount = ref.watch(captureQueueProvider).length; final workQueue = ref.watch(captureWorkQueueProvider); final isWorking = workQueue.isNotEmpty; - final totalPending = workQueue.length + offlineQueueCount; // Show snackbar when a result is published. ref.listen(captureResultProvider, (_, result) { @@ -634,12 +592,7 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 14, vertical: 10), - prefixIcon: totalPending > 0 - ? Badge( - label: Text('$totalPending'), - child: const Icon(Icons.cloud_upload_outlined), - ) - : isWorking + prefixIcon: isWorking ? const Padding( padding: EdgeInsets.all(12), child: SizedBox( diff --git a/lib/providers/capture_work_queue_provider.dart b/lib/providers/capture_work_queue_provider.dart index afa0f5a..6cd4cc3 100644 --- a/lib/providers/capture_work_queue_provider.dart +++ b/lib/providers/capture_work_queue_provider.dart @@ -2,9 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/exceptions.dart'; import 'api_client_provider.dart'; -import 'capture_queue_provider.dart'; -import 'notes_provider.dart'; -import 'tasks_provider.dart'; +import 'chat_provider.dart'; /// Outcome of a single capture attempt — consumed by the UI for snackbars. class CaptureResult { @@ -25,7 +23,6 @@ class _CaptureResultNotifier extends Notifier { } /// In-memory sequential work queue for quick captures. -/// Separate from [captureQueueProvider] (which is the offline persistence queue). final captureWorkQueueProvider = NotifierProvider>( CaptureWorkQueueNotifier.new, @@ -52,54 +49,38 @@ class CaptureWorkQueueNotifier extends Notifier> { // Signal "no result yet" so the same result value can re-trigger watch. ref.read(captureResultProvider.notifier).state = null; try { - final api = ref.read(quickCaptureApiProvider); - final result = await api.capture(text); + // Create a new conversation, add it to the conversations list, then + // send the message and kick off generation in the background. + final conv = + await ref.read(conversationsProvider.notifier).create(''); + final chatRepo = ref.read(chatRepositoryProvider); + await chatRepo.sendMessage(conv.id, text); + // Fire-and-forget: drain the SSE stream so the server generates a + // response (creating notes/tasks/etc.) without blocking the UI. + chatRepo.streamGeneration(conv.id).drain().ignore(); - // Dequeue on success. state = state.length > 1 ? state.sublist(1) : []; - - // Invalidate content providers so lists refresh. - switch (result.type) { - case 'note': - ref.invalidate(notesProvider); - case 'task': - case 'todo': - ref.invalidate(tasksProvider); - } - - // Publish result for snackbar. - final msg = result.message.isNotEmpty - ? result.message - : '${_typeLabel(result.type)} created: ${result.title}'; - ref.read(captureResultProvider.notifier).state = CaptureResult(msg); + ref.read(captureResultProvider.notifier).state = + const CaptureResult('Sent to Fabled.'); } on NetworkException catch (_) { - // Persist to offline queue and stop draining — still offline. - await ref.read(captureQueueProvider.notifier).enqueue(text); state = state.length > 1 ? state.sublist(1) : []; - ref.read(captureResultProvider.notifier).state = CaptureResult( - "You're offline — capture saved and will retry automatically.", + ref.read(captureResultProvider.notifier).state = const CaptureResult( + "You're offline — message not sent.", + isError: true, ); - break; } on AppException catch (e) { state = state.length > 1 ? state.sublist(1) : []; ref.read(captureResultProvider.notifier).state = CaptureResult(e.message, isError: true); } catch (_) { state = state.length > 1 ? state.sublist(1) : []; - ref.read(captureResultProvider.notifier).state = - CaptureResult('Capture failed. Please try again.', isError: true); + ref.read(captureResultProvider.notifier).state = const CaptureResult( + 'Failed to send. Please try again.', + isError: true); } } } finally { _running = false; } } - - String _typeLabel(String type) => switch (type) { - 'note' => 'Note', - 'task' => 'Task', - 'event' => 'Event', - 'todo' => 'To-do', - _ => type, - }; } From c177bf0691b1a2ab6b2ec029eb5bed5ae54dc5b2 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 19:06:13 -0400 Subject: [PATCH 4/4] fix: restore offline queue for captures, drain via chat on retry Offline queue (SharedPreferences) persists captures when the device is offline and replays them as chat conversations on next launch, preserving the same fire-and-forget guarantee as the online path. Co-Authored-By: Claude Sonnet 4.6 --- lib/app.dart | 39 ++++++++++++++++++- .../capture_work_queue_provider.dart | 7 +++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 15164f4..294aec3 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -8,6 +8,8 @@ import 'core/constants.dart'; import 'core/theme.dart'; import 'providers/api_client_provider.dart'; import 'providers/auth_provider.dart'; +import 'core/exceptions.dart'; +import 'providers/capture_queue_provider.dart'; import 'providers/capture_work_queue_provider.dart'; import 'providers/briefing_provider.dart'; import 'providers/calendar_provider.dart'; @@ -512,6 +514,12 @@ class _QuickCaptureBar extends ConsumerStatefulWidget { class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { final _controller = TextEditingController(); + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue()); + } + @override void dispose() { _controller.dispose(); @@ -527,6 +535,28 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { ref.read(captureWorkQueueProvider.notifier).enqueue(text); } + Future _drainOfflineQueue() async { + if (!mounted) return; + final queue = ref.read(captureQueueProvider); + if (queue.isEmpty) return; + for (final text in List.from(queue)) { + if (!mounted) break; + try { + final conv = + await ref.read(conversationsProvider.notifier).create(''); + final chatRepo = ref.read(chatRepositoryProvider); + await chatRepo.sendMessage(conv.id, text); + chatRepo.streamGeneration(conv.id).drain().ignore(); + await ref.read(captureQueueProvider.notifier).dequeue(text); + } on NetworkException { + break; + } catch (_) { + // Server error — drop from queue to prevent ghost items. + await ref.read(captureQueueProvider.notifier).dequeue(text); + } + } + } + Future _toggleCaptureMic() async { final voice = ref.read(voiceProvider); if (voice.voiceModeActive) { @@ -556,8 +586,10 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { @override Widget build(BuildContext context) { final location = GoRouterState.of(context).matchedLocation; + final offlineQueueCount = ref.watch(captureQueueProvider).length; final workQueue = ref.watch(captureWorkQueueProvider); final isWorking = workQueue.isNotEmpty; + final totalPending = workQueue.length + offlineQueueCount; // Show snackbar when a result is published. ref.listen(captureResultProvider, (_, result) { @@ -592,7 +624,12 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 14, vertical: 10), - prefixIcon: isWorking + prefixIcon: totalPending > 0 + ? Badge( + label: Text('$totalPending'), + child: const Icon(Icons.cloud_upload_outlined), + ) + : isWorking ? const Padding( padding: EdgeInsets.all(12), child: SizedBox( diff --git a/lib/providers/capture_work_queue_provider.dart b/lib/providers/capture_work_queue_provider.dart index 6cd4cc3..28b8e1f 100644 --- a/lib/providers/capture_work_queue_provider.dart +++ b/lib/providers/capture_work_queue_provider.dart @@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/exceptions.dart'; import 'api_client_provider.dart'; +import 'capture_queue_provider.dart'; import 'chat_provider.dart'; /// Outcome of a single capture attempt — consumed by the UI for snackbars. @@ -63,11 +64,13 @@ class CaptureWorkQueueNotifier extends Notifier> { ref.read(captureResultProvider.notifier).state = const CaptureResult('Sent to Fabled.'); } on NetworkException catch (_) { + // Persist to offline queue and stop draining — still offline. + await ref.read(captureQueueProvider.notifier).enqueue(text); state = state.length > 1 ? state.sublist(1) : []; ref.read(captureResultProvider.notifier).state = const CaptureResult( - "You're offline — message not sent.", - isError: true, + "You're offline — capture saved and will retry automatically.", ); + break; } on AppException catch (e) { state = state.length > 1 ? state.sublist(1) : []; ref.read(captureResultProvider.notifier).state =