diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index edfb88410b..df2c4ab947 100644
Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ
diff --git a/assets/icons/settings.svg b/assets/icons/settings.svg
new file mode 100644
index 0000000000..202eb2deaf
--- /dev/null
+++ b/assets/icons/settings.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index f453ef1adc..7f37a00dad 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -19,6 +19,10 @@
"@chooseAccountPageTitle": {
"description": "Title for the page to choose between Zulip accounts."
},
+ "settingsPageTitle": "Settings",
+ "@settingsPageTitle": {
+ "description": "Title for the settings page."
+ },
"switchAccountButton": "Switch account",
"@switchAccountButton": {
"description": "Label for main-menu button leading to the choose-account page."
@@ -791,6 +795,22 @@
"voterNames": {"type": "String", "example": "Alice, Bob, Chad"}
}
},
+ "themeSettingTitle": "THEME",
+ "@themeSettingTitle": {
+ "description": "Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)"
+ },
+ "themeSettingDark": "Dark",
+ "@themeSettingDark": {
+ "description": "Label for dark theme setting."
+ },
+ "themeSettingLight": "Light",
+ "@themeSettingLight": {
+ "description": "Label for light theme setting."
+ },
+ "themeSettingSystem": "System",
+ "@themeSettingSystem": {
+ "description": "Label for system theme setting."
+ },
"pollWidgetQuestionMissing": "No question.",
"@pollWidgetQuestionMissing": {
"description": "Text to display for a poll when the question is missing"
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 123fe10847..2f03458e05 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -135,6 +135,12 @@ abstract class ZulipLocalizations {
/// **'Choose account'**
String get chooseAccountPageTitle;
+ /// Title for the settings page.
+ ///
+ /// In en, this message translates to:
+ /// **'Settings'**
+ String get settingsPageTitle;
+
/// Label for main-menu button leading to the choose-account page.
///
/// In en, this message translates to:
@@ -1155,6 +1161,30 @@ abstract class ZulipLocalizations {
/// **'({voterNames})'**
String pollVoterNames(String voterNames);
+ /// Title for theme setting. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)
+ ///
+ /// In en, this message translates to:
+ /// **'THEME'**
+ String get themeSettingTitle;
+
+ /// Label for dark theme setting.
+ ///
+ /// In en, this message translates to:
+ /// **'Dark'**
+ String get themeSettingDark;
+
+ /// Label for light theme setting.
+ ///
+ /// In en, this message translates to:
+ /// **'Light'**
+ String get themeSettingLight;
+
+ /// Label for system theme setting.
+ ///
+ /// In en, this message translates to:
+ /// **'System'**
+ String get themeSettingSystem;
+
/// 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 fc1b2dea93..fd7905924b 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -23,6 +23,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
@override
String get chooseAccountPageTitle => 'Choose account';
+ @override
+ String get settingsPageTitle => 'Settings';
+
@override
String get switchAccountButton => 'Switch account';
@@ -619,6 +622,18 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
return '($voterNames)';
}
+ @override
+ String get themeSettingTitle => 'THEME';
+
+ @override
+ String get themeSettingDark => 'Dark';
+
+ @override
+ String get themeSettingLight => 'Light';
+
+ @override
+ String get themeSettingSystem => 'System';
+
@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 d83387b2b8..1d19cfc7b0 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -23,6 +23,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
@override
String get chooseAccountPageTitle => 'Choose account';
+ @override
+ String get settingsPageTitle => 'Settings';
+
@override
String get switchAccountButton => 'Switch account';
@@ -619,6 +622,18 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
return '($voterNames)';
}
+ @override
+ String get themeSettingTitle => 'THEME';
+
+ @override
+ String get themeSettingDark => 'Dark';
+
+ @override
+ String get themeSettingLight => 'Light';
+
+ @override
+ String get themeSettingSystem => 'System';
+
@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 39e230f859..58a4a1de59 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -23,6 +23,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get chooseAccountPageTitle => 'アカウントを選択';
+ @override
+ String get settingsPageTitle => 'Settings';
+
@override
String get switchAccountButton => 'Switch account';
@@ -619,6 +622,18 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
return '($voterNames)';
}
+ @override
+ String get themeSettingTitle => 'THEME';
+
+ @override
+ String get themeSettingDark => 'Dark';
+
+ @override
+ String get themeSettingLight => 'Light';
+
+ @override
+ String get themeSettingSystem => 'System';
+
@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 bc6796667f..a5bba71bd1 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -23,6 +23,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
@override
String get chooseAccountPageTitle => 'Choose account';
+ @override
+ String get settingsPageTitle => 'Settings';
+
@override
String get switchAccountButton => 'Switch account';
@@ -619,6 +622,18 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
return '($voterNames)';
}
+ @override
+ String get themeSettingTitle => 'THEME';
+
+ @override
+ String get themeSettingDark => 'Dark';
+
+ @override
+ String get themeSettingLight => 'Light';
+
+ @override
+ String get themeSettingSystem => 'System';
+
@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 45957536e8..e78e60dcb2 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -23,6 +23,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
@override
String get chooseAccountPageTitle => 'Wybierz konto';
+ @override
+ String get settingsPageTitle => 'Settings';
+
@override
String get switchAccountButton => 'Przełącz konto';
@@ -619,6 +622,18 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
return '($voterNames)';
}
+ @override
+ String get themeSettingTitle => 'THEME';
+
+ @override
+ String get themeSettingDark => 'Dark';
+
+ @override
+ String get themeSettingLight => 'Light';
+
+ @override
+ String get themeSettingSystem => 'System';
+
@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 37cff93bbd..e4bd460f72 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -23,6 +23,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
@override
String get chooseAccountPageTitle => 'Выберите учетную запись';
+ @override
+ String get settingsPageTitle => 'Settings';
+
@override
String get switchAccountButton => 'Сменить учетную запись';
@@ -619,6 +622,18 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
return '($voterNames)';
}
+ @override
+ String get themeSettingTitle => 'THEME';
+
+ @override
+ String get themeSettingDark => 'Dark';
+
+ @override
+ String get themeSettingLight => 'Light';
+
+ @override
+ String get themeSettingSystem => 'System';
+
@override
String get pollWidgetQuestionMissing => 'Нет вопроса.';
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index 1bcd7578c1..9ec68077ae 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -23,6 +23,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
@override
String get chooseAccountPageTitle => 'Zvoliť účet';
+ @override
+ String get settingsPageTitle => 'Settings';
+
@override
String get switchAccountButton => 'Zmeniť účet';
@@ -619,6 +622,18 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
return '($voterNames)';
}
+ @override
+ String get themeSettingTitle => 'THEME';
+
+ @override
+ String get themeSettingDark => 'Dark';
+
+ @override
+ String get themeSettingLight => 'Light';
+
+ @override
+ String get themeSettingSystem => 'System';
+
@override
String get pollWidgetQuestionMissing => 'Bez otázky.';
diff --git a/lib/model/binding.dart b/lib/model/binding.dart
index 50477ec01a..d8e860dca0 100644
--- a/lib/model/binding.dart
+++ b/lib/model/binding.dart
@@ -76,7 +76,7 @@ abstract class ZulipBinding {
}
/// Get the app's singleton [GlobalStore],
- /// calling [loadGlobalStore] if not already loaded.
+ /// loading it asynchronously if not already loaded.
///
/// Where possible, use [GlobalStoreWidget.of] to get access to a [GlobalStore].
/// Use this method only in contexts like notifications where
@@ -312,7 +312,7 @@ class PackageInfo {
/// A concrete binding for use in the live application.
///
-/// The global store returned by [loadGlobalStore], and consequently by
+/// The global store returned by [getGlobalStore], and consequently by
/// [GlobalStoreWidget.of] in application code, will be a [LiveGlobalStore].
/// It therefore uses a live server and live, persistent local database.
///
diff --git a/lib/model/database.dart b/lib/model/database.dart
index f9000f5cf5..263c1ab989 100644
--- a/lib/model/database.dart
+++ b/lib/model/database.dart
@@ -5,9 +5,21 @@ import 'package:sqlite3/common.dart';
import '../log.dart';
import 'schema_versions.g.dart';
+import 'settings.dart';
part 'database.g.dart';
+/// The table of the user's chosen settings independent of account, on this
+/// client.
+///
+/// These apply across all the user's accounts on this client (i.e. on this
+/// install of the app on this device).
+@DataClassName('GlobalSettingsData')
+class GlobalSettings extends Table {
+ Column get themeSetting => textEnum()
+ .nullable()();
+}
+
/// The table of [Account] records in the app's database.
class Accounts extends Table {
/// The ID of this account in the app's local database.
@@ -59,12 +71,14 @@ VersionedSchema _getSchema({
switch (schemaVersion) {
case 2:
return Schema2(database: database);
+ case 3:
+ return Schema3(database: database);
default:
throw Exception('unknown schema version: $schemaVersion');
}
}
-@DriftDatabase(tables: [Accounts])
+@DriftDatabase(tables: [GlobalSettings, Accounts])
class AppDatabase extends _$AppDatabase {
AppDatabase(super.e);
@@ -79,7 +93,7 @@ class AppDatabase extends _$AppDatabase {
// * Write a migration in `onUpgrade` below.
// * Write tests.
@override
- int get schemaVersion => 2; // See note.
+ int get schemaVersion => 3; // See note.
Future _dropAndCreateAll(Migrator m, {
required int schemaVersion,
@@ -128,10 +142,32 @@ class AppDatabase extends _$AppDatabase {
from1To2: (m, schema) async {
await m.addColumn(schema.accounts, schema.accounts.ackedPushToken);
},
+ from2To3: (m, schema) async {
+ await m.createTable(schema.globalSettings);
+ },
));
});
}
+ Future ensureGlobalSettings() async {
+ final settings = await select(globalSettings).get();
+ // TODO(db): Enforce the singleton constraint more robustly.
+ if (settings.isNotEmpty) {
+ if (settings.length > 1) {
+ assert(debugLog('Expected one globalSettings, got multiple: $settings'));
+ }
+ return settings.first;
+ }
+
+ final rowsAffected = await into(globalSettings).insert(GlobalSettingsCompanion.insert());
+ assert(rowsAffected == 1);
+ final result = await select(globalSettings).get();
+ if (result.length > 1) {
+ assert(debugLog('Expected one globalSettings, got multiple: $result'));
+ }
+ return result.first;
+ }
+
Future createAccount(AccountsCompanion values) async {
try {
return await into(accounts).insert(values);
diff --git a/lib/model/database.g.dart b/lib/model/database.g.dart
index 423fde1d16..fcb5292aa6 100644
--- a/lib/model/database.g.dart
+++ b/lib/model/database.g.dart
@@ -5,6 +5,188 @@
part of 'database.dart';
// ignore_for_file: type=lint
+class $GlobalSettingsTable extends GlobalSettings
+ with TableInfo<$GlobalSettingsTable, GlobalSettingsData> {
+ @override
+ final GeneratedDatabase attachedDatabase;
+ final String? _alias;
+ $GlobalSettingsTable(this.attachedDatabase, [this._alias]);
+ @override
+ late final GeneratedColumnWithTypeConverter
+ themeSetting = GeneratedColumn(
+ 'theme_setting',
+ aliasedName,
+ true,
+ type: DriftSqlType.string,
+ requiredDuringInsert: false,
+ ).withConverter($GlobalSettingsTable.$converterthemeSettingn);
+ @override
+ List get $columns => [themeSetting];
+ @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: $GlobalSettingsTable.$converterthemeSettingn.fromSql(
+ attachedDatabase.typeMapping.read(
+ DriftSqlType.string,
+ data['${effectivePrefix}theme_setting'],
+ ),
+ ),
+ );
+ }
+
+ @override
+ $GlobalSettingsTable createAlias(String alias) {
+ return $GlobalSettingsTable(attachedDatabase, alias);
+ }
+
+ static JsonTypeConverter2
+ $converterthemeSetting = const EnumNameConverter(
+ ThemeSetting.values,
+ );
+ static JsonTypeConverter2
+ $converterthemeSettingn = JsonTypeConverter2.asNullable(
+ $converterthemeSetting,
+ );
+}
+
+class GlobalSettingsData extends DataClass
+ implements Insertable {
+ final ThemeSetting? themeSetting;
+ const GlobalSettingsData({this.themeSetting});
+ @override
+ Map toColumns(bool nullToAbsent) {
+ final map = {};
+ if (!nullToAbsent || themeSetting != null) {
+ map['theme_setting'] = Variable(
+ $GlobalSettingsTable.$converterthemeSettingn.toSql(themeSetting),
+ );
+ }
+ return map;
+ }
+
+ GlobalSettingsCompanion toCompanion(bool nullToAbsent) {
+ return GlobalSettingsCompanion(
+ themeSetting:
+ themeSetting == null && nullToAbsent
+ ? const Value.absent()
+ : Value(themeSetting),
+ );
+ }
+
+ factory GlobalSettingsData.fromJson(
+ Map json, {
+ ValueSerializer? serializer,
+ }) {
+ serializer ??= driftRuntimeOptions.defaultSerializer;
+ return GlobalSettingsData(
+ themeSetting: $GlobalSettingsTable.$converterthemeSettingn.fromJson(
+ serializer.fromJson(json['themeSetting']),
+ ),
+ );
+ }
+ @override
+ Map toJson({ValueSerializer? serializer}) {
+ serializer ??= driftRuntimeOptions.defaultSerializer;
+ return {
+ 'themeSetting': serializer.toJson(
+ $GlobalSettingsTable.$converterthemeSettingn.toJson(themeSetting),
+ ),
+ };
+ }
+
+ GlobalSettingsData copyWith({
+ Value themeSetting = const Value.absent(),
+ }) => GlobalSettingsData(
+ themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting,
+ );
+ GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) {
+ return GlobalSettingsData(
+ themeSetting:
+ data.themeSetting.present
+ ? data.themeSetting.value
+ : this.themeSetting,
+ );
+ }
+
+ @override
+ String toString() {
+ return (StringBuffer('GlobalSettingsData(')
+ ..write('themeSetting: $themeSetting')
+ ..write(')'))
+ .toString();
+ }
+
+ @override
+ int get hashCode => themeSetting.hashCode;
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ (other is GlobalSettingsData && other.themeSetting == this.themeSetting);
+}
+
+class GlobalSettingsCompanion extends UpdateCompanion {
+ final Value themeSetting;
+ final Value rowid;
+ const GlobalSettingsCompanion({
+ this.themeSetting = const Value.absent(),
+ this.rowid = const Value.absent(),
+ });
+ GlobalSettingsCompanion.insert({
+ this.themeSetting = const Value.absent(),
+ this.rowid = const Value.absent(),
+ });
+ static Insertable custom({
+ Expression? themeSetting,
+ Expression? rowid,
+ }) {
+ return RawValuesInsertable({
+ if (themeSetting != null) 'theme_setting': themeSetting,
+ if (rowid != null) 'rowid': rowid,
+ });
+ }
+
+ GlobalSettingsCompanion copyWith({
+ Value? themeSetting,
+ Value? rowid,
+ }) {
+ return GlobalSettingsCompanion(
+ themeSetting: themeSetting ?? this.themeSetting,
+ rowid: rowid ?? this.rowid,
+ );
+ }
+
+ @override
+ Map toColumns(bool nullToAbsent) {
+ final map = {};
+ if (themeSetting.present) {
+ map['theme_setting'] = Variable(
+ $GlobalSettingsTable.$converterthemeSettingn.toSql(themeSetting.value),
+ );
+ }
+ if (rowid.present) {
+ map['rowid'] = Variable(rowid.value);
+ }
+ return map;
+ }
+
+ @override
+ String toString() {
+ return (StringBuffer('GlobalSettingsCompanion(')
+ ..write('themeSetting: $themeSetting, ')
+ ..write('rowid: $rowid')
+ ..write(')'))
+ .toString();
+ }
+}
+
class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> {
@override
final GeneratedDatabase attachedDatabase;
@@ -23,9 +205,6 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> {
'PRIMARY KEY AUTOINCREMENT',
),
);
- static const VerificationMeta _realmUrlMeta = const VerificationMeta(
- 'realmUrl',
- );
@override
late final GeneratedColumnWithTypeConverter realmUrl =
GeneratedColumn(
@@ -133,7 +312,6 @@ class $AccountsTable extends Accounts with TableInfo<$AccountsTable, Account> {
if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
}
- context.handle(_realmUrlMeta, const VerificationResult.success());
if (data.containsKey('user_id')) {
context.handle(
_userIdMeta,
@@ -611,14 +789,163 @@ class AccountsCompanion extends UpdateCompanion {
abstract class _$AppDatabase extends GeneratedDatabase {
_$AppDatabase(QueryExecutor e) : super(e);
$AppDatabaseManager get managers => $AppDatabaseManager(this);
+ late final $GlobalSettingsTable globalSettings = $GlobalSettingsTable(this);
late final $AccountsTable accounts = $AccountsTable(this);
@override
Iterable> get allTables =>
allSchemaEntities.whereType>();
@override
- List get allSchemaEntities => [accounts];
+ List get allSchemaEntities => [
+ globalSettings,
+ accounts,
+ ];
+}
+
+typedef $$GlobalSettingsTableCreateCompanionBuilder =
+ GlobalSettingsCompanion Function({
+ Value themeSetting,
+ Value rowid,
+ });
+typedef $$GlobalSettingsTableUpdateCompanionBuilder =
+ GlobalSettingsCompanion Function({
+ Value themeSetting,
+ Value rowid,
+ });
+
+class $$GlobalSettingsTableFilterComposer
+ extends Composer<_$AppDatabase, $GlobalSettingsTable> {
+ $$GlobalSettingsTableFilterComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ ColumnWithTypeConverterFilters
+ get themeSetting => $composableBuilder(
+ column: $table.themeSetting,
+ builder: (column) => ColumnWithTypeConverterFilters(column),
+ );
+}
+
+class $$GlobalSettingsTableOrderingComposer
+ extends Composer<_$AppDatabase, $GlobalSettingsTable> {
+ $$GlobalSettingsTableOrderingComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ ColumnOrderings get themeSetting => $composableBuilder(
+ column: $table.themeSetting,
+ builder: (column) => ColumnOrderings(column),
+ );
+}
+
+class $$GlobalSettingsTableAnnotationComposer
+ extends Composer<_$AppDatabase, $GlobalSettingsTable> {
+ $$GlobalSettingsTableAnnotationComposer({
+ required super.$db,
+ required super.$table,
+ super.joinBuilder,
+ super.$addJoinBuilderToRootComposer,
+ super.$removeJoinBuilderFromRootComposer,
+ });
+ GeneratedColumnWithTypeConverter get themeSetting =>
+ $composableBuilder(
+ column: $table.themeSetting,
+ builder: (column) => column,
+ );
}
+class $$GlobalSettingsTableTableManager
+ extends
+ RootTableManager<
+ _$AppDatabase,
+ $GlobalSettingsTable,
+ GlobalSettingsData,
+ $$GlobalSettingsTableFilterComposer,
+ $$GlobalSettingsTableOrderingComposer,
+ $$GlobalSettingsTableAnnotationComposer,
+ $$GlobalSettingsTableCreateCompanionBuilder,
+ $$GlobalSettingsTableUpdateCompanionBuilder,
+ (
+ GlobalSettingsData,
+ BaseReferences<
+ _$AppDatabase,
+ $GlobalSettingsTable,
+ GlobalSettingsData
+ >,
+ ),
+ GlobalSettingsData,
+ PrefetchHooks Function()
+ > {
+ $$GlobalSettingsTableTableManager(
+ _$AppDatabase db,
+ $GlobalSettingsTable table,
+ ) : super(
+ TableManagerState(
+ db: db,
+ table: table,
+ createFilteringComposer:
+ () => $$GlobalSettingsTableFilterComposer($db: db, $table: table),
+ createOrderingComposer:
+ () =>
+ $$GlobalSettingsTableOrderingComposer($db: db, $table: table),
+ createComputedFieldComposer:
+ () => $$GlobalSettingsTableAnnotationComposer(
+ $db: db,
+ $table: table,
+ ),
+ updateCompanionCallback:
+ ({
+ Value themeSetting = const Value.absent(),
+ Value rowid = const Value.absent(),
+ }) => GlobalSettingsCompanion(
+ themeSetting: themeSetting,
+ rowid: rowid,
+ ),
+ createCompanionCallback:
+ ({
+ Value themeSetting = const Value.absent(),
+ Value rowid = const Value.absent(),
+ }) => GlobalSettingsCompanion.insert(
+ themeSetting: themeSetting,
+ rowid: rowid,
+ ),
+ withReferenceMapper:
+ (p0) =>
+ p0
+ .map(
+ (e) => (
+ e.readTable(table),
+ BaseReferences(db, table, e),
+ ),
+ )
+ .toList(),
+ prefetchHooksCallback: null,
+ ),
+ );
+}
+
+typedef $$GlobalSettingsTableProcessedTableManager =
+ ProcessedTableManager<
+ _$AppDatabase,
+ $GlobalSettingsTable,
+ GlobalSettingsData,
+ $$GlobalSettingsTableFilterComposer,
+ $$GlobalSettingsTableOrderingComposer,
+ $$GlobalSettingsTableAnnotationComposer,
+ $$GlobalSettingsTableCreateCompanionBuilder,
+ $$GlobalSettingsTableUpdateCompanionBuilder,
+ (
+ GlobalSettingsData,
+ BaseReferences<_$AppDatabase, $GlobalSettingsTable, GlobalSettingsData>,
+ ),
+ GlobalSettingsData,
+ PrefetchHooks Function()
+ >;
typedef $$AccountsTableCreateCompanionBuilder =
AccountsCompanion Function({
Value id,
@@ -903,6 +1230,8 @@ typedef $$AccountsTableProcessedTableManager =
class $AppDatabaseManager {
final _$AppDatabase _db;
$AppDatabaseManager(this._db);
+ $$GlobalSettingsTableTableManager get globalSettings =>
+ $$GlobalSettingsTableTableManager(_db, _db.globalSettings);
$$AccountsTableTableManager get accounts =>
$$AccountsTableTableManager(_db, _db.accounts);
}
diff --git a/lib/model/schema_versions.g.dart b/lib/model/schema_versions.g.dart
index 77ec78baf8..bb5a62475a 100644
--- a/lib/model/schema_versions.g.dart
+++ b/lib/model/schema_versions.g.dart
@@ -123,8 +123,67 @@ i1.GeneratedColumn _column_8(String aliasedName) =>
true,
type: i1.DriftSqlType.string,
);
+
+final class Schema3 extends i0.VersionedSchema {
+ Schema3({required super.database}) : super(version: 3);
+ @override
+ late final List entities = [
+ globalSettings,
+ accounts,
+ ];
+ late final Shape1 globalSettings = Shape1(
+ source: i0.VersionedTable(
+ entityName: 'global_settings',
+ withoutRowId: false,
+ isStrict: false,
+ tableConstraints: [],
+ columns: [_column_9],
+ 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 Shape1 extends i0.VersionedTable {
+ Shape1({required super.source, required super.alias}) : super.aliased();
+ i1.GeneratedColumn get themeSetting =>
+ columnsByName['theme_setting']! as i1.GeneratedColumn;
+}
+
+i1.GeneratedColumn _column_9(String aliasedName) =>
+ i1.GeneratedColumn(
+ 'theme_setting',
+ 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,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@@ -133,6 +192,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from1To2(migrator, schema);
return 2;
+ case 2:
+ final schema = Schema3(database: database);
+ final migrator = i1.Migrator(database, schema);
+ await from2To3(migrator, schema);
+ return 3;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@@ -141,6 +205,7 @@ i0.MigrationStepWithVersion migrationSteps({
i1.OnUpgrade stepByStep({
required Future Function(i1.Migrator m, Schema2 schema) from1To2,
+ required Future Function(i1.Migrator m, Schema3 schema) from2To3,
}) => i0.VersionedSchema.stepByStepHelper(
- step: migrationSteps(from1To2: from1To2),
+ step: migrationSteps(from1To2: from1To2, from2To3: from2To3),
);
diff --git a/lib/model/settings.dart b/lib/model/settings.dart
new file mode 100644
index 0000000000..535d06aa01
--- /dev/null
+++ b/lib/model/settings.dart
@@ -0,0 +1,26 @@
+import '../generated/l10n/zulip_localizations.dart';
+
+/// The user's choice of visual theme for the app.
+///
+/// See [zulipThemeData] for how themes are determined.
+///
+/// Renaming existing enum values will invalidate the database.
+/// Write a migration if such a change is necessary.
+enum ThemeSetting {
+ /// Corresponds to [Brightness.light].
+ light,
+
+ /// Corresponds to [Brightness.dark].
+ dark;
+
+ static String displayName({
+ required ThemeSetting? themeSetting,
+ required ZulipLocalizations zulipLocalizations,
+ }) {
+ return switch (themeSetting) {
+ null => zulipLocalizations.themeSettingSystem,
+ ThemeSetting.light => zulipLocalizations.themeSettingLight,
+ ThemeSetting.dark => zulipLocalizations.themeSettingDark,
+ };
+ }
+}
diff --git a/lib/model/store.dart b/lib/model/store.dart
index 8f00aed2bc..bc5c752174 100644
--- a/lib/model/store.dart
+++ b/lib/model/store.dart
@@ -53,8 +53,31 @@ export 'database.dart' show Account, AccountsCompanion, AccountAlreadyExistsExce
/// * [LiveGlobalStore], the implementation of this class that
/// we use outside of tests.
abstract class GlobalStore extends ChangeNotifier {
- GlobalStore({required Iterable accounts})
- : _accounts = Map.fromEntries(accounts.map((a) => MapEntry(a.id, a)));
+ GlobalStore({
+ required GlobalSettingsData globalSettings,
+ required Iterable accounts,
+ })
+ : _globalSettings = globalSettings,
+ _accounts = Map.fromEntries(accounts.map((a) => MapEntry(a.id, a)));
+
+ /// A cache of the [GlobalSettingsData] singleton in the underlying data store.
+ GlobalSettingsData get globalSettings => _globalSettings;
+ GlobalSettingsData _globalSettings;
+
+ /// Update the global settings in the store, returning the new version.
+ ///
+ /// The global settings must already exist in the store.
+ Future updateGlobalSettings(GlobalSettingsCompanion data) async {
+ await doUpdateGlobalSettings(data);
+ _globalSettings = _globalSettings.copyWithCompanion(data);
+ notifyListeners();
+ return _globalSettings;
+ }
+
+ /// Update the global settings in the underlying data store.
+ ///
+ /// This should only be called from [updateGlobalSettings].
+ Future doUpdateGlobalSettings(GlobalSettingsCompanion data);
/// A cache of the [Accounts] table in the underlying data store.
final Map _accounts;
@@ -814,6 +837,7 @@ Uri? tryResolveUrl(Uri baseUrl, String reference) {
class LiveGlobalStore extends GlobalStore {
LiveGlobalStore._({
required AppDatabase db,
+ required super.globalSettings,
required super.accounts,
}) : _db = db;
@@ -830,8 +854,11 @@ class LiveGlobalStore extends GlobalStore {
// by doing this loading up front before constructing a [GlobalStore].
static Future load() async {
final db = AppDatabase(NativeDatabase.createInBackground(await _dbFile()));
+ final globalSettings = await db.ensureGlobalSettings();
final accounts = await db.select(db.accounts).get();
- return LiveGlobalStore._(db: db, accounts: accounts);
+ return LiveGlobalStore._(db: db,
+ globalSettings: globalSettings,
+ accounts: accounts);
}
/// The file path to use for the app database.
@@ -855,6 +882,12 @@ class LiveGlobalStore extends GlobalStore {
final AppDatabase _db;
+ @override
+ Future doUpdateGlobalSettings(GlobalSettingsCompanion data) async {
+ final rowsAffected = await _db.update(_db.globalSettings).write(data);
+ assert(rowsAffected == 1);
+ }
+
@override
Future doLoadPerAccount(int accountId) async {
final updateMachine = await UpdateMachine.load(this, accountId);
diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart
index 73a4e5f232..d251b88b58 100644
--- a/lib/widgets/app.dart
+++ b/lib/widgets/app.dart
@@ -212,41 +212,44 @@ class _ZulipAppState extends State with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
- final themeData = zulipThemeData(context);
return GlobalStoreWidget(
- child: MaterialApp(
- onGenerateTitle: (BuildContext context) {
- return ZulipLocalizations.of(context).zulipAppTitle;
- },
- localizationsDelegates: ZulipLocalizations.localizationsDelegates,
- supportedLocales: ZulipLocalizations.supportedLocales,
- theme: themeData,
-
- navigatorKey: ZulipApp.navigatorKey,
- navigatorObservers: [
- if (widget.navigatorObservers != null)
- ...widget.navigatorObservers!,
- _PreventEmptyStack(),
- ],
- builder: (BuildContext context, Widget? child) {
- if (!ZulipApp.ready.value) {
- SchedulerBinding.instance.addPostFrameCallback(
- (_) => widget._declareReady());
- }
- GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context);
- return child!;
- },
-
- // We use onGenerateInitialRoutes for the real work of specifying the
- // initial nav state. To do that we need [MaterialApp] to decide to
- // build a [Navigator]... which means specifying either `home`, `routes`,
- // `onGenerateRoute`, or `onUnknownRoute`. Make it `onGenerateRoute`.
- // It never actually gets called, though: `onGenerateInitialRoutes`
- // handles startup, and then we always push whole routes with methods
- // like [Navigator.push], never mere names as with [Navigator.pushNamed].
- onGenerateRoute: (_) => null,
-
- onGenerateInitialRoutes: _handleGenerateInitialRoutes));
+ child: Builder(builder: (context) {
+ return MaterialApp(
+ onGenerateTitle: (BuildContext context) {
+ return ZulipLocalizations.of(context).zulipAppTitle;
+ },
+ localizationsDelegates: ZulipLocalizations.localizationsDelegates,
+ supportedLocales: ZulipLocalizations.supportedLocales,
+ // The context has to be taken from the [Builder] because
+ // [zulipThemeData] requires access to [GlobalStoreWidget] in the tree.
+ theme: zulipThemeData(context),
+
+ navigatorKey: ZulipApp.navigatorKey,
+ navigatorObservers: [
+ if (widget.navigatorObservers != null)
+ ...widget.navigatorObservers!,
+ _PreventEmptyStack(),
+ ],
+ builder: (BuildContext context, Widget? child) {
+ if (!ZulipApp.ready.value) {
+ SchedulerBinding.instance.addPostFrameCallback(
+ (_) => widget._declareReady());
+ }
+ GlobalLocalizations.zulipLocalizations = ZulipLocalizations.of(context);
+ return child!;
+ },
+
+ // We use onGenerateInitialRoutes for the real work of specifying the
+ // initial nav state. To do that we need [MaterialApp] to decide to
+ // build a [Navigator]... which means specifying either `home`, `routes`,
+ // `onGenerateRoute`, or `onUnknownRoute`. Make it `onGenerateRoute`.
+ // It never actually gets called, though: `onGenerateInitialRoutes`
+ // handles startup, and then we always push whole routes with methods
+ // like [Navigator.push], never mere names as with [Navigator.pushNamed].
+ onGenerateRoute: (_) => null,
+
+ onGenerateInitialRoutes: _handleGenerateInitialRoutes);
+ }));
}
}
diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart
index 4591b7afb3..f444f0a6a5 100644
--- a/lib/widgets/home.dart
+++ b/lib/widgets/home.dart
@@ -17,6 +17,7 @@ import 'message_list.dart';
import 'page.dart';
import 'profile.dart';
import 'recent_dm_conversations.dart';
+import 'settings.dart';
import 'store.dart';
import 'subscription_list.dart';
import 'text.dart';
@@ -280,7 +281,7 @@ void _showMainMenu(BuildContext context, {
const _SwitchAccountButton(),
// TODO(#198): Set my status
// const SizedBox(height: 8),
- // TODO(#97): Settings
+ const _SettingsButton(),
// TODO(#661): Notifications
// const SizedBox(height: 8),
const _AboutZulipButton(),
@@ -567,6 +568,23 @@ class _SwitchAccountButton extends _MenuButton {
}
}
+class _SettingsButton extends _MenuButton {
+ const _SettingsButton();
+
+ @override
+ IconData get icon => ZulipIcons.settings;
+
+ @override
+ String label(ZulipLocalizations zulipLocalizations) {
+ return zulipLocalizations.settingsPageTitle;
+ }
+
+ @override
+ void onPressed(BuildContext context) {
+ Navigator.of(context).push(SettingsPage.buildRoute(context: context));
+ }
+}
+
class _AboutZulipButton extends _MenuButton {
const _AboutZulipButton();
diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart
index da50c70665..ff9b2f7794 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -117,32 +117,35 @@ abstract final class ZulipIcons {
/// The Zulip custom icon "send".
static const IconData send = IconData(0xf11f, fontFamily: "Zulip Icons");
+ /// The Zulip custom icon "settings".
+ static const IconData settings = IconData(0xf120, fontFamily: "Zulip Icons");
+
/// The Zulip custom icon "share".
- static const IconData share = IconData(0xf120, fontFamily: "Zulip Icons");
+ static const IconData share = IconData(0xf121, fontFamily: "Zulip Icons");
/// The Zulip custom icon "share_ios".
- static const IconData share_ios = IconData(0xf121, fontFamily: "Zulip Icons");
+ static const IconData share_ios = IconData(0xf122, fontFamily: "Zulip Icons");
/// The Zulip custom icon "smile".
- static const IconData smile = IconData(0xf122, fontFamily: "Zulip Icons");
+ static const IconData smile = IconData(0xf123, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star".
- static const IconData star = IconData(0xf123, fontFamily: "Zulip Icons");
+ static const IconData star = IconData(0xf124, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star_filled".
- static const IconData star_filled = IconData(0xf124, fontFamily: "Zulip Icons");
+ static const IconData star_filled = IconData(0xf125, fontFamily: "Zulip Icons");
/// The Zulip custom icon "three_person".
- static const IconData three_person = IconData(0xf125, fontFamily: "Zulip Icons");
+ static const IconData three_person = IconData(0xf126, fontFamily: "Zulip Icons");
/// The Zulip custom icon "topic".
- static const IconData topic = IconData(0xf126, fontFamily: "Zulip Icons");
+ static const IconData topic = IconData(0xf127, fontFamily: "Zulip Icons");
/// The Zulip custom icon "unmute".
- static const IconData unmute = IconData(0xf127, fontFamily: "Zulip Icons");
+ static const IconData unmute = IconData(0xf128, fontFamily: "Zulip Icons");
/// The Zulip custom icon "user".
- static const IconData user = IconData(0xf128, fontFamily: "Zulip Icons");
+ static const IconData user = IconData(0xf129, fontFamily: "Zulip Icons");
// END GENERATED ICON DATA
}
diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart
new file mode 100644
index 0000000000..8ece4d0578
--- /dev/null
+++ b/lib/widgets/settings.dart
@@ -0,0 +1,56 @@
+import 'package:drift/drift.dart' hide Column;
+import 'package:flutter/material.dart';
+
+import '../generated/l10n/zulip_localizations.dart';
+import '../model/database.dart';
+import '../model/settings.dart';
+import 'app_bar.dart';
+import 'page.dart';
+import 'store.dart';
+
+class SettingsPage extends StatelessWidget {
+ const SettingsPage({super.key});
+
+ static AccountRoute buildRoute({required BuildContext context}) {
+ return MaterialAccountWidgetRoute(
+ context: context, page: const SettingsPage());
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ return Scaffold(
+ appBar: ZulipAppBar(
+ title: Text(zulipLocalizations.settingsPageTitle)),
+ body: Column(children: [
+ const _ThemeSetting(),
+ ]));
+ }
+}
+
+class _ThemeSetting extends StatelessWidget {
+ const _ThemeSetting();
+
+ void _handleChange(BuildContext context, ThemeSetting? newThemeSetting) {
+ GlobalStoreWidget.of(context).updateGlobalSettings(
+ GlobalSettingsCompanion(themeSetting: Value(newThemeSetting)));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ final globalStore = GlobalStoreWidget.of(context);
+ return Column(
+ children: [
+ ListTile(title: Text(zulipLocalizations.themeSettingTitle)),
+ for (final themeSettingOption in [null, ...ThemeSetting.values])
+ RadioListTile.adaptive(
+ title: Text(ThemeSetting.displayName(
+ themeSetting: themeSettingOption,
+ zulipLocalizations: zulipLocalizations)),
+ value: themeSettingOption,
+ groupValue: globalStore.globalSettings.themeSetting,
+ onChanged: (newValue) => _handleChange(context, newValue)),
+ ]);
+ }
+}
diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart
index 3fb70365c8..cc2c51fe20 100644
--- a/lib/widgets/theme.dart
+++ b/lib/widgets/theme.dart
@@ -1,17 +1,24 @@
import 'package:flutter/material.dart';
import '../api/model/model.dart';
+import '../model/settings.dart';
import 'compose_box.dart';
import 'content.dart';
import 'emoji_reaction.dart';
import 'message_list.dart';
import 'channel_colors.dart';
+import 'store.dart';
import 'text.dart';
ThemeData zulipThemeData(BuildContext context) {
final DesignVariables designVariables;
final List themeExtensions;
- Brightness brightness = MediaQuery.platformBrightnessOf(context);
+ final globalSettings = GlobalStoreWidget.of(context).globalSettings;
+ Brightness brightness = switch (globalSettings.themeSetting) {
+ null => MediaQuery.platformBrightnessOf(context),
+ ThemeSetting.light => Brightness.light,
+ ThemeSetting.dark => Brightness.dark,
+ };
// This applies Material 3's color system to produce a palette of
// appropriately matching and contrasting colors for use in a UI.
diff --git a/pubspec.lock b/pubspec.lock
index 77adae52d1..2a9993093f 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -270,18 +270,18 @@ packages:
dependency: "direct main"
description:
name: drift
- sha256: "97d5832657d49f26e7a8e07de397ddc63790b039372878d5117af816d0fdb5cb"
+ sha256: "14a61af39d4584faf1d73b5b35e4b758a43008cf4c0fdb0576ec8e7032c0d9a5"
url: "https://pub.dev"
source: hosted
- version: "2.25.1"
+ version: "2.26.0"
drift_dev:
dependency: "direct dev"
description:
name: drift_dev
- sha256: f1db88482dbb016b9bbddddf746d5d0a6938b156ff20e07320052981f97388cc
+ sha256: "0d3f8b33b76cf1c6a82ee34d9511c40957549c4674b8f1688609e6d6c7306588"
url: "https://pub.dev"
source: hosted
- version: "2.25.2"
+ version: "2.26.0"
fake_async:
dependency: "direct dev"
description:
diff --git a/test/example_data.dart b/test/example_data.dart
index 635667eb6c..4785917814 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -9,7 +9,9 @@ import 'package:zulip/api/model/submessage.dart';
import 'package:zulip/api/route/messages.dart';
import 'package:zulip/api/route/realm.dart';
import 'package:zulip/api/route/channels.dart';
+import 'package:zulip/model/database.dart';
import 'package:zulip/model/narrow.dart';
+import 'package:zulip/model/settings.dart';
import 'package:zulip/model/store.dart';
import 'model/test_store.dart';
@@ -876,8 +878,23 @@ ChannelUpdateEvent channelUpdateEvent(
// The entire per-account or global state.
//
-TestGlobalStore globalStore({List accounts = const []}) {
- return TestGlobalStore(accounts: accounts);
+GlobalSettingsData globalSettings({
+ ThemeSetting? themeSetting,
+}) {
+ return GlobalSettingsData(
+ themeSetting: themeSetting,
+ );
+}
+const _globalSettings = globalSettings;
+
+TestGlobalStore globalStore({
+ GlobalSettingsData? globalSettings,
+ List accounts = const [],
+}) {
+ return TestGlobalStore(
+ globalSettings: globalSettings ?? _globalSettings(),
+ accounts: accounts,
+ );
}
const _globalStore = globalStore;
diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart
index d2d4c91581..2f3eac042f 100644
--- a/test/flutter_checks.dart
+++ b/test/flutter_checks.dart
@@ -172,6 +172,14 @@ extension InputDecorationChecks on Subject {
Subject get hintText => has((x) => x.hintText, 'hintText');
}
+extension RadioListTileChecks on Subject> {
+ Subject get checked => has((x) => x.checked, 'checked');
+}
+
+extension ThemeDataChecks on Subject {
+ Subject get brightness => has((x) => x.brightness, 'brightness');
+}
+
extension BoxDecorationChecks on Subject {
Subject get color => has((x) => x.color, 'color');
}
diff --git a/test/model/binding.dart b/test/model/binding.dart
index 039d6c3787..17c9565770 100644
--- a/test/model/binding.dart
+++ b/test/model/binding.dart
@@ -12,6 +12,7 @@ import 'package:zulip/model/binding.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/widgets/app.dart';
+import '../example_data.dart' as eg;
import 'test_store.dart';
/// The binding instance used in tests.
@@ -30,7 +31,7 @@ TestZulipBinding get testBinding => TestZulipBinding.instance;
/// and [TestGlobalStore.add] to set up test data there. Such test functions
/// must also call [reset] to clean up the global store.
///
-/// The global store returned by [loadGlobalStore], and consequently by
+/// The global store returned by [getGlobalStore], and consequently by
/// [GlobalStoreWidget.of] in application code, will be a [TestGlobalStore].
class TestZulipBinding extends ZulipBinding {
/// Initialize the binding if necessary, and ensure it is a [TestZulipBinding].
@@ -86,7 +87,7 @@ class TestZulipBinding extends ZulipBinding {
///
/// Tests that access this getter, or that mount a [GlobalStoreWidget],
/// should clean up by calling [reset].
- TestGlobalStore get globalStore => _globalStore ??= TestGlobalStore(accounts: []);
+ TestGlobalStore get globalStore => _globalStore ??= eg.globalStore();
TestGlobalStore? _globalStore;
bool _debugAlreadyLoadedStore = false;
diff --git a/test/model/database_test.dart b/test/model/database_test.dart
index 0f8b21297c..90dbcbddf1 100644
--- a/test/model/database_test.dart
+++ b/test/model/database_test.dart
@@ -4,10 +4,12 @@ import 'package:drift/native.dart';
import 'package:drift_dev/api/migrations_native.dart';
import 'package:test/scaffolding.dart';
import 'package:zulip/model/database.dart';
+import 'package:zulip/model/settings.dart';
import 'schemas/schema.dart';
import 'schemas/schema_v1.dart' as v1;
import 'schemas/schema_v2.dart' as v2;
+import 'store_checks.dart';
void main() {
group('non-migration tests', () {
@@ -20,6 +22,34 @@ void main() {
await database.close();
});
+ test('initialize GlobalSettings with defaults', () async {
+ check(await database.ensureGlobalSettings()).themeSetting.isNull();
+ });
+
+ test('ensure single GlobalSettings row', () async {
+ check(await database.select(database.globalSettings).get()).isEmpty();
+
+ final globalSettings = await database.ensureGlobalSettings();
+ check(await database.select(database.globalSettings).get())
+ .single.equals(globalSettings);
+
+ // Subsequent calls to `ensureGlobalSettings` do not insert new rows.
+ check(await database.ensureGlobalSettings()).equals(globalSettings);
+ check(await database.select(database.globalSettings).get())
+ .single.equals(globalSettings);
+ });
+
+ test('does not crash if multiple global settings rows', () async {
+ await database.into(database.globalSettings)
+ .insert(const GlobalSettingsCompanion(themeSetting: Value(ThemeSetting.dark)));
+ await database.into(database.globalSettings)
+ .insert(const GlobalSettingsCompanion(themeSetting: Value(ThemeSetting.light)));
+
+ check(await database.select(database.globalSettings).get()).length.equals(2);
+ check(await database.ensureGlobalSettings())
+ .themeSetting.equals(ThemeSetting.dark);
+ });
+
test('create account', () async {
final accountData = AccountsCompanion.insert(
realmUrl: Uri.parse('https://chat.example/'),
diff --git a/test/model/schemas/drift_schema_v3.json b/test/model/schemas/drift_schema_v3.json
new file mode 100644
index 0000000000..1c04b495f3
--- /dev/null
+++ b/test/model/schemas/drift_schema_v3.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"}}],"is_virtual":false,"without_rowid":false,"constraints":[]}},{"id":1,"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 b2b7404b5a..209e70d788 100644
--- a/test/model/schemas/schema.dart
+++ b/test/model/schemas/schema.dart
@@ -5,6 +5,7 @@ import 'package:drift/drift.dart';
import 'package:drift/internal/migrations.dart';
import 'schema_v1.dart' as v1;
import 'schema_v2.dart' as v2;
+import 'schema_v3.dart' as v3;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@@ -14,10 +15,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v1.DatabaseAtV1(db);
case 2:
return v2.DatabaseAtV2(db);
+ case 3:
+ return v3.DatabaseAtV3(db);
default:
throw MissingSchemaException(version, versions);
}
}
- static const versions = const [1, 2];
+ static const versions = const [1, 2, 3];
}
diff --git a/test/model/schemas/schema_v3.dart b/test/model/schemas/schema_v3.dart
new file mode 100644
index 0000000000..7a78e85840
--- /dev/null
+++ b/test/model/schemas/schema_v3.dart
@@ -0,0 +1,653 @@
+// 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,
+ );
+ @override
+ List get $columns => [themeSetting];
+ @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'],
+ ),
+ );
+ }
+
+ @override
+ GlobalSettings createAlias(String alias) {
+ return GlobalSettings(attachedDatabase, alias);
+ }
+}
+
+class GlobalSettingsData extends DataClass
+ implements Insertable {
+ final String? themeSetting;
+ const GlobalSettingsData({this.themeSetting});
+ @override
+ Map toColumns(bool nullToAbsent) {
+ final map = {};
+ if (!nullToAbsent || themeSetting != null) {
+ map['theme_setting'] = Variable(themeSetting);
+ }
+ return map;
+ }
+
+ GlobalSettingsCompanion toCompanion(bool nullToAbsent) {
+ return GlobalSettingsCompanion(
+ themeSetting:
+ themeSetting == null && nullToAbsent
+ ? const Value.absent()
+ : Value(themeSetting),
+ );
+ }
+
+ factory GlobalSettingsData.fromJson(
+ Map json, {
+ ValueSerializer? serializer,
+ }) {
+ serializer ??= driftRuntimeOptions.defaultSerializer;
+ return GlobalSettingsData(
+ themeSetting: serializer.fromJson(json['themeSetting']),
+ );
+ }
+ @override
+ Map toJson({ValueSerializer? serializer}) {
+ serializer ??= driftRuntimeOptions.defaultSerializer;
+ return {
+ 'themeSetting': serializer.toJson(themeSetting),
+ };
+ }
+
+ GlobalSettingsData copyWith({
+ Value themeSetting = const Value.absent(),
+ }) => GlobalSettingsData(
+ themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting,
+ );
+ GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) {
+ return GlobalSettingsData(
+ themeSetting:
+ data.themeSetting.present
+ ? data.themeSetting.value
+ : this.themeSetting,
+ );
+ }
+
+ @override
+ String toString() {
+ return (StringBuffer('GlobalSettingsData(')
+ ..write('themeSetting: $themeSetting')
+ ..write(')'))
+ .toString();
+ }
+
+ @override
+ int get hashCode => themeSetting.hashCode;
+ @override
+ bool operator ==(Object other) =>
+ identical(this, other) ||
+ (other is GlobalSettingsData && other.themeSetting == this.themeSetting);
+}
+
+class GlobalSettingsCompanion extends UpdateCompanion {
+ final Value themeSetting;
+ final Value rowid;
+ const GlobalSettingsCompanion({
+ this.themeSetting = const Value.absent(),
+ this.rowid = const Value.absent(),
+ });
+ GlobalSettingsCompanion.insert({
+ this.themeSetting = const Value.absent(),
+ this.rowid = const Value.absent(),
+ });
+ static Insertable custom({
+ Expression? themeSetting,
+ Expression? rowid,
+ }) {
+ return RawValuesInsertable({
+ if (themeSetting != null) 'theme_setting': themeSetting,
+ if (rowid != null) 'rowid': rowid,
+ });
+ }
+
+ GlobalSettingsCompanion copyWith({
+ Value? themeSetting,
+ Value? rowid,
+ }) {
+ return GlobalSettingsCompanion(
+ themeSetting: themeSetting ?? this.themeSetting,
+ rowid: rowid ?? this.rowid,
+ );
+ }
+
+ @override
+ Map toColumns(bool nullToAbsent) {
+ final map = {};
+ if (themeSetting.present) {
+ map['theme_setting'] = Variable(themeSetting.value);
+ }
+ if (rowid.present) {
+ map['rowid'] = Variable(rowid.value);
+ }
+ return map;
+ }
+
+ @override
+ String toString() {
+ return (StringBuffer('GlobalSettingsCompanion(')
+ ..write('themeSetting: $themeSetting, ')
+ ..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 DatabaseAtV3 extends GeneratedDatabase {
+ DatabaseAtV3(QueryExecutor e) : super(e);
+ late final GlobalSettings globalSettings = GlobalSettings(this);
+ late final Accounts accounts = Accounts(this);
+ @override
+ Iterable> get allTables =>
+ allSchemaEntities.whereType>();
+ @override
+ List get allSchemaEntities => [
+ globalSettings,
+ accounts,
+ ];
+ @override
+ int get schemaVersion => 3;
+}
diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart
index 00ada1eea5..5b05935572 100644
--- a/test/model/store_checks.dart
+++ b/test/model/store_checks.dart
@@ -3,7 +3,9 @@ import 'package:zulip/api/core.dart';
import 'package:zulip/api/model/initial_snapshot.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/model/autocomplete.dart';
+import 'package:zulip/model/database.dart';
import 'package:zulip/model/recent_dm_conversations.dart';
+import 'package:zulip/model/settings.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/model/unreads.dart';
@@ -20,12 +22,17 @@ extension AccountChecks on Subject {
}
extension GlobalStoreChecks on Subject {
+ Subject get globalSettings => has((x) => x.globalSettings, 'globalSettings');
Subject> get accounts => has((x) => x.accounts, 'accounts');
Subject> get accountIds => has((x) => x.accountIds, 'accountIds');
Subject> get accountEntries => has((x) => x.accountEntries, 'accountEntries');
Subject getAccount(int id) => has((x) => x.getAccount(id), 'getAccount($id)');
}
+extension GlobalSettingsDataChecks on Subject {
+ Subject get themeSetting => has((x) => x.themeSetting, 'themeSetting');
+}
+
extension PerAccountStoreChecks on Subject {
Subject get connection => has((x) => x.connection, 'connection');
Subject get isLoading => has((x) => x.isLoading, 'isLoading');
diff --git a/test/model/store_test.dart b/test/model/store_test.dart
index eb6444309d..9d92285800 100644
--- a/test/model/store_test.dart
+++ b/test/model/store_test.dart
@@ -14,6 +14,8 @@ import 'package:zulip/api/route/messages.dart';
import 'package:zulip/api/route/realm.dart';
import 'package:zulip/log.dart';
import 'package:zulip/model/actions.dart';
+import 'package:zulip/model/database.dart';
+import 'package:zulip/model/settings.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/notifications/receive.dart';
@@ -29,6 +31,31 @@ import 'test_store.dart';
void main() {
TestZulipBinding.ensureInitialized();
+ group('GlobalStore.updateGlobalSettings', () {
+ test('smoke', () async {
+ final globalStore = eg.globalStore();
+ check(globalStore).globalSettings.themeSetting.equals(null);
+
+ final result = await globalStore.updateGlobalSettings(
+ GlobalSettingsCompanion(themeSetting: Value(ThemeSetting.dark)));
+ check(globalStore).globalSettings.themeSetting.equals(ThemeSetting.dark);
+ check(result).equals(globalStore.globalSettings);
+ });
+
+ test('should notify listeners', () async {
+ int notifyCount = 0;
+ final globalStore = eg.globalStore();
+ globalStore.addListener(() => notifyCount++);
+ check(notifyCount).equals(0);
+
+ await globalStore.updateGlobalSettings(
+ GlobalSettingsCompanion(themeSetting: Value(ThemeSetting.light)));
+ check(notifyCount).equals(1);
+ });
+
+ // TODO integration tests with sqlite
+ });
+
final account1 = eg.selfAccount.copyWith(id: 1);
final account2 = eg.otherAccount.copyWith(id: 2);
@@ -400,7 +427,7 @@ void main() {
late FakeApiConnection connection;
Future prepareStore({Account? account}) async {
- globalStore = TestGlobalStore(accounts: []);
+ globalStore = eg.globalStore();
account ??= eg.selfAccount;
await globalStore.insertAccount(account.toCompanion(false));
connection = (globalStore.apiConnectionFromAccount(account)
@@ -575,7 +602,7 @@ void main() {
}
Future preparePoll({int? lastEventId}) async {
- globalStore = TestGlobalStore(accounts: []);
+ globalStore = eg.globalStore();
await globalStore.add(eg.selfAccount, eg.initialSnapshot(
lastEventId: lastEventId));
await globalStore.perAccount(eg.selfAccount.id);
@@ -1125,7 +1152,9 @@ void main() {
}
class LoadingTestGlobalStore extends TestGlobalStore {
- LoadingTestGlobalStore({required super.accounts});
+ LoadingTestGlobalStore({
+ required super.accounts,
+ }) : super(globalSettings: eg.globalSettings());
Map>> completers = {};
diff --git a/test/model/test_store.dart b/test/model/test_store.dart
index 534a6003b5..f119343b0f 100644
--- a/test/model/test_store.dart
+++ b/test/model/test_store.dart
@@ -1,6 +1,7 @@
import 'package:zulip/api/model/events.dart';
import 'package:zulip/api/model/initial_snapshot.dart';
import 'package:zulip/api/model/model.dart';
+import 'package:zulip/model/database.dart';
import 'package:zulip/model/store.dart';
import 'package:zulip/widgets/store.dart';
@@ -22,7 +23,12 @@ import '../example_data.dart' as eg;
///
/// See also [TestZulipBinding.globalStore], which provides one of these.
class TestGlobalStore extends GlobalStore {
- TestGlobalStore({required super.accounts});
+ TestGlobalStore({required super.globalSettings, required super.accounts});
+
+ @override
+ Future doUpdateGlobalSettings(GlobalSettingsCompanion data) async {
+ // Nothing to do.
+ }
final Map<
({Uri realmUrl, int? zulipFeatureLevel, String? email, String? apiKey}),
diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart
new file mode 100644
index 0000000000..2eb59d2c14
--- /dev/null
+++ b/test/widgets/settings_test.dart
@@ -0,0 +1,86 @@
+import 'package:checks/checks.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/model/settings.dart';
+import 'package:zulip/widgets/settings.dart';
+
+import '../flutter_checks.dart';
+import '../model/binding.dart';
+import '../model/store_checks.dart';
+import '../example_data.dart' as eg;
+import 'test_app.dart';
+
+void main() {
+ TestZulipBinding.ensureInitialized();
+
+ Future prepare(WidgetTester tester) async {
+ addTearDown(testBinding.reset);
+
+ await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+ await tester.pumpWidget(TestZulipApp(
+ accountId: eg.selfAccount.id,
+ child: SettingsPage()));
+ await tester.pump();
+ await tester.pump();
+ }
+
+ group('ThemeSetting', () {
+ Finder findRadioListTileWithTitle(String title) => find.ancestor(
+ of: find.text(title),
+ matching: find.byType(RadioListTile));
+
+ void checkThemeSetting(WidgetTester tester, {
+ required ThemeSetting? expectedThemeSetting,
+ }) {
+ final expectedCheckedTitle = switch (expectedThemeSetting) {
+ null => 'System',
+ ThemeSetting.light => 'Light',
+ ThemeSetting.dark => 'Dark',
+ };
+ for (final title in ['System', 'Light', 'Dark']) {
+ check(tester.widget>(
+ findRadioListTileWithTitle(title)))
+ .checked.equals(title == expectedCheckedTitle);
+ }
+ check(testBinding.globalStore)
+ .globalSettings.themeSetting.equals(expectedThemeSetting);
+ }
+
+ testWidgets('smoke', (tester) async {
+ debugBrightnessOverride = Brightness.light;
+
+ await testBinding.globalStore.updateGlobalSettings(
+ eg.globalSettings(themeSetting: ThemeSetting.light).toCompanion(false));
+ await prepare(tester);
+ final element = tester.element(find.byType(SettingsPage));
+ check(Theme.of(element)).brightness.equals(Brightness.light);
+ checkThemeSetting(tester, expectedThemeSetting: ThemeSetting.light);
+
+ await tester.tap(findRadioListTileWithTitle('Dark'));
+ await tester.pump();
+ await tester.pump(Duration(milliseconds: 250)); // wait for transition
+ check(Theme.of(element)).brightness.equals(Brightness.dark);
+ checkThemeSetting(tester, expectedThemeSetting: ThemeSetting.dark);
+
+ await tester.tap(findRadioListTileWithTitle('System'));
+ await tester.pump();
+ await tester.pump(Duration(milliseconds: 250)); // wait for transition
+ check(Theme.of(element)).brightness.equals(Brightness.light);
+ checkThemeSetting(tester, expectedThemeSetting: null);
+
+ debugBrightnessOverride = null;
+ });
+
+ testWidgets('follow system setting when themeSetting is null', (tester) async {
+ debugBrightnessOverride = Brightness.dark;
+
+ await prepare(tester);
+ final element = tester.element(find.byType(SettingsPage));
+ check(Theme.of(element)).brightness.equals(Brightness.dark);
+ checkThemeSetting(tester, expectedThemeSetting: null);
+
+ debugBrightnessOverride = null;
+ });
+ });
+}
diff --git a/test/widgets/test_app.dart b/test/widgets/test_app.dart
index e431aeaf23..5cec418cdd 100644
--- a/test/widgets/test_app.dart
+++ b/test/widgets/test_app.dart
@@ -73,6 +73,8 @@ class TestZulipApp extends StatelessWidget {
title: 'Zulip',
localizationsDelegates: ZulipLocalizations.localizationsDelegates,
supportedLocales: ZulipLocalizations.supportedLocales,
+ // The context has to be taken from the [Builder] because
+ // [zulipThemeData] requires access to [GlobalStoreWidget] in the tree.
theme: zulipThemeData(context),
navigatorObservers: navigatorObservers ?? const [],
diff --git a/test/widgets/theme_test.dart b/test/widgets/theme_test.dart
index 88cad71d0d..2437049a51 100644
--- a/test/widgets/theme_test.dart
+++ b/test/widgets/theme_test.dart
@@ -1,7 +1,10 @@
import 'package:checks/checks.dart';
+import 'package:drift/drift.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
+import 'package:zulip/model/database.dart';
+import 'package:zulip/model/settings.dart';
import 'package:zulip/widgets/channel_colors.dart';
import 'package:zulip/widgets/text.dart';
import 'package:zulip/widgets/theme.dart';
@@ -9,6 +12,7 @@ import 'package:zulip/widgets/theme.dart';
import '../example_data.dart' as eg;
import '../flutter_checks.dart';
import '../model/binding.dart';
+import '../model/store_checks.dart';
import 'colors_checks.dart';
import 'test_app.dart';
@@ -99,6 +103,46 @@ void main() {
});
});
+ testWidgets('when globalSettings.themeSetting is null, follow system setting', (tester) async {
+ addTearDown(testBinding.reset);
+
+ tester.platformDispatcher.platformBrightnessTestValue = Brightness.light;
+ addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue);
+
+ await tester.pumpWidget(const TestZulipApp(child: Placeholder()));
+ await tester.pump();
+ check(testBinding.globalStore).globalSettings.themeSetting.isNull();
+
+ final element = tester.element(find.byType(Placeholder));
+ check(zulipThemeData(element)).brightness.equals(Brightness.light);
+
+ tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark;
+ await tester.pump();
+ check(zulipThemeData(element)).brightness.equals(Brightness.dark);
+ });
+
+ testWidgets('when globalSettings.themeSetting is non-null, override system setting', (tester) async {
+ addTearDown(testBinding.reset);
+
+ tester.platformDispatcher.platformBrightnessTestValue = Brightness.light;
+ addTearDown(tester.platformDispatcher.clearPlatformBrightnessTestValue);
+
+ await tester.pumpWidget(const TestZulipApp(child: Placeholder()));
+ await tester.pump();
+ check(testBinding.globalStore).globalSettings.themeSetting.isNull();
+
+ final element = tester.element(find.byType(Placeholder));
+ check(zulipThemeData(element)).brightness.equals(Brightness.light);
+
+ await testBinding.globalStore.updateGlobalSettings(
+ const GlobalSettingsCompanion(themeSetting: Value(ThemeSetting.dark)));
+ check(zulipThemeData(element)).brightness.equals(Brightness.dark);
+
+ await testBinding.globalStore.updateGlobalSettings(
+ const GlobalSettingsCompanion(themeSetting: Value(null)));
+ check(zulipThemeData(element)).brightness.equals(Brightness.light);
+ });
+
group('colorSwatchFor', () {
const baseColor = 0xff76ce90;