import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/exceptions.dart'; import '../../data/models/message.dart'; import '../../providers/chat_provider.dart'; class ChatScreen extends ConsumerStatefulWidget { final int conversationId; const ChatScreen({super.key, required this.conversationId}); @override ConsumerState createState() => _ChatScreenState(); } class _ChatScreenState extends ConsumerState { final _controller = TextEditingController(); final _scrollController = ScrollController(); @override void dispose() { _controller.dispose(); _scrollController.dispose(); 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))); } } } @override Widget build(BuildContext context) { final messagesAsync = ref.watch(messagesProvider(widget.conversationId)); final isStreaming = ref.watch(isStreamingProvider(widget.conversationId)); // Scroll when messages change ref.listen(messagesProvider(widget.conversationId), (_, _) { _scrollToBottom(); }); return Scaffold( appBar: AppBar(title: const Text('Chat')), body: Column( children: [ Expanded( child: messagesAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Center(child: Text('Error: $e')), data: (messages) { if (messages.isEmpty) { return const Center( child: Text('Send a message to start.')); } return ListView.builder( controller: _scrollController, padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 12), itemCount: messages.length, itemBuilder: (context, i) => _MessageBubble(message: messages[i]), ); }, ), ), const Divider(height: 1), SafeArea( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), child: Row( children: [ Expanded( child: TextField( controller: _controller, decoration: const InputDecoration( hintText: 'Message...', border: OutlineInputBorder(), isDense: true, contentPadding: EdgeInsets.symmetric( horizontal: 12, vertical: 10), ), maxLines: 4, minLines: 1, textInputAction: TextInputAction.newline, enabled: !isStreaming, ), ), const SizedBox(width: 8), IconButton.filled( onPressed: isStreaming ? null : _send, icon: isStreaming ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Icon(Icons.send), ), ], ), ), ), ], ), ); } } class _MessageBubble extends StatelessWidget { final Message message; const _MessageBubble({required this.message}); @override Widget build(BuildContext context) { final isUser = message.role == MessageRole.user; final scheme = Theme.of(context).colorScheme; return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.8, ), margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: isUser ? scheme.primary : scheme.surfaceContainerHighest, borderRadius: BorderRadius.only( topLeft: const Radius.circular(16), topRight: const Radius.circular(16), bottomLeft: Radius.circular(isUser ? 16 : 4), bottomRight: Radius.circular(isUser ? 4 : 16), ), ), child: isUser ? Text( message.content, style: TextStyle(color: scheme.onPrimary), ) : MarkdownBody( data: message.content.isEmpty ? '...' : message.content, styleSheet: MarkdownStyleSheet.fromTheme(Theme.of(context)), ), ), ); } }