Skip to content

Commit 8b25402

Browse files
committed
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.
1 parent 1fad245 commit 8b25402

File tree

8 files changed

+207
-21
lines changed

8 files changed

+207
-21
lines changed

README.md

+10
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,16 @@ The generated files that most frequently need an update are
145145
run `flutter pub get && flutter build ios --config-only && flutter build macos --config-only`.
146146

147147

148+
### Translation
149+
150+
We currently have a framework for string translation in place that
151+
incorporates the `flutter_localizations` package and has some
152+
example usages.
153+
154+
For information on how the dart bindings are generated and how
155+
to add new strings, refer to the [translation docs](docs/translation.md).
156+
157+
148158
## License
149159

150160
Copyright (c) 2022 Kandra Labs, Inc., and contributors.

assets/l10n/app_en.arb

+60-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,60 @@
1-
{}
1+
{
2+
"chooseAccountPageTitle": "Choose account",
3+
"@chooseAccountPageTitle": {
4+
"description": "Title for ChooseAccountPage"
5+
},
6+
"chooseAccountButtonAddAnAccount": "Add an account",
7+
"@chooseAccountButtonAddAnAccount": {
8+
"description": "Label for ChooseAccountPage button to add an account"
9+
},
10+
"profileButtonSendDirectMessage": "Send direct message",
11+
"@profileButtonSendDirectMessage": {
12+
"description": "Label for button in profile screen to navigate to DMs with the shown user."
13+
},
14+
"cameraAccessDeniedTitle": "Permissions needed",
15+
"@cameraAccessDeniedTitle": {
16+
"description": "Title for dialog when the user needs to grant permissions for camera access."
17+
},
18+
"cameraAccessDeniedMessage": "To upload an image, please grant Zulip additional permissions in Settings.",
19+
"@cameraAccessDeniedMessage": {
20+
"description": "Message for dialog when the user needs to grant permissions for camera access."
21+
},
22+
"cameraAccessDeniedButtonText": "Open settings",
23+
"@cameraAccessDeniedButtonText": {
24+
"description": "Message for dialog when the user needs to grant permissions for camera access."
25+
},
26+
"subscribedToNStreams": "Subscribed to {num, plural, =0{no streams} =1{1 stream} other{{num} streams}}",
27+
"@subscribedToNStreams": {
28+
"description": "Test page label showing number of streams user is subscribed to.",
29+
"placeholders": {
30+
"num": {
31+
"type": "int",
32+
"example": "4"
33+
}
34+
}
35+
},
36+
"userRoleOwner": "Owner",
37+
"@userRoleOwner": {
38+
"description": "Label for UserRole.owner"
39+
},
40+
"userRoleAdministrator": "Administrator",
41+
"@userRoleAdministrator": {
42+
"description": "Label for UserRole.administrator"
43+
},
44+
"userRoleModerator": "Moderator",
45+
"@userRoleModerator": {
46+
"description": "Label for UserRole.moderator"
47+
},
48+
"userRoleMember": "Member",
49+
"@userRoleMember": {
50+
"description": "Label for UserRole.member"
51+
},
52+
"userRoleGuest": "Guest",
53+
"@userRoleGuest": {
54+
"description": "Label for UserRole.guest"
55+
},
56+
"userRoleUnknown": "Unknown",
57+
"@userRoleUnknown": {
58+
"description": "Label for UserRole.unknown"
59+
}
60+
}

assets/l10n/app_ja.arb

+15-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,15 @@
1-
{}
1+
{
2+
"chooseAccountPageTitle": "アカウントを選択",
3+
"chooseAccountButtonAddAnAccount": "新しいアカウントを追加",
4+
"profileButtonSendDirectMessage": "ダイレクトメッセージを送信",
5+
"cameraAccessDeniedTitle": "権限が必要です",
6+
"cameraAccessDeniedMessage": "画像をアップロードするには、「設定」で Zulip に追加の権限を許可してください。",
7+
"cameraAccessDeniedButtonText": "設定を開く",
8+
"subscribedToNStreams": "{num, plural, other{{num}つのストリームをフォローしています}}",
9+
"userRoleOwner": "オーナー",
10+
"userRoleAdministrator": "管理者",
11+
"userRoleModerator": "モデレータ",
12+
"userRoleMember": "メンバー",
13+
"userRoleGuest": "ゲスト",
14+
"userRoleUnknown": "不明"
15+
}

