Compare commits

...

7 Commits

Author SHA1 Message Date
bvandeusen 96e6b6466f Merge pull request 'fix(calendar): convert event times to local timezone on parse' (#15) from dev into main 2026-04-07 03:07:22 +00:00
bvandeusen d75d34ce8e fix(calendar): convert event times to local timezone on parse 2026-04-06 23:05:33 -04:00
bvandeusen 1c97f9dea5 Android app: Calendar, News, Knowledge, Voice, Nav restructure, bug fixes 2026-04-07 02:41:10 +00:00
bvandeusen c177bf0691 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>
2026-04-06 19:06:13 -04:00
bvandeusen 4ebc57d2e5 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>
2026-04-06 19:00:33 -04:00
bvandeusen 946b70ecc4 fix: use dialog context in event delete confirmation
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 <noreply@anthropic.com>
2026-04-06 18:35:41 -04:00
bvandeusen 6ea268bf58 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 <noreply@anthropic.com>
2026-04-06 18:33:04 -04:00
5 changed files with 33 additions and 59 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);
} }
} }
+2 -2
View File
@@ -28,9 +28,9 @@ class CalendarEvent {
factory CalendarEvent.fromJson(Map<String, dynamic> json) => CalendarEvent( factory CalendarEvent.fromJson(Map<String, dynamic> json) => CalendarEvent(
id: json['id'] as int, id: json['id'] as int,
title: json['title'] as String? ?? '', title: json['title'] as String? ?? '',
startDt: DateTime.parse(json['start_dt'] as String), startDt: DateTime.parse(json['start_dt'] as String).toLocal(),
endDt: json['end_dt'] != null endDt: json['end_dt'] != null
? DateTime.parse(json['end_dt'] as String) ? DateTime.parse(json['end_dt'] as String).toLocal()
: null, : null,
allDay: json['all_day'] as bool? ?? false, allDay: json['all_day'] as bool? ?? false,
description: json['description'] as String? ?? '', description: json['description'] as String? ?? '',
+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')),
], ],
), ),