diff --git a/lib/model/database.dart b/lib/model/database.dart index 027cec9958..6393294438 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -9,11 +9,13 @@ import 'settings.dart'; part 'database.g.dart'; -/// The table of the user's chosen settings independent of account, on this -/// client. +/// The table of one [GlobalSettingsData] record, the user's chosen settings +/// on this client that are independent of account. /// /// These apply across all the user's accounts on this client (i.e. on this /// install of the app on this device). +/// +/// This table should always have exactly one row (it's created by a migration). @DataClassName('GlobalSettingsData') class GlobalSettings extends Table { Column get themeSetting => textEnum() @@ -78,6 +80,8 @@ VersionedSchema _getSchema({ return Schema3(database: database); case 4: return Schema4(database: database); + case 5: + return Schema5(database: database); default: throw Exception('unknown schema version: $schemaVersion'); } @@ -95,12 +99,12 @@ class AppDatabase extends _$AppDatabase { // See ../../README.md#generated-files for more // information on using the build_runner. // * Update [_getSchema] to handle the new schemaVersion. - // * Write a migration in `onUpgrade` below. + // * Write a migration in `_migrationSteps` below. // * Write tests. @override - int get schemaVersion => 4; // See note. + int get schemaVersion => 5; // See note. - Future _dropAndCreateAll(Migrator m, { + static Future _dropAndCreateAll(Migrator m, { required int schemaVersion, }) async { await m.database.transaction(() async { @@ -125,11 +129,42 @@ class AppDatabase extends _$AppDatabase { }); } + static final MigrationStepWithVersion _migrationSteps = migrationSteps( + from1To2: (m, schema) async { + await m.addColumn(schema.accounts, schema.accounts.ackedPushToken); + }, + from2To3: (m, schema) async { + await m.createTable(schema.globalSettings); + }, + from3To4: (m, schema) async { + await m.addColumn( + schema.globalSettings, schema.globalSettings.browserPreference); + }, + from4To5: (m, schema) async { + // Corresponds to the `into(globalSettings).insert` in `onCreate`. + // This migration ensures there is a row in GlobalSettings. + // (If the app already ran at schema 3 or 4, there will be; + // if not, there won't be before this point.) + await m.database.transaction(() async { + final rows = await m.database.select(schema.globalSettings).get(); + if (rows.isEmpty) { + await m.database.into(schema.globalSettings).insert( + // No field values; just use the defaults for both fields. + // (This is like `GlobalSettingsCompanion.insert()`, but + // without dependence on the current schema.) + RawValuesInsertable({})); + } + }); + }, + ); + @override MigrationStrategy get migration { return MigrationStrategy( onCreate: (Migrator m) async { await m.createAll(); + // Corresponds to `from4to5` above. + await into(globalSettings).insert(GlobalSettingsCompanion()); }, onUpgrade: (Migrator m, int from, int to) async { if (from > to) { @@ -142,39 +177,13 @@ class AppDatabase extends _$AppDatabase { } assert(1 <= from && from <= to && to <= schemaVersion); - await m.runMigrationSteps(from: from, to: to, - steps: migrationSteps( - from1To2: (m, schema) async { - await m.addColumn(schema.accounts, schema.accounts.ackedPushToken); - }, - from2To3: (m, schema) async { - await m.createTable(schema.globalSettings); - }, - from3To4: (m, schema) async { - await m.addColumn( - schema.globalSettings, schema.globalSettings.browserPreference); - }, - )); + await m.runMigrationSteps(from: from, to: to, steps: _migrationSteps); }); } - 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 getGlobalSettings() async { + // The migrations ensure there is a row. + return await (select(globalSettings)..limit(1)).getSingle(); } Future createAccount(AccountsCompanion values) async { diff --git a/lib/model/schema_versions.g.dart b/lib/model/schema_versions.g.dart index aa2dec5a0c..82ffcb7f00 100644 --- a/lib/model/schema_versions.g.dart +++ b/lib/model/schema_versions.g.dart @@ -241,10 +241,56 @@ i1.GeneratedColumn _column_10(String aliasedName) => true, type: i1.DriftSqlType.string, ); + +final class Schema5 extends i0.VersionedSchema { + Schema5({required super.database}) : super(version: 5); + @override + late final List entities = [ + globalSettings, + accounts, + ]; + late final Shape2 globalSettings = Shape2( + source: i0.VersionedTable( + entityName: 'global_settings', + withoutRowId: false, + isStrict: false, + tableConstraints: [], + columns: [_column_9, _column_10], + 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, + ); +} + i0.MigrationStepWithVersion migrationSteps({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, required Future Function(i1.Migrator m, Schema4 schema) from3To4, + required Future Function(i1.Migrator m, Schema5 schema) from4To5, }) { return (currentVersion, database) async { switch (currentVersion) { @@ -263,6 +309,11 @@ i0.MigrationStepWithVersion migrationSteps({ final migrator = i1.Migrator(database, schema); await from3To4(migrator, schema); return 4; + case 4: + final schema = Schema5(database: database); + final migrator = i1.Migrator(database, schema); + await from4To5(migrator, schema); + return 5; default: throw ArgumentError.value('Unknown migration from $currentVersion'); } @@ -273,10 +324,12 @@ i1.OnUpgrade stepByStep({ required Future Function(i1.Migrator m, Schema2 schema) from1To2, required Future Function(i1.Migrator m, Schema3 schema) from2To3, required Future Function(i1.Migrator m, Schema4 schema) from3To4, + required Future Function(i1.Migrator m, Schema5 schema) from4To5, }) => i0.VersionedSchema.stepByStepHelper( step: migrationSteps( from1To2: from1To2, from2To3: from2To3, from3To4: from3To4, + from4To5: from4To5, ), ); diff --git a/lib/model/settings.dart b/lib/model/settings.dart index 928eb0e1cd..44cc5262f3 100644 --- a/lib/model/settings.dart +++ b/lib/model/settings.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import '../generated/l10n/zulip_localizations.dart'; import 'binding.dart'; import 'database.dart'; +import 'store.dart'; /// The user's choice of visual theme for the app. /// @@ -44,7 +45,39 @@ enum BrowserPreference { external, } -extension GlobalSettingsHelpers on GlobalSettingsData { +/// Store for the user's account-independent settings. +/// +/// From UI code, use [GlobalStoreWidget.settingsOf] to get hold of +/// an appropriate instance of this class. +class GlobalSettingsStore extends ChangeNotifier { + GlobalSettingsStore({ + required GlobalStoreBackend backend, + required GlobalSettingsData data, + }) : _backend = backend, _data = data; + + final GlobalStoreBackend _backend; + + /// A cache of the [GlobalSettingsData] singleton in the underlying data store. + GlobalSettingsData _data; + + /// The user's choice of [ThemeSetting]; + /// null means the device-level choice of theme. + /// + /// See also [setThemeSetting]. + ThemeSetting? get themeSetting => _data.themeSetting; + + /// The user's choice of [BrowserPreference]; + /// null means use our default choice. + /// + /// Consider using [effectiveBrowserPreference] or [getUrlLaunchMode]. + /// + /// See also [setBrowserPreference]. + BrowserPreference? get browserPreference => _data.browserPreference; + + /// The value of [BrowserPreference] to use: + /// the user's choice [browserPreference] if any, else our default. + /// + /// See also [getUrlLaunchMode]. BrowserPreference get effectiveBrowserPreference { if (browserPreference != null) return browserPreference!; return switch (defaultTargetPlatform) { @@ -61,6 +94,8 @@ extension GlobalSettingsHelpers on GlobalSettingsData { }; } + /// The launch mode to use with `url_launcher`, + /// based on the user's choice in [browserPreference]. UrlLaunchMode getUrlLaunchMode(Uri url) { switch (effectiveBrowserPreference) { case BrowserPreference.inApp: @@ -78,4 +113,20 @@ extension GlobalSettingsHelpers on GlobalSettingsData { return UrlLaunchMode.externalApplication; } } + + Future _update(GlobalSettingsCompanion data) async { + await _backend.doUpdateGlobalSettings(data); + _data = _data.copyWithCompanion(data); + notifyListeners(); + } + + /// Set [themeSetting], persistently for future runs of the app. + Future setThemeSetting(ThemeSetting? value) async { + await _update(GlobalSettingsCompanion(themeSetting: Value(value))); + } + + /// Set [browserPreference], persistently for future runs of the app. + Future setBrowserPreference(BrowserPreference? value) async { + await _update(GlobalSettingsCompanion(browserPreference: Value(value))); + } } diff --git a/lib/model/store.dart b/lib/model/store.dart index 05a9faabf3..3ca945157a 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -29,6 +29,7 @@ import 'message_list.dart'; import 'recent_dm_conversations.dart'; import 'recent_senders.dart'; import 'channel.dart'; +import 'settings.dart'; import 'typing_status.dart'; import 'unreads.dart'; import 'user.dart'; @@ -36,6 +37,22 @@ import 'user.dart'; export 'package:drift/drift.dart' show Value; export 'database.dart' show Account, AccountsCompanion, AccountAlreadyExistsException; +/// An underlying data store that can support a [GlobalStore], +/// possibly storing the data to persist between runs of the app. +/// +/// In the real app, the implementation used is [LiveGlobalStoreBackend], +/// which stores data persistently in a database on the user's device. +/// This interface enables tests to use a different implementation. +abstract class GlobalStoreBackend { + /// Update the global settings in the underlying data store. + /// + /// This should only be called from [GlobalSettingsStore]. + Future doUpdateGlobalSettings(GlobalSettingsCompanion data); + + // TODO move here the similar methods for accounts; + // perhaps the rest of the GlobalStore abstract methods, too. +} + /// Store for all the user's data. /// /// From UI code, use [GlobalStoreWidget.of] to get hold of an appropriate @@ -54,35 +71,24 @@ export 'database.dart' show Account, AccountsCompanion, AccountAlreadyExistsExce /// we use outside of tests. abstract class GlobalStore extends ChangeNotifier { GlobalStore({ + required GlobalStoreBackend backend, required GlobalSettingsData globalSettings, required Iterable accounts, }) - : _globalSettings = globalSettings, + : settings = GlobalSettingsStore(backend: backend, data: 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 store for the user's account-independent settings. /// - /// 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); + /// When the settings data changes, the [GlobalSettingsStore] will notify + /// its listeners, but the [GlobalStore] will not notify its own listeners. + /// Consider using [GlobalStoreWidget.settingsOf], which automatically + /// subscribes to changes in the [GlobalSettingsStore]. + final GlobalSettingsStore settings; /// A cache of the [Accounts] table in the underlying data store. final Map _accounts; - // TODO settings (those that are per-device rather than per-account) // TODO push token, and other data corresponding to GlobalSessionState /// Construct a new [ApiConnection], real or fake as appropriate. @@ -832,6 +838,23 @@ Uri? tryResolveUrl(Uri baseUrl, String reference) { } } +/// A [GlobalStoreBackend] that uses a live, persistent local database. +/// +/// Used as part of a [LiveGlobalStore]. +/// The underlying data store is an [AppDatabase] corresponding to a +/// SQLite database file in the app's persistent storage on the device. +class LiveGlobalStoreBackend implements GlobalStoreBackend { + LiveGlobalStoreBackend._({required AppDatabase db}) : _db = db; + + final AppDatabase _db; + + @override + Future doUpdateGlobalSettings(GlobalSettingsCompanion data) async { + final rowsAffected = await _db.update(_db.globalSettings).write(data); + assert(rowsAffected == 1); + } +} + /// A [GlobalStore] that uses a live server and live, persistent local database. /// /// The underlying data store is an [AppDatabase] corresponding to a SQLite @@ -841,10 +864,11 @@ Uri? tryResolveUrl(Uri baseUrl, String reference) { /// and will have an associated [UpdateMachine]. class LiveGlobalStore extends GlobalStore { LiveGlobalStore._({ - required AppDatabase db, + required LiveGlobalStoreBackend backend, required super.globalSettings, required super.accounts, - }) : _db = db; + }) : _backend = backend, + super(backend: backend); @override ApiConnection apiConnection({ @@ -859,9 +883,10 @@ 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 globalSettings = await db.getGlobalSettings(); final accounts = await db.select(db.accounts).get(); - return LiveGlobalStore._(db: db, + return LiveGlobalStore._( + backend: LiveGlobalStoreBackend._(db: db), globalSettings: globalSettings, accounts: accounts); } @@ -885,13 +910,13 @@ class LiveGlobalStore extends GlobalStore { return File(p.join(dir.path, 'zulip.db')); } - final AppDatabase _db; + final LiveGlobalStoreBackend _backend; - @override - Future doUpdateGlobalSettings(GlobalSettingsCompanion data) async { - final rowsAffected = await _db.update(_db.globalSettings).write(data); - assert(rowsAffected == 1); - } + // The methods that use this should probably all move to [GlobalStoreBackend] + // and [LiveGlobalStoreBackend] anyway (see comment on the former); + // so let the latter be the canonical home of the [AppDatabase]. + // This getter just simplifies the transition. + AppDatabase get _db => _backend._db; @override Future doLoadPerAccount(int accountId) async { diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index afafd03e8c..3d2f3aa2b8 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -15,7 +15,6 @@ import '../model/avatar_url.dart'; import '../model/binding.dart'; import '../model/content.dart'; import '../model/internal_link.dart'; -import '../model/settings.dart'; import 'code_block.dart'; import 'dialog.dart'; import 'icons.dart'; @@ -1439,7 +1438,7 @@ void _launchUrl(BuildContext context, String urlString) async { return; } - final globalSettings = GlobalStoreWidget.of(context).globalSettings; + final globalSettings = GlobalStoreWidget.settingsOf(context); bool launched = false; String? errorMessage; try { diff --git a/lib/widgets/settings.dart b/lib/widgets/settings.dart index 7777759c4f..3d70f6b39f 100644 --- a/lib/widgets/settings.dart +++ b/lib/widgets/settings.dart @@ -1,8 +1,6 @@ -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'; @@ -33,14 +31,14 @@ class _ThemeSetting extends StatelessWidget { const _ThemeSetting(); void _handleChange(BuildContext context, ThemeSetting? newThemeSetting) { - GlobalStoreWidget.of(context).updateGlobalSettings( - GlobalSettingsCompanion(themeSetting: Value(newThemeSetting))); + final globalSettings = GlobalStoreWidget.settingsOf(context); + globalSettings.setThemeSetting(newThemeSetting); } @override Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); - final globalStore = GlobalStoreWidget.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); return Column( children: [ ListTile(title: Text(zulipLocalizations.themeSettingTitle)), @@ -50,7 +48,7 @@ class _ThemeSetting extends StatelessWidget { themeSetting: themeSettingOption, zulipLocalizations: zulipLocalizations)), value: themeSettingOption, - groupValue: globalStore.globalSettings.themeSetting, + groupValue: globalSettings.themeSetting, onChanged: (newValue) => _handleChange(context, newValue)), ]); } @@ -60,18 +58,18 @@ class _BrowserPreferenceSetting extends StatelessWidget { const _BrowserPreferenceSetting(); void _handleChange(BuildContext context, bool newOpenLinksWithInAppBrowser) { - GlobalStoreWidget.of(context).updateGlobalSettings( - GlobalSettingsCompanion(browserPreference: Value( - newOpenLinksWithInAppBrowser ? BrowserPreference.inApp - : BrowserPreference.external))); + final globalSettings = GlobalStoreWidget.settingsOf(context); + globalSettings.setBrowserPreference( + newOpenLinksWithInAppBrowser ? BrowserPreference.inApp + : BrowserPreference.external); } @override Widget build(BuildContext context) { final zulipLocalizations = ZulipLocalizations.of(context); + final globalSettings = GlobalStoreWidget.settingsOf(context); final openLinksWithInAppBrowser = - GlobalStoreWidget.of(context).globalSettings.effectiveBrowserPreference - == BrowserPreference.inApp; + globalSettings.effectiveBrowserPreference == BrowserPreference.inApp; return SwitchListTile.adaptive( title: Text(zulipLocalizations.openLinksWithInAppBrowser), value: openLinksWithInAppBrowser, diff --git a/lib/widgets/store.dart b/lib/widgets/store.dart index 13c27b9165..ab287b745a 100644 --- a/lib/widgets/store.dart +++ b/lib/widgets/store.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import '../model/binding.dart'; +import '../model/database.dart'; +import '../model/settings.dart'; import '../model/store.dart'; import 'page.dart'; @@ -51,6 +53,28 @@ class GlobalStoreWidget extends StatefulWidget { return widget!.store; } + /// The user's [GlobalSettings] data within the app's global data store. + /// + /// The given build context will be registered as a dependency and + /// subscribed to changes in the returned [GlobalSettingsStore]. + /// This means that when the setting values in the store change, + /// the element at that build context will be rebuilt. + /// + /// This method is typically called near the top of a build method or a + /// [State.didChangeDependencies] method, like so: + /// ``` + /// @override + /// Widget build(BuildContext context) { + /// final globalSettings = GlobalStoreWidget.settingsOf(context); + /// ``` + /// + /// See [of] for further discussion of how to use this kind of method. + static GlobalSettingsStore settingsOf(BuildContext context) { + final widget = context.dependOnInheritedWidgetOfExactType<_GlobalSettingsStoreInheritedWidget>(); + assert(widget != null, 'No GlobalStoreWidget ancestor'); + return widget!.store; + } + @override State createState() => _GlobalStoreWidgetState(); } @@ -81,16 +105,26 @@ class _GlobalStoreWidgetState extends State { // a [StatefulWidget] to get hold of the store, and an [InheritedWidget] to // provide it to descendants, and one widget can't be both of those. class _GlobalStoreInheritedWidget extends InheritedNotifier { - const _GlobalStoreInheritedWidget({ + _GlobalStoreInheritedWidget({ required GlobalStore store, - required super.child, - }) : super(notifier: store); + required Widget child, + }) : super(notifier: store, + child: _GlobalSettingsStoreInheritedWidget( + store: store.settings, child: child)); GlobalStore get store => notifier!; +} - @override - bool updateShouldNotify(covariant _GlobalStoreInheritedWidget oldWidget) => - store != oldWidget.store; +// This is like [_GlobalStoreInheritedWidget] except it subscribes to the +// [GlobalSettingsStore] instead of the overall [GlobalStore]. +// That enables [settingsOf] to do the same. +class _GlobalSettingsStoreInheritedWidget extends InheritedNotifier { + const _GlobalSettingsStoreInheritedWidget({ + required GlobalSettingsStore store, + required super.child, + }) : super(notifier: store); + + GlobalSettingsStore get store => notifier!; } /// Provides access to the user's data for a particular Zulip account. diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index cc2c51fe20..abee956825 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -13,7 +13,7 @@ import 'text.dart'; ThemeData zulipThemeData(BuildContext context) { final DesignVariables designVariables; final List themeExtensions; - final globalSettings = GlobalStoreWidget.of(context).globalSettings; + final globalSettings = GlobalStoreWidget.settingsOf(context); Brightness brightness = switch (globalSettings.themeSetting) { null => MediaQuery.platformBrightnessOf(context), ThemeSetting.light => Brightness.light, diff --git a/test/example_data.dart b/test/example_data.dart index cfb333baec..c2ffcae660 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -11,7 +11,6 @@ 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'; @@ -874,16 +873,6 @@ ChannelUpdateEvent channelUpdateEvent( // The entire per-account or global state. // -GlobalSettingsData globalSettings({ - ThemeSetting? themeSetting, - BrowserPreference? browserPreference, -}) { - return GlobalSettingsData( - themeSetting: themeSetting, - browserPreference: browserPreference, - ); -} - TestGlobalStore globalStore({ GlobalSettingsData? globalSettings, List accounts = const [], diff --git a/test/model/database_test.dart b/test/model/database_test.dart index 28441e13ec..c6245bc5cc 100644 --- a/test/model/database_test.dart +++ b/test/model/database_test.dart @@ -11,6 +11,7 @@ import 'schemas/schema_v1.dart' as v1; import 'schemas/schema_v2.dart' as v2; import 'schemas/schema_v3.dart' as v3; import 'schemas/schema_v4.dart' as v4; +import 'schemas/schema_v5.dart' as v5; import 'store_checks.dart'; void main() { @@ -25,30 +26,25 @@ void main() { }); 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); + check(await database.getGlobalSettings()).themeSetting.isNull(); }); 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()) + check(await database.getGlobalSettings()).themeSetting.isNull(); + }); + + test('GlobalSettings updates work', () async { + check(await database.getGlobalSettings()) + .themeSetting.isNull(); + + // As in doUpdateGlobalSettings. + await database.update(database.globalSettings) + .write(GlobalSettingsCompanion(themeSetting: Value(ThemeSetting.dark))); + check(await database.getGlobalSettings()) .themeSetting.equals(ThemeSetting.dark); }); @@ -225,6 +221,42 @@ void main() { check(globalSettings.browserPreference).isNull(); await after.close(); }); + + test('upgrade to v5: with existing GlobalSettings row, do nothing', () async { + final schema = await verifier.schemaAt(4); + final before = v4.DatabaseAtV4(schema.newConnection()); + await before.into(before.globalSettings).insert( + v4.GlobalSettingsCompanion.insert( + themeSetting: Value(ThemeSetting.light.name))); + await before.close(); + + final db = AppDatabase(schema.newConnection()); + await verifier.migrateAndValidate(db, 5); + await db.close(); + + final after = v5.DatabaseAtV5(schema.newConnection()); + final globalSettings = await after.select(after.globalSettings).getSingle(); + check(globalSettings.themeSetting).equals(ThemeSetting.light.name); + check(globalSettings.browserPreference).isNull(); + await after.close(); + }); + + test('upgrade to v5: with no existing GlobalSettings row, insert one', () async { + final schema = await verifier.schemaAt(4); + final before = v4.DatabaseAtV4(schema.newConnection()); + check(await before.select(before.globalSettings).get()).isEmpty(); + await before.close(); + + final db = AppDatabase(schema.newConnection()); + await verifier.migrateAndValidate(db, 5); + await db.close(); + + final after = v5.DatabaseAtV5(schema.newConnection()); + final globalSettings = await after.select(after.globalSettings).getSingle(); + check(globalSettings.themeSetting).isNull(); + check(globalSettings.browserPreference).isNull(); + await after.close(); + }); }); } diff --git a/test/model/schemas/drift_schema_v5.json b/test/model/schemas/drift_schema_v5.json new file mode 100644 index 0000000000..718222de66 --- /dev/null +++ b/test/model/schemas/drift_schema_v5.json @@ -0,0 +1 @@ +{"_meta":{"description":"This file contains a serialized version of schema entities for drift.","version":"1.2.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(ThemeSetting.values)","dart_type_name":"ThemeSetting"}},{"name":"browser_preference","getter_name":"browserPreference","moor_type":"string","nullable":true,"customConstraints":null,"default_dart":null,"default_client_dart":null,"dsl_features":[],"type_converter":{"dart_expr":"const EnumNameConverter(BrowserPreference.values)","dart_type_name":"BrowserPreference"}}],"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 22131b11bb..c42542afb3 100644 --- a/test/model/schemas/schema.dart +++ b/test/model/schemas/schema.dart @@ -7,6 +7,7 @@ import 'schema_v1.dart' as v1; import 'schema_v2.dart' as v2; import 'schema_v3.dart' as v3; import 'schema_v4.dart' as v4; +import 'schema_v5.dart' as v5; class GeneratedHelper implements SchemaInstantiationHelper { @override @@ -20,10 +21,12 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v3.DatabaseAtV3(db); case 4: return v4.DatabaseAtV4(db); + case 5: + return v5.DatabaseAtV5(db); default: throw MissingSchemaException(version, versions); } } - static const versions = const [1, 2, 3, 4]; + static const versions = const [1, 2, 3, 4, 5]; } diff --git a/test/model/schemas/schema_v5.dart b/test/model/schemas/schema_v5.dart new file mode 100644 index 0000000000..3bf383ef27 --- /dev/null +++ b/test/model/schemas/schema_v5.dart @@ -0,0 +1,700 @@ +// dart format width=80 +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +import 'package:drift/drift.dart'; + +class GlobalSettings extends Table + with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + GlobalSettings(this.attachedDatabase, [this._alias]); + late final GeneratedColumn themeSetting = GeneratedColumn( + 'theme_setting', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + late final GeneratedColumn browserPreference = + GeneratedColumn( + 'browser_preference', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + @override + List get $columns => [themeSetting, browserPreference]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}theme_setting'], + ), + browserPreference: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}browser_preference'], + ), + ); + } + + @override + GlobalSettings createAlias(String alias) { + return GlobalSettings(attachedDatabase, alias); + } +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final String? themeSetting; + final String? browserPreference; + const GlobalSettingsData({this.themeSetting, this.browserPreference}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (!nullToAbsent || themeSetting != null) { + map['theme_setting'] = Variable(themeSetting); + } + if (!nullToAbsent || browserPreference != null) { + map['browser_preference'] = Variable(browserPreference); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: + themeSetting == null && nullToAbsent + ? const Value.absent() + : Value(themeSetting), + browserPreference: + browserPreference == null && nullToAbsent + ? const Value.absent() + : Value(browserPreference), + ); + } + + factory GlobalSettingsData.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: serializer.fromJson(json['themeSetting']), + browserPreference: serializer.fromJson( + json['browserPreference'], + ), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson(themeSetting), + 'browserPreference': serializer.toJson(browserPreference), + }; + } + + GlobalSettingsData copyWith({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + }) => GlobalSettingsData( + themeSetting: themeSetting.present ? themeSetting.value : this.themeSetting, + browserPreference: + browserPreference.present + ? browserPreference.value + : this.browserPreference, + ); + GlobalSettingsData copyWithCompanion(GlobalSettingsCompanion data) { + return GlobalSettingsData( + themeSetting: + data.themeSetting.present + ? data.themeSetting.value + : this.themeSetting, + browserPreference: + data.browserPreference.present + ? data.browserPreference.value + : this.browserPreference, + ); + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsData(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(themeSetting, browserPreference); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is GlobalSettingsData && + other.themeSetting == this.themeSetting && + other.browserPreference == this.browserPreference); +} + +class GlobalSettingsCompanion extends UpdateCompanion { + final Value themeSetting; + final Value browserPreference; + final Value rowid; + const GlobalSettingsCompanion({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.rowid = const Value.absent(), + }); + GlobalSettingsCompanion.insert({ + this.themeSetting = const Value.absent(), + this.browserPreference = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? themeSetting, + Expression? browserPreference, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (themeSetting != null) 'theme_setting': themeSetting, + if (browserPreference != null) 'browser_preference': browserPreference, + if (rowid != null) 'rowid': rowid, + }); + } + + GlobalSettingsCompanion copyWith({ + Value? themeSetting, + Value? browserPreference, + Value? rowid, + }) { + return GlobalSettingsCompanion( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? this.browserPreference, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (themeSetting.present) { + map['theme_setting'] = Variable(themeSetting.value); + } + if (browserPreference.present) { + map['browser_preference'] = Variable(browserPreference.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('GlobalSettingsCompanion(') + ..write('themeSetting: $themeSetting, ') + ..write('browserPreference: $browserPreference, ') + ..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 DatabaseAtV5 extends GeneratedDatabase { + DatabaseAtV5(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 => 5; +} diff --git a/test/model/settings_test.dart b/test/model/settings_test.dart index e4e0def398..66f109158c 100644 --- a/test/model/settings_test.dart +++ b/test/model/settings_test.dart @@ -2,6 +2,7 @@ import 'package:checks/checks.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/model/binding.dart'; +import 'package:zulip/model/database.dart'; import 'package:zulip/model/settings.dart'; import '../example_data.dart' as eg; @@ -14,39 +15,39 @@ void main() { group('getUrlLaunchMode', () { testAndroidIos('globalSettings.browserPreference is null; use our per-platform defaults for HTTP links', () { - final globalStore = eg.globalStore(globalSettings: eg.globalSettings( + final globalStore = eg.globalStore(globalSettings: GlobalSettingsData( browserPreference: null)); - check(globalStore).globalSettings.getUrlLaunchMode(httpLink).equals( + check(globalStore).settings.getUrlLaunchMode(httpLink).equals( defaultTargetPlatform == TargetPlatform.android ? UrlLaunchMode.inAppBrowserView : UrlLaunchMode.externalApplication); }); testAndroidIos('globalSettings.browserPreference is null; use our per-platform defaults for non-HTTP links', () { - final globalStore = eg.globalStore(globalSettings: eg.globalSettings( + final globalStore = eg.globalStore(globalSettings: GlobalSettingsData( browserPreference: null)); - check(globalStore).globalSettings.getUrlLaunchMode(nonHttpLink).equals( + check(globalStore).settings.getUrlLaunchMode(nonHttpLink).equals( defaultTargetPlatform == TargetPlatform.android ? UrlLaunchMode.platformDefault : UrlLaunchMode.externalApplication); }); testAndroidIos('globalSettings.browserPreference is inApp; follow the user preference for http links', () { - final globalStore = eg.globalStore(globalSettings: eg.globalSettings( + final globalStore = eg.globalStore(globalSettings: GlobalSettingsData( browserPreference: BrowserPreference.inApp)); - check(globalStore).globalSettings.getUrlLaunchMode(httpLink).equals( + check(globalStore).settings.getUrlLaunchMode(httpLink).equals( UrlLaunchMode.inAppBrowserView); }); testAndroidIos('globalSettings.browserPreference is inApp; use platform default for non-http links', () { - final globalStore = eg.globalStore(globalSettings: eg.globalSettings( + final globalStore = eg.globalStore(globalSettings: GlobalSettingsData( browserPreference: BrowserPreference.inApp)); - check(globalStore).globalSettings.getUrlLaunchMode(nonHttpLink).equals( + check(globalStore).settings.getUrlLaunchMode(nonHttpLink).equals( UrlLaunchMode.platformDefault); }); testAndroidIos('globalSettings.browserPreference is external; follow the user preference', () { - final globalStore = eg.globalStore(globalSettings: eg.globalSettings( + final globalStore = eg.globalStore(globalSettings: GlobalSettingsData( browserPreference: BrowserPreference.external)); - check(globalStore).globalSettings.getUrlLaunchMode(httpLink).equals( + check(globalStore).settings.getUrlLaunchMode(httpLink).equals( UrlLaunchMode.externalApplication); }); }); diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index 96edf05d41..170be3ca47 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -10,6 +10,11 @@ import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/model/unreads.dart'; +extension GlobalSettingsDataChecks on Subject { + Subject get themeSetting => has((x) => x.themeSetting, 'themeSetting'); + Subject get browserPreference => has((x) => x.browserPreference, 'browserPreference'); +} + extension AccountChecks on Subject { Subject get id => has((x) => x.id, 'id'); Subject get realmUrl => has((x) => x.realmUrl, 'realmUrl'); @@ -22,21 +27,21 @@ extension AccountChecks on Subject { Subject get ackedPushToken => has((x) => x.ackedPushToken, 'ackedPushToken'); } +extension GlobalSettingsStoreChecks on Subject { + Subject get themeSetting => has((x) => x.themeSetting, 'themeSetting'); + Subject get browserPreference => has((x) => x.browserPreference, 'browserPreference'); + Subject get effectiveBrowserPreference => has((x) => x.effectiveBrowserPreference, 'effectiveBrowserPreference'); + Subject getUrlLaunchMode(Uri url) => has((x) => x.getUrlLaunchMode(url), 'getUrlLaunchMode'); +} + extension GlobalStoreChecks on Subject { - Subject get globalSettings => has((x) => x.globalSettings, 'globalSettings'); + Subject get settings => has((x) => x.settings, 'settings'); 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'); - Subject get browserPreference => has((x) => x.browserPreference, 'browserPreference'); - Subject get effectiveBrowserPreference => has((x) => x.effectiveBrowserPreference, 'effectiveBrowserPreference'); - Subject getUrlLaunchMode(Uri url) => has((x) => x.getUrlLaunchMode(url), 'getUrlLaunchMode'); -} - 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 4bcb272e9e..a00194c8f8 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -15,7 +15,6 @@ 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'; @@ -35,22 +34,19 @@ void main() { group('GlobalStore.updateGlobalSettings', () { test('smoke', () async { final globalStore = eg.globalStore(); - check(globalStore).globalSettings.themeSetting.equals(null); + check(globalStore).settings.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); + await globalStore.settings.setThemeSetting(ThemeSetting.dark); + check(globalStore).settings.themeSetting.equals(ThemeSetting.dark); }); test('should notify listeners', () async { int notifyCount = 0; final globalStore = eg.globalStore(); - globalStore.addListener(() => notifyCount++); + globalStore.settings.addListener(() => notifyCount++); check(notifyCount).equals(0); - await globalStore.updateGlobalSettings( - GlobalSettingsCompanion(themeSetting: Value(ThemeSetting.light))); + await globalStore.settings.setThemeSetting(ThemeSetting.light); check(notifyCount).equals(1); }); diff --git a/test/model/test_store.dart b/test/model/test_store.dart index 7e11c62ee1..b819125091 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -62,12 +62,14 @@ mixin _ApiConnectionsMixin on GlobalStore { } } -mixin _DatabaseMixin on GlobalStore { +class _TestGlobalStoreBackend implements GlobalStoreBackend { @override Future doUpdateGlobalSettings(GlobalSettingsCompanion data) async { // Nothing to do. } +} +mixin _DatabaseMixin on GlobalStore { int _nextAccountId = 1; @override @@ -138,7 +140,8 @@ class TestGlobalStore extends GlobalStore with _ApiConnectionsMixin, _DatabaseMi TestGlobalStore({ GlobalSettingsData? globalSettings, required super.accounts, - }) : super(globalSettings: globalSettings ?? eg.globalSettings()); + }) : super(backend: _TestGlobalStoreBackend(), + globalSettings: globalSettings ?? GlobalSettingsData()); final Map _initialSnapshots = {}; @@ -202,7 +205,8 @@ class UpdateMachineTestGlobalStore extends GlobalStore with _ApiConnectionsMixin UpdateMachineTestGlobalStore({ GlobalSettingsData? globalSettings, required super.accounts, - }) : super(globalSettings: globalSettings ?? eg.globalSettings()); + }) : super(backend: _TestGlobalStoreBackend(), + globalSettings: globalSettings ?? GlobalSettingsData()); // [doLoadPerAccount] depends on the cache to prepare the API responses. // Calling [clearCachedApiConnections] is permitted, though. diff --git a/test/widgets/content_test.dart b/test/widgets/content_test.dart index c2bb5fee4f..f871615571 100644 --- a/test/widgets/content_test.dart +++ b/test/widgets/content_test.dart @@ -800,9 +800,8 @@ void main() { }, variant: const TargetPlatformVariant({TargetPlatform.android, TargetPlatform.iOS})); testWidgets('follow browser preference setting to open URL', (tester) async { - await testBinding.globalStore.updateGlobalSettings( - eg.globalSettings( - browserPreference: BrowserPreference.inApp).toCompanion(false)); + await testBinding.globalStore.settings + .setBrowserPreference(BrowserPreference.inApp); await prepare(tester, '

hello

'); diff --git a/test/widgets/settings_test.dart b/test/widgets/settings_test.dart index 65336479eb..ced9c3f853 100644 --- a/test/widgets/settings_test.dart +++ b/test/widgets/settings_test.dart @@ -44,14 +44,13 @@ void main() { .checked.equals(title == expectedCheckedTitle); } check(testBinding.globalStore) - .globalSettings.themeSetting.equals(expectedThemeSetting); + .settings.themeSetting.equals(expectedThemeSetting); } testWidgets('smoke', (tester) async { debugBrightnessOverride = Brightness.light; - await testBinding.globalStore.updateGlobalSettings( - eg.globalSettings(themeSetting: ThemeSetting.light).toCompanion(false)); + await testBinding.globalStore.settings.setThemeSetting(ThemeSetting.light); await prepare(tester); final element = tester.element(find.byType(SettingsPage)); check(Theme.of(element)).brightness.equals(Brightness.light); @@ -96,13 +95,12 @@ void main() { check(tester.widget(useInAppBrowserSwitchFinder)) .value.equals(checked); check(testBinding.globalStore) - .globalSettings.browserPreference.equals(expectedBrowserPreference); + .settings.browserPreference.equals(expectedBrowserPreference); } testWidgets('smoke', (tester) async { - await testBinding.globalStore.updateGlobalSettings( - eg.globalSettings( - browserPreference: BrowserPreference.external).toCompanion(false)); + await testBinding.globalStore.settings + .setBrowserPreference(BrowserPreference.external); await prepare(tester); checkSwitchAndGlobalSettings(tester, checked: false, expectedBrowserPreference: BrowserPreference.external); diff --git a/test/widgets/store_test.dart b/test/widgets/store_test.dart index dc67647eb7..f8da5e24a0 100644 --- a/test/widgets/store_test.dart +++ b/test/widgets/store_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_checks/flutter_checks.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:zulip/model/actions.dart'; +import 'package:zulip/model/settings.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app.dart'; import 'package:zulip/widgets/inbox.dart'; @@ -57,7 +58,7 @@ extension MyWidgetWithMixinStateChecks on Subject { void main() { TestZulipBinding.ensureInitialized(); - testWidgets('GlobalStoreWidget', (tester) async { + testWidgets('GlobalStoreWidget loads data while showing placeholder', (tester) async { addTearDown(testBinding.reset); GlobalStore? globalStore; @@ -83,6 +84,45 @@ void main() { .equals((accountId: eg.selfAccount.id, account: eg.selfAccount)); }); + testWidgets('GlobalStoreWidget.of updates dependents', (tester) async { + addTearDown(testBinding.reset); + + List? accountIds; + await tester.pumpWidget( + Directionality(textDirection: TextDirection.ltr, + child: GlobalStoreWidget( + child: Builder(builder: (context) { + accountIds = GlobalStoreWidget.of(context).accountIds.toList(); + return SizedBox.shrink(); + })))); + await tester.pump(); + check(accountIds).isNotNull().isEmpty(); + + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + await tester.pump(); + check(accountIds).isNotNull().deepEquals([eg.selfAccount.id]); + }); + + testWidgets('GlobalStoreWidget.settingsOf updates on settings update', (tester) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.settings.setThemeSetting(ThemeSetting.dark); + + ThemeSetting? themeSetting; + await tester.pumpWidget( + GlobalStoreWidget( + child: Builder( + builder: (context) { + themeSetting = GlobalStoreWidget.settingsOf(context).themeSetting; + return const SizedBox.shrink(); + }))); + await tester.pump(); + check(themeSetting).equals(ThemeSetting.dark); + + await testBinding.globalStore.settings.setThemeSetting(ThemeSetting.light); + await tester.pump(); + check(themeSetting).equals(ThemeSetting.light); + }); + testWidgets('PerAccountStoreWidget basic', (tester) async { await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); addTearDown(testBinding.reset); diff --git a/test/widgets/theme_test.dart b/test/widgets/theme_test.dart index 2437049a51..60f834bb36 100644 --- a/test/widgets/theme_test.dart +++ b/test/widgets/theme_test.dart @@ -1,9 +1,7 @@ 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'; @@ -111,7 +109,7 @@ void main() { await tester.pumpWidget(const TestZulipApp(child: Placeholder())); await tester.pump(); - check(testBinding.globalStore).globalSettings.themeSetting.isNull(); + check(testBinding.globalStore).settings.themeSetting.isNull(); final element = tester.element(find.byType(Placeholder)); check(zulipThemeData(element)).brightness.equals(Brightness.light); @@ -129,17 +127,15 @@ void main() { await tester.pumpWidget(const TestZulipApp(child: Placeholder())); await tester.pump(); - check(testBinding.globalStore).globalSettings.themeSetting.isNull(); + check(testBinding.globalStore).settings.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))); + await testBinding.globalStore.settings.setThemeSetting(ThemeSetting.dark); check(zulipThemeData(element)).brightness.equals(Brightness.dark); - await testBinding.globalStore.updateGlobalSettings( - const GlobalSettingsCompanion(themeSetting: Value(null))); + await testBinding.globalStore.settings.setThemeSetting(null); check(zulipThemeData(element)).brightness.equals(Brightness.light); });