diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index e03f421761..e765fa4ea0 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -943,6 +943,26 @@ "@openLinksWithInAppBrowser": { "description": "Label for toggling setting to open links with in-app browser" }, + "languageSettingTitle": "Language", + "@languageSettingTitle": { + "description": "Title for language setting." + }, + "languageEn": "English", + "@languageEn": { + "description": "Label for the English language." + }, + "languagePl": "Polish", + "@languagePl": { + "description": "Label for the Polish language." + }, + "languageRu": "Russian", + "@languageRu": { + "description": "Label for the Russian language." + }, + "languageUk": "Ukrainian", + "@languageUk": { + "description": "Label for the Ukrainian language." + }, "pollWidgetQuestionMissing": "No question.", "@pollWidgetQuestionMissing": { "description": "Text to display for a poll when the question is missing" diff --git a/docs/translation.md b/docs/translation.md index c8cc132384..a2875b29f5 100644 --- a/docs/translation.md +++ b/docs/translation.md @@ -25,6 +25,8 @@ is working correctly. ## Adding new UI strings +
+ ### Adding a string to the translation database To add a new string in the UI, start by @@ -79,6 +81,26 @@ For example: `zulipLocalizations.subscribedToNChannels(store.subscriptions.length)`. +## Adding a new language + +ARB files for new languages are automatically created in pull requests generated +by [the update-translations GitHub workflow](/.github/workflows/update-translations.yml). +However, this won't make them in the in-app settings UI. + +On [Weblate](https://hosted.weblate.org/projects/zulip/zulip-flutter/), +we can check the percentage of strings translated in each language. +We use this information to determine if we should start offerring the language +in the in-app settings UI. For reference, on the web app, we offer a language +when it is 5% translated. (Search for `percent_translated` in the server code.) + +When a language has a good percentage of strings translated, follow these steps +to add it: + +- If the language tag is, for example, 'en-GB', [add a string](#add-string) + named 'languageEnGb'. +- Update [localizations.dart](/lib/model/localizations.dart) to include the new language in + `languages`, following the instructions there. + ## Hack to enforce locale (for testing, etc.) For testing the app's behavior in different locales, diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index c13cdd3a9f..26ca909743 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1407,6 +1407,36 @@ abstract class ZulipLocalizations { /// **'Open links with in-app browser'** String get openLinksWithInAppBrowser; + /// Title for language setting. + /// + /// In en, this message translates to: + /// **'Language'** + String get languageSettingTitle; + + /// Label for the English language. + /// + /// In en, this message translates to: + /// **'English'** + String get languageEn; + + /// Label for the Polish language. + /// + /// In en, this message translates to: + /// **'Polish'** + String get languagePl; + + /// Label for the Russian language. + /// + /// In en, this message translates to: + /// **'Russian'** + String get languageRu; + + /// Label for the Ukrainian language. + /// + /// In en, this message translates to: + /// **'Ukrainian'** + String get languageUk; + /// Text to display for a poll when the question is missing /// /// In en, this message translates to: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index c47095ee06..94342640bd 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -767,6 +767,21 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + + @override + String get languageEn => 'English'; + + @override + String get languagePl => 'Polish'; + + @override + String get languageRu => 'Russian'; + + @override + String get languageUk => 'Ukrainian'; + @override String get pollWidgetQuestionMissing => 'No question.'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index 301f70d34d..f148d08418 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -767,6 +767,21 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + + @override + String get languageEn => 'English'; + + @override + String get languagePl => 'Polish'; + + @override + String get languageRu => 'Russian'; + + @override + String get languageUk => 'Ukrainian'; + @override String get pollWidgetQuestionMissing => 'No question.'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index 52e4393767..7c775d9bdf 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -767,6 +767,21 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + + @override + String get languageEn => 'English'; + + @override + String get languagePl => 'Polish'; + + @override + String get languageRu => 'Russian'; + + @override + String get languageUk => 'Ukrainian'; + @override String get pollWidgetQuestionMissing => 'No question.'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index cb6dc8a2ce..c019ab07e1 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -773,6 +773,21 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + + @override + String get languageEn => 'English'; + + @override + String get languagePl => 'Polish'; + + @override + String get languageRu => 'Russian'; + + @override + String get languageUk => 'Ukrainian'; + @override String get pollWidgetQuestionMissing => 'No question.'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 1fdc2f585d..a4030595f6 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -767,6 +767,21 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + + @override + String get languageEn => 'English'; + + @override + String get languagePl => 'Polish'; + + @override + String get languageRu => 'Russian'; + + @override + String get languageUk => 'Ukrainian'; + @override String get pollWidgetQuestionMissing => 'No question.'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 4bdd16533d..6c3f94658e 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -767,6 +767,21 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + + @override + String get languageEn => 'English'; + + @override + String get languagePl => 'Polish'; + + @override + String get languageRu => 'Russian'; + + @override + String get languageUk => 'Ukrainian'; + @override String get pollWidgetQuestionMissing => 'No question.'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index e046358d11..f8d15dc614 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -777,6 +777,21 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Otwieraj odnośniki w aplikacji'; + @override + String get languageSettingTitle => 'Language'; + + @override + String get languageEn => 'English'; + + @override + String get languagePl => 'Polish'; + + @override + String get languageRu => 'Russian'; + + @override + String get languageUk => 'Ukrainian'; + @override String get pollWidgetQuestionMissing => 'Brak pytania.'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index 57b33f03b1..b9c3081824 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -781,6 +781,21 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Открывать ссылки внутри приложения'; + @override + String get languageSettingTitle => 'Language'; + + @override + String get languageEn => 'English'; + + @override + String get languagePl => 'Polish'; + + @override + String get languageRu => 'Russian'; + + @override + String get languageUk => 'Ukrainian'; + @override String get pollWidgetQuestionMissing => 'Нет вопроса.'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index 29e203cccb..e78d100f30 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -769,6 +769,21 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + + @override + String get languageEn => 'English'; + + @override + String get languagePl => 'Polish'; + + @override + String get languageRu => 'Russian'; + + @override + String get languageUk => 'Ukrainian'; + @override String get pollWidgetQuestionMissing => 'Bez otázky.'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index c69ff12045..e8055c7320 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -789,6 +789,21 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get openLinksWithInAppBrowser => 'Odpri povezave v brskalniku znotraj aplikacije'; + @override + String get languageSettingTitle => 'Language'; + + @override + String get languageEn => 'English'; + + @override + String get languagePl => 'Polish'; + + @override + String get languageRu => 'Russian'; + + @override + String get languageUk => 'Ukrainian'; + @override String get pollWidgetQuestionMissing => 'Brez vprašanja.'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 6c5e7264d1..4278ff3297 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -781,6 +781,21 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get openLinksWithInAppBrowser => 'Відкривати посилання за допомогою браузера додатку'; + @override + String get languageSettingTitle => 'Language'; + + @override + String get languageEn => 'English'; + + @override + String get languagePl => 'Polish'; + + @override + String get languageRu => 'Russian'; + + @override + String get languageUk => 'Ukrainian'; + @override String get pollWidgetQuestionMissing => 'Немає питання.'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index d61e7cd6e3..f915137801 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -767,6 +767,21 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + + @override + String get languageEn => 'English'; + + @override + String get languagePl => 'Polish'; + + @override + String get languageRu => 'Russian'; + + @override + String get languageUk => 'Ukrainian'; + @override String get pollWidgetQuestionMissing => 'No question.'; diff --git a/lib/model/database.dart b/lib/model/database.dart index e20380b9e7..0ae04e2928 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:drift/drift.dart'; import 'package:drift/internal/versioned_schema.dart'; import 'package:drift/remote.dart'; @@ -30,6 +32,9 @@ class GlobalSettings extends Table { Column get markReadOnScroll => textEnum() .nullable()(); + Column get language => text().map(const LocaleConverter()) + .nullable()(); + // If adding a new column to this table, consider whether [BoolGlobalSettings] // can do the job instead (by adding a value to the [BoolGlobalSetting] enum). // That way is more convenient, when it works, because @@ -125,7 +130,7 @@ class AppDatabase extends _$AppDatabase { // information on using the build_runner. // * Write a migration in `_migrationSteps` below. // * Write tests. - static const int latestSchemaVersion = 8; // See note. + static const int latestSchemaVersion = 9; // See note. @override int get schemaVersion => latestSchemaVersion; @@ -188,6 +193,9 @@ class AppDatabase extends _$AppDatabase { await m.addColumn(schema.globalSettings, schema.globalSettings.markReadOnScroll); }, + from8To9: (m, schema) async { + await m.addColumn(schema.globalSettings, schema.globalSettings.language); + } ); Future _createLatestSchema(Migrator m) async { @@ -257,3 +265,55 @@ class AppDatabase extends _$AppDatabase { } class AccountAlreadyExistsException implements Exception {} + +class LocaleConverter extends TypeConverter { + const LocaleConverter(); + + /// Parse a Unicode BCP 47 Language Identifier into [Locale]. + /// + /// Throw when it fails to convert [languageTag] into a [Locale]. + /// + /// This supports parsing a Unicode Language Identifier returned from + /// [Locale.toLanguageTag]. + /// + /// This implementation refers to a part of + /// [this EBNF grammar](https://www.unicode.org/reports/tr35/#Unicode_language_identifier), + /// assuming the identifier is valid without + /// [unicode_variant_subtag](https://www.unicode.org/reports/tr35/#unicode_variant_subtag). + /// + /// This doesn't check if the [languageTag] is a valid identifier, (i.e., when + /// this returns without errors, the identifier is not necessarily + /// syntactically well-formed or valid). + // TODO(upstream): send this as a factory Locale.fromLanguageTag + // https://github.com/flutter/flutter/issues/143491 + Locale _fromLanguageTag(String languageTag) { + final subtags = languageTag.replaceAll('_', '-').split('-'); + + return switch (subtags) { + [final language, final script, final region] => + Locale.fromSubtags( + languageCode: language, scriptCode: script, countryCode: region), + + [final language, final script] when script.length == 4 => + Locale.fromSubtags(languageCode: language, scriptCode: script), + + [final language, final region] => + Locale(language, region), + + [final language] => + Locale(language), + + _ => throw ArgumentError.value(languageTag, 'languageTag'), + }; + } + + @override + Locale fromSql(String fromDb) { + return _fromLanguageTag(fromDb); + } + + @override + String toSql(Locale value) { + return value.toLanguageTag(); + } +} diff --git a/lib/model/database.g.dart b/lib/model/database.g.dart index d78f7ede84..b81844eaad 100644 --- a/lib/model/database.g.dart +++ b/lib/model/database.g.dart @@ -57,11 +57,21 @@ class $GlobalSettingsTable extends GlobalSettings $GlobalSettingsTable.$convertermarkReadOnScrolln, ); @override + late final GeneratedColumnWithTypeConverter language = + GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ).withConverter($GlobalSettingsTable.$converterlanguagen); + @override List get $columns => [ themeSetting, browserPreference, visitFirstUnread, markReadOnScroll, + language, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -101,6 +111,12 @@ class $GlobalSettingsTable extends GlobalSettings data['${effectivePrefix}mark_read_on_scroll'], ), ), + language: $GlobalSettingsTable.$converterlanguagen.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}language'], + ), + ), ); } @@ -141,6 +157,10 @@ class $GlobalSettingsTable extends GlobalSettings $convertermarkReadOnScrolln = JsonTypeConverter2.asNullable( $convertermarkReadOnScroll, ); + static TypeConverter $converterlanguage = + const LocaleConverter(); + static TypeConverter $converterlanguagen = + NullAwareTypeConverter.wrap($converterlanguage); } class GlobalSettingsData extends DataClass @@ -149,11 +169,13 @@ class GlobalSettingsData extends DataClass final BrowserPreference? browserPreference; final VisitFirstUnreadSetting? visitFirstUnread; final MarkReadOnScrollSetting? markReadOnScroll; + final Locale? language; const GlobalSettingsData({ this.themeSetting, this.browserPreference, this.visitFirstUnread, this.markReadOnScroll, + this.language, }); @override Map toColumns(bool nullToAbsent) { @@ -184,6 +206,11 @@ class GlobalSettingsData extends DataClass ), ); } + if (!nullToAbsent || language != null) { + map['language'] = Variable( + $GlobalSettingsTable.$converterlanguagen.toSql(language), + ); + } return map; } @@ -201,6 +228,9 @@ class GlobalSettingsData extends DataClass markReadOnScroll: markReadOnScroll == null && nullToAbsent ? const Value.absent() : Value(markReadOnScroll), + language: language == null && nullToAbsent + ? const Value.absent() + : Value(language), ); } @@ -219,6 +249,7 @@ class GlobalSettingsData extends DataClass .fromJson(serializer.fromJson(json['visitFirstUnread'])), markReadOnScroll: $GlobalSettingsTable.$convertermarkReadOnScrolln .fromJson(serializer.fromJson(json['markReadOnScroll'])), + language: serializer.fromJson(json['language']), ); } @override @@ -243,6 +274,7 @@ class GlobalSettingsData extends DataClass markReadOnScroll, ), ), + 'language': serializer.toJson(language), }; } @@ -251,6 +283,7 @@ class GlobalSettingsData extends DataClass Value browserPreference = const Value.absent(), Value visitFirstUnread = const Value.absent(), Value markReadOnScroll = const Value.absent(), + Value language = const Value.absent(), }) => GlobalSettingsData( themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, browserPreference: browserPreference.present @@ -262,6 +295,7 @@ class GlobalSettingsData extends DataClass markReadOnScroll: markReadOnScroll.present ? markReadOnScroll.value : this.markReadOnScroll, + language: language.present ? language.value : this.language, ); GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { return GlobalSettingsData( @@ -277,6 +311,7 @@ class GlobalSettingsData extends DataClass markReadOnScroll: data.markReadOnScroll.present ? data.markReadOnScroll.value : this.markReadOnScroll, + language: data.language.present ? data.language.value : this.language, ); } @@ -286,7 +321,8 @@ class GlobalSettingsData extends DataClass ..write('themeSetting: $themeSetting, ') ..write('browserPreference: $browserPreference, ') ..write('visitFirstUnread: $visitFirstUnread, ') - ..write('markReadOnScroll: $markReadOnScroll') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('language: $language') ..write(')')) .toString(); } @@ -297,6 +333,7 @@ class GlobalSettingsData extends DataClass browserPreference, visitFirstUnread, markReadOnScroll, + language, ); @override bool operator ==(Object other) => @@ -305,7 +342,8 @@ class GlobalSettingsData extends DataClass other.themeSetting == this.themeSetting && other.browserPreference == this.browserPreference && other.visitFirstUnread == this.visitFirstUnread && - other.markReadOnScroll == this.markReadOnScroll); + other.markReadOnScroll == this.markReadOnScroll && + other.language == this.language); } class GlobalSettingsCompanion extends UpdateCompanion { @@ -313,12 +351,14 @@ class GlobalSettingsCompanion extends UpdateCompanion { final Value browserPreference; final Value visitFirstUnread; final Value markReadOnScroll; + final Value language; final Value rowid; const GlobalSettingsCompanion({ this.themeSetting = const Value.absent(), this.browserPreference = const Value.absent(), this.visitFirstUnread = const Value.absent(), this.markReadOnScroll = const Value.absent(), + this.language = const Value.absent(), this.rowid = const Value.absent(), }); GlobalSettingsCompanion.insert({ @@ -326,6 +366,7 @@ class GlobalSettingsCompanion extends UpdateCompanion { this.browserPreference = const Value.absent(), this.visitFirstUnread = const Value.absent(), this.markReadOnScroll = const Value.absent(), + this.language = const Value.absent(), this.rowid = const Value.absent(), }); static Insertable custom({ @@ -333,6 +374,7 @@ class GlobalSettingsCompanion extends UpdateCompanion { Expression? browserPreference, Expression? visitFirstUnread, Expression? markReadOnScroll, + Expression? language, Expression? rowid, }) { return RawValuesInsertable({ @@ -340,6 +382,7 @@ class GlobalSettingsCompanion extends UpdateCompanion { if (browserPreference != null) 'browser_preference': browserPreference, if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, + if (language != null) 'language': language, if (rowid != null) 'rowid': rowid, }); } @@ -349,6 +392,7 @@ class GlobalSettingsCompanion extends UpdateCompanion { Value? browserPreference, Value? visitFirstUnread, Value? markReadOnScroll, + Value? language, Value? rowid, }) { return GlobalSettingsCompanion( @@ -356,6 +400,7 @@ class GlobalSettingsCompanion extends UpdateCompanion { browserPreference: browserPreference ?? this.browserPreference, visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, + language: language ?? this.language, rowid: rowid ?? this.rowid, ); } @@ -389,6 +434,11 @@ class GlobalSettingsCompanion extends UpdateCompanion { ), ); } + if (language.present) { + map['language'] = Variable( + $GlobalSettingsTable.$converterlanguagen.toSql(language.value), + ); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -402,6 +452,7 @@ class GlobalSettingsCompanion extends UpdateCompanion { ..write('browserPreference: $browserPreference, ') ..write('visitFirstUnread: $visitFirstUnread, ') ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('language: $language, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -1248,6 +1299,7 @@ typedef $$GlobalSettingsTableCreateCompanionBuilder = Value browserPreference, Value visitFirstUnread, Value markReadOnScroll, + Value language, Value rowid, }); typedef $$GlobalSettingsTableUpdateCompanionBuilder = @@ -1256,6 +1308,7 @@ typedef $$GlobalSettingsTableUpdateCompanionBuilder = Value browserPreference, Value visitFirstUnread, Value markReadOnScroll, + Value language, Value rowid, }); @@ -1299,6 +1352,12 @@ class $$GlobalSettingsTableFilterComposer column: $table.markReadOnScroll, builder: (column) => ColumnWithTypeConverterFilters(column), ); + + ColumnWithTypeConverterFilters get language => + $composableBuilder( + column: $table.language, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); } class $$GlobalSettingsTableOrderingComposer @@ -1329,6 +1388,11 @@ class $$GlobalSettingsTableOrderingComposer column: $table.markReadOnScroll, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get language => $composableBuilder( + column: $table.language, + builder: (column) => ColumnOrderings(column), + ); } class $$GlobalSettingsTableAnnotationComposer @@ -1363,6 +1427,9 @@ class $$GlobalSettingsTableAnnotationComposer column: $table.markReadOnScroll, builder: (column) => column, ); + + GeneratedColumnWithTypeConverter get language => + $composableBuilder(column: $table.language, builder: (column) => column); } class $$GlobalSettingsTableTableManager @@ -1409,12 +1476,14 @@ class $$GlobalSettingsTableTableManager const Value.absent(), Value markReadOnScroll = const Value.absent(), + Value language = const Value.absent(), Value rowid = const Value.absent(), }) => GlobalSettingsCompanion( themeSetting: themeSetting, browserPreference: browserPreference, visitFirstUnread: visitFirstUnread, markReadOnScroll: markReadOnScroll, + language: language, rowid: rowid, ), createCompanionCallback: @@ -1426,12 +1495,14 @@ class $$GlobalSettingsTableTableManager const Value.absent(), Value markReadOnScroll = const Value.absent(), + Value language = const Value.absent(), Value rowid = const Value.absent(), }) => GlobalSettingsCompanion.insert( themeSetting: themeSetting, browserPreference: browserPreference, visitFirstUnread: visitFirstUnread, markReadOnScroll: markReadOnScroll, + language: language, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/model/localizations.dart b/lib/model/localizations.dart index 07598cd8fe..1f3391b663 100644 --- a/lib/model/localizations.dart +++ b/lib/model/localizations.dart @@ -1,3 +1,7 @@ +import 'dart:ui'; + +import 'package:collection/collection.dart'; + import '../generated/l10n/zulip_localizations.dart'; abstract final class GlobalLocalizations { @@ -15,3 +19,62 @@ abstract final class GlobalLocalizations { static ZulipLocalizations zulipLocalizations = lookupZulipLocalizations(ZulipLocalizations.supportedLocales.first); } + +extension ZulipLocalizationsHelper on ZulipLocalizations { + /// Returns a list of the locales we offer, with their display name in their + /// own locale a.k.a. selfname, and the display name localized in this + /// [ZulipLocalizations] instance. + /// + /// This list only includes some of [ZulipLocalizations.supportedLocales]; + /// it includes languages that have substantially complete translations. + /// For what counts as substantially translated, see docs/translation.md. + /// + /// The list should be sorted by selfname, to help users find their + /// language in the UI. When in doubt how to sort (like between different + /// scripts, or in scripts you don't know), try to match the order found in + /// other UIs, like for choosing a language in your phone's system settings. + /// + /// For the values of selfname, consult Wikipedia: + /// https://meta.wikimedia.org/wiki/List_of_Wikipedias + /// https://en.wikipedia.org/wiki/Special:Preferences + /// or better yet, Wikipedia's own mobile UIs. Wikipedia is a very + /// conscientiously international and intercultural project with a lot of + /// effort going into it by speakers of many languages, which makes it a + /// useful gold standard for this. + /// + /// This is adapted from: + /// https://github.com/zulip/zulip-mobile/blob/91f5c3289/src/settings/languages.js + /// + /// See also: + /// * [kSelfnamesByLocale], a map for looking up the selfname of a locale, + /// when its [ZulipLocalizations]-specific displayName is not needed. + List languages() { + return [ + (Locale('en'), 'English', languageEn), + (Locale('pl'), 'Polski', languagePl), + (Locale('ru'), 'Русский', languageRu), + (Locale('uk'), 'Українська', languageUk), + ]; + } +} + +typedef Language = (Locale locale, String selfname, String displayName); + +/// The map, of the locales we offer, to their display name in their own +/// locale a.k.a. selfname. +/// +/// See also: +/// * [ZulipLocalizations.languages], similar helper that returns +/// a list of locales with their localized display name in that +/// [ZulipLocalizations] instance and their selfname. +final kSelfnamesByLocale = UnmodifiableMapView(_selfnamesByLocale); +final _selfnamesByLocale = { + // While [ZulipLocalizations.languages]' result will be different depending + // on the language setting, `locale` and `selfname` are the same for all + // languages. This makes it fine to generate this map from + // [GlobalLocalizations.zulipLocalizations], + // despite it being a mutable static field. + for (final (locale, selfname, _) + in GlobalLocalizations.zulipLocalizations.languages()) + locale: selfname, +}; diff --git a/lib/model/schema_versions.g.dart b/lib/model/schema_versions.g.dart index 5712a94fbb..56be3dcd8d 100644 --- a/lib/model/schema_versions.g.dart +++ b/lib/model/schema_versions.g.dart @@ -517,6 +517,84 @@ i1.GeneratedColumn _column_14(String aliasedName) => true, type: i1.DriftSqlType.string, ); + +final class Schema9 extends i0.VersionedSchema { + Schema9({required super.database}) : super(version: 9); + @override + late final List entities = [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + late final Shape6 globalSettings = Shape6( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10, _column_13, _column_14, _column_15], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape3 boolGlobalSettings = Shape3( + source: i0.VersionedTable( + entityName: 'bool_global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: ['PRIMARY KEY(name)'], + columns: [_column_11, _column_12], + attachedDatabase: database, + ), + alias: null, + ); + late final Shape0 accounts = Shape0( + source: i0.VersionedTable( + entityName: 'accounts', + withoutRowId: false, + isStrict: false, + tableConstraints: [ + 'UNIQUE(realm_url, user_id)', + 'UNIQUE(realm_url, email)', + ], + columns: [ + _column_0, + _column_1, + _column_2, + _column_3, + _column_4, + _column_5, + _column_6, + _column_7, + _column_8, + ], + attachedDatabase: database, + ), + alias: null, + ); +} + +class Shape6 extends i0.VersionedTable { + Shape6({required super.source, required super.alias}) : super.aliased(); + i1.GeneratedColumn get themeSetting => + columnsByName['theme_setting']! as i1.GeneratedColumn; + i1.GeneratedColumn get browserPreference => + columnsByName['browser_preference']! as i1.GeneratedColumn; + i1.GeneratedColumn get visitFirstUnread => + columnsByName['visit_first_unread']! as i1.GeneratedColumn; + i1.GeneratedColumn get markReadOnScroll => + columnsByName['mark_read_on_scroll']! as i1.GeneratedColumn; + i1.GeneratedColumn get language => + columnsByName['language']! as i1.GeneratedColumn; +} + +i1.GeneratedColumn _column_15(String aliasedName) => + i1.GeneratedColumn( + 'language', + aliasedName, + true, + type: i1.DriftSqlType.string, + ); i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, @@ -525,6 +603,7 @@ i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema6 schema) from5To6, required Future Function(i1.Migrator m, Schema7 schema) from6To7, required Future Function(i1.Migrator m, Schema8 schema) from7To8, + required Future Function(i1.Migrator m, Schema9 schema) from8To9, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -563,6 +642,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from7To8(migrator, schema); return 8; + case 8: + final schema = Schema9(database: database); + final migrator = i1.Migrator(database, schema); + await from8To9(migrator, schema); + return 9; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -577,6 +661,7 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema6 schema) from5To6, required Future Function(i1.Migrator m, Schema7 schema) from6To7, required Future Function(i1.Migrator m, Schema8 schema) from7To8, + required Future Function(i1.Migrator m, Schema9 schema) from8To9, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, @@ -586,5 +671,6 @@ i1.OnUpgrade stepByStep({ from5To6: from5To6, from6To7: from6To7, from7To8: from7To8, + from8To9: from8To9, ), ); diff --git a/lib/model/settings.dart b/lib/model/settings.dart index 298980a395..e401b830b7 100644 --- a/lib/model/settings.dart +++ b/lib/model/settings.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/foundation.dart'; import '../generated/l10n/zulip_localizations.dart'; @@ -324,6 +326,23 @@ class GlobalSettingsStore extends ChangeNotifier { }; } + /// The user's choice of [Locale]. + /// + /// In most cases, this will be present in the list returned by + /// [ZulipLocalizations.languages] (i.e., one of our supported languages). + /// This might not be true if we migrate from a [Locale] to another for the + /// same language. + /// + /// This is null if the value has never been set. + // TODO check if this [Locale] is supported, and update it in the persistent + // store if this is known to be an alias of a supported [Locale]. + Locale? get language => _data.language; + + /// Set [language], persistently for future runs of the app. + Future setLanguage(Locale value) async { + await _update(GlobalSettingsCompanion(language: Value(value))); + } + /// The user's choice of the given bool-valued setting, or our default for it. /// /// See also [setBool]. diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index b1aa763ac8..5fced7cb8b 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -242,6 +242,7 @@ class _ZulipAppState extends State with WidgetsBindingObserver { onGenerateTitle: (BuildContext context) { return ZulipLocalizations.of(context).zulipAppTitle; }, + locale: GlobalStoreWidget.settingsOf(context).language, localizationsDelegates: ZulipLocalizations.localizationsDelegates, supportedLocales: ZulipLocalizations.supportedLocales, // The context has to be taken from the [Builder] because diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index 394415a8be..4c97a536ac 100644 --- a/lib/widgets/settings.dart +++ b/lib/widgets/settings.dart @@ -1,8 +1,12 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../generated/l10n/zulip_localizations.dart'; +import '../model/localizations.dart'; import '../model/settings.dart'; import 'app_bar.dart'; +import 'icons.dart'; import 'page.dart'; import 'store.dart'; @@ -20,17 +24,18 @@ class SettingsPage extends StatelessWidget { return Scaffold( appBar: ZulipAppBar( title: Text(zulipLocalizations.settingsPageTitle)), - body: Column(children: [ + body: SingleChildScrollView(child: Column(children: [ const _ThemeSetting(), const _BrowserPreferenceSetting(), const _VisitFirstUnreadSetting(), const _MarkReadOnScrollSetting(), + const _LanguageSetting(), if (GlobalSettingsStore.experimentalFeatureFlags.isNotEmpty) ListTile( title: Text(zulipLocalizations.experimentalFeatureSettingsPageTitle), onTap: () => Navigator.push(context, ExperimentalFeaturesPage.buildRoute())) - ])); + ]))); } } @@ -231,6 +236,76 @@ class MarkReadOnScrollSettingPage extends StatelessWidget { } } +class _LanguageSetting extends StatelessWidget { + const _LanguageSetting(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + + Widget? subtitle; + final currentLanguageSelfname = kSelfnamesByLocale[ + GlobalStoreWidget.settingsOf(context).language]; + if (currentLanguageSelfname != null) { + subtitle = Text(currentLanguageSelfname); + } + + return ListTile( + title: Text(zulipLocalizations.languageSettingTitle), + subtitle: subtitle, + onTap: () => Navigator.push(context, _LanguagePage.buildRoute())); + } +} + +class _LanguagePage extends StatelessWidget { + const _LanguagePage(); + + static WidgetRoute buildRoute() { + return MaterialWidgetRoute(page: const _LanguagePage()); + } + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + return Scaffold( + appBar: AppBar( + title: Text(zulipLocalizations.languageSettingTitle)), + body: SingleChildScrollView( + child: Column(children: [ + for (final language in zulipLocalizations.languages()) + _LanguageItem(language: language), + ]))); + } +} + +class _LanguageItem extends StatelessWidget { + const _LanguageItem({required this.language}); + + /// The [Language] this corresponds to, from [ZulipLocalizations.languages]. + final Language language; + + @override + Widget build(BuildContext context) { + final (locale, selfname, displayName) = language; + final isCurrentLanguageInSettings = + locale == GlobalStoreWidget.settingsOf(context).language; + + return ListTile( + title: Text(selfname), + subtitle: Text( + isCurrentLanguageInSettings + ? // Make sure the subtitle text is consistent to the title — since + // displayName (decided by translators) can be different from our + // hard-coded selfname when isCurrentLanguage is true. + selfname + : displayName), + trailing: isCurrentLanguageInSettings ? Icon(ZulipIcons.check) : null, + onTap: () { + unawaited(GlobalStoreWidget.settingsOf(context).setLanguage(locale)); + }); + } +} + class ExperimentalFeaturesPage extends StatelessWidget { const ExperimentalFeaturesPage({super.key}); diff --git a/test/model/database_test.dart b/test/model/database_test.dart index e6e2b729be..2066da2b2d 100644 --- a/test/model/database_test.dart +++ b/test/model/database_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:checks/checks.dart'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; @@ -46,6 +48,20 @@ void main() { check(await db.getGlobalSettings()).themeSetting.equals(ThemeSetting.dark); }); + test('LocaleConverter roundtrips', () async { + Future doCheck(Locale locale) async { + await db.update(db.globalSettings) + .write(GlobalSettingsCompanion(language: Value(locale))); + check(await db.getGlobalSettings()).language.equals(locale); + } + + await doCheck(Locale('en')); + await doCheck(Locale('en', 'GB')); + await doCheck(Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hans')); + await doCheck(Locale.fromSubtags( + languageCode: 'zh', scriptCode: 'Hant', countryCode: 'TW')); + }); + test('BoolGlobalSettings get ignores unknown names', () async { await db.into(db.boolGlobalSettings) .insert(BoolGlobalSettingRow(name: 'nonsense', value: true)); diff --git a/test/model/localizations_test.dart b/test/model/localizations_test.dart new file mode 100644 index 0000000000..be1e9f6d3f --- /dev/null +++ b/test/model/localizations_test.dart @@ -0,0 +1,20 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/generated/l10n/zulip_localizations.dart'; +import 'package:zulip/model/localizations.dart'; + +void main() { + test('kSelfnamesByLocale', () { + for (final locale in kSelfnamesByLocale.keys) { + check(ZulipLocalizations.supportedLocales).contains(locale); + } + }); + + test('languages', () { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + for (final (locale, selfname, _) in zulipLocalizations.languages()) { + check(ZulipLocalizations.supportedLocales).contains(locale); + check(kSelfnamesByLocale)[locale].isNotNull().equals(selfname); + } + }); +} diff --git a/test/model/schemas/drift_schema_v9.json b/test/model/schemas/drift_schema_v9.json new file mode 100644 index 0000000000..97859dbbd5 --- /dev/null +++ b/test/model/schemas/drift_schema_v9.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}},{"name":"visit_first_unread","getter_name":"visitFirstUnread","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(VisitFirstUnreadSetting.values)","dart_type_name":"VisitFirstUnreadSetting"}},{"name":"mark_read_on_scroll","getter_name":"markReadOnScroll","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(MarkReadOnScrollSetting.values)","dart_type_name":"MarkReadOnScrollSetting"}},{"name":"language","getter_name":"language","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const LocaleConverter()","dart_type_name":"Locale"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"references":[],"type":"table","data":{"name":"bool_global_settings","was_declared_in_moor":false,"columns":[{"name":"name","getter_name":"name","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"value","getter_name":"value","moor_type":"bool","nullable":false,"customConstraints":null,"defaultConstraints":"CHECK (\"value\" IN (0, 1))","dialectAwareDefaultConstraints":{"sqlite":"CHECK (\"value\" IN (0, 1))"},"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"explicit_pk":["name"]}},{"id":2,"references":[],"type":"table","data":{"name":"accounts","was_declared_in_moor":false,"columns":[{"name":"id","getter_name":"id","moor_type":"int","nullable":false,"customConstraints":null,"defaultConstraints":"PRIMARY KEY AUTOINCREMENT","dialectAwareDefaultConstraints":{"sqlite":"PRIMARY KEY AUTOINCREMENT"},"default_dart":null,"default_client_dart":null,"dsl_features":["auto-increment"]},{"name":"realm_url","getter_name":"realmUrl","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const UriConverter()","dart_type_name":"Uri"}},{"name":"user_id","getter_name":"userId","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"email","getter_name":"email","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"api_key","getter_name":"apiKey","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_version","getter_name":"zulipVersion","moor_type":"string","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_merge_base","getter_name":"zulipMergeBase","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"zulip_feature_level","getter_name":"zulipFeatureLevel","moor_type":"int","nullable":false,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]},{"name":"acked_push_token","getter_name":"ackedPushToken","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[]}],"is_virtual":false,"without_rowid":false,"constraints":[],"unique_keys":[["realm_url","user_id"],["realm_url","email"]]}}]} \ No newline at end of file diff --git a/test/model/schemas/schema.dart b/test/model/schemas/schema.dart index 746206e453..413b4408c4 100644 --- a/test/model/schemas/schema.dart +++ b/test/model/schemas/schema.dart @@ -11,6 +11,7 @@ import 'schema_v5.dart' as v5; import 'schema_v6.dart' as v6; import 'schema_v7.dart' as v7; import 'schema_v8.dart' as v8; +import 'schema_v9.dart' as v9; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -32,10 +33,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v7.DatabaseAtV7(db); case 8: return v8.DatabaseAtV8(db); + case 9: + return v9.DatabaseAtV9(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4, 5, 6, 7, 8]; + static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9]; } diff --git a/test/model/schemas/schema_v9.dart b/test/model/schemas/schema_v9.dart new file mode 100644 index 0000000000..d35d417cd9 --- /dev/null +++ b/test/model/schemas/schema_v9.dart @@ -0,0 +1,1006 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn visitFirstUnread = GeneratedColumn( + 'visit_first_unread', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn markReadOnScroll = GeneratedColumn( + 'mark_read_on_scroll', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn language = GeneratedColumn( + 'language', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + language, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + visitFirstUnread: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}visit_first_unread'], + ), + markReadOnScroll: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}mark_read_on_scroll'], + ), + language: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}language'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + final String? visitFirstUnread; + final String? markReadOnScroll; + final String? language; + const GlobalSettingsData({ + this.themeSetting, + this.browserPreference, + this.visitFirstUnread, + this.markReadOnScroll, + this.language, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + if (!nullToAbsent || visitFirstUnread != null) { + map['visit_first_unread'] = Variable(visitFirstUnread); + } + if (!nullToAbsent || markReadOnScroll != null) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll); + } + if (!nullToAbsent || language != null) { + map['language'] = Variable(language); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + visitFirstUnread: visitFirstUnread == null && nullToAbsent + ? const Value.absent() + : Value(visitFirstUnread), + markReadOnScroll: markReadOnScroll == null && nullToAbsent + ? const Value.absent() + : Value(markReadOnScroll), + language: language == null && nullToAbsent + ? const Value.absent() + : Value(language), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + visitFirstUnread: serializer.fromJson(json['visitFirstUnread']), + markReadOnScroll: serializer.fromJson(json['markReadOnScroll']), + language: serializer.fromJson(json['language']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + 'visitFirstUnread': serializer.toJson(visitFirstUnread), + 'markReadOnScroll': serializer.toJson(markReadOnScroll), + 'language': serializer.toJson(language), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value visitFirstUnread = const Value.absent(), + Value markReadOnScroll = const Value.absent(), + Value language = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: browserPreference.present + ? browserPreference.value + : this.browserPreference, + visitFirstUnread: visitFirstUnread.present + ? visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: markReadOnScroll.present + ? markReadOnScroll.value + : this.markReadOnScroll, + language: language.present ? language.value : this.language, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + visitFirstUnread: data.visitFirstUnread.present + ? data.visitFirstUnread.value + : this.visitFirstUnread, + markReadOnScroll: data.markReadOnScroll.present + ? data.markReadOnScroll.value + : this.markReadOnScroll, + language: data.language.present ? data.language.value : this.language, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('language: $language') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + themeSetting, + browserPreference, + visitFirstUnread, + markReadOnScroll, + language, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference && + other.visitFirstUnread == this.visitFirstUnread && + other.markReadOnScroll == this.markReadOnScroll && + other.language == this.language); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value visitFirstUnread; + final Value markReadOnScroll; + final Value language; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.language = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.visitFirstUnread = const Value.absent(), + this.markReadOnScroll = const Value.absent(), + this.language = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? visitFirstUnread, + Expression? markReadOnScroll, + Expression? language, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (visitFirstUnread != null) 'visit_first_unread': visitFirstUnread, + if (markReadOnScroll != null) 'mark_read_on_scroll': markReadOnScroll, + if (language != null) 'language': language, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? visitFirstUnread, + Value? markReadOnScroll, + Value? language, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + visitFirstUnread: visitFirstUnread ?? this.visitFirstUnread, + markReadOnScroll: markReadOnScroll ?? this.markReadOnScroll, + language: language ?? this.language, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (visitFirstUnread.present) { + map['visit_first_unread'] = Variable(visitFirstUnread.value); + } + if (markReadOnScroll.present) { + map['mark_read_on_scroll'] = Variable(markReadOnScroll.value); + } + if (language.present) { + map['language'] = Variable(language.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..write('visitFirstUnread: $visitFirstUnread, ') + ..write('markReadOnScroll: $markReadOnScroll, ') + ..write('language: $language, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class BoolGlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + BoolGlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn name = GeneratedColumn( + 'name', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn value = GeneratedColumn( + 'value', + aliasedName, + false, + type: DriftSqlType.bool, + requiredDuringInsert: true, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'CHECK ("value" IN (0, 1))', + ), + ); + @override + List get $columns => [name, value]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'bool_global_settings'; + @override + Set get $primaryKey => {name}; + @override + BoolGlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return BoolGlobalSettingsData( + name: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}name'], + )!, + value: attachedDatabase.typeMapping.read( + DriftSqlType.bool, + data['${effectivePrefix}value'], + )!, + ); + } + + @override + BoolGlobalSettings createAlias(String alias) { + return BoolGlobalSettings(attachedDatabase, alias); + } +} + +class BoolGlobalSettingsData extends DataClass + implements Insertable { + final String name; + final bool value; + const BoolGlobalSettingsData({required this.name, required this.value}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['name'] = Variable(name); + map['value'] = Variable(value); + return map; + } + + BoolGlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return BoolGlobalSettingsCompanion(name: Value(name), value: Value(value)); + } + + factory BoolGlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return BoolGlobalSettingsData( + name: serializer.fromJson(json['name']), + value: serializer.fromJson(json['value']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'name': serializer.toJson(name), + 'value': serializer.toJson(value), + }; + } + + BoolGlobalSettingsData copyWith({String? name, bool? value}) => + BoolGlobalSettingsData( + name: name ?? this.name, + value: value ?? this.value, + ); + BoolGlobalSettingsData copyWithCompanion(BoolGlobalSettingsCompanion data) { + return BoolGlobalSettingsData( + name: data.name.present ? data.name.value : this.name, + value: data.value.present ? data.value.value : this.value, + ); + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsData(') + ..write('name: $name, ') + ..write('value: $value') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(name, value); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is BoolGlobalSettingsData && + other.name == this.name && + other.value == this.value); +} + +class BoolGlobalSettingsCompanion + extends UpdateCompanion { + final Value name; + final Value value; + final Value rowid; + const BoolGlobalSettingsCompanion({ + this.name = const Value.absent(), + this.value = const Value.absent(), + this.rowid = const Value.absent(), + }); + BoolGlobalSettingsCompanion.insert({ + required String name, + required bool value, + this.rowid = const Value.absent(), + }) : name = Value(name), + value = Value(value); + static Insertable custom({ + Expression? name, + Expression? value, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (name != null) 'name': name, + if (value != null) 'value': value, + if (rowid != null) 'rowid': rowid, + }); + } + + BoolGlobalSettingsCompanion copyWith({ + Value? name, + Value? value, + Value? rowid, + }) { + return BoolGlobalSettingsCompanion( + name: name ?? this.name, + value: value ?? this.value, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (name.present) { + map['name'] = Variable(name.value); + } + if (value.present) { + map['value'] = Variable(value.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('BoolGlobalSettingsCompanion(') + ..write('name: $name, ') + ..write('value: $value, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Accounts extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Accounts(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + hasAutoIncrement: true, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultConstraints: GeneratedColumn.constraintIsAlways( + 'PRIMARY KEY AUTOINCREMENT', + ), + ); + late final GeneratedColumn realmUrl = GeneratedColumn( + 'realm_url', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn userId = GeneratedColumn( + 'user_id', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn email = GeneratedColumn( + 'email', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn apiKey = GeneratedColumn( + 'api_key', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipVersion = GeneratedColumn( + 'zulip_version', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + late final GeneratedColumn zulipMergeBase = GeneratedColumn( + 'zulip_merge_base', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn zulipFeatureLevel = GeneratedColumn( + 'zulip_feature_level', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + late final GeneratedColumn ackedPushToken = GeneratedColumn( + 'acked_push_token', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'accounts'; + @override + Set get $primaryKey => {id}; + @override + List> get uniqueKeys => [ + {realmUrl, userId}, + {realmUrl, email}, + ]; + @override + AccountsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return AccountsData( + id: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}id'], + )!, + realmUrl: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}realm_url'], + )!, + userId: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}user_id'], + )!, + email: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}email'], + )!, + apiKey: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}api_key'], + )!, + zulipVersion: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_version'], + )!, + zulipMergeBase: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}zulip_merge_base'], + ), + zulipFeatureLevel: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}zulip_feature_level'], + )!, + ackedPushToken: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}acked_push_token'], + ), + ); + } + + @override + Accounts createAlias(String alias) { + return Accounts(attachedDatabase, alias); + } +} + +class AccountsData extends DataClass implements Insertable { + final int id; + final String realmUrl; + final int userId; + final String email; + final String apiKey; + final String zulipVersion; + final String? zulipMergeBase; + final int zulipFeatureLevel; + final String? ackedPushToken; + const AccountsData({ + required this.id, + required this.realmUrl, + required this.userId, + required this.email, + required this.apiKey, + required this.zulipVersion, + this.zulipMergeBase, + required this.zulipFeatureLevel, + this.ackedPushToken, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['realm_url'] = Variable(realmUrl); + map['user_id'] = Variable(userId); + map['email'] = Variable(email); + map['api_key'] = Variable(apiKey); + map['zulip_version'] = Variable(zulipVersion); + if (!nullToAbsent || zulipMergeBase != null) { + map['zulip_merge_base'] = Variable(zulipMergeBase); + } + map['zulip_feature_level'] = Variable(zulipFeatureLevel); + if (!nullToAbsent || ackedPushToken != null) { + map['acked_push_token'] = Variable(ackedPushToken); + } + return map; + } + + AccountsCompanion toCompanion(bool nullToAbsent) { + return AccountsCompanion( + id: Value(id), + realmUrl: Value(realmUrl), + userId: Value(userId), + email: Value(email), + apiKey: Value(apiKey), + zulipVersion: Value(zulipVersion), + zulipMergeBase: zulipMergeBase == null && nullToAbsent + ? const Value.absent() + : Value(zulipMergeBase), + zulipFeatureLevel: Value(zulipFeatureLevel), + ackedPushToken: ackedPushToken == null && nullToAbsent + ? const Value.absent() + : Value(ackedPushToken), + ); + } + + factory AccountsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return AccountsData( + id: serializer.fromJson(json['id']), + realmUrl: serializer.fromJson(json['realmUrl']), + userId: serializer.fromJson(json['userId']), + email: serializer.fromJson(json['email']), + apiKey: serializer.fromJson(json['apiKey']), + zulipVersion: serializer.fromJson(json['zulipVersion']), + zulipMergeBase: serializer.fromJson(json['zulipMergeBase']), + zulipFeatureLevel: serializer.fromJson(json['zulipFeatureLevel']), + ackedPushToken: serializer.fromJson(json['ackedPushToken']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'realmUrl': serializer.toJson(realmUrl), + 'userId': serializer.toJson(userId), + 'email': serializer.toJson(email), + 'apiKey': serializer.toJson(apiKey), + 'zulipVersion': serializer.toJson(zulipVersion), + 'zulipMergeBase': serializer.toJson(zulipMergeBase), + 'zulipFeatureLevel': serializer.toJson(zulipFeatureLevel), + 'ackedPushToken': serializer.toJson(ackedPushToken), + }; + } + + AccountsData copyWith({ + int? id, + String? realmUrl, + int? userId, + String? email, + String? apiKey, + String? zulipVersion, + Value zulipMergeBase = const Value.absent(), + int? zulipFeatureLevel, + Value ackedPushToken = const Value.absent(), + }) => AccountsData( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase.present + ? zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken.present + ? ackedPushToken.value + : this.ackedPushToken, + ); + AccountsData copyWithCompanion(AccountsCompanion data) { + return AccountsData( + id: data.id.present ? data.id.value : this.id, + realmUrl: data.realmUrl.present ? data.realmUrl.value : this.realmUrl, + userId: data.userId.present ? data.userId.value : this.userId, + email: data.email.present ? data.email.value : this.email, + apiKey: data.apiKey.present ? data.apiKey.value : this.apiKey, + zulipVersion: data.zulipVersion.present + ? data.zulipVersion.value + : this.zulipVersion, + zulipMergeBase: data.zulipMergeBase.present + ? data.zulipMergeBase.value + : this.zulipMergeBase, + zulipFeatureLevel: data.zulipFeatureLevel.present + ? data.zulipFeatureLevel.value + : this.zulipFeatureLevel, + ackedPushToken: data.ackedPushToken.present + ? data.ackedPushToken.value + : this.ackedPushToken, + ); + } + + @override + String toString() { + return (StringBuffer('AccountsData(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + realmUrl, + userId, + email, + apiKey, + zulipVersion, + zulipMergeBase, + zulipFeatureLevel, + ackedPushToken, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is AccountsData && + other.id == this.id && + other.realmUrl == this.realmUrl && + other.userId == this.userId && + other.email == this.email && + other.apiKey == this.apiKey && + other.zulipVersion == this.zulipVersion && + other.zulipMergeBase == this.zulipMergeBase && + other.zulipFeatureLevel == this.zulipFeatureLevel && + other.ackedPushToken == this.ackedPushToken); +} + +class AccountsCompanion extends UpdateCompanion { + final Value id; + final Value realmUrl; + final Value userId; + final Value email; + final Value apiKey; + final Value zulipVersion; + final Value zulipMergeBase; + final Value zulipFeatureLevel; + final Value ackedPushToken; + const AccountsCompanion({ + this.id = const Value.absent(), + this.realmUrl = const Value.absent(), + this.userId = const Value.absent(), + this.email = const Value.absent(), + this.apiKey = const Value.absent(), + this.zulipVersion = const Value.absent(), + this.zulipMergeBase = const Value.absent(), + this.zulipFeatureLevel = const Value.absent(), + this.ackedPushToken = const Value.absent(), + }); + AccountsCompanion.insert({ + this.id = const Value.absent(), + required String realmUrl, + required int userId, + required String email, + required String apiKey, + required String zulipVersion, + this.zulipMergeBase = const Value.absent(), + required int zulipFeatureLevel, + this.ackedPushToken = const Value.absent(), + }) : realmUrl = Value(realmUrl), + userId = Value(userId), + email = Value(email), + apiKey = Value(apiKey), + zulipVersion = Value(zulipVersion), + zulipFeatureLevel = Value(zulipFeatureLevel); + static Insertable custom({ + Expression? id, + Expression? realmUrl, + Expression? userId, + Expression? email, + Expression? apiKey, + Expression? zulipVersion, + Expression? zulipMergeBase, + Expression? zulipFeatureLevel, + Expression? ackedPushToken, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (realmUrl != null) 'realm_url': realmUrl, + if (userId != null) 'user_id': userId, + if (email != null) 'email': email, + if (apiKey != null) 'api_key': apiKey, + if (zulipVersion != null) 'zulip_version': zulipVersion, + if (zulipMergeBase != null) 'zulip_merge_base': zulipMergeBase, + if (zulipFeatureLevel != null) 'zulip_feature_level': zulipFeatureLevel, + if (ackedPushToken != null) 'acked_push_token': ackedPushToken, + }); + } + + AccountsCompanion copyWith({ + Value? id, + Value? realmUrl, + Value? userId, + Value? email, + Value? apiKey, + Value? zulipVersion, + Value? zulipMergeBase, + Value? zulipFeatureLevel, + Value? ackedPushToken, + }) { + return AccountsCompanion( + id: id ?? this.id, + realmUrl: realmUrl ?? this.realmUrl, + userId: userId ?? this.userId, + email: email ?? this.email, + apiKey: apiKey ?? this.apiKey, + zulipVersion: zulipVersion ?? this.zulipVersion, + zulipMergeBase: zulipMergeBase ?? this.zulipMergeBase, + zulipFeatureLevel: zulipFeatureLevel ?? this.zulipFeatureLevel, + ackedPushToken: ackedPushToken ?? this.ackedPushToken, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (realmUrl.present) { + map['realm_url'] = Variable(realmUrl.value); + } + if (userId.present) { + map['user_id'] = Variable(userId.value); + } + if (email.present) { + map['email'] = Variable(email.value); + } + if (apiKey.present) { + map['api_key'] = Variable(apiKey.value); + } + if (zulipVersion.present) { + map['zulip_version'] = Variable(zulipVersion.value); + } + if (zulipMergeBase.present) { + map['zulip_merge_base'] = Variable(zulipMergeBase.value); + } + if (zulipFeatureLevel.present) { + map['zulip_feature_level'] = Variable(zulipFeatureLevel.value); + } + if (ackedPushToken.present) { + map['acked_push_token'] = Variable(ackedPushToken.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('AccountsCompanion(') + ..write('id: $id, ') + ..write('realmUrl: $realmUrl, ') + ..write('userId: $userId, ') + ..write('email: $email, ') + ..write('apiKey: $apiKey, ') + ..write('zulipVersion: $zulipVersion, ') + ..write('zulipMergeBase: $zulipMergeBase, ') + ..write('zulipFeatureLevel: $zulipFeatureLevel, ') + ..write('ackedPushToken: $ackedPushToken') + ..write(')')) + .toString(); + } +} + +class DatabaseAtV9 extends GeneratedDatabase { + DatabaseAtV9(QueryExecutor e) : super(e); + late final GlobalSettings globalSettings = GlobalSettings(this); + late final BoolGlobalSettings boolGlobalSettings = BoolGlobalSettings(this); + late final Accounts accounts = Accounts(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + globalSettings, + boolGlobalSettings, + accounts, + ]; + @override + int get schemaVersion => 9; +} diff --git a/test/model/settings_test.dart b/test/model/settings_test.dart index b4842ecd04..cda3287598 100644 --- a/test/model/settings_test.dart +++ b/test/model/settings_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -83,6 +85,14 @@ void main() { // TODO(#1583) test markReadOnScroll applies default // TODO(#1583) test markReadOnScrollForNarrow + test('setLanguage smoke', () async { + final globalSettings = eg.globalStore().settings; + check(globalSettings).language.isNull(); + + await globalSettings.setLanguage(Locale('en')); + check(globalSettings).language.equals(Locale('en')); + }); + group('getBool/setBool', () { test('get from default', () { final globalSettings = eg.globalStore(boolGlobalSettings: {}).settings; diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index 93e24dffdd..e985f195aa 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:checks/checks.dart'; import 'package:zulip/api/core.dart'; import 'package:zulip/api/model/initial_snapshot.dart'; @@ -13,6 +15,7 @@ import 'package:zulip/model/unreads.dart'; extension GlobalSettingsDataChecks on Subject { Subject get themeSetting => has((x) => x.themeSetting, 'themeSetting'); Subject get browserPreference => has((x) => x.browserPreference, 'browserPreference'); + Subject get language => has((x) => x.language, 'language'); } extension AccountChecks on Subject { @@ -32,6 +35,7 @@ extension GlobalSettingsStoreChecks on Subject { Subject get browserPreference => has((x) => x.browserPreference, 'browserPreference'); Subject get effectiveBrowserPreference => has((x) => x.effectiveBrowserPreference, 'effectiveBrowserPreference'); Subject getUrlLaunchMode(Uri url) => has((x) => x.getUrlLaunchMode(url), 'getUrlLaunchMode'); + Subject get language => has((x) => x.language, 'language'); Subject getBool(BoolGlobalSetting setting) => has((x) => x.getBool(setting), 'getBool(${setting.name}'); } diff --git a/test/widgets/app_test.dart b/test/widgets/app_test.dart index 7b1388dbec..f80c8a3e49 100644 --- a/test/widgets/app_test.dart +++ b/test/widgets/app_test.dart @@ -23,6 +23,37 @@ import 'test_app.dart'; void main() { TestZulipBinding.ensureInitialized(); + group('ZulipApp locale', () { + Future prepare(WidgetTester tester, {required Locale? locale}) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + if (locale != null) { + await testBinding.globalStore.settings.setLanguage(locale); + } + await tester.pumpWidget(ZulipApp()); + await tester.pump(); + await tester.pump(); + } + + testWidgets('no language is set', (tester) async { + await prepare(tester, locale: null); + final element = tester.element(find.byType(HomePage)); + check(Localizations.localeOf(element)).equals(const Locale('en')); + }); + + testWidgets('has language set', (tester) async { + await prepare(tester, locale: const Locale('uk')); + final element = tester.element(find.byType(HomePage)); + check(Localizations.localeOf(element)).equals(const Locale('uk')); + }); + + testWidgets('has unsupported language set', (tester) async { + await prepare(tester, locale: const Locale('zxx')); + final element = tester.element(find.byType(HomePage)); + check(Localizations.localeOf(element)).equals(const Locale('en')); + }); + }); + group('ZulipApp initial navigation', () { late List> pushedRoutes = []; diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart index 96fd62feeb..d3c6738280 100644 --- a/test/widgets/settings_test.dart +++ b/test/widgets/settings_test.dart @@ -1,8 +1,10 @@ import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/model/settings.dart'; +import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/settings.dart'; import '../flutter_checks.dart'; @@ -129,6 +131,75 @@ void main() { // TODO(#1571): test visitFirstUnread setting UI + group('language setting', () { + Finder languageListTileFinder = find.ancestor( + of: find.text('Language'), matching: find.byType(ListTile)); + + Subject checkAmbientLocale(WidgetTester tester) => + check(Localizations.localeOf(tester.element(find.byType(SettingsPage)))); + + testWidgets('on SettingsPage, when no language is set', (tester) async { + await prepare(tester); + checkAmbientLocale(tester).equals(const Locale('en')); + + assert(testBinding.globalStore.settings.language == null); + await tester.pump(); + check(languageListTileFinder).findsOne(); + check(find.text('English')).findsNothing(); + }); + + testWidgets('on SettingsPage, when a language is set', (tester) async { + await prepare(tester); + checkAmbientLocale(tester).equals(const Locale('en')); + + await testBinding.globalStore.settings.setLanguage(const Locale('en')); + await tester.pump(); + check(find.descendant( + of: languageListTileFinder, matching: find.text('English'))).findsOne(); + }); + + testWidgets('LanguagePage smoke', (tester) async { + await prepare(tester); + await tester.tap(languageListTileFinder); + await tester.pump(); + await tester.pump(); + check(find.text('Polski').hitTestable()).findsOne(); + check(find.text('Polish')).findsOne(); + check(find.byIcon(ZulipIcons.check)).findsNothing(); + checkAmbientLocale(tester).equals(const Locale('en')); + check(testBinding.globalStore).settings.language.isNull(); + + await tester.tap(find.text('Polish')); + await tester.pump(); + check(find.text('Polski').hitTestable()).findsExactly(2); + check(find.text('Polish')).findsNothing(); + check(find.descendant( + of: find.widgetWithText(ListTile, 'Polski'), + matching: find.byIcon(ZulipIcons.check)), + ).findsOne(); + checkAmbientLocale(tester).equals(const Locale('pl')); + check(testBinding.globalStore).settings.language.equals(const Locale('pl')); + }); + + testWidgets('handle unsupported (but valid) locale stored in database', (tester) async { + await prepare(tester); + // https://www.loc.gov/standards/iso639-2/php/code_list.php + await testBinding.globalStore.settings.setLanguage(const Locale('zxx')); + await tester.pumpAndSettle(); // expect no errors + checkAmbientLocale(tester).equals(const Locale('en')); + + await tester.tap(languageListTileFinder); + await tester.pump(); + await tester.pump(); + check(find.byIcon(ZulipIcons.check)).findsNothing(); + + await tester.tap(find.text('Polish')); + await tester.pump(); + checkAmbientLocale(tester).equals(const Locale('pl')); + check(testBinding.globalStore).settings.language.equals(const Locale('pl')); + }); + }); + // TODO maybe test GlobalSettingType.experimentalFeatureFlag settings // Or maybe not; after all, it's a developer-facing feature, so // should be low risk. diff --git a/test/widgets/test_app.dart b/test/widgets/test_app.dart index 5cec418cdd..5e130d0bf1 100644 --- a/test/widgets/test_app.dart +++ b/test/widgets/test_app.dart @@ -71,6 +71,7 @@ class TestZulipApp extends StatelessWidget { return MaterialApp( title: 'Zulip', + locale: GlobalStoreWidget.settingsOf(context).language, localizationsDelegates: ZulipLocalizations.localizationsDelegates, supportedLocales: ZulipLocalizations.supportedLocales, // The context has to be taken from the [Builder] because