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 687011026f..236a966d14 100644 --- a/lib/model/database.dart +++ b/lib/model/database.dart @@ -1,10 +1,5 @@ -import 'dart:io'; - import 'package:drift/drift.dart'; -import 'package:drift/native.dart'; import 'package:drift/remote.dart'; -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; import 'package:sqlite3/common.dart'; part 'database.g.dart'; @@ -39,8 +34,6 @@ class Accounts extends Table { Column get ackedPushToken => text().nullable()(); - // If adding a column, be sure to add it to copyWithCompanion too. - @override List>> get uniqueKeys => [ {realmUrl, userId}, @@ -48,20 +41,48 @@ class Accounts extends Table { ]; } -extension AccountExtension on Account { - Account copyWithCompanion(AccountsCompanion data) { // TODO(drift): generate this - return Account( - id: data.id.present ? data.id.value : id, - realmUrl: data.realmUrl.present ? data.realmUrl.value : realmUrl, - userId: data.userId.present ? data.userId.value : userId, - email: data.email.present ? data.email.value : email, - apiKey: data.apiKey.present ? data.apiKey.value : apiKey, - zulipVersion: data.zulipVersion.present ? data.zulipVersion.value : zulipVersion, - zulipMergeBase: data.zulipMergeBase.present ? data.zulipMergeBase.value : zulipMergeBase, - zulipFeatureLevel: data.zulipFeatureLevel.present ? data.zulipFeatureLevel.value : zulipFeatureLevel, - ackedPushToken: data.ackedPushToken.present ? data.ackedPushToken.value : ackedPushToken, - ); - } +/// The visual theme of the app. +/// +/// See [zulipThemeData] for how themes are determined. +enum ThemeSetting { + /// Corresponds to the default platform setting. + none, + + /// Corresponds to [Brightness.light]. + light, + + /// Corresponds to [Brightness.dark]. + dark, +} + +/// What browser the user has set to use for opening links in messages. +/// +/// See https://chat.zulip.org/#narrow/stream/48-mobile/topic/in-app.20browser +/// for the reasoning behind these options. +enum BrowserPreference { + /// Use [UrlLaunchMode.externalApplication] on iOS, + /// [UrlLaunchMode.platformDefault] on Android. + none, + + /// Use the in-app browser. + embedded, + + /// Use the user's default browser app. + external, +} + +/// 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 { + // TODO(db): Maybe make this optional and/or use clientDefault? + Column get themeSetting => textEnum()(); + + // TODO(db): Maybe make this optional and/or use clientDefault? + Column get browserPreference => textEnum()(); } class UriConverter extends TypeConverter { @@ -70,21 +91,10 @@ class UriConverter extends TypeConverter { @override Uri fromSql(String fromDb) => Uri.parse(fromDb); } -LazyDatabase _openConnection() { - return LazyDatabase(() async { - // TODO decide if this path is the right one to use - final dbFolder = await getApplicationDocumentsDirectory(); - final file = File(path.join(dbFolder.path, 'db.sqlite')); - return NativeDatabase.createInBackground(file); - }); -} - -@DriftDatabase(tables: [Accounts]) +@DriftDatabase(tables: [Accounts, GlobalSettings]) class AppDatabase extends _$AppDatabase { AppDatabase(super.e); - AppDatabase.live() : this(_openConnection()); - // When updating the schema: // * Make the change in the table classes, and bump schemaVersion. // * Export the new schema and generate test migrations: @@ -92,7 +102,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. @override MigrationStrategy get migration { @@ -118,6 +128,10 @@ class AppDatabase extends _$AppDatabase { if (from < 2 && 2 <= to) { await m.addColumn(accounts, accounts.ackedPushToken); } + + if (from < 3 && 3 <= to) { + await m.createTable(globalSettings); + } // New migrations go here. } ); @@ -138,6 +152,20 @@ class AppDatabase extends _$AppDatabase { rethrow; } } + + Future ensureGlobalSettings() async { + final settings = await select(globalSettings).getSingleOrNull(); + // TODO(db): Enforce the singleton constraint more robustly. + if (settings != null) { + return settings; + } + + await into(globalSettings).insert(GlobalSettingsCompanion.insert( + themeSetting: ThemeSetting.none, + browserPreference: BrowserPreference.none, + )); + return select(globalSettings).getSingle(); + } } class AccountAlreadyExistsException implements Exception {} diff --git a/lib/model/database.g.dart b/lib/model/database.g.dart index 9285a8abae..c3149be01b 100644 --- a/lib/model/database.g.dart +++ b/lib/model/database.g.dart @@ -501,15 +501,240 @@ class AccountsCompanion extends UpdateCompanion { } } +class $GlobalSettingsTable extends GlobalSettings + with TableInfo<$GlobalSettingsTable, GlobalSettingsData> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $GlobalSettingsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _themeSettingMeta = + const VerificationMeta('themeSetting'); + @override + late final GeneratedColumnWithTypeConverter + themeSetting = GeneratedColumn( + 'theme_setting', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter( + $GlobalSettingsTable.$converterthemeSetting); + static const VerificationMeta _browserPreferenceMeta = + const VerificationMeta('browserPreference'); + @override + late final GeneratedColumnWithTypeConverter + browserPreference = GeneratedColumn( + 'browser_preference', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true) + .withConverter( + $GlobalSettingsTable.$converterbrowserPreference); + @override + List get $columns => [themeSetting, browserPreference]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'global_settings'; + @override + VerificationContext validateIntegrity(Insertable instance, + {bool isInserting = false}) { + final context = VerificationContext(); + final data = instance.toColumns(true); + context.handle(_themeSettingMeta, const VerificationResult.success()); + context.handle(_browserPreferenceMeta, const VerificationResult.success()); + return context; + } + + @override + Set get $primaryKey => const {}; + @override + GlobalSettingsData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return GlobalSettingsData( + themeSetting: $GlobalSettingsTable.$converterthemeSetting.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, data['${effectivePrefix}theme_setting'])!), + browserPreference: $GlobalSettingsTable.$converterbrowserPreference + .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string, + data['${effectivePrefix}browser_preference'])!), + ); + } + + @override + $GlobalSettingsTable createAlias(String alias) { + return $GlobalSettingsTable(attachedDatabase, alias); + } + + static JsonTypeConverter2 + $converterthemeSetting = + const EnumNameConverter(ThemeSetting.values); + static JsonTypeConverter2 + $converterbrowserPreference = + const EnumNameConverter(BrowserPreference.values); +} + +class GlobalSettingsData extends DataClass + implements Insertable { + final ThemeSetting themeSetting; + final BrowserPreference browserPreference; + const GlobalSettingsData( + {required this.themeSetting, required this.browserPreference}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + { + map['theme_setting'] = Variable( + $GlobalSettingsTable.$converterthemeSetting.toSql(themeSetting)); + } + { + map['browser_preference'] = Variable($GlobalSettingsTable + .$converterbrowserPreference + .toSql(browserPreference)); + } + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: Value(themeSetting), + browserPreference: Value(browserPreference), + ); + } + + factory GlobalSettingsData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return GlobalSettingsData( + themeSetting: $GlobalSettingsTable.$converterthemeSetting + .fromJson(serializer.fromJson(json['themeSetting'])), + browserPreference: $GlobalSettingsTable.$converterbrowserPreference + .fromJson(serializer.fromJson(json['browserPreference'])), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'themeSetting': serializer.toJson( + $GlobalSettingsTable.$converterthemeSetting.toJson(themeSetting)), + 'browserPreference': serializer.toJson($GlobalSettingsTable + .$converterbrowserPreference + .toJson(browserPreference)), + }; + } + + GlobalSettingsData copyWith( + {ThemeSetting? themeSetting, BrowserPreference? browserPreference}) => + GlobalSettingsData( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? 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({ + required ThemeSetting themeSetting, + required BrowserPreference browserPreference, + this.rowid = const Value.absent(), + }) : themeSetting = Value(themeSetting), + browserPreference = Value(browserPreference); + 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($GlobalSettingsTable + .$converterthemeSetting + .toSql(themeSetting.value)); + } + if (browserPreference.present) { + map['browser_preference'] = Variable($GlobalSettingsTable + .$converterbrowserPreference + .toSql(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(); + } +} + abstract class _$AppDatabase extends GeneratedDatabase { _$AppDatabase(QueryExecutor e) : super(e); $AppDatabaseManager get managers => $AppDatabaseManager(this); late final $AccountsTable accounts = $AccountsTable(this); + late final $GlobalSettingsTable globalSettings = $GlobalSettingsTable(this); @override Iterable> get allTables => allSchemaEntities.whereType>(); @override - List get allSchemaEntities => [accounts]; + List get allSchemaEntities => + [accounts, globalSettings]; } typedef $$AccountsTableCreateCompanionBuilder = AccountsCompanion Function({ @@ -698,9 +923,96 @@ class $$AccountsTableOrderingComposer ColumnOrderings(column, joinBuilders: joinBuilders)); } +typedef $$GlobalSettingsTableCreateCompanionBuilder = GlobalSettingsCompanion + Function({ + required ThemeSetting themeSetting, + required BrowserPreference browserPreference, + Value rowid, +}); +typedef $$GlobalSettingsTableUpdateCompanionBuilder = GlobalSettingsCompanion + Function({ + Value themeSetting, + Value browserPreference, + Value rowid, +}); + +class $$GlobalSettingsTableTableManager extends RootTableManager< + _$AppDatabase, + $GlobalSettingsTable, + GlobalSettingsData, + $$GlobalSettingsTableFilterComposer, + $$GlobalSettingsTableOrderingComposer, + $$GlobalSettingsTableCreateCompanionBuilder, + $$GlobalSettingsTableUpdateCompanionBuilder> { + $$GlobalSettingsTableTableManager( + _$AppDatabase db, $GlobalSettingsTable table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: + $$GlobalSettingsTableFilterComposer(ComposerState(db, table)), + orderingComposer: + $$GlobalSettingsTableOrderingComposer(ComposerState(db, table)), + updateCompanionCallback: ({ + Value themeSetting = const Value.absent(), + Value browserPreference = const Value.absent(), + Value rowid = const Value.absent(), + }) => + GlobalSettingsCompanion( + themeSetting: themeSetting, + browserPreference: browserPreference, + rowid: rowid, + ), + createCompanionCallback: ({ + required ThemeSetting themeSetting, + required BrowserPreference browserPreference, + Value rowid = const Value.absent(), + }) => + GlobalSettingsCompanion.insert( + themeSetting: themeSetting, + browserPreference: browserPreference, + rowid: rowid, + ), + )); +} + +class $$GlobalSettingsTableFilterComposer + extends FilterComposer<_$AppDatabase, $GlobalSettingsTable> { + $$GlobalSettingsTableFilterComposer(super.$state); + ColumnWithTypeConverterFilters + get themeSetting => $state.composableBuilder( + column: $state.table.themeSetting, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters + get browserPreference => $state.composableBuilder( + column: $state.table.browserPreference, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); +} + +class $$GlobalSettingsTableOrderingComposer + extends OrderingComposer<_$AppDatabase, $GlobalSettingsTable> { + $$GlobalSettingsTableOrderingComposer(super.$state); + ColumnOrderings get themeSetting => $state.composableBuilder( + column: $state.table.themeSetting, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get browserPreference => $state.composableBuilder( + column: $state.table.browserPreference, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + class $AppDatabaseManager { final _$AppDatabase _db; $AppDatabaseManager(this._db); $$AccountsTableTableManager get accounts => $$AccountsTableTableManager(_db, _db.accounts); + $$GlobalSettingsTableTableManager get globalSettings => + $$GlobalSettingsTableTableManager(_db, _db.globalSettings); } diff --git a/lib/model/store.dart b/lib/model/store.dart index 3a3b59bea0..ef73317e20 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -51,12 +51,19 @@ 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 Iterable accounts, + required GlobalSettingsData globalSettings, + }) + : _accounts = Map.fromEntries(accounts.map((a) => MapEntry(a.id, a))), + _globalSettings = globalSettings; /// A cache of the [Accounts] table in the underlying data store. final Map _accounts; + /// A cache of the [GlobalSettingsData] singleton in the underlying data store. + GlobalSettingsData _globalSettings; + // TODO settings (those that are per-device rather than per-account) // TODO push token, and other data corresponding to GlobalSessionState @@ -223,6 +230,23 @@ abstract class GlobalStore extends ChangeNotifier { /// Remove an account from the underlying data store. Future doRemoveAccount(int accountId); + GlobalSettingsData get globalSettings => _globalSettings; + + /// Update the global settings in the store, return 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); + @override String toString() => '${objectRuntimeType(this, 'GlobalStore')}#${shortHash(this)}'; } @@ -757,6 +781,7 @@ class LiveGlobalStore extends GlobalStore { LiveGlobalStore._({ required AppDatabase db, required super.accounts, + required super.globalSettings, }) : _db = db; @override @@ -773,24 +798,30 @@ class LiveGlobalStore extends GlobalStore { static Future load() async { final db = AppDatabase(NativeDatabase.createInBackground(await _dbFile())); final accounts = await db.select(db.accounts).get(); - return LiveGlobalStore._(db: db, accounts: accounts); + final globalSettings = await db.ensureGlobalSettings(); + return LiveGlobalStore._(db: db, + accounts: accounts, + globalSettings: globalSettings); } /// The file path to use for the app database. static Future _dbFile() async { - // What directory should we use? - // path_provider's getApplicationSupportDirectory: - // on Android, -> Flutter's PathUtils.getFilesDir -> https://developer.android.com/reference/android/content/Context#getFilesDir() - // -> empirically /data/data/com.zulip.flutter/files/ - // on iOS, -> "Library/Application Support" via https://developer.apple.com/documentation/foundation/nssearchpathdirectory/nsapplicationsupportdirectory - // on Linux, -> "${XDG_DATA_HOME:-~/.local/share}/com.zulip.flutter/" - // All seem reasonable. - // path_provider's getApplicationDocumentsDirectory: - // on Android, -> Flutter's PathUtils.getDataDirectory -> https://developer.android.com/reference/android/content/Context#getDir(java.lang.String,%20int) - // with https://developer.android.com/reference/android/content/Context#MODE_PRIVATE - // on iOS, "Document directory" via https://developer.apple.com/documentation/foundation/nssearchpathdirectory/nsdocumentdirectory - // on Linux, -> `xdg-user-dir DOCUMENTS` -> e.g. ~/Documents - // That Linux answer is definitely not a fit. Harder to tell about the rest. + // path_provider's getApplicationSupportDirectory: + // on Android, -> Flutter's PathUtils.getFilesDir -> https://developer.android.com/reference/android/content/Context#getFilesDir() + // -> empirically /data/data/com.zulip.flutter/files/ + // on iOS, -> "Library/Application Support" via https://developer.apple.com/documentation/foundation/nssearchpathdirectory/nsapplicationsupportdirectory + // on Linux, -> "${XDG_DATA_HOME:-~/.local/share}/com.zulip.flutter/" + // + // This is reasonable for iOS per Apple's recommendation: + // > Use ["Library/Application Support"] to store all app data files except + // > those associated with the user’s documents. For example, you might use + // > this directory to store app-created data files, configuration files, + // > templates, or other fixed or modifiable resources that are managed by + // > the app. + // See: https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemProgrammingGuide/FileSystemOverview/FileSystemOverview.html#//apple_ref/doc/uid/TP40010672-CH2-SW2 + // + // The paths are reasonable for both Android and Linux, compared to the + // ones from using path_provider's getApplicationDocumentsDirectory. final dir = await getApplicationSupportDirectory(); return File(p.join(dir.path, 'zulip.db')); } @@ -832,6 +863,12 @@ class LiveGlobalStore extends GlobalStore { assert(rowsAffected == 1); } + @override + Future doUpdateGlobalSettings(GlobalSettingsCompanion data) async { + final rowsAffected = await _db.update(_db.globalSettings).write(data); + assert(rowsAffected == 1); + } + @override String toString() => '${objectRuntimeType(this, 'LiveGlobalStore')}#${shortHash(this)}'; } diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart index f3b369e876..64bbfff76c 100644 --- a/lib/widgets/content.dart +++ b/lib/widgets/content.dart @@ -14,6 +14,7 @@ import '../generated/l10n/zulip_localizations.dart'; import '../model/avatar_url.dart'; import '../model/binding.dart'; import '../model/content.dart'; +import '../model/database.dart'; import '../model/internal_link.dart'; import 'code_block.dart'; import 'dialog.dart'; @@ -1342,17 +1343,20 @@ void _launchUrl(BuildContext context, String urlString) async { return; } + final globalSettings = GlobalStoreWidget.of(context).globalSettings; bool launched = false; String? errorMessage; try { launched = await ZulipBinding.instance.launchUrl(url, - mode: switch (defaultTargetPlatform) { + mode: switch ((globalSettings.browserPreference, defaultTargetPlatform)) { + (BrowserPreference.embedded, _) => UrlLaunchMode.inAppBrowserView, + (BrowserPreference.external, _) => UrlLaunchMode.externalApplication, // On iOS we prefer LaunchMode.externalApplication because (for // HTTP URLs) LaunchMode.platformDefault uses SFSafariViewController, // which gives an awkward UX as described here: // https://chat.zulip.org/#narrow/stream/48-mobile/topic/in-app.20browser/near/1169118 - TargetPlatform.iOS => UrlLaunchMode.externalApplication, - _ => UrlLaunchMode.platformDefault, + (BrowserPreference.none, TargetPlatform.iOS) => UrlLaunchMode.externalApplication, + (BrowserPreference.none, _) => UrlLaunchMode.platformDefault, }); } on PlatformException catch (e) { errorMessage = e.message; diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index ec6cd81ce0..e86317bbdf 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -1,16 +1,27 @@ import 'package:flutter/material.dart'; import '../api/model/model.dart'; +import '../model/database.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) { + case ThemeSetting.none: + brightness = MediaQuery.platformBrightnessOf(context); + case ThemeSetting.light: + brightness = Brightness.light; + case ThemeSetting.dark: + brightness = 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/test/example_data.dart b/test/example_data.dart index d071307ec4..8c983a00fb 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -8,6 +8,7 @@ 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/store.dart'; @@ -805,8 +806,12 @@ ChannelUpdateEvent channelUpdateEvent( // The entire per-account or global state. // -TestGlobalStore globalStore({List accounts = const []}) { - return TestGlobalStore(accounts: accounts); +TestGlobalStore globalStore({ + List accounts = const [], + GlobalSettingsData globalSettings = const GlobalSettingsData( + themeSetting: ThemeSetting.none, browserPreference: BrowserPreference.none) +}) { + return TestGlobalStore(accounts: accounts, globalSettings: globalSettings); } InitialSnapshot initialSnapshot({ diff --git a/test/model/binding.dart b/test/model/binding.dart index badbbcf7e0..600c328f06 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -8,6 +8,7 @@ import 'package:test/fake.dart'; import 'package:url_launcher/url_launcher.dart' as url_launcher; import 'package:zulip/host/android_notifications.dart'; import 'package:zulip/model/binding.dart'; +import 'package:zulip/model/database.dart'; import 'package:zulip/model/store.dart'; import 'package:zulip/widgets/app.dart'; @@ -29,7 +30,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]. @@ -85,7 +86,11 @@ 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 ??= TestGlobalStore( + accounts: [], + globalSettings: const GlobalSettingsData( + themeSetting: ThemeSetting.none, + browserPreference: BrowserPreference.none)); TestGlobalStore? _globalStore; bool _debugAlreadyLoadedStore = false; diff --git a/test/model/database_test.dart b/test/model/database_test.dart index 5194e45f8c..4484439cff 100644 --- a/test/model/database_test.dart +++ b/test/model/database_test.dart @@ -89,6 +89,26 @@ void main() { await check(database.createAccount(accountDataWithSameEmail)) .throws(); }); + + test('initialize GlobalSettings with defaults', () async { + check(await database.ensureGlobalSettings()) + ..themeSetting.equals(ThemeSetting.none) + ..browserPreference.equals(BrowserPreference.none); + }); + + test('ensure single GlobalSettings row', () async { + check(await database.select(database.globalSettings).getSingleOrNull()) + .isNull(); + + final globalSettings = await database.ensureGlobalSettings(); + check(await database.select(database.globalSettings).getSingle()) + .equals(globalSettings); + + // Subsequent calls to `ensureGlobalSettings` do not insert new rows. + check(await database.ensureGlobalSettings()).equals(globalSettings); + check(await database.select(database.globalSettings).getSingle()) + .equals(globalSettings); + }); }); group('migrations', () { @@ -131,6 +151,13 @@ void main() { 'ackedPushToken': null, }); }); + + test('upgrade to v3', () async { + final connection = await verifier.startAt(2); + final db = AppDatabase(connection); + await verifier.migrateAndValidate(db, 3); + await db.close(); + }); }); } @@ -150,3 +177,8 @@ extension UpdateCompanionExtension on UpdateCompanion { }; } } + +extension GlobalSettingsDataChecks on Subject { + Subject get themeSetting => has((x) => x.themeSetting, 'themeSetting'); + Subject get browserPreference => has((x) => x.browserPreference, 'browserPreference'); +} diff --git a/test/model/schemas/drift_schema_v3.json b/test/model/schemas/drift_schema_v3.json new file mode 100644 index 0000000000..5fda06554a --- /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.1.0"},"options":{"store_date_time_values_as_text":false},"entities":[{"id":0,"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","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"]]}},{"id":1,"references":[],"type":"table","data":{"name":"global_settings","was_declared_in_moor":false,"columns":[{"name":"theme_setting","getter_name":"themeSetting","moor_type":"string","nullable":false,"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":false,"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":[]}}]} \ No newline at end of file diff --git a/test/model/schemas/schema.dart b/test/model/schemas/schema.dart index c8c6ff5926..1c9347e900 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,8 +15,10 @@ class GeneratedHelper implements SchemaInstantiationHelper { return v1.DatabaseAtV1(db); case 2: return v2.DatabaseAtV2(db); + case 3: + return v3.DatabaseAtV3(db); default: - throw MissingSchemaException(version, const {1, 2}); + throw MissingSchemaException(version, 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..8cba7d4e93 --- /dev/null +++ b/test/model/schemas/schema_v3.dart @@ -0,0 +1,586 @@ +// GENERATED CODE, DO NOT EDIT BY HAND. +// ignore_for_file: type=lint +//@dart=2.12 +import 'package:drift/drift.dart'; + +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 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, false, + type: DriftSqlType.string, requiredDuringInsert: true); + late final GeneratedColumn browserPreference = + GeneratedColumn('browser_preference', aliasedName, false, + type: DriftSqlType.string, requiredDuringInsert: true); + @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( + {required this.themeSetting, required this.browserPreference}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['theme_setting'] = Variable(themeSetting); + map['browser_preference'] = Variable(browserPreference); + return map; + } + + GlobalSettingsCompanion toCompanion(bool nullToAbsent) { + return GlobalSettingsCompanion( + themeSetting: Value(themeSetting), + browserPreference: 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( + {String? themeSetting, String? browserPreference}) => + GlobalSettingsData( + themeSetting: themeSetting ?? this.themeSetting, + browserPreference: browserPreference ?? 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({ + required String themeSetting, + required String browserPreference, + this.rowid = const Value.absent(), + }) : themeSetting = Value(themeSetting), + browserPreference = Value(browserPreference); + 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 DatabaseAtV3 extends GeneratedDatabase { + DatabaseAtV3(QueryExecutor e) : super(e); + late final Accounts accounts = Accounts(this); + late final GlobalSettings globalSettings = GlobalSettings(this); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => + [accounts, globalSettings]; + @override + int get schemaVersion => 3; +} diff --git a/test/model/store_test.dart b/test/model/store_test.dart index 10e8698360..9af81a3da5 100644 --- a/test/model/store_test.dart +++ b/test/model/store_test.dart @@ -12,6 +12,7 @@ import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/route/events.dart'; import 'package:zulip/api/route/messages.dart'; import 'package:zulip/api/route/realm.dart'; +import 'package:zulip/model/database.dart'; import 'package:zulip/model/message_list.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/log.dart'; @@ -406,7 +407,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) @@ -581,7 +582,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); @@ -1086,7 +1087,12 @@ void main() { } class LoadingTestGlobalStore extends TestGlobalStore { - LoadingTestGlobalStore({required super.accounts}); + LoadingTestGlobalStore({ + super.accounts = const [], + super.globalSettings = const GlobalSettingsData( + themeSetting: ThemeSetting.none, + browserPreference: BrowserPreference.none), + }); Map>> completers = {}; diff --git a/test/model/test_store.dart b/test/model/test_store.dart index 2322e78d7a..898beccdde 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,7 @@ 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.accounts, required super.globalSettings}); final Map< ({Uri realmUrl, int? zulipFeatureLevel, String? email, String? apiKey}), @@ -157,6 +158,13 @@ class TestGlobalStore extends GlobalStore { store: store, initialSnapshot: initialSnapshot); return Future.value(store); } + + GlobalSettingsData? _globalSettings; + + @override + Future doUpdateGlobalSettings(GlobalSettingsCompanion data) async { + _globalSettings = _globalSettings!.copyWithCompanion(data); + } } extension PerAccountStoreTestExtension on PerAccountStore {