From c177bf0691b1a2ab6b2ec029eb5bed5ae54dc5b2 Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Mon, 6 Apr 2026 19:06:13 -0400 Subject: [PATCH] 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 =