diff --git a/lib/screens/chat/chat_screen.dart b/lib/screens/chat/chat_screen.dart index 91efd5d..e526b31 100644 --- a/lib/screens/chat/chat_screen.dart +++ b/lib/screens/chat/chat_screen.dart @@ -2,8 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/exceptions.dart'; +import '../../data/models/message.dart'; import '../../providers/chat_provider.dart'; +import '../../providers/voice_provider.dart'; import '../../widgets/chat_message_bubble.dart'; +import '../../widgets/voice_mic_button.dart'; class ChatScreen extends ConsumerStatefulWidget { final int conversationId; @@ -21,6 +24,8 @@ class _ChatScreenState extends ConsumerState { void dispose() { _controller.dispose(); _scrollController.dispose(); + // Exit voice mode if the user navigates away mid-session. + ref.read(voiceProvider.notifier).exitVoiceMode(); super.dispose(); } @@ -58,16 +63,52 @@ class _ChatScreenState 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(messagesProvider(widget.conversationId).notifier) + .sendMessage(transcript); + }, + enableTts: true, + onError: (msg) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg))); + } + }, + ); + } + @override Widget build(BuildContext context) { final messagesAsync = ref.watch(messagesProvider(widget.conversationId)); final isStreaming = ref.watch(isStreamingProvider(widget.conversationId)); + final voiceState = ref.watch(voiceProvider); - // Scroll when messages change - ref.listen(messagesProvider(widget.conversationId), (_, _) { + // Scroll when messages change. + ref.listen(messagesProvider(widget.conversationId), (prev, next) { _scrollToBottom(); }); + // Feed streaming content to VoiceNotifier for TTS. + ref.listen(messagesProvider(widget.conversationId), (prev, next) { + if (!voiceState.voiceModeActive) return; + final messages = next.value; + if (messages == null || messages.isEmpty) return; + final last = messages.last; + if (last.role != MessageRole.assistant) return; + final isComplete = last.status != 'generating'; + ref + .read(voiceProvider.notifier) + .feedContent(last.content, isComplete: isComplete); + }); + final convTitle = ref .watch(conversationsProvider) .value @@ -85,7 +126,7 @@ class _ChatScreenState extends ConsumerState { child: messagesAsync.when( loading: () => const Center(child: CircularProgressIndicator()), - error: (_, _) => const Center( + error: (err, stack) => const Center( child: Text('Could not load messages.'), ), data: (messages) { @@ -104,6 +145,21 @@ class _ChatScreenState extends ConsumerState { }, ), ), + // 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), + ), + ), + ), const Divider(height: 1), SafeArea( child: Padding( @@ -114,23 +170,36 @@ class _ChatScreenState extends ConsumerState { Expanded( child: TextField( controller: _controller, - decoration: const InputDecoration( - hintText: 'Message...', - border: OutlineInputBorder(), + decoration: InputDecoration( + hintText: voiceState.voiceModeActive + ? 'Listening…' + : 'Message…', + 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), ), maxLines: MediaQuery.of(context).size.width >= 600 ? 2 : 4, minLines: 1, 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), IconButton.filled( - onPressed: isStreaming ? null : _send, + onPressed: (isStreaming || voiceState.voiceModeActive) + ? null + : _send, icon: isStreaming ? const SizedBox( width: 20, @@ -149,4 +218,3 @@ class _ChatScreenState extends ConsumerState { ); } } -