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 <noreply@anthropic.com>
This commit is contained in:
+1
-48
@@ -5,21 +5,17 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:flutter_timezone/flutter_timezone.dart';
|
import 'package:flutter_timezone/flutter_timezone.dart';
|
||||||
|
|
||||||
import 'core/constants.dart';
|
import 'core/constants.dart';
|
||||||
import 'core/exceptions.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 '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';
|
||||||
import 'providers/chat_provider.dart';
|
import 'providers/chat_provider.dart';
|
||||||
import 'providers/knowledge_provider.dart';
|
import 'providers/knowledge_provider.dart';
|
||||||
import 'providers/news_provider.dart';
|
import 'providers/news_provider.dart';
|
||||||
import 'providers/notes_provider.dart';
|
|
||||||
import 'providers/settings_provider.dart';
|
import 'providers/settings_provider.dart';
|
||||||
import 'providers/update_provider.dart';
|
import 'providers/update_provider.dart';
|
||||||
import 'providers/tasks_provider.dart';
|
|
||||||
import 'screens/auth/login_screen.dart';
|
import 'screens/auth/login_screen.dart';
|
||||||
import 'screens/briefing/briefing_screen.dart';
|
import 'screens/briefing/briefing_screen.dart';
|
||||||
import 'screens/knowledge/knowledge_screen.dart';
|
import 'screens/knowledge/knowledge_screen.dart';
|
||||||
@@ -516,12 +512,6 @@ 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();
|
||||||
@@ -537,36 +527,6 @@ 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;
|
|
||||||
final api = ref.read(quickCaptureApiProvider);
|
|
||||||
for (final text in List<String>.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<void> _toggleCaptureMic() async {
|
Future<void> _toggleCaptureMic() async {
|
||||||
final voice = ref.read(voiceProvider);
|
final voice = ref.read(voiceProvider);
|
||||||
if (voice.voiceModeActive) {
|
if (voice.voiceModeActive) {
|
||||||
@@ -596,10 +556,8 @@ 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) {
|
||||||
@@ -634,12 +592,7 @@ 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: totalPending > 0
|
prefixIcon: isWorking
|
||||||
? 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,9 +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 'notes_provider.dart';
|
|
||||||
import 'tasks_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.
|
||||||
class CaptureResult {
|
class CaptureResult {
|
||||||
@@ -25,7 +23,6 @@ class _CaptureResultNotifier extends Notifier<CaptureResult?> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// In-memory sequential work queue for quick captures.
|
/// In-memory sequential work queue for quick captures.
|
||||||
/// Separate from [captureQueueProvider] (which is the offline persistence queue).
|
|
||||||
final captureWorkQueueProvider =
|
final captureWorkQueueProvider =
|
||||||
NotifierProvider<CaptureWorkQueueNotifier, List<String>>(
|
NotifierProvider<CaptureWorkQueueNotifier, List<String>>(
|
||||||
CaptureWorkQueueNotifier.new,
|
CaptureWorkQueueNotifier.new,
|
||||||
@@ -52,54 +49,38 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
|
|||||||
// Signal "no result yet" so the same result value can re-trigger watch.
|
// Signal "no result yet" so the same result value can re-trigger watch.
|
||||||
ref.read(captureResultProvider.notifier).state = null;
|
ref.read(captureResultProvider.notifier).state = null;
|
||||||
try {
|
try {
|
||||||
final api = ref.read(quickCaptureApiProvider);
|
// Create a new conversation, add it to the conversations list, then
|
||||||
final result = await api.capture(text);
|
// 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<void>().ignore();
|
||||||
|
|
||||||
// Dequeue on success.
|
|
||||||
state = state.length > 1 ? state.sublist(1) : [];
|
state = state.length > 1 ? state.sublist(1) : [];
|
||||||
|
ref.read(captureResultProvider.notifier).state =
|
||||||
// Invalidate content providers so lists refresh.
|
const CaptureResult('Sent to Fabled.');
|
||||||
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);
|
|
||||||
} 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 = CaptureResult(
|
ref.read(captureResultProvider.notifier).state = const CaptureResult(
|
||||||
"You're offline — capture saved and will retry automatically.",
|
"You're offline — message not sent.",
|
||||||
|
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 =
|
||||||
CaptureResult(e.message, isError: true);
|
CaptureResult(e.message, isError: true);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
state = state.length > 1 ? state.sublist(1) : [];
|
state = state.length > 1 ? state.sublist(1) : [];
|
||||||
ref.read(captureResultProvider.notifier).state =
|
ref.read(captureResultProvider.notifier).state = const CaptureResult(
|
||||||
CaptureResult('Capture failed. Please try again.', isError: true);
|
'Failed to send. Please try again.',
|
||||||
|
isError: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_running = false;
|
_running = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _typeLabel(String type) => switch (type) {
|
|
||||||
'note' => 'Note',
|
|
||||||
'task' => 'Task',
|
|
||||||
'event' => 'Event',
|
|
||||||
'todo' => 'To-do',
|
|
||||||
_ => type,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user