From c88b990529265a88998fef653383862537113ee2 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Tue, 5 Sep 2023 15:30:21 +0100 Subject: [PATCH 1/4] intl: Set up basic i18n framework Set up `flutter_localizations` integration and blank test bundle of ARB files. Fixes: #275 --- assets/l10n/app_en.arb | 1 + assets/l10n/app_ja.arb | 1 + l10n.yaml | 11 +++++++++++ lib/widgets/app.dart | 3 +++ pubspec.lock | 5 +++++ pubspec.yaml | 7 +++++++ 6 files changed, 28 insertions(+) create mode 100644 assets/l10n/app_en.arb create mode 100644 assets/l10n/app_ja.arb create mode 100644 l10n.yaml diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/assets/l10n/app_en.arb @@ -0,0 +1 @@ +{} diff --git a/assets/l10n/app_ja.arb b/assets/l10n/app_ja.arb new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/assets/l10n/app_ja.arb @@ -0,0 +1 @@ +{} diff --git a/l10n.yaml b/l10n.yaml new file mode 100644 index 0000000000..e9bf7ee62d --- /dev/null +++ b/l10n.yaml @@ -0,0 +1,11 @@ +# Docs on this config file: +# https://docs.flutter.dev/ui/accessibility-and-localization/internationalization#configuring-the-l10nyaml-file + +arb-dir: assets/l10n +template-arb-file: app_en.arb +required-resource-attributes: true +output-localization-file: zulip_localizations.dart +untranslated-messages-file: build/untranslated_messages.json +output-class: ZulipLocalizations +preferred-supported-locales: [ en ] +nullable-getter: false diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index af9051ad4e..fcc93b9059 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import '../model/narrow.dart'; import 'about_zulip.dart'; @@ -45,6 +46,8 @@ class ZulipApp extends StatelessWidget { return GlobalStoreWidget( child: MaterialApp( title: 'Zulip', + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, theme: theme, home: const ChooseAccountPage())); } diff --git a/pubspec.lock b/pubspec.lock index 320c58b783..67fd3f522b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -358,6 +358,11 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.2" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c1812524a7..efdadb7223 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,8 @@ dependencies: package_info_plus: ^4.0.1 collection: ^1.17.2 url_launcher: ^6.1.11 + flutter_localizations: + sdk: flutter dev_dependencies: flutter_test: @@ -82,6 +84,11 @@ dev_dependencies: # The following section is specific to Flutter packages. flutter: + # Generate localization bindings from ARB files in lib/l10n/. + # This happens automatically with `flutter run` + # but can be manually run with `flutter gen-l10n` + generate: true + # The following line ensures that the Material Icons font is # included with your application, so that you can use the icons in # the material Icons class. From 9db431008d36df84a968720b5ce1739fd37aaf3f Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Tue, 5 Sep 2023 15:38:52 +0100 Subject: [PATCH 2/4] intl: Change some hard-coded strings to be localized Showcasing using full static strings in widgets, strings with placeholders that need to respond to pluralization, and strings in error dialogs. --- README.md | 10 ++++ assets/l10n/app_en.arb | 61 ++++++++++++++++++++- assets/l10n/app_ja.arb | 16 +++++- docs/translation.md | 97 ++++++++++++++++++++++++++++++++++ lib/widgets/app.dart | 11 ++-- lib/widgets/compose_box.dart | 10 ++-- lib/widgets/profile.dart | 20 +++---- test/widgets/profile_test.dart | 3 ++ 8 files changed, 207 insertions(+), 21 deletions(-) create mode 100644 docs/translation.md diff --git a/README.md b/README.md index 17f003e7a4..bdbe5b65dc 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,16 @@ The generated files that most frequently need an update are run `flutter pub get && flutter build ios --config-only && flutter build macos --config-only`. +### Translation + +We currently have a framework for string translation in place that +incorporates the `flutter_localizations` package and has some +example usages. + +For information on how the dart bindings are generated and how +to add new strings, refer to the [translation docs](docs/translation.md). + + ## License Copyright (c) 2022 Kandra Labs, Inc., and contributors. diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 0967ef424b..f6d5bfe9b1 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -1 +1,60 @@ -{} +{ + "chooseAccountPageTitle": "Choose account", + "@chooseAccountPageTitle": { + "description": "Title for ChooseAccountPage" + }, + "chooseAccountButtonAddAnAccount": "Add an account", + "@chooseAccountButtonAddAnAccount": { + "description": "Label for ChooseAccountPage button to add an account" + }, + "profileButtonSendDirectMessage": "Send direct message", + "@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." + }, + "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." + }, + "cameraAccessDeniedButtonText": "Open settings", + "@cameraAccessDeniedButtonText": { + "description": "Message for dialog when the user needs to grant permissions for camera access." + }, + "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" + } + } + }, + "userRoleOwner": "Owner", + "@userRoleOwner": { + "description": "Label for UserRole.owner" + }, + "userRoleAdministrator": "Administrator", + "@userRoleAdministrator": { + "description": "Label for UserRole.administrator" + }, + "userRoleModerator": "Moderator", + "@userRoleModerator": { + "description": "Label for UserRole.moderator" + }, + "userRoleMember": "Member", + "@userRoleMember": { + "description": "Label for UserRole.member" + }, + "userRoleGuest": "Guest", + "@userRoleGuest": { + "description": "Label for UserRole.guest" + }, + "userRoleUnknown": "Unknown", + "@userRoleUnknown": { + "description": "Label for UserRole.unknown" + } +} diff --git a/assets/l10n/app_ja.arb b/assets/l10n/app_ja.arb index 0967ef424b..e547e5ca8b 100644 --- a/assets/l10n/app_ja.arb +++ b/assets/l10n/app_ja.arb @@ -1 +1,15 @@ -{} +{ + "chooseAccountPageTitle": "アカウントを選択", + "chooseAccountButtonAddAnAccount": "新しいアカウントを追加", + "profileButtonSendDirectMessage": "ダイレクトメッセージを送信", + "cameraAccessDeniedTitle": "権限が必要です", + "cameraAccessDeniedMessage": "画像をアップロードするには、「設定」で Zulip に追加の権限を許可してください。", + "cameraAccessDeniedButtonText": "設定を開く", + "subscribedToNStreams": "{num, plural, other{{num}つのストリームをフォローしています}}", + "userRoleOwner": "オーナー", + "userRoleAdministrator": "管理者", + "userRoleModerator": "モデレータ", + "userRoleMember": "メンバー", + "userRoleGuest": "ゲスト", + "userRoleUnknown": "不明" +} diff --git a/docs/translation.md b/docs/translation.md new file mode 100644 index 0000000000..f11411b8f5 --- /dev/null +++ b/docs/translation.md @@ -0,0 +1,97 @@ +# Translations + +Our goal is for this app to be localized and offered in many +languages, just like zulip-mobile and zulip web. + +## Current state + +Currently in place is integration with `flutter_localizations` +package, allowing all flutter UI elements to be localized + +Per the discussion in #275 the approach here is to start with +ARB files and have dart autogenerate the bindings. I believe +this is the most straightforward way when connecting with a +translation management system, as they output ARB files that +we consume (this is also the same way web and mobile works +but with .po or .json files, I believe). + +## Adding new strings + +Add the appropriate entry in `assets/l10n/app_en.arb` ensuring +you add a corresponding resource attribute describing the +string in context. Example: + +``` + "profileButtonSendDirectMessage": "Send direct message", + "@profileButtonSendDirectMessage": { + "description": "Label for button in profile screen to navigate to DMs with the shown user." + }, +``` + +The bindings are automatically generated when you execute +`flutter run` although you can also manually trigger it +using `flutter gen-l10n`. + +Untranslated strings will be included in a generated +`build/untranslated_messages.json` file. This output +awaits #276. + +## Using in code + +To utilize in our widgets you need to import the generated +bindings: +``` +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; +``` + +And in your widget code pull the localizations out of the context: +``` +Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); +``` + +And finally access one of the generated properties: +`Text(zulipLocalizations.chooseAccountButtonAddAnAccount)`. + +String that take placeholders are generated as functions +that take arguments: `zulipLocalizations.subscribedToNStreams(store.subscriptions.length)` + +## Hack to enforce locale (for testing, etc) + +To manually trigger a locale change for testing I've found +it helpful to add the `localeResolutionCallback` in +`app.dart` to enforce a particular locale: + +``` +return GlobalStoreWidget( + child: MaterialApp( + title: 'Zulip', + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, + localeResolutionCallback: (locale, supportedLocales) { + return const Locale("ja"); + }, + theme: theme, + home: const ChooseAccountPage())); +``` + +(careful that returning a locale not in `supportedLocales` +will crash, the default behavior ensures a fallback is +always selected) + +## Tests + +Widgets that access localization will fail if the root +`MaterialApp` given in the setup isn't also set up with +localizations. Make sure to add the right +`localizationDelegates` and `supportedLocales`: + +``` + await tester.pumpWidget( + GlobalStoreWidget( + child: MaterialApp( + navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [], + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, + home: PerAccountStoreWidget( +``` diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index fcc93b9059..17f2e2e457 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -79,11 +79,12 @@ class ChooseAccountPage extends StatelessWidget { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); assert(!PerAccountStoreWidget.debugExistsOf(context)); final globalStore = GlobalStoreWidget.of(context); return Scaffold( appBar: AppBar( - title: const Text('Choose account'), + title: Text(zulipLocalizations.chooseAccountPageTitle), actions: const [ChooseAccountPageOverflowButton()]), body: SafeArea( minimum: const EdgeInsets.all(8), @@ -100,7 +101,7 @@ class ChooseAccountPage extends StatelessWidget { ElevatedButton( onPressed: () => Navigator.push(context, AddAccountPage.buildRoute()), - child: const Text('Add an account')), + child: Text(zulipLocalizations.chooseAccountButtonAddAnAccount)), ]))), )); } @@ -140,6 +141,7 @@ class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); InlineSpan bold(String text) => TextSpan( text: text, style: const TextStyle(fontWeight: FontWeight.bold)); @@ -164,10 +166,7 @@ class HomePage extends StatelessWidget { Text.rich(TextSpan( text: 'Zulip server version: ', children: [bold(store.zulipVersion)])), - Text.rich(TextSpan(text: 'Subscribed to ', children: [ - bold(store.subscriptions.length.toString()), - const TextSpan(text: ' streams'), - ])), + Text(zulipLocalizations.subscribedToNStreams(store.subscriptions.length)), ])), const SizedBox(height: 16), ElevatedButton( diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index e91f8d1f2f..dbcca82903 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -2,6 +2,7 @@ import 'package:app_settings/app_settings.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:image_picker/image_picker.dart'; import '../api/model/model.dart'; @@ -603,6 +604,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton { @override Future> getFiles(BuildContext context) async { + final zulipLocalizations = ZulipLocalizations.of(context); final picker = ImagePicker(); final XFile? result; try { @@ -619,10 +621,10 @@ class _AttachFromCameraButton extends _AttachUploadsButton { // permission-request alert once, the first time the app wants to // use a protected resource. After that, the only way the user can // grant it is in Settings. - showSuggestedActionDialog(context: context, // TODO(i18n) - title: 'Permissions needed', - message: 'To upload an image, please grant Zulip additional permissions in Settings.', - actionButtonText: 'Open settings', + showSuggestedActionDialog(context: context, + title: zulipLocalizations.cameraAccessDeniedTitle, + message: zulipLocalizations.cameraAccessDeniedMessage, + actionButtonText: zulipLocalizations.cameraAccessDeniedButtonText, onActionButtonPress: () { AppSettings.openAppSettings(); }); diff --git a/lib/widgets/profile.dart b/lib/widgets/profile.dart index bca6a0922d..6d125fbff0 100644 --- a/lib/widgets/profile.dart +++ b/lib/widgets/profile.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import '../api/model/model.dart'; import '../model/content.dart'; @@ -28,6 +29,7 @@ class ProfilePage extends StatelessWidget { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); final store = PerAccountStoreWidget.of(context); final user = store.users[userId]; if (user == null) { @@ -42,7 +44,7 @@ class ProfilePage extends StatelessWidget { textAlign: TextAlign.center, style: _TextStyles.primaryFieldText.merge(const TextStyle(fontWeight: FontWeight.bold))), // TODO(#291) render email field - Text(roleToLabel(user.role), + Text(roleToLabel(user.role, zulipLocalizations), textAlign: TextAlign.center, style: _TextStyles.primaryFieldText), // TODO(#197) render user status @@ -56,7 +58,7 @@ class ProfilePage extends StatelessWidget { MessageListPage.buildRoute(context: context, narrow: DmNarrow.withUser(userId, selfUserId: store.account.userId))), icon: const Icon(Icons.email), - label: const Text('Send direct message')), + label: Text(zulipLocalizations.profileButtonSendDirectMessage)), ]; return Scaffold( @@ -93,14 +95,14 @@ class _ProfileErrorPage extends StatelessWidget { } } -String roleToLabel(UserRole role) { +String roleToLabel(UserRole role, ZulipLocalizations zulipLocalizations) { return switch (role) { - UserRole.owner => 'Owner', - UserRole.administrator => 'Administrator', - UserRole.moderator => 'Moderator', - UserRole.member => 'Member', - UserRole.guest => 'Guest', - UserRole.unknown => 'Unknown', + UserRole.owner => zulipLocalizations.userRoleOwner, + UserRole.administrator => zulipLocalizations.userRoleAdministrator, + UserRole.moderator => zulipLocalizations.userRoleModerator, + UserRole.member => zulipLocalizations.userRoleMember, + UserRole.guest => zulipLocalizations.userRoleGuest, + UserRole.unknown => zulipLocalizations.userRoleUnknown, }; } diff --git a/test/widgets/profile_test.dart b/test/widgets/profile_test.dart index 37e029047c..1d7f72d0fe 100644 --- a/test/widgets/profile_test.dart +++ b/test/widgets/profile_test.dart @@ -1,6 +1,7 @@ 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/model/initial_snapshot.dart'; @@ -44,6 +45,8 @@ Future setupPage(WidgetTester tester, { GlobalStoreWidget( child: MaterialApp( navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [], + localizationsDelegates: ZulipLocalizations.localizationsDelegates, + supportedLocales: ZulipLocalizations.supportedLocales, home: PerAccountStoreWidget( accountId: eg.selfAccount.id, child: ProfilePage(userId: pageUserId))))); From 3165c8121656a3421fb95af0d8f0eba97a378598 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Thu, 14 Sep 2023 18:22:59 +0100 Subject: [PATCH 3/4] intl: Convert strings in `about_zulip.dart` to pull from translations This commit showcases how to add new translation entries as well as how to insert them into widget rendering code. --- assets/l10n/app_en.arb | 16 ++++++++++++++++ lib/widgets/about_zulip.dart | 10 ++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index f6d5bfe9b1..45f7fa33fa 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -1,4 +1,20 @@ { + "aboutPageTitle": "About Zulip", + "@aboutPageTitle": { + "description": "Title for About Zulip page" + }, + "aboutPageAppVersion": "App version", + "@aboutPageAppVersion": { + "description": "Label for Zulip app version in About Zulip page" + }, + "aboutPageOpenSourceLicenses": "Open-source licenses", + "@aboutPageOpenSourceLicenses": { + "description": "Section heading in About Zulip page to navigate to Open-source Licenses page" + }, + "aboutPageTapToView": "Tap to view", + "@aboutPageTapToView": { + "description": "Button label in About Zulip page to navigate to Open-source Licenses page" + }, "chooseAccountPageTitle": "Choose account", "@chooseAccountPageTitle": { "description": "Title for ChooseAccountPage" diff --git a/lib/widgets/about_zulip.dart b/lib/widgets/about_zulip.dart index 57afa6737a..3cd55eacaf 100644 --- a/lib/widgets/about_zulip.dart +++ b/lib/widgets/about_zulip.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'page.dart'; @@ -30,8 +31,9 @@ class _AboutZulipPageState extends State { @override Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); return Scaffold( - appBar: AppBar(title: const Text("About Zulip")), + appBar: AppBar(title: Text(zulipLocalizations.aboutPageTitle)), body: SingleChildScrollView( child: SafeArea( minimum: const EdgeInsets.all(8), // ListView pads vertical @@ -40,11 +42,11 @@ class _AboutZulipPageState extends State { constraints: const BoxConstraints(maxWidth: 400), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ ListTile( - title: const Text('App version'), + title: Text(zulipLocalizations.aboutPageAppVersion), subtitle: Text(_packageInfo?.version ?? '(…)')), ListTile( - title: const Text('Open-source licenses'), - subtitle: const Text('Tap to view'), + title: Text(zulipLocalizations.aboutPageOpenSourceLicenses), + subtitle: Text(zulipLocalizations.aboutPageTapToView), onTap: () { // TODO(upstream?): This route and its child routes (pushed // when you tap a package to view its licenses) can't be From 67dff5130a77094e23c4c25f14617c05303027d2 Mon Sep 17 00:00:00 2001 From: Greg Price Date: Thu, 14 Sep 2023 16:48:38 -0700 Subject: [PATCH 4/4] docs: Make an editing pass on new translation docs Mostly this revises the text to be more fully oriented toward giving instructions on what one needs to know for working on the app, and in particular for adding UI features. Also add the fun and helpful fact that a hot reload is enough to cause the bindings to get updated from the ARB files. And tweak the descriptions on a couple of strings; these label just a single item, not a section. --- README.md | 10 +--- assets/l10n/app_en.arb | 4 +- docs/translation.md | 130 +++++++++++++++++++++++++++++------------ 3 files changed, 97 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index bdbe5b65dc..14950f1488 100644 --- a/README.md +++ b/README.md @@ -145,14 +145,10 @@ The generated files that most frequently need an update are run `flutter pub get && flutter build ios --config-only && flutter build macos --config-only`. -### Translation +### Translations and i18n -We currently have a framework for string translation in place that -incorporates the `flutter_localizations` package and has some -example usages. - -For information on how the dart bindings are generated and how -to add new strings, refer to the [translation docs](docs/translation.md). +When adding new strings in the UI, we set them up to be translated. +For details on how to do this, see the [translation doc](docs/translation.md). ## License diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 45f7fa33fa..f642df803c 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -9,11 +9,11 @@ }, "aboutPageOpenSourceLicenses": "Open-source licenses", "@aboutPageOpenSourceLicenses": { - "description": "Section heading in About Zulip page to navigate to Open-source Licenses page" + "description": "Item title in About Zulip page to navigate to Licenses page" }, "aboutPageTapToView": "Tap to view", "@aboutPageTapToView": { - "description": "Button label in About Zulip page to navigate to Open-source Licenses page" + "description": "Item subtitle in About Zulip page to navigate to Licenses page" }, "chooseAccountPageTitle": "Choose account", "@chooseAccountPageTitle": { diff --git a/docs/translation.md b/docs/translation.md index f11411b8f5..fcded2ef10 100644 --- a/docs/translation.md +++ b/docs/translation.md @@ -1,26 +1,43 @@ # Translations Our goal is for this app to be localized and offered in many -languages, just like zulip-mobile and zulip web. +languages, just like zulip-mobile and Zulip web. + ## Current state -Currently in place is integration with `flutter_localizations` -package, allowing all flutter UI elements to be localized +We have a framework set up that makes it possible for UI strings +to be translated. (This was issue #275.) This means that when +adding new strings to the UI, instead of using a constant string +in English we'll add the string to that framework. +For details, see below. + +At present not all of the codebase has been migrated to use the framework, +so you'll see some existing code that uses constant strings. +Fixing that is issue #277. + +At present we don't have the strings wired up to a platform for +people to contribute translations. That's issue #276. +Until then, we have only a handful of strings actually translated, +just to make it possible to demonstrate the framework +is working correctly. + -Per the discussion in #275 the approach here is to start with -ARB files and have dart autogenerate the bindings. I believe -this is the most straightforward way when connecting with a -translation management system, as they output ARB files that -we consume (this is also the same way web and mobile works -but with .po or .json files, I believe). +## Adding new UI strings -## Adding new strings +### Adding a string to the translation database -Add the appropriate entry in `assets/l10n/app_en.arb` ensuring -you add a corresponding resource attribute describing the -string in context. Example: +To add a new string in the UI, start by +adding an entry in the ARB file `assets/l10n/app_en.arb`. +This includes a name that you choose for the string, +its value in English, +and a "resource attribute" describing the string in context. +The name will become an identifier in our Dart code. +The description will provide context for people contributing translations. +For example, this entry describes a UI string +named `profileButtonSendDirectMessage` +which appears in English as "Send direct message": ``` "profileButtonSendDirectMessage": "Send direct message", "@profileButtonSendDirectMessage": { @@ -28,39 +45,54 @@ string in context. Example: }, ``` -The bindings are automatically generated when you execute -`flutter run` although you can also manually trigger it -using `flutter gen-l10n`. +Then run the app (with `flutter run` or in your IDE), +or perform a hot reload, +to cause the Dart bindings to be updated based on your +changes to the ARB file. +(You can also trigger an update directly, with `flutter gen-l10n`.) -Untranslated strings will be included in a generated -`build/untranslated_messages.json` file. This output -awaits #276. -## Using in code +### Using a translated string in the code -To utilize in our widgets you need to import the generated -bindings: +To use in our widgets, you need to import the generated bindings: ``` import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; ``` -And in your widget code pull the localizations out of the context: +Then in your widget code, pull the localizations object +off of the Flutter build context: ``` Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); ``` -And finally access one of the generated properties: -`Text(zulipLocalizations.chooseAccountButtonAddAnAccount)`. +Finally, on the localizations object use the getter +that was generated for the new string: +`Text(zulipLocalizations.profileButtonSendDirectMessage)`. + + +### Strings with placeholders + +When a UI string is a constant per language, with no placeholders, +the generated Dart code provides a simple getter, as seen above. + +When the string takes a placeholder, +the generated Dart binding for it will instead be a function, +taking arguments corresponding to the placeholders. -String that take placeholders are generated as functions -that take arguments: `zulipLocalizations.subscribedToNStreams(store.subscriptions.length)` +For example: +`zulipLocalizations.subscribedToNStreams(store.subscriptions.length)`. -## Hack to enforce locale (for testing, etc) -To manually trigger a locale change for testing I've found -it helpful to add the `localeResolutionCallback` in -`app.dart` to enforce a particular locale: +## Hack to enforce locale (for testing, etc.) + +For testing the app's behavior in different locales, +you can use your device's system settings to +change the preferred language. + +Alternatively, you may find it helpful to +pass a `localeResolutionCallback` to the `MaterialApp` in `app.dart` +to enforce a particular locale: ``` return GlobalStoreWidget( @@ -75,16 +107,20 @@ return GlobalStoreWidget( home: const ChooseAccountPage())); ``` -(careful that returning a locale not in `supportedLocales` -will crash, the default behavior ensures a fallback is -always selected) +(When using this hack, returning a locale not in `supportedLocales` will +cause a crash. +The default behavior without `localeResolutionCallback` ensures +a fallback is always selected.) + ## Tests -Widgets that access localization will fail if the root -`MaterialApp` given in the setup isn't also set up with -localizations. Make sure to add the right -`localizationDelegates` and `supportedLocales`: +Widgets that access localizations will fail if +the ambient `MaterialApp` isn't set up for localizations. +For the `MaterialApp` used in the app, we do this in `app.dart`. +In tests, this typically requires a test's setup code to provide +arguments `localizationDelegates` and `supportedLocales`. +For example: ``` await tester.pumpWidget( @@ -95,3 +131,21 @@ localizations. Make sure to add the right supportedLocales: ZulipLocalizations.supportedLocales, home: PerAccountStoreWidget( ``` + + +## Other notes + +Our approach uses the `flutter_localizations` package. +We use the `gen_l10n` way, where we write ARB files +and the tool generates the Dart bindings. + +As discussed in issue #275, the other way around was +also an option. But this way seems most straightforward +when connecting with a translation management system, +as they output ARB files that we consume. +This also parallels how zulip-mobile works with `.json` files +(and Zulip web, and the Zulip server with `.po` files?) + +A file `build/untranslated_messages.json` is emitted +whenever the Dart bindings are generated from the ARB files. +This output awaits #276.