Files
m8chat-app2/lib/features/chat/presentation/chat_screen.dart
help4bis b941cdfe4b refactor: /simplify — 22 fixes from 3-agent code review
Critical:
- Fix MXC URI resolution: all avatars/images now resolve mxc:// to HTTP
- Sync persistence: only write changed rooms, batch message upserts
- lastActivityAt uses room.lastEvent.originServerTs, not creation time

High:
- Shared MatrixAvatar widget replaces 6 duplicate implementations
- CallScreen decodes roomId before LiveKit JWT fetch
- Decline button actually dismisses incoming call overlay
- EventTypes constants replace raw string literals
- LiveKitService uses lazy auth reads, onDispose disconnects

Medium:
- CallController is keepAlive with timer/room cleanup
- authRepository is keepAlive (used from keepAlive notifier)
- StreamController not closed in stopListening (crash fix)
- Index on messages.roomId for query performance
- 400ms debounce on user search
- Static DateFormat in MessageBubble
- Hardcoded strings replaced with AppConfig refs
- Duplicate isDirectMessage field removed from RoomModel
- E2EE profile claim corrected to Phase 3

Shared utilities:
- lib/shared/widgets/matrix_avatar.dart
- lib/shared/utils/mxc_url.dart
- lib/shared/utils/room_preview.dart
- lib/shared/utils/matrix_id.dart

rawJson column removed (unused, caused main-thread jsonEncode)
Schema migrated to v2 with roomId index.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:19:22 +10:00

400 lines
12 KiB
Dart

// 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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends ConsumerState<ChatScreen> {
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<void>(
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<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),
),
),
),
);
}
}
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();
}
},
);
}
}