docs/translation.md

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Translations
2+
3+
Our goal is for this app to be localized and offered in many
4+
languages, just like zulip-mobile and zulip web.
5+
6+
## Current state
7+
8+
Currently in place is integration with `flutter_localizations`
9+
package, allowing all flutter UI elements to be localized
10+
11+
Per the discussion in #275 the approach here is to start with
12+
ARB files and have dart autogenerate the bindings. I believe
13+
this is the most straightforward way when connecting with a
14+
translation management system, as they output ARB files that
15+
we consume (this is also the same way web and mobile works
16+
but with .po or .json files, I believe).
17+
18+
## Adding new strings
19+
20+
Add the appropriate entry in `assets/l10n/app_en.arb` ensuring
21+
you add a corresponding resource attribute describing the
22+
string in context. Example:
23+
24+
```
25+
"profileButtonSendDirectMessage": "Send direct message",
26+
"@profileButtonSendDirectMessage": {
27+
"description": "Label for button in profile screen to navigate to DMs with the shown user."
28+
},
29+
```
30+
31+
The bindings are automatically generated when you execute
32+
`flutter run` although you can also manually trigger it
33+
using `flutter gen-l10n`.
34+
35+
Untranslated strings will be included in a generated
36+
`build/untranslated_messages.json` file. This output
37+
awaits #276.
38+
39+
## Using in code
40+
41+
To utilize in our widgets you need to import the generated
42+
bindings:
43+
```
44+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
45+
```
46+
47+
And in your widget code pull the localizations out of the context:
48+
```
49+
Widget build(BuildContext context) {
50+
final zulipLocalizations = ZulipLocalizations.of(context);
51+
```
52+
53+
And finally access one of the generated properties:
54+
`Text(zulipLocalizations.chooseAccountButtonAddAnAccount)`.
55+
56+
String that take placeholders are generated as functions
57+
that take arguments: `zulipLocalizations.subscribedToNStreams(store.subscriptions.length)`
58+
59+
## Hack to enforce locale (for testing, etc)
60+
61+
To manually trigger a locale change for testing I've found
62+
it helpful to add the `localeResolutionCallback` in
63+
`app.dart` to enforce a particular locale:
64+
65+
```
66+
return GlobalStoreWidget(
67+
child: MaterialApp(
68+
title: 'Zulip',
69+
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
70+
supportedLocales: ZulipLocalizations.supportedLocales,
71+
localeResolutionCallback: (locale, supportedLocales) {
72+
return const Locale("ja");
73+
},
74+
theme: theme,
75+
home: const ChooseAccountPage()));
76+
```
77+
78+
(careful that returning a locale not in `supportedLocales`
79+
will crash, the default behavior ensures a fallback is
80+
always selected)
81+
82+
## Tests
83+
84+
Widgets that access localization will fail if the root
85+
`MaterialApp` given in the setup isn't also set up with
86+
localizations. Make sure to add the right
87+
`localizationDelegates` and `supportedLocales`:
88+
89+
```
90+
await tester.pumpWidget(
91+
GlobalStoreWidget(
92+
child: MaterialApp(
93+
navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [],
94+
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
95+
supportedLocales: ZulipLocalizations.supportedLocales,
96+
home: PerAccountStoreWidget(
97+
```

lib/widgets/app.dart

