feat: ship the interface in eight languages, with per-language plurals and mirroring

This commit is contained in:
randogoth 2026-10-11 14:54:43 +03:00
parent 6de6672ae3
commit a4a49f41d7
28 changed files with 1795 additions and 32 deletions

89
app/lib/l10n/locales.dart Normal file
View 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';
}
}