// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04 // Full chat screen — timeline + input + typing indicators + read receipts // + long-press context menu (quick reactions, reply, react, copy, edit, // delete) + day separators + same-sender message grouping + room options. 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:intl/intl.dart'; import 'package:matrix/matrix.dart' show MatrixFile, Room; import '../../../app/theme.dart'; 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 '../../rooms/presentation/user_search_dialog.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 _showRoomOptions(BuildContext context, Room room) { showModalBottomSheet( context: context, isScrollControlled: true, builder: (_) => _RoomOptionsSheet(room: room), ); } 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=0', ), ), // Video call IconButton( icon: const Icon(Icons.videocam_outlined), tooltip: 'Video call', onPressed: () => context.push( '/calls/${Uri.encodeComponent(_decodedRoomId)}?video=1', ), ), IconButton( icon: const Icon(Icons.more_vert), tooltip: 'Room options', onPressed: room != null ? () => _showRoomOptions(context, room) : null, ), ], ), 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]; // List is reverse:true, so the visually-older neighbour is at // index+1 and the visually-newer neighbour is at index-1. final older = index < messages.length - 1 ? messages[index + 1] : null; final newer = index > 0 ? messages[index - 1] : null; const groupGap = Duration(minutes: 5); final isFirstInGroup = older == null || older.senderId != message.senderId || message.timestamp.difference(older.timestamp) > groupGap; final isLastInGroup = newer == null || newer.senderId != message.senderId || newer.timestamp.difference(message.timestamp) > groupGap; // A day chip sits above the first message of each local day. final showDay = older == null || !_sameLocalDay(message.timestamp, older.timestamp); final bubble = _MessageWithGestures( message: message, roomId: roomId, onReply: () => onReply(message), isFirstInGroup: isFirstInGroup, isLastInGroup: isLastInGroup, ); if (!showDay) return bubble; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _DayChip(timestamp: message.timestamp), bubble, ], ); }, ); }, ); } } /// True when two timestamps fall on the same day in the local timezone. bool _sameLocalDay(DateTime a, DateTime b) { final la = a.toLocal(); final lb = b.toLocal(); return la.year == lb.year && la.month == lb.month && la.day == lb.day; } /// Centred date pill shown above the first message of each local day. class _DayChip extends StatelessWidget { const _DayChip({required this.timestamp}); final DateTime timestamp; String _label() { final now = DateTime.now().toLocal(); final local = timestamp.toLocal(); final today = DateTime(now.year, now.month, now.day); final that = DateTime(local.year, local.month, local.day); final diff = today.difference(that).inDays; if (diff == 0) return 'Today'; if (diff == 1) return 'Yesterday'; if (local.year == now.year) return DateFormat('d MMM').format(local); return DateFormat('d MMM yyyy').format(local); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Center( child: Container( margin: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( color: theme.colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(12), ), child: Text( _label(), style: theme.textTheme.labelSmall?.copyWith( color: M8Colours.subtleText, fontWeight: FontWeight.w500, ), ), ), ); } } // --------------------------------------------------------------------------- // Long-press context menu // --------------------------------------------------------------------------- class _MessageWithGestures extends ConsumerWidget { const _MessageWithGestures({ required this.message, required this.roomId, required this.onReply, this.isFirstInGroup = true, this.isLastInGroup = true, }); final MessageModel message; final String roomId; final VoidCallback onReply; final bool isFirstInGroup; final bool isLastInGroup; @override Widget build(BuildContext context, WidgetRef ref) { return GestureDetector( onLongPress: () => _showContextMenu(context, ref), child: MessageBubble( message: message, isFirstInGroup: isFirstInGroup, isLastInGroup: isLastInGroup, ), ); } void _showContextMenu(BuildContext context, WidgetRef ref) { showModalBottomSheet( context: context, builder: (_) => _MessageContextMenu( message: message, roomId: roomId, onQuickReact: (emoji) { Navigator.pop(context); ref .read(sendReactionProvider.notifier) .react(roomId, message.eventId, emoji); }, 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.'))); }, onEdit: message.isMine && message.body != null ? () { Navigator.pop(context); _showEditDialog(context, ref); } : null, onDelete: message.isMine ? () async { Navigator.pop(context); await ref .read(deleteMessageProvider.notifier) .delete(roomId, message.eventId); } : null, ), ); } void _showEditDialog(BuildContext context, WidgetRef ref) { final controller = TextEditingController(text: message.body ?? ''); showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Edit message'), content: TextField( controller: controller, autofocus: true, maxLines: null, decoration: const InputDecoration(border: OutlineInputBorder()), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(), child: const Text('Cancel'), ), FilledButton( onPressed: () async { final newText = controller.text.trim(); Navigator.of(ctx).pop(); if (newText.isEmpty || newText == message.body) return; final error = await ref .read(editMessageProvider.notifier) .edit(roomId, message.eventId, newText); if (error != null && context.mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Edit failed: $error'))); } }, child: const Text('Save'), ), ], ), ); } 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), ), ), ), ); } } // --------------------------------------------------------------------------- // Room options sheet // --------------------------------------------------------------------------- class _RoomOptionsSheet extends ConsumerStatefulWidget { const _RoomOptionsSheet({required this.room}); final Room room; @override ConsumerState<_RoomOptionsSheet> createState() => _RoomOptionsSheetState(); } class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> { bool _leaving = false; Future _leaveRoom() async { final confirmed = await showDialog( context: context, builder: (_) => AlertDialog( title: const Text('Leave room?'), content: Text( 'You will leave "${widget.room.getLocalizedDisplayname()}". ' 'You can rejoin if you have an invite.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.of(context).pop(true), style: TextButton.styleFrom( foregroundColor: Theme.of(context).colorScheme.error, ), child: const Text('Leave'), ), ], ), ); if (confirmed != true || !mounted) return; setState(() => _leaving = true); try { await widget.room.leave(); if (mounted) { Navigator.of(context).pop(); // close sheet GoRouter.of(context).go('/rooms'); } } on Exception catch (e) { if (mounted) { setState(() => _leaving = false); ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Could not leave room: $e'))); } } } @override Widget build(BuildContext context) { final client = ref.watch(matrixClientProvider); final theme = Theme.of(context); final room = widget.room; final members = room.getParticipants(); final canInvite = room.canInvite; return SafeArea( child: ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(context).size.height * 0.75, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header Padding( padding: const EdgeInsets.fromLTRB(20, 16, 16, 4), child: Row( children: [ Expanded( child: Text( room.getLocalizedDisplayname(), style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), overflow: TextOverflow.ellipsis, ), ), IconButton( icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), ), ], ), ), // Members section Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 4), child: Text( 'Members (${members.length})', style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ), ), ), Flexible( child: ListView.builder( shrinkWrap: true, itemCount: members.length, itemBuilder: (context, index) { final member = members[index]; final name = member.displayName ?? member.id.matrixLocalpart; final avatarUrl = resolveMxcUrl(client, member.avatarUrl); final isMe = member.id == client.userID; return ListTile( dense: true, leading: MatrixAvatar( name: name, avatarUrl: avatarUrl, radius: 16, ), title: Text( isMe ? '$name (you)' : name, style: theme.textTheme.bodyMedium, ), subtitle: Text( member.id, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withAlpha(120), ), ), ); }, ), ), const Divider(), // Actions if (canInvite) ListTile( leading: const Icon(Icons.person_add_outlined), title: const Text('Invite someone'), onTap: () { Navigator.of(context).pop(); showUserSearchDialog(context); }, ), ListTile( leading: _leaving ? const SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ) : Icon(Icons.exit_to_app, color: theme.colorScheme.error), title: Text( 'Leave room', style: TextStyle(color: theme.colorScheme.error), ), enabled: !_leaving, onTap: _leaveRoom, ), ], ), ), ); } } class _MessageContextMenu extends StatelessWidget { const _MessageContextMenu({ required this.message, required this.roomId, required this.onQuickReact, required this.onReply, required this.onReact, required this.onCopy, this.onEdit, this.onDelete, }); /// Six one-tap presets shown across the top of the menu. static const _quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏']; final MessageModel message; final String roomId; final void Function(String emoji) onQuickReact; final VoidCallback onReply; final VoidCallback onReact; final VoidCallback onCopy; final VoidCallback? onEdit; final VoidCallback? onDelete; @override Widget build(BuildContext context) { return SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ // Quick-reaction row: one-tap presets + open the full picker. Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ for (final emoji in _quickReactions) InkWell( borderRadius: BorderRadius.circular(22), onTap: () => onQuickReact(emoji), child: SizedBox( width: 44, height: 44, child: Center( child: Text( emoji, style: const TextStyle(fontSize: 24), ), ), ), ), InkWell( borderRadius: BorderRadius.circular(22), onTap: onReact, child: SizedBox( width: 44, height: 44, child: Icon( Icons.add, color: Theme.of(context).colorScheme.onSurface, ), ), ), ], ), ), const Divider(height: 1), 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 (onEdit != null) ListTile( leading: const Icon(Icons.edit_outlined), title: const Text('Edit'), onTap: onEdit, ), 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(); } }, ); } }