From 9dba5a61d2a304d7702da1a9a0883ce23226cd98 Mon Sep 17 00:00:00 2001 From: Sahil Kumar Date: Wed, 11 Aug 2021 17:01:50 +0530 Subject: [PATCH] feat(ui): add possibility to limit attachments in `MessageInput` Signed-off-by: xsahil03x --- .../stream_chat_flutter/example/lib/main.dart | 2 +- .../lib/src/message_input.dart | 462 +++++++++--------- .../stream_chat_flutter/lib/src/utils.dart | 48 ++ 3 files changed, 268 insertions(+), 244 deletions(-) diff --git a/packages/stream_chat_flutter/example/lib/main.dart b/packages/stream_chat_flutter/example/lib/main.dart index eb03f89f..3d292b09 100644 --- a/packages/stream_chat_flutter/example/lib/main.dart +++ b/packages/stream_chat_flutter/example/lib/main.dart @@ -106,7 +106,7 @@ class ChannelPage extends StatelessWidget { Expanded( child: MessageListView(), ), - MessageInput(), + MessageInput(attachmentLimit: 3), ], ), ); diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 9431cd22..4f61258c 100644 --- a/packages/stream_chat_flutter/lib/src/message_input.dart +++ b/packages/stream_chat_flutter/lib/src/message_input.dart @@ -37,6 +37,16 @@ typedef ErrorListener = void Function( StackTrace? stackTrace, ); +/// A callback that can be passed to [MessageInput.onAttachmentLimitExceed]. +/// +/// This callback should not throw. +/// +/// It exists merely for showing custom error, and should not be used otherwise. +typedef AttachmentLimitExceedListener = void Function( + int limit, + String error, +); + /// Builder for attachment thumbnails typedef AttachmentThumbnailBuilder = Widget Function( BuildContext, @@ -164,7 +174,13 @@ class MessageInput extends StatefulWidget { this.compressedVideoQuality = VideoQuality.DefaultQuality, this.compressedVideoFrameRate = 30, this.onError, - }) : super(key: key); + this.attachmentLimit = 10, + this.onAttachmentLimitExceed, + }) : assert( + initialMessage == null || editMessage == null, + "Can't provide both `initialMessage` and `editMessage`", + ), + super(key: key); /// Message to edit final Message? editMessage; @@ -247,6 +263,11 @@ class MessageInput extends StatefulWidget { /// A callback for error reporting final ErrorListener? onError; + final int attachmentLimit; + + /// A callback for error reporting + final AttachmentLimitExceedListener? onAttachmentLimitExceed; + @override MessageInputState createState() => MessageInputState(); @@ -270,7 +291,6 @@ class MessageInputState extends State { final _imagePicker = ImagePicker(); late final FocusNode _focusNode; bool _inputEnabled = true; - bool _messageIsPresent = false; bool _commandEnabled = false; OverlayEntry? _commandsOverlay, _mentionsOverlay, _emojiOverlay; late Iterable _emojiNames; @@ -285,13 +305,15 @@ class MessageInputState extends State { KeyboardVisibilityController(); /// The editing controller passed to the input TextField - late final TextEditingController textEditingController; + late final TextEditingController _textEditingController; late StreamChatThemeData _streamChatTheme; late MessageInputThemeData _messageInputTheme; bool get _hasQuotedMessage => widget.quotedMessage != null; + bool get _messageIsPresent => _textEditingController.text.trim().isNotEmpty; + @override void initState() { super.initState(); @@ -303,19 +325,19 @@ class MessageInputState extends State { _keyboardListener = _keyboardVisibilityController.onChange.listen((visible) { if (_focusNode.hasFocus) { - _onChanged(context, textEditingController.text); + _onChanged(context, _textEditingController.text); } }); } - textEditingController = + _textEditingController = widget.textEditingController ?? TextEditingController(); if (widget.editMessage != null || widget.initialMessage != null) { _parseExistingMessage(widget.editMessage ?? widget.initialMessage!); } - textEditingController.addListener(() { - _onChanged(context, textEditingController.text); + _textEditingController.addListener(() { + _onChanged(context, _textEditingController.text); }); _focusNode.addListener(() { @@ -582,7 +604,7 @@ class MessageInputState extends State { maxLines: null, onSubmitted: (_) => sendMessage(), keyboardType: widget.keyboardType, - controller: textEditingController, + controller: _textEditingController, focusNode: _focusNode, style: _messageInputTheme.inputTextStyle, autofocus: widget.autofocus, @@ -728,7 +750,6 @@ class MessageInputState extends State { .catchError((e) {}); setState(() { - _messageIsPresent = s.trim().isNotEmpty; _actionsShrunk = s.trim().isNotEmpty && ((widget.actions?.length ?? 0) + (widget.showCommandsButton ? 1 : 0) + @@ -764,15 +785,15 @@ class MessageInputState extends State { void _checkEmoji(String s, BuildContext context) { if (s.isNotEmpty && - textEditingController.selection.baseOffset > 0 && - textEditingController.text + _textEditingController.selection.baseOffset > 0 && + _textEditingController.text .substring( 0, - textEditingController.selection.baseOffset, + _textEditingController.selection.baseOffset, ) .contains(':')) { - final textToSelection = textEditingController.text - .substring(0, textEditingController.value.selection.start); + 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); @@ -791,9 +812,9 @@ class MessageInputState extends State { void _checkMentions(String s, BuildContext context) { if (s.isNotEmpty && - textEditingController.selection.baseOffset > 0 && - textEditingController.text - .substring(0, textEditingController.selection.baseOffset) + _textEditingController.selection.baseOffset > 0 && + _textEditingController.text + .substring(0, _textEditingController.selection.baseOffset) .split(' ') .last .contains('@')) { @@ -816,8 +837,7 @@ class MessageInputState extends State { if (matchedCommandsList.length == 1) { _chosenCommand = matchedCommandsList[0]; - textEditingController.clear(); - _messageIsPresent = false; + _textEditingController.clear(); setState(() { _commandEnabled = true; }); @@ -833,7 +853,7 @@ class MessageInputState extends State { } OverlayEntry? _buildCommandsOverlayEntry() { - final text = textEditingController.text.trimLeft(); + final text = _textEditingController.text.trimLeft(); final commands = StreamChannel.of(context) .channel .config @@ -1037,7 +1057,7 @@ class MessageInputState extends State { onPressed: _attachmentContainsFile && _attachments.isNotEmpty ? null : () { - pickFile(DefaultAttachmentTypes.image, true); + pickFile(DefaultAttachmentTypes.image, camera: true); }, ), IconButton( @@ -1048,7 +1068,7 @@ class MessageInputState extends State { onPressed: _attachmentContainsFile && _attachments.isNotEmpty ? null : () { - pickFile(DefaultAttachmentTypes.video, true); + pickFile(DefaultAttachmentTypes.video, camera: true); }, ), ], @@ -1107,7 +1127,7 @@ class MessageInputState extends State { if (_attachments.containsKey(media.id)) { setState(() => _attachments.remove(media.id)); } else { - _addAttachment(media); + _addAssetAttachment(media); } }, ), @@ -1119,15 +1139,13 @@ class MessageInputState extends State { ); } - void _addAttachment(AssetEntity medium) async { + void _addAssetAttachment(AssetEntity medium) async { final mediaFile = await medium.originFile.timeout( const Duration(seconds: 5), onTimeout: () => medium.originFile, ); - if (mediaFile == null) { - return; - } + if (mediaFile == null) return; var file = AttachmentFile( path: mediaFile.path, @@ -1166,11 +1184,12 @@ class MessageInputState extends State { } setState(() { - _attachments[medium.id] = Attachment( + final attachment = Attachment( id: medium.id, file: file, type: medium.type == AssetType.image ? 'image' : 'video', ); + _addAttachments([attachment]); }); } @@ -1251,8 +1270,8 @@ class MessageInputState extends State { } OverlayEntry? _buildMentionsOverlayEntry() { - final splits = textEditingController.text - .substring(0, textEditingController.value.selection.start) + final splits = _textEditingController.text + .substring(0, _textEditingController.value.selection.start) .split('@'); final query = splits.last.toLowerCase(); @@ -1314,10 +1333,10 @@ class MessageInputState extends State { splits[splits.length - 1] = m.user!.name; final rejoin = splits.join('@'); - textEditingController.value = TextEditingValue( + _textEditingController.value = TextEditingValue( text: rejoin + - textEditingController.text.substring( - textEditingController.selection.start), + _textEditingController.text.substring( + _textEditingController.selection.start), selection: TextSelection.collapsed( offset: rejoin.length, ), @@ -1361,8 +1380,8 @@ class MessageInputState extends State { } OverlayEntry? _buildEmojiOverlay() { - final splits = textEditingController.text - .substring(0, textEditingController.value.selection.start) + final splits = _textEditingController.text + .substring(0, _textEditingController.value.selection.start) .split(':'); final query = splits.last.toLowerCase(); @@ -1473,10 +1492,10 @@ class MessageInputState extends State { void _chooseEmoji(List splits, Emoji emoji) { final rejoin = splits.sublist(0, splits.length - 1).join(':') + emoji.char!; - textEditingController.value = TextEditingValue( + _textEditingController.value = TextEditingValue( text: rejoin + - textEditingController.text - .substring(textEditingController.selection.start), + _textEditingController.text + .substring(_textEditingController.selection.start), selection: TextSelection.collapsed( offset: rejoin.length, ), @@ -1487,11 +1506,10 @@ class MessageInputState extends State { } void _setCommand(Command c) { - textEditingController.clear(); + _textEditingController.clear(); setState(() { _chosenCommand = c; _commandEnabled = true; - _messageIsPresent = false; }); _commandsOverlay?.remove(); _commandsOverlay = null; @@ -1682,7 +1700,7 @@ class MessageInputState extends State { } Widget _buildCommandButton() { - final s = textEditingController.text.trim(); + final s = _textEditingController.text.trim(); return IconButton( icon: StreamSvgIcon.lightning( @@ -1768,87 +1786,100 @@ class MessageInputState extends State { }); } else { showModalBottomSheet( - clipBehavior: Clip.hardEdge, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(32), - topRight: Radius.circular(32), - ), + clipBehavior: Clip.hardEdge, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(32), + topRight: Radius.circular(32), ), - context: context, - isScrollControlled: true, - builder: (_) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: Text( - context.translations.addAFileLabel, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ), - ListTile( - leading: const Icon(Icons.image), - title: Text(context.translations.uploadAPhotoLabel), - onTap: () { - pickFile(DefaultAttachmentTypes.image); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.video_library), - title: Text(context.translations.uploadAVideoLabel), - onTap: () { - pickFile(DefaultAttachmentTypes.video); - Navigator.pop(context); - }, - ), - if (!kIsWeb) - ListTile( - leading: const Icon(Icons.camera_alt), - title: Text(context.translations.photoFromCameraLabel), - onTap: () { - pickFile(DefaultAttachmentTypes.image, true); - Navigator.pop(context); - }, - ), - if (!kIsWeb) - ListTile( - leading: const Icon(Icons.videocam), - title: Text(context.translations.videoFromCameraLabel), - onTap: () { - pickFile(DefaultAttachmentTypes.video, true); - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.insert_drive_file), - title: Text(context.translations.uploadAFileLabel), - onTap: () { - pickFile(DefaultAttachmentTypes.file); - Navigator.pop(context); - }, - ), - ], - )); + ), + context: context, + isScrollControlled: true, + builder: (_) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + context.translations.addAFileLabel, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + ListTile( + leading: const Icon(Icons.image), + title: Text(context.translations.uploadAPhotoLabel), + onTap: () { + pickFile(DefaultAttachmentTypes.image); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.video_library), + title: Text(context.translations.uploadAVideoLabel), + onTap: () { + pickFile(DefaultAttachmentTypes.video); + Navigator.pop(context); + }, + ), + if (!kIsWeb) + ListTile( + leading: const Icon(Icons.camera_alt), + title: Text(context.translations.photoFromCameraLabel), + onTap: () { + pickFile(DefaultAttachmentTypes.image, camera: true); + Navigator.pop(context); + }, + ), + if (!kIsWeb) + ListTile( + leading: const Icon(Icons.videocam), + title: Text(context.translations.videoFromCameraLabel), + onTap: () { + pickFile(DefaultAttachmentTypes.video, camera: true); + Navigator.pop(context); + }, + ), + ListTile( + leading: const Icon(Icons.insert_drive_file), + title: Text(context.translations.uploadAFileLabel), + onTap: () { + pickFile(DefaultAttachmentTypes.file); + 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, + /// Adds an attachment to the [_attachments] map + void _addAttachments(Iterable attachments) { + final length = _attachments.length + attachments.length; + if (length > widget.attachmentLimit) { + final onAttachmentLimitExceed = widget.onAttachmentLimitExceed; + if (onAttachmentLimitExceed != null) { + return onAttachmentLimitExceed( + widget.attachmentLimit, + 'Attachment Limit crossed ${widget.attachmentLimit}', + ); + } + return _showErrorAlert( + 'Attachment Limit crossed ${widget.attachmentLimit}', ); - }); + } + for (final attachment in attachments) { + _attachments[attachment.id] = attachment; + } } /// Pick a file from the device /// If [camera] is true then the camera will open - // ignore: avoid_positional_boolean_parameters - void pickFile(DefaultAttachmentTypes fileType, [bool camera = false]) async { + void pickFile( + DefaultAttachmentTypes fileType, { + bool camera = false, + }) async { setState(() => _inputEnabled = false); AttachmentFile? file; @@ -1947,16 +1978,14 @@ class MessageInputState extends State { } } - _attachments[attachment.id] = attachment; - setState(() { - _attachments.update( - attachment.id, - (it) => it.copyWith( - file: file, - extraData: {...it.extraData} - ..update('file_size', ((_) => file!.size!)), - )); + _addAttachments([ + attachment.copyWith( + file: file, + extraData: {...attachment.extraData} + ..update('file_size', ((_) => file!.size!)), + ), + ]); }); } @@ -2005,7 +2034,7 @@ class MessageInputState extends State { /// Sends the current message Future sendMessage() async { - var text = textEditingController.text.trim(); + var text = _textEditingController.text.trim(); if (text.isEmpty && _attachments.isEmpty) { return; } @@ -2018,12 +2047,11 @@ class MessageInputState extends State { final attachments = [..._attachments.values]; - textEditingController.clear(); + _textEditingController.clear(); _attachments.clear(); widget.onQuotedMessageCleared?.call(); setState(() { - _messageIsPresent = false; _commandEnabled = false; }); @@ -2153,7 +2181,8 @@ class MessageInputState extends State { child: Text( context.translations.okLabel, style: _streamChatTheme.textTheme.bodyBold.copyWith( - color: _streamChatTheme.colorTheme.accentPrimary), + color: _streamChatTheme.colorTheme.accentPrimary, + ), ), ), ], @@ -2164,13 +2193,8 @@ class MessageInputState extends State { } void _parseExistingMessage(Message message) { - textEditingController.text = message.text!; - _messageIsPresent = true; - for (final attachment in message.attachments) { - _attachments[attachment.id] = attachment.copyWith( - uploadState: attachment.uploadState, - ); - } + _textEditingController.text = message.text!; + _addAttachments(message.attachments); } @override @@ -2196,54 +2220,6 @@ class MessageInputState extends State { } } -/// Represents a 2-tuple, or pair. -class Tuple2 { - /// 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 the first item of the tuple - final T1 item1; - - /// Returns the second item of the tuple - final T2 item2; - - /// 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 { const _PickerWidget({ Key? key, @@ -2281,74 +2257,74 @@ class _PickerWidgetState extends State<_PickerWidget> { return const Offstage(); } return FutureBuilder( - future: requestPermission, - builder: (context, snapshot) { - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } + 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: const BoxConstraints.expand(), - color: widget.streamChatTheme.colorTheme.inputBg, - alignment: Alignment.center, + if (snapshot.data!) { + if (widget.containsFile) { + return GestureDetector( + onTap: () { + widget.onAddMoreFilesClick(DefaultAttachmentTypes.file); + }, + child: Container( + constraints: const BoxConstraints.expand(), + color: widget.streamChatTheme.colorTheme.inputBg, + alignment: Alignment.center, + child: Text( + context.translations.addMoreFilesLabel, + style: TextStyle( + color: widget.streamChatTheme.colorTheme.accentPrimary, + fontWeight: FontWeight.bold, + ), + ), + ), + ); + } + return MediaListView( + selectedIds: widget.selectedMedias, + onSelect: widget.onMediaSelected, + ); + } + + return InkWell( + onTap: () async { + PhotoManager.openSetting(); + }, + child: Container( + color: widget.streamChatTheme.colorTheme.inputBg, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SvgPicture.asset( + 'svgs/icon_picture_empty_state.svg', + package: 'stream_chat_flutter', + height: 140, + color: widget.streamChatTheme.colorTheme.disabled, + ), + Text( + context.translations.enablePhotoAndVideoAccessMessage, + style: widget.streamChatTheme.textTheme.body.copyWith( + color: widget.streamChatTheme.colorTheme.textLowEmphasis), + textAlign: TextAlign.center, + ), + const SizedBox(height: 6), + Center( child: Text( - context.translations.addMoreFilesLabel, - style: TextStyle( + context.translations.allowGalleryAccessMessage, + style: widget.streamChatTheme.textTheme.bodyBold.copyWith( color: widget.streamChatTheme.colorTheme.accentPrimary, - fontWeight: FontWeight.bold, ), ), ), - ); - } - return MediaListView( - selectedIds: widget.selectedMedias, - onSelect: widget.onMediaSelected, - ); - } - - return InkWell( - onTap: () async { - PhotoManager.openSetting(); - }, - child: Container( - color: widget.streamChatTheme.colorTheme.inputBg, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SvgPicture.asset( - 'svgs/icon_picture_empty_state.svg', - package: 'stream_chat_flutter', - height: 140, - color: widget.streamChatTheme.colorTheme.disabled, - ), - Text( - context.translations.enablePhotoAndVideoAccessMessage, - style: widget.streamChatTheme.textTheme.body.copyWith( - color: - widget.streamChatTheme.colorTheme.textLowEmphasis), - textAlign: TextAlign.center, - ), - const SizedBox(height: 6), - Center( - child: Text( - context.translations.allowGalleryAccessMessage, - style: widget.streamChatTheme.textTheme.bodyBold.copyWith( - color: widget.streamChatTheme.colorTheme.accentPrimary, - ), - ), - ), - ], - ), + ], ), - ); - }); + ), + ); + }, + ); } } diff --git a/packages/stream_chat_flutter/lib/src/utils.dart b/packages/stream_chat_flutter/lib/src/utils.dart index 314a788a..d97ea6a1 100644 --- a/packages/stream_chat_flutter/lib/src/utils.dart +++ b/packages/stream_chat_flutter/lib/src/utils.dart @@ -340,3 +340,51 @@ Widget wrapAttachmentWidget( type: MaterialType.transparency, child: attachmentWidget, ); + +/// Represents a 2-tuple, or pair. +class Tuple2 { + /// 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 the first item of the tuple + final T1 item1; + + /// Returns the second item of the tuple + final T2 item2; + + /// 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; +}