import 'dart:async'; import 'dart:math'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:emojis/emoji.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:image_picker/image_picker.dart'; import 'package:photo_manager/photo_manager.dart'; import 'package:shimmer/shimmer.dart'; import 'package:stream_chat_flutter/src/media_list_view.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/stream_svg_icon.dart'; import 'package:stream_chat_flutter/src/video_service.dart'; import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; import 'package:substring_highlight/substring_highlight.dart'; import '../stream_chat_flutter.dart'; import 'attachment/attachment.dart'; import 'extension.dart'; import 'quoted_message_widget.dart'; import 'video_thumbnail_image.dart'; typedef AttachmentThumbnailBuilder = Widget Function( BuildContext, Attachment, ); /// Builder function for building a mention tile /// Use [MentionTile] for the default implementation typedef MentionTileBuilder = Widget Function( BuildContext context, Member member, ); enum ActionsLocation { left, right, leftInside, rightInside, } enum DefaultAttachmentTypes { image, video, file, } /// Available locations for the sendMessage button relative to the textField enum SendButtonLocation { /// inside the textField inside, /// outside the textField outside, } const _kMinMediaPickerSize = 360.0; const _kMaxAttachmentSize = 20971520; // 20MB in Bytes /// 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.preMessageSending, this.parentMessage, this.editMessage, this.maxHeight = 150, this.keyboardType = TextInputType.multiline, this.disableAttachments = false, this.initialMessage, this.textEditingController, this.actions, this.actionsLocation = ActionsLocation.left, this.attachmentThumbnailBuilders, this.focusNode, this.quotedMessage, this.onQuotedMessageCleared, this.sendButtonLocation = SendButtonLocation.outside, this.autofocus = false, this.hideSendAsDm = false, this.idleSendButton, this.activeSendButton, this.showCommandsButton = true, this.mentionsTileBuilder, }) : 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; /// Function called right before sending the message /// Use this to transform the message final FutureOr Function(Message) preMessageSending; /// 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; /// Use this property to hide/show the commands button final bool showCommandsButton; /// Hide send as dm checkbox final bool hideSendAsDm; /// The text controller of the TextField final TextEditingController textEditingController; /// List of action widgets final List actions; /// The location of the custom actions final ActionsLocation actionsLocation; /// Map that defines a thumbnail builder for an attachment type final Map attachmentThumbnailBuilders; /// The focus node associated to the TextField final FocusNode focusNode; /// final Message quotedMessage; /// final VoidCallback onQuotedMessageCleared; /// The location of the send button final SendButtonLocation sendButtonLocation; /// Autofocus property passed to the TextField final bool autofocus; /// Send button widget in an idle state final Widget idleSendButton; /// Send button widget in an active state final Widget activeSendButton; /// Customize the tile for the mentions overlay final MentionTileBuilder mentionsTileBuilder; @override MessageInputState createState() => MessageInputState(); /// Use this method to get the current [StreamChatState] instance static MessageInputState of(BuildContext context) { MessageInputState messageInputState; messageInputState = context.findAncestorStateOfType(); if (messageInputState == null) { throw Exception( 'You must have a MessageInput widget as ancestor of your widget tree'); } return messageInputState; } } class MessageInputState extends State { final _attachments = {}; final List _mentionedUsers = []; final _imagePicker = ImagePicker(); FocusNode _focusNode; bool _inputEnabled = true; bool _messageIsPresent = false; bool _animateContainer = true; bool _commandEnabled = false; OverlayEntry _commandsOverlay, _mentionsOverlay, _emojiOverlay; Iterable _emojiNames; Command _chosenCommand; bool _actionsShrunk = false; bool _sendAsDm = false; bool _openFilePickerSection = false; int _filePickerIndex = 0; double _filePickerSize = _kMinMediaPickerSize; final KeyboardVisibilityController _keyboardVisibilityController = KeyboardVisibilityController(); /// The editing controller passed to the input TextField TextEditingController textEditingController; bool get _hasQuotedMessage => widget.quotedMessage != null; @override void initState() { super.initState(); _focusNode = widget.focusNode ?? FocusNode(); _emojiNames = Emoji.all().map((e) => e.name); if (!kIsWeb) { _keyboardListener = _keyboardVisibilityController.onChange.listen((visible) { if (_focusNode.hasFocus) { _onChanged(context, textEditingController.text); } }); } textEditingController = widget.textEditingController ?? TextEditingController(); if (widget.editMessage != null || widget.initialMessage != null) { _parseExistingMessage(widget.editMessage ?? widget.initialMessage); } textEditingController.addListener(() { _onChanged(context, textEditingController.text); }); _focusNode.addListener(() { if (_focusNode.hasFocus) { _openFilePickerSection = false; } }); } @override Widget build(BuildContext context) { Widget child = Container( color: StreamChatTheme.of(context).messageInputTheme.inputBackground, child: SafeArea( child: GestureDetector( onPanUpdate: (details) { if (details.delta.dy > 0) { _focusNode.unfocus(); if (_openFilePickerSection) { setState(() { _openFilePickerSection = false; }); } } }, child: Column( mainAxisSize: MainAxisSize.min, children: [ if (_hasQuotedMessage) Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Padding( padding: const EdgeInsets.all(8.0), child: StreamSvgIcon.reply( color: StreamChatTheme.of(context) .colorTheme .greyGainsboro, ), ), Text( 'Reply to Message', style: TextStyle(fontWeight: FontWeight.bold), ), IconButton( visualDensity: VisualDensity.compact, icon: StreamSvgIcon.closeSmall(), onPressed: widget.onQuotedMessageCleared, ), ], ), ), Padding( padding: const EdgeInsets.symmetric(vertical: 8.0), child: _buildTextField(context), ), if (widget.parentMessage != null && !widget.hideSendAsDm) Padding( padding: const EdgeInsets.only( right: 12.0, left: 12.0, bottom: 12.0, ), child: _buildDmCheckbox(), ), _buildFilePickerSection(), ], ), ), ), ); if (widget.editMessage == null) { child = Material( elevation: 8, child: child, ); } return child; } Flex _buildTextField(BuildContext context) { return Flex( direction: Axis.horizontal, crossAxisAlignment: CrossAxisAlignment.center, children: [ if (!_commandEnabled && widget.actionsLocation == ActionsLocation.left) _buildExpandActionsButton(), _buildTextInput(context), if (!_commandEnabled && widget.actionsLocation == ActionsLocation.right) _buildExpandActionsButton(), if (widget.sendButtonLocation == SendButtonLocation.outside) _animateSendButton(context), ], ); } Widget _buildDmCheckbox() { return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( height: 16, width: 16, foregroundDecoration: BoxDecoration( border: _sendAsDm ? null : Border.all( color: StreamChatTheme.of(context) .colorTheme .black .withOpacity(.5), width: 2, ), borderRadius: BorderRadius.circular(3), ), child: Center( child: Material( borderRadius: BorderRadius.circular(3), color: _sendAsDm ? StreamChatTheme.of(context).colorTheme.accentBlue : StreamChatTheme.of(context).colorTheme.white, child: InkWell( onTap: () { setState(() { _sendAsDm = !_sendAsDm; }); }, child: AnimatedCrossFade( duration: Duration(milliseconds: 300), reverseDuration: Duration(milliseconds: 300), crossFadeState: _sendAsDm ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: StreamSvgIcon.check( size: 16.0, color: StreamChatTheme.of(context).colorTheme.white, ), secondChild: SizedBox( height: 16, width: 16, ), ), ), ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 12.0), child: Text( 'Also send as direct message', style: StreamChatTheme.of(context).textTheme.footnote.copyWith( color: StreamChatTheme.of(context) .colorTheme .black .withOpacity(0.5), ), ), ), ], ); } Widget _animateSendButton(BuildContext context) { final sendButton = widget.activeSendButton != null ? InkWell( onTap: sendMessage, child: widget.activeSendButton, ) : _buildSendButton(context); return AnimatedCrossFade( crossFadeState: (_messageIsPresent || _attachments.isNotEmpty) ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: sendButton, secondChild: widget.idleSendButton ?? _buildIdleSendButton(context), duration: StreamChatTheme.of(context).messageInputTheme.sendAnimationDuration, alignment: Alignment.center, ); } Widget _buildExpandActionsButton() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: AnimatedCrossFade( crossFadeState: _actionsShrunk ? CrossFadeState.showFirst : CrossFadeState.showSecond, firstChild: IconButton( onPressed: () => setState(() => _actionsShrunk = false), icon: Transform.rotate( alignment: Alignment.center, angle: (widget.actionsLocation == ActionsLocation.right || widget.actionsLocation == ActionsLocation.rightInside) ? pi : 0, child: StreamSvgIcon.emptyCircleLeft( color: StreamChatTheme.of(context) .messageInputTheme .expandButtonColor, ), ), padding: const EdgeInsets.all(0), constraints: BoxConstraints.tightFor( height: 24, width: 24, ), splashRadius: 24, ), secondChild: FittedBox( child: Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ if (!widget.disableAttachments) _buildAttachmentButton(), if (widget.showCommandsButton && widget.editMessage == null && StreamChannel.of(context) .channel ?.config ?.commands ?.isNotEmpty == true) _buildCommandButton(), ...widget.actions ?? [], ].insertBetween(const SizedBox(width: 8)), ), ), duration: Duration(milliseconds: 300), alignment: Alignment.center, ), ); } Expanded _buildTextInput(BuildContext context) { final theme = StreamChatTheme.of(context); final margin = (widget.sendButtonLocation == SendButtonLocation.inside ? const EdgeInsets.only(right: 8.0) : EdgeInsets.zero) + (widget.actionsLocation != ActionsLocation.left ? const EdgeInsets.only(left: 8.0) : EdgeInsets.zero); return Expanded( child: Center( child: Container( clipBehavior: Clip.antiAlias, margin: margin, decoration: BoxDecoration( borderRadius: theme.messageInputTheme.borderRadius, gradient: _focusNode.hasFocus ? theme.messageInputTheme.activeBorderGradient : theme.messageInputTheme.idleBorderGradient, ), child: Padding( padding: const EdgeInsets.all(1.5), child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( borderRadius: theme.messageInputTheme.borderRadius, color: theme.messageInputTheme.inputBackground, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildReplyToMessage(), _buildAttachments(), LimitedBox( maxHeight: widget.maxHeight, child: TextField( key: Key('messageInputText'), enabled: _inputEnabled, minLines: null, maxLines: null, onSubmitted: (_) => sendMessage(), keyboardType: widget.keyboardType, controller: textEditingController, focusNode: _focusNode, style: theme.messageInputTheme.inputTextStyle, autofocus: widget.autofocus, textAlignVertical: TextAlignVertical.center, decoration: _getInputDecoration(), textCapitalization: TextCapitalization.sentences, ), ) ], ), ), ), ), ), ); } InputDecoration _getInputDecoration() { final theme = StreamChatTheme.of(context); final passedDecoration = theme.messageInputTheme.inputDecoration; return InputDecoration( isDense: true, hintText: _getHint(), hintStyle: theme.messageInputTheme.inputTextStyle.copyWith( color: theme.colorTheme.grey, ), border: OutlineInputBorder( borderSide: BorderSide( color: Colors.transparent, ), ), focusedBorder: OutlineInputBorder( borderSide: BorderSide( color: Colors.transparent, ), ), enabledBorder: OutlineInputBorder( borderSide: BorderSide( color: Colors.transparent, ), ), errorBorder: OutlineInputBorder( borderSide: BorderSide( color: Colors.transparent, ), ), disabledBorder: OutlineInputBorder( borderSide: BorderSide( color: Colors.transparent, ), ), contentPadding: const EdgeInsets.fromLTRB(16, 12, 13, 11), prefixIcon: _commandEnabled ? Row( mainAxisSize: MainAxisSize.min, children: [ Padding( padding: const EdgeInsets.all(8.0), child: Container( constraints: BoxConstraints.tight(Size(64, 24)), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), color: theme.colorTheme.accentBlue, ), alignment: Alignment.center, child: Row( mainAxisSize: MainAxisSize.min, children: [ StreamSvgIcon.lightning( color: Colors.white, size: 16.0, ), Text( _chosenCommand?.name?.toUpperCase() ?? '', style: StreamChatTheme.of(context) .textTheme .footnoteBold .copyWith( color: Colors.white, ), ), ], ), ), ), ], ) : (widget.actionsLocation == ActionsLocation.leftInside ? Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ _buildExpandActionsButton(), ], ) : null), suffixIconConstraints: BoxConstraints.tightFor(height: 40), prefixIconConstraints: BoxConstraints.tightFor(height: 40), suffixIcon: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ if (_commandEnabled) Padding( padding: const EdgeInsets.only(right: 8.0), child: IconButton( icon: StreamSvgIcon.closeSmall(), splashRadius: 24, padding: const EdgeInsets.all(0), constraints: BoxConstraints.tightFor( height: 24, width: 24, ), onPressed: () { setState(() => _commandEnabled = false); }, ), ), if (!_commandEnabled && widget.actionsLocation == ActionsLocation.rightInside) _buildExpandActionsButton(), if (widget.sendButtonLocation == SendButtonLocation.inside) _animateSendButton(context), ], ), ).merge(passedDecoration); } Timer _debounce; String _previousValue; void _onChanged(BuildContext context, String s) { if (s == _previousValue) { return; } _previousValue = s; if (_debounce?.isActive == true) _debounce.cancel(); _debounce = Timer( const Duration(milliseconds: 350), () { if (!mounted) { return; } StreamChannel.of(context).channel.keyStroke()?.catchError((e) {}); setState(() { _messageIsPresent = s.trim().isNotEmpty; _actionsShrunk = s.trim().isNotEmpty && ((widget.actions?.length ?? 0) + (widget.showCommandsButton ? 1 : 0) + (widget.disableAttachments ? 0 : 1) > 1); }); _commandsOverlay?.remove(); _commandsOverlay = null; _mentionsOverlay?.remove(); _mentionsOverlay = null; _emojiOverlay?.remove(); _emojiOverlay = null; _checkCommands(s.trim(), context); _checkMentions(s, context); _checkEmoji(s, context); }, ); } String _getHint() { if (_commandEnabled && _chosenCommand.name == 'giphy') { return 'Search GIFs'; } if (_attachments.isNotEmpty) { return 'Add a comment or send'; } return 'Write a message'; } void _checkEmoji(String s, BuildContext context) { if (s.isNotEmpty && textEditingController.selection.baseOffset > 0 && textEditingController.text .substring( 0, textEditingController.selection.baseOffset, ) .contains(':')) { final textToSelection = textEditingController.text .substring(0, textEditingController.value.selection.start); final splits = textToSelection.split(':'); final query = splits[splits.length - 2]?.toLowerCase(); final emoji = Emoji.byName(query); if (textToSelection.endsWith(':') && emoji != null) { _chooseEmoji(splits.sublist(0, splits.length - 1), emoji); } else { _emojiOverlay = _buildEmojiOverlay(); if (_emojiOverlay != null) { Overlay.of(context).insert(_emojiOverlay); } } } } void _checkMentions(String s, BuildContext context) { if (s.isNotEmpty && textEditingController.selection.baseOffset > 0 && textEditingController.text .substring(0, textEditingController.selection.baseOffset) .split(' ') .last .contains('@')) { _mentionsOverlay = _buildMentionsOverlayEntry(); if (_mentionsOverlay != null) { Overlay.of(context).insert(_mentionsOverlay); } } } void _checkCommands(String s, BuildContext context) { if (s.startsWith('/')) { var matchedCommandsList = StreamChannel.of(context) .channel .config ?.commands ?.where((element) => element.name == s.substring(1)) ?.toList() ?? []; if (matchedCommandsList.length == 1) { _chosenCommand = matchedCommandsList[0]; textEditingController.clear(); _messageIsPresent = false; setState(() { _commandEnabled = true; }); _commandsOverlay.remove(); _commandsOverlay = null; } else { _commandsOverlay = _buildCommandsOverlayEntry(); if (_commandsOverlay != null) { Overlay.of(context).insert(_commandsOverlay); } } } } OverlayEntry _buildCommandsOverlayEntry() { final text = textEditingController.text.trimLeft(); final commands = StreamChannel.of(context) .channel .config ?.commands ?.where((c) => c.name.contains(text.replaceFirst('/', ''))) ?.toList() ?? []; if (commands.isEmpty) { return null; } 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: TweenAnimationBuilder( tween: Tween(begin: 0.0, end: 1.0), duration: Duration(milliseconds: 300), curve: Curves.easeInOutExpo, builder: (context, val, wid) { return Transform.scale( alignment: Alignment.center, scale: val, child: Padding( padding: const EdgeInsets.all(8.0), child: Card( elevation: 2.0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), color: StreamChatTheme.of(context).colorTheme.white, clipBehavior: Clip.antiAlias, child: Container( constraints: BoxConstraints.loose(Size.fromHeight(400)), decoration: BoxDecoration( color: StreamChatTheme.of(context).colorTheme.white, borderRadius: BorderRadius.circular(8.0)), child: ListView( padding: const EdgeInsets.all(0), shrinkWrap: true, children: [ if (commands.isNotEmpty) Padding( padding: const EdgeInsets.only(left: 0.0, top: 8.0), child: Row( children: [ Padding( padding: const EdgeInsets.symmetric( horizontal: 8.0, ), child: StreamSvgIcon.lightning( color: StreamChatTheme.of(context) .colorTheme .accentBlue, ), ), Text( 'Instant Commands', style: TextStyle( color: StreamChatTheme.of(context) .colorTheme .black .withOpacity(.5), ), ) ], ), ), SizedBox( height: 10.0, ), ...commands .map( (c) => InkWell( onTap: () { _setCommand(c); }, child: Container( height: 40.0, child: Row( children: [ SizedBox( width: 16.0, ), _buildCommandIcon(c.name), SizedBox( width: 8.0, ), Text.rich( TextSpan( text: '${c.name.capitalize()}', style: TextStyle( fontWeight: FontWeight.bold), children: [ TextSpan( text: ' /${c.name} ${c.args}', style: StreamChatTheme.of(context) .textTheme .body .copyWith( color: StreamChatTheme .of(context) .colorTheme .grey, ), ), ], ), ), ], ), ), ), ) .toList(), ], ), ), ), ), ); }), ); }); } Widget _buildFilePickerSection() { final _attachmentContainsFile = _attachments.values.any((it) { return it.type == 'file'; }); Color _getIconColor(int index) { switch (index) { case 0: return _attachments.isEmpty ? StreamChatTheme.of(context).colorTheme.accentBlue : (!_attachmentContainsFile ? StreamChatTheme.of(context).colorTheme.accentBlue : StreamChatTheme.of(context) .colorTheme .black .withOpacity(0.2)); break; case 1: return _attachmentContainsFile ? StreamChatTheme.of(context).colorTheme.accentBlue : (_attachments.isEmpty ? StreamChatTheme.of(context) .colorTheme .black .withOpacity(0.5) : StreamChatTheme.of(context) .colorTheme .black .withOpacity(0.2)); break; case 2: return _attachmentContainsFile && _attachments.isNotEmpty ? StreamChatTheme.of(context).colorTheme.black.withOpacity(0.2) : StreamChatTheme.of(context).colorTheme.black.withOpacity(0.5); break; case 3: return _attachmentContainsFile && _attachments.isNotEmpty ? StreamChatTheme.of(context).colorTheme.black.withOpacity(0.2) : StreamChatTheme.of(context).colorTheme.black.withOpacity(0.5); break; default: return Colors.black; } } return AnimatedContainer( duration: _animateContainer ? Duration(milliseconds: 300) : Duration.zero, height: _openFilePickerSection ? _filePickerSize : 0, child: Material( color: StreamChatTheme.of(context).colorTheme.whiteSmoke, child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( mainAxisAlignment: MainAxisAlignment.start, children: [ IconButton( iconSize: 24, icon: StreamSvgIcon.pictures( color: _getIconColor(0), ), onPressed: _attachmentContainsFile && _attachments.isNotEmpty ? null : () { setState(() { _filePickerIndex = 0; }); }, ), IconButton( iconSize: 32, icon: StreamSvgIcon.files( color: _getIconColor(1), ), onPressed: !_attachmentContainsFile && _attachments.isNotEmpty ? null : () { pickFile(DefaultAttachmentTypes.file, false); }, ), IconButton( iconSize: 24, icon: StreamSvgIcon.camera( color: _getIconColor(2), ), onPressed: _attachmentContainsFile && _attachments.isNotEmpty ? null : () { pickFile(DefaultAttachmentTypes.image, true); }, ), IconButton( padding: const EdgeInsets.all(0), iconSize: 24, icon: StreamSvgIcon.record( color: _getIconColor(3), ), onPressed: _attachmentContainsFile && _attachments.isNotEmpty ? null : () { pickFile(DefaultAttachmentTypes.video, true); }, ), ], ), GestureDetector( onVerticalDragUpdate: (update) { setState(() { _animateContainer = false; _filePickerSize = (_filePickerSize - update.delta.dy).clamp( _kMinMediaPickerSize, MediaQuery.of(context).size.height / 1.7, ); }); }, child: Container( decoration: BoxDecoration( color: StreamChatTheme.of(context).colorTheme.white, borderRadius: BorderRadius.only( topLeft: Radius.circular(16.0), topRight: Radius.circular(16.0), ), ), child: Container( width: double.infinity, child: Center( child: Padding( padding: const EdgeInsets.all(8.0), child: Container( width: 40.0, height: 4.0, decoration: BoxDecoration( color: StreamChatTheme.of(context).colorTheme.whiteSmoke, borderRadius: BorderRadius.circular(4.0), ), ), ), ), ), ), ), if (_openFilePickerSection) Expanded( child: Container( decoration: BoxDecoration( color: StreamChatTheme.of(context).colorTheme.white, borderRadius: BorderRadius.circular(8.0), ), child: _PickerWidget( filePickerIndex: _filePickerIndex, containsFile: _attachmentContainsFile, selectedMedias: _attachments.keys.toList(), onAddMoreFilesClick: pickFile, onMediaSelected: (media) { if (_attachments.containsKey(media.id)) { setState(() => _attachments.remove(media.id)); } else { _addAttachment(media); } }, ), ), ), ], ), ), ); } void _addAttachment(AssetEntity medium) async { final mediaFile = await medium.originFile.timeout( Duration(seconds: 5), onTimeout: () => medium.originFile, ); var file = AttachmentFile( path: mediaFile.path, size: await mediaFile.length(), bytes: mediaFile.readAsBytesSync(), ); if (file.size > _kMaxAttachmentSize) { if (medium?.type == AssetType.video) { final mediaInfo = await VideoService.compressVideo(file.path); if (mediaInfo.filesize > _kMaxAttachmentSize) { _showErrorAlert( 'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.', ); return; } file = AttachmentFile( name: file.name, size: mediaInfo.filesize, bytes: await mediaInfo.file.readAsBytes(), path: mediaInfo.path, ); } else { _showErrorAlert( 'The file is too large to upload. The file size limit is 20MB.', ); return; } } setState(() { _attachments[medium.id] = Attachment( id: medium.id, file: file, type: medium.type == AssetType.image ? 'image' : 'video', ); }); } Widget _buildCommandIcon(String iconType) { switch (iconType) { case 'giphy': return CircleAvatar( radius: 12, child: StreamSvgIcon.giphyIcon( size: 24.0, ), ); break; case 'ban': return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, radius: 12, child: StreamSvgIcon.iconUserDelete( size: 16.0, color: Colors.white, ), ); break; case 'flag': return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, radius: 12, child: StreamSvgIcon.flag( size: 14.0, color: Colors.white, ), ); break; case 'imgur': return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, radius: 12, child: ClipOval( child: StreamSvgIcon.imgur( size: 24.0, ), ), ); break; case 'mute': return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, radius: 12, child: StreamSvgIcon.mute( size: 16.0, color: Colors.white, ), ); break; case 'unban': return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, radius: 12, child: StreamSvgIcon.userAdd( size: 16.0, color: Colors.white, ), ); break; case 'unmute': return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, radius: 12, child: StreamSvgIcon.volumeUp( size: 16.0, color: Colors.white, ), ); break; default: return CircleAvatar( backgroundColor: StreamChatTheme.of(context).colorTheme.accentBlue, radius: 12, child: StreamSvgIcon.lightning( size: 16.0, color: Colors.white, ), ); break; } } OverlayEntry _buildMentionsOverlayEntry() { final splits = textEditingController.text .substring(0, textEditingController.value.selection.start) .split('@'); final query = splits.last.toLowerCase(); Future> queryMembers; if (query.isNotEmpty) { queryMembers = StreamChannel.of(context) .channel .queryMembers(filter: Filter.autoComplete('name', query)) .then((res) => res.members); } final members = StreamChannel.of(context).channel.state.members?.where((m) { return m.user.name.toLowerCase().contains(query); })?.toList() ?? []; if (members.isEmpty) { return null; } 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: TweenAnimationBuilder( tween: Tween(begin: 0.0, end: 1.0), duration: Duration(milliseconds: 300), curve: Curves.easeInOutExpo, builder: (context, val, wid) { return Transform.scale( scale: val, child: Card( margin: EdgeInsets.all(8.0), elevation: 2.0, color: StreamChatTheme.of(context).colorTheme.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), clipBehavior: Clip.antiAlias, child: Container( constraints: BoxConstraints.loose(Size.fromHeight(240)), decoration: BoxDecoration( color: StreamChatTheme.of(context).colorTheme.white, ), child: FutureBuilder>( future: queryMembers ?? Future.value(members), initialData: members, builder: (context, snapshot) { return ListView( padding: const EdgeInsets.all(0), shrinkWrap: true, children: [ SizedBox( height: 8.0, ), ...snapshot.data.map( (m) { return Material( color: StreamChatTheme.of(context) .colorTheme .white, child: InkWell( onTap: () { _mentionedUsers.add(m.user); splits[splits.length - 1] = m.user.name; final rejoin = splits.join('@'); textEditingController.value = TextEditingValue( text: rejoin + textEditingController.text .substring(textEditingController .selection.start), selection: TextSelection.collapsed( offset: rejoin.length, ), ); _debounce.cancel(); _mentionsOverlay?.remove(); _mentionsOverlay = null; }, child: widget.mentionsTileBuilder != null ? widget.mentionsTileBuilder(context, m) : MentionTile(m), ), ); }, ).toList(), SizedBox( height: 8.0, ), ], ); }, ), ), ), ); }, ), ); }, ); } OverlayEntry _buildEmojiOverlay() { final splits = textEditingController.text .substring(0, textEditingController.value.selection.start) .split(':'); final query = splits.last.toLowerCase(); if (query.isEmpty) { return null; } final emojis = _emojiNames .where((e) => e.contains(query)) .map((e) => Emoji.byName(e)) .where((e) => e != null); if (emojis.isEmpty) { return null; } 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: Card( margin: EdgeInsets.all(8.0), elevation: 2.0, color: StreamChatTheme.of(context).colorTheme.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8.0), ), clipBehavior: Clip.antiAlias, child: Container( constraints: BoxConstraints.loose(Size.fromHeight(200)), decoration: BoxDecoration( boxShadow: [ BoxShadow( spreadRadius: -8, blurRadius: 5.0, offset: Offset(0, -4), ), ], color: StreamChatTheme.of(context).colorTheme.white, ), child: ListView.builder( padding: const EdgeInsets.all(0), shrinkWrap: true, itemCount: emojis.length + 1, itemBuilder: (context, i) { if (i == 0) { return Padding( padding: const EdgeInsets.only(left: 8.0, top: 8.0), child: Row( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0), child: StreamSvgIcon.smile( color: StreamChatTheme.of(context) .colorTheme .accentBlue, ), ), Flexible( child: Text( 'Emoji matching "$query"', style: TextStyle( color: StreamChatTheme.of(context) .colorTheme .black .withOpacity(.5), ), ), ) ], ), ); } final emoji = emojis.elementAt(i - 1); return ListTile( title: SubstringHighlight( text: "${emoji.char} ${emoji.name.replaceAll('_', ' ')}", term: query, textStyleHighlight: Theme.of(context).textTheme.headline6.copyWith( fontSize: 14.5, fontWeight: FontWeight.bold, ), textStyle: Theme.of(context).textTheme.headline6.copyWith( fontSize: 14.5, ), ), onTap: () { _chooseEmoji(splits, emoji); }, ); }), ), ), ); }); } void _chooseEmoji(List splits, Emoji emoji) { final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char; textEditingController.value = TextEditingValue( text: rejoin + textEditingController.text .substring(textEditingController.selection.start), selection: TextSelection.collapsed( offset: rejoin.length, ), ); _emojiOverlay?.remove(); _emojiOverlay = null; } void _setCommand(Command c) { textEditingController.clear(); setState(() { _chosenCommand = c; _commandEnabled = true; _messageIsPresent = false; }); _commandsOverlay?.remove(); _commandsOverlay = null; } Widget _buildReplyToMessage() { if (!_hasQuotedMessage) return Offstage(); final containsUrl = widget.quotedMessage.attachments ?.any((element) => element.ogScrapeUrl != null) == true; return Transform( transform: Matrix4.rotationY(pi), alignment: Alignment.center, child: QuotedMessageWidget( reverse: true, showBorder: !containsUrl, message: widget.quotedMessage, messageTheme: StreamChatTheme.of(context).otherMessageTheme, padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), ), ); } Widget _buildAttachments() { if (_attachments.isEmpty) return Offstage(); final fileAttachments = _attachments.values .where((it) => it.type == 'file') .toList(growable: false); final remainingAttachments = _attachments.values .where((it) => it.type != 'file') .toList(growable: false); return Column( children: [ if (fileAttachments.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), child: LimitedBox( maxHeight: 136.0, child: ListView( reverse: true, shrinkWrap: true, children: fileAttachments.reversed .map( (e) => ClipRRect( borderRadius: BorderRadius.circular(10), clipBehavior: Clip.antiAlias, child: FileAttachment( message: null, attachment: e, size: Size( MediaQuery.of(context).size.width * 0.65, 56.0, ), trailing: Padding( padding: const EdgeInsets.all(8.0), child: _buildRemoveButton(e), ), ), ), ) .insertBetween(const SizedBox(height: 8)), ), ), ), if (remainingAttachments.isNotEmpty) Padding( padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), child: LimitedBox( maxHeight: 104.0, child: ListView( scrollDirection: Axis.horizontal, children: remainingAttachments .map( (attachment) => ClipRRect( borderRadius: BorderRadius.circular(10), clipBehavior: Clip.antiAlias, child: Stack( children: [ AspectRatio( aspectRatio: 1.0, child: Container( height: 104, width: 104, child: _buildAttachment(attachment), ), ), Positioned( top: 8, right: 8, child: _buildRemoveButton(attachment), ), ], ), ), ) .insertBetween(const SizedBox(width: 8)), ), ), ), ], ); } Widget _buildRemoveButton(Attachment attachment) { return Container( height: 24, width: 24, child: RawMaterialButton( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), elevation: 0, highlightElevation: 0, focusElevation: 0, disabledElevation: 0, hoverElevation: 0, onPressed: () { setState(() => _attachments.remove(attachment.id)); }, fillColor: StreamChatTheme.of(context).colorTheme.black.withOpacity(.5), child: Center( child: StreamSvgIcon.close( size: 24, color: StreamChatTheme.of(context).colorTheme.white, ), ), ), ); } Widget _buildAttachment(Attachment attachment) { if (attachment == null) return Offstage(); if (widget.attachmentThumbnailBuilders?.containsKey(attachment.type) == true) { return widget.attachmentThumbnailBuilders[attachment.type]( context, attachment, ); } switch (attachment.type) { case 'image': case 'giphy': return attachment.file != null ? Image.memory( attachment.file.bytes, fit: BoxFit.cover, errorBuilder: (context, _, __) { return Image.asset( 'images/placeholder.png', package: 'stream_chat_flutter', ); }, ) : CachedNetworkImage( imageUrl: attachment.imageUrl ?? attachment.assetUrl ?? attachment.thumbUrl, fit: BoxFit.cover, errorWidget: (_, obj, trace) { return getFileTypeImage(attachment.extraData['other']); }, progressIndicatorBuilder: (context, _, progress) { return Shimmer.fromColors( baseColor: StreamChatTheme.of(context).colorTheme.greyGainsboro, highlightColor: StreamChatTheme.of(context).colorTheme.whiteSmoke, child: Image.asset( 'images/placeholder.png', fit: BoxFit.cover, package: 'stream_chat_flutter', ), ); }, ); case 'video': return Stack( children: [ VideoThumbnailImage( height: 104, width: 104, video: attachment.file?.path ?? attachment.assetUrl, fit: BoxFit.cover, ), Positioned( left: 8, bottom: 10, child: SvgPicture.asset( 'svgs/video_call_icon.svg', package: 'stream_chat_flutter', ), ), ], ); default: return Container( color: Colors.black26, child: Icon(Icons.insert_drive_file), ); } } Widget _buildCommandButton() { final s = textEditingController.text.trim(); return IconButton( icon: StreamSvgIcon.lightning( color: s.isNotEmpty ? StreamChatTheme.of(context).colorTheme.greyGainsboro : (_commandsOverlay != null ? StreamChatTheme.of(context) .messageInputTheme .actionButtonColor : StreamChatTheme.of(context) .messageInputTheme .actionButtonIdleColor), ), padding: const EdgeInsets.all(0), constraints: BoxConstraints.tightFor( height: 24, width: 24, ), splashRadius: 24, onPressed: () async { if (_openFilePickerSection) { setState(() { _animateContainer = false; _openFilePickerSection = false; _filePickerSize = _kMinMediaPickerSize; }); await Future.delayed(Duration(milliseconds: 300)); } if (_commandsOverlay == null) { setState(() { _commandsOverlay = _buildCommandsOverlayEntry(); if (_commandsOverlay != null) { Overlay.of(context).insert(_commandsOverlay); } }); } else { setState(() { _commandsOverlay?.remove(); _commandsOverlay = null; }); } }, ); } Widget _buildAttachmentButton() { return IconButton( icon: StreamSvgIcon.attach( color: _openFilePickerSection ? StreamChatTheme.of(context).messageInputTheme.actionButtonColor : StreamChatTheme.of(context) .messageInputTheme .actionButtonIdleColor, ), padding: const EdgeInsets.all(0), constraints: BoxConstraints.tightFor( height: 24, width: 24, ), splashRadius: 24, onPressed: () async { _emojiOverlay?.remove(); _emojiOverlay = null; _commandsOverlay?.remove(); _commandsOverlay = null; _mentionsOverlay?.remove(); _mentionsOverlay = null; if (_openFilePickerSection) { setState(() { _animateContainer = true; _openFilePickerSection = false; _filePickerSize = _kMinMediaPickerSize; }); } else { showAttachmentModal(); } }, ); } /// Show the attachment modal, making the user choose where to pick a media from void showAttachmentModal() { if (_focusNode.hasFocus) { _focusNode.unfocus(); } if (!kIsWeb) { setState(() { _openFilePickerSection = true; }); } else { 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(DefaultAttachmentTypes.image, false); Navigator.pop(context); }, ), ListTile( leading: Icon(Icons.video_library), title: Text('Upload a video'), onTap: () { pickFile(DefaultAttachmentTypes.video, false); Navigator.pop(context); }, ), if (!kIsWeb) ListTile( leading: Icon(Icons.camera_alt), title: Text('Photo from camera'), onTap: () { pickFile(DefaultAttachmentTypes.image, true); Navigator.pop(context); }, ), if (!kIsWeb) ListTile( leading: Icon(Icons.videocam), title: Text('Video from camera'), onTap: () { pickFile(DefaultAttachmentTypes.video, true); Navigator.pop(context); }, ), ListTile( leading: Icon(Icons.insert_drive_file), title: Text('Upload a file'), onTap: () { pickFile(DefaultAttachmentTypes.file, false); Navigator.pop(context); }, ), ], ); }); } } /// Add an attachment to the sending message /// Use this to add custom type attachments void addAttachment(Attachment attachment) { setState(() { _attachments[attachment.id] = attachment.copyWith( uploadState: attachment.uploadState ?? UploadState.success(), ); }); } /// Pick a file from the device /// If [camera] is true then the camera will open void pickFile(DefaultAttachmentTypes fileType, [bool camera = false]) async { setState(() => _inputEnabled = false); AttachmentFile file; String attachmentType; if (fileType == DefaultAttachmentTypes.image) { attachmentType = 'image'; } else if (fileType == DefaultAttachmentTypes.video) { attachmentType = 'video'; } else if (fileType == DefaultAttachmentTypes.file) { attachmentType = 'file'; } if (camera) { PickedFile pickedFile; if (fileType == DefaultAttachmentTypes.image) { pickedFile = await _imagePicker.getImage(source: ImageSource.camera); } else if (fileType == DefaultAttachmentTypes.video) { pickedFile = await _imagePicker.getVideo(source: ImageSource.camera); } if (pickedFile == null) { return; } final bytes = await pickedFile.readAsBytes(); file = AttachmentFile( size: bytes.length, path: pickedFile.path, bytes: bytes, ); } else { FileType type; if (fileType == DefaultAttachmentTypes.image) { type = FileType.image; } else if (fileType == DefaultAttachmentTypes.video) { type = FileType.video; } else if (fileType == DefaultAttachmentTypes.file) { type = FileType.any; } final res = await FilePicker.platform.pickFiles( type: type, withData: true, ); if (res?.files?.isNotEmpty == true) { file = res.files.single.toAttachmentFile; } } setState(() => _inputEnabled = true); if (file == null) return; final mimeType = file.name?.mimeType ?? file.path.split('/').last.mimeType; final extraDataMap = {}; if (mimeType?.subtype != null) { extraDataMap['mime_type'] = mimeType.subtype.toLowerCase(); } if (file.size != null) { extraDataMap['file_size'] = file.size; } final attachment = Attachment( file: file, type: attachmentType, extraData: extraDataMap.isNotEmpty ? extraDataMap : null, ); if (file.size > _kMaxAttachmentSize) { if (attachmentType == 'Video') { final mediaInfo = await VideoService.compressVideo(file.path); if (mediaInfo.filesize > _kMaxAttachmentSize) { _showErrorAlert( 'The file is too large to upload. The file size limit is 20MB. We tried compressing it, but it was not enough.', ); return; } file = AttachmentFile( name: file.name, size: mediaInfo.filesize, bytes: await mediaInfo.file.readAsBytes(), path: mediaInfo.path, ); } else { _showErrorAlert( 'The file is too large to upload. The file size limit is 20MB.', ); return; } } _attachments[attachment.id] = attachment; setState(() { _attachments.update(attachment.id, (it) { return it.copyWith( file: file, extraData: {...it.extraData}..update('file_size', (_) => file.size), ); }); }); } Widget _buildIdleSendButton(BuildContext context) { return Padding( padding: const EdgeInsets.all(8.0), child: StreamSvgIcon( assetName: _getIdleSendIcon(), color: StreamChatTheme.of(context).messageInputTheme.sendButtonIdleColor, ), ); } Widget _buildSendButton(BuildContext context) { return Padding( padding: const EdgeInsets.all(8.0), child: IconButton( onPressed: sendMessage, padding: const EdgeInsets.all(0), splashRadius: 24, constraints: BoxConstraints.tightFor( height: 24, width: 24, ), icon: StreamSvgIcon( assetName: _getSendIcon(), color: StreamChatTheme.of(context).messageInputTheme.sendButtonColor, ), ), ); } String _getIdleSendIcon() { if (_commandEnabled) { return 'Icon_search.svg'; } else { return 'Icon_circle_right.svg'; } } String _getSendIcon() { if (widget.editMessage != null) { return 'Icon_circle_up.svg'; } else if (_commandEnabled) { return 'Icon_search.svg'; } else { return 'Icon_circle_up.svg'; } } /// Sends the current message void sendMessage() async { var text = textEditingController.text.trim(); if (text.isEmpty && _attachments.isEmpty) { return; } final shouldUnfocus = _commandEnabled; if (_commandEnabled) { text = '/${_chosenCommand.name} ' + text; } final attachments = [..._attachments.values]; textEditingController.clear(); _attachments.clear(); if (widget.onQuotedMessageCleared != null) { widget.onQuotedMessageCleared(); } setState(() { _messageIsPresent = false; _commandEnabled = false; }); _commandsOverlay?.remove(); _commandsOverlay = null; _mentionsOverlay?.remove(); _mentionsOverlay = null; Future sendingFuture; Message message; if (widget.editMessage != null) { message = widget.editMessage.copyWith( text: text, attachments: attachments, mentionedUsers: _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), ); } else { message = (widget.initialMessage ?? Message()).copyWith( parentId: widget.parentMessage?.id, text: text, attachments: attachments, mentionedUsers: _mentionedUsers.where((u) => text.contains('@${u.name}')).toList(), showInChannel: widget.parentMessage != null ? _sendAsDm : null, ); } if (widget.quotedMessage != null) { message = message.copyWith( quotedMessageId: widget.quotedMessage.id, ); } if (widget.preMessageSending != null) { message = await widget.preMessageSending(message); } final streamChannel = StreamChannel.of(context); final channel = streamChannel.channel; if (!channel.state.isUpToDate) { await streamChannel.reloadChannel(); } _mentionedUsers.clear(); if (widget.editMessage == null || widget.editMessage.status == MessageSendingStatus.failed || widget.editMessage.status == MessageSendingStatus.sending) { sendingFuture = channel.sendMessage(message); } else { sendingFuture = channel.updateMessage(message); } if (!shouldUnfocus) { FocusScope.of(context).requestFocus(_focusNode); } return sendingFuture.then((resp) { if (resp.message?._type == 'error') { _parseExistingMessage(message); } if (widget.onMessageSent != null) { widget.onMessageSent(resp.message); } }); } StreamSubscription _keyboardListener; void _showErrorAlert(String description) { showModalBottomSheet( backgroundColor: StreamChatTheme.of(context).colorTheme.white, context: context, shape: RoundedRectangleBorder( borderRadius: BorderRadius.only( topLeft: Radius.circular(16.0), topRight: Radius.circular(16.0), )), builder: (context) { return Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( height: 26.0, ), StreamSvgIcon.error( color: StreamChatTheme.of(context).colorTheme.accentRed, size: 24.0, ), SizedBox( height: 26.0, ), Text( 'Something went wrong', style: StreamChatTheme.of(context).textTheme.headlineBold, ), SizedBox( height: 7.0, ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16.0), child: Text( description, textAlign: TextAlign.center, ), ), SizedBox( height: 36.0, ), Container( color: StreamChatTheme.of(context).colorTheme.black.withOpacity(.08), height: 1.0, ), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: Text( 'OK', style: StreamChatTheme.of(context) .textTheme .bodyBold .copyWith( color: StreamChatTheme.of(context) .colorTheme .accentBlue), ), ), ], ), ], ); }, ); } void _parseExistingMessage(Message message) { textEditingController.text = message.text; _messageIsPresent = true; if (message.attachments != null) { for (final attachment in message.attachments) { _attachments[attachment.id] = attachment.copyWith( uploadState: attachment.uploadState ?? UploadState.success(), ); } } } @override void dispose() { _commandsOverlay?.remove(); _emojiOverlay?.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(); } } /// Represents a 2-tuple, or pair. class Tuple2 { /// Returns the first item of the tuple final T1 item1; /// Returns the second item of the tuple final T2 item2; /// Creates a new tuple value with the specified items. const Tuple2(this.item1, this.item2); /// Create a new tuple value with the specified list [items]. factory Tuple2.fromList(List items) { if (items.length != 2) { throw ArgumentError('items must have length 2'); } return Tuple2(items[0] as T1, items[1] as T2); } /// Returns a tuple with the first item set to the specified value. Tuple2 withItem1(T1 v) => Tuple2(v, item2); /// Returns a tuple with the second item set to the specified value. Tuple2 withItem2(T2 v) => Tuple2(item1, v); /// Creates a [List] containing the items of this [Tuple2]. /// /// The elements are in item order. The list is variable-length /// if [growable] is true. List toList({bool growable = false}) => List.from([item1, item2], growable: growable); @override String toString() => '[$item1, $item2]'; @override bool operator ==(Object other) => identical(this, other) || other is Tuple2 && runtimeType == other.runtimeType && item1 == other.item1 && item2 == other.item2; @override int get hashCode => item1.hashCode ^ item2.hashCode; } class _PickerWidget extends StatefulWidget { final int filePickerIndex; final bool containsFile; final List selectedMedias; final void Function(DefaultAttachmentTypes) onAddMoreFilesClick; final void Function(AssetEntity) onMediaSelected; const _PickerWidget({ Key key, @required this.filePickerIndex, @required this.containsFile, @required this.selectedMedias, @required this.onAddMoreFilesClick, @required this.onMediaSelected, }) : super(key: key); @override __PickerWidgetState createState() => __PickerWidgetState(); } class __PickerWidgetState extends State<_PickerWidget> { Future requestPermission; @override void initState() { super.initState(); requestPermission = PhotoManager.requestPermission(); } @override Widget build(BuildContext context) { if (widget.filePickerIndex != 0) { return Offstage(); } return FutureBuilder( future: requestPermission, builder: (context, snapshot) { if (!snapshot.hasData) { return const Center(child: CircularProgressIndicator()); } if (snapshot.data) { if (widget.containsFile) { return GestureDetector( onTap: () { widget.onAddMoreFilesClick(DefaultAttachmentTypes.file); }, child: Container( constraints: BoxConstraints.expand(), color: StreamChatTheme.of(context).colorTheme.whiteSmoke, alignment: Alignment.center, child: Text( 'Add more files', style: TextStyle( color: StreamChatTheme.of(context).colorTheme.accentBlue, fontWeight: FontWeight.bold, ), ), ), ); } return MediaListView( selectedIds: widget.selectedMedias, onSelect: widget.onMediaSelected, ); } return InkWell( onTap: () async { PhotoManager.openSetting(); }, child: Container( color: StreamChatTheme.of(context).colorTheme.whiteSmoke, child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SvgPicture.asset( 'svgs/icon_picture_empty_state.svg', package: 'stream_chat_flutter', height: 140, color: StreamChatTheme.of(context).colorTheme.greyGainsboro, ), Text( 'Please enable access to your photos \nand videos so you can share them with friends.', style: StreamChatTheme.of(context).textTheme.body.copyWith( color: StreamChatTheme.of(context).colorTheme.grey), textAlign: TextAlign.center, ), SizedBox(height: 6.0), Center( child: Text( 'Allow access to your gallery', style: StreamChatTheme.of(context) .textTheme .bodyBold .copyWith( color: StreamChatTheme.of(context) .colorTheme .accentBlue, ), ), ), ], ), ), ); }); } }