diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 5ec9b94b09d..f5b8cebd811 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -27,18 +27,22 @@ "@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." + "permissionsNeededTitle": "Permissions needed", + "@permissionsNeededTitle": { + "description": "Title for dialog when the user needs to grant additional permissions." }, - "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." + "permissionsNeededOpenSettings": "Open settings", + "@permissionsNeededOpenSettings": { + "description": "Button label for permissions dialog button that opens the system settings screen." }, - "cameraAccessDeniedButtonText": "Open settings", - "@cameraAccessDeniedButtonText": { + "permissionsDeniedCameraAccess": "To upload an image, please grant Zulip additional permissions in Settings.", + "@permissionsDeniedCameraAccess": { "description": "Message for dialog when the user needs to grant permissions for camera access." }, + "permissionsDeniedReadExternalStorage": "To upload files, please grant Zulip additional permissions in Settings.", + "@permissionsDeniedReadExternalStorage": { + "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." @@ -59,6 +63,59 @@ "@errorCopyingFailed": { "description": "Error message when copying the text of a message to the users system clipboard failed." }, + "errorFailedToUploadFileTitle": "Failed to upload file: {filename}", + "@errorFailedToUploadFileTitle": { + "description": "Error title when 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": "2"}, + "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 title for login when input is invalid." + }, + "errorLoginFailed": "Login failed", + "@errorLoginFailed": { + "description": "Error title for login when signing into a Zulip server fails." + }, + "errorMessageNotSent": "Message not sent", + "@errorMessageNotSent": { + "description": "Error message for compose box when a message could not be sent." + }, + "errorServerErrorMessage": "The server said:\n\n{message}", + "@errorServerErrorMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": {"type": "String", "example": "Invalid format"} + } + }, + "errorLoginCouldNotConnect": "Failed to connect to server:\n{url}", + "@errorLoginCouldNotConnect": { + "description": "Error message when the app could not connect to the server.", + "placeholders": { + "url": {"type": "String", "example": "http://example.com/"} + } + }, + "errorLoginCouldNotConnectTitle": "Could not connect", + "@errorLoginCouldNotConnectTitle": { + "description": "Error title when the app could not connect to the server." + }, "errorMessageDoesNotSeemToExist": "That message does not seem to exist.", "@errorMessageDoesNotSeemToExist": { "description": "Error message when loading a message that does not exist." @@ -67,6 +124,13 @@ "@errorQuotationFailed": { "description": "Error message when quoting a message failed." }, + "errorServerMessage": "The server said:\n\n{message}", + "@errorServerMessage": { + "description": "Error message that quotes an error from the server.", + "placeholders": { + "message": {"type": "String", "example": "Invalid format"} + } + }, "successLinkCopied": "Link copied", "@successLinkCopied": { "description": "Success message after copy link action completed." @@ -75,6 +139,80 @@ "@successMessageCopied": { "description": "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." + }, + "composeBoxGenericContentHint": "Type a message", + "@composeBoxGenericContentHint": { + "description": "Hint text for content input when sending a message." + }, + "composeBoxDmContentHint": "Message @{user}", + "@composeBoxDmContentHint": { + "description": "Hint text for content input when sending a message to one other person.", + "placeholders": { + "user": {"type": "String", "example": "stream name"} + } + }, + "composeBoxGroupDmContentHint": "Message group", + "@composeBoxGroupDmContentHint": { + "description": "Hint text for content input when sending a message to a group." + }, + "composeBoxSelfDmContentHint": "Jot down something", + "@composeBoxSelfDmContentHint": { + "description": "Hint text for content input when sending a message to yourself." + }, + "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." + }, "dialogCancel": "Cancel", "@dialogCancel": { "description": "Button label in dialogs to cancel." @@ -87,10 +225,66 @@ "@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." }, + "loginPageTitle": "Log in", + "@loginPageTitle": { + "description": "Page title for login page." + }, + "loginFormSubmitLabel": "Log in", + "@loginFormSubmitLabel": { + "description": "Button text to submit login credentials." + }, + "loginAddAnAccount": "Add an account", + "@loginAddAnAccount": { + "description": "Page title for screen to add a Zulip account." + }, + "loginServerUrlInputLabel": "Your Zulip server URL", + "@loginServerUrlInputLabel": { + "description": "Input label in login page for Zulip server URL entry." + }, + "loginHidePassword": "Hide password", + "@loginHidePassword": { + "description": "Icon label for button to hide password in input form." + }, + "loginEmailLabel": "Email address", + "@loginEmailLabel": { + "description": "Label for input when an email is required to login." + }, + "loginErrorMissingEmail": "Please enter your email.", + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "loginPasswordLabel": "Password", + "@loginPasswordLabel": { + "description": "Label for input for password field." + }, + "loginErrorMissingPassword": "Please enter your password.", + "@loginErrorMissingPassword": { + "description": "Error message when an empty password was provided." + }, + "loginUsernameLabel": "Username", + "@loginUsernameLabel": { + "description": "Label for input when a username is required to login." + }, + "loginErrorMissingUsername": "Please enter your username.", + "@loginErrorMissingUsername": { + "description": "Error message when an empty username was provided." + }, + "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.", @@ -116,6 +310,22 @@ "httpStatus": {"type": "int", "example": "500"} } }, + "serverUrlValidationErrorEmpty": "Please enter a URL.", + "@serverUrlValidationErrorEmpty": { + "description": "Error message when URL is empty" + }, + "serverUrlValidationErrorInvalidUrl": "Please enter a valid URL.", + "@serverUrlValidationErrorInvalidUrl": { + "description": "Error message when URL is not in a valid format." + }, + "serverUrlValidationErrorNoUseEmail": "Please enter the server URL, not your email.", + "@serverUrlValidationErrorNoUseEmail": { + "description": "Error message when URL looks like an email" + }, + "serverUrlValidationErrorUnsupportedScheme": "The server URL must start with http:// or https://.", + "@serverUrlValidationErrorUnsupportedScheme": { + "description": "Error message when URL has an unsupported scheme." + }, "userRoleOwner": "Owner", "@userRoleOwner": { "description": "Label for UserRole.owner" diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index dbcca82903f..34b2c6d33bb 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -48,12 +48,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; } } } @@ -90,16 +90,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; } } } @@ -208,10 +208,11 @@ 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 linkText = zulipLocalizations.composeBoxUploadingFilename(filename); + final placeholder = inlineLink(linkText, null); _uploads[tag] = (filename: filename, placeholder: placeholder); notifyListeners(); // _uploads change could affect validationErrors value = value.replaced(insertionIndex(), '$placeholder\n\n'); @@ -359,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)); } } @@ -380,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.composeBoxSelfDmContentHint; 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.composeBoxGenericContentHint; + return zulipLocalizations.composeBoxDmContentHint(fullName); case DmNarrow(): // A group DM thread. - return 'Message group'; + return zulipLocalizations.composeBoxGroupDmContentHint; } } @@ -430,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 = []; @@ -445,18 +451,19 @@ 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) { @@ -475,7 +482,8 @@ Future _uploadFiles({ // TODO(#37): Specifically handle `413 Payload Too Large` // TODO(#37): On API errors, quote `msg` from server, with "The server said:" showErrorDialog(context: context, - title: 'Failed to upload file: $filename', message: e.toString()); + title: zulipLocalizations.errorFailedToUploadFileTitle(filename), + message: e.toString()); } finally { contentController.registerUploadEnd(tag, url); } @@ -489,7 +497,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. /// @@ -521,9 +529,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)); } } @@ -535,6 +544,7 @@ Future> _getFilePickerFiles(BuildContext context, FileType type) .pickFiles(allowMultiple: true, withReadStream: true, type: type); } catch (e) { if (!context.mounted) return []; + final zulipLocalizations = ZulipLocalizations.of(context); if (e is PlatformException && e.code == 'read_external_storage_denied') { // Observed on Android. If Android's error message tells us whether the // user has checked "Don't ask again", it seems the library doesn't pass @@ -542,16 +552,17 @@ 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.permissionsDeniedReadExternalStorage, + actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, onActionButtonPress: () { AppSettings.openAppSettings(); }); } else { - // TODO(i18n) - showErrorDialog(context: context, title: 'Error', message: e.toString()); + showErrorDialog(context: context, + title: zulipLocalizations.errorDialogTitle, + message: e.toString()); } return []; } @@ -570,8 +581,10 @@ class _AttachFileButton extends _AttachUploadsButton { @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 { @@ -584,8 +597,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 { @@ -599,8 +614,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 { @@ -622,15 +639,16 @@ 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.permissionsDeniedCameraAccess, + actionButtonText: zulipLocalizations.permissionsNeededOpenSettings, onActionButtonPress: () { AppSettings.openAppSettings(); }); } else { - // TODO(i18n) - showErrorDialog(context: context, title: 'Error', message: e.toString()); + showErrorDialog(context: context, + title: zulipLocalizations.errorDialogTitle, + message: e.toString()); } return []; } @@ -699,15 +717,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; } @@ -723,6 +742,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 @@ -740,7 +760,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. @@ -858,6 +878,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, @@ -865,7 +886,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, diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index ec140c04740..16c60dc143a 100644 --- a/lib/widgets/login.dart +++ b/lib/widgets/login.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import '../api/core.dart'; import '../api/exception.dart'; @@ -42,17 +43,17 @@ enum ServerUrlValidationError { } } - String message() { // TODO(i18n) + String message(ZulipLocalizations zulipLocalizations) { switch (this) { case empty: - return 'Please enter a URL.'; + return zulipLocalizations.serverUrlValidationErrorEmpty; case invalidUrl: - return 'Please enter a valid URL.'; + return zulipLocalizations.serverUrlValidationErrorInvalidUrl; case noUseEmail: - return 'Please enter the server URL, not your email.'; + return zulipLocalizations.serverUrlValidationErrorNoUseEmail; case unsupportedSchemeZulip: case unsupportedSchemeOther: - return 'The server URL must start with http:// or https://.'; + return zulipLocalizations.serverUrlValidationErrorUnsupportedScheme; } } } @@ -135,11 +136,13 @@ class _AddAccountPageState extends State { } Future _onSubmitted(BuildContext context) async { + final zulipLocalizations = ZulipLocalizations.of(context); final url = _parseResult.url; final error = _parseResult.error; if (error != null) { showErrorDialog(context: context, - title: 'Invalid input', message: error.message()); + title: zulipLocalizations.errorLoginInvalidInputTitle, + message: error.message(zulipLocalizations)); return; } assert(url != null); @@ -158,7 +161,8 @@ class _AddAccountPageState extends State { // TODO(#105) give more helpful feedback; see `fetchServerSettings` // in zulip-mobile's src/message/fetchActions.js. showErrorDialog(context: context, - title: 'Could not connect', message: 'Failed to connect to server:\n$url'); + title: zulipLocalizations.errorLoginCouldNotConnectTitle, + message: zulipLocalizations.errorLoginCouldNotConnect(url.toString())); return; } // https://github.com/dart-lang/linter/issues/4007 @@ -180,13 +184,14 @@ class _AddAccountPageState extends State { @override Widget build(BuildContext context) { assert(!PerAccountStoreWidget.debugExistsOf(context)); + final zulipLocalizations = ZulipLocalizations.of(context); final error = _parseResult.error; final errorText = error == null || error.shouldDeferFeedback() ? null - : error.message(); + : error.message(zulipLocalizations); return Scaffold( - appBar: AppBar(title: const Text('Add an account'), + appBar: AppBar(title: Text(zulipLocalizations.loginAddAnAccount), bottom: _inProgress ? const PreferredSize(preferredSize: Size.fromHeight(4), child: LinearProgressIndicator(minHeight: 4)) // 4 restates default @@ -211,7 +216,7 @@ class _AddAccountPageState extends State { // …but leave out unfocusing the input in case more editing is needed. }, decoration: InputDecoration( - labelText: 'Your Zulip server URL', + labelText: zulipLocalizations.loginServerUrlInputLabel, errorText: errorText, helperText: kLayoutPinningHelperText, hintText: 'your-org.zulipchat.com')), @@ -220,7 +225,7 @@ class _AddAccountPageState extends State { onPressed: !_inProgress && errorText == null ? () => _onSubmitted(context) : null, - child: const Text('Continue')), + child: Text(zulipLocalizations.dialogContinue)), ]))))); } } @@ -289,10 +294,13 @@ class _PasswordLoginPageState extends State { // TODO(#105) give more helpful feedback. The RN app is // unhelpful here; we should at least recognize invalid auth errors, and // errors for deactivated user or realm (see zulip-mobile#4571). + final zulipLocalizations = ZulipLocalizations.of(context); final message = (e is ZulipApiException) - ? 'The server said:\n\n${e.message}' + ? zulipLocalizations.errorServerMessage(e.message) : e.message; - showErrorDialog(context: context, title: 'Login failed', message: message); + showErrorDialog(context: context, + title: zulipLocalizations.errorLoginFailed, + message: message); return; } @@ -335,6 +343,7 @@ class _PasswordLoginPageState extends State { @override Widget build(BuildContext context) { assert(!PerAccountStoreWidget.debugExistsOf(context)); + final zulipLocalizations = ZulipLocalizations.of(context); final requireEmailFormatUsernames = widget.serverSettings.requireEmailFormatUsernames; final usernameField = TextFormField( @@ -350,8 +359,8 @@ class _PasswordLoginPageState extends State { validator: (value) { if (value == null || value.trim().isEmpty) { return requireEmailFormatUsernames - ? 'Please enter your email.' - : 'Please enter your username.'; + ? zulipLocalizations.loginErrorMissingEmail + : zulipLocalizations.loginErrorMissingUsername; } if (requireEmailFormatUsernames) { // TODO(#106): validate is in the shape of an email @@ -360,7 +369,9 @@ class _PasswordLoginPageState extends State { }, textInputAction: TextInputAction.next, decoration: InputDecoration( - labelText: requireEmailFormatUsernames ? 'Email address' : 'Username', + labelText: requireEmailFormatUsernames + ? zulipLocalizations.loginEmailLabel + : zulipLocalizations.loginUsernameLabel, helperText: kLayoutPinningHelperText, )); @@ -372,14 +383,14 @@ class _PasswordLoginPageState extends State { autovalidateMode: AutovalidateMode.onUserInteraction, validator: (value) { if (value == null || value.isEmpty) { - return 'Please enter your password.'; + return zulipLocalizations.loginErrorMissingPassword; } return null; }, textInputAction: TextInputAction.go, onFieldSubmitted: (value) => _submit(), decoration: InputDecoration( - labelText: 'Password', + labelText: zulipLocalizations.loginPasswordLabel, helperText: kLayoutPinningHelperText, // TODO(material-3): Simplify away `Semantics` by using IconButton's // M3-only params `isSelected` / `selectedIcon`, after fixing @@ -389,14 +400,14 @@ class _PasswordLoginPageState extends State { // [ButtonStyleButton].) suffixIcon: Semantics(toggled: _obscurePassword, child: IconButton( - tooltip: 'Hide password', + tooltip: zulipLocalizations.loginHidePassword, onPressed: _handlePasswordVisibilityPress, icon: _obscurePassword ? const Icon(Icons.visibility_off) : const Icon(Icons.visibility))))); return Scaffold( - appBar: AppBar(title: const Text('Log in'), + appBar: AppBar(title: Text(zulipLocalizations.loginPageTitle), bottom: _inProgress ? const PreferredSize(preferredSize: Size.fromHeight(4), child: LinearProgressIndicator(minHeight: 4)) // 4 restates default @@ -416,7 +427,7 @@ class _PasswordLoginPageState extends State { const SizedBox(height: 8), ElevatedButton( onPressed: _inProgress ? null : _submit, - child: const Text('Log in')), + child: Text(zulipLocalizations.loginFormSubmitLabel)), ]))))))); } }