Android app: Calendar, News, Knowledge, Voice, Nav restructure, bug fixes

This commit was merged in pull request #14.
This commit is contained in:
2026-04-07 02:41:10 +00:00
4 changed files with 31 additions and 57 deletions
+7 -17
View File
@@ -5,10 +5,10 @@ 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 'core/exceptions.dart';
import 'providers/capture_queue_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';
@@ -16,10 +16,8 @@ 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';
@@ -541,27 +539,19 @@ class _QuickCaptureBarState extends ConsumerState<_QuickCaptureBar> {
if (!mounted) return; if (!mounted) return;
final queue = ref.read(captureQueueProvider); final queue = ref.read(captureQueueProvider);
if (queue.isEmpty) return; if (queue.isEmpty) return;
final api = ref.read(quickCaptureApiProvider);
for (final text in List<String>.from(queue)) { for (final text in List<String>.from(queue)) {
if (!mounted) break; if (!mounted) break;
try { try {
final result = await api.capture(text); final conv =
// Dequeue before the mounted check — SharedPreferences doesn't need await ref.read(conversationsProvider.notifier).create('');
// the widget alive, and skipping this would leave a ghost item. 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); 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 { } on NetworkException {
break; break;
} catch (_) { } catch (_) {
// Server error or unexpected failure — drop from queue to prevent // Server error — drop from queue to prevent ghost items.
// ghost items that can never be cleared.
await ref.read(captureQueueProvider.notifier).dequeue(text); await ref.read(captureQueueProvider.notifier).dequeue(text);
} }
} }
+16 -32
View File
@@ -3,8 +3,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 'capture_queue_provider.dart';
import 'notes_provider.dart'; import 'chat_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 +24,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,31 +50,24 @@ 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. // Persist to offline queue and stop draining — still offline.
await ref.read(captureQueueProvider.notifier).enqueue(text); 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 — capture saved and will retry automatically.",
); );
break; break;
@@ -86,20 +77,13 @@ class CaptureWorkQueueNotifier extends Notifier<List<String>> {
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,
};
} }
+5 -5
View File
@@ -143,19 +143,19 @@ class _EventFormSheetState extends ConsumerState<EventFormSheet> {
Future<void> _delete() async { Future<void> _delete() async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (_) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Delete this event?'), title: const Text('Delete this event?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(false), onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.pop(dialogContext, true),
child: Text( child: Text(
'Delete', 'Delete',
style: style: TextStyle(
TextStyle(color: Theme.of(context).colorScheme.error), color: Theme.of(dialogContext).colorScheme.error),
), ),
), ),
], ],
@@ -99,15 +99,15 @@ class ConversationsTabScreen extends ConsumerWidget {
BuildContext context, WidgetRef ref, int id, String title) async { BuildContext context, WidgetRef ref, int id, String title) async {
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (_) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Delete conversation?'), title: const Text('Delete conversation?'),
content: Text('"$title" will be permanently deleted.'), content: Text('"$title" will be permanently deleted.'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(context, false), onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')), child: const Text('Cancel')),
FilledButton( FilledButton(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Delete')), child: const Text('Delete')),
], ],
), ),