From 1b08b2fd9ea234bf10012ca000b2ba863f431fca Mon Sep 17 00:00:00 2001 From: bvandeusen Date: Sun, 5 Apr 2026 14:47:38 -0400 Subject: [PATCH] feat(voice): integrate voice mode into Briefing screen Co-Authored-By: Claude Sonnet 4.6 --- lib/screens/briefing/briefing_screen.dart | 81 ++++++++++++++++++++--- 1 file changed, 73 insertions(+), 8 deletions(-) diff --git a/lib/screens/briefing/briefing_screen.dart b/lib/screens/briefing/briefing_screen.dart index 36bcf3b..4ff546f 100644 --- a/lib/screens/briefing/briefing_screen.dart +++ b/lib/screens/briefing/briefing_screen.dart @@ -11,6 +11,8 @@ import '../../widgets/chat_message_bubble.dart'; import '../../widgets/weather_card.dart'; import '../../widgets/news_card.dart'; import 'briefing_history_screen.dart'; +import '../../providers/voice_provider.dart'; +import '../../widgets/voice_mic_button.dart'; class BriefingScreen extends ConsumerStatefulWidget { const BriefingScreen({super.key}); @@ -55,6 +57,7 @@ class _BriefingScreenState extends ConsumerState WidgetsBinding.instance.removeObserver(this); _controller.dispose(); _scrollController.dispose(); + ref.read(voiceProvider.notifier).exitVoiceMode(); super.dispose(); } @@ -106,6 +109,26 @@ class _BriefingScreenState extends ConsumerState } } + Future _toggleVoiceMode() async { + final voice = ref.read(voiceProvider); + if (voice.voiceModeActive) { + ref.read(voiceProvider.notifier).exitVoiceMode(); + return; + } + await ref.read(voiceProvider.notifier).enterVoiceMode( + onTranscript: (transcript) async { + await ref.read(briefingProvider.notifier).sendReply(transcript); + }, + enableTts: true, + onError: (msg) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); + } + }, + ); + } + Future _refresh() async { setState(() => _refreshing = true); try { @@ -125,10 +148,24 @@ class _BriefingScreenState extends ConsumerState Widget build(BuildContext context) { final briefingAsync = ref.watch(briefingProvider); final isStreaming = ref.watch(isBriefingStreamingProvider); + final voiceState = ref.watch(voiceProvider); final scheme = Theme.of(context).colorScheme; // Scroll to bottom when messages change - ref.listen(briefingProvider, (_, _) => _scrollToBottom()); + ref.listen(briefingProvider, (prev, next) => _scrollToBottom()); + + // Feed streaming assistant content to VoiceNotifier for TTS. + ref.listen(briefingProvider, (prev, next) { + if (!voiceState.voiceModeActive) return; + final conv = next.value; + if (conv == null || conv.messages.isEmpty) return; + final last = conv.messages.last; + if (last.role != MessageRole.assistant) return; + final isComplete = last.status != 'generating'; + ref + .read(voiceProvider.notifier) + .feedContent(last.content, isComplete: isComplete); + }); return Scaffold( appBar: AppBar( @@ -179,7 +216,7 @@ class _BriefingScreenState extends ConsumerState ), body: briefingAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (_, _) => Center( + error: (err, stack) => Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -248,6 +285,21 @@ class _BriefingScreenState extends ConsumerState color: scheme.primary, ), + // Voice mode banner + if (voiceState.voiceModeActive) + Container( + width: double.infinity, + color: const Color(0xFFEF4444).withValues(alpha: 0.12), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 6), + child: const Text( + '🎤 Listening… tap mic to exit voice mode', + style: TextStyle( + fontSize: 12, + color: Color(0xFFF87171), + ), + ), + ), // Reply bar const Divider(height: 1), SafeArea( @@ -258,22 +310,35 @@ class _BriefingScreenState extends ConsumerState Expanded( child: TextField( controller: _controller, - decoration: const InputDecoration( - hintText: 'Reply to your briefing…', - border: OutlineInputBorder(), + decoration: InputDecoration( + hintText: voiceState.voiceModeActive + ? 'Listening…' + : 'Reply to your briefing…', + hintStyle: voiceState.voiceModeActive + ? const TextStyle(fontStyle: FontStyle.italic) + : null, + border: const OutlineInputBorder(), isDense: true, - contentPadding: EdgeInsets.symmetric( + contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), ), minLines: 1, maxLines: 4, textInputAction: TextInputAction.newline, - enabled: !isStreaming, + enabled: !isStreaming && !voiceState.voiceModeActive, ), ), const SizedBox(width: 8), + VoiceMicButton( + mode: voiceState.mode, + voiceModeActive: voiceState.voiceModeActive, + onTap: _toggleVoiceMode, + ), + const SizedBox(width: 6), _GradientSendButton( - onPressed: isStreaming ? null : _sendReply, + onPressed: (isStreaming || voiceState.voiceModeActive) + ? null + : _sendReply, isStreaming: isStreaming, ), ],