diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 8e42054452..2037134f28 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -31,18 +31,26 @@ "@profileButtonSendDirectMessage": { "description": "Label for button in profile screen to navigate to DMs with the shown user." }, - "cameraAccessDeniedTitle": "Permissions needed", - "@cameraAccessDeniedTitle": { - "description": "Title for dialog when the user needs to grant permissions for camera access." + "permissionsNeededError": "Error", + "@permissionsNeededError": { + "description": "General title for access dialog when we encounter an unknown error." }, - "cameraAccessDeniedMessage": "To upload an image, please grant Zulip additional permissions in Settings.", - "@cameraAccessDeniedMessage": { - "description": "Message for dialog when the user needs to grant permissions for camera access." + "permissionsNeededTitle": "Permissions needed", + "@permissionsNeededTitle": { + "description": "Title for dialog when the user needs to grant additional permissions." + }, + "permissionsNeededOpenSettings": "Open settings", + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." }, - "cameraAccessDeniedButtonText": "Open settings", - "@cameraAccessDeniedButtonText": { + "permissionsNeededCameraAccessDenied": "To upload an image, please grant Zulip additional permissions in Settings.", + "@permissionsNeededCameraAccessDenied": { "description": "Message for dialog when the user needs to grant permissions for camera access." }, + "permissionsReadExternalStorageDenied": "To upload files, please grant Zulip additional permissions in Settings.", + "@permissionsReadExternalStorageDenied": { + "description": "Message for dialog when the user needs to grant permissions for external storage read access." + }, "actionSheetOptionCopy": "Copy message text", "@actionSheetOptionCopy": { "description": "Label for copy message text button on action sheet." @@ -63,6 +71,44 @@ "@errorCopyingFailed": { "description": "Dialog message when copying the text of a message to the users system clipboard failed." }, + "errorFailedToUploadFile": "Failed to upload file: {filename}", + "@errorFailedToUploadFile": { + "description": "Label in compose box showing the specified file failed to upload.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "errorFilesTooLarge": "{num, plural, =1{File is} other{{num} files are}} larger than the server's limit of {maxFileUploadSizeMib} MiB and will not be uploaded:\n\n{listMessage}", + "@errorFilesTooLarge": { + "description": "Error message when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + }, + "maxFileUploadSizeMib": { + "type": "int", + "example": "15" + }, + "listMessage": { + "type": "String", + "example": "foo.txt\nbar.txt" + } + } + }, + "errorFilesTooLargeTitle": "{num, plural, =1{File} other{Files}} too large", + "@errorFilesTooLargeTitle": { + "description": "Error title when attached files are too large in size.", + "placeholders": { + "num": { + "type": "int", + "example": "4" + } + } + }, "errorLoginInvalidInputTitle": "Invalid input", "@errorLoginInvalidInputTitle": { "description": "Error dialog title for login dialog when input is invalid." @@ -71,6 +117,10 @@ "@errorLoginFailed": { "description": "Error dialog title when login for a Zulip server fails." }, + "errorMessageNotSent": "Message not sent", + "@errorMessageNotSent": { + "description": "Message for compose box when a message could not be sent." + }, "errorMessageDoesNotSeemToExist": "That message does not seem to exist.", "@errorMessageDoesNotSeemToExist": { "description": "Error message when loading a message that does not exist." @@ -111,6 +161,92 @@ "@successMessageCopied": { "description": "Dialog message when content of a message was copied to the users system clipboard." }, + "composeBoxAttachFilesTooltip": "Attach files", + "@composeBoxAttachFilesTooltip": { + "description": "Tooltip for compose box icon to attach a file to the message." + }, + "composeBoxAttachMediaTooltip": "Attach images or videos", + "@composeBoxAttachMediaTooltip": { + "description": "Tooltip for compose box icon to attach media to the message." + }, + "composeBoxAttachFromCameraTooltip": "Take a photo", + "@composeBoxAttachFromCameraTooltip": { + "description": "Tooltip for compose box icon to attach an image from the camera to the message." + }, + "composeBoxSelfDMHint": "Jot down something", + "@composeBoxSelfDMHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "composeBoxDMHintNoName": "Type a message", + "@composeBoxDMHintNoName": { + "description": "Hint text for content input when sending a message to one other person but we cannot determine their name." + }, + "composeBoxDMHint": "Message @{user}", + "@composeBoxDMHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": { + "type": "String", + "example": "stream name" + } + } + }, + "composeBoxGroupDMHint": "Message group", + "@composeBoxGroupDMHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "composeBoxStreamContentHint": "Message #{stream} > ${topic}", + "@composeBoxStreamContentHint": { + "description": "Hint text for content input when sending a message to a stream", + "placeholders": { + "stream": { + "type": "String", + "example": "stream name" + }, + "topic": { + "type": "String", + "example": "topic name" + } + } + }, + "composeBoxSendTooltip": "Send", + "@composeBoxSendTooltip": { + "description": "Tooltip for send button in compose box." + }, + "composeBoxUnknownStreamName": "(unknown stream)", + "@composeBoxUnknownStreamName": { + "description": "Replacement name for stream when it cannot be found in the store." + }, + "composeBoxTopicHintText": "Topic", + "@composeBoxTopicHintText": { + "description": "Hint text for topic input widget in compose box." + }, + "composeBoxUploadingFilename": "Uploading {filename}...", + "@composeBoxUploadingFilename": { + "description": "Label in compose box showing the specified file is currently uploading.", + "placeholders": { + "filename": { + "type": "String", + "example": "file.txt" + } + } + }, + "contentValidationErrorTooLong": "Message length shouldn't be greater than 10000 characters.", + "@contentValidationErrorTooLong": { + "description": "Content validation error message when the message is too long." + }, + "contentValidationErrorEmpty": "You have nothing to send!", + "@contentValidationErrorEmpty": { + "description": "Content validation error message when the message is empty." + }, + "contentValidationErrorQuoteAndReplyInProgress": "Please wait for the quotation to complete.", + "@contentValidationErrorQuoteAndReplyInProgress": { + "description": "Content validation error message when a quotation has not completed yet." + }, + "contentValidationErrorUploadInProgress": "Please wait for the upload to complete.", + "@contentValidationErrorUploadInProgress": { + "description": "Content validation error message when attachments have not finished uploading." + }, "suggestedActionDialogCancel": "Cancel", "@suggestedActionDialogCancel": { "description": "Button label to cancel a suggested action." @@ -123,6 +259,10 @@ "@errorDialogContinue": { "description": "Button label in error dialogs to acknowledge error." }, + "errorDialogTitle": "Error", + "@errorDialogTitle": { + "description": "Generic title for error dialog." + }, "lightboxCopyLinkTooltip": "Copy link", "@lightboxCopyLinkTooltip": { "description": "Tooltip in lightbox for the copy link action." @@ -175,6 +315,14 @@ "@loginValidationRequireUsernameLabel": { "description": "Label for input when a username is required to login." }, + "topicValidationErrorTooLong": "Topic length shouldn't be greater than 60 characters.", + "@topicValidationErrorTooLong": { + "description": "Topic validation error when topic is too long." + }, + "topicValidationErrorMandatoryButEmpty": "Topics are required in this organization.", + "@topicValidationErrorMandatoryButEmpty": { + "description": "Topic validation error when topic is required but was empty." + }, "subscribedToNStreams": "Subscribed to {num, plural, =0{no streams} =1{1 stream} other{{num} streams}}", "@subscribedToNStreams": { "description": "Test page label showing number of streams user is subscribed to.", diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index 4c7219e737..76e8e4cc1d 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -49,12 +49,12 @@ enum TopicValidationError { mandatoryButEmpty, tooLong; - String message() { + String message(ZulipLocalizations zulipLocalizations) { switch (this) { case tooLong: - return "Topic length shouldn't be greater than 60 characters."; + return zulipLocalizations.topicValidationErrorTooLong; case mandatoryButEmpty: - return 'Topics are required in this organization.'; + return zulipLocalizations.topicValidationErrorMandatoryButEmpty; } } } @@ -91,16 +91,16 @@ enum ContentValidationError { quoteAndReplyInProgress, uploadInProgress; - String message() { + String message(ZulipLocalizations zulipLocalizations) { switch (this) { case ContentValidationError.tooLong: - return "Message length shouldn't be greater than 10000 characters."; + return zulipLocalizations.contentValidationErrorTooLong; case ContentValidationError.empty: - return 'You have nothing to send!'; + return zulipLocalizations.contentValidationErrorEmpty; case ContentValidationError.quoteAndReplyInProgress: - return 'Please wait for the quotation to complete.'; + return zulipLocalizations.contentValidationErrorQuoteAndReplyInProgress; case ContentValidationError.uploadInProgress: - return 'Please wait for the upload to complete.'; + return zulipLocalizations.contentValidationErrorUploadInProgress; } } } @@ -209,10 +209,10 @@ class ComposeContentController extends ComposeController /// /// Returns an int "tag" that should be passed to registerUploadEnd on the /// upload's success or failure. - int registerUploadStart(String filename) { + int registerUploadStart(String filename, ZulipLocalizations zulipLocalizations) { final tag = _nextUploadTag; _nextUploadTag += 1; - final placeholder = inlineLink('Uploading $filename...', null); // TODO(i18n) + final placeholder = inlineLink(zulipLocalizations.composeBoxUploadingFilename(filename), null); _uploads[tag] = (filename: filename, placeholder: placeholder); notifyListeners(); // _uploads change could affect validationErrors value = value.replaced(insertionIndex(), '$placeholder\n\n'); @@ -360,12 +360,14 @@ class _StreamContentInputState extends State<_StreamContentInput> { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); - final streamName = store.streams[widget.narrow.streamId]?.name ?? '(unknown stream)'; + final zulipLocalizations = ZulipLocalizations.of(context); + final streamName = store.streams[widget.narrow.streamId]?.name + ?? zulipLocalizations.composeBoxUnknownStreamName; return _ContentInput( narrow: widget.narrow, controller: widget.controller, focusNode: widget.focusNode, - hintText: "Message #$streamName > $_topicTextNormalized"); + hintText: zulipLocalizations.composeBoxStreamContentHint(streamName, _topicTextNormalized)); } } @@ -381,23 +383,25 @@ class _FixedDestinationContentInput extends StatelessWidget { final FocusNode focusNode; String _hintText(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); switch (narrow) { case TopicNarrow(:final streamId, :final topic): final store = PerAccountStoreWidget.of(context); - final streamName = store.streams[streamId]?.name ?? '(unknown stream)'; - return "Message #$streamName > $topic"; + final streamName = store.streams[streamId]?.name + ?? zulipLocalizations.composeBoxUnknownStreamName; + return zulipLocalizations.composeBoxStreamContentHint(streamName, topic); case DmNarrow(otherRecipientIds: []): // The self-1:1 thread. - return "Jot down something"; + return zulipLocalizations.composeBoxSelfDMHint; case DmNarrow(otherRecipientIds: [final otherUserId]): final store = PerAccountStoreWidget.of(context); final fullName = store.users[otherUserId]?.fullName; - if (fullName == null) return 'Type a message'; - return 'Message @$fullName'; + if (fullName == null) return zulipLocalizations.composeBoxDMHintNoName; + return zulipLocalizations.composeBoxDMHint(fullName); case DmNarrow(): // A group DM thread. - return 'Message group'; + return zulipLocalizations.composeBoxGroupDMHint; } } @@ -431,6 +435,7 @@ Future _uploadFiles({ }) async { assert(context.mounted); final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); final List<_File> tooLargeFiles = []; final List<_File> rightSizeFiles = []; @@ -446,18 +451,18 @@ Future _uploadFiles({ final listMessage = tooLargeFiles .map((file) => '${file.filename}: ${(file.length / (1 << 20)).toStringAsFixed(1)} MiB') .join('\n'); - showErrorDialog( // TODO(i18n) + showErrorDialog( context: context, - title: 'File(s) too large', - message: - '${tooLargeFiles.length} file(s) are larger than the server\'s limit' - ' of ${store.maxFileUploadSizeMib} MiB and will not be uploaded:' - '\n\n$listMessage'); + title: zulipLocalizations.errorFilesTooLargeTitle(tooLargeFiles.length), + message: zulipLocalizations.errorFilesTooLarge( + tooLargeFiles.length, + store.maxFileUploadSizeMib, + listMessage)); } final List<(int, _File)> uploadsInProgress = []; for (final file in rightSizeFiles) { - final tag = contentController.registerUploadStart(file.filename); + final tag = contentController.registerUploadStart(file.filename, zulipLocalizations); uploadsInProgress.add((tag, file)); } if (!contentFocusNode.hasFocus) { @@ -480,7 +485,8 @@ Future _uploadFiles({ ? e.message(zulipLocalizations) : e.toString(); showErrorDialog(context: context, - title: 'Failed to upload file: $filename', message: message); + title: zulipLocalizations.errorFailedToUploadFile(filename), + message: message); } finally { contentController.registerUploadEnd(tag, url); } @@ -494,7 +500,7 @@ abstract class _AttachUploadsButton extends StatelessWidget { final FocusNode contentFocusNode; IconData get icon; - String get tooltip; + String tooltip(ZulipLocalizations zulipLocalizations); /// Request files from the user, in the way specific to this upload type. /// @@ -526,9 +532,10 @@ abstract class _AttachUploadsButton extends StatelessWidget { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); return IconButton( icon: Icon(icon), - tooltip: tooltip, + tooltip: tooltip(zulipLocalizations), onPressed: () => _handlePress(context)); } } @@ -548,10 +555,10 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) // If the user hasn't checked "Don't ask again", they can always dismiss // our prompt and retry, and the permissions request will reappear, // letting them grant permissions and complete the upload. - showSuggestedActionDialog(context: context, // TODO(i18n) - title: 'Permissions needed', - message: 'To upload files, please grant Zulip additional permissions in Settings.', - actionButtonText: 'Open settings', + showSuggestedActionDialog(context: context, + title: zulipLocalizations.permissionsNeededTitle, + message: zulipLocalizations.permissionsReadExternalStorageDenied, + actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, onActionButtonPress: () { AppSettings.openAppSettings(); }); @@ -576,13 +583,14 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) }); } -class _AttachFileButton extends _AttachUploadsButton { - const _AttachFileButton({required super.contentController, required super.contentFocusNode}); +class _AttachFileButton extends _AttachUploadsButton { const _AttachFileButton({required super.contentController, required super.contentFocusNode}); @override IconData get icon => Icons.attach_file; + @override - String get tooltip => 'Attach files'; + String tooltip(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.composeBoxAttachFilesTooltip; @override Future> getFiles(BuildContext context) async { @@ -595,8 +603,10 @@ class _AttachMediaButton extends _AttachUploadsButton { @override IconData get icon => Icons.image; + @override - String get tooltip => 'Attach images or videos'; + String tooltip(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.composeBoxAttachMediaTooltip; @override Future> getFiles(BuildContext context) async { @@ -610,8 +620,10 @@ class _AttachFromCameraButton extends _AttachUploadsButton { @override IconData get icon => Icons.camera_alt; + @override - String get tooltip => 'Take a photo'; + String tooltip(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.composeBoxAttachFromCameraTooltip; @override Future> getFiles(BuildContext context) async { @@ -633,9 +645,9 @@ class _AttachFromCameraButton extends _AttachUploadsButton { // use a protected resource. After that, the only way the user can // grant it is in Settings. showSuggestedActionDialog(context: context, - title: zulipLocalizations.cameraAccessDeniedTitle, - message: zulipLocalizations.cameraAccessDeniedMessage, - actionButtonText: zulipLocalizations.cameraAccessDeniedButtonText, + title: zulipLocalizations.permissionsNeededTitle, + message: zulipLocalizations.permissionsNeededCameraAccessDenied, + actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, onActionButtonPress: () { AppSettings.openAppSettings(); }); @@ -715,15 +727,16 @@ class _SendButtonState extends State<_SendButton> { void _send() { if (_hasValidationErrors) { + final zulipLocalizations = ZulipLocalizations.of(context); List validationErrorMessages = [ for (final error in widget.topicController?.validationErrors ?? const []) - error.message(), + error.message(zulipLocalizations), for (final error in widget.contentController.validationErrors) - error.message(), + error.message(zulipLocalizations), ]; showErrorDialog( context: context, - title: 'Message not sent', + title: zulipLocalizations.errorMessageNotSent, message: validationErrorMessages.join('\n\n')); return; } @@ -739,6 +752,7 @@ class _SendButtonState extends State<_SendButton> { Widget build(BuildContext context) { final disabled = _hasValidationErrors; final colorScheme = Theme.of(context).colorScheme; + final zulipLocalizations = ZulipLocalizations.of(context); // Copy FilledButton defaults (_FilledButtonDefaultsM3.backgroundColor) final backgroundColor = disabled @@ -756,7 +770,7 @@ class _SendButtonState extends State<_SendButton> { color: backgroundColor, ), child: IconButton( - tooltip: 'Send', + tooltip: zulipLocalizations.composeBoxSendTooltip, // Match the height of the content input. Zeroing the padding lets the // constraints take over. @@ -874,6 +888,7 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final zulipLocalizations = ZulipLocalizations.of(context); return _ComposeBoxLayout( contentController: _contentController, @@ -881,7 +896,7 @@ class _StreamComposeBoxState extends State<_StreamComposeBox> implements Compose topicInput: TextField( controller: _topicController, style: TextStyle(color: colorScheme.onSurface), - decoration: const InputDecoration(hintText: 'Topic'), + decoration: InputDecoration(hintText: zulipLocalizations.composeBoxTopicHintText), ), contentInput: _StreamContentInput( narrow: widget.narrow,