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,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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user