diff --git a/README.md b/README.md index 17f003e7a4..14950f1488 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb new file mode 100644 index 0000000000..f642df803c --- /dev/null +++ b/assets/l10n/app_en.arb @@ -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" + } +} diff --git a/assets/l10n/app_ja.arb b/assets/l10n/app_ja.arb new file mode 100644 index 0000000000..e547e5ca8b --- /dev/null +++ b/assets/l10n/app_ja.arb @@ -0,0 +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..fcded2ef10 --- /dev/null +++ b/docs/translation.md @@ -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. 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/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 diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index af9051ad4e..17f2e2e457 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())); } @@ -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), @@ -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)), ]))), )); } @@ -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)); @@ -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( 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/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. 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)))));