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

View file

@ -73,6 +73,9 @@ The v1 client presents one reader screen:
- Each library page carries a search box that narrows it by title or address, and each row carries its own actions: remove a bookmark, leave a site or check it now, forget a page. History lists one row per address, headed by the most recent visit, rather than repeating a page once per visit. Clearing history takes a second step on a page the reader can simply leave, since it cannot be undone.
- A change is a link too: following `mews:do/<verb>` applies it and rerenders the page in place without adding a history entry. Only a link followed from one of the client's own pages is honored, so a fetched page cannot clear history or change a setting on the reader's behalf (spec 8.1). A form on one of these pages is routed the same way rather than sent to the network.
- Our own pages are built from the same blocks as a page but are interface, not an article, so the renderer lays them out differently: wider before they stop growing, denser, rows divided by a line instead of bulleted, section headings quieter, a note under a row set smaller and dimmer, links carried by weight rather than an underline, and a one-field form on a single line.
- The interface ships in English, German, Spanish, French, Hebrew, Japanese, Portuguese and Russian. It follows the device's language by default and the reader can choose another on the settings page, where each language is named in its own words so it can be found from an interface they cannot read. A message a language has not translated falls back to English rather than disappearing.
- Hebrew mirrors the whole interface. Flutter's own widgets follow the same language, and our own pages take their direction from it; a fetched page keeps declaring its own, so an English interface still renders a Hebrew page right to left.
- Plurals are separate messages rather than a rule inside one, and the choice is made in code from the language's own rules: Russian separates two to four from the rest, Hebrew has a form for two, French and Portuguese count zero as singular, and Japanese has one form. A language needing a form English lacks names it with the same id and that suffix.
- Fonts are bundled, never fetched. The reader picks between two sans faces, Atkinson Hyperlegible and Luciole, and two serifs, Literata and Charis SIL; all four carry regular, italic, bold and bold italic, which the renderer needs for emphasis. The chosen face applies to the whole app, chrome included, so the address bar and the page below it are one face.
- A character the chosen face has no glyph for is looked for in that face's own fallbacks before the platform is asked. Hebrew always comes from a bundled Hebrew face matched to the shape of the chosen one, so it never depends on what the device has installed. The serifs also cover Greek, Cyrillic and a wide Latin range and fall back to each other; the two sans faces are narrow and have no bundled sans to turn to, so for those the platform answers, and it answers a sans with a sans. Scripts nothing bundled covers, such as Arabic and the CJK languages, always come from the platform.
- Adding a font is four TTFs in `app/assets/fonts/` with its licence beside them, one `fonts` entry in `app/pubspec.yaml`, one entry in `app/lib/application/fonts.dart`, and one label in `app/locales/en-US/main.ftl`. A family named in the registry but missing from pubspec would render in the platform's font without saying so, so `app/test/fonts_test.dart` checks that the two agree.

View file

@ -1,6 +1,7 @@
import 'package:signals/signals.dart';
import '../../database/database.dart';
import '../../l10n/locales.dart';
import '../fonts.dart';
/// The reader's presentation choices (spec 8.2) plus the per-app limits they
@ -25,6 +26,10 @@ class ReaderSettingsController {
final themeMode = signal('system');
final variant = signal('site');
/// The interface language: a shipped locale tag, or [systemLanguage] to take
/// whatever the device asks for.
final language = signal(systemLanguage);
Future<void> load() async {
final settings = await _db.readSettings();
if (settings == null) return;
@ -32,6 +37,7 @@ class ReaderSettingsController {
fontScale.value = settings.fontScale;
themeMode.value = settings.themeMode;
variant.value = settings.variant;
language.value = settings.language;
}
Future<bool> setFont(String family) async {
@ -71,10 +77,19 @@ class ReaderSettingsController {
return true;
}
Future<bool> setLanguage(String tag) async {
if (tag != systemLanguage && matchLocale(tag) == null) return false;
if (tag == language.value) return false;
language.value = tag;
await _persist();
return true;
}
Future<void> _persist() => _db.saveSettings(
font: font.value,
fontScale: fontScale.value,
themeMode: themeMode.value,
variant: variant.value,
language: language.value,
);
}

View file

@ -15,15 +15,18 @@ import 'pages/mews_urls.dart';
class InternalActions {
InternalActions({
required MewsDatabase db,
required Future<void> Function() applyLanguage,
required ReaderSettingsController settings,
required BookmarksController bookmarks,
required SubscriptionsController subscriptions,
}) : _db = db,
_applyLanguage = applyLanguage,
_settings = settings,
_bookmarks = bookmarks,
_subscriptions = subscriptions;
final MewsDatabase _db;
final Future<void> Function() _applyLanguage;
final ReaderSettingsController _settings;
final BookmarksController _bookmarks;
final SubscriptionsController _subscriptions;
@ -37,6 +40,7 @@ class InternalActions {
'set-size' => _setSize(args['step']),
'set-theme' => _settings.setThemeMode(args['value'] ?? ''),
'set-colors' => _settings.setVariant(args['value'] ?? ''),
'set-language' => _setLanguage(args['value']),
'mark-read' => _markRead(args, read: true),
'mark-unread' => _markRead(args, read: false),
'refresh-subscriptions' => _refreshAll(),
@ -52,6 +56,15 @@ class InternalActions {
};
}
/// Changing the language has to load its messages before the page is built
/// again, or the rerender would still read the old ones.
Future<bool> _setLanguage(String? tag) async {
if (tag == null || tag.isEmpty) return false;
if (!await _settings.setLanguage(tag)) return false;
await _applyLanguage();
return true;
}
Future<bool> _setSize(String? step) => switch (step) {
'up' => _settings.increaseSize(),
'down' => _settings.decreaseSize(),

View file

@ -20,6 +20,7 @@ Future<MewsPage> buildBookmarksPage(
return internalPage(
url: url,
title: title,
strings: strings,
body: [
...chrome(title: title, current: url, strings: strings),
textForm(

View file

@ -17,6 +17,7 @@ Future<MewsPage> buildHistoryPage(
return internalPage(
url: url,
title: title,
strings: strings,
body: [
...chrome(title: title, current: url, strings: strings),
textForm(

View file

@ -23,6 +23,7 @@ Future<MewsPage> buildHubPage(
return internalPage(
url: url,
title: strings.t('hub-title'),
strings: strings,
body: [
HeadingBlock(1, [MewsText(strings.t('hub-title'))]),
itemList([

View file

@ -196,25 +196,35 @@ ListBlock itemList(List<MewsListItem> items) =>
/// Wraps blocks as an internal document. The marker makes the renderer treat
/// it as a Mews page rather than prepending the "not a Mews page" notice, and
/// no entries are offered so an internal page is never subscribable.
///
/// Unlike a fetched page, which declares its own language, one of ours is
/// written in whichever language the reader set, so it takes its direction
/// from that and mirrors when the interface does.
MewsPage internalPage({
required Uri url,
required String title,
required List<MewsBlock> body,
required Strings strings,
}) {
return MewsPage(
url: url.toString(),
specVersion: 'internal',
title: title,
body: body,
lang: 'en',
direction: TextDirection.ltr,
lang: strings.tag,
direction: strings.isRtl ? TextDirection.rtl : TextDirection.ltr,
variant: MewsVariant.plain,
entries: const [],
);
}
/// Picks the message for [count]. Plurals are separate messages, so the
/// choice happens here and Fluent formats the number.
/// Picks the message for [count]. Plurals are separate messages rather than a
/// rule inside one, so the choice happens here.
///
/// A language needing a form English does not have, such as Russian's "few"
/// or Hebrew's "two", names it with the same id and that suffix. Those are
/// derived from [many] rather than passed at every call, and a language
/// without them falls through to [many].
String countText(
Strings strings,
int count, {
@ -222,11 +232,15 @@ String countText(
required String one,
required String many,
}) {
return switch (count) {
0 => strings.t(none),
1 => strings.t(one),
_ => strings.t(many, {'count': count}),
};
if (count == 0) return strings.t(none);
final suffix = strings.pluralSuffixFor(count);
if (suffix == 'one') return strings.t(one, {'count': count});
if (suffix != 'many') {
final stem = many.substring(0, many.lastIndexOf('-'));
final text = strings.lookup('$stem-$suffix', {'count': count});
if (text != null) return text;
}
return strings.t(many, {'count': count});
}
/// YYYY-MM-DD, the shape a Mews entry link uses (spec 6.1).

View file

@ -23,6 +23,7 @@ Future<MewsPage> buildPostsPage(
return internalPage(
url: url,
title: title,
strings: strings,
body: [
...chrome(title: title, current: url, strings: strings),
ParagraphBlock([

View file

@ -1,4 +1,5 @@
import '../../l10n/strings.dart';
import '../../l10n/locales.dart';
import '../../mews/document.dart';
import '../fonts.dart';
import '../controllers/reader_settings_controller.dart'
@ -33,6 +34,7 @@ Future<MewsPage> buildSettingsPage(
return internalPage(
url: url,
title: title,
strings: strings,
body: [
...chrome(title: title, current: url, strings: strings),
HeadingBlock(2, [MewsText(strings.t('settings-font-heading'))]),
@ -80,6 +82,22 @@ Future<MewsPage> buildSettingsPage(
current: mode == settings.themeMode.value,
),
]),
// The language a reader wants may not be the one they can currently
// read, so each is named in its own words.
HeadingBlock(2, [MewsText(strings.t('settings-language-heading'))]),
choiceRow([
(
href: MewsUrls.action('set-language', {'value': systemLanguage}),
label: strings.t('settings-language-system'),
current: settings.language.value == systemLanguage,
),
for (final locale in appLocales)
(
href: MewsUrls.action('set-language', {'value': locale.tag}),
label: strings.t(locale.labelId),
current: locale.tag == settings.language.value,
),
]),
HeadingBlock(2, [MewsText(strings.t('settings-variant-heading'))]),
choiceRow([
for (final variant in ReaderSettingsController.variants)

View file

@ -21,6 +21,7 @@ Future<MewsPage> buildSubscriptionsPage(
return internalPage(
url: url,
title: title,
strings: strings,
body: [
...chrome(title: title, current: url, strings: strings),
textForm(

View file

@ -42,6 +42,7 @@ Future<MewsPage> _index(
return internalPage(
url: url,
title: title,
strings: strings,
body: [
...chrome(title: title, current: url, strings: strings),
if (counts.isEmpty)
@ -111,6 +112,7 @@ Future<MewsPage> _assign(
return internalPage(
url: url,
title: title,
strings: strings,
body: [
HeadingBlock(1, [MewsText(title)]),
ParagraphBlock([

View file

@ -82,6 +82,7 @@ class ReaderSettings extends Table {
RealColumn get fontScale => real().withDefault(const Constant(1.0))();
TextColumn get themeMode => text().withDefault(const Constant('system'))();
TextColumn get variant => text().withDefault(const Constant('site'))();
TextColumn get language => text().withDefault(const Constant('system'))();
@override
Set<Column> get primaryKey => {id};
@ -116,13 +117,27 @@ class MewsDatabase extends _$MewsDatabase {
MewsDatabase.test(super.executor);
@override
int get schemaVersion => 2;
int get schemaVersion => 3;
/// Pre-release: the entry store replaces the old list of seen entry URLs and
/// the library tables gain real primary keys, so an existing database is
/// recreated rather than migrated.
@override
MigrationStrategy get migration => destructiveFallback;
MigrationStrategy get migration => MigrationStrategy(
onCreate: (m) => m.createAll(),
onUpgrade: (m, from, to) async {
// Version 1 predates the entry store and the library tables' real
// primary keys. Nothing of it is worth carrying, so it is recreated;
// later versions only gain columns and keep what the reader has.
if (from < 2) {
for (final entity in allSchemaEntities.toList().reversed) {
await m.drop(entity);
}
await m.createAll();
return;
}
if (from < 3) {
await m.addColumn(readerSettings, readerSettings.language);
}
},
);
static QueryExecutor _open() => LazyDatabase(() async {
final dir = await getApplicationDocumentsDirectory();
@ -522,6 +537,7 @@ class MewsDatabase extends _$MewsDatabase {
required double fontScale,
required String themeMode,
required String variant,
required String language,
}) =>
into(readerSettings).insert(
ReaderSettingsCompanion.insert(
@ -530,6 +546,7 @@ class MewsDatabase extends _$MewsDatabase {
fontScale: Value(fontScale),
themeMode: Value(themeMode),
variant: Value(variant),
language: Value(language),
),
mode: InsertMode.insertOrReplace);

View file

@ -1711,6 +1711,18 @@ class $ReaderSettingsTable extends ReaderSettings
requiredDuringInsert: false,
defaultValue: const Constant('site'),
);
static const VerificationMeta _languageMeta = const VerificationMeta(
'language',
);
@override
late final GeneratedColumn<String> language = GeneratedColumn<String>(
'language',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant('system'),
);
@override
List<GeneratedColumn> get $columns => [
id,
@ -1718,6 +1730,7 @@ class $ReaderSettingsTable extends ReaderSettings
fontScale,
themeMode,
variant,
language,
];
@override
String get aliasedName => _alias ?? actualTableName;
@ -1758,6 +1771,12 @@ class $ReaderSettingsTable extends ReaderSettings
variant.isAcceptableOrUnknown(data['variant']!, _variantMeta),
);
}
if (data.containsKey('language')) {
context.handle(
_languageMeta,
language.isAcceptableOrUnknown(data['language']!, _languageMeta),
);
}
return context;
}
@ -1787,6 +1806,10 @@ class $ReaderSettingsTable extends ReaderSettings
DriftSqlType.string,
data['${effectivePrefix}variant'],
)!,
language: attachedDatabase.typeMapping.read(
DriftSqlType.string,
data['${effectivePrefix}language'],
)!,
);
}
@ -1802,12 +1825,14 @@ class ReaderSetting extends DataClass implements Insertable<ReaderSetting> {
final double fontScale;
final String themeMode;
final String variant;
final String language;
const ReaderSetting({
required this.id,
required this.font,
required this.fontScale,
required this.themeMode,
required this.variant,
required this.language,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@ -1817,6 +1842,7 @@ class ReaderSetting extends DataClass implements Insertable<ReaderSetting> {
map['font_scale'] = Variable<double>(fontScale);
map['theme_mode'] = Variable<String>(themeMode);
map['variant'] = Variable<String>(variant);
map['language'] = Variable<String>(language);
return map;
}
@ -1827,6 +1853,7 @@ class ReaderSetting extends DataClass implements Insertable<ReaderSetting> {
fontScale: Value(fontScale),
themeMode: Value(themeMode),
variant: Value(variant),
language: Value(language),
);
}
@ -1841,6 +1868,7 @@ class ReaderSetting extends DataClass implements Insertable<ReaderSetting> {
fontScale: serializer.fromJson<double>(json['fontScale']),
themeMode: serializer.fromJson<String>(json['themeMode']),
variant: serializer.fromJson<String>(json['variant']),
language: serializer.fromJson<String>(json['language']),
);
}
@override
@ -1852,6 +1880,7 @@ class ReaderSetting extends DataClass implements Insertable<ReaderSetting> {
'fontScale': serializer.toJson<double>(fontScale),
'themeMode': serializer.toJson<String>(themeMode),
'variant': serializer.toJson<String>(variant),
'language': serializer.toJson<String>(language),
};
}
@ -1861,12 +1890,14 @@ class ReaderSetting extends DataClass implements Insertable<ReaderSetting> {
double? fontScale,
String? themeMode,
String? variant,
String? language,
}) => ReaderSetting(
id: id ?? this.id,
font: font ?? this.font,
fontScale: fontScale ?? this.fontScale,
themeMode: themeMode ?? this.themeMode,
variant: variant ?? this.variant,
language: language ?? this.language,
);
ReaderSetting copyWithCompanion(ReaderSettingsCompanion data) {
return ReaderSetting(
@ -1875,6 +1906,7 @@ class ReaderSetting extends DataClass implements Insertable<ReaderSetting> {
fontScale: data.fontScale.present ? data.fontScale.value : this.fontScale,
themeMode: data.themeMode.present ? data.themeMode.value : this.themeMode,
variant: data.variant.present ? data.variant.value : this.variant,
language: data.language.present ? data.language.value : this.language,
);
}
@ -1885,13 +1917,15 @@ class ReaderSetting extends DataClass implements Insertable<ReaderSetting> {
..write('font: $font, ')
..write('fontScale: $fontScale, ')
..write('themeMode: $themeMode, ')
..write('variant: $variant')
..write('variant: $variant, ')
..write('language: $language')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(id, font, fontScale, themeMode, variant);
int get hashCode =>
Object.hash(id, font, fontScale, themeMode, variant, language);
@override
bool operator ==(Object other) =>
identical(this, other) ||
@ -1900,7 +1934,8 @@ class ReaderSetting extends DataClass implements Insertable<ReaderSetting> {
other.font == this.font &&
other.fontScale == this.fontScale &&
other.themeMode == this.themeMode &&
other.variant == this.variant);
other.variant == this.variant &&
other.language == this.language);
}
class ReaderSettingsCompanion extends UpdateCompanion<ReaderSetting> {
@ -1909,12 +1944,14 @@ class ReaderSettingsCompanion extends UpdateCompanion<ReaderSetting> {
final Value<double> fontScale;
final Value<String> themeMode;
final Value<String> variant;
final Value<String> language;
const ReaderSettingsCompanion({
this.id = const Value.absent(),
this.font = const Value.absent(),
this.fontScale = const Value.absent(),
this.themeMode = const Value.absent(),
this.variant = const Value.absent(),
this.language = const Value.absent(),
});
ReaderSettingsCompanion.insert({
this.id = const Value.absent(),
@ -1922,6 +1959,7 @@ class ReaderSettingsCompanion extends UpdateCompanion<ReaderSetting> {
this.fontScale = const Value.absent(),
this.themeMode = const Value.absent(),
this.variant = const Value.absent(),
this.language = const Value.absent(),
});
static Insertable<ReaderSetting> custom({
Expression<int>? id,
@ -1929,6 +1967,7 @@ class ReaderSettingsCompanion extends UpdateCompanion<ReaderSetting> {
Expression<double>? fontScale,
Expression<String>? themeMode,
Expression<String>? variant,
Expression<String>? language,
}) {
return RawValuesInsertable({
if (id != null) 'id': id,
@ -1936,6 +1975,7 @@ class ReaderSettingsCompanion extends UpdateCompanion<ReaderSetting> {
if (fontScale != null) 'font_scale': fontScale,
if (themeMode != null) 'theme_mode': themeMode,
if (variant != null) 'variant': variant,
if (language != null) 'language': language,
});
}
@ -1945,6 +1985,7 @@ class ReaderSettingsCompanion extends UpdateCompanion<ReaderSetting> {
Value<double>? fontScale,
Value<String>? themeMode,
Value<String>? variant,
Value<String>? language,
}) {
return ReaderSettingsCompanion(
id: id ?? this.id,
@ -1952,6 +1993,7 @@ class ReaderSettingsCompanion extends UpdateCompanion<ReaderSetting> {
fontScale: fontScale ?? this.fontScale,
themeMode: themeMode ?? this.themeMode,
variant: variant ?? this.variant,
language: language ?? this.language,
);
}
@ -1973,6 +2015,9 @@ class ReaderSettingsCompanion extends UpdateCompanion<ReaderSetting> {
if (variant.present) {
map['variant'] = Variable<String>(variant.value);
}
if (language.present) {
map['language'] = Variable<String>(language.value);
}
return map;
}
@ -1983,7 +2028,8 @@ class ReaderSettingsCompanion extends UpdateCompanion<ReaderSetting> {
..write('font: $font, ')
..write('fontScale: $fontScale, ')
..write('themeMode: $themeMode, ')
..write('variant: $variant')
..write('variant: $variant, ')
..write('language: $language')
..write(')'))
.toString();
}
@ -3362,6 +3408,7 @@ typedef $$ReaderSettingsTableCreateCompanionBuilder =
Value<double> fontScale,
Value<String> themeMode,
Value<String> variant,
Value<String> language,
});
typedef $$ReaderSettingsTableUpdateCompanionBuilder =
ReaderSettingsCompanion Function({
@ -3370,6 +3417,7 @@ typedef $$ReaderSettingsTableUpdateCompanionBuilder =
Value<double> fontScale,
Value<String> themeMode,
Value<String> variant,
Value<String> language,
});
class $$ReaderSettingsTableFilterComposer
@ -3405,6 +3453,11 @@ class $$ReaderSettingsTableFilterComposer
column: $table.variant,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get language => $composableBuilder(
column: $table.language,
builder: (column) => ColumnFilters(column),
);
}
class $$ReaderSettingsTableOrderingComposer
@ -3440,6 +3493,11 @@ class $$ReaderSettingsTableOrderingComposer
column: $table.variant,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get language => $composableBuilder(
column: $table.language,
builder: (column) => ColumnOrderings(column),
);
}
class $$ReaderSettingsTableAnnotationComposer
@ -3465,6 +3523,9 @@ class $$ReaderSettingsTableAnnotationComposer
GeneratedColumn<String> get variant =>
$composableBuilder(column: $table.variant, builder: (column) => column);
GeneratedColumn<String> get language =>
$composableBuilder(column: $table.language, builder: (column) => column);
}
class $$ReaderSettingsTableTableManager
@ -3505,12 +3566,14 @@ class $$ReaderSettingsTableTableManager
Value<double> fontScale = const Value.absent(),
Value<String> themeMode = const Value.absent(),
Value<String> variant = const Value.absent(),
Value<String> language = const Value.absent(),
}) => ReaderSettingsCompanion(
id: id,
font: font,
fontScale: fontScale,
themeMode: themeMode,
variant: variant,
language: language,
),
createCompanionCallback:
({
@ -3519,12 +3582,14 @@ class $$ReaderSettingsTableTableManager
Value<double> fontScale = const Value.absent(),
Value<String> themeMode = const Value.absent(),
Value<String> variant = const Value.absent(),
Value<String> language = const Value.absent(),
}) => ReaderSettingsCompanion.insert(
id: id,
font: font,
fontScale: fontScale,
themeMode: themeMode,
variant: variant,
language: language,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), BaseReferences(db, table, e)))

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';
}
}

View file

@ -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.

View file

@ -1,7 +1,10 @@
import 'dart:async' show unawaited;
import 'dart:ui' show PlatformDispatcher;
import 'package:actors/actors.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:signals/signals_flutter.dart' show SignalBuilder;
import '../application/actors/fetch_actor.dart';
@ -13,6 +16,7 @@ import '../application/fonts.dart';
import '../application/internal_actions.dart';
import '../application/internal_pages.dart';
import '../database/database.dart';
import '../l10n/locales.dart';
import '../l10n/strings.dart';
import '../mews/document.dart';
import 'reader/palettes.dart';
@ -38,6 +42,7 @@ class AppDependencies {
strings: this.strings);
internalActions = InternalActions(
db: db,
applyLanguage: applyLanguage,
settings: settings,
bookmarks: bookmarks,
subscriptions: subscriptions,
@ -65,8 +70,20 @@ class AppDependencies {
late final InternalPages internalPages;
late final InternalActions internalActions;
/// The language to read the interface in: the reader's choice, or the best
/// match for what the device asks for.
String get activeLocale => settings.language.value == systemLanguage
? resolveLocale(
PlatformDispatcher.instance.locales.map((l) => l.toLanguageTag()))
: settings.language.value;
/// Loads the chosen language's messages. Called at startup and whenever the
/// reader changes the setting.
Future<void> applyLanguage() => strings.use(activeLocale);
Future<void> start() async {
await settings.load();
await applyLanguage();
await bookmarks.load();
await subscriptions.load();
await browser.restoreLastSession();
@ -129,13 +146,30 @@ class _ThemedApp extends StatelessWidget {
};
final settings = deps.settings;
final font = settings.font.value;
// Read so the whole app rebuilds when the reader changes language.
settings.language.value;
final page = deps.browser.currentPage.value;
final variant = settings.variant.value == 'site'
? (page?.variant ?? MewsVariant.plain)
: pageVariantFromName(settings.variant.value);
// Flutter's own widgets and the text direction follow the same
// language, so a right-to-left interface mirrors without us asking.
final locale = deps.activeLocale;
return MaterialApp(
title: 'Mews',
locale: Locale(locale.split('-').first,
locale.contains('-') ? locale.split('-').last : null),
supportedLocales: [
for (final l in appLocales)
Locale(l.tag.split('-').first,
l.tag.contains('-') ? l.tag.split('-').last : null),
],
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
themeMode: mode,
theme: _themeFor(
paletteFor(pageVariant: variant, readerVariant: 'site', dark: false),

172
app/locales/de/main.ftl Normal file
View file

@ -0,0 +1,172 @@
## Reader chrome
reader-address-placeholder = Webadresse eingeben
# Accessibility label for the address field.
reader-address-label = Webadresse
reader-reload = Diese Seite neu laden
reader-open-hub = Deine Mews-Seiten öffnen
reader-back = Zurück
reader-forward = Vorwärts
reader-bookmark-add = Lesezeichen hinzufügen
reader-bookmark-remove = Lesezeichen entfernen
reader-subscribe = Diese Website abonnieren
reader-unsubscribe = Abo beenden
## Navigation
# The strip of the app's own pages, carried by each of them.
nav-hub = Mews
nav-posts = Beiträge
nav-bookmarks = Lesezeichen
nav-subscriptions = Abos
nav-history = Verlauf
nav-tags = Schlagwörter
nav-settings = Einstellungen
## Hub
hub-title = Mews
## Counts
# Shown under an item. Format the number in code.
count-bookmarks-none = Noch keine Lesezeichen
count-bookmarks-one = 1 Lesezeichen
count-bookmarks-many = { $count } Lesezeichen
count-sites-none = Noch keine Websites
count-sites-one = 1 Website
count-sites-many = { $count } Websites
count-pages-none = Noch keine Seiten
count-pages-one = 1 Seite
count-pages-many = { $count } Seiten
count-tags-none = Noch keine Schlagwörter
count-tags-one = 1 Schlagwort
count-tags-many = { $count } Schlagwörter
count-unread-none = Keine ungelesenen Beiträge
count-unread-one = 1 ungelesener Beitrag
count-unread-many = { $count } ungelesene Beiträge
## Posts
posts-title = Beiträge
posts-empty = Hier erscheinen Beiträge der Websites, die du abonnierst.
posts-none-unread = Du hast alles gelesen.
posts-checking = Es wird nach neuen Beiträgen gesucht.
posts-sort-label = Sortieren nach
posts-sort-date = Datum
posts-sort-source = Website
posts-sort-unread = Ungelesene zuerst
posts-show-label = Anzeigen
posts-show-all = Alle Beiträge
posts-show-unread = Nur ungelesene
posts-mark-read = Als gelesen markieren
posts-mark-unread = Als ungelesen markieren
## Actions
# Link labels used on more than one page.
action-refresh = Nach neuen Beiträgen suchen
action-search = Suchen
action-clear-search = Suche zurücksetzen
action-remove-bookmark = Lesezeichen entfernen
action-unsubscribe = Abo beenden
action-edit-tags = Schlagwörter bearbeiten
## Library pages
# Internal pages rendered like ordinary Mews pages.
history-title = Verlauf
history-empty = Hier erscheinen die Seiten, die du besuchst.
history-no-matches = Keine Seiten passen zu deiner Suche.
history-search-label = Verlauf durchsuchen
history-forget = Aus dem Verlauf entfernen
history-clear = Verlauf löschen
history-clear-confirm = Beim Löschen des Verlaufs werden alle besuchten Seiten entfernt. Das lässt sich nicht rückgängig machen.
history-clear-yes = Verlauf jetzt löschen
history-clear-no = Verlauf behalten
bookmarks-title = Lesezeichen
bookmarks-empty = Hier erscheinen die Seiten, die du als Lesezeichen speicherst.
bookmarks-no-matches = Keine Lesezeichen passen zu deiner Suche.
bookmarks-search-label = Lesezeichen durchsuchen
subscriptions-title = Abos
subscriptions-empty = Hier erscheinen die Websites, die du abonnierst.
subscriptions-no-matches = Keine Websites passen zu deiner Suche.
subscriptions-search-label = Websites durchsuchen
# When a site was last checked for posts. Format the date in code.
subscriptions-checked = Zuletzt geprüft { $date }
subscriptions-never-checked = Noch nicht geprüft
## Tags
tags-title = Schlagwörter
tags-empty = Hier erscheinen die Schlagwörter, die du Lesezeichen und Websites gibst.
# The item being tagged. Shows its title, or its address until it has one.
tags-assign-title = Schlagwörter für { $name }
tags-add-label = Schlagwort hinzufügen
tags-add-submit = Hinzufügen
tags-on-item = Vergeben
tags-other = Weitere Schlagwörter
tags-remove = Dieses Schlagwort entfernen
tags-delete = Dieses Schlagwort überall löschen
tags-done = Fertig
bookmarks-show-all = Alle Lesezeichen anzeigen
# Shown when a tag narrows the list.
bookmarks-tagged = Lesezeichen mit dem Schlagwort { $tag }
subscriptions-show-all = Alle Websites anzeigen
subscriptions-tagged = Websites mit dem Schlagwort { $tag }
## Settings page
settings-page-title = Einstellungen
settings-font-heading = Schriftart
settings-font-atkinson = Atkinson Hyperlegible
settings-font-luciole = Luciole
settings-font-literata = Literata
settings-font-charis = Charis SIL
settings-size-heading = Schriftgröße
settings-size-decrease = Kleinere Schrift
settings-size-increase = Größere Schrift
# The text size now in use. Format the number in code.
settings-size-value = { $percent }%
settings-theme-heading = Darstellung
settings-theme-light = Hell
settings-theme-dark = Dunkel
settings-theme-system = Systemeinstellung folgen
settings-variant-heading = Farben
settings-variant-site = Website
settings-variant-warm = Warm
settings-variant-cool = Kühl
settings-variant-green = Grün
settings-variant-mono = Monochrom
settings-language-heading = Sprache
settings-language-system = Systemeinstellung folgen
## Languages
# Every language is named in its own words, so a reader who cannot read the
# current interface can still find theirs. Leave these untranslated.
language-en = English
language-de = Deutsch
language-es = Español
language-fr = Français
language-he = עברית
language-ja = 日本語
language-pt = Português
language-ru = Русский
## Forms
# Default label for a page form's submit control when the author gave none.
form-submit-default = Absenden
form-reset-default = Zurücksetzen
## Errors
error-load-failed = Die Seite konnte nicht geladen werden. Prüfe die Adresse und deine Verbindung und versuche es erneut.
error-try-again = Erneut versuchen
error-broken-page = Diese Seite hat einen Auszeichnungsfehler und kann deshalb nicht angezeigt werden.
error-broken-page-show-text = Seitentext anzeigen
error-broken-page-text-title = Seitentext
error-non-mews = Dies ist keine Mews-Seite und wird deshalb schlicht angezeigt.
error-image-blocked = Dieses Bild liegt auf einer anderen Website und wird nicht angezeigt.
error-image-unavailable = Dieses Bild konnte nicht geladen werden.

View file

@ -138,6 +138,21 @@ settings-variant-warm = Warm
settings-variant-cool = Cool
settings-variant-green = Green
settings-variant-mono = Mono
settings-language-heading = Language
settings-language-system = Follow system
## Languages
# Every language is named in its own words, so a reader who cannot read the
# current interface can still find theirs. Leave these untranslated.
language-en = English
language-de = Deutsch
language-es = Español
language-fr = Français
language-he = עברית
language-ja = 日本語
language-pt = Português
language-ru = Русский
## Forms

172
app/locales/es/main.ftl Normal file
View file

@ -0,0 +1,172 @@
## Reader chrome
reader-address-placeholder = Escribe una dirección web
# Accessibility label for the address field.
reader-address-label = Dirección web
reader-reload = Recargar esta página
reader-open-hub = Abrir tus páginas de Mews
reader-back = Atrás
reader-forward = Adelante
reader-bookmark-add = Añadir marcador
reader-bookmark-remove = Quitar marcador
reader-subscribe = Suscribirte a este sitio
reader-unsubscribe = Cancelar la suscripción
## Navigation
# The strip of the app's own pages, carried by each of them.
nav-hub = Mews
nav-posts = Entradas
nav-bookmarks = Marcadores
nav-subscriptions = Suscripciones
nav-history = Historial
nav-tags = Etiquetas
nav-settings = Ajustes
## Hub
hub-title = Mews
## Counts
# Shown under an item. Format the number in code.
count-bookmarks-none = Aún no hay marcadores
count-bookmarks-one = 1 marcador
count-bookmarks-many = { $count } marcadores
count-sites-none = Aún no hay sitios
count-sites-one = 1 sitio
count-sites-many = { $count } sitios
count-pages-none = Aún no hay páginas
count-pages-one = 1 página
count-pages-many = { $count } páginas
count-tags-none = Aún no hay etiquetas
count-tags-one = 1 etiqueta
count-tags-many = { $count } etiquetas
count-unread-none = No hay entradas sin leer
count-unread-one = 1 entrada sin leer
count-unread-many = { $count } entradas sin leer
## Posts
posts-title = Entradas
posts-empty = Aquí aparecen las entradas de los sitios a los que te suscribes.
posts-none-unread = Lo has leído todo.
posts-checking = Buscando entradas nuevas.
posts-sort-label = Ordenar por
posts-sort-date = Fecha
posts-sort-source = Sitio
posts-sort-unread = Sin leer primero
posts-show-label = Mostrar
posts-show-all = Todas las entradas
posts-show-unread = Solo sin leer
posts-mark-read = Marcar como leída
posts-mark-unread = Marcar como no leída
## Actions
# Link labels used on more than one page.
action-refresh = Buscar entradas nuevas
action-search = Buscar
action-clear-search = Borrar la búsqueda
action-remove-bookmark = Quitar marcador
action-unsubscribe = Cancelar la suscripción
action-edit-tags = Editar etiquetas
## Library pages
# Internal pages rendered like ordinary Mews pages.
history-title = Historial
history-empty = Aquí aparecen las páginas que visitas.
history-no-matches = Ninguna página coincide con tu búsqueda.
history-search-label = Buscar en el historial
history-forget = Quitar del historial
history-clear = Borrar el historial
history-clear-confirm = Al borrar el historial se eliminan todas las páginas que has visitado. Esto no se puede deshacer.
history-clear-yes = Borrar el historial ahora
history-clear-no = Conservar mi historial
bookmarks-title = Marcadores
bookmarks-empty = Aquí aparecen las páginas que guardas como marcador.
bookmarks-no-matches = Ningún marcador coincide con tu búsqueda.
bookmarks-search-label = Buscar en los marcadores
subscriptions-title = Suscripciones
subscriptions-empty = Aquí aparecen los sitios a los que te suscribes.
subscriptions-no-matches = Ningún sitio coincide con tu búsqueda.
subscriptions-search-label = Buscar sitios
# When a site was last checked for posts. Format the date in code.
subscriptions-checked = Última comprobación { $date }
subscriptions-never-checked = Sin comprobar todavía
## Tags
tags-title = Etiquetas
tags-empty = Aquí aparecen las etiquetas que añades a marcadores y sitios.
# The item being tagged. Shows its title, or its address until it has one.
tags-assign-title = Etiquetas de { $name }
tags-add-label = Añadir una etiqueta
tags-add-submit = Añadir
tags-on-item = En este elemento
tags-other = Otras etiquetas
tags-remove = Quitar esta etiqueta
tags-delete = Eliminar esta etiqueta en todas partes
tags-done = Listo
bookmarks-show-all = Mostrar todos los marcadores
# Shown when a tag narrows the list.
bookmarks-tagged = Marcadores con la etiqueta { $tag }
subscriptions-show-all = Mostrar todos los sitios
subscriptions-tagged = Sitios con la etiqueta { $tag }
## Settings page
settings-page-title = Ajustes
settings-font-heading = Tipografía
settings-font-atkinson = Atkinson Hyperlegible
settings-font-luciole = Luciole
settings-font-literata = Literata
settings-font-charis = Charis SIL
settings-size-heading = Tamaño del texto
settings-size-decrease = Texto más pequeño
settings-size-increase = Texto más grande
# The text size now in use. Format the number in code.
settings-size-value = { $percent }%
settings-theme-heading = Tema
settings-theme-light = Claro
settings-theme-dark = Oscuro
settings-theme-system = Seguir al sistema
settings-variant-heading = Colores
settings-variant-site = Sitio
settings-variant-warm = Cálido
settings-variant-cool = Frío
settings-variant-green = Verde
settings-variant-mono = Monocromo
settings-language-heading = Idioma
settings-language-system = Seguir al sistema
## Languages
# Every language is named in its own words, so a reader who cannot read the
# current interface can still find theirs. Leave these untranslated.
language-en = English
language-de = Deutsch
language-es = Español
language-fr = Français
language-he = עברית
language-ja = 日本語
language-pt = Português
language-ru = Русский
## Forms
# Default label for a page form's submit control when the author gave none.
form-submit-default = Enviar
form-reset-default = Restablecer
## Errors
error-load-failed = No se ha podido cargar la página. Comprueba la dirección y tu conexión, y vuelve a intentarlo.
error-try-again = Volver a intentarlo
error-broken-page = Esta página tiene un error de marcado, así que no se puede mostrar.
error-broken-page-show-text = Mostrar el texto de la página
error-broken-page-text-title = Texto de la página
error-non-mews = Esta página no es una página de Mews, así que se muestra de forma sencilla.
error-image-blocked = Esta imagen está en otro sitio y no se muestra.
error-image-unavailable = No se ha podido cargar esta imagen.

172
app/locales/fr/main.ftl Normal file
View file

@ -0,0 +1,172 @@
## Reader chrome
reader-address-placeholder = Saisis une adresse web
# Accessibility label for the address field.
reader-address-label = Adresse web
reader-reload = Recharger cette page
reader-open-hub = Ouvrir tes pages Mews
reader-back = Revenir en arrière
reader-forward = Aller en avant
reader-bookmark-add = Ajouter un signet
reader-bookmark-remove = Retirer le signet
reader-subscribe = S'abonner à ce site
reader-unsubscribe = Se désabonner
## Navigation
# The strip of the app's own pages, carried by each of them.
nav-hub = Mews
nav-posts = Billets
nav-bookmarks = Signets
nav-subscriptions = Abonnements
nav-history = Historique
nav-tags = Étiquettes
nav-settings = Réglages
## Hub
hub-title = Mews
## Counts
# Shown under an item. Format the number in code.
count-bookmarks-none = Aucun signet pour l'instant
count-bookmarks-one = { $count } signet
count-bookmarks-many = { $count } signets
count-sites-none = Aucun site pour l'instant
count-sites-one = { $count } site
count-sites-many = { $count } sites
count-pages-none = Aucune page pour l'instant
count-pages-one = { $count } page
count-pages-many = { $count } pages
count-tags-none = Aucune étiquette pour l'instant
count-tags-one = { $count } étiquette
count-tags-many = { $count } étiquettes
count-unread-none = Aucun billet non lu
count-unread-one = { $count } billet non lu
count-unread-many = { $count } billets non lus
## Posts
posts-title = Billets
posts-empty = Les billets des sites auxquels tu es abonné apparaissent ici.
posts-none-unread = Tu as tout lu.
posts-checking = Recherche de nouveaux billets en cours.
posts-sort-label = Trier par
posts-sort-date = Date
posts-sort-source = Site
posts-sort-unread = Non lus d'abord
posts-show-label = Afficher
posts-show-all = Tous les billets
posts-show-unread = Non lus seulement
posts-mark-read = Marquer comme lu
posts-mark-unread = Marquer comme non lu
## Actions
# Link labels used on more than one page.
action-refresh = Chercher de nouveaux billets
action-search = Rechercher
action-clear-search = Effacer la recherche
action-remove-bookmark = Retirer le signet
action-unsubscribe = Se désabonner
action-edit-tags = Modifier les étiquettes
## Library pages
# Internal pages rendered like ordinary Mews pages.
history-title = Historique
history-empty = Les pages que tu visites apparaissent ici.
history-no-matches = Aucune page ne correspond à ta recherche.
history-search-label = Rechercher dans l'historique
history-forget = Retirer de l'historique
history-clear = Effacer l'historique
history-clear-confirm = Effacer l'historique supprime toutes les pages visitées. Cette action est définitive.
history-clear-yes = Effacer l'historique maintenant
history-clear-no = Garder mon historique
bookmarks-title = Signets
bookmarks-empty = Les pages que tu mets en signet apparaissent ici.
bookmarks-no-matches = Aucun signet ne correspond à ta recherche.
bookmarks-search-label = Rechercher dans les signets
subscriptions-title = Abonnements
subscriptions-empty = Les sites auxquels tu t'abonnes apparaissent ici.
subscriptions-no-matches = Aucun site ne correspond à ta recherche.
subscriptions-search-label = Rechercher un site
# When a site was last checked for posts. Format the date in code.
subscriptions-checked = Dernière vérification { $date }
subscriptions-never-checked = Pas encore vérifié
## Tags
tags-title = Étiquettes
tags-empty = Les étiquettes que tu ajoutes aux signets et aux sites apparaissent ici.
# The item being tagged. Shows its title, or its address until it has one.
tags-assign-title = Étiquettes de { $name }
tags-add-label = Ajouter une étiquette
tags-add-submit = Ajouter
tags-on-item = Sur cet élément
tags-other = Autres étiquettes
tags-remove = Retirer cette étiquette
tags-delete = Supprimer cette étiquette partout
tags-done = Terminé
bookmarks-show-all = Afficher tous les signets
# Shown when a tag narrows the list.
bookmarks-tagged = Signets portant l'étiquette { $tag }
subscriptions-show-all = Afficher tous les sites
subscriptions-tagged = Sites portant l'étiquette { $tag }
## Settings page
settings-page-title = Réglages
settings-font-heading = Police
settings-font-atkinson = Atkinson Hyperlegible
settings-font-luciole = Luciole
settings-font-literata = Literata
settings-font-charis = Charis SIL
settings-size-heading = Taille du texte
settings-size-decrease = Texte plus petit
settings-size-increase = Texte plus grand
# The text size now in use. Format the number in code.
settings-size-value = { $percent }%
settings-theme-heading = Thème
settings-theme-light = Clair
settings-theme-dark = Sombre
settings-theme-system = Suivre le système
settings-variant-heading = Couleurs
settings-variant-site = Site
settings-variant-warm = Chaud
settings-variant-cool = Froid
settings-variant-green = Vert
settings-variant-mono = Monochrome
settings-language-heading = Langue
settings-language-system = Suivre le système
## Languages
# Every language is named in its own words, so a reader who cannot read the
# current interface can still find theirs. Leave these untranslated.
language-en = English
language-de = Deutsch
language-es = Español
language-fr = Français
language-he = עברית
language-ja = 日本語
language-pt = Português
language-ru = Русский
## Forms
# Default label for a page form's submit control when the author gave none.
form-submit-default = Envoyer
form-reset-default = Réinitialiser
## Errors
error-load-failed = La page n'a pas pu être chargée. Vérifie l'adresse et ta connexion, puis réessaie.
error-try-again = Réessayer
error-broken-page = Cette page comporte une erreur de balisage et ne peut donc pas être affichée.
error-broken-page-show-text = Afficher le texte de la page
error-broken-page-text-title = Texte de la page
error-non-mews = Cette page n'est pas une page Mews, elle est donc affichée simplement.
error-image-blocked = Cette image se trouve sur un autre site et n'est pas affichée.
error-image-unavailable = Cette image n'a pas pu être chargée.

177
app/locales/he/main.ftl Normal file
View file

@ -0,0 +1,177 @@
## Reader chrome
reader-address-placeholder = הזינו כתובת אינטרנט
# Accessibility label for the address field.
reader-address-label = כתובת אינטרנט
reader-reload = טעינה מחדש של הדף
reader-open-hub = פתיחת דפי Mews שלכם
reader-back = חזרה
reader-forward = קדימה
reader-bookmark-add = הוספת סימנייה
reader-bookmark-remove = הסרת הסימנייה
reader-subscribe = הרשמה לאתר הזה
reader-unsubscribe = ביטול ההרשמה
## Navigation
# The strip of the app's own pages, carried by each of them.
nav-hub = Mews
nav-posts = רשומות
nav-bookmarks = סימניות
nav-subscriptions = הרשמות
nav-history = היסטוריה
nav-tags = תגיות
nav-settings = הגדרות
## Hub
hub-title = Mews
## Counts
# Shown under an item. Format the number in code.
count-bookmarks-none = אין עדיין סימניות
count-bookmarks-one = סימנייה אחת
count-bookmarks-many = { $count } סימניות
count-bookmarks-two = שתי סימניות
count-sites-none = אין עדיין אתרים
count-sites-one = אתר אחד
count-sites-many = { $count } אתרים
count-sites-two = שני אתרים
count-pages-none = אין עדיין דפים
count-pages-one = דף אחד
count-pages-many = { $count } דפים
count-pages-two = שני דפים
count-tags-none = אין עדיין תגיות
count-tags-one = תגית אחת
count-tags-many = { $count } תגיות
count-tags-two = שתי תגיות
count-unread-none = אין רשומות שלא נקראו
count-unread-one = רשומה אחת שלא נקראה
count-unread-many = { $count } רשומות שלא נקראו
count-unread-two = שתי רשומות שלא נקראו
## Posts
posts-title = רשומות
posts-empty = רשומות מהאתרים שנרשמתם אליהם יופיעו כאן.
posts-none-unread = קראתם הכול.
posts-checking = מתבצעת בדיקה אחר רשומות חדשות.
posts-sort-label = מיון לפי
posts-sort-date = תאריך
posts-sort-source = אתר
posts-sort-unread = שלא נקראו תחילה
posts-show-label = הצגה
posts-show-all = כל הרשומות
posts-show-unread = שלא נקראו בלבד
posts-mark-read = סימון כנקראה
posts-mark-unread = סימון כלא נקראה
## Actions
# Link labels used on more than one page.
action-refresh = בדיקה אחר רשומות חדשות
action-search = חיפוש
action-clear-search = ניקוי החיפוש
action-remove-bookmark = הסרת הסימנייה
action-unsubscribe = ביטול ההרשמה
action-edit-tags = עריכת התגיות
## Library pages
# Internal pages rendered like ordinary Mews pages.
history-title = היסטוריה
history-empty = הדפים שתבקרו בהם יופיעו כאן.
history-no-matches = אין דפים התואמים לחיפוש.
history-search-label = חיפוש בהיסטוריה
history-forget = הסרה מההיסטוריה
history-clear = ניקוי ההיסטוריה
history-clear-confirm = ניקוי ההיסטוריה מסיר כל דף שביקרתם בו. אי אפשר לבטל את הפעולה.
history-clear-yes = לנקות את ההיסטוריה
history-clear-no = להשאיר את ההיסטוריה
bookmarks-title = סימניות
bookmarks-empty = הדפים שתסמנו יופיעו כאן.
bookmarks-no-matches = אין סימניות התואמות לחיפוש.
bookmarks-search-label = חיפוש בסימניות
subscriptions-title = הרשמות
subscriptions-empty = האתרים שתירשמו אליהם יופיעו כאן.
subscriptions-no-matches = אין אתרים התואמים לחיפוש.
subscriptions-search-label = חיפוש אתרים
# When a site was last checked for posts. Format the date in code.
subscriptions-checked = נבדק לאחרונה { $date }
subscriptions-never-checked = טרם נבדק
## Tags
tags-title = תגיות
tags-empty = התגיות שתוסיפו לסימניות ולאתרים יופיעו כאן.
# The item being tagged. Shows its title, or its address until it has one.
tags-assign-title = תגיות עבור { $name }
tags-add-label = הוספת תגית
tags-add-submit = הוספה
tags-on-item = על הפריט הזה
tags-other = תגיות נוספות
tags-remove = הסרת התגית הזו
tags-delete = מחיקת התגית הזו מכל מקום
tags-done = סיום
bookmarks-show-all = הצגת כל הסימניות
# Shown when a tag narrows the list.
bookmarks-tagged = סימניות עם התגית { $tag }
subscriptions-show-all = הצגת כל האתרים
subscriptions-tagged = אתרים עם התגית { $tag }
## Settings page
settings-page-title = הגדרות
settings-font-heading = גופן
settings-font-atkinson = Atkinson Hyperlegible
settings-font-luciole = Luciole
settings-font-literata = Literata
settings-font-charis = Charis SIL
settings-size-heading = גודל הטקסט
settings-size-decrease = טקסט קטן יותר
settings-size-increase = טקסט גדול יותר
# The text size now in use. Format the number in code.
settings-size-value = { $percent }%
settings-theme-heading = ערכת נושא
settings-theme-light = בהירה
settings-theme-dark = כהה
settings-theme-system = לפי המערכת
settings-variant-heading = צבעים
settings-variant-site = של האתר
settings-variant-warm = חמים
settings-variant-cool = קרים
settings-variant-green = ירוקים
settings-variant-mono = מונוכרום
settings-language-heading = שפה
settings-language-system = לפי המערכת
## Languages
# Every language is named in its own words, so a reader who cannot read the
# current interface can still find theirs. Leave these untranslated.
language-en = English
language-de = Deutsch
language-es = Español
language-fr = Français
language-he = עברית
language-ja = 日本語
language-pt = Português
language-ru = Русский
## Forms
# Default label for a page form's submit control when the author gave none.
form-submit-default = שליחה
form-reset-default = איפוס
## Errors
error-load-failed = לא ניתן לטעון את הדף. בדקו את הכתובת ואת החיבור ונסו שוב.
error-try-again = ניסיון חוזר
error-broken-page = בדף הזה יש שגיאת סימון, ולכן אי אפשר להציג אותו.
error-broken-page-show-text = הצגת הטקסט של הדף
error-broken-page-text-title = הטקסט של הדף
error-non-mews = הדף הזה אינו דף Mews, ולכן הוא מוצג בפשטות.
error-image-blocked = התמונה הזו נמצאת באתר אחר ואינה מוצגת.
error-image-unavailable = לא ניתן לטעון את התמונה הזו.

172
app/locales/ja/main.ftl Normal file
View file

@ -0,0 +1,172 @@
## Reader chrome
reader-address-placeholder = ウェブアドレスを入力
# Accessibility label for the address field.
reader-address-label = ウェブアドレス
reader-reload = このページを再読み込み
reader-open-hub = Mews のページを開く
reader-back = 戻る
reader-forward = 進む
reader-bookmark-add = ブックマークに追加
reader-bookmark-remove = ブックマークを削除
reader-subscribe = このサイトを購読
reader-unsubscribe = 購読をやめる
## Navigation
# The strip of the app's own pages, carried by each of them.
nav-hub = Mews
nav-posts = 投稿
nav-bookmarks = ブックマーク
nav-subscriptions = 購読
nav-history = 履歴
nav-tags = タグ
nav-settings = 設定
## Hub
hub-title = Mews
## Counts
# Shown under an item. Format the number in code.
count-bookmarks-none = ブックマークはまだありません
count-bookmarks-one = ブックマーク { $count } 件
count-bookmarks-many = ブックマーク { $count } 件
count-sites-none = サイトはまだありません
count-sites-one = サイト { $count } 件
count-sites-many = サイト { $count } 件
count-pages-none = ページはまだありません
count-pages-one = ページ { $count } 件
count-pages-many = ページ { $count } 件
count-tags-none = タグはまだありません
count-tags-one = タグ { $count } 件
count-tags-many = タグ { $count } 件
count-unread-none = 未読の投稿はありません
count-unread-one = 未読の投稿 { $count } 件
count-unread-many = 未読の投稿 { $count } 件
## Posts
posts-title = 投稿
posts-empty = 購読しているサイトの投稿がここに表示されます。
posts-none-unread = すべて読み終えました。
posts-checking = 新しい投稿を確認しています。
posts-sort-label = 並べ替え
posts-sort-date = 日付
posts-sort-source = サイト
posts-sort-unread = 未読を先に
posts-show-label = 表示
posts-show-all = すべての投稿
posts-show-unread = 未読のみ
posts-mark-read = 既読にする
posts-mark-unread = 未読にする
## Actions
# Link labels used on more than one page.
action-refresh = 新しい投稿を確認
action-search = 検索
action-clear-search = 検索を消去
action-remove-bookmark = ブックマークを削除
action-unsubscribe = 購読をやめる
action-edit-tags = タグを編集
## Library pages
# Internal pages rendered like ordinary Mews pages.
history-title = 履歴
history-empty = 訪れたページがここに表示されます。
history-no-matches = 検索に一致するページはありません。
history-search-label = 履歴を検索
history-forget = 履歴から削除
history-clear = 履歴を消去
history-clear-confirm = 履歴を消去すると、訪れたページがすべて削除されます。元に戻せません。
history-clear-yes = 履歴を消去する
history-clear-no = 履歴を残す
bookmarks-title = ブックマーク
bookmarks-empty = ブックマークしたページがここに表示されます。
bookmarks-no-matches = 検索に一致するブックマークはありません。
bookmarks-search-label = ブックマークを検索
subscriptions-title = 購読
subscriptions-empty = 購読しているサイトがここに表示されます。
subscriptions-no-matches = 検索に一致するサイトはありません。
subscriptions-search-label = サイトを検索
# When a site was last checked for posts. Format the date in code.
subscriptions-checked = 最終確認 { $date }
subscriptions-never-checked = 未確認
## Tags
tags-title = タグ
tags-empty = ブックマークやサイトに付けたタグがここに表示されます。
# The item being tagged. Shows its title, or its address until it has one.
tags-assign-title = { $name } のタグ
tags-add-label = タグを追加
tags-add-submit = 追加
tags-on-item = この項目のタグ
tags-other = ほかのタグ
tags-remove = このタグを外す
tags-delete = このタグをすべてから削除
tags-done = 完了
bookmarks-show-all = すべてのブックマークを表示
# Shown when a tag narrows the list.
bookmarks-tagged = タグ { $tag } のブックマーク
subscriptions-show-all = すべてのサイトを表示
subscriptions-tagged = タグ { $tag } のサイト
## Settings page
settings-page-title = 設定
settings-font-heading = 書体
settings-font-atkinson = Atkinson Hyperlegible
settings-font-luciole = Luciole
settings-font-literata = Literata
settings-font-charis = Charis SIL
settings-size-heading = 文字の大きさ
settings-size-decrease = 小さく
settings-size-increase = 大きく
# The text size now in use. Format the number in code.
settings-size-value = { $percent }%
settings-theme-heading = テーマ
settings-theme-light = ライト
settings-theme-dark = ダーク
settings-theme-system = システムに合わせる
settings-variant-heading = 配色
settings-variant-site = サイト
settings-variant-warm = 暖色
settings-variant-cool = 寒色
settings-variant-green = グリーン
settings-variant-mono = モノクロ
settings-language-heading = 言語
settings-language-system = システムに合わせる
## Languages
# Every language is named in its own words, so a reader who cannot read the
# current interface can still find theirs. Leave these untranslated.
language-en = English
language-de = Deutsch
language-es = Español
language-fr = Français
language-he = עברית
language-ja = 日本語
language-pt = Português
language-ru = Русский
## Forms
# Default label for a page form's submit control when the author gave none.
form-submit-default = 送信
form-reset-default = リセット
## Errors
error-load-failed = ページを読み込めませんでした。アドレスと接続を確認して、もう一度お試しください。
error-try-again = もう一度試す
error-broken-page = このページにはマークアップの誤りがあるため、表示できません。
error-broken-page-show-text = ページのテキストを表示
error-broken-page-text-title = ページのテキスト
error-non-mews = これは Mews のページではないため、簡素に表示しています。
error-image-blocked = この画像は別のサイトにあるため表示しません。
error-image-unavailable = この画像を読み込めませんでした。

172
app/locales/pt/main.ftl Normal file
View file

@ -0,0 +1,172 @@
## Reader chrome
reader-address-placeholder = Escreve um endereço web
# Accessibility label for the address field.
reader-address-label = Endereço web
reader-reload = Recarregar esta página
reader-open-hub = Abrir as tuas páginas Mews
reader-back = Retroceder
reader-forward = Avançar
reader-bookmark-add = Adicionar marcador
reader-bookmark-remove = Remover marcador
reader-subscribe = Subscrever este site
reader-unsubscribe = Cancelar a subscrição
## Navigation
# The strip of the app's own pages, carried by each of them.
nav-hub = Mews
nav-posts = Publicações
nav-bookmarks = Marcadores
nav-subscriptions = Subscrições
nav-history = Histórico
nav-tags = Etiquetas
nav-settings = Definições
## Hub
hub-title = Mews
## Counts
# Shown under an item. Format the number in code.
count-bookmarks-none = Ainda não há marcadores
count-bookmarks-one = { $count } marcador
count-bookmarks-many = { $count } marcadores
count-sites-none = Ainda não há sites
count-sites-one = { $count } site
count-sites-many = { $count } sites
count-pages-none = Ainda não há páginas
count-pages-one = { $count } página
count-pages-many = { $count } páginas
count-tags-none = Ainda não há etiquetas
count-tags-one = { $count } etiqueta
count-tags-many = { $count } etiquetas
count-unread-none = Não há publicações por ler
count-unread-one = { $count } publicação por ler
count-unread-many = { $count } publicações por ler
## Posts
posts-title = Publicações
posts-empty = Aqui aparecem as publicações dos sites que subscreves.
posts-none-unread = Já leste tudo.
posts-checking = A procurar publicações novas.
posts-sort-label = Ordenar por
posts-sort-date = Data
posts-sort-source = Site
posts-sort-unread = Por ler primeiro
posts-show-label = Mostrar
posts-show-all = Todas as publicações
posts-show-unread = Apenas por ler
posts-mark-read = Marcar como lida
posts-mark-unread = Marcar como não lida
## Actions
# Link labels used on more than one page.
action-refresh = Procurar publicações novas
action-search = Procurar
action-clear-search = Limpar a procura
action-remove-bookmark = Remover marcador
action-unsubscribe = Cancelar a subscrição
action-edit-tags = Editar etiquetas
## Library pages
# Internal pages rendered like ordinary Mews pages.
history-title = Histórico
history-empty = Aqui aparecem as páginas que visitas.
history-no-matches = Nenhuma página corresponde à tua procura.
history-search-label = Procurar no histórico
history-forget = Remover do histórico
history-clear = Limpar o histórico
history-clear-confirm = Limpar o histórico remove todas as páginas que visitaste. Não é possível anular.
history-clear-yes = Limpar o histórico agora
history-clear-no = Manter o meu histórico
bookmarks-title = Marcadores
bookmarks-empty = Aqui aparecem as páginas que guardas como marcador.
bookmarks-no-matches = Nenhum marcador corresponde à tua procura.
bookmarks-search-label = Procurar nos marcadores
subscriptions-title = Subscrições
subscriptions-empty = Aqui aparecem os sites que subscreves.
subscriptions-no-matches = Nenhum site corresponde à tua procura.
subscriptions-search-label = Procurar sites
# When a site was last checked for posts. Format the date in code.
subscriptions-checked = Última verificação { $date }
subscriptions-never-checked = Ainda não verificado
## Tags
tags-title = Etiquetas
tags-empty = Aqui aparecem as etiquetas que dás a marcadores e sites.
# The item being tagged. Shows its title, or its address until it has one.
tags-assign-title = Etiquetas de { $name }
tags-add-label = Adicionar uma etiqueta
tags-add-submit = Adicionar
tags-on-item = Neste item
tags-other = Outras etiquetas
tags-remove = Remover esta etiqueta
tags-delete = Eliminar esta etiqueta em todo o lado
tags-done = Concluído
bookmarks-show-all = Mostrar todos os marcadores
# Shown when a tag narrows the list.
bookmarks-tagged = Marcadores com a etiqueta { $tag }
subscriptions-show-all = Mostrar todos os sites
subscriptions-tagged = Sites com a etiqueta { $tag }
## Settings page
settings-page-title = Definições
settings-font-heading = Tipo de letra
settings-font-atkinson = Atkinson Hyperlegible
settings-font-luciole = Luciole
settings-font-literata = Literata
settings-font-charis = Charis SIL
settings-size-heading = Tamanho do texto
settings-size-decrease = Texto mais pequeno
settings-size-increase = Texto maior
# The text size now in use. Format the number in code.
settings-size-value = { $percent }%
settings-theme-heading = Tema
settings-theme-light = Claro
settings-theme-dark = Escuro
settings-theme-system = Seguir o sistema
settings-variant-heading = Cores
settings-variant-site = Site
settings-variant-warm = Quente
settings-variant-cool = Frio
settings-variant-green = Verde
settings-variant-mono = Monocromático
settings-language-heading = Idioma
settings-language-system = Seguir o sistema
## Languages
# Every language is named in its own words, so a reader who cannot read the
# current interface can still find theirs. Leave these untranslated.
language-en = English
language-de = Deutsch
language-es = Español
language-fr = Français
language-he = עברית
language-ja = 日本語
language-pt = Português
language-ru = Русский
## Forms
# Default label for a page form's submit control when the author gave none.
form-submit-default = Enviar
form-reset-default = Repor
## Errors
error-load-failed = Não foi possível carregar a página. Verifica o endereço e a tua ligação e tenta de novo.
error-try-again = Tentar de novo
error-broken-page = Esta página tem um erro de marcação, por isso não pode ser mostrada.
error-broken-page-show-text = Mostrar o texto da página
error-broken-page-text-title = Texto da página
error-non-mews = Esta não é uma página Mews, por isso é mostrada de forma simples.
error-image-blocked = Esta imagem está noutro site e não é mostrada.
error-image-unavailable = Não foi possível carregar esta imagem.

177
app/locales/ru/main.ftl Normal file
View file

@ -0,0 +1,177 @@
## Reader chrome
reader-address-placeholder = Введите веб-адрес
# Accessibility label for the address field.
reader-address-label = Веб-адрес
reader-reload = Обновить эту страницу
reader-open-hub = Открыть ваши страницы Mews
reader-back = Назад
reader-forward = Вперёд
reader-bookmark-add = Добавить закладку
reader-bookmark-remove = Удалить закладку
reader-subscribe = Подписаться на этот сайт
reader-unsubscribe = Отписаться
## Navigation
# The strip of the app's own pages, carried by each of them.
nav-hub = Mews
nav-posts = Записи
nav-bookmarks = Закладки
nav-subscriptions = Подписки
nav-history = История
nav-tags = Метки
nav-settings = Настройки
## Hub
hub-title = Mews
## Counts
# Shown under an item. Format the number in code.
count-bookmarks-none = Закладок пока нет
count-bookmarks-one = { $count } закладка
count-bookmarks-many = { $count } закладок
count-bookmarks-few = { $count } закладки
count-sites-none = Сайтов пока нет
count-sites-one = { $count } сайт
count-sites-many = { $count } сайтов
count-sites-few = { $count } сайта
count-pages-none = Страниц пока нет
count-pages-one = { $count } страница
count-pages-many = { $count } страниц
count-pages-few = { $count } страницы
count-tags-none = Меток пока нет
count-tags-one = { $count } метка
count-tags-many = { $count } меток
count-tags-few = { $count } метки
count-unread-none = Непрочитанных записей нет
count-unread-one = { $count } непрочитанная запись
count-unread-many = { $count } непрочитанных записей
count-unread-few = { $count } непрочитанные записи
## Posts
posts-title = Записи
posts-empty = Здесь появятся записи сайтов, на которые вы подписаны.
posts-none-unread = Вы всё прочитали.
posts-checking = Идёт проверка новых записей.
posts-sort-label = Сортировка
posts-sort-date = По дате
posts-sort-source = По сайту
posts-sort-unread = Непрочитанные сверху
posts-show-label = Показывать
posts-show-all = Все записи
posts-show-unread = Только непрочитанные
posts-mark-read = Отметить прочитанной
posts-mark-unread = Отметить непрочитанной
## Actions
# Link labels used on more than one page.
action-refresh = Проверить новые записи
action-search = Искать
action-clear-search = Очистить поиск
action-remove-bookmark = Удалить закладку
action-unsubscribe = Отписаться
action-edit-tags = Изменить метки
## Library pages
# Internal pages rendered like ordinary Mews pages.
history-title = История
history-empty = Здесь появятся страницы, которые вы открываете.
history-no-matches = Ни одна страница не найдена по запросу.
history-search-label = Поиск по истории
history-forget = Удалить из истории
history-clear = Очистить историю
history-clear-confirm = При очистке истории будут удалены все посещённые страницы. Это действие необратимо.
history-clear-yes = Очистить историю
history-clear-no = Оставить историю
bookmarks-title = Закладки
bookmarks-empty = Здесь появятся страницы, которые вы добавите в закладки.
bookmarks-no-matches = Ни одна закладка не найдена по запросу.
bookmarks-search-label = Поиск по закладкам
subscriptions-title = Подписки
subscriptions-empty = Здесь появятся сайты, на которые вы подпишетесь.
subscriptions-no-matches = Ни один сайт не найден по запросу.
subscriptions-search-label = Поиск по сайтам
# When a site was last checked for posts. Format the date in code.
subscriptions-checked = Последняя проверка { $date }
subscriptions-never-checked = Ещё не проверялся
## Tags
tags-title = Метки
tags-empty = Здесь появятся метки, которые вы добавите к закладкам и сайтам.
# The item being tagged. Shows its title, or its address until it has one.
tags-assign-title = Метки для { $name }
tags-add-label = Добавить метку
tags-add-submit = Добавить
tags-on-item = У этого элемента
tags-other = Другие метки
tags-remove = Убрать эту метку
tags-delete = Удалить эту метку везде
tags-done = Готово
bookmarks-show-all = Показать все закладки
# Shown when a tag narrows the list.
bookmarks-tagged = Закладки с меткой { $tag }
subscriptions-show-all = Показать все сайты
subscriptions-tagged = Сайты с меткой { $tag }
## Settings page
settings-page-title = Настройки
settings-font-heading = Шрифт
settings-font-atkinson = Atkinson Hyperlegible
settings-font-luciole = Luciole
settings-font-literata = Literata
settings-font-charis = Charis SIL
settings-size-heading = Размер текста
settings-size-decrease = Мельче
settings-size-increase = Крупнее
# The text size now in use. Format the number in code.
settings-size-value = { $percent }%
settings-theme-heading = Оформление
settings-theme-light = Светлое
settings-theme-dark = Тёмное
settings-theme-system = Как в системе
settings-variant-heading = Цвета
settings-variant-site = Сайта
settings-variant-warm = Тёплые
settings-variant-cool = Холодные
settings-variant-green = Зелёные
settings-variant-mono = Монохромные
settings-language-heading = Язык
settings-language-system = Как в системе
## Languages
# Every language is named in its own words, so a reader who cannot read the
# current interface can still find theirs. Leave these untranslated.
language-en = English
language-de = Deutsch
language-es = Español
language-fr = Français
language-he = עברית
language-ja = 日本語
language-pt = Português
language-ru = Русский
## Forms
# Default label for a page form's submit control when the author gave none.
form-submit-default = Отправить
form-reset-default = Сбросить
## Errors
error-load-failed = Не удалось загрузить страницу. Проверьте адрес и подключение, затем повторите попытку.
error-try-again = Повторить попытку
error-broken-page = На этой странице ошибка разметки, поэтому показать её нельзя.
error-broken-page-show-text = Показать текст страницы
error-broken-page-text-title = Текст страницы
error-non-mews = Это не страница Mews, поэтому она показана простым текстом.
error-image-blocked = Это изображение находится на другом сайте и не показывается.
error-image-unavailable = Не удалось загрузить изображение.

View file

@ -254,6 +254,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_localizations:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
@ -311,10 +316,10 @@ packages:
dependency: transitive
description:
name: intl
sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867"
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.3"
version: "0.20.2"
io:
dependency: transitive
description:

View file

@ -10,6 +10,8 @@ environment:
dependencies:
flutter:
sdk: flutter
flutter_localizations:
sdk: flutter
fluent: ^2.0.4
signals: ^7.1.0
@ -34,6 +36,13 @@ flutter:
assets:
- locales/en-US/main.ftl
- locales/de/main.ftl
- locales/es/main.ftl
- locales/fr/main.ftl
- locales/he/main.ftl
- locales/ja/main.ftl
- locales/pt/main.ftl
- locales/ru/main.ftl
fonts:
- family: Atkinson Hyperlegible

View file

@ -245,6 +245,50 @@ void main() {
expect(deps.browser.error.value, isNull);
});
testWidgets('choosing a language changes the interface in place',
(tester) async {
await pumpApp(tester);
await deps.browser.open(MewsUrls.settings);
await tester.pumpAndSettle();
expect(find.text('Settings'), findsOneWidget);
// The language group sits below the fold.
Future<void> scrollTo(String label) async {
final target = find.textContaining(label);
for (var i = 0; i < 30 && target.evaluate().isEmpty; i++) {
await tester.drag(find.byType(ListView), const Offset(0, -200));
await tester.pumpAndSettle();
}
await tester.ensureVisible(target.first);
await tester.pumpAndSettle();
}
// Each language is named in its own words, so it can be found from an
// interface the reader cannot read.
await scrollTo('Deutsch');
final stepsBack = deps.browser.canGoBack;
await tapRun(tester, 'Deutsch');
expect(deps.settings.language.value, 'de');
// Applied in place, like the other reader choices.
expect(deps.browser.currentPage.value?.url, MewsUrls.settings);
expect(deps.browser.canGoBack, stepsBack);
expect(find.text('Einstellungen'), findsOneWidget);
expect(find.text('Settings'), findsNothing);
// Hebrew turns the whole page around.
await scrollTo('עברית');
await tapRun(tester, 'עברית');
expect(deps.settings.language.value, 'he');
expect(deps.browser.currentPage.value?.direction, TextDirection.rtl);
// Back to English.
await scrollTo('English');
await tapRun(tester, 'English');
expect(deps.settings.language.value, 'en-US');
expect(deps.browser.currentPage.value?.direction, TextDirection.ltr);
expect(find.text('Settings'), findsOneWidget);
});
testWidgets('bare mews: and an unknown name resolve predictably',
(tester) async {
await pumpApp(tester);

162
app/test/locales_test.dart Normal file
View file

@ -0,0 +1,162 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:mews/l10n/locales.dart';
import 'package:mews/l10n/strings.dart';
final _message = RegExp(r'^([a-z0-9-]+) = (.*)$', multiLine: true);
final _placeholder = RegExp(r'\{ \$([a-z]+) \}');
Map<String, String> _messagesOf(String tag) {
final source = File('locales/$tag/main.ftl').readAsStringSync();
return {
for (final m in _message.allMatches(source)) m.group(1)!: m.group(2)!,
};
}
void main() {
final english = _messagesOf(fallbackLocale);
test('every shipped language has a file declared as an asset', () {
final pubspec = File('pubspec.yaml').readAsStringSync();
for (final locale in appLocales) {
expect(File('locales/${locale.tag}/main.ftl').existsSync(), isTrue,
reason: '${locale.tag} has no messages');
expect(pubspec, contains('locales/${locale.tag}/main.ftl'),
reason: '${locale.tag} is not bundled as an asset');
}
});
test('every language says everything English says', () {
for (final locale in appLocales) {
if (locale.tag == fallbackLocale) continue;
final messages = _messagesOf(locale.tag);
for (final id in english.keys) {
expect(messages, contains(id),
reason: '${locale.tag} is missing $id');
expect(messages[id], isNotEmpty, reason: '${locale.tag}: $id is empty');
}
// The only messages a language may add are plural forms English has no
// use for, such as Russian's few or Hebrew's two.
for (final id in messages.keys.where((i) => !english.containsKey(i))) {
expect(id, matches(r'-(two|few)$'),
reason: '${locale.tag} defines an unknown $id');
expect(english, contains(id.replaceFirst(RegExp(r'-(two|few)$'), '-many')),
reason: '$id has no English counterpart to fall back on');
}
}
});
test('every language keeps the placeholders a message needs', () {
for (final locale in appLocales) {
if (locale.tag == fallbackLocale) continue;
final messages = _messagesOf(locale.tag);
for (final entry in english.entries) {
final wanted = _placeholder
.allMatches(entry.value)
.map((m) => m.group(1))
.toSet();
final got = _placeholder
.allMatches(messages[entry.key]!)
.map((m) => m.group(1))
.toSet();
// A dropped placeholder loses the number or name it carried.
expect(got, containsAll(wanted),
reason: '${locale.tag}: ${entry.key} drops a placeholder');
// An invented one renders as nothing. The exception is the count on a
// singular message: English writes "1 bookmark", but a language whose
// singular also covers 21 and 31 has to show the number.
final extra = got.difference(wanted);
expect(
extra.isEmpty ||
(extra.length == 1 &&
extra.first == 'count' &&
entry.key.endsWith('-one')),
isTrue,
reason: '${locale.tag}: ${entry.key} adds $extra');
}
}
});
test('a language naming a plural form defines every one it needs', () {
// The plural rules decide which suffix a count asks for; a language whose
// rules can produce a form it never defines would fall back to English.
for (final locale in appLocales) {
final messages = _messagesOf(locale.tag);
final stems = english.keys
.where((id) => id.endsWith('-many'))
.map((id) => id.substring(0, id.length - '-many'.length));
for (final stem in stems) {
for (var count = 1; count <= 200; count++) {
final suffix = pluralSuffix(locale.tag, count);
if (suffix == 'one' || suffix == 'many') continue;
expect(messages, contains('$stem-$suffix'),
reason: '${locale.tag} needs $stem-$suffix for $count');
}
}
}
});
group('choosing a language', () {
test('matches on the language alone, so a region still fits', () {
expect(matchLocale('de-AT'), 'de');
expect(matchLocale('pt-BR'), 'pt');
expect(matchLocale('en-GB'), 'en-US');
expect(matchLocale('en-US'), 'en-US');
expect(matchLocale('is'), isNull);
});
test('takes the first of the device languages it ships', () {
expect(resolveLocale(['is', 'fo', 'ru-RU']), 'ru');
expect(resolveLocale(['is', 'fo']), fallbackLocale);
expect(resolveLocale([]), fallbackLocale);
});
test('knows which languages mirror', () {
expect(isRightToLeft('he'), isTrue);
expect(isRightToLeft('en-US'), isFalse);
});
});
group('plural rules', () {
test('Russian separates two to four from the rest', () {
expect(pluralSuffix('ru', 1), 'one');
expect(pluralSuffix('ru', 21), 'one');
expect(pluralSuffix('ru', 11), 'many');
expect(pluralSuffix('ru', 2), 'few');
expect(pluralSuffix('ru', 24), 'few');
expect(pluralSuffix('ru', 12), 'many');
expect(pluralSuffix('ru', 5), 'many');
});
test('Hebrew has a form for two', () {
expect(pluralSuffix('he', 1), 'one');
expect(pluralSuffix('he', 2), 'two');
expect(pluralSuffix('he', 3), 'many');
});
test('French and Portuguese read one for a single thing', () {
expect(pluralSuffix('fr', 1), 'one');
expect(pluralSuffix('fr', 2), 'many');
expect(pluralSuffix('pt', 1), 'one');
});
test('Japanese has one form', () {
expect(pluralSuffix('ja', 1), 'many');
expect(pluralSuffix('ja', 7), 'many');
});
});
test('a message missing from a language falls back to English', () {
final strings = Strings()
..loadSource(File('locales/en-US/main.ftl').readAsStringSync())
..loadSource('nav-posts = Beiträge', tag: 'de');
expect(strings.t('nav-posts'), 'Beiträge');
// Not translated in this stub, so English stands in rather than an id.
expect(strings.t('nav-bookmarks'), 'Bookmarks');
expect(strings.t('no-such-message'), 'no-such-message');
expect(strings.lookup('no-such-message'), isNull);
});
}