diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index f642df803c..cebffbe8ea 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -27,28 +27,298 @@ "@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 asking the user 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": { - "description": "Message for dialog when the user needs to grant permissions for camera access." + "permissionsDeniedCameraAccess": "To upload an image, please grant Zulip additional permissions in Settings.", + "@permissionsDeniedCameraAccess": { + "description": "Message for dialog asking the user to grant permissions for camera access." + }, + "permissionsDeniedReadExternalStorage": "To upload files, please grant Zulip additional permissions in Settings.", + "@permissionsDeniedReadExternalStorage": { + "description": "Message for dialog asking the user to grant permissions for external storage read access." + }, + "actionSheetOptionCopy": "Copy message text", + "@actionSheetOptionCopy": { + "description": "Label for copy message text button on action sheet." + }, + "actionSheetOptionShare": "Share", + "@actionSheetOptionShare": { + "description": "Label for share button on action sheet." + }, + "actionSheetOptionQuoteAndReply": "Quote and reply", + "@actionSheetOptionQuoteAndReply": { + "description": "Label for Quote and reply button on action sheet." + }, + "errorCouldNotFetchMessageSource": "Could not fetch message source", + "@errorCouldNotFetchMessageSource": { + "description": "Error message when the source of a message could not be fetched." + }, + "errorCopyingFailed": "Copying failed", + "@errorCopyingFailed": { + "description": "Error message when copying the text of a message to the user's 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." + }, + "errorLoginFailedTitle": "Login failed", + "@errorLoginFailedTitle": { + "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." + }, + "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." + }, + "errorQuotationFailed": "Quotation failed", + "@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." + }, + "successMessageCopied": "Message Copied", + "@successMessageCopied": { + "description": "Message when content of a message was copied to the user's 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": "Placeholder 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." + }, + "dialogContinue": "Continue", + "@dialogContinue": { + "description": "Button label in dialogs to proceed." + }, + "errorDialogContinue": "OK", + "@errorDialogContinue": { + "description": "Button label in error dialogs to acknowledge the error and close the dialog." + }, + "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." + }, + "loginAddAnAccountPageTitle": "Add an account", + "@loginAddAnAccountPageTitle": { + "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 log in." + }, + "loginErrorMissingEmail": "Please enter your email.", + "@loginErrorMissingEmail": { + "description": "Error message when an empty email was provided." + }, + "loginPasswordLabel": "Password", + "@loginPasswordLabel": { + "description": "Label for password input 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 log in." + }, + "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.", "placeholders": { - "num": { - "type": "int", - "example": "4" - } + "num": {"type": "int", "example": "4"} + } + }, + "errorNetworkRequestFailed": "Network request failed", + "@errorNetworkRequestFailed": { + "description": "Error message when a network request fails." + }, + "errorMalformedResponse": "Server gave malformed response; HTTP status {httpStatus}", + "@errorMalformedResponse": { + "description": "Error message when an API call fails because we could not parse the response.", + "placeholders": { + "httpStatus": {"type": "int", "example": "200"} + } + }, + "errorRequestFailed": "Network request failed: HTTP status {httpStatus}", + "@errorRequestFailed": { + "description": "Error message when an API call fails.", + "placeholders": { + "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/api/core.dart b/lib/api/core.dart index 3a56cc4db9..b28da078b8 100644 --- a/lib/api/core.dart +++ b/lib/api/core.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:http/http.dart' as http; import '../log.dart'; +import '../model/localizations.dart'; import 'exception.dart'; /// A value for an API request parameter, to use directly without JSON encoding. @@ -90,7 +91,8 @@ class ApiConnection { } else if (e is TlsException) { message = e.message; } else { - message = 'Network request failed'; + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + message = zulipLocalizations.errorNetworkRequestFailed; } throw NetworkException(routeName: routeName, cause: e, message: message); } diff --git a/lib/api/exception.dart b/lib/api/exception.dart index 43f548e6df..f43d3a63e1 100644 --- a/lib/api/exception.dart +++ b/lib/api/exception.dart @@ -1,4 +1,6 @@ +import '../model/localizations.dart'; + /// Some kind of error from a Zulip API network request. sealed class ApiRequestException implements Exception { /// The name of the Zulip API route for the request. @@ -18,6 +20,9 @@ sealed class ApiRequestException implements Exception { final String message; ApiRequestException({required this.routeName, required this.message}); + + @override + String toString() => message; } /// An error returned through the Zulip server API. @@ -89,7 +94,8 @@ class Server5xxException extends ServerException { required super.httpStatus, required super.data, }) : assert(500 <= httpStatus && httpStatus <= 599), - super(message: 'Network request failed: HTTP status $httpStatus'); // TODO(i18n) + super(message: GlobalLocalizations.zulipLocalizations + .errorRequestFailed(httpStatus)); } /// An error where the server's response doesn't match the Zulip API. @@ -110,5 +116,6 @@ class MalformedServerResponseException extends ServerException { required super.routeName, required super.httpStatus, required super.data, - }) : super(message: 'Server gave malformed response; HTTP status $httpStatus'); // TODO(i18n) + }) : super(message: GlobalLocalizations.zulipLocalizations + .errorMalformedResponse(httpStatus)); } diff --git a/lib/model/localizations.dart b/lib/model/localizations.dart new file mode 100644 index 0000000000..4aa4b5dcc4 --- /dev/null +++ b/lib/model/localizations.dart @@ -0,0 +1,6 @@ +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; + +abstract final class GlobalLocalizations { + static ZulipLocalizations zulipLocalizations = + lookupZulipLocalizations(ZulipLocalizations.supportedLocales.first); +} diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart index f7bee7b7f2..0019583883 100644 --- a/lib/widgets/action_sheet.dart +++ b/lib/widgets/action_sheet.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:share_plus/share_plus.dart'; import '../api/exception.dart'; @@ -43,7 +44,7 @@ abstract class MessageActionSheetMenuItemButton extends StatelessWidget { }) : assert(messageListContext.findAncestorWidgetOfExactType() != null); IconData get icon; - String get label; + String label(ZulipLocalizations zulipLocalizations); 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(label(zulipLocalizations))); } } @@ -67,7 +69,10 @@ class ShareButton extends MessageActionSheetMenuItemButton { @override get icon => Icons.adaptive.share; - @override get label => 'Share'; + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionShare; + } @override get onPressed => (BuildContext context) async { // Close the message action sheet; we're about to show the share @@ -104,13 +109,14 @@ 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.errorMessageDoesNotSeemToExist; } } catch (e) { switch (e) { @@ -119,7 +125,7 @@ Future fetchRawContentWithFeedback({ // TODO specific messages for common errors, like network errors // (support with reusable code) default: - errorMessage = 'Could not fetch message source.'; + errorMessage = zulipLocalizations.errorCouldNotFetchMessageSource; } } @@ -146,12 +152,16 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { @override get icon => Icons.format_quote_outlined; - @override get label => 'Quote and reply'; + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionQuoteAndReply; + } @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 +184,7 @@ class QuoteAndReplyButton extends MessageActionSheetMenuItemButton { final rawContent = await fetchRawContentWithFeedback( context: messageListContext, messageId: message.id, - errorDialogTitle: 'Quotation failed', + errorDialogTitle: zulipLocalizations.errorQuotationFailed, ); if (!messageListContext.mounted) return; @@ -203,26 +213,30 @@ class CopyButton extends MessageActionSheetMenuItemButton { @override get icon => Icons.copy; - @override get label => 'Copy message text'; + @override + String label(ZulipLocalizations zulipLocalizations) { + return zulipLocalizations.actionSheetOptionCopy; + } @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.errorCopyingFailed, ); if (rawContent == null) return; if (!messageListContext.mounted) return; - // TODO(i18n) - copyWithPopup(context: context, successContent: const Text('Message copied'), + copyWithPopup(context: context, + successContent: Text(zulipLocalizations.successMessageCopied), data: ClipboardData(text: rawContent)); }; } diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 17f2e2e457..fb350e9738 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; +import '../model/localizations.dart'; import '../model/narrow.dart'; import 'about_zulip.dart'; import 'login.dart'; @@ -49,6 +50,10 @@ class ZulipApp extends StatelessWidget { localizationsDelegates: ZulipLocalizations.localizationsDelegates, supportedLocales: ZulipLocalizations.supportedLocales, theme: theme, + builder: (BuildContext context, Widget? child) { + GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context); + return child!; + }, home: const ChooseAccountPage())); } } diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index dbcca82903..34b2c6d33b 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/dialog.dart b/lib/widgets/dialog.dart index 985e7cd5f9..be2fca21f5 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.errorDialogContinue)), ])); } @@ -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.dialogCancel)), TextButton( onPressed: onActionButtonPress, - child: _dialogActionText(actionButtonText ?? 'Continue')), + child: _dialogActionText(actionButtonText ?? zulipLocalizations.dialogContinue)), ])); } diff --git a/lib/widgets/lightbox.dart b/lib/widgets/lightbox.dart index 829b7ab527..4f47e9e919 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.successLinkCopied), 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 ec140c0474..affe0d6433 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.loginAddAnAccountPageTitle), 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.errorLoginFailedTitle, + 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)), ]))))))); } } diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart index 79fdd24cfd..8bf20d5a56 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 9b1cc26b61..c5883b386f 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'; @@ -153,9 +154,11 @@ void main() { ..which(condition)); } + final zulipLocalizations = lookupZulipLocalizations(ZulipLocalizations.supportedLocales.first); 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')); + checkRequest((foo: 'bar'), it() + ..message.equals(zulipLocalizations.errorNetworkRequestFailed)); }); test('API 4xx errors, well formed', () async { diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart index 0d143ae5e3..64c4c2cd5b 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 164cd76515..6f540111ca 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 ace8c4d831..2b06625377 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 8078d16ab7..cd9324ebe9 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)))));