Files
m8chat-app2/lib/features/chat/presentation/message_input.dart
help4bis f12a7ac1fd 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>
2026-04-02 06:48:03 +10:00

293 lines
8.8 KiB
Dart

// 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();
}
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);
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _submit() async {
final text = _controller.text.trim();
if (text.isEmpty) return;
_controller.clear();
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(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
top: BorderSide(color: theme.colorScheme.outline.withAlpha(51)),
),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Reply quote strip
if (widget.replyTo != null)
_ReplyQuote(
replyTo: widget.replyTo!,
onCancel: widget.onCancelReply ?? () {},
),
// 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,
),
),
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',
),
),
],
),
),
],
),
),
);
}
}
// ---------------------------------------------------------------------------
// 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),
),
],
),
);
}
}