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; const ChatScreen({super.key, required this.conversationId}); @override ConsumerState createState() => _ChatScreenState(); } class _ChatScreenState extends ConsumerState with WidgetsBindingObserver { final _controller = TextEditingController(); final _scrollController = ScrollController(); bool _refreshing = false; Future _refreshMessages() async { if (_refreshing) return; setState(() => _refreshing = true); try { await ref .read(messagesProvider(widget.conversationId).notifier) .refresh(); } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Could not refresh messages.')), ); } } finally { if (mounted) setState(() => _refreshing = false); } } @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); } @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.resumed) { ref.read(messagesProvider(widget.conversationId).notifier).refresh(); } } @override void dispose() { WidgetsBinding.instance.removeObserver(this); _controller.dispose(); _scrollController.dispose(); // Exit voice mode if the user navigates away mid-session. ref.read(voiceProvider.notifier).exitVoiceMode(); super.dispose(); } void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } }); } Future _send() async { final text = _controller.text.trim(); if (text.isEmpty) return; _controller.clear(); try { await ref .read(messagesProvider(widget.conversationId).notifier) .sendMessage(text); } on AppException catch (e) { if (mounted) { ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(e.message))); } } catch (_) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Failed to send message.')), ); } } } 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 streamingStatus = ref.watch(streamingStatusProvider(widget.conversationId)); final voiceState = ref.watch(voiceProvider); // 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 ?.where((c) => c.id == widget.conversationId) .firstOrNull ?.title; return Scaffold( appBar: AppBar( title: Text(convTitle?.isNotEmpty == true ? convTitle! : 'Chat'), actions: [ IconButton( tooltip: 'Refresh', onPressed: _refreshing ? null : _refreshMessages, icon: _refreshing ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.refresh), ), ], ), body: Column( children: [ Expanded( child: messagesAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (err, stack) => const Center( child: Text('Could not load messages.'), ), data: (messages) { return RefreshIndicator( onRefresh: _refreshMessages, child: messages.isEmpty ? ListView( // Needs to be scrollable for RefreshIndicator to // fire on empty state — plain Center won't work. physics: const AlwaysScrollableScrollPhysics(), children: const [ SizedBox(height: 240), Center(child: Text('Send a message to start.')), ], ) : ListView.builder( controller: _scrollController, physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 12), itemCount: messages.length, itemBuilder: (context, i) => ChatMessageBubble( message: messages[i], streamingStatus: (i == messages.length - 1 && messages[i].status == 'generating') ? streamingStatus : '', ), ), ); }, ), ), // 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( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Row( children: [ Expanded( child: TextField( controller: _controller, decoration: InputDecoration( hintText: voiceState.voiceModeActive ? 'Listening…' : 'Message…', hintStyle: voiceState.voiceModeActive ? const TextStyle(fontStyle: FontStyle.italic) : null, border: const OutlineInputBorder(), isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10), ), maxLines: MediaQuery.of(context).size.width >= 600 ? 2 : 4, minLines: 1, textInputAction: TextInputAction.newline, 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 || voiceState.voiceModeActive) ? null : _send, icon: isStreaming ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.send), ), ], ), ), ), ], ), ); } }