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