From 891c717dbef799a0e1783fa46cf4d39aad75bf6b Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Wed, 20 Sep 2023 22:44:12 +0100 Subject: [PATCH] intl: replace all hard-coded strings to be wired into localization framework Fixes: #277 --- lib/api/core.dart | 18 +++- lib/api/exception.dart | 41 +++++++-- lib/widgets/action_sheet.dart | 35 +++++--- lib/widgets/compose_box.dart | 129 +++++++++++++++++----------- lib/widgets/dialog.dart | 10 ++- lib/widgets/lightbox.dart | 10 ++- lib/widgets/login.dart | 53 +++++++----- lib/widgets/message_list.dart | 2 +- test/api/core_test.dart | 7 +- test/api/exception_checks.dart | 5 +- test/widgets/action_sheet_test.dart | 3 + test/widgets/autocomplete_test.dart | 3 + test/widgets/clipboard_test.dart | 1 - test/widgets/content_test.dart | 3 + 14 files changed, 215 insertions(+), 105 deletions(-) diff --git a/lib/api/core.dart b/lib/api/core.dart index 3a56cc4db90..ae52c716309 100644 --- a/lib/api/core.dart +++ b/lib/api/core.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:http/http.dart' as http; import '../log.dart'; @@ -84,15 +85,25 @@ class ApiConnection { try { response = await _client.send(request); } catch (e) { - final String message; + final String? message; + final String Function(ZulipLocalizations)? translatableMessage; if (e is http.ClientException) { message = e.message; + translatableMessage = null; } else if (e is TlsException) { message = e.message; + translatableMessage = null; } else { - message = 'Network request failed'; + message = null; + translatableMessage = (ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.apiConnectionNetworkRequestFailed; + }; } - throw NetworkException(routeName: routeName, cause: e, message: message); + throw NetworkException( + routeName: routeName, + cause: e, + message: message, + translatableMessage: translatableMessage); } final int httpStatus = response.statusCode; @@ -173,6 +184,7 @@ ApiRequestException _makeApiException(String routeName, int httpStatus, Map() != null); IconData get icon; - String get label; + String Function(ZulipLocalizations) get translateLabel; void Function(BuildContext) get onPressed; final Message message; @@ -51,10 +52,11 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); return MenuItemButton( leadingIcon: Icon(icon), onPressed: () => onPressed(context), - child: Text(label)); + child: Text(translateLabel(zulipLocalizations))); } } @@ -67,7 +69,9 @@ class ShareButton extends MessageActionSheetMenuItemButton { @override get icon => Icons.adaptive.share; - @override get label => 'Share'; + @override get translateLabel => (ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetShare; + }; @override get onPressed => (BuildContext context) async { // Close the message action sheet; we're about to show the share @@ -104,22 +108,23 @@ Future fetchRawContentWithFeedback({ // - If request(s) take(s) a long time, show snackbar with cancel // button, like "Still working on quote-and-reply…". // On final failure or success, auto-dismiss the snackbar. + final zulipLocalizations = ZulipLocalizations.of(context); try { fetchedMessage = await getMessageCompat(PerAccountStoreWidget.of(context).connection, messageId: messageId, applyMarkdown: false, ); if (fetchedMessage == null) { - errorMessage = 'That message does not seem to exist.'; + errorMessage = zulipLocalizations.actionSheetMessageDoesNotSeemToExist; } } catch (e) { switch (e) { case ZulipApiException(): - errorMessage = e.message; + errorMessage = e.toTranslatedString(zulipLocalizations); // TODO specific messages for common errors, like network errors // (support with reusable code) default: - errorMessage = 'Could not fetch message source.'; + errorMessage = zulipLocalizations.actionSheetCouldNotFetchMessageSource; } } @@ -146,12 +151,15 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { @override get icon => Icons.format_quote_outlined; - @override get label => 'Quote and reply'; + @override get translateLabel => (ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetQuoteAndReply; + }; @override get onPressed => (BuildContext bottomSheetContext) async { // Close the message action sheet. We'll show the request progress // in the compose-box content input with a "[Quoting…]" placeholder. Navigator.of(bottomSheetContext).pop(); + final zulipLocalizations = ZulipLocalizations.of(messageListContext); // This will be null only if the compose box disappeared after the // message action sheet opened, and before "Quote and reply" was pressed. @@ -174,7 +182,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { final rawContent = await fetchRawContentWithFeedback( context: messageListContext, messageId: message.id, - errorDialogTitle: 'Quotation failed', + errorDialogTitle: zulipLocalizations.actionSheetQuotationFailed, ); if (!messageListContext.mounted) return; @@ -203,26 +211,29 @@ class CopyButton extends MessageActionSheetMenuItemButton { @override get icon => Icons.copy; - @override get label => 'Copy message text'; + @override get translateLabel => (ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetCopyMessageText; + }; @override get onPressed => (BuildContext context) async { // Close the message action sheet. We won't be showing request progress, // but hopefully it won't take long at all, and // fetchRawContentWithFeedback has a TODO for giving feedback if it does. Navigator.of(context).pop(); + final zulipLocalizations = ZulipLocalizations.of(messageListContext); final rawContent = await fetchRawContentWithFeedback( context: messageListContext, messageId: message.id, - errorDialogTitle: 'Copying failed', + errorDialogTitle: zulipLocalizations.actionSheetCopyingFailed, ); if (rawContent == null) return; if (!messageListContext.mounted) return; - // TODO(i18n) - copyWithPopup(context: context, successContent: const Text('Message copied'), + copyWithPopup(context: context, + successContent: Text(zulipLocalizations.actionSheetMessageCopied), data: ClipboardData(text: rawContent)); }; } diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index dbcca82903f..136ca0e89d6 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:image_picker/image_picker.dart'; +import '../api/exception.dart'; import '../api/model/model.dart'; import '../api/route/messages.dart'; import '../model/compose.dart'; @@ -48,12 +49,12 @@ enum TopicValidationError { mandatoryButEmpty, tooLong; - String message() { + String translateMessage(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 +91,16 @@ enum ContentValidationError { quoteAndReplyInProgress, uploadInProgress; - String message() { + String translateMessage(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 +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'); @@ -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.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; } } @@ -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,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.composeBoxFilesTooLargeTitle(tooLargeFiles.length), + message: zulipLocalizations.composeBoxFilesTooLargeMessage( + 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) { @@ -474,8 +480,12 @@ Future _uploadFiles({ if (!context.mounted) return; // TODO(#37): Specifically handle `413 Payload Too Large` // TODO(#37): On API errors, quote `msg` from server, with "The server said:" + final String message = (e is ApiRequestException) + ? e.toTranslatedString(zulipLocalizations) + : e.toString(); showErrorDialog(context: context, - title: 'Failed to upload file: $filename', message: e.toString()); + title: zulipLocalizations.composeBoxFailedToUploadFile(filename), + message: message); } finally { contentController.registerUploadEnd(tag, url); } @@ -489,7 +499,6 @@ abstract class _AttachUploadsButton extends StatelessWidget { final FocusNode contentFocusNode; IconData get icon; - String get tooltip; /// Request files from the user, in the way specific to this upload type. /// @@ -500,6 +509,8 @@ abstract class _AttachUploadsButton extends StatelessWidget { /// return an empty [Iterable] after showing user feedback as appropriate. Future> getFiles(BuildContext context); + String getTooltip(ZulipLocalizations zulipLocalizations); + void _handlePress(BuildContext context) async { final files = await getFiles(context); if (files.isEmpty) { @@ -521,15 +532,17 @@ abstract class _AttachUploadsButton extends StatelessWidget { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); return IconButton( icon: Icon(icon), - tooltip: tooltip, + tooltip: getTooltip(zulipLocalizations), onPressed: () => _handlePress(context)); } } Future> _getFilePickerFiles(BuildContext context, FileType type) async { FilePickerResult? result; + final zulipLocalizations = ZulipLocalizations.of(context); try { result = await FilePicker.platform .pickFiles(allowMultiple: true, withReadStream: true, type: type); @@ -542,16 +555,21 @@ 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(); }); } else { - // TODO(i18n) - showErrorDialog(context: context, title: 'Error', message: e.toString()); + final String message = (e is ApiRequestException) + ? e.toTranslatedString(zulipLocalizations) + : e.toString(); + showErrorDialog( + context: context, + title: zulipLocalizations.permissionsNeededError, + message: message); } return []; } @@ -565,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 getTooltip(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.composeBoxAttachFilesTooltip; @override Future> getFiles(BuildContext context) async { @@ -584,8 +603,10 @@ class _AttachMediaButton extends _AttachUploadsButton { @override IconData get icon => Icons.image; + @override - String get tooltip => 'Attach images or videos'; + String getTooltip(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.composeBoxAttachMediaTooltip; @override Future> getFiles(BuildContext context) async { @@ -599,8 +620,10 @@ class _AttachFromCameraButton extends _AttachUploadsButton { @override IconData get icon => Icons.camera_alt; + @override - String get tooltip => 'Take a photo'; + String getTooltip(ZulipLocalizations zulipLocalizations) => + zulipLocalizations.composeBoxAttachFromCameraTooltip; @override Future> getFiles(BuildContext context) async { @@ -622,15 +645,20 @@ 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(); }); } else { - // TODO(i18n) - showErrorDialog(context: context, title: 'Error', message: e.toString()); + final String message = (e is ApiRequestException) + ? e.toTranslatedString(zulipLocalizations) + : e.toString(); + showErrorDialog( + context: context, + title: zulipLocalizations.permissionsNeededError, + message: message); } return []; } @@ -699,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.translateMessage(zulipLocalizations), for (final error in widget.contentController.validationErrors) - error.message(), + error.translateMessage(zulipLocalizations), ]; showErrorDialog( context: context, - title: 'Message not sent', + title: zulipLocalizations.composeBoxMessageNotSent, message: validationErrorMessages.join('\n\n')); return; } @@ -723,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 @@ -740,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. @@ -858,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, @@ -865,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, diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 985e7cd5f99..717c18b09b2 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; Widget _dialogActionText(String text) { return Text( @@ -14,12 +15,12 @@ Widget _dialogActionText(String text) { ); } -// TODO(i18n): title, message, and action-button text Future showErrorDialog({ required BuildContext context, required String title, String? message, }) { + final zulipLocalizations = ZulipLocalizations.of(context); return showDialog( context: context, builder: (BuildContext context) => AlertDialog( @@ -28,7 +29,7 @@ Future showErrorDialog({ actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: _dialogActionText('OK')), + child: _dialogActionText(zulipLocalizations.dialogButtonOK)), ])); } @@ -39,6 +40,7 @@ void showSuggestedActionDialog({ required String? actionButtonText, required VoidCallback onActionButtonPress, }) { + final zulipLocalizations = ZulipLocalizations.of(context); showDialog( context: context, builder: (BuildContext context) => AlertDialog( @@ -47,9 +49,9 @@ void showSuggestedActionDialog({ actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: _dialogActionText('Cancel')), + child: _dialogActionText(zulipLocalizations.dialogButtonCancel)), TextButton( onPressed: onActionButtonPress, - child: _dialogActionText(actionButtonText ?? 'Continue')), + child: _dialogActionText(actionButtonText ?? zulipLocalizations.dialogButtonContinue)), ])); } diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 829b7ab5270..8a8cb3495a5 100644 --- a/lib/widgets/lightbox.dart +++ b/lib/widgets/lightbox.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:intl/intl.dart'; import '../api/model/model.dart'; @@ -70,12 +71,13 @@ class _CopyLinkButton extends StatelessWidget { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); return IconButton( - tooltip: 'Copy link', + tooltip: zulipLocalizations.lightboxCopyLinkTooltip, icon: const Icon(Icons.copy), onPressed: () async { - // TODO(i18n) - copyWithPopup(context: context, successContent: const Text('Link copied'), + copyWithPopup(context: context, + successContent: Text(zulipLocalizations.lightboxCopyLinkSuccessToast), data: ClipboardData(text: url.toString())); }); } @@ -136,7 +138,7 @@ class _LightboxPageState extends State<_LightboxPage> { if (_headerFooterVisible) { // TODO(#45): Format with e.g. "Yesterday at 4:47 PM" final timestampText = DateFormat - .yMMMd(/* TODO(i18n): Pass selected language here, I think? */) + .yMMMd(/* TODO(#278): Pass selected language here, I think? */) .add_Hms() .format(DateTime.fromMillisecondsSinceEpoch(widget.message.timestamp * 1000)); diff --git a/lib/widgets/login.dart b/lib/widgets/login.dart index ec140c04740..c80d06e718f 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 translatedMessage(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.loginInvalidInput, + message: error.translatedMessage(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.loginCouldNotConnectTitle, + message: zulipLocalizations.loginCouldNotConnectMessage(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.translatedMessage(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.dialogButtonContinue)), ]))))); } } @@ -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.loginServerErrorMessage(e.toTranslatedString(zulipLocalizations)) : e.message; - showErrorDialog(context: context, title: 'Login failed', message: message); + showErrorDialog(context: context, + title: zulipLocalizations.loginServerLoginFailed, + 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.loginValidationRequireEmail + : zulipLocalizations.loginValidationRequireUsername; } 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.loginValidationRequireEmailLabel + : zulipLocalizations.loginValidationRequireUsernameLabel, 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.loginValidationPassword; } return null; }, textInputAction: TextInputAction.go, onFieldSubmitted: (value) => _submit(), decoration: InputDecoration( - labelText: 'Password', + labelText: zulipLocalizations.loginValidationPasswordLabel, 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)), ]))))))); } } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 79fdd24cfda..8bf20d5a561 100644 --- a/lib/widgets/message_list.dart +++ b/lib/widgets/message_list.dart @@ -536,7 +536,7 @@ final _kRecipientHeaderDateStyle = TextStyle( color: const HSLColor.fromAHSL(0.75, 0, 0, 0.15).toColor(), ); -final _kRecipientHeaderDateFormat = DateFormat('y-MM-dd', 'en_US'); // TODO(i18n) +final _kRecipientHeaderDateFormat = DateFormat('y-MM-dd', 'en_US'); // TODO(#278) /// A widget with the distinctive chevron-tailed shape in Zulip recipient headers. class RecipientHeaderChevronContainer extends StatelessWidget { diff --git a/test/api/core_test.dart b/test/api/core_test.dart index 9b1cc26b619..a2e469eaf5d 100644 --- a/test/api/core_test.dart +++ b/test/api/core_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:checks/checks.dart'; import 'package:checks/context.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:http/http.dart' as http; import 'package:test/scaffolding.dart'; import 'package:zulip/api/core.dart'; @@ -155,7 +156,11 @@ void main() { checkRequest(http.ClientException('Oops'), it()..message.equals('Oops')); checkRequest(const TlsException('Oops'), it()..message.equals('Oops')); - checkRequest((foo: 'bar'), it()..message.equals('Network request failed')); + final zulipLocalizations = lookupZulipLocalizations(ZulipLocalizations.supportedLocales.first); + final expectedMessage = zulipLocalizations.apiConnectionNetworkRequestFailed; + checkRequest((foo: 'bar'), it() + ..toTranslatedString(zulipLocalizations) + .equals(expectedMessage)); }); test('API 4xx errors, well formed', () async { diff --git a/test/api/exception_checks.dart b/test/api/exception_checks.dart index 60b792fda22..5e2f5458c01 100644 --- a/test/api/exception_checks.dart +++ b/test/api/exception_checks.dart @@ -1,9 +1,12 @@ import 'package:checks/checks.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:zulip/api/exception.dart'; extension ApiRequestExceptionChecks on Subject { Subject get routeName => has((e) => e.routeName, 'routeName'); - Subject get message => has((e) => e.message, 'message'); + Subject get message => has((e) => e.message, 'message'); + Subject toTranslatedString(ZulipLocalizations zulipLocalizations) => + has((e) => e.toTranslatedString(zulipLocalizations), 'getTranslatedMessage'); } extension ZulipApiExceptionChecks on Subject { diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 0d143ae5e3f..64c4c2cd5b8 100644 --- a/test/widgets/action_sheet_test.dart +++ b/test/widgets/action_sheet_test.dart @@ -1,6 +1,7 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; @@ -48,6 +49,8 @@ Future setupToMessageActionSheet(WidgetTester tester, { await tester.pumpWidget( MaterialApp( + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, home: GlobalStoreWidget( child: PerAccountStoreWidget( accountId: eg.selfAccount.id, diff --git a/test/widgets/autocomplete_test.dart b/test/widgets/autocomplete_test.dart index 164cd765155..6f540111ca9 100644 --- a/test/widgets/autocomplete_test.dart +++ b/test/widgets/autocomplete_test.dart @@ -1,5 +1,6 @@ import 'package:checks/checks.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/messages.dart'; @@ -40,6 +41,8 @@ Future setupToComposeInput(WidgetTester tester, { await tester.pumpWidget( MaterialApp( + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, home: GlobalStoreWidget( child: PerAccountStoreWidget( accountId: eg.selfAccount.id, diff --git a/test/widgets/clipboard_test.dart b/test/widgets/clipboard_test.dart index ace8c4d8311..2b06625377a 100644 --- a/test/widgets/clipboard_test.dart +++ b/test/widgets/clipboard_test.dart @@ -32,7 +32,6 @@ void main() { body: Builder(builder: (context) => Center( child: ElevatedButton( onPressed: () async { - // TODO(i18n) copyWithPopup(context: context, successContent: const Text('Text copied'), data: ClipboardData(text: text)); }, diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index 8078d16ab7c..cd9324ebe94 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:zulip/api/core.dart'; @@ -68,6 +69,8 @@ void main() { addTearDown(testBinding.reset); await tester.pumpWidget(GlobalStoreWidget(child: MaterialApp( + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, home: PerAccountStoreWidget(accountId: eg.selfAccount.id, child: BlockContentList( nodes: parseContent(html).nodes)))));