From c130a80e435772e57702072b44a2410f54ad0565 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Wed, 14 May 2025 18:36:54 -0400 Subject: [PATCH 1/5] i18n: Maintain a list of supported languages with metadata This is similar to what we did in zulip-mobile: https://github.com/zulip/zulip-mobile/blob/91f5c3289/src/settings/languages.js The difference here being that the display names come from a live ZulipLocalizations instance, so we can't just hardcode the list with literal strings. --- assets/l10n/app_en.arb | 16 +++++ docs/translation.md | 22 +++++++ lib/generated/l10n/zulip_localizations.dart | 24 +++++++ .../l10n/zulip_localizations_ar.dart | 12 ++++ .../l10n/zulip_localizations_de.dart | 12 ++++ .../l10n/zulip_localizations_en.dart | 12 ++++ .../l10n/zulip_localizations_it.dart | 12 ++++ .../l10n/zulip_localizations_ja.dart | 12 ++++ .../l10n/zulip_localizations_nb.dart | 12 ++++ .../l10n/zulip_localizations_pl.dart | 12 ++++ .../l10n/zulip_localizations_ru.dart | 12 ++++ .../l10n/zulip_localizations_sk.dart | 12 ++++ .../l10n/zulip_localizations_sl.dart | 12 ++++ .../l10n/zulip_localizations_uk.dart | 12 ++++ .../l10n/zulip_localizations_zh.dart | 12 ++++ lib/model/localizations.dart | 63 +++++++++++++++++++ test/model/localizations_test.dart | 20 ++++++ 17 files changed, 289 insertions(+) create mode 100644 test/model/localizations_test.dart diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index e03f421761..154dae4e26 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -943,6 +943,22 @@ "@openLinksWithInAppBrowser": { "description": "Label for toggling setting to open links with in-app browser" }, + "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..b9884cb131 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1407,6 +1407,30 @@ abstract class ZulipLocalizations { /// **'Open links with in-app browser'** String get openLinksWithInAppBrowser; + /// 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..91d3b9208a 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -767,6 +767,18 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @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..e3ac49c8e6 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -767,6 +767,18 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @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..a9131fcc1b 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -767,6 +767,18 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @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..8bde48af83 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -773,6 +773,18 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @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..23117d2556 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -767,6 +767,18 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @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..6900ef68a6 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -767,6 +767,18 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @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..b610fd6d40 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -777,6 +777,18 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Otwieraj odnośniki w aplikacji'; + @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..eb23035cb4 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -781,6 +781,18 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Открывать ссылки внутри приложения'; + @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..a4cda7f5f7 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -769,6 +769,18 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @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..e9bea968f5 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -789,6 +789,18 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get openLinksWithInAppBrowser => 'Odpri povezave v brskalniku znotraj aplikacije'; + @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..590820ba46 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -781,6 +781,18 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get openLinksWithInAppBrowser => 'Відкривати посилання за допомогою браузера додатку'; + @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..3be6b4efcc 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -767,6 +767,18 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @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/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/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); + } + }); +} From c5787be7df59616d40487f673da10cedb276048d Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 15 May 2025 14:24:34 -0400 Subject: [PATCH 2/5] db: Store language global setting in database An alternative to implementing _fromLanguageTag is using the LocaleParser from package:intl/locale.dart. In this case though, the values to parse always come from `toLanguageTag`, so we can utilize that knowledge to get away with a more simple implementation, that should also be handy upstream. The locale stored in the database is not guaranteed to be one of our supported value, especially if we need to rename locales in the future. See discussion: https://github.com/zulip/zulip-flutter/pull/1513#issuecomment-2899633541 --- lib/model/database.dart | 62 +- lib/model/database.g.dart | 75 +- lib/model/schema_versions.g.dart | 86 ++ lib/model/settings.dart | 19 + test/model/database_test.dart | 16 + test/model/schemas/drift_schema_v9.json | 1 + test/model/schemas/schema.dart | 5 +- test/model/schemas/schema_v9.dart | 1006 +++++++++++++++++++++++ test/model/settings_test.dart | 10 + test/model/store_checks.dart | 4 + 10 files changed, 1280 insertions(+), 4 deletions(-) create mode 100644 test/model/schemas/drift_schema_v9.json create mode 100644 test/model/schemas/schema_v9.dart 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/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/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/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}'); } From c97927906b01aea04bd2f9bee0bde8c3a6f6e1f4 Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Fri, 16 May 2025 17:15:09 -0400 Subject: [PATCH 3/5] app: Override locale with language global setting This doesn't really have user-facing changes yet, because they cannot set the language. In this case, ZulipApp.locale is null, and localization will follow system setting, like we did before this change. This behavior is an improvement compared to the legacy app, which just uses English (en) when language is not set. --- lib/widgets/app.dart | 1 + test/widgets/app_test.dart | 31 +++++++++++++++++++++++++++++++ test/widgets/test_app.dart | 1 + 3 files changed, 33 insertions(+) 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/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/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 From 9561dd963d892485fd8c3ee889b7acd1aefd36bd Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Sat, 14 Jun 2025 22:17:30 -0400 Subject: [PATCH 4/5] settings: Make settings scrollable Otherwise, the settings can overflow at the bottom in landscape mode. --- lib/widgets/settings.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index 394415a8be..28826b926f 100644 --- a/lib/widgets/settings.dart +++ b/lib/widgets/settings.dart @@ -20,7 +20,7 @@ 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(), @@ -30,7 +30,7 @@ class SettingsPage extends StatelessWidget { title: Text(zulipLocalizations.experimentalFeatureSettingsPageTitle), onTap: () => Navigator.push(context, ExperimentalFeaturesPage.buildRoute())) - ])); + ]))); } } From d0bc01e2e0273d4c250897e46fe8d7f428dbb0df Mon Sep 17 00:00:00 2001 From: Zixuan James Li Date: Thu, 15 May 2025 14:58:24 -0400 Subject: [PATCH 5/5] settings: Add language setting Since there is no Figma design for the settings page yet, the design is kept simple while mostly matching zulip-mobile: we show both selfname and name of each available language option, and leave out the search funtionality. We don't allow unsetting the language once it is set, but that can easily change. Fixes: #1139 --- assets/l10n/app_en.arb | 4 + lib/generated/l10n/zulip_localizations.dart | 6 ++ .../l10n/zulip_localizations_ar.dart | 3 + .../l10n/zulip_localizations_de.dart | 3 + .../l10n/zulip_localizations_en.dart | 3 + .../l10n/zulip_localizations_it.dart | 3 + .../l10n/zulip_localizations_ja.dart | 3 + .../l10n/zulip_localizations_nb.dart | 3 + .../l10n/zulip_localizations_pl.dart | 3 + .../l10n/zulip_localizations_ru.dart | 3 + .../l10n/zulip_localizations_sk.dart | 3 + .../l10n/zulip_localizations_sl.dart | 3 + .../l10n/zulip_localizations_uk.dart | 3 + .../l10n/zulip_localizations_zh.dart | 3 + lib/widgets/settings.dart | 75 +++++++++++++++++++ test/widgets/settings_test.dart | 71 ++++++++++++++++++ 16 files changed, 192 insertions(+) diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 154dae4e26..e765fa4ea0 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -943,6 +943,10 @@ "@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." diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart index b9884cb131..26ca909743 100644 --- a/lib/generated/l10n/zulip_localizations.dart +++ b/lib/generated/l10n/zulip_localizations.dart @@ -1407,6 +1407,12 @@ 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: diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart index 91d3b9208a..94342640bd 100644 --- a/lib/generated/l10n/zulip_localizations_ar.dart +++ b/lib/generated/l10n/zulip_localizations_ar.dart @@ -767,6 +767,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + @override String get languageEn => 'English'; diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart index e3ac49c8e6..f148d08418 100644 --- a/lib/generated/l10n/zulip_localizations_de.dart +++ b/lib/generated/l10n/zulip_localizations_de.dart @@ -767,6 +767,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + @override String get languageEn => 'English'; diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart index a9131fcc1b..7c775d9bdf 100644 --- a/lib/generated/l10n/zulip_localizations_en.dart +++ b/lib/generated/l10n/zulip_localizations_en.dart @@ -767,6 +767,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + @override String get languageEn => 'English'; diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart index 8bde48af83..c019ab07e1 100644 --- a/lib/generated/l10n/zulip_localizations_it.dart +++ b/lib/generated/l10n/zulip_localizations_it.dart @@ -773,6 +773,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + @override String get languageEn => 'English'; diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart index 23117d2556..a4030595f6 100644 --- a/lib/generated/l10n/zulip_localizations_ja.dart +++ b/lib/generated/l10n/zulip_localizations_ja.dart @@ -767,6 +767,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + @override String get languageEn => 'English'; diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart index 6900ef68a6..6c3f94658e 100644 --- a/lib/generated/l10n/zulip_localizations_nb.dart +++ b/lib/generated/l10n/zulip_localizations_nb.dart @@ -767,6 +767,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + @override String get languageEn => 'English'; diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart index b610fd6d40..f8d15dc614 100644 --- a/lib/generated/l10n/zulip_localizations_pl.dart +++ b/lib/generated/l10n/zulip_localizations_pl.dart @@ -777,6 +777,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Otwieraj odnośniki w aplikacji'; + @override + String get languageSettingTitle => 'Language'; + @override String get languageEn => 'English'; diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart index eb23035cb4..b9c3081824 100644 --- a/lib/generated/l10n/zulip_localizations_ru.dart +++ b/lib/generated/l10n/zulip_localizations_ru.dart @@ -781,6 +781,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Открывать ссылки внутри приложения'; + @override + String get languageSettingTitle => 'Language'; + @override String get languageEn => 'English'; diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart index a4cda7f5f7..e78d100f30 100644 --- a/lib/generated/l10n/zulip_localizations_sk.dart +++ b/lib/generated/l10n/zulip_localizations_sk.dart @@ -769,6 +769,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + @override String get languageEn => 'English'; diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart index e9bea968f5..e8055c7320 100644 --- a/lib/generated/l10n/zulip_localizations_sl.dart +++ b/lib/generated/l10n/zulip_localizations_sl.dart @@ -789,6 +789,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations { String get openLinksWithInAppBrowser => 'Odpri povezave v brskalniku znotraj aplikacije'; + @override + String get languageSettingTitle => 'Language'; + @override String get languageEn => 'English'; diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart index 590820ba46..4278ff3297 100644 --- a/lib/generated/l10n/zulip_localizations_uk.dart +++ b/lib/generated/l10n/zulip_localizations_uk.dart @@ -781,6 +781,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations { String get openLinksWithInAppBrowser => 'Відкривати посилання за допомогою браузера додатку'; + @override + String get languageSettingTitle => 'Language'; + @override String get languageEn => 'English'; diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart index 3be6b4efcc..f915137801 100644 --- a/lib/generated/l10n/zulip_localizations_zh.dart +++ b/lib/generated/l10n/zulip_localizations_zh.dart @@ -767,6 +767,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations { @override String get openLinksWithInAppBrowser => 'Open links with in-app browser'; + @override + String get languageSettingTitle => 'Language'; + @override String get languageEn => 'English'; diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index 28826b926f..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'; @@ -25,6 +29,7 @@ class SettingsPage extends StatelessWidget { const _BrowserPreferenceSetting(), const _VisitFirstUnreadSetting(), const _MarkReadOnScrollSetting(), + const _LanguageSetting(), if (GlobalSettingsStore.experimentalFeatureFlags.isNotEmpty) ListTile( title: Text(zulipLocalizations.experimentalFeatureSettingsPageTitle), @@ -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/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.