Browse Source

Merge pull request #497 from nextcloud/refactor/settings_export

Refactor/settings export
pull/691/head
Nikolas Rimikis 1 year ago committed by GitHub
parent
commit
2221c52ca4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/neon/neon/lib/settings.dart
  2. 2
      packages/neon/neon/lib/src/blocs/apps.dart
  3. 2
      packages/neon/neon/lib/src/models/app_implementation.dart
  4. 2
      packages/neon/neon/lib/src/models/notifications_interface.dart
  5. 41
      packages/neon/neon/lib/src/pages/settings.dart
  6. 40
      packages/neon/neon/lib/src/settings/models/exportable.dart
  7. 23
      packages/neon/neon/lib/src/settings/models/nextcloud_app_options.dart
  8. 10
      packages/neon/neon/lib/src/settings/models/option.dart
  9. 4
      packages/neon/neon/lib/src/settings/models/options_category.dart
  10. 65
      packages/neon/neon/lib/src/settings/models/options_collection.dart
  11. 2
      packages/neon/neon/lib/src/settings/models/storage.dart
  12. 147
      packages/neon/neon/lib/src/settings/utils/settings_export_helper.dart
  13. 21
      packages/neon/neon/lib/src/utils/account_options.dart
  14. 54
      packages/neon/neon/lib/src/utils/global_options.dart
  15. 98
      packages/neon/neon/lib/src/utils/settings_export_helper.dart
  16. 38
      packages/neon/neon/test/option_test.dart
  17. 84
      packages/neon/neon/test/options_collection_test.dart
  18. 130
      packages/neon/neon/test/settings_export_test.dart
  19. 2
      packages/neon/neon/test/storage_test.dart

2
packages/neon/neon/lib/settings.dart

@ -1,6 +1,6 @@
export 'package:neon/src/models/label_builder.dart';
export 'package:neon/src/settings/models/nextcloud_app_options.dart';
export 'package:neon/src/settings/models/options_category.dart';
export 'package:neon/src/settings/models/options_collection.dart';
export 'package:neon/src/settings/models/select_option.dart';
export 'package:neon/src/settings/models/storage.dart' show Storable;
export 'package:neon/src/settings/models/toggle_option.dart';

2
packages/neon/neon/lib/src/blocs/apps.dart

@ -10,7 +10,7 @@ import 'package:neon/src/models/account.dart';
import 'package:neon/src/models/app_ids.dart';
import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/models/notifications_interface.dart';
import 'package:neon/src/settings/models/nextcloud_app_options.dart';
import 'package:neon/src/settings/models/options_collection.dart';
import 'package:neon/src/utils/request_manager.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:provider/provider.dart';

2
packages/neon/neon/lib/src/models/app_implementation.dart

@ -7,7 +7,7 @@ import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/bloc/bloc.dart';
import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/settings/models/nextcloud_app_options.dart';
import 'package:neon/src/settings/models/options_collection.dart';
import 'package:neon/src/settings/models/storage.dart';
import 'package:neon/src/widgets/drawer_destination.dart';
import 'package:provider/provider.dart';

2
packages/neon/neon/lib/src/models/notifications_interface.dart

