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;