import 'dart:async'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:image_picker/image_picker.dart'; import 'package:matrix/matrix.dart'; import '../matrix/client.dart'; import '../theme.dart'; import 'message_extras.dart'; /// Where a message goes: the room's main timeline, or a thread in it. class SendTarget { const SendTarget({this.threadRootId, this.threadLastEventId}); /// The thread's root, when sending into a thread. final String? threadRootId; /// The newest event in the thread: what a client that doesn't know /// threads shows the message as a reply to. final String? Function()? threadLastEventId; } /// The message box: text, attachments, typing notifications, and the /// message being replied to, shown above it until sent or dismissed. class Composer extends StatefulWidget { const Composer({ super.key, required this.room, this.target = const SendTarget(), this.replyTo, this.onCancelReply, this.focus, }); final Room room; final SendTarget target; /// The message being answered, or null. final Event? replyTo; final VoidCallback? onCancelReply; final FocusNode? focus; @override State createState() => _ComposerState(); } class _ComposerState extends State { final _controller = TextEditingController(); late final FocusNode _focus = widget.focus ?? FocusNode(); Timer? _typingTimer; bool _typing = false; Room get _room => widget.room; @override void initState() { super.initState(); _controller.addListener(_onChanged); } @override void didUpdateWidget(Composer old) { super.didUpdateWidget(old); // Choosing a message to reply to puts the cursor in the box. if (widget.replyTo != null && widget.replyTo != old.replyTo) { _focus.requestFocus(); } } @override void dispose() { _typingTimer?.cancel(); if (_typing) _room.setTyping(false).catchError((_) {}); _controller.dispose(); if (widget.focus == null) _focus.dispose(); super.dispose(); } void _onChanged() { final hasText = _controller.text.trim().isNotEmpty; if (hasText && !_typing) { _typing = true; _room.setTyping(true, timeout: 30000).catchError((_) {}); } _typingTimer?.cancel(); if (hasText) { _typingTimer = Timer(const Duration(seconds: 5), _stopTyping); } else { _stopTyping(); } setState(() {}); } void _stopTyping() { if (!_typing) return; _typing = false; _room.setTyping(false).catchError((_) {}); } Future _send() async { final text = _controller.text.trim(); if (text.isEmpty) return; final replyTo = widget.replyTo; _controller.clear(); _stopTyping(); widget.onCancelReply?.call(); try { // The pending message shows at once as "Sending"; a failure leaves it // in the timeline with Retry and Delete. await _room.sendTextEvent( text, inReplyTo: replyTo, threadRootEventId: widget.target.threadRootId, threadLastEventId: widget.target.threadLastEventId?.call(), ); } catch (e) { _snack(describeError(e)); } } Future _sendFile(MatrixFile file, Event? replyTo) async { try { await _room.sendFileEvent( file, inReplyTo: replyTo, threadRootEventId: widget.target.threadRootId, threadLastEventId: widget.target.threadLastEventId?.call(), shrinkImageMaxDimension: 1600, extraContent: {'body': file.name}, ); } catch (e) { _snack(describeError(e)); } } Future _attach() async { final choice = await showModalBottomSheet( context: context, builder: (sheet) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( leading: const Icon(Icons.photo_library_outlined), title: const Text('Photo or video'), onTap: () => Navigator.pop(sheet, 'gallery'), ), ListTile( leading: const Icon(Icons.photo_camera_outlined), title: const Text('Take a photo'), onTap: () => Navigator.pop(sheet, 'camera'), ), ListTile( leading: const Icon(Icons.insert_drive_file_outlined), title: const Text('File'), onTap: () => Navigator.pop(sheet, 'file'), ), const SizedBox(height: 8), ], ), ), ); if (choice == null) return; final replyTo = widget.replyTo; widget.onCancelReply?.call(); try { if (choice == 'file') { for (final f in await FilePicker.pickFiles()) { final bytes = await f.xFile.readAsBytes(); await _sendFile( MatrixFile.fromMimeType(bytes: bytes, name: f.name), replyTo, ); } return; } final picker = ImagePicker(); final picked = choice == 'camera' ? [ ?await picker.pickImage( source: ImageSource.camera, imageQuality: 90, ), ] : await picker.pickMultipleMedia(imageQuality: 90); for (final x in picked) { final bytes = await x.readAsBytes(); await _sendFile( MatrixFile.fromMimeType( bytes: bytes, name: x.name, mimeType: x.mimeType, ), replyTo, ); } } on PlatformException catch (e) { _snack(e.message ?? "Couldn't open that."); } } void _snack(String text) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); } @override Widget build(BuildContext context) { final c = context.warp; final canSend = _controller.text.trim().isNotEmpty; final replyTo = widget.replyTo; final inThread = widget.target.threadRootId != null; final hint = inThread ? 'Reply in thread' : _room.encrypted ? 'Encrypted message' : 'Message'; return Container( decoration: BoxDecoration( color: c.surface, border: Border(top: BorderSide(color: c.line)), ), child: SafeArea( top: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (replyTo != null) Padding( padding: const EdgeInsets.fromLTRB(16, 8, 4, 0), child: Row( children: [ Icon(Icons.reply, size: 18, color: c.tealText), const SizedBox(width: 8), Expanded( child: QuoteBlock( sender: 'Replying to ${replyTo.senderFromMemoryOrFallback.calcDisplayname()}', text: quoteText(replyTo), ), ), IconButton( tooltip: 'Cancel reply', icon: const Icon(Icons.close), onPressed: widget.onCancelReply, ), ], ), ), Padding( padding: const EdgeInsets.fromLTRB(4, 6, 8, 6), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ IconButton( tooltip: 'Attach', icon: const Icon(Icons.add_circle_outline), color: c.inkMuted, onPressed: _attach, ), Expanded( child: TextField( controller: _controller, focusNode: _focus, minLines: 1, maxLines: 6, textCapitalization: TextCapitalization.sentences, keyboardType: TextInputType.multiline, decoration: InputDecoration( hintText: hint, isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(22), borderSide: BorderSide.none, ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(22), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(22), borderSide: BorderSide(color: c.tealStrong), ), ), ), ), const SizedBox(width: 6), IconButton.filled( tooltip: 'Send', onPressed: canSend ? _send : null, icon: const Icon(Icons.send_rounded), ), ], ), ), ], ), ), ); } }