@ -1,7 +1,7 @@
import 'package:meta/meta.dart';
import 'package:neon/src/bloc/bloc.dart';
import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/settings/models/nextcloud_app_options.dart';
import 'package:neon/src/settings/models/options_collection.dart';
abstract interface class NotificationsAppInterface<T extends NotificationsBlocInterface,
R extends NotificationsOptionsInterface> extends AppImplementation<T, R> {

41
packages/neon/neon/lib/src/pages/settings.dart

@ -1,6 +1,3 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
@ -10,6 +7,7 @@ import 'package:neon/src/models/account.dart';
import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/platform/platform.dart';
import 'package:neon/src/router.dart';
import 'package:neon/src/settings/utils/settings_export_helper.dart';
import 'package:neon/src/settings/widgets/account_settings_tile.dart';
import 'package:neon/src/settings/widgets/checkbox_settings_tile.dart';
import 'package:neon/src/settings/widgets/custom_settings_tile.dart';
@ -23,7 +21,6 @@ import 'package:neon/src/theme/dialog.dart';
import 'package:neon/src/utils/confirmation_dialog.dart';
import 'package:neon/src/utils/global_options.dart';
import 'package:neon/src/utils/save_file.dart';
import 'package:neon/src/utils/settings_export_helper.dart';
import 'package:neon/src/widgets/exception.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
@ -250,16 +247,10 @@ class _SettingsPageState extends State<SettingsPage> {
final settingsExportHelper = _buildSettingsExportHelper(context);
try {
final fileName =
'nextcloud-neon-settings-${DateTime.now().millisecondsSinceEpoch ~/ 1000}.json.base64';
final data = base64.encode(
utf8.encode(
json.encode(
settingsExportHelper.toJsonExport(),
),
),
);
await saveFileWithPickDialog(fileName, utf8.encode(data) as Uint8List);
final fileName = 'nextcloud-neon-settings-${DateTime.now().millisecondsSinceEpoch ~/ 1000}.json';
final data = settingsExportHelper.exportToFile();
await saveFileWithPickDialog(fileName, data);
} catch (e, s) {
debugPrint(e.toString());
debugPrint(s.toString());
@ -280,14 +271,14 @@ class _SettingsPageState extends State<SettingsPage> {
try {
final result = await FilePicker.platform.pickFiles(
withData: true,
withReadStream: true,
);
if (result == null) {
return;
}
if (!result.files.single.path!.endsWith('.json.base64')) {
if (!result.files.single.path!.endsWith('.json')) {
if (mounted) {
NeonException.showSnackbar(
context,
@ -297,9 +288,7 @@ class _SettingsPageState extends State<SettingsPage> {
return;
}
final data = json.decode(utf8.decode(base64.decode(utf8.decode(result.files.single.bytes!))));
await settingsExportHelper.applyFromJson(data as Map<String, dynamic>);
await settingsExportHelper.applyFromFile(result.files.single.readStream);
} catch (e, s) {
debugPrint(e.toString());
debugPrint(s.toString());
@ -329,16 +318,16 @@ class _SettingsPageState extends State<SettingsPage> {
}
SettingsExportHelper _buildSettingsExportHelper(final BuildContext context) {
final globalOptions = Provider.of<GlobalOptions>(context);
final globalOptions = Provider.of<GlobalOptions>(context, listen: false);
final accountsBloc = Provider.of<AccountsBloc>(context, listen: false);
final appImplementations = Provider.of<Iterable<AppImplementation>>(context);
final appImplementations = Provider.of<Iterable<AppImplementation>>(context, listen: false);
return SettingsExportHelper(
globalOptions: globalOptions,
appImplementations: appImplementations,
accountSpecificOptions: accountsBloc.accounts.value.asMap().map(
(final _, final account) => MapEntry(account, accountsBloc.getOptionsFor(account).options),
),
exportables: {
globalOptions,
AccountsBlocExporter(accountsBloc),
AppImplementationsExporter(appImplementations),
},
);
}
}

40
packages/neon/neon/lib/src/settings/models/exportable.dart

@ -0,0 +1,40 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:neon/src/settings/models/option.dart';
/// Exportable data interface.
abstract interface class Exportable {
/// Exports into a json map entry.
MapEntry<String, Object?> export();
/// Imports [data] from an export.
FutureOr<void> import(final Map<String, Object?> data);
}
/// Serialization helpers for a collection of [Option]s.
extension SerializeOptions on Iterable<Option<dynamic>> {
/// Serializes into an [Iterable<JsonEntry>].
///
/// Use [Map.fromEntries] to get a json Map.
Iterable<MapEntry<String, Object?>> serialize() sync* {
for (final option in this) {
if (option.enabled) {
yield MapEntry(option.key.value, option.serialize());
}
}
}
/// Deserializes [data] and updates the [Option]s.
void deserialize(final Map<String, Object?> data) {
for (final entry in data.entries) {
final option = firstWhereOrNull((final option) => option.key.value == entry.key);
if (entry.value != null) {
option?.load(entry.value);
} else {
option?.reset();
}
}
}
}

23
packages/neon/neon/lib/src/settings/models/nextcloud_app_options.dart

@ -1,23 +0,0 @@
import 'package:neon/src/settings/models/option.dart';
import 'package:neon/src/settings/models/options_category.dart';
import 'package:neon/src/settings/models/storage.dart';
abstract class NextcloudAppOptions {
NextcloudAppOptions(this.storage);
final AppStorage storage;
late final List<OptionsCategory> categories;
late final List<Option> options;
void reset() {
for (final option in options) {
option.reset();
}
}
void dispose() {
for (final option in options) {
option.dispose();
}
}
}

10
packages/neon/neon/lib/src/settings/models/option.dart

@ -79,10 +79,20 @@ abstract class Option<T> extends ChangeNotifier implements ValueListenable<T> {
}
/// Resets the option to its [default] value.
@mustBeOverridden
void reset() {
value = defaultValue;
}
/// Loads [data] into [value] by calling [deserialize] on it.
void load(final Object? data) {
final value = deserialize(data);
if (value != null) {
this.value = value;
}
}
/// Deserializes the data.
T? deserialize(final Object? data);

4
packages/neon/neon/lib/src/settings/models/options_category.dart

@ -1,7 +1,9 @@
import 'package:meta/meta.dart';
import 'package:neon/src/models/label_builder.dart';
@immutable
class OptionsCategory {
OptionsCategory({
const OptionsCategory({
required this.name,
});

65
packages/neon/neon/lib/src/settings/models/options_collection.dart

@ -0,0 +1,65 @@
import 'package:meta/meta.dart';
import 'package:neon/src/settings/models/exportable.dart';
import 'package:neon/src/settings/models/option.dart';
import 'package:neon/src/settings/models/options_category.dart';
import 'package:neon/src/settings/models/storage.dart';
/// Collection of [Option]s.
abstract class OptionsCollection implements Exportable {
OptionsCollection(this.storage);
/// Storage backend to use.
@protected
final AppStorage storage;
/// Collection of options.
@protected
Iterable<Option> get options;
/// Resets all [options].
///
/// Implementers extending this must call super.
@mustCallSuper
void reset() {
for (final option in options) {
option.reset();
}
}
/// Disposes all [options].
///
/// Implementers extending this must call super.
@mustCallSuper
void dispose() {
for (final option in options) {
option.dispose();
}
}
@override
MapEntry<String, Object?> export() {
final data = Map.fromEntries(options.serialize());
return MapEntry(storage.id, data);
}
@override
void import(final Map<String, Object?> data) {
final values = data[storage.id] as Map<String, Object?>?;
if (values != null) {
options.deserialize(values);
}
}
}
/// OpptionsCollection for a neon app.
abstract class NextcloudAppOptions extends OptionsCollection {
NextcloudAppOptions(super.storage);
/// Collection of categories to display the options in the settings.
late final Iterable<OptionsCategory> categories;
@override
late final Iterable<Option> options;
}

2
packages/neon/neon/lib/src/settings/models/storage.dart

@ -108,6 +108,8 @@ final class AppStorage implements SettingsStorage {
final String? suffix;
String get id => suffix ?? key.value;
@visibleForTesting
String formatKey(final String key) {
if (suffix != null) {

147
packages/neon/neon/lib/src/settings/utils/settings_export_helper.dart

@ -0,0 +1,147 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:meta/meta.dart';
import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/models/account.dart' show Account, AccountFind;
import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/settings/models/exportable.dart';
import 'package:neon/src/settings/models/option.dart';
import 'package:neon/src/settings/models/storage.dart';
/// Helper class to export all [Option]s.
///
/// Json based operations:
/// * [exportToJson]
/// * [applyFromJson]
///
/// [Uint8List] based operations:
/// * [exportToFile]
/// * [applyFromFile]
@internal
@immutable
class SettingsExportHelper {
const SettingsExportHelper({
required this.exportables,
});
/// Collections of elements to export.
final Set<Exportable> exportables;
/// Imports [file] and applies the stored [Option]s.
///
/// See:
/// * [applyFromJson] to import a json map.
Future<void> applyFromFile(final Stream<List<int>>? file) async {
final transformer = const Utf8Decoder().fuse(const JsonDecoder());
final data = await file?.transform(transformer).single;
if (data == null) {
return;
}
await applyFromJson(data as Map<String, Object?>);
}
/// Imports the [json] data and applies the stored [Option]s.
///
/// See:
/// * [applyFromFile] to import data from a [Stream<Uint8List>].
Future<void> applyFromJson(final Map<String, Object?> json) async {
for (final exportable in exportables) {
await exportable.import(json);
}
}
/// Exports the stored [Option]s into a [Uint8List].
///
/// See:
/// * [exportToJson] to export to a json map.
Uint8List exportToFile() {
final transformer = JsonUtf8Encoder();
final json = exportToJson();
return transformer.convert(json) as Uint8List;
}
/// Exports the stored [Option]s into a json map.
///
/// See:
/// * [exportToFile] to export data to a [Uint8List].
Map<String, Object?> exportToJson() => Map.fromEntries(exportables.map((final e) => e.export()));
}
/// Helper class to export [AppImplementation]s implementing the [Exportable] interface.
@immutable
class AppImplementationsExporter implements Exportable {
const AppImplementationsExporter(this.appImplementations);
/// List of apps to export.
final Iterable<AppImplementation> appImplementations;
/// Key the exported value will be stored at.
static final _key = StorageKeys.apps.value;
@override
MapEntry<String, Object?> export() => MapEntry(
_key,
Map.fromEntries(appImplementations.map((final app) => app.options.export())),
);
@override
void import(final Map<String, Object?> data) {
final values = data[_key] as Map<String, Object?>?;
if (values == null) {
return;
}
for (final element in values.entries) {
final app = appImplementations.tryFind(element.key);
if (app != null) {
app.options.import(values);
}
}
}
}
/// Helper class to export [Account]s implementing the [Exportable] interface.
@immutable
class AccountsBlocExporter implements Exportable {
const AccountsBlocExporter(this.accountsBloc);
/// AccountsBloc containing the accounts to export.
final AccountsBloc accountsBloc;
/// Key the exported value will be stored at.
static final _key = StorageKeys.accounts.value;
@override
MapEntry<String, Object?> export() => MapEntry(_key, Map.fromEntries(_serialize()));
Iterable<MapEntry<String, Object?>> _serialize() sync* {
for (final account in accountsBloc.accounts.value) {
yield accountsBloc.getOptionsFor(account).export();
}
}
@override
void import(final Map<String, Object?> data) {
final values = data[_key] as Map<String, Object?>?;
if (values == null) {
return;
}
for (final element in values.entries) {
final account = accountsBloc.accounts.value.tryFind(element.key);
if (account != null) {
accountsBloc.getOptionsFor(account).import(values);
}
}
}
}

21
packages/neon/neon/lib/src/utils/account_options.dart

@ -2,14 +2,15 @@ import 'package:meta/meta.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/blocs/apps.dart';
import 'package:neon/src/settings/models/option.dart';
import 'package:neon/src/settings/models/options_collection.dart';
import 'package:neon/src/settings/models/select_option.dart';
import 'package:neon/src/settings/models/storage.dart';
@internal
@immutable
class AccountSpecificOptions {
class AccountSpecificOptions extends OptionsCollection {
AccountSpecificOptions(
this._storage,
super.storage,
this._appsBloc,
) {
_appsBloc.appImplementations.listen((final result) {
@ -26,27 +27,15 @@ class AccountSpecificOptions {
});
}
final AppStorage _storage;
final AppsBloc _appsBloc;
@override
late final List<Option> options = [
initialApp,
];
void reset() {
for (final option in options) {
option.reset();
}
}
void dispose() {
for (final option in options) {
option.dispose();
}
}
late final initialApp = SelectOption<String?>(
storage: _storage,
storage: storage,
key: AccountOptionKeys.initialApp,
label: (final context) => AppLocalizations.of(context).accountOptionsInitialApp,
defaultValue: null,

54
packages/neon/neon/lib/src/utils/global_options.dart

@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
@ -6,6 +5,7 @@ import 'package:meta/meta.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/settings/models/option.dart';
import 'package:neon/src/settings/models/options_collection.dart';
import 'package:neon/src/settings/models/select_option.dart';
import 'package:neon/src/settings/models/storage.dart';
import 'package:neon/src/settings/models/toggle_option.dart';
@ -16,10 +16,10 @@ const unifiedPushNextPushID = 'org.unifiedpush.distributor.nextpush';
@internal
@immutable
class GlobalOptions {
class GlobalOptions extends OptionsCollection {
GlobalOptions(
this._packageInfo,
) {
) : super(const AppStorage(StorageKeys.global)) {
pushNotificationsEnabled.addListener(_pushNotificationsEnabledListener);
rememberLastUsedAccount.addListener(_rememberLastUsedAccountListener);
}
@ -49,7 +49,6 @@ class GlobalOptions {
}
}
late final AppStorage _storage = const AppStorage(StorageKeys.global);
final PackageInfo _packageInfo;
late final _distributorsMap = <String, String Function(BuildContext)>{
@ -68,6 +67,7 @@ class GlobalOptions {
AppLocalizations.of(context).globalOptionsPushNotificationsDistributorNoProvider2Push,
};
@override
late final List<Option> options = [
themeMode,
themeOLEDAsDark,
@ -83,30 +83,22 @@ class GlobalOptions {
navigationMode,
];
void reset() {
for (final option in options) {
option.reset();
}
}
@override
void dispose() {
for (final option in options) {
option.dispose();
}
super.dispose();
pushNotificationsEnabled.removeListener(_pushNotificationsEnabledListener);
rememberLastUsedAccount.removeListener(_rememberLastUsedAccountListener);
}
void updateAccounts(final List<Account> accounts) {
if (accounts.isEmpty) {
return;
}
initialAccount.values = {
for (final account in accounts) ...{
account.id: (final context) => account.humanReadableID,
},
for (final account in accounts) account.id: (final context) => account.humanReadableID,
};
if (accounts.tryFind(initialAccount.value) == null) {
initialAccount.reset();
}
}
Future updateDistributors(final List<String> distributors) async {
@ -124,7 +116,7 @@ class GlobalOptions {
}
late final themeMode = SelectOption<ThemeMode>(
storage: _storage,
storage: storage,
key: GlobalOptionKeys.themeMode,
label: (final context) => AppLocalizations.of(context).globalOptionsThemeMode,
defaultValue: ThemeMode.system,
@ -136,28 +128,28 @@ class GlobalOptions {
);
late final themeOLEDAsDark = ToggleOption(
storage: _storage,
storage: storage,
key: GlobalOptionKeys.themeOledAsDark,
label: (final context) => AppLocalizations.of(context).globalOptionsThemeOLEDAsDark,
defaultValue: false,
);
late final themeKeepOriginalAccentColor = ToggleOption(
storage: _storage,
storage: storage,
key: GlobalOptionKeys.themeKeepOriginalAccentColor,
label: (final context) => AppLocalizations.of(context).globalOptionsThemeKeepOriginalAccentColor,
defaultValue: false,
);
late final pushNotificationsEnabled = ToggleOption(
storage: _storage,
storage: storage,
key: GlobalOptionKeys.pushNotificationsEnabled,
label: (final context) => AppLocalizations.of(context).globalOptionsPushNotificationsEnabled,
defaultValue: false,
);
late final pushNotificationsDistributor = SelectOption<String?>.depend(
storage: _storage,
storage: storage,
key: GlobalOptionKeys.pushNotificationsDistributor,
label: (final context) => AppLocalizations.of(context).globalOptionsPushNotificationsDistributor,
defaultValue: null,
@ -166,14 +158,14 @@ class GlobalOptions {
);
late final startupMinimized = ToggleOption(
storage: _storage,
storage: storage,
key: GlobalOptionKeys.startupMinimized,
label: (final context) => AppLocalizations.of(context).globalOptionsStartupMinimized,
defaultValue: false,
);
late final startupMinimizeInsteadOfExit = ToggleOption(
storage: _storage,
storage: storage,
key: GlobalOptionKeys.startupMinimizeInsteadOfExit,
label: (final context) => AppLocalizations.of(context).globalOptionsStartupMinimizeInsteadOfExit,
defaultValue: false,
@ -182,14 +174,14 @@ class GlobalOptions {
// TODO: Autostart option
late final systemTrayEnabled = ToggleOption(
storage: _storage,
storage: storage,
key: GlobalOptionKeys.systemtrayEnabled,
label: (final context) => AppLocalizations.of(context).globalOptionsSystemTrayEnabled,
defaultValue: false,
);
late final systemTrayHideToTrayWhenMinimized = ToggleOption.depend(
storage: _storage,
storage: storage,
key: GlobalOptionKeys.systemtrayHideToTrayWhenMinimized,
label: (final context) => AppLocalizations.of(context).globalOptionsSystemTrayHideToTrayWhenMinimized,
defaultValue: true,
@ -197,14 +189,14 @@ class GlobalOptions {
);
late final rememberLastUsedAccount = ToggleOption(
storage: _storage,
storage: storage,
key: GlobalOptionKeys.rememberLastUsedAccount,
label: (final context) => AppLocalizations.of(context).globalOptionsAccountsRememberLastUsedAccount,
defaultValue: true,
);
late final initialAccount = SelectOption<String?>(
storage: _storage,
storage: storage,
key: GlobalOptionKeys.initialAccount,
label: (final context) => AppLocalizations.of(context).globalOptionsAccountsInitialAccount,
defaultValue: null,
@ -212,7 +204,7 @@ class GlobalOptions {
);
late final navigationMode = SelectOption<NavigationMode>(
storage: _storage,
storage: storage,
key: GlobalOptionKeys.navigationMode,
label: (final context) => AppLocalizations.of(context).globalOptionsNavigationMode,
defaultValue: Platform.isAndroid || Platform.isIOS ? NavigationMode.drawer : NavigationMode.drawerAlwaysVisible,

98
packages/neon/neon/lib/src/utils/settings_export_helper.dart

@ -1,98 +0,0 @@
import 'package:meta/meta.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/settings/models/option.dart';
import 'package:neon/src/utils/global_options.dart';
@internal
class SettingsExportHelper {
SettingsExportHelper({
required this.globalOptions,
required this.appImplementations,
required this.accountSpecificOptions,
});
final GlobalOptions globalOptions;
final Iterable<AppImplementation> appImplementations;
final Map<Account, List<Option>> accountSpecificOptions;
Future applyFromJson(final Map<String, dynamic> data) async {
final globalOptionsData = data['global'] as Map<String, dynamic>;
await _applyOptionsMapToOptions(
globalOptions.options,
globalOptionsData,
);
final appImplementationsData = data['apps'] as Map<String, dynamic>;
for (final appId in appImplementationsData.keys) {
final app = appImplementations.tryFind(appId);
if (app == null) {
return;
}
final appImplementationData = appImplementationsData[appId]! as Map<String, dynamic>;
await _applyOptionsMapToOptions(
app.options.options,
appImplementationData,
);
}
final accountsData = data['accounts'] as Map<String, dynamic>;
for (final accountId in accountsData.keys) {
final account = accountSpecificOptions.keys.tryFind(accountId);
if (account == null) {
return;
}
final accountData = accountsData[accountId]! as Map<String, dynamic>;
await _applyOptionsMapToOptions(
accountSpecificOptions[account]!,
accountData,
);
}
}
Future _applyOptionsMapToOptions(final List<Option> options, final Map<String, dynamic> data) async {
for (final optionKey in data.keys) {
for (final option in options) {
if (option.key.value == optionKey) {
final Object? value = data[optionKey];
if (value != null) {
option.value = await option.deserialize(value);
}
}
}
}
}
Map<String, dynamic> toJsonExport() => {
'global': {
for (final option in globalOptions.options) ...{
if (option.enabled) ...{
option.key: option.serialize(),
},
},
},
'apps': {
for (final appImplementation in appImplementations) ...{
appImplementation.id: {
for (final option in appImplementation.options.options) ...{
if (option.enabled) ...{
option.key: option.serialize(),
},
},
},
},
},
'accounts': {
for (final account in accountSpecificOptions.keys) ...{
account.id: {
for (final option in accountSpecificOptions[account]!) ...{
if (option.enabled) ...{
option.key: option.serialize(),
},
},
},
},
},
};
}

38
packages/neon/neon/test/option_test.dart

@ -174,6 +174,35 @@ void main() {
expect(option.serialize(), null, reason: 'Should serialize to null. A string containing "null" is an error');
});
test('Deserialize', () {
final option = SelectOption<SelectValues?>(
storage: storage,
key: key,
label: labelBuilder,
defaultValue: null,
values: valuesLabel,
);
// ignore: cascade_invocations
option.load('SelectValues.second');
expect(option.value, SelectValues.second);
});
test('Stream', () async {
final option = SelectOption<SelectValues?>(
storage: storage,
key: key,
label: labelBuilder,
defaultValue: null,
values: valuesLabel,
);
expect(await option.stream.first, option.defaultValue);
option.value = SelectValues.second;
expect(await option.stream.first, SelectValues.second);
});
});
group('ToggleOption', () {
@ -274,5 +303,14 @@ void main() {
verify(() => storage.remove(key.value)).called(1);
expect(option.value, option.defaultValue, reason: 'Should reset the value.');
});
test('Deserialize', () {
expect(option.value, true);
// ignore: cascade_invocations
option.load(false);
expect(option.value, false);
});
});
}

84
packages/neon/neon/test/options_collection_test.dart

@ -0,0 +1,84 @@
import 'package:mocktail/mocktail.dart';
import 'package:neon/settings.dart';
import 'package:neon/src/settings/models/option.dart';
import 'package:neon/src/settings/models/storage.dart';
import 'package:test/test.dart';
// ignore: missing_override_of_must_be_overridden
class OptionMock extends Mock implements Option {}
class Collection extends NextcloudAppOptions {
Collection(final List<Option> options) : super(const AppStorage(StorageKeys.apps)) {
super.options = options;
}
}
enum Keys implements Storable {
key1._('key1'),
key2._('key2');
const Keys._(this.value);
@override
final String value;
}
void main() {
group('OptionsCollection', () {
final option1 = OptionMock();
final option2 = OptionMock();
final collection = Collection([
option1,
option2,
]);
test('reset', () {
collection.reset();
verify(option1.reset).called(1);
verify(option2.reset).called(1);
});
test('dispose', () {
collection.dispose();
verify(option1.dispose).called(1);
verify(option2.dispose).called(1);
});
test('export', () {
when(() => option1.key).thenReturn(Keys.key1);
when(option1.serialize).thenReturn('value1');
when(() => option1.enabled).thenReturn(true);
when(() => option2.key).thenReturn(Keys.key2);
when(option2.serialize).thenReturn('value2');
when(() => option2.enabled).thenReturn(false);
const json = {
'app': {'key1': 'value1'},
};
final export = collection.export();
expect(Map.fromEntries([export]), equals(json));
});
test('import', () {
when(() => option1.key).thenReturn(Keys.key1);
when(() => option2.key).thenReturn(Keys.key2);
const json = {
'app': {
'key1': 'value1',
'key2': null,
},
};
collection.import(json);
verify(() => option1.load('value1')).called(1);
verify(option2.reset).called(1);
});
});
}

130
packages/neon/neon/test/settings_export_test.dart

@ -0,0 +1,130 @@
// ignore_for_file: avoid_implementing_value_types
import 'dart:convert';
import 'dart:typed_data';
import 'package:mocktail/mocktail.dart';
import 'package:neon/blocs.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/models/app_implementation.dart';
import 'package:neon/src/settings/models/exportable.dart';
import 'package:neon/src/settings/models/options_collection.dart';
import 'package:neon/src/settings/utils/settings_export_helper.dart';
import 'package:neon/src/utils/account_options.dart';
import 'package:rxdart/rxdart.dart';
import 'package:test/test.dart';
// ignore: missing_override_of_must_be_overridden
class FakeAppImplementation extends Mock implements AppImplementation {}
class NextcloudAppOptionsMock extends Mock implements NextcloudAppOptions {}
class AccountsBlocMock extends Mock implements AccountsBloc {}
class FakeAccount extends Mock implements Account {}
class AccountSpecificOptionsMock extends Mock implements AccountSpecificOptions {}
class ExporterMock extends Mock implements Exportable {}
void main() {
group('Exporter', () {
test('AccountsBlocExporter', () {
var exporter = const AppImplementationsExporter([]);
var export = exporter.export();
expect(Map.fromEntries([export]), {'app': {}});
final fakeApp = FakeAppImplementation();
final fakeOptions = NextcloudAppOptionsMock();
exporter = AppImplementationsExporter([fakeApp]);
const appValue = MapEntry('appID', 'value');
const appExport = {
'app': {'appID': 'value'},
};
when(() => fakeApp.options).thenReturn(fakeOptions);
when(fakeOptions.export).thenReturn(appValue);
when(() => fakeApp.id).thenReturn('appID');
export = exporter.export();
expect(Map.fromEntries([export]), appExport);
exporter.import(Map.fromEntries([export]));
verify(() => fakeOptions.import(Map.fromEntries([appValue]))).called(1);
});
test('AccountsBlocExporter', () {
final bloc = AccountsBlocMock();
final exporter = AccountsBlocExporter(bloc);
const accountValue = MapEntry('accountID', 'value');
const accountExport = {
'accounts': {'accountID': 'value'},
};
when(() => bloc.accounts).thenAnswer((final _) => BehaviorSubject.seeded([]));
var export = exporter.export();
expect(Map.fromEntries([export]), {'accounts': {}});
final fakeAccount = FakeAccount();
final fakeOptions = AccountSpecificOptionsMock();
when(() => bloc.accounts).thenAnswer((final _) => BehaviorSubject.seeded([fakeAccount]));
when(() => bloc.getOptionsFor(fakeAccount)).thenReturn(fakeOptions);
when(fakeOptions.export).thenReturn(accountValue);
when(() => fakeAccount.id).thenReturn('accountID');
export = exporter.export();
expect(Map.fromEntries([export]), accountExport);
exporter.import(Map.fromEntries([export]));
verify(() => fakeOptions.import(Map.fromEntries([accountValue]))).called(1);
});
});
group('SettingsExportHelper', () {
test('SettingsExportHelper.json', () async {
final exportable = ExporterMock();
final settingsExporter = SettingsExportHelper(
exportables: {
exportable,
},
);
const value = MapEntry('sxportableKey', 'value');
const export = {'sxportableKey': 'value'};
when(exportable.export).thenAnswer((final _) => value);
expect(settingsExporter.exportToJson(), equals(export));
await settingsExporter.applyFromJson(export);
verify(() => exportable.import(Map.fromEntries([value]))).called(1);
});
test('SettingsExportHelper.file', () async {
final exportable = ExporterMock();
final settingsExporter = SettingsExportHelper(
exportables: {
exportable,
},
);
const value = MapEntry('sxportableKey', 'value');
const jsonExport = {'sxportableKey': 'value'};
final export = JsonUtf8Encoder().convert(jsonExport) as Uint8List;
when(exportable.export).thenAnswer((final _) => value);
expect(settingsExporter.exportToFile(), equals(export));
await settingsExporter.applyFromFile(_streamValue(export));
verify(() => exportable.import(Map.fromEntries([value]))).called(1);
});
});
}
Stream<T> _streamValue<T>(final T value) async* {
yield value;
}

2
packages/neon/neon/test/storage_test.dart

@ -20,10 +20,12 @@ void main() {
var appStorage = const AppStorage(StorageKeys.accounts);
var key = appStorage.formatKey('test-key');
expect(key, 'accounts-test-key');
expect(appStorage.id, StorageKeys.accounts.value);
appStorage = const AppStorage(StorageKeys.accounts, 'test-suffix');
key = appStorage.formatKey('test-key');
expect(key, 'accounts-test-suffix-test-key');
expect(appStorage.id, 'test-suffix');
});
test('interface', () async {

Loading…
Cancel
Save