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>
400 lines
12 KiB
Dart
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();
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|