feat: ship the interface in eight languages, with per-language plurals and mirroring
This commit is contained in:
parent
6de6672ae3
commit
a4a49f41d7
28 changed files with 1795 additions and 32 deletions
89
app/lib/l10n/locales.dart
Normal file
89
app/lib/l10n/locales.dart
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/// The interface languages the client ships. Pure data: the settings page
|
||||
/// offers them, the loader reads their files, and the app tells Flutter which
|
||||
/// one is in use.
|
||||
library;
|
||||
|
||||
/// One shipped language: the tag used in settings and in the asset path, and
|
||||
/// the id of its name in the locale files. A language is always named in its
|
||||
/// own words, so a reader who cannot read the current interface can still find
|
||||
/// theirs.
|
||||
typedef AppLocale = ({String tag, String labelId});
|
||||
|
||||
/// Offered in this order, English first and then alphabetically by endonym.
|
||||
const appLocales = <AppLocale>[
|
||||
(tag: 'en-US', labelId: 'language-en'),
|
||||
(tag: 'de', labelId: 'language-de'),
|
||||
(tag: 'es', labelId: 'language-es'),
|
||||
(tag: 'fr', labelId: 'language-fr'),
|
||||
(tag: 'he', labelId: 'language-he'),
|
||||
(tag: 'ja', labelId: 'language-ja'),
|
||||
(tag: 'pt', labelId: 'language-pt'),
|
||||
(tag: 'ru', labelId: 'language-ru'),
|
||||
];
|
||||
|
||||
/// What the reader's language setting holds when they have not chosen one.
|
||||
const systemLanguage = 'system';
|
||||
|
||||
const fallbackLocale = 'en-US';
|
||||
|
||||
/// Written right to left, so the whole interface mirrors.
|
||||
const rtlLocales = {'he'};
|
||||
|
||||
bool isRightToLeft(String tag) => rtlLocales.contains(_languageOf(tag));
|
||||
|
||||
/// The shipped locale for [tag], or null when nothing matches. A tag matches
|
||||
/// on its language alone, so a device set to de-AT reads the German files.
|
||||
String? matchLocale(String tag) {
|
||||
final language = _languageOf(tag);
|
||||
for (final locale in appLocales) {
|
||||
if (locale.tag == tag) return locale.tag;
|
||||
}
|
||||
for (final locale in appLocales) {
|
||||
if (_languageOf(locale.tag) == language) return locale.tag;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The first of [tags] the client ships, for resolving the device's list of
|
||||
/// preferred languages.
|
||||
String resolveLocale(Iterable<String> tags) {
|
||||
for (final tag in tags) {
|
||||
final match = matchLocale(tag);
|
||||
if (match != null) return match;
|
||||
}
|
||||
return fallbackLocale;
|
||||
}
|
||||
|
||||
String _languageOf(String tag) =>
|
||||
tag.split(RegExp('[-_]')).first.toLowerCase();
|
||||
|
||||
/// Which plural message a count needs, as a message id suffix. Zero is handled
|
||||
/// before this, so every language can say "none" in its own way.
|
||||
///
|
||||
/// Only the languages the client ships are described (CLDR cardinal rules):
|
||||
/// Russian separates two to four from the rest, Hebrew has a form for two,
|
||||
/// French and Portuguese treat zero and one alike, and Japanese has one form.
|
||||
String pluralSuffix(String tag, int count) {
|
||||
final n = count.abs();
|
||||
switch (_languageOf(tag)) {
|
||||
case 'ru':
|
||||
final mod10 = n % 10;
|
||||
final mod100 = n % 100;
|
||||
if (mod10 == 1 && mod100 != 11) return 'one';
|
||||
if (mod10 >= 2 && mod10 <= 4 && !(mod100 >= 12 && mod100 <= 14)) {
|
||||
return 'few';
|
||||
}
|
||||
return 'many';
|
||||
case 'he':
|
||||
if (n == 1) return 'one';
|
||||
if (n == 2) return 'two';
|
||||
return 'many';
|
||||
case 'fr':
|
||||
case 'pt':
|
||||
return n <= 1 ? 'one' : 'many';
|
||||
case 'ja':
|
||||
return 'many';
|
||||
default:
|
||||
return n == 1 ? 'one' : 'many';
|
||||
}
|
||||
}
|
||||
|
|
@ -2,27 +2,66 @@ import 'package:fluent/fluent.dart';
|
|||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'locales.dart';
|
||||
|
||||
/// Loads and formats the app's Fluent messages. All user-facing strings come
|
||||
/// from locales/en-US/main.ftl; lookups fall back to the message ID when a
|
||||
/// message is missing so that missing translations are obvious in the UI.
|
||||
/// from `locales/<tag>/main.ftl`.
|
||||
///
|
||||
/// A message missing from the chosen language falls back to English, and a
|
||||
/// message missing from English too comes back as its own id, so a gap shows
|
||||
/// in the interface rather than crashing or rendering as nothing.
|
||||
class Strings {
|
||||
final FluentBundle _bundle = FluentBundle('en-US');
|
||||
FluentBundle _bundle = FluentBundle(fallbackLocale);
|
||||
FluentBundle _fallback = FluentBundle(fallbackLocale);
|
||||
|
||||
/// Loads the messages from the app's asset bundle.
|
||||
String _tag = fallbackLocale;
|
||||
|
||||
/// The language now in use, as a shipped locale tag.
|
||||
String get tag => _tag;
|
||||
|
||||
bool get isRtl => isRightToLeft(_tag);
|
||||
|
||||
/// Loads English, which every other language falls back to.
|
||||
Future<void> load() async {
|
||||
final source = await rootBundle.loadString('locales/en-US/main.ftl');
|
||||
loadSource(source);
|
||||
_fallback = await _bundleFor(fallbackLocale);
|
||||
_bundle = _fallback;
|
||||
_tag = fallbackLocale;
|
||||
}
|
||||
|
||||
/// Loads messages from an already-read source, so tests can read the file
|
||||
/// from disk instead of the asset bundle.
|
||||
void loadSource(String source) {
|
||||
_bundle.addMessages(source);
|
||||
/// Switches to [tag], keeping English underneath it.
|
||||
Future<void> use(String tag) async {
|
||||
if (tag == _tag) return;
|
||||
_bundle = tag == fallbackLocale ? _fallback : await _bundleFor(tag);
|
||||
_tag = tag;
|
||||
}
|
||||
|
||||
String t(String id, [Map<String, Object?> args = const {}]) {
|
||||
return _bundle.format(id, args: args) ?? id;
|
||||
Future<FluentBundle> _bundleFor(String tag) async {
|
||||
final bundle = FluentBundle(tag);
|
||||
bundle.addMessages(await rootBundle.loadString('locales/$tag/main.ftl'));
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/// Loads messages from an already-read source, so tests can read files from
|
||||
/// disk instead of the asset bundle.
|
||||
void loadSource(String source, {String tag = fallbackLocale}) {
|
||||
final bundle = FluentBundle(tag);
|
||||
bundle.addMessages(source);
|
||||
_bundle = bundle;
|
||||
_tag = tag;
|
||||
if (tag == fallbackLocale) _fallback = bundle;
|
||||
}
|
||||
|
||||
/// The message for [id], or null when neither the chosen language nor
|
||||
/// English has one. Used where a missing message is expected, such as a
|
||||
/// plural form a language does not need.
|
||||
String? lookup(String id, [Map<String, Object?> args = const {}]) =>
|
||||
_bundle.format(id, args: args) ?? _fallback.format(id, args: args);
|
||||
|
||||
String t(String id, [Map<String, Object?> args = const {}]) =>
|
||||
lookup(id, args) ?? id;
|
||||
|
||||
/// Which plural message [count] needs in the language in use.
|
||||
String pluralSuffixFor(int count) => pluralSuffix(_tag, count);
|
||||
}
|
||||
|
||||
/// Provides [Strings] to the widget tree.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue