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
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ Future<MewsPage> buildBookmarksPage(
|
|||
return internalPage(
|
||||
url: url,
|
||||
title: title,
|
||||
strings: strings,
|
||||
body: [
|
||||
...chrome(title: title, current: url, strings: strings),
|
||||
textForm(
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ Future<MewsPage> buildHistoryPage(
|
|||
return internalPage(
|
||||
url: url,
|
||||
title: title,
|
||||
strings: strings,
|
||||
body: [
|
||||
...chrome(title: title, current: url, strings: strings),
|
||||
textForm(
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ Future<MewsPage> buildPostsPage(
|
|||
return internalPage(
|
||||
url: url,
|
||||
title: title,
|
||||
strings: strings,
|
||||
body: [
|
||||
...chrome(title: title, current: url, strings: strings),
|
||||
ParagraphBlock([
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ Future<MewsPage> buildSubscriptionsPage(
|
|||
return internalPage(
|
||||
url: url,
|
||||
title: title,
|
||||
strings: strings,
|
||||
body: [
|
||||
...chrome(title: title, current: url, strings: strings),
|
||||
textForm(
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue