diff --git a/packages/stream_chat_flutter/CHANGELOG.md b/packages/stream_chat_flutter/CHANGELOG.md index b58b9992..afeaeffa 100644 --- a/packages/stream_chat_flutter/CHANGELOG.md +++ b/packages/stream_chat_flutter/CHANGELOG.md @@ -6,14 +6,15 @@ Added `StreamChatThemeData.placeholderUserImage` for building a widget when the `UserAvatar` image is loading - Added a `backgroundColor` property to the following widgets: - - `ChannelHeader` - - `ChannelListHeader` - - `GalleryHeader` - - `GalleryFooter` - - `ThreadHeader` - -- Added `MessageInput.attachmentButtonBuilder` and `MessageInput.commandButtonBuilder` for more - customizations. + - `ChannelHeader` + - `ChannelListHeader` + - `GalleryHeader` + - `GalleryFooter` + - `ThreadHeader` +- Added `MessageInput.attachmentLimit` in order to limit the no. of attachments that can be sent with a single message. +- Added `MessageInput.onAttachmentLimitExceed` callback which will be called when the `attachmentLimit` is exceeded. + This will override the default error alert behaviour. +- Added `MessageInput.attachmentButtonBuilder` and `MessageInput.commandButtonBuilder` for more customizations. ```dart typedef ActionButtonBuilder = Widget Function( 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/localization/translations.dart b/packages/stream_chat_flutter/lib/src/localization/translations.dart index e603656c..246cd9bf 100644 --- a/packages/stream_chat_flutter/lib/src/localization/translations.dart +++ b/packages/stream_chat_flutter/lib/src/localization/translations.dart @@ -308,6 +308,10 @@ abstract class Translations { /// The label for "Reply to message" String get replyToMessageLabel; + + /// Label for "Attachment limit exceeded: + /// it's not possible to add more than $limit attachments" + String attachmentLimitExceedError(int limit); } /// Default implementation of Translation strings for the stream chat widgets @@ -673,4 +677,8 @@ class DefaultTranslations implements Translations { @override String get slowModeOnLabel => 'Slow mode ON'; + + @override + String attachmentLimitExceedError(int limit) => """ +Attachment limit exceeded: it's not possible to add more than $limit attachments"""; } diff --git a/packages/stream_chat_flutter/lib/src/message_input.dart b/packages/stream_chat_flutter/lib/src/message_input.dart index 388df21b..4227c90d 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, @@ -173,9 +183,15 @@ class MessageInput extends StatefulWidget { this.compressedVideoQuality = VideoQuality.DefaultQuality, this.compressedVideoFrameRate = 30, this.onError, + this.attachmentLimit = 10, + this.onAttachmentLimitExceed, this.attachmentButtonBuilder, this.commandButtonBuilder, - }) : super(key: key); + }) : assert( + initialMessage == null || editMessage == null, + "Can't provide both `initialMessage` and `editMessage`", + ), + super(key: key); /// Message to edit final Message? editMessage; @@ -258,6 +274,14 @@ class MessageInput extends StatefulWidget { /// A callback for error reporting final ErrorListener? onError; + /// A limit for the no. of attachments that can be sent with a single message. + final int attachmentLimit; + + /// A callback for when the [attachmentLimit] is exceeded. + /// + /// This will override the default error alert behaviour. + final AttachmentLimitExceedListener? onAttachmentLimitExceed; + /// Builder for customizing the attachment button. /// /// The builder contains the default [IconButton] that can be customized by @@ -293,7 +317,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; @@ -303,9 +326,8 @@ class MessageInputState extends State { bool _sendAsDm = false; bool _openFilePickerSection = false; int _filePickerIndex = 0; - double _filePickerSize = _kMinMediaPickerSize; - final KeyboardVisibilityController _keyboardVisibilityController = - KeyboardVisibilityController(); + + final _keyboardVisibilityController = KeyboardVisibilityController(); /// The editing controller passed to the input TextField late final TextEditingController textEditingController; @@ -315,6 +337,7 @@ class MessageInputState extends State { bool get _hasQuotedMessage => widget.quotedMessage != null; + bool get _messageIsPresent => textEditingController.text.trim().isNotEmpty; late DateTime? _cooldownStartedAt; int? _timeOut; @@ -602,7 +625,7 @@ class MessageInputState extends State { final margin = (widget.sendButtonLocation == SendButtonLocation.inside ? const EdgeInsets.only(right: 8) : EdgeInsets.zero) + - (widget.actionsLocation != ActionsLocation.left + (widget.actionsLocation != ActionsLocation.left || _commandEnabled ? const EdgeInsets.only(left: 8) : EdgeInsets.zero); return Expanded( @@ -780,7 +803,6 @@ class MessageInputState extends State { .catchError((e) {}); setState(() { - _messageIsPresent = s.trim().isNotEmpty; _actionsShrunk = s.trim().isNotEmpty && ((widget.actions?.length ?? 0) + (widget.showCommandsButton ? 1 : 0) + @@ -873,7 +895,6 @@ class MessageInputState extends State { if (matchedCommandsList.length == 1) { _chosenCommand = matchedCommandsList[0]; textEditingController.clear(); - _messageIsPresent = false; setState(() { _commandEnabled = true; }); @@ -1020,6 +1041,9 @@ class MessageInputState extends State { final _attachmentContainsFile = _attachments.values.any((it) => it.type == 'file'); + final attachmentLimitCrossed = + _attachments.length >= widget.attachmentLimit; + Color _getIconColor(int index) { final streamChatThemeData = _streamChatTheme; switch (index) { @@ -1039,15 +1063,21 @@ class MessageInputState extends State { : streamChatThemeData.colorTheme.textHighEmphasis .withOpacity(0.2)); case 2: - return _attachmentContainsFile && _attachments.isNotEmpty + return attachmentLimitCrossed ? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2) - : streamChatThemeData.colorTheme.textHighEmphasis - .withOpacity(0.5); + : _attachmentContainsFile && _attachments.isNotEmpty + ? streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.2) + : streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.5); case 3: - return _attachmentContainsFile && _attachments.isNotEmpty + return attachmentLimitCrossed ? streamChatThemeData.colorTheme.textHighEmphasis.withOpacity(0.2) - : streamChatThemeData.colorTheme.textHighEmphasis - .withOpacity(0.5); + : _attachmentContainsFile && _attachments.isNotEmpty + ? streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.2) + : streamChatThemeData.colorTheme.textHighEmphasis + .withOpacity(0.5); default: return Colors.black; } @@ -1055,7 +1085,7 @@ class MessageInputState extends State { return AnimatedContainer( duration: const Duration(milliseconds: 300), - height: _openFilePickerSection ? _filePickerSize : 0, + height: _openFilePickerSection ? _kMinMediaPickerSize : 0, child: Material( color: _streamChatTheme.colorTheme.inputBg, child: Column( @@ -1090,10 +1120,11 @@ class MessageInputState extends State { icon: StreamSvgIcon.camera( color: _getIconColor(2), ), - onPressed: _attachmentContainsFile && _attachments.isNotEmpty + onPressed: attachmentLimitCrossed || + (_attachmentContainsFile && _attachments.isNotEmpty) ? null : () { - pickFile(DefaultAttachmentTypes.image, true); + pickFile(DefaultAttachmentTypes.image, camera: true); }, ), IconButton( @@ -1101,46 +1132,32 @@ class MessageInputState extends State { icon: StreamSvgIcon.record( color: _getIconColor(3), ), - onPressed: _attachmentContainsFile && _attachments.isNotEmpty + onPressed: attachmentLimitCrossed || + (_attachmentContainsFile && _attachments.isNotEmpty) ? null : () { - pickFile(DefaultAttachmentTypes.video, true); + pickFile(DefaultAttachmentTypes.video, camera: true); }, ), ], ), - GestureDetector( - onVerticalDragUpdate: (update) { - setState(() { - _filePickerSize = (_filePickerSize - update.delta.dy).clamp( - _kMinMediaPickerSize, - MediaQuery.of(context).size.height / 1.7, - ); - }); - }, - child: DecoratedBox( - decoration: BoxDecoration( - color: _streamChatTheme.colorTheme.barsBg, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(16), - topRight: Radius.circular(16), - ), + DecoratedBox( + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.barsBg, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), ), - child: SizedBox( - width: double.infinity, - child: Center( - child: Padding( - padding: const EdgeInsets.all(8), - child: SizedBox( - width: 40, - height: 4, - child: DecoratedBox( - decoration: BoxDecoration( - color: _streamChatTheme.colorTheme.inputBg, - borderRadius: BorderRadius.circular(4), - ), - ), - ), + ), + child: Center( + child: Padding( + padding: const EdgeInsets.all(8), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: _streamChatTheme.colorTheme.inputBg, + borderRadius: BorderRadius.circular(4), ), ), ), @@ -1163,7 +1180,7 @@ class MessageInputState extends State { if (_attachments.containsKey(media.id)) { setState(() => _attachments.remove(media.id)); } else { - _addAttachment(media); + _addAssetAttachment(media); } }, ), @@ -1175,15 +1192,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, @@ -1222,11 +1237,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]); }); } @@ -1547,7 +1563,6 @@ class MessageInputState extends State { setState(() { _chosenCommand = c; _commandEnabled = true; - _messageIsPresent = false; }); _commandsOverlay?.remove(); _commandsOverlay = null; @@ -1755,10 +1770,7 @@ class MessageInputState extends State { splashRadius: 24, onPressed: () async { if (_openFilePickerSection) { - setState(() { - _openFilePickerSection = false; - _filePickerSize = _kMinMediaPickerSize; - }); + setState(() => _openFilePickerSection = false); await Future.delayed(const Duration(milliseconds: 300)); } @@ -1804,10 +1816,7 @@ class MessageInputState extends State { _mentionsOverlay = null; if (_openFilePickerSection) { - setState(() { - _openFilePickerSection = false; - _filePickerSize = _kMinMediaPickerSize; - }); + setState(() => _openFilePickerSection = false); } else { showAttachmentModal(); } @@ -1831,87 +1840,91 @@ 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); + }, + ), + 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 + /// + /// Note: Only meant to be used from outside the state. void addAttachment(Attachment attachment) { - setState(() { - _attachments[attachment.id] = attachment.copyWith( - uploadState: attachment.uploadState, + setState(() => _addAttachments([attachment])); + } + + /// Adds an attachment to the [_attachments] map + void _addAttachments(Iterable attachments) { + final limit = widget.attachmentLimit; + final length = _attachments.length + attachments.length; + if (length > limit) { + final onAttachmentLimitExceed = widget.onAttachmentLimitExceed; + if (onAttachmentLimitExceed != null) { + return onAttachmentLimitExceed( + widget.attachmentLimit, + context.translations.attachmentLimitExceedError(limit), + ); + } + return _showErrorAlert( + context.translations.attachmentLimitExceedError(limit), ); - }); + } + 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; @@ -2009,16 +2022,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!)), + ), + ]); }); } @@ -2085,7 +2096,6 @@ class MessageInputState extends State { widget.onQuotedMessageCleared?.call(); setState(() { - _messageIsPresent = false; _commandEnabled = false; }); @@ -2216,7 +2226,8 @@ class MessageInputState extends State { child: Text( context.translations.okLabel, style: _streamChatTheme.textTheme.bodyBold.copyWith( - color: _streamChatTheme.colorTheme.accentPrimary), + color: _streamChatTheme.colorTheme.accentPrimary, + ), ), ), ], @@ -2227,13 +2238,9 @@ 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, - ); - } + final messageText = message.text; + if (messageText != null) textEditingController.text = messageText; + _addAttachments(message.attachments); } @override @@ -2252,7 +2259,8 @@ class MessageInputState extends State { void didChangeDependencies() { _streamChatTheme = StreamChatTheme.of(context); _messageInputTheme = MessageInputTheme.of(context); - if (widget.editMessage != null && !_initialized) { + if ((widget.editMessage != null || widget.initialMessage != null) && + !_initialized) { FocusScope.of(context).requestFocus(_focusNode); _initialized = true; } @@ -2260,54 +2268,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, @@ -2345,75 +2305,75 @@ 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; +} diff --git a/packages/stream_chat_localizations/example/lib/add_new_lang.dart b/packages/stream_chat_localizations/example/lib/add_new_lang.dart index eced0616..28ad50fb 100644 --- a/packages/stream_chat_localizations/example/lib/add_new_lang.dart +++ b/packages/stream_chat_localizations/example/lib/add_new_lang.dart @@ -384,6 +384,10 @@ class NnStreamChatLocalizations extends GlobalStreamChatLocalizations { @override String get replyToMessageLabel => 'Reply to Message'; + @override + String attachmentLimitExceedError(int limit) => + 'Attachment limit exceeded, limit: $limit'; + @override String get slowModeOnLabel => 'Slow mode ON'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart index aa9b4249..5cb59755 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_en.dart @@ -360,6 +360,10 @@ class StreamChatLocalizationsEn extends GlobalStreamChatLocalizations { @override String get replyToMessageLabel => 'Reply to Message'; + @override + String attachmentLimitExceedError(int limit) => + 'Attachment limit exceeded, limit: $limit'; + @override String get slowModeOnLabel => 'Slow mode ON'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart index 7244e3d0..69ee9237 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_es.dart @@ -365,6 +365,11 @@ class StreamChatLocalizationsEs extends GlobalStreamChatLocalizations { @override String get replyToMessageLabel => 'Responder al Mensaje'; + @override + String attachmentLimitExceedError(int limit) => ''' +No es posible añadir más de $limit archivos adjuntos + '''; + @override String get slowModeOnLabel => 'Modo lento activado'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart index 0a0b551a..0727075a 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_fr.dart @@ -364,6 +364,11 @@ class StreamChatLocalizationsFr extends GlobalStreamChatLocalizations { @override String get replyToMessageLabel => 'Répondre au Message'; + @override + String attachmentLimitExceedError(int limit) => ''' +Limite de pièces jointes dépassée : il n'est pas possible d'ajouter plus de $limit pièces jointes + '''; + @override String get slowModeOnLabel => 'Mode lent activé'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart index 5da076af..ea1738de 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_hi.dart @@ -359,6 +359,11 @@ class StreamChatLocalizationsHi extends GlobalStreamChatLocalizations { @override String get replyToMessageLabel => 'संदेश का जवाब'; + @override + String attachmentLimitExceedError(int limit) => ''' +अटैचमेंट लिमिट: $limit अटैचमेंट से अधिक जोड़ना संभव नहीं है + '''; + @override String get slowModeOnLabel => 'स्लो मोड चालू'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart index 84cf76f6..64e96dc0 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_it.dart @@ -361,6 +361,11 @@ Il file è troppo grande per essere caricato. Il limite è di $limitInMB MB.'''; @override String get replyToMessageLabel => 'Rispondi al messaggio'; + @override + String attachmentLimitExceedError(int limit) => ''' +Attenzione: il limite massimo di $limit file è stato superato. + '''; + @override String get slowModeOnLabel => 'Slowmode attiva'; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart index 21fc48fa..8b58630d 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ja.dart @@ -349,4 +349,9 @@ class StreamChatLocalizationsJa extends GlobalStreamChatLocalizations { @override String get slowModeOnLabel => 'スローモードオン'; + + @override + String attachmentLimitExceedError(int limit) => ''' +添付ファイルの制限を超えました:$limit個のファイル以上を添付することはできません + '''; } diff --git a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart index 49015674..a52a914f 100644 --- a/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart +++ b/packages/stream_chat_localizations/lib/src/stream_chat_localizations_ko.dart @@ -347,4 +347,8 @@ class StreamChatLocalizationsKo extends GlobalStreamChatLocalizations { @override String get slowModeOnLabel => '슬로모드 켜짐'; + + @override + String attachmentLimitExceedError(int limit) => + '첨부 파일 제한 초과: $limit 이상의 첨부 파일을 추가할 수 없습니다'; } diff --git a/packages/stream_chat_localizations/test/translations_test.dart b/packages/stream_chat_localizations/test/translations_test.dart index c7314a58..669fd285 100644 --- a/packages/stream_chat_localizations/test/translations_test.dart +++ b/packages/stream_chat_localizations/test/translations_test.dart @@ -177,6 +177,7 @@ void main() { expect(localizations.galleryPaginationText, isNotNull); expect(localizations.fileText, isNotNull); expect(localizations.replyToMessageLabel, isNotNull); + expect(localizations.attachmentLimitExceedError(3), isNotNull); }); }