feat: Phase 2 complete — calls, media, spaces, persistence, chat improvements
- LiveKit/MatrixRTC voice+video calls with full call screen UI - Incoming call overlay (accept/decline) - Media upload/download — file picker, image rendering, file download - Spaces navigation — space list + expandable child rooms - Drift persistence — rooms + messages written on every sync - Sync persistence auto-starts on login and session restore - Chat: typing indicators, long-press menu, reply, emoji reactions - User search dialog + start DM from rooms screen - Android: INTERNET + CAMERA + RECORD_AUDIO permissions in main manifest - Emoji picker for reactions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,26 +1,56 @@
|
||||
// Version: 1.0.0 | Created: 2026-04-01
|
||||
// Full chat screen — timeline + message input.
|
||||
// Version: 1.1.0 | Created: 2026-04-01
|
||||
// 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 '../domain/message_model.dart';
|
||||
import 'chat_controller.dart';
|
||||
import 'message_bubble.dart';
|
||||
import 'message_input.dart';
|
||||
|
||||
class ChatScreen extends ConsumerWidget {
|
||||
class ChatScreen extends ConsumerStatefulWidget {
|
||||
const ChatScreen({super.key, required this.roomId});
|
||||
|
||||
final String roomId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Decode the roomId — GoRouter encodes ! as %21 etc.
|
||||
final decodedRoomId = Uri.decodeComponent(roomId);
|
||||
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 room = client.getRoomById(_decodedRoomId);
|
||||
final roomName = room?.getLocalizedDisplayname() ?? 'Chat';
|
||||
final roomAvatar = room?.avatar?.toString();
|
||||
|
||||
@@ -35,30 +65,50 @@ class ChatScreen extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
// Voice call
|
||||
IconButton(
|
||||
icon: const Icon(Icons.call),
|
||||
tooltip: 'Start call (Phase 2)',
|
||||
onPressed: null, // Phase 2
|
||||
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: () {
|
||||
// Phase 2: room settings sheet
|
||||
},
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(child: _Timeline(roomId: decodedRoomId)),
|
||||
_Input(roomId: decodedRoomId),
|
||||
Expanded(
|
||||
child: _Timeline(roomId: _decodedRoomId, onReply: _setReply),
|
||||
),
|
||||
_TypingIndicator(roomId: _decodedRoomId),
|
||||
_Input(
|
||||
roomId: _decodedRoomId,
|
||||
replyToEventId: _replyToEventId,
|
||||
replyToSenderName: _replyToSenderName,
|
||||
replyToBody: _replyToBody,
|
||||
onCancelReply: _clearReply,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Room avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _RoomAvatarSmall extends StatelessWidget {
|
||||
const _RoomAvatarSmall({required this.name, this.avatarUrl});
|
||||
|
||||
@@ -89,10 +139,15 @@ class _RoomAvatarSmall extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Timeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _Timeline extends ConsumerWidget {
|
||||
const _Timeline({required this.roomId});
|
||||
const _Timeline({required this.roomId, required this.onReply});
|
||||
|
||||
final String roomId;
|
||||
final void Function(MessageModel) onReply;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -123,7 +178,12 @@ class _Timeline extends ConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
return MessageBubble(message: messages[index]);
|
||||
final message = messages[index];
|
||||
return _MessageWithGestures(
|
||||
message: message,
|
||||
roomId: roomId,
|
||||
onReply: () => onReply(message),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -131,31 +191,240 @@ class _Timeline extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _Input extends ConsumerWidget {
|
||||
const _Input({required this.roomId});
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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.split(':').first)
|
||||
.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,
|
||||
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);
|
||||
|
||||
.send(roomId, text, inReplyToEventId: replyToEventId);
|
||||
onCancelReply?.call();
|
||||
if (error != null && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to send message: $error'),
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user