Compare commits

...

10 Commits

Author SHA1 Message Date
bvandeusen 5957551546 Merge pull request 'STT context, refresh improvements, calendar timezone fix' (#16) from dev into main 2026-04-07 21:52:20 +00:00
bvandeusen d2582f9111 feat: reduce resume cooldown to 30s; add pull-to-refresh to News, Calendar; refresh chat on resume 2026-04-07 17:43:46 -04:00
bvandeusen 36350d35b1 feat(stt): pass last assistant response as Whisper context to reduce mishearings 2026-04-07 09:57:07 -04:00
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
11 changed files with 85 additions and 73 deletions
+8 -18
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';
@@ -195,7 +193,7 @@ class _ShellState extends ConsumerState<_Shell> with WidgetsBindingObserver {
]; ];
// Minimum gap between app-resume refreshes to avoid hammering the server. // Minimum gap between app-resume refreshes to avoid hammering the server.
static const _resumeCooldown = Duration(minutes: 5); static const _resumeCooldown = Duration(seconds: 30);
DateTime? _lastResumeRefresh; DateTime? _lastResumeRefresh;
int? _prevTabIndex; int? _prevTabIndex;
@@ -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);
} }
} }
+7 -3
View File
@@ -40,16 +40,20 @@ class VoiceApi {
} }
/// POST WebM/Opus audio bytes and return the transcript string. /// POST WebM/Opus audio bytes and return the transcript string.
/// [context] is optional recent conversation text passed as initial_prompt
/// to Whisper, reducing mishearings of domain-specific words.
/// Returns empty string on empty or error response. /// Returns empty string on empty or error response.
Future<String> transcribe(Uint8List audioBytes) async { Future<String> transcribe(Uint8List audioBytes, {String? context}) async {
try { try {
final formData = FormData.fromMap({ final fields = <String, dynamic>{
'audio': MultipartFile.fromBytes( 'audio': MultipartFile.fromBytes(
audioBytes, audioBytes,
filename: 'audio.m4a', filename: 'audio.m4a',
contentType: DioMediaType('audio', 'mp4'), contentType: DioMediaType('audio', 'mp4'),
), ),
}); if (context != null && context.isNotEmpty) 'context': context,
};
final formData = FormData.fromMap(fields);
final response = await _dio.post( final response = await _dio.post(
'/api/voice/transcribe', '/api/voice/transcribe',
data: formData, data: formData,
+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? ?? '',
+2 -1
View File
@@ -7,6 +7,7 @@ class VoiceRepository {
const VoiceRepository(this._api); const VoiceRepository(this._api);
Future<VoiceStatus> checkStatus() => _api.checkStatus(); Future<VoiceStatus> checkStatus() => _api.checkStatus();
Future<String> transcribe(Uint8List audioBytes) => _api.transcribe(audioBytes); Future<String> transcribe(Uint8List audioBytes, {String? context}) =>
_api.transcribe(audioBytes, context: context);
Future<Uint8List> synthesise(String text) => _api.synthesise(text); Future<Uint8List> synthesise(String text) => _api.synthesise(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,
};
} }
+9 -2
View File
@@ -108,6 +108,10 @@ class VoiceNotifier extends Notifier<VoiceState> {
int _lastSeenLength = 0; int _lastSeenLength = 0;
bool _streamComplete = false; bool _streamComplete = false;
// Last complete assistant response — passed to Whisper as initial_prompt
// to reduce STT mishearings of domain-specific words.
String _lastAssistantContent = '';
// TTS playback queue // TTS playback queue
final _ttsQueue = Queue<Uint8List>(); final _ttsQueue = Queue<Uint8List>();
bool _ttsPlaying = false; bool _ttsPlaying = false;
@@ -205,6 +209,7 @@ class VoiceNotifier extends Notifier<VoiceState> {
_dispatchSentences(flush: isComplete); _dispatchSentences(flush: isComplete);
if (isComplete) { if (isComplete) {
_lastAssistantContent = fullContent;
_streamComplete = true; _streamComplete = true;
_checkRestartListening(); _checkRestartListening();
} }
@@ -278,8 +283,10 @@ class VoiceNotifier extends Notifier<VoiceState> {
if (!state.voiceModeActive) return; if (!state.voiceModeActive) return;
final transcript = final transcript = await ref.read(voiceRepositoryProvider).transcribe(
await ref.read(voiceRepositoryProvider).transcribe(bytes); bytes,
context: _lastAssistantContent.isNotEmpty ? _lastAssistantContent : null,
);
if (!state.voiceModeActive) return; if (!state.voiceModeActive) return;
+13 -5
View File
@@ -67,10 +67,13 @@ class CalendarScreen extends ConsumerWidget {
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: _AgendaList( child: RefreshIndicator(
events: onRefresh: () async => ref.invalidate(calendarProvider),
cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [], child: _AgendaList(
notifier: notifier, events:
cal.eventsByDay[dateOnly(cal.selectedDay)] ?? [],
notifier: notifier,
),
), ),
), ),
], ],
@@ -106,7 +109,12 @@ class _AgendaList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (events.isEmpty) { if (events.isEmpty) {
return const Center(child: Text('No events')); return ListView(
children: const [
SizedBox(height: 80),
Center(child: Text('No events')),
],
);
} }
return ListView.builder( return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
+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),
), ),
), ),
], ],
+16 -1
View File
@@ -16,12 +16,27 @@ class ChatScreen extends ConsumerStatefulWidget {
ConsumerState<ChatScreen> createState() => _ChatScreenState(); ConsumerState<ChatScreen> createState() => _ChatScreenState();
} }
class _ChatScreenState extends ConsumerState<ChatScreen> { class _ChatScreenState extends ConsumerState<ChatScreen>
with WidgetsBindingObserver {
final _controller = TextEditingController(); final _controller = TextEditingController();
final _scrollController = ScrollController(); final _scrollController = ScrollController();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.invalidate(messagesProvider(widget.conversationId));
}
}
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_controller.dispose(); _controller.dispose();
_scrollController.dispose(); _scrollController.dispose();
// Exit voice mode if the user navigates away mid-session. // Exit voice mode if the user navigates away mid-session.
@@ -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')),
], ],
), ),
+4 -1
View File
@@ -96,7 +96,9 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
), ),
const Divider(height: 1), const Divider(height: 1),
Expanded( Expanded(
child: ListView.builder( child: RefreshIndicator(
onRefresh: () async => ref.invalidate(newsProvider),
child: ListView.builder(
padding: padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 8), const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
itemCount: news.items.length + 1, itemCount: news.items.length + 1,
@@ -128,6 +130,7 @@ class _NewsScreenState extends ConsumerState<NewsScreen> {
); );
}, },
), ),
),
), ),
], ],
), ),