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:
2026-04-02 06:48:03 +10:00
parent 8f13c725a4
commit f12a7ac1fd
20 changed files with 2458 additions and 191 deletions

View File

@@ -1,6 +1,6 @@
// Version: 1.0.1 | Created: 2026-04-01
// Chat repository. Bridges Matrix SDK timeline to app domain models.
// Uses room.getTimeline() — timeline is async in matrix 0.33.0.
// Version: 1.1.0 | Created: 2026-04-01
// Chat repository — bridges Matrix SDK timeline to app domain models.
// Phase 2 additions: sendFile, sendReaction, redactEvent, reply support.
import 'package:matrix/matrix.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -23,39 +23,66 @@ class ChatRepository {
Room? _getRoom(String roomId) => _client.getRoomById(roomId);
/// Returns a stream of message lists for [roomId].
///
/// Opens the room's timeline once and then emits on every update.
/// The timeline object is closed when the stream subscription is cancelled.
Stream<List<MessageModel>> watchTimeline(String roomId) async* {
final room = _getRoom(roomId);
if (room == null) return;
final timeline = await room.getTimeline(
onUpdate: () {
// Handled by the stream controller below.
},
);
final timeline = await room.getTimeline();
// Emit the initial state.
yield _mapTimeline(timeline, room);
yield await _mapTimeline(timeline, room);
// Emit on subsequent sync events that affect this room.
await for (final update in _client.onSync.stream) {
final updatesThisRoom = update.rooms?.join?.containsKey(roomId) ?? false;
if (updatesThisRoom) {
yield _mapTimeline(timeline, room);
yield await _mapTimeline(timeline, room);
}
}
// Clean up timeline subscriptions when the stream is cancelled.
timeline.cancelSubscriptions();
}
/// Sends a plain text message to [roomId].
Future<void> sendTextMessage(String roomId, String text) async {
/// Sends a plain text message. Supports replies via [inReplyToEventId].
Future<void> sendTextMessage(
String roomId,
String text, {
String? inReplyToEventId,
}) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.sendTextEvent(text);
if (inReplyToEventId != null) {
// Find the original event in the timeline for the in-reply-to relation.
final timeline = await room.getTimeline();
final inReplyTo = timeline.events
.where((e) => e.eventId == inReplyToEventId)
.firstOrNull;
timeline.cancelSubscriptions();
await room.sendTextEvent(text, inReplyTo: inReplyTo);
} else {
await room.sendTextEvent(text);
}
}
/// Uploads [file] using Matrix media API, then sends an m.room.message.
Future<void> sendFile(String roomId, MatrixFile file) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.sendFileEvent(file);
}
/// Sends an emoji reaction to [eventId].
Future<void> sendReaction(String roomId, String eventId, String emoji) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.sendReaction(eventId, emoji);
}
/// Redacts (deletes) a message event.
Future<void> redactEvent(String roomId, String eventId) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.redactEvent(eventId);
}
/// Sends a read receipt for the latest event in [roomId].
@@ -67,27 +94,63 @@ class ChatRepository {
await room.setReadMarker(lastEventId, mRead: lastEventId);
}
/// Requests older messages be loaded (pagination).
/// Requests older messages (pagination).
Future<void> loadMoreMessages(String roomId) async {
final room = _getRoom(roomId);
if (room == null) return;
await room.requestHistory();
}
List<MessageModel> _mapTimeline(Timeline timeline, Room room) {
Future<List<MessageModel>> _mapTimeline(Timeline timeline, Room room) async {
final myUserId = _client.userID ?? '';
return timeline.events
.map((e) => _toModel(e, timeline, myUserId))
.toList()
.reversed
.toList();
final models = <MessageModel>[];
for (final e in timeline.events) {
models.add(await _toModel(e, timeline, myUserId));
}
return models.reversed.toList();
}
MessageModel _toModel(Event event, Timeline timeline, String myUserId) {
Future<MessageModel> _toModel(
Event event,
Timeline timeline,
String myUserId,
) async {
final senderProfile = event.room.unsafeGetUserFromMemoryOrFallback(
event.senderId,
);
// Resolve mxc:// to an authenticated HTTP URL for display.
final mxcUrl = _extractMxcUrl(event);
String? resolvedMediaUrl;
if (mxcUrl != null) {
try {
final mxcUri = Uri.parse(mxcUrl);
final httpUri = await mxcUri.getDownloadUri(_client);
resolvedMediaUrl = httpUri.toString();
} on Exception {
// Leave as null — the bubble will show a broken image indicator.
}
}
// Build reactions map: emoji → [senderId, ...]
final reactionEvents = event.aggregatedEvents(
timeline,
RelationshipTypes.reaction,
);
final reactions = <String, List<String>>{};
for (final r in reactionEvents) {
final emoji =
r.content.tryGet<Map<String, dynamic>>('m.relates_to')?['key']
as String? ??
r.content['key'] as String?;
if (emoji != null) {
reactions.putIfAbsent(emoji, () => []).add(r.senderId);
}
}
// Read receipts: user IDs that have a receipt pointing to this event.
final readBy = event.receipts.map((r) => r.user.id).toList();
return MessageModel(
eventId: event.eventId,
roomId: event.roomId ?? '',
@@ -98,10 +161,13 @@ class ChatRepository {
timestamp: event.originServerTs,
type: _messageType(event),
body: event.redacted ? null : event.body,
mxcUrl: _extractMxcUrl(event),
mediaUrl: resolvedMediaUrl,
mxcUrl: mxcUrl,
inReplyToEventId: event.relationshipEventId,
isMine: event.senderId == myUserId,
isEdited: event.hasAggregatedEvents(timeline, RelationshipTypes.edit),
reactions: reactions,
readByUserIds: readBy,
);
}

View File

@@ -1,6 +1,7 @@
// Version: 1.0.0 | Created: 2026-04-01
// Riverpod providers for chat timeline.
// Version: 1.1.0 | Created: 2026-04-01
// Riverpod providers for chat timeline, send, upload, react, reply.
import 'package:matrix/matrix.dart' show MatrixFile;
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../data/chat_repository.dart';
@@ -16,16 +17,83 @@ Stream<List<MessageModel>> chatTimeline(Ref ref, String roomId) {
}
/// Sends a text message. Returns an error string on failure, null on success.
/// Also handles sending replies when [inReplyToEventId] is set.
@riverpod
class SendMessage extends _$SendMessage {
@override
bool build() => false; // isSending
Future<String?> send(String roomId, String text) async {
Future<String?> send(
String roomId,
String text, {
String? inReplyToEventId,
}) async {
if (text.trim().isEmpty) return null;
state = true;
try {
await ref.read(chatRepositoryProvider).sendTextMessage(roomId, text);
await ref
.read(chatRepositoryProvider)
.sendTextMessage(roomId, text, inReplyToEventId: inReplyToEventId);
return null;
} on Exception catch (e) {
return e.toString();
} finally {
state = false;
}
}
}
/// Uploads a file and sends it as a room message.
/// State: null = idle, empty string = uploading, non-empty = error message.
@riverpod
class UploadFile extends _$UploadFile {
@override
String? build() => null; // null = idle
Future<void> upload(String roomId, MatrixFile file) async {
state = ''; // uploading
try {
await ref.read(chatRepositoryProvider).sendFile(roomId, file);
state = null; // success — back to idle
} on Exception catch (e) {
state = e.toString(); // error
}
}
void clearError() => state = null;
}
/// Sends an emoji reaction to [eventId].
@riverpod
class SendReaction extends _$SendReaction {
@override
bool build() => false;
Future<String?> react(String roomId, String eventId, String emoji) async {
state = true;
try {
await ref
.read(chatRepositoryProvider)
.sendReaction(roomId, eventId, emoji);
return null;
} on Exception catch (e) {
return e.toString();
} finally {
state = false;
}
}
}
/// Deletes (redacts) a message by eventId.
@riverpod
class DeleteMessage extends _$DeleteMessage {
@override
bool build() => false;
Future<String?> delete(String roomId, String eventId) async {
state = true;
try {
await ref.read(chatRepositoryProvider).redactEvent(roomId, eventId);
return null;
} on Exception catch (e) {
return e.toString();

View File

@@ -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();
}
},
);
}
}

View File

@@ -1,18 +1,51 @@
// Version: 1.0.0 | Created: 2026-04-01
// Message input bar. Text field + send button.
// Version: 1.1.0 | Created: 2026-04-01
// Message input bar — text, send, attach file, reply quote.
// File picker enabled in Phase 2 via file_picker ^8.0.0.
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart' show MatrixFile;
/// Describes a pending reply — shown as a quote above the input field.
class ReplyTo {
const ReplyTo({
required this.eventId,
required this.senderDisplayName,
required this.body,
});
final String eventId;
final String senderDisplayName;
final String body;
}
class MessageInput extends StatefulWidget {
const MessageInput({
super.key,
required this.onSend,
required this.isSending,
this.replyTo,
this.onCancelReply,
this.onAttach,
});
/// Called with text content when the user sends a plain text message.
final Future<void> Function(String text) onSend;
/// Called with a [MatrixFile] when the user picks an attachment.
final Future<void> Function(MatrixFile file)? onAttach;
/// Whether a send/upload operation is in progress.
final bool isSending;
/// If non-null, a reply quote is shown above the input.
final ReplyTo? replyTo;
/// Called when the user cancels the pending reply.
final VoidCallback? onCancelReply;
@override
State<MessageInput> createState() => _MessageInputState();
}
@@ -20,15 +53,14 @@ class MessageInput extends StatefulWidget {
class _MessageInputState extends State<MessageInput> {
final _controller = TextEditingController();
bool _hasText = false;
bool _isPickingFile = false;
@override
void initState() {
super.initState();
_controller.addListener(() {
final hasText = _controller.text.trim().isNotEmpty;
if (hasText != _hasText) {
setState(() => _hasText = hasText);
}
if (hasText != _hasText) setState(() => _hasText = hasText);
});
}
@@ -45,12 +77,62 @@ class _MessageInputState extends State<MessageInput> {
await widget.onSend(text);
}
Future<void> _pickFile() async {
if (_isPickingFile) return;
setState(() => _isPickingFile = true);
try {
final result = await FilePicker.platform.pickFiles(
withData: true, // required on web — reads bytes immediately
);
if (result == null || result.files.isEmpty) return;
final picked = result.files.first;
final Uint8List? bytes = picked.bytes;
if (bytes == null || bytes.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not read file data.')),
);
}
return;
}
final matrixFile = MatrixFile(
bytes: bytes,
name: picked.name,
mimeType: picked.extension != null
? _mimeFromExtension(picked.extension!)
: null,
);
await widget.onAttach?.call(matrixFile);
} finally {
if (mounted) setState(() => _isPickingFile = false);
}
}
String? _mimeFromExtension(String ext) {
return switch (ext.toLowerCase()) {
'jpg' || 'jpeg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'mp4' => 'video/mp4',
'mov' => 'video/quicktime',
'mp3' => 'audio/mpeg',
'ogg' => 'audio/ogg',
'pdf' => 'application/pdf',
_ => null,
};
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 12),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
@@ -59,53 +141,88 @@ class _MessageInputState extends State<MessageInput> {
),
child: SafeArea(
top: false,
child: Row(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.add),
tooltip: 'Attach file (Phase 2)',
onPressed: null, // Phase 2
color: theme.colorScheme.onSurface.withAlpha(153),
),
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Message',
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
),
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submit(),
maxLines: null,
keyboardType: TextInputType.multiline,
// Reply quote strip
if (widget.replyTo != null)
_ReplyQuote(
replyTo: widget.replyTo!,
onCancel: widget.onCancelReply ?? () {},
),
),
const SizedBox(width: 8),
AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: widget.isSending
? const SizedBox(
width: 40,
height: 40,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2.5),
// Input row
Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Attach button
if (widget.onAttach != null)
IconButton(
icon: _isPickingFile
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.add),
tooltip: 'Attach file',
onPressed: (_isPickingFile || widget.isSending)
? null
: _pickFile,
color: theme.colorScheme.onSurface.withAlpha(153),
)
else
const SizedBox(width: 8),
// Text field
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: 'Message',
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
),
)
: IconButton(
icon: const Icon(Icons.send_rounded),
onPressed: _hasText ? _submit : null,
color: _hasText
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withAlpha(77),
tooltip: 'Send message',
textInputAction: TextInputAction.send,
onSubmitted: (_) => _submit(),
maxLines: null,
keyboardType: TextInputType.multiline,
),
),
const SizedBox(width: 8),
// Send button
AnimatedSwitcher(
duration: const Duration(milliseconds: 150),
child: widget.isSending
? const SizedBox(
width: 40,
height: 40,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
),
),
),
)
: IconButton(
icon: const Icon(Icons.send_rounded),
onPressed: _hasText ? _submit : null,
color: _hasText
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withAlpha(77),
tooltip: 'Send message',
),
),
],
),
),
],
),
@@ -113,3 +230,63 @@ class _MessageInputState extends State<MessageInput> {
);
}
}
// ---------------------------------------------------------------------------
// Reply quote strip
// ---------------------------------------------------------------------------
class _ReplyQuote extends StatelessWidget {
const _ReplyQuote({required this.replyTo, required this.onCancel});
final ReplyTo replyTo;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
border: Border(
top: BorderSide(color: theme.colorScheme.outline.withAlpha(51)),
left: BorderSide(color: theme.colorScheme.primary, width: 3),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
replyTo.senderDisplayName,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
replyTo.body,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withAlpha(153),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: onCancel,
tooltip: 'Cancel reply',
color: theme.colorScheme.onSurface.withAlpha(153),
),
],
),
);
}
}