// Version: 1.2.0 | Created: 2026-04-01 | Updated: 2026-04-02 // Full chat screen — timeline + input + typing indicators + read receipts // + long-press context menu (reply, react, copy, delete). import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:matrix/matrix.dart' show MatrixFile; import '../../../core/network/matrix_client.dart'; import '../../../shared/utils/matrix_id.dart'; import '../../../shared/utils/mxc_url.dart'; import '../../../shared/widgets/matrix_avatar.dart'; import '../domain/message_model.dart'; import 'chat_controller.dart'; import 'message_bubble.dart'; import 'message_input.dart'; class ChatScreen extends ConsumerStatefulWidget { const ChatScreen({super.key, required this.roomId}); final String roomId; @override ConsumerState createState() => _ChatScreenState(); } class _ChatScreenState extends ConsumerState { String? _replyToEventId; String? _replyToSenderName; String? _replyToBody; String get _decodedRoomId => Uri.decodeComponent(widget.roomId); void _setReply(MessageModel message) { setState(() { _replyToEventId = message.eventId; _replyToSenderName = message.senderDisplayName; _replyToBody = message.body ?? ''; }); } void _clearReply() { setState(() { _replyToEventId = null; _replyToSenderName = null; _replyToBody = null; }); } @override Widget build(BuildContext context) { final client = ref.watch(matrixClientProvider); final room = client.getRoomById(_decodedRoomId); final roomName = room?.getLocalizedDisplayname() ?? 'Chat'; final roomAvatar = resolveMxcUrl(client, room?.avatar); return Scaffold( appBar: AppBar( titleSpacing: 0, title: Row( children: [ MatrixAvatar(name: roomName, avatarUrl: roomAvatar, radius: 18), const SizedBox(width: 10), Flexible(child: Text(roomName, overflow: TextOverflow.ellipsis)), ], ), actions: [ // Voice call IconButton( icon: const Icon(Icons.call), tooltip: 'Voice call', onPressed: () => context.push('/calls/${Uri.encodeComponent(_decodedRoomId)}'), ), // Video call IconButton( icon: const Icon(Icons.videocam_outlined), tooltip: 'Video call', onPressed: () => context.push('/calls/${Uri.encodeComponent(_decodedRoomId)}'), ), IconButton( icon: const Icon(Icons.more_vert), tooltip: 'Room options', onPressed: () {}, ), ], ), body: Column( children: [ Expanded( child: _Timeline(roomId: _decodedRoomId, onReply: _setReply), ), _TypingIndicator(roomId: _decodedRoomId), _Input( roomId: _decodedRoomId, replyToEventId: _replyToEventId, replyToSenderName: _replyToSenderName, replyToBody: _replyToBody, onCancelReply: _clearReply, ), ], ), ); } } // --------------------------------------------------------------------------- // Timeline // --------------------------------------------------------------------------- class _Timeline extends ConsumerWidget { const _Timeline({required this.roomId, required this.onReply}); final String roomId; final void Function(MessageModel) onReply; @override Widget build(BuildContext context, WidgetRef ref) { final timelineAsync = ref.watch(chatTimelineProvider(roomId)); return timelineAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (error, _) => Center( child: Padding( padding: const EdgeInsets.all(24), child: Text('Could not load messages: $error'), ), ), data: (messages) { if (messages.isEmpty) { return Center( child: Text( 'No messages yet. Say hello!', style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: Theme.of(context).colorScheme.onSurface.withAlpha(102), ), ), ); } return ListView.builder( reverse: true, padding: const EdgeInsets.symmetric(vertical: 8), itemCount: messages.length, itemBuilder: (context, index) { final message = messages[index]; return _MessageWithGestures( message: message, roomId: roomId, onReply: () => onReply(message), ); }, ); }, ); } } // --------------------------------------------------------------------------- // Long-press context menu // --------------------------------------------------------------------------- class _MessageWithGestures extends ConsumerWidget { const _MessageWithGestures({ required this.message, required this.roomId, required this.onReply, }); final MessageModel message; final String roomId; final VoidCallback onReply; @override Widget build(BuildContext context, WidgetRef ref) { return GestureDetector( onLongPress: () => _showContextMenu(context, ref), child: MessageBubble(message: message), ); } void _showContextMenu(BuildContext context, WidgetRef ref) { showModalBottomSheet( context: context, builder: (_) => _MessageContextMenu( message: message, roomId: roomId, onReply: () { Navigator.pop(context); onReply(); }, onReact: () { Navigator.pop(context); _showEmojiPicker(context, ref); }, onCopy: () { Clipboard.setData(ClipboardData(text: message.body ?? '')); Navigator.pop(context); ScaffoldMessenger.of( context, ).showSnackBar(const SnackBar(content: Text('Message copied.'))); }, onDelete: message.isMine ? () async { Navigator.pop(context); await ref .read(deleteMessageProvider.notifier) .delete(roomId, message.eventId); } : null, ), ); } void _showEmojiPicker(BuildContext context, WidgetRef ref) { showModalBottomSheet( context: context, builder: (_) => SizedBox( height: 320, child: EmojiPicker( onEmojiSelected: (_, emoji) async { Navigator.pop(context); await ref .read(sendReactionProvider.notifier) .react(roomId, message.eventId, emoji.emoji); }, config: const Config( emojiViewConfig: EmojiViewConfig(columns: 8, emojiSizeMax: 28), ), ), ), ); } } class _MessageContextMenu extends StatelessWidget { const _MessageContextMenu({ required this.message, required this.roomId, required this.onReply, required this.onReact, required this.onCopy, this.onDelete, }); final MessageModel message; final String roomId; final VoidCallback onReply; final VoidCallback onReact; final VoidCallback onCopy; final VoidCallback? onDelete; @override Widget build(BuildContext context) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.reply), title: const Text('Reply'), onTap: onReply, ), ListTile( leading: const Icon(Icons.add_reaction_outlined), title: const Text('React'), onTap: onReact, ), if (message.body != null) ListTile( leading: const Icon(Icons.copy), title: const Text('Copy text'), onTap: onCopy, ), if (onDelete != null) ListTile( leading: const Icon(Icons.delete_outline, color: Colors.red), title: const Text('Delete', style: TextStyle(color: Colors.red)), onTap: onDelete, ), ], ), ); } } // --------------------------------------------------------------------------- // Typing indicator // --------------------------------------------------------------------------- class _TypingIndicator extends ConsumerWidget { const _TypingIndicator({required this.roomId}); final String roomId; @override Widget build(BuildContext context, WidgetRef ref) { final client = ref.watch(matrixClientProvider); final room = client.getRoomById(roomId); if (room == null) return const SizedBox.shrink(); // typingUsers returns Users currently typing (excluding self). final typing = room.typingUsers .where((u) => u.id != client.userID) .map((u) => u.displayName ?? u.id.matrixLocalpart) .toList(); if (typing.isEmpty) return const SizedBox(height: 4); final label = typing.length == 1 ? '${typing.first} is typing…' : '${typing.take(2).join(', ')} are typing…'; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Row( children: [ Text( label, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurface.withAlpha(153), fontStyle: FontStyle.italic, ), ), ], ), ); } } // --------------------------------------------------------------------------- // Input wrapper // --------------------------------------------------------------------------- class _Input extends ConsumerWidget { const _Input({ required this.roomId, this.replyToEventId, this.replyToSenderName, this.replyToBody, this.onCancelReply, }); final String roomId; final String? replyToEventId; final String? replyToSenderName; final String? replyToBody; final VoidCallback? onCancelReply; @override Widget build(BuildContext context, WidgetRef ref) { final isSending = ref.watch(sendMessageProvider); final uploadState = ref.watch(uploadFileProvider); final isUploading = uploadState == ''; return MessageInput( isSending: isSending || isUploading, replyTo: (replyToEventId != null && replyToSenderName != null) ? ReplyTo( eventId: replyToEventId!, senderDisplayName: replyToSenderName!, body: replyToBody ?? '', ) : null, onCancelReply: onCancelReply, onSend: (text) async { final error = await ref .read(sendMessageProvider.notifier) .send(roomId, text, inReplyToEventId: replyToEventId); onCancelReply?.call(); if (error != null && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Failed to send: $error'), backgroundColor: Theme.of(context).colorScheme.error, ), ); } }, onAttach: (MatrixFile file) async { await ref.read(uploadFileProvider.notifier).upload(roomId, file); final err = ref.read(uploadFileProvider); if (err != null && err.isNotEmpty && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Upload failed: $err'), backgroundColor: Theme.of(context).colorScheme.error, ), ); ref.read(uploadFileProvider.notifier).clearError(); } }, ); } }