+5-6
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,12 @@ class ChooseAccountPage extends StatelessWidget {
7979

8080
@override
8181
Widget build(BuildContext context) {
82+
final zulipLocalizations = ZulipLocalizations.of(context);
8283
assert(!PerAccountStoreWidget.debugExistsOf(context));
8384
final globalStore = GlobalStoreWidget.of(context);
8485
return Scaffold(
8586
appBar: AppBar(
86-
title: const Text('Choose account'),
87+
title: Text(zulipLocalizations.chooseAccountPageTitle),
8788
actions: const [ChooseAccountPageOverflowButton()]),
8889
body: SafeArea(
8990
minimum: const EdgeInsets.all(8),
@@ -100,7 +101,7 @@ class ChooseAccountPage extends StatelessWidget {
100101
ElevatedButton(
101102
onPressed: () => Navigator.push(context,
102103
AddAccountPage.buildRoute()),
103-
child: const Text('Add an account')),
104+
child: Text(zulipLocalizations.chooseAccountButtonAddAnAccount)),
104105
]))),
105106
));
106107
}
@@ -140,6 +141,7 @@ class HomePage extends StatelessWidget {
140141
@override
141142
Widget build(BuildContext context) {
142143
final store = PerAccountStoreWidget.of(context);
144+
final zulipLocalizations = ZulipLocalizations.of(context);
143145

144146
InlineSpan bold(String text) => TextSpan(
145147
text: text, style: const TextStyle(fontWeight: FontWeight.bold));
@@ -164,10 +166,7 @@ class HomePage extends StatelessWidget {
164166
Text.rich(TextSpan(
165167
text: 'Zulip server version: ',
166168
children: [bold(store.zulipVersion)])),
167-
Text.rich(TextSpan(text: 'Subscribed to ', children: [
168-
bold(store.subscriptions.length.toString()),
169-
const TextSpan(text: ' streams'),
170-
])),
169+
Text(zulipLocalizations.subscribedToNStreams(store.subscriptions.length)),
171170
])),
172171
const SizedBox(height: 16),
173172
ElevatedButton(

lib/widgets/compose_box.dart

+6-4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:app_settings/app_settings.dart';
22
import 'package:file_picker/file_picker.dart';
33
import 'package:flutter/material.dart';
44
import 'package:flutter/services.dart';
5+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
56
import 'package:image_picker/image_picker.dart';
67

78
import '../api/model/model.dart';
@@ -603,6 +604,7 @@ class _AttachFromCameraButton extends _AttachUploadsButton {
603604

604605
@override
605606
Future<Iterable<_File>> getFiles(BuildContext context) async {
607+
final zulipLocalizations = ZulipLocalizations.of(context);
606608
final picker = ImagePicker();
607609
final XFile? result;
608610
try {
@@ -619,10 +621,10 @@ class _AttachFromCameraButton extends _AttachUploadsButton {
619621
// permission-request alert once, the first time the app wants to
620622
// use a protected resource. After that, the only way the user can
621623
// grant it is in Settings.
622-
showSuggestedActionDialog(context: context, // TODO(i18n)
623-
title: 'Permissions needed',
624-
message: 'To upload an image, please grant Zulip additional permissions in Settings.',
625-
actionButtonText: 'Open settings',
624+
showSuggestedActionDialog(context: context,
625+
title: zulipLocalizations.cameraAccessDeniedTitle,
626+
message: zulipLocalizations.cameraAccessDeniedMessage,
627+
actionButtonText: zulipLocalizations.cameraAccessDeniedButtonText,
626628
onActionButtonPress: () {
627629
AppSettings.openAppSettings();
628630
});

lib/widgets/profile.dart

+11-9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'dart:convert';
22

33
import 'package:flutter/material.dart';
4+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
45

56
import '../api/model/model.dart';
67
import '../model/content.dart';
@@ -28,6 +29,7 @@ class ProfilePage extends StatelessWidget {
2829

2930
@override
3031
Widget build(BuildContext context) {
32+
final zulipLocalizations = ZulipLocalizations.of(context);
3133
final store = PerAccountStoreWidget.of(context);
3234
final user = store.users[userId];
3335
if (user == null) {
@@ -42,7 +44,7 @@ class ProfilePage extends StatelessWidget {
4244
textAlign: TextAlign.center,
4345
style: _TextStyles.primaryFieldText.merge(const TextStyle(fontWeight: FontWeight.bold))),
4446
// TODO(#291) render email field
45-
Text(roleToLabel(user.role),
47+
Text(roleToLabel(user.role, zulipLocalizations),
4648
textAlign: TextAlign.center,
4749
style: _TextStyles.primaryFieldText),
4850
// TODO(#197) render user status
@@ -56,7 +58,7 @@ class ProfilePage extends StatelessWidget {
5658
MessageListPage.buildRoute(context: context,
5759
narrow: DmNarrow.withUser(userId, selfUserId: store.account.userId))),
5860
icon: const Icon(Icons.email),
59-
label: const Text('Send direct message')),
61+
label: Text(zulipLocalizations.profileButtonSendDirectMessage)),
6062
];
6163

6264
return Scaffold(
@@ -93,14 +95,14 @@ class _ProfileErrorPage extends StatelessWidget {
9395
}
9496
}
9597

96-
String roleToLabel(UserRole role) {
98+
String roleToLabel(UserRole role, ZulipLocalizations zulipLocalizations) {
9799
return switch (role) {
98-
UserRole.owner => 'Owner',
99-
UserRole.administrator => 'Administrator',
100-
UserRole.moderator => 'Moderator',
101-
UserRole.member => 'Member',
102-
UserRole.guest => 'Guest',
103-
UserRole.unknown => 'Unknown',
100+
UserRole.owner => zulipLocalizations.userRoleOwner,
101+
UserRole.administrator => zulipLocalizations.userRoleAdministrator,
102+
UserRole.moderator => zulipLocalizations.userRoleModerator,
103+
UserRole.member => zulipLocalizations.userRoleMember,
104+
UserRole.guest => zulipLocalizations.userRoleGuest,
105+
UserRole.unknown => zulipLocalizations.userRoleUnknown,
104106
};
105107
}
106108

test/widgets/profile_test.dart

+3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import 'package:checks/checks.dart';
22
import 'package:flutter/foundation.dart';
33
import 'package:flutter/material.dart';
4+
import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
45
import 'package:flutter_test/flutter_test.dart';
56
import 'package:url_launcher/url_launcher.dart';
67
import 'package:zulip/api/model/initial_snapshot.dart';
@@ -44,6 +45,8 @@ Future<void> setupPage(WidgetTester tester, {
4445
GlobalStoreWidget(
4546
child: MaterialApp(
4647
navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [],
48+
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
49+
supportedLocales: ZulipLocalizations.supportedLocales,
4750
home: PerAccountStoreWidget(
4851
accountId: eg.selfAccount.id,
4952
child: ProfilePage(userId: pageUserId)))));

0 commit comments

Comments
 (0)