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 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 19:06:13 -04:00
parent 4ebc57d2e5
commit c177bf0691
2 changed files with 43 additions and 3 deletions
+38 -1
View File
@@ -8,6 +8,8 @@ import 'core/constants.dart';
import 'core/theme.dart'; import 'core/theme.dart';
import 'providers/api_client_provider.dart'; import 'providers/api_client_provider.dart';
import 'providers/auth_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/capture_work_queue_provider.dart';
import 'providers/briefing_provider.dart'; import 'providers/briefing_provider.dart';
import 'providers/calendar_provider.dart'; import 'providers/calendar_provider.dart';
@@ -512,6 +514,12 @@ class _QuickCaptureBar extends ConsumerStatefulWidget {
class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> { class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
final _controller = TextEditingController(); final _controller = TextEditingController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _drainOfflineQueue());
}
@override @override
void dispose() { void dispose() {
_controller.dispose(); _controller.dispose();
@@ -527,6 +535,28 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
ref.read(captureWorkQueueProvider.notifier).enqueue(text); ref.read(captureWorkQueueProvider.notifier).enqueue(text);
} }
Future<void> _drainOfflineQueue() async {
if (!mounted) return;
final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return;
for (final text in List<String>.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<void>().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<void> _toggleCaptureMic() async { Future<void> _toggleCaptureMic() async {
final voice = ref.read(voiceProvider); final voice = ref.read(voiceProvider);
if (voice.voiceModeActive) { if (voice.voiceModeActive) {
@@ -556,8 +586,10 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation; final location = GoRouterState.of(context).matchedLocation;
final offlineQueueCount = ref.watch(captureQueueProvider).length;
final workQueue = ref.watch(captureWorkQueueProvider); final workQueue = ref.watch(captureWorkQueueProvider);
final isWorking = workQueue.isNotEmpty; final isWorking = workQueue.isNotEmpty;
final totalPending = workQueue.length + offlineQueueCount;
// Show snackbar when a result is published. // Show snackbar when a result is published.
ref.listen(captureResultProvider, (_, result) { ref.listen(captureResultProvider, (_, result) {
@@ -592,7 +624,12 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
isDense: true, isDense: true,
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 10), horizontal: 14, vertical: 10),
prefixIcon: isWorking prefixIcon: totalPending > 0
? Badge(
label: Text('$totalPending'),
child: const Icon(Icons.cloud_upload_outlined),
)
: isWorking
? const Padding( ? const Padding(
padding: EdgeInsets.all(12), padding: EdgeInsets.all(12),
child: SizedBox( child: SizedBox(
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/exceptions.dart'; import '../core/exceptions.dart';
import 'api_client_provider.dart'; import 'api_client_provider.dart';
import 'capture_queue_provider.dart';
import 'chat_provider.dart'; import 'chat_provider.dart';
/// Outcome of a single capture attempt — consumed by the UI for snackbars. /// Outcome of a single capture attempt — consumed by the UI for snackbars.
@@ -63,11 +64,13 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
ref.read(captureResultProvider.notifier).state = ref.read(captureResultProvider.notifier).state =
const CaptureResult('Sent to Fabled.'); const CaptureResult('Sent to Fabled.');
} on NetworkException catch (_) { } 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) : []; state = state.length > 1 ? state.sublist(1) : [];
ref.read(captureResultProvider.notifier).state = const CaptureResult( ref.read(captureResultProvider.notifier).state = const CaptureResult(
"You're offline — message not sent.", "You're offline — capture saved and will retry automatically.",
isError: true,
); );
break;
} on AppException catch (e) { } on AppException catch (e) {
state = state.length > 1 ? state.sublist(1) : []; state = state.length > 1 ? state.sublist(1) : [];
ref.read(captureResultProvider.notifier).state = ref.read(captureResultProvider.notifier).state =