Files
m8chat-app2/lib/features/chat/presentation/chat_screen.dart
help4bis f9c21fddc4 feat(chat): scroll-to-latest button + panic hold-progress ring (v1.10.0+14)
B6 scroll-to-latest: ScrollController on the timeline; a small
  arrow-down FAB fades in once scrolled >400px up and animates back
  to the newest message on tap (overlaid on the list, not the FAB slot)
B5 panic hold-ring: 700ms AnimationController drives a red progress
  ring around the panic icon; haptic + fire on completion, silent
  reset on early release; tap-hint and configured guard preserved

Panic ring verified in-browser (ring fills, alert fires to the room).
Scroll-to-latest verified with a deterministic widget test because the
CanvasKit surface rejects synthetic scroll input in headless Chromium.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 08:32:48 +10:00

841 lines
26 KiB
Dart

// Version: 1.6.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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends ConsumerState<ChatScreen> {
String? _replyToEventId;
String? _replyToSenderName;
String? _replyToBody;
String get _decodedRoomId => Uri.decodeComponent(widget.roomId);
void _showRoomOptions(BuildContext context, Room room) {
showModalBottomSheet<void>(
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 ConsumerStatefulWidget {
const _Timeline({required this.roomId, required this.onReply});
final String roomId;
final void Function(MessageModel) onReply;
@override
ConsumerState<_Timeline> createState() => _TimelineState();
}
class _TimelineState extends ConsumerState<_Timeline> {
final _scrollController = ScrollController();
/// The list is reverse:true, so the newest message sits at offset 0.
/// Show the jump-to-latest button once the user scrolls up past this.
static const _showButtonThreshold = 400.0;
bool _showScrollToLatest = false;
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (!_scrollController.hasClients) return;
final show = _scrollController.offset > _showButtonThreshold;
if (show != _showScrollToLatest) {
setState(() => _showScrollToLatest = show);
}
}
void _scrollToLatest() {
if (!_scrollController.hasClients) return;
_scrollController.animateTo(
0,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
@override
Widget build(BuildContext context) {
final timelineAsync = ref.watch(chatTimelineProvider(widget.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 Stack(
children: [
ListView.builder(
controller: _scrollController,
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: widget.roomId,
onReply: () => widget.onReply(message),
isFirstInGroup: isFirstInGroup,
isLastInGroup: isLastInGroup,
);
if (!showDay) return bubble;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_DayChip(timestamp: message.timestamp),
bubble,
],
);
},
),
Positioned(
right: 12,
bottom: 12,
child: AnimatedScale(
duration: const Duration(milliseconds: 150),
scale: _showScrollToLatest ? 1 : 0,
child: FloatingActionButton.small(
heroTag: 'scrollToLatest',
onPressed: _scrollToLatest,
tooltip: 'Jump to latest',
child: const Icon(Icons.arrow_downward),
),
),
),
],
);
},
);
}
}
/// 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<void>(
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<void>(
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<void>(
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<void> _leaveRoom() async {
final confirmed = await showDialog<bool>(
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();
}
},
);
}
}