// 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 Function(String text) onSend; /// Called with a [MatrixFile] when the user picks an attachment. final Future 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 createState() => _MessageInputState(); } class _MessageInputState extends State { final _controller = TextEditingController(); final _focusNode = FocusNode(); 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(); _focusNode.dispose(); super.dispose(); } Future _submit() async { final text = _controller.text.trim(); if (text.isEmpty) return; _controller.clear(); await widget.onSend(text); // Return focus to the input so the user can keep typing. _focusNode.requestFocus(); } Future _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, focusNode: _focusNode, autofocus: true, 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), ), ], ), ); } }