Skip to content

Set up basic i18n framework #302

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Sep 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ The generated files that most frequently need an update are
run `flutter pub get && flutter build ios --config-only && flutter build macos --config-only`.


### Translations and i18n

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

Copyright (c) 2022 Kandra Labs, Inc., and contributors.
Expand Down
76 changes: 76 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
{
"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": "Item title in About Zulip page to navigate to Licenses page"
},
"aboutPageTapToView": "Tap to view",
"@aboutPageTapToView": {
"description": "Item subtitle in About Zulip page to navigate to Licenses page"
},
"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"
}
}
15 changes: 15 additions & 0 deletions assets/l10n/app_ja.arb
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"chooseAccountPageTitle": "アカウントを選択",
"chooseAccountButtonAddAnAccount": "新しいアカウントを追加",
"profileButtonSendDirectMessage": "ダイレクトメッセージを送信",
"cameraAccessDeniedTitle": "権限が必要です",
"cameraAccessDeniedMessage": "画像をアップロードするには、「設定」で Zulip に追加の権限を許可してください。",
"cameraAccessDeniedButtonText": "設定を開く",
"subscribedToNStreams": "{num, plural, other{{num}つのストリームをフォローしています}}",
"userRoleOwner": "オーナー",
"userRoleAdministrator": "管理者",
"userRoleModerator": "モデレータ",
"userRoleMember": "メンバー",
"userRoleGuest": "ゲスト",
"userRoleUnknown": "不明"
}
151 changes: 151 additions & 0 deletions docs/translation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Translations

Our goal is for this app to be localized and offered in many
languages, just like zulip-mobile and Zulip web.


## Current state

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.


## Adding new UI strings

### Adding a string to the translation database

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": {
"description": "Label for button in profile screen to navigate to DMs with the shown user."
},
```

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`.)


### Using a translated string in the code

To use in our widgets, you need to import the generated bindings:
```
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
```

Then in your widget code, pull the localizations object
off of the Flutter build context:
```
Widget build(BuildContext context) {
final zulipLocalizations = ZulipLocalizations.of(context);
```

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.

For example:
`zulipLocalizations.subscribedToNStreams(store.subscriptions.length)`.


## 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(
child: MaterialApp(
title: 'Zulip',
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
supportedLocales: ZulipLocalizations.supportedLocales,
localeResolutionCallback: (locale, supportedLocales) {
return const Locale("ja");
},
theme: theme,
home: const ChooseAccountPage()));
```

(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 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(
GlobalStoreWidget(
child: MaterialApp(
navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [],
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
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.
11 changes: 11 additions & 0 deletions l10n.yaml
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From reading through those docs on this config file, one other option looks like it'd be good to add:

Suggested change
template-arb-file: app_en.arb
template-arb-file: app_en.arb
required-resource-attributes: true

Requires all resource ids to contain a corresponding resource attribute.

By default, simple messages won’t require metadata, but it’s highly recommended as this provides context for the meaning of a message to readers.

We can always relent on that if we decide it's a pain. But my guess is that it won't actually be much of a burden to write a few words about what the message is for, at the time when we're adding it and it's right in front of us. (And I see you've consistently done that in the demo messages in this PR.)

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
10 changes: 6 additions & 4 deletions lib/widgets/about_zulip.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -30,8 +31,9 @@ class _AboutZulipPageState extends State<AboutZulipPage> {

@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
Expand All @@ -40,11 +42,11 @@ class _AboutZulipPageState extends State<AboutZulipPage> {
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
Expand Down
14 changes: 8 additions & 6 deletions lib/widgets/app.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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()));
}
Expand Down Expand Up @@ -76,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),
Expand All @@ -97,7 +101,7 @@ class ChooseAccountPage extends StatelessWidget {
ElevatedButton(
onPressed: () => Navigator.push(context,
AddAccountPage.buildRoute()),
child: const Text('Add an account')),
child: Text(zulipLocalizations.chooseAccountButtonAddAnAccount)),
]))),
));
}
Expand Down Expand Up @@ -137,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));
Expand All @@ -161,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(
Expand Down
10 changes: 6 additions & 4 deletions lib/widgets/compose_box.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -603,6 +604,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton {

@override
Future<Iterable<_File>> getFiles(BuildContext context) async {
final zulipLocalizations = ZulipLocalizations.of(context);
final picker = ImagePicker();
final XFile? result;
try {
Expand All @@ -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();
});
Expand Down
Loading