import 'dart:async'; import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:http_parser/http_parser.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mime/mime.dart'; import 'package:stream_chat/stream_chat.dart'; import 'package:stream_chat_flutter/src/message_list_view.dart'; import 'package:stream_chat_flutter/src/stream_chat_theme.dart'; import 'package:stream_chat_flutter/src/user_avatar.dart'; import '../stream_chat_flutter.dart'; import 'stream_channel.dart'; typedef FileUploader = Future Function(File, Channel); /// Inactive state /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input_paint.png) /// Focused state /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input2.png) /// ![screenshot](https://raw.githubusercontent.com/GetStream/stream-chat-flutter/master/screenshots/message_input2_paint.png) /// /// Widget used to enter the message and add attachments /// /// ```dart /// class ChannelPage extends StatelessWidget { /// const ChannelPage({ /// Key key, /// }) : super(key: key); /// /// @override /// Widget build(BuildContext context) { /// return Scaffold( /// appBar: ChannelHeader(), /// body: Column( /// children: [ /// Expanded( /// child: MessageListView( /// threadBuilder: (_, parentMessage) { /// return ThreadPage( /// parent: parentMessage, /// ); /// }, /// ), /// ), /// MessageInput(), /// ], /// ), /// ); /// } /// } /// ``` /// /// You usually put this widget in the same page of a [MessageListView] as the bottom widget. /// /// The widget renders the ui based on the first ancestor of type [StreamChatTheme]. /// Modify it to change the widget appearance. class MessageInput extends StatefulWidget { /// Instantiate a new MessageInput MessageInput({ Key key, this.onMessageSent, this.parentMessage, this.editMessage, this.maxHeight = 150, this.keyboardType = TextInputType.multiline, this.disableAttachments = false, this.doImageUploadRequest, this.doFileUploadRequest, this.initialMessage, }) : super(key: key); /// Message to edit final Message editMessage; /// Message to start with final Message initialMessage; /// Function called after sending the message final void Function(Message) onMessageSent; /// Parent message in case of a thread final Message parentMessage; /// Maximum Height for the TextField to grow before it starts scrolling final double maxHeight; /// The keyboard type assigned to the TextField final TextInputType keyboardType; /// If true the attachments button will not be displayed final bool disableAttachments; /// Override image upload request final FileUploader doImageUploadRequest; /// Override file upload request final FileUploader doFileUploadRequest; @override _MessageInputState createState() => _MessageInputState( doFileUploadRequest: doFileUploadRequest, doImageUploadRequest: doImageUploadRequest, ); } class _MessageInputState extends State { final List<_SendingAttachment> _attachments = []; final _focusNode = FocusNode(); final List _mentionedUsers = []; FileUploader doImageUploadRequest; FileUploader doFileUploadRequest; TextEditingController _textController; bool _inputEnabled = true; bool _messageIsPresent = false; bool _typingStarted = false; OverlayEntry _commandsOverlay, _mentionsOverlay; _MessageInputState({ this.doImageUploadRequest, this.doFileUploadRequest, }) { doImageUploadRequest ??= _uploadImage; doFileUploadRequest ??= _uploadFile; } @override Widget build(BuildContext context) { return SafeArea( child: GestureDetector( onPanUpdate: (details) { if (details.delta.dy > 0) { _focusNode.unfocus(); } }, child: Padding( padding: const EdgeInsets.all(8.0), child: Stack( overflow: Overflow.visible, children: [ _buildBorder(context), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildAttachments(), _buildTextField(context), ], ), ], ), ), ), ); } Flex _buildTextField(BuildContext context) { return Flex( direction: Axis.horizontal, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!widget.disableAttachments) _buildAttachmentButton(), _buildTextInput(context), _animateSendButton(context), ], ); } AnimatedCrossFade _animateSendButton(BuildContext context) { return AnimatedCrossFade( crossFadeState: ((_messageIsPresent || _attachments.isNotEmpty) && _attachments.every((a) => a.uploaded == true)) ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: _buildSendButton(context), secondChild: SizedBox(), duration: Duration(milliseconds: 300), alignment: Alignment.center, ); } Expanded _buildTextInput(BuildContext context) { return Expanded( child: LimitedBox( maxHeight: widget.maxHeight, child: TextField( key: Key('messageInputText'), enabled: _inputEnabled, minLines: null, maxLines: null, onSubmitted: (_) { _sendMessage(context); }, keyboardType: widget.keyboardType, controller: _textController, focusNode: _focusNode, onChanged: (s) { StreamChannel.of(context).channel.keyStroke(); setState(() { _messageIsPresent = s.trim().isNotEmpty; }); _commandsOverlay?.remove(); _commandsOverlay = null; _mentionsOverlay?.remove(); _mentionsOverlay = null; if (s.startsWith('/')) { _commandsOverlay = _buildCommandsOverlayEntry(); Overlay.of(context).insert(_commandsOverlay); } if (_textController.selection.isCollapsed && (s[_textController.selection.start - 1] == '@' || _textController.text .substring(0, _textController.selection.start) .split(' ') .last .contains('@'))) { _mentionsOverlay = _buildMentionsOverlayEntry(); Overlay.of(context).insert(_mentionsOverlay); } }, onTap: () { setState(() { _typingStarted = true; }); }, style: Theme.of(context).textTheme.body1, autofocus: false, decoration: InputDecoration( hintText: 'Write a message', prefixText: ' ', border: InputBorder.none, ), ), ), ); } Positioned _buildBorder(BuildContext context) { return Positioned.fill( child: Container( width: MediaQuery.of(context).size.width, padding: EdgeInsets.all(2), decoration: BoxDecoration( borderRadius: BorderRadius.circular(10.0), gradient: _getGradient(context), ), child: Container( decoration: BoxDecoration( color: StreamChatTheme.of(context) .channelTheme .inputBackground .withAlpha(255), borderRadius: BorderRadius.circular(10.0), ), child: Container( decoration: BoxDecoration( color: StreamChatTheme.of(context).channelTheme.inputBackground, borderRadius: BorderRadius.circular(10.0), border: Border.all( color: _typingStarted ? Colors.transparent : Theme.of(context).brightness == Brightness.dark ? Colors.white.withOpacity(.2) : Colors.black.withOpacity(.2)), ), ), ), ), ); } OverlayEntry _buildCommandsOverlayEntry() { final text = _textController.text; final commands = StreamChannel.of(context) .channel .config .commands .where((c) => c.name.contains(text.replaceFirst('/', ''))) .toList(); RenderBox renderBox = context.findRenderObject(); final size = renderBox.size; return OverlayEntry(builder: (context) { return Positioned( bottom: size.height + MediaQuery.of(context).viewInsets.bottom, left: 0, right: 0, child: Material( color: StreamChatTheme.of(context).primaryColor, child: Container( constraints: BoxConstraints.loose(Size.fromHeight(400)), decoration: BoxDecoration( boxShadow: [ BoxShadow( spreadRadius: -8, blurRadius: 5.0, offset: Offset(0, -4), ), ], color: StreamChatTheme.of(context).primaryColor, ), child: ListView( padding: const EdgeInsets.all(0), shrinkWrap: true, children: commands .map( (c) => ListTile( title: Text.rich( TextSpan( text: '${c.name}', style: TextStyle(fontWeight: FontWeight.bold), children: [ TextSpan( text: ' ${c.args}', style: TextStyle( fontWeight: FontWeight.w300, ), ), ], ), ), subtitle: Text(c.description), onTap: () { _setCommand(c); }, ), ) .toList(), ), ), ), ); }); } OverlayEntry _buildMentionsOverlayEntry() { final splits = _textController.text .substring(0, _textController.value.selection.start) .split('@'); final query = splits.last.toLowerCase(); final members = StreamChannel.of(context).channel.state.members?.where((m) { return m.user.name.toLowerCase().contains(query); })?.toList() ?? []; RenderBox renderBox = context.findRenderObject(); final size = renderBox.size; return OverlayEntry(builder: (context) { return Positioned( bottom: size.height + MediaQuery.of(context).viewInsets.bottom, left: 0, right: 0, child: Material( color: StreamChatTheme.of(context).primaryColor, child: Container( constraints: BoxConstraints.loose(Size.fromHeight(400)), decoration: BoxDecoration( boxShadow: [ BoxShadow( spreadRadius: -8, blurRadius: 5.0, offset: Offset(0, -4), ), ], color: StreamChatTheme.of(context).primaryColor, ), child: ListView( padding: const EdgeInsets.all(0), shrinkWrap: true, children: members .map((m) => ListTile( leading: UserAvatar( user: m.user, ), title: Text('${m.user.name}'), onTap: () { _mentionedUsers.add(m.user); splits[splits.length - 1] = m.user.name; final rejoin = splits.join('@'); _textController.value = TextEditingValue( text: rejoin + _textController.text .substring(_textController.selection.start), selection: TextSelection.collapsed( offset: rejoin.length, ), ); _mentionsOverlay?.remove(); _mentionsOverlay = null; }, )) .toList(), ), ), ), ); }); } void _setCommand(Command c) { _textController.value = TextEditingValue( text: '/${c.name} ', selection: TextSelection.collapsed( offset: c.name.length + 2, ), ); _commandsOverlay?.remove(); _commandsOverlay = null; } Gradient _getGradient(BuildContext context) { if (_typingStarted) { if (widget.editMessage == null) { return StreamChatTheme.of(context).channelTheme.inputGradient; } return LinearGradient( colors: [Colors.lightGreen, Colors.green], ); } else { return null; } } Widget _buildAttachments() { return Wrap( direction: Axis.horizontal, children: _attachments .map( (attachment) => Padding( padding: const EdgeInsets.all(8.0), child: ClipRRect( borderRadius: BorderRadius.circular(10), child: Stack( children: [ Container( height: 50, width: 50, child: _buildAttachment(attachment), ), Positioned( height: 16, width: 16, top: 4, right: 4, child: RawMaterialButton( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), elevation: 0, highlightElevation: 0, focusElevation: 0, disabledElevation: 0, hoverElevation: 0, onPressed: () { setState(() { _attachments.remove(attachment); }); }, fillColor: Colors.white.withOpacity(.5), child: Center( child: Icon( Icons.close, size: 15, color: Colors.black, ), ), ), ), attachment.uploaded ? SizedBox() : Positioned.fill( child: Center( child: Padding( padding: const EdgeInsets.all(16.0), child: CircularProgressIndicator(), ), ), ), ], ), ), ), ) .toList(), ); } Widget _buildAttachment(_SendingAttachment attachment) { switch (attachment.type) { case FileType.image: return attachment.file != null ? Image.file( attachment.file, fit: BoxFit.cover, ) : Image.network( attachment.url, fit: BoxFit.cover, ); break; case FileType.video: return Container( child: Icon(Icons.videocam), color: Colors.black26, ); break; default: return Container( child: Icon(Icons.insert_drive_file), color: Colors.black26, ); } } Material _buildAttachmentButton() { return Material( clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(32), ), color: Colors.transparent, child: IconButton( onPressed: () { _showAttachmentModal(); }, icon: Icon( Icons.add_circle_outline, ), ), ); } void _showAttachmentModal() { if (_focusNode.hasFocus) { _focusNode.unfocus(); } showModalBottomSheet( clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: Radius.circular(32), topRight: Radius.circular(32), ), ), context: context, isScrollControlled: true, builder: (_) { return Column( mainAxisSize: MainAxisSize.min, children: [ ListTile( title: Text( 'Add a file', style: TextStyle( fontWeight: FontWeight.bold, ), ), ), ListTile( leading: Icon(Icons.image), title: Text('Upload a photo'), onTap: () { _pickFile(FileType.image, false); Navigator.pop(context); }, ), ListTile( leading: Icon(Icons.video_library), title: Text('Upload a video'), onTap: () { _pickFile(FileType.video, false); Navigator.pop(context); }, ), ListTile( leading: Icon(Icons.camera_alt), title: Text('Photo from camera'), onTap: () { _pickFile(FileType.image, true); Navigator.pop(context); }, ), ListTile( leading: Icon(Icons.videocam), title: Text('Video from camera'), onTap: () { _pickFile(FileType.video, true); Navigator.pop(context); }, ), ListTile( leading: Icon(Icons.insert_drive_file), title: Text('Upload a file'), onTap: () { _pickFile(FileType.any, false); Navigator.pop(context); }, ), ], ); }); } void _pickFile(FileType type, bool camera) async { setState(() { _inputEnabled = false; }); File file; if (camera) { if (type == FileType.image) { file = await ImagePicker.pickImage(source: ImageSource.camera); } else if (type == FileType.video) { file = await ImagePicker.pickVideo(source: ImageSource.camera); } } else { file = await FilePicker.getFile(type: type); } setState(() { _inputEnabled = true; }); if (file == null) { return; } final channel = StreamChannel.of(context).channel; final attachment = _SendingAttachment( file: file, type: type, ); setState(() { _attachments.add(attachment); }); final url = await _uploadAttachment(file, type, channel); attachment.url = url; setState(() { attachment.uploaded = true; }); } Future _uploadAttachment( File file, FileType type, Channel channel, ) async { String url; if (type == FileType.image) { url = await doImageUploadRequest(file, channel); } else { url = await doFileUploadRequest(file, channel); } return url; } Future _uploadImage(File file, Channel channel) async { final filename = file.path.split('/').last; final bytes = await file.readAsBytes(); final res = await channel.sendImage( MultipartFile.fromBytes( bytes, filename: filename, contentType: MediaType.parse(lookupMimeType(filename)), ), ); return res.file; } Future _uploadFile(File file, Channel channel) async { final filename = file.path.split('/').last; final bytes = await file.readAsBytes(); final res = await channel.sendFile( MultipartFile.fromBytes( bytes, filename: filename, contentType: MediaType.parse(lookupMimeType(filename)), ), ); return res.file; } Widget _buildSendButton(BuildContext context) { return IconTheme( data: StreamChatTheme.of(context).channelTheme.messageInputButtonIconTheme, child: Material( clipBehavior: Clip.hardEdge, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(32), ), color: Colors.transparent, child: IconButton( key: Key('sendButton'), onPressed: () { _sendMessage(context); }, icon: Icon( Icons.send, color: StreamChatTheme.of(context).accentColor, ), ), ), ); } void _sendMessage(BuildContext context) { final text = _textController.text.trim(); if (text.isEmpty && _attachments.isEmpty) { return; } final attachments = List<_SendingAttachment>.from(_attachments); _textController.clear(); _attachments.clear(); setState(() { _messageIsPresent = false; _typingStarted = false; }); _commandsOverlay?.remove(); _commandsOverlay = null; _mentionsOverlay?.remove(); _mentionsOverlay = null; final channel = StreamChannel.of(context).channel; Future sendingFuture; Message message; if (widget.editMessage != null) { message = widget.editMessage.copyWith( parentId: widget.parentMessage?.id, text: text, attachments: widget.editMessage.attachments .where((attachment) => attachment.type == 'giphy') .toList() + _getAttachments(attachments).toList(), mentionedUsers: _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), ); if (widget.editMessage.status == MessageSendingStatus.FAILED) { sendingFuture = channel.sendMessage(message); } sendingFuture = StreamChat.of(context).client.updateMessage( message, channel.cid, ); } else { message = (widget.initialMessage ?? Message()).copyWith( parentId: widget.parentMessage?.id, text: text, attachments: _getAttachments(attachments).toList(), mentionedUsers: _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), ); sendingFuture = channel.sendMessage(message); } sendingFuture.whenComplete(() { if (widget.onMessageSent != null) { widget.onMessageSent(message); } }); } Iterable _getAttachments(List<_SendingAttachment> attachments) { return attachments.map((attachment) { String type; switch (attachment.type) { case FileType.image: type = 'image'; break; case FileType.video: type = 'video'; break; default: type = 'file'; } return Attachment( imageUrl: attachment.type == FileType.image ? attachment.url : null, assetUrl: attachment.url, type: type, localUri: attachment.file.uri, ); }); } StreamSubscription _keyboardListener; @override void initState() { super.initState(); StreamChannel.of(context).queryMembersAndWatchers(); _keyboardListener = KeyboardVisibility.onChange.listen((visible) { if (visible) { if (_commandsOverlay != null) { if (_textController.text.startsWith('/')) { WidgetsBinding.instance.addPostFrameCallback((_) { _commandsOverlay = _buildCommandsOverlayEntry(); Overlay.of(context).insert(_commandsOverlay); }); } } if (_mentionsOverlay != null) { if (_textController.text.contains('@')) { WidgetsBinding.instance.addPostFrameCallback((_) { _mentionsOverlay = _buildCommandsOverlayEntry(); Overlay.of(context).insert(_mentionsOverlay); }); } } } else { if (_commandsOverlay != null) { _commandsOverlay.remove(); } if (_mentionsOverlay != null) { _mentionsOverlay.remove(); } } }); if (widget.editMessage != null) { _parseExistingMessage(widget.editMessage); } else if (widget.initialMessage != null) { _parseExistingMessage(widget.initialMessage); } else { _textController = TextEditingController(); } } void _parseExistingMessage(Message message) { _textController = TextEditingController(text: message.text); _typingStarted = true; _messageIsPresent = true; message.attachments?.forEach((attachment) { if (attachment.type == 'image') { _attachments.add(_SendingAttachment( type: FileType.image, url: attachment.imageUrl ?? attachment.assetUrl ?? attachment.thumbUrl ?? attachment.ogScrapeUrl, uploaded: true, )); } else if (attachment.type == 'video') { _attachments.add(_SendingAttachment( type: FileType.video, url: attachment.assetUrl, uploaded: true, )); } else if (attachment.type != 'giphy') { _attachments.add(_SendingAttachment( type: FileType.any, url: attachment.assetUrl, uploaded: true, )); } }); } @override void dispose() { _commandsOverlay?.remove(); _mentionsOverlay?.remove(); _keyboardListener.cancel(); super.dispose(); } bool _initialized = false; @override void didChangeDependencies() { if (widget.editMessage != null && !_initialized) { FocusScope.of(context).requestFocus(_focusNode); _initialized = true; } super.didChangeDependencies(); } } class _SendingAttachment { final File file; final FileType type; String url; bool uploaded; _SendingAttachment({ this.url, this.file, this.type, this.uploaded = false, }); }