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

@ -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([