Merge pull request 'STT context, refresh improvements, calendar timezone fix' (#16) from dev into main

This commit was merged in pull request #16.
This commit is contained in:
2026-04-07 21:52:20 +00:00
7 changed files with 52 additions and 14 deletions
+1 -1
View File
@@ -193,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;
+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 -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);
} }
+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),
+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.
+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> {
); );
}, },
), ),
),
), ),
], ],
), ),