From 4903e4c016cdbabb4ca312ec981c15a94fc0ae03 Mon Sep 17 00:00:00 2001 From: jld3103 Date: Tue, 8 Aug 2023 18:05:51 +0200 Subject: [PATCH] feat(neon): Implement syncing Signed-off-by: jld3103 --- packages/app/pubspec.lock | 7 + packages/app/pubspec_overrides.yaml | 4 +- packages/neon/neon/lib/l10n/en.arb | 24 +- .../neon/neon/lib/l10n/localizations.dart | 72 +++++ .../neon/neon/lib/l10n/localizations_en.dart | 38 +++ packages/neon/neon/lib/neon.dart | 8 + packages/neon/neon/lib/src/blocs/apps.dart | 3 + packages/neon/neon/lib/src/blocs/sync.dart | 270 ++++++++++++++++++ .../lib/src/models/app_implementation.dart | 4 + .../pages/app_implementation_settings.dart | 183 ++++++++++++ .../lib/src/pages/nextcloud_app_settings.dart | 81 ------ .../neon/neon/lib/src/pages/settings.dart | 4 +- .../lib/src/pages/sync_mapping_settings.dart | 72 +++++ packages/neon/neon/lib/src/router.dart | 4 +- .../neon/lib/src/settings/models/storage.dart | 1 + .../widgets/custom_settings_tile.dart | 3 + .../src/settings/widgets/settings_list.dart | 3 +- .../neon/neon/lib/src/sync/conflicts.dart | 18 ++ .../neon/lib/src/sync/implementation.dart | 37 +++ packages/neon/neon/lib/src/sync/mapping.dart | 23 ++ .../neon/neon/lib/src/utils/file_utils.dart | 46 +++ .../neon/lib/src/utils/global_popups.dart | 33 ++- .../neon/neon/lib/src/utils/save_file.dart | 26 -- .../lib/src/utils/sync_mapping_options.dart | 45 +++ .../resolve_sync_conflicts_dialog.dart | 150 ++++++++++ .../lib/src/widgets/sync_status_icon.dart | 30 ++ packages/neon/neon/lib/sync.dart | 4 + packages/neon/neon/lib/utils.dart | 1 + packages/neon/neon/pubspec.yaml | 4 + packages/neon/neon/pubspec_overrides.yaml | 4 +- .../neon/neon_files/pubspec_overrides.yaml | 4 +- .../neon/neon_news/pubspec_overrides.yaml | 4 +- .../neon/neon_notes/pubspec_overrides.yaml | 4 +- .../neon_notifications/pubspec_overrides.yaml | 4 +- 34 files changed, 1097 insertions(+), 121 deletions(-) create mode 100644 packages/neon/neon/lib/src/blocs/sync.dart create mode 100644 packages/neon/neon/lib/src/pages/app_implementation_settings.dart delete mode 100644 packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart create mode 100644 packages/neon/neon/lib/src/pages/sync_mapping_settings.dart create mode 100644 packages/neon/neon/lib/src/sync/conflicts.dart create mode 100644 packages/neon/neon/lib/src/sync/implementation.dart create mode 100644 packages/neon/neon/lib/src/sync/mapping.dart create mode 100644 packages/neon/neon/lib/src/utils/file_utils.dart delete mode 100644 packages/neon/neon/lib/src/utils/save_file.dart create mode 100644 packages/neon/neon/lib/src/utils/sync_mapping_options.dart create mode 100644 packages/neon/neon/lib/src/widgets/resolve_sync_conflicts_dialog.dart create mode 100644 packages/neon/neon/lib/src/widgets/sync_status_icon.dart create mode 100644 packages/neon/neon/lib/sync.dart diff --git a/packages/app/pubspec.lock b/packages/app/pubspec.lock index b1222809a02..2bcaf203bae 100644 --- a/packages/app/pubspec.lock +++ b/packages/app/pubspec.lock @@ -1079,6 +1079,13 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + synchronize: + dependency: "direct overridden" + description: + path: "../synchronize" + relative: true + source: path + version: "1.0.0" synchronized: dependency: transitive description: diff --git a/packages/app/pubspec_overrides.yaml b/packages/app/pubspec_overrides.yaml index 7bd861e42e7..e374dcb54b3 100644 --- a/packages/app/pubspec_overrides.yaml +++ b/packages/app/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,neon_files,neon_news,neon_notes,neon_notifications,nextcloud,sort_box,neon_lints +# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,neon_files,neon_news,neon_notes,neon_notifications,nextcloud,sort_box,neon_lints,synchronize dependency_overrides: dynamite_runtime: path: ../dynamite/dynamite_runtime @@ -20,3 +20,5 @@ dependency_overrides: path: ../nextcloud sort_box: path: ../sort_box + synchronize: + path: ../synchronize diff --git a/packages/neon/neon/lib/l10n/en.arb b/packages/neon/neon/lib/l10n/en.arb index 17af0ba0bed..cf13309fbef 100644 --- a/packages/neon/neon/lib/l10n/en.arb +++ b/packages/neon/neon/lib/l10n/en.arb @@ -85,6 +85,10 @@ "actionShowSlashHide": "Show/Hide", "actionExit": "Exit", "actionContinue": "Continue", + "actionPrevious": "Previous", + "actionNext": "Next", + "actionCancel": "Cancel", + "actionFinish": "Finish", "firstLaunchGoToSettingsToEnablePushNotifications": "Go to the settings to enable push notifications", "nextPushSupported": "NextPush is supported!", "nextPushSupportedText": "NextPush is a FOSS way of receiving push notifications using the UnifiedPush protocol via a Nextcloud instance.\nYou can install NextPush from the F-Droid app store.", @@ -125,6 +129,7 @@ "optionsCategoryStartup": "Startup", "optionsCategorySystemTray": "System tray", "optionsCategoryNavigation": "Navigation", + "optionsCategorySync": "Synchronization", "optionsSortOrderAscending": "Ascending", "optionsSortOrderDescending": "Descending", "globalOptionsThemeMode": "Theme mode", @@ -180,5 +185,22 @@ }, "accountOptionsInitialApp": "App to show initially", "accountOptionsAutomatic": "Automatic", - "licenses": "Licenses" + "licenses": "Licenses", + "syncOptionsAdd": "Add synchronization", + "syncOptionsRemove": "Remove synchronization", + "syncOptionsRemoveConfirmation": "Do you want to remove the synchronization?", + "syncOptionsAutomaticSync": "Sync automatically", + "syncResolveConflictsLocal": "Local", + "syncResolveConflictsRemote": "Remote", + "syncResolveConflictsTitle": "Found {count} conflicts for syncing {name}", + "@syncResolveConflictsTitle": { + "placeholders": { + "count": { + "type": "int" + }, + "name": { + "type": "String" + } + } + } } diff --git a/packages/neon/neon/lib/l10n/localizations.dart b/packages/neon/neon/lib/l10n/localizations.dart index 1152702d3e3..f2e4b7f985c 100644 --- a/packages/neon/neon/lib/l10n/localizations.dart +++ b/packages/neon/neon/lib/l10n/localizations.dart @@ -317,6 +317,30 @@ abstract class AppLocalizations { /// **'Continue'** String get actionContinue; + /// No description provided for @actionPrevious. + /// + /// In en, this message translates to: + /// **'Previous'** + String get actionPrevious; + + /// No description provided for @actionNext. + /// + /// In en, this message translates to: + /// **'Next'** + String get actionNext; + + /// No description provided for @actionCancel. + /// + /// In en, this message translates to: + /// **'Cancel'** + String get actionCancel; + + /// No description provided for @actionFinish. + /// + /// In en, this message translates to: + /// **'Finish'** + String get actionFinish; + /// No description provided for @firstLaunchGoToSettingsToEnablePushNotifications. /// /// In en, this message translates to: @@ -473,6 +497,12 @@ abstract class AppLocalizations { /// **'Navigation'** String get optionsCategoryNavigation; + /// No description provided for @optionsCategorySync. + /// + /// In en, this message translates to: + /// **'Synchronization'** + String get optionsCategorySync; + /// No description provided for @optionsSortOrderAscending. /// /// In en, this message translates to: @@ -688,6 +718,48 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Licenses'** String get licenses; + + /// No description provided for @syncOptionsAdd. + /// + /// In en, this message translates to: + /// **'Add synchronization'** + String get syncOptionsAdd; + + /// No description provided for @syncOptionsRemove. + /// + /// In en, this message translates to: + /// **'Remove synchronization'** + String get syncOptionsRemove; + + /// No description provided for @syncOptionsRemoveConfirmation. + /// + /// In en, this message translates to: + /// **'Do you want to remove the synchronization?'** + String get syncOptionsRemoveConfirmation; + + /// No description provided for @syncOptionsAutomaticSync. + /// + /// In en, this message translates to: + /// **'Sync automatically'** + String get syncOptionsAutomaticSync; + + /// No description provided for @syncResolveConflictsLocal. + /// + /// In en, this message translates to: + /// **'Local'** + String get syncResolveConflictsLocal; + + /// No description provided for @syncResolveConflictsRemote. + /// + /// In en, this message translates to: + /// **'Remote'** + String get syncResolveConflictsRemote; + + /// No description provided for @syncResolveConflictsTitle. + /// + /// In en, this message translates to: + /// **'Found {count} conflicts for syncing {name}'** + String syncResolveConflictsTitle(int count, String name); } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/packages/neon/neon/lib/l10n/localizations_en.dart b/packages/neon/neon/lib/l10n/localizations_en.dart index bd75edc46c1..f4f611cda09 100644 --- a/packages/neon/neon/lib/l10n/localizations_en.dart +++ b/packages/neon/neon/lib/l10n/localizations_en.dart @@ -149,6 +149,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get actionContinue => 'Continue'; + @override + String get actionPrevious => 'Previous'; + + @override + String get actionNext => 'Next'; + + @override + String get actionCancel => 'Cancel'; + + @override + String get actionFinish => 'Finish'; + @override String get firstLaunchGoToSettingsToEnablePushNotifications => 'Go to the settings to enable push notifications'; @@ -232,6 +244,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get optionsCategoryNavigation => 'Navigation'; + @override + String get optionsCategorySync => 'Synchronization'; + @override String get optionsSortOrderAscending => 'Ascending'; @@ -344,4 +359,27 @@ class AppLocalizationsEn extends AppLocalizations { @override String get licenses => 'Licenses'; + + @override + String get syncOptionsAdd => 'Add synchronization'; + + @override + String get syncOptionsRemove => 'Remove synchronization'; + + @override + String get syncOptionsRemoveConfirmation => 'Do you want to remove the synchronization?'; + + @override + String get syncOptionsAutomaticSync => 'Sync automatically'; + + @override + String get syncResolveConflictsLocal => 'Local'; + + @override + String get syncResolveConflictsRemote => 'Remote'; + + @override + String syncResolveConflictsTitle(int count, String name) { + return 'Found $count conflicts for syncing $name'; + } } diff --git a/packages/neon/neon/lib/neon.dart b/packages/neon/neon/lib/neon.dart index 62d4e126c70..4adb695b0c4 100644 --- a/packages/neon/neon/lib/neon.dart +++ b/packages/neon/neon/lib/neon.dart @@ -7,6 +7,7 @@ import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/blocs/first_launch.dart'; import 'package:neon/src/blocs/next_push.dart'; import 'package:neon/src/blocs/push_notifications.dart'; +import 'package:neon/src/blocs/sync.dart'; import 'package:neon/src/models/account.dart'; import 'package:neon/src/models/app_implementation.dart'; import 'package:neon/src/platform/platform.dart'; @@ -61,6 +62,10 @@ Future runNeon({ globalOptions, disabled: nextPushDisabled, ); + final syncBloc = SyncBloc( + accountsBloc, + appImplementations, + ); runApp( MultiProvider( @@ -80,6 +85,9 @@ Future runNeon({ Provider( create: (final _) => nextPushBloc, ), + Provider( + create: (final _) => syncBloc, + ), Provider>( create: (final _) => appImplementations, ), diff --git a/packages/neon/neon/lib/src/blocs/apps.dart b/packages/neon/neon/lib/src/blocs/apps.dart index 6f35a3ecb71..19e90c4471f 100644 --- a/packages/neon/neon/lib/src/blocs/apps.dart +++ b/packages/neon/neon/lib/src/blocs/apps.dart @@ -222,6 +222,9 @@ class AppsBloc extends InteractiveBloc implements AppsBlocEvents, AppsBlocStates T getAppBloc(final AppImplementation appImplementation) => appImplementation.getBloc(_account); + T? getAppBlocByID(final String appId) => + _allAppImplementations.tryFind(appId)?.getBloc(_account) as T?; + List get appBlocProviders => _allAppImplementations.map((final appImplementation) => appImplementation.blocProvider).toList(); } diff --git a/packages/neon/neon/lib/src/blocs/sync.dart b/packages/neon/neon/lib/src/blocs/sync.dart new file mode 100644 index 00000000000..9366aa34468 --- /dev/null +++ b/packages/neon/neon/lib/src/blocs/sync.dart @@ -0,0 +1,270 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.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/storage.dart'; +import 'package:neon/src/sync/conflicts.dart'; +import 'package:neon/src/sync/implementation.dart'; +import 'package:neon/src/sync/mapping.dart'; +import 'package:neon/src/utils/sync_mapping_options.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:synchronize/synchronize.dart'; + +abstract interface class SyncBlocEvents { + /// Adds a new [mapping] that will be synced. + void addMapping(final SyncMapping mapping); + + /// Removes an existing [mapping] that will no longer be synced. + void removeMapping(final SyncMapping mapping); + + /// Explicitly trigger a sync for the [mapping]. + /// [solutions] can be use to apply solutions for conflicts. + void syncMapping(final SyncMapping mapping, {final Map solutions = const {}}); +} + +abstract interface class SyncBlocStates { + /// Map of [SyncMapping]s and their [SyncMappingStatus]es + BehaviorSubject> get mappingStatuses; + + /// Stream of conflicts that have arisen during syncing. + Stream get conflicts; +} + +class SyncBloc extends InteractiveBloc implements SyncBlocEvents, SyncBlocStates { + SyncBloc( + this._accountsBloc, + final Iterable appImplementations, + ) { + _syncImplementations = appImplementations.map((final app) => app.syncImplementation).whereNotNull(); + _timer = TimerBloc().registerTimer(const Duration(minutes: 1), refresh); + + _loadMappings(); + mappingStatuses.value.keys.forEach(_watchMapping); + unawaited(refresh()); + } + + final AccountsBloc _accountsBloc; + static const _storage = SingleValueStorage(StorageKeys.sync); + late final Iterable _syncImplementations; + late final NeonTimer _timer; + final _conflictsController = StreamController(); + final _watchControllers = {}; + final _syncMappingOptions = {}; + + @override + void dispose() { + _timer.cancel(); + for (final options in _syncMappingOptions.values) { + options.dispose(); + } + for (final mapping in mappingStatuses.value.keys) { + mapping.dispose(); + } + unawaited(mappingStatuses.close()); + for (final controller in _watchControllers.values) { + unawaited(controller.close()); + } + unawaited(_conflictsController.close()); + } + + @override + late final Stream conflicts = _conflictsController.stream.asBroadcastStream(); + + @override + final BehaviorSubject> mappingStatuses = BehaviorSubject(); + + @override + Future refresh() async { + for (final mapping in mappingStatuses.value.keys) { + await _updateMapping(mapping); + } + } + + @override + Future addMapping(final SyncMapping mapping) async { + debugPrint('Adding mapping: $mapping'); + mappingStatuses.add({ + ...mappingStatuses.value, + mapping: SyncMappingStatus.unknown, + }); + await _saveMappings(); + // Directly trigger sync after adding the mapping + await syncMapping(mapping); + // And start watching for local or remote changes + _watchMapping(mapping); + } + + @override + Future removeMapping(final SyncMapping mapping) async { + debugPrint('Removing mapping: $mapping'); + mappingStatuses.add(Map.fromEntries(mappingStatuses.value.entries.where((final m) => m.key != mapping))); + mapping.dispose(); + await _saveMappings(); + } + + @override + Future syncMapping(final SyncMapping mapping, {final Map solutions = const {}}) async { + debugPrint('Syncing mapping: $mapping'); + mappingStatuses.add({ + ...mappingStatuses.value, + mapping: SyncMappingStatus.incomplete, + }); + + final account = _accountsBloc.accounts.value.tryFind(mapping.accountId); + if (account == null) { + await removeMapping(mapping); + return; + } + + // This shouldn't be necessary, but it sadly is because of https://github.com/flutter/flutter/issues/25659. + // Alternative would be to use https://pub.dev/packages/shared_storage, + // but to be efficient we'd need https://github.com/alexrintt/shared-storage/issues/91 + // or copy the files to the app cache (which is also not optimal). + if (Platform.isAndroid && !await Permission.manageExternalStorage.request().isGranted) { + return; + } + + try { + final implementation = _syncImplementations.find(mapping.appId); + final sources = implementation.getSources(account, mapping); + + final diff = await computeSyncDiff( + sources, + mapping.journal, + conflictSolutions: solutions, + keepSkipsAsConflicts: true, + ); + debugPrint('Conflicts: ${diff.conflicts}'); + debugPrint('Actions: ${diff.actions}'); + + if (diff.conflicts.isNotEmpty && diff.conflicts.whereNot((final conflict) => conflict.skipped).isNotEmpty) { + _conflictsController.add( + SyncConflicts( + account, + implementation, + mapping, + diff.conflicts, + ), + ); + } + + await executeSyncDiff(sources, mapping.journal, diff); + + mappingStatuses.add({ + ...mappingStatuses.value, + mapping: diff.conflicts.isEmpty ? SyncMappingStatus.complete : SyncMappingStatus.incomplete, + }); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + addError(e); + } + + // Save after syncing even if an error occurred + await _saveMappings(); + } + + Future _updateMapping(final SyncMapping mapping) async { + final account = _accountsBloc.accounts.value.tryFind(mapping.accountId); + if (account == null) { + await removeMapping(mapping); + return; + } + + final options = getSyncMappingOptionsFor(mapping); + if (options.automaticSync.value) { + await syncMapping(mapping); + } else { + try { + final status = await _getMappingStatus(account, mapping); + mappingStatuses.add({ + ...mappingStatuses.value, + mapping: status, + }); + } catch (e, s) { + debugPrint(e.toString()); + debugPrint(s.toString()); + addError(e); + } + } + } + + Future _getMappingStatus(final Account account, final SyncMapping mapping) async { + final implementation = _syncImplementations.find(mapping.appId); + final sources = implementation.getSources(account, mapping); + final diff = await computeSyncDiff(sources, mapping.journal); + return diff.actions.isEmpty && diff.conflicts.isEmpty ? SyncMappingStatus.complete : SyncMappingStatus.incomplete; + } + + void _loadMappings() { + debugPrint('Loading mappings'); + final loadedMappings = []; + + if (_storage.hasValue()) { + final serializedMappings = (json.decode(_storage.getString()!) as Map) + .map((final key, final value) => MapEntry(key, (value as List).map((final e) => e as Map))); + + for (final mapping in serializedMappings.entries) { + final syncImplementation = _syncImplementations.tryFind(mapping.key); + if (syncImplementation == null) { + continue; + } + + for (final serializedMapping in mapping.value) { + loadedMappings.add(syncImplementation.deserializeMapping(serializedMapping)); + } + } + } + + mappingStatuses.add({ + for (final mapping in loadedMappings) mapping: SyncMappingStatus.unknown, + }); + } + + Future _saveMappings() async { + debugPrint('Saving mappings'); + final serializedMappings = >>{}; + + for (final mapping in mappingStatuses.value.keys) { + final syncImplementation = _syncImplementations.find(mapping.appId); + serializedMappings[mapping.appId] ??= []; + serializedMappings[mapping.appId]!.add(syncImplementation.serializeMapping(mapping)); + } + + await _storage.setString(json.encode(serializedMappings)); + } + + void _watchMapping(final SyncMapping mapping) { + final syncImplementation = _syncImplementations.find(mapping.appId); + if (_watchControllers.containsKey(syncImplementation.getMappingId(mapping))) { + return; + } + + // ignore: close_sinks + final controller = StreamController(); + // Debounce is required to stop bulk operations flooding the sync and potentially creating race conditions. + controller.stream.debounceTime(const Duration(seconds: 1)).listen((final _) async { + await _updateMapping(mapping); + }); + + _watchControllers[syncImplementation.getMappingId(mapping)] = controller; + + mapping.watch(() { + controller.add(null); + }); + } + + SyncMappingOptions getSyncMappingOptionsFor(final SyncMapping mapping) { + final syncImplementation = _syncImplementations.find(mapping.appId); + final id = '${mapping.accountId}-${mapping.appId}-${syncImplementation.getMappingId(mapping)}'; + return _syncMappingOptions[id] ??= SyncMappingOptions( + AppStorage(StorageKeys.sync, id), + ); + } +} diff --git a/packages/neon/neon/lib/src/models/app_implementation.dart b/packages/neon/neon/lib/src/models/app_implementation.dart index 4c50508ca9a..0e5e5cfa53b 100644 --- a/packages/neon/neon/lib/src/models/app_implementation.dart +++ b/packages/neon/neon/lib/src/models/app_implementation.dart @@ -9,6 +9,7 @@ import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/models/account.dart'; import 'package:neon/src/settings/models/options_collection.dart'; import 'package:neon/src/settings/models/storage.dart'; +import 'package:neon/src/sync/implementation.dart'; import 'package:neon/src/widgets/drawer_destination.dart'; import 'package:provider/provider.dart'; import 'package:rxdart/rxdart.dart'; @@ -37,6 +38,9 @@ abstract class AppImplementation @protected T buildBloc(final Account account); + /// Optional [SyncImplementation] for this [AppImplementation]. + SyncImplementation? get syncImplementation => null; + Provider get blocProvider => Provider( create: (final context) { final accountsBloc = Provider.of(context, listen: false); diff --git a/packages/neon/neon/lib/src/pages/app_implementation_settings.dart b/packages/neon/neon/lib/src/pages/app_implementation_settings.dart new file mode 100644 index 00000000000..db481dffa4b --- /dev/null +++ b/packages/neon/neon/lib/src/pages/app_implementation_settings.dart @@ -0,0 +1,183 @@ +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/blocs/accounts.dart'; +import 'package:neon/src/blocs/sync.dart'; +import 'package:neon/src/models/account.dart'; +import 'package:neon/src/models/app_implementation.dart'; +import 'package:neon/src/pages/sync_mapping_settings.dart'; +import 'package:neon/src/settings/models/select_option.dart'; +import 'package:neon/src/settings/models/toggle_option.dart'; +import 'package:neon/src/settings/widgets/checkbox_settings_tile.dart'; +import 'package:neon/src/settings/widgets/custom_settings_tile.dart'; +import 'package:neon/src/settings/widgets/dropdown_button_settings_tile.dart'; +import 'package:neon/src/settings/widgets/settings_category.dart'; +import 'package:neon/src/settings/widgets/settings_list.dart'; +import 'package:neon/src/theme/dialog.dart'; +import 'package:neon/src/utils/confirmation_dialog.dart'; +import 'package:neon/src/widgets/account_tile.dart'; +import 'package:neon/src/widgets/sync_status_icon.dart'; +import 'package:neon/src/widgets/user_avatar.dart'; +import 'package:provider/provider.dart'; + +class AppImplementationSettingsPage extends StatelessWidget { + const AppImplementationSettingsPage({ + required this.appImplementation, + super.key, + }); + + final AppImplementation appImplementation; + + @override + Widget build(final BuildContext context) { + final accountsBloc = Provider.of(context, listen: false); + final syncBloc = Provider.of(context, listen: false); + + final appBar = AppBar( + title: Text(appImplementation.name(context)), + actions: [ + IconButton( + onPressed: () async { + if (await showConfirmationDialog( + context, + AppLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)), + )) { + appImplementation.options.reset(); + } + }, + tooltip: AppLocalizations.of(context).settingsResetFor(appImplementation.name(context)), + icon: Icon(MdiIcons.cogRefresh), + ), + ], + ); + + final body = SettingsList( + categories: [ + for (final category in [...appImplementation.options.categories, null]) ...[ + if (appImplementation.options.options.where((final option) => option.category == category).isNotEmpty) ...[ + SettingsCategory( + title: Text( + category != null ? category.name(context) : AppLocalizations.of(context).optionsCategoryOther, + ), + tiles: [ + for (final option + in appImplementation.options.options.where((final option) => option.category == category)) ...[ + if (option is ToggleOption) ...[ + CheckBoxSettingsTile( + option: option, + ), + ] else if (option is SelectOption) ...[ + DropdownButtonSettingsTile( + option: option, + ), + ], + ], + ], + ), + ], + ], + if (appImplementation.syncImplementation != null) ...[ + StreamBuilder( + stream: syncBloc.mappingStatuses, + builder: (final context, final mappingStatuses) => !mappingStatuses.hasData + ? const SizedBox.shrink() + : SettingsCategory( + title: Text(AppLocalizations.of(context).optionsCategorySync), + tiles: [ + for (final mappingStatus in mappingStatuses.requireData.entries + .where((final mappingStatus) => mappingStatus.key.appId == appImplementation.id)) ...[ + CustomSettingsTile( + title: Text(appImplementation.syncImplementation!.getMappingDisplayTitle(mappingStatus.key)), + subtitle: + Text(appImplementation.syncImplementation!.getMappingDisplaySubtitle(mappingStatus.key)), + leading: NeonUserAvatar( + account: accountsBloc.accounts.value + .singleWhere((final account) => account.id == mappingStatus.key.accountId), + showStatus: false, + ), + trailing: IconButton( + onPressed: () async { + await syncBloc.syncMapping(mappingStatus.key); + }, + iconSize: 30, + icon: SyncStatusIcon( + status: mappingStatus.value, + ), + ), + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => SyncMappingSettingsPage( + mapping: mappingStatus.key, + ), + ), + ); + }, + ), + ], + CustomSettingsTile( + title: ElevatedButton.icon( + onPressed: () async { + final account = await showDialog( + context: context, + builder: (final context) { + final body = Column( + children: [ + for (final account in accountsBloc.accounts.value) ...[ + NeonAccountTile( + account: account, + onTap: () => Navigator.of(context).pop(account), + ), + ], + ], + ); + + return Dialog( + child: IntrinsicHeight( + child: Container( + padding: const EdgeInsets.all(24), + constraints: NeonDialogTheme.of(context).constraints, + child: body, + ), + ), + ); + }, + ); + if (account == null) { + return; + } + + if (!context.mounted) { + return; + } + + final mapping = await appImplementation.syncImplementation!.addMapping(context, account); + if (mapping == null) { + return; + } + + await syncBloc.addMapping(mapping); + }, + icon: Icon(MdiIcons.cloudSync), + label: Text(AppLocalizations.of(context).syncOptionsAdd), + ), + ), + ], + ), + ), + ], + ], + ); + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: appBar, + body: Center( + child: ConstrainedBox( + constraints: NeonDialogTheme.of(context).constraints, + child: body, + ), + ), + ); + } +} diff --git a/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart b/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart deleted file mode 100644 index b67458d4bad..00000000000 --- a/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; -import 'package:neon/l10n/localizations.dart'; -import 'package:neon/src/models/app_implementation.dart'; -import 'package:neon/src/settings/models/select_option.dart'; -import 'package:neon/src/settings/models/toggle_option.dart'; -import 'package:neon/src/settings/widgets/checkbox_settings_tile.dart'; -import 'package:neon/src/settings/widgets/dropdown_button_settings_tile.dart'; -import 'package:neon/src/settings/widgets/settings_category.dart'; -import 'package:neon/src/settings/widgets/settings_list.dart'; -import 'package:neon/src/theme/dialog.dart'; -import 'package:neon/src/utils/confirmation_dialog.dart'; - -class NextcloudAppSettingsPage extends StatelessWidget { - const NextcloudAppSettingsPage({ - required this.appImplementation, - super.key, - }); - - final AppImplementation appImplementation; - - @override - Widget build(final BuildContext context) { - final appBar = AppBar( - title: Text(appImplementation.name(context)), - actions: [ - IconButton( - onPressed: () async { - if (await showConfirmationDialog( - context, - AppLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)), - )) { - appImplementation.options.reset(); - } - }, - tooltip: AppLocalizations.of(context).settingsResetFor(appImplementation.name(context)), - icon: Icon(MdiIcons.cogRefresh), - ), - ], - ); - - final body = SettingsList( - categories: [ - for (final category in [...appImplementation.options.categories, null]) ...[ - if (appImplementation.options.options.where((final option) => option.category == category).isNotEmpty) ...[ - SettingsCategory( - title: Text( - category != null ? category.name(context) : AppLocalizations.of(context).optionsCategoryOther, - ), - tiles: [ - for (final option - in appImplementation.options.options.where((final option) => option.category == category)) ...[ - if (option is ToggleOption) ...[ - CheckBoxSettingsTile( - option: option, - ), - ] else if (option is SelectOption) ...[ - DropdownButtonSettingsTile( - option: option, - ), - ], - ], - ], - ), - ], - ], - ], - ); - - return Scaffold( - resizeToAvoidBottomInset: false, - appBar: appBar, - body: Center( - child: ConstrainedBox( - constraints: NeonDialogTheme.of(context).constraints, - child: body, - ), - ), - ); - } -} diff --git a/packages/neon/neon/lib/src/pages/settings.dart b/packages/neon/neon/lib/src/pages/settings.dart index a1e2bd70ccb..bd6aa96bed0 100644 --- a/packages/neon/neon/lib/src/pages/settings.dart +++ b/packages/neon/neon/lib/src/pages/settings.dart @@ -19,8 +19,8 @@ import 'package:neon/src/settings/widgets/text_settings_tile.dart'; import 'package:neon/src/theme/branding.dart'; import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/utils/confirmation_dialog.dart'; +import 'package:neon/src/utils/file_utils.dart'; import 'package:neon/src/utils/global_options.dart'; -import 'package:neon/src/utils/save_file.dart'; import 'package:neon/src/widgets/exception.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; @@ -250,7 +250,7 @@ class _SettingsPageState extends State { final fileName = 'nextcloud-neon-settings-${DateTime.now().millisecondsSinceEpoch ~/ 1000}.json'; final data = settingsExportHelper.exportToFile(); - await saveFileWithPickDialog(fileName, data); + await FileUtils.saveFileWithPickDialog(fileName, data); } catch (e, s) { debugPrint(e.toString()); debugPrint(s.toString()); diff --git a/packages/neon/neon/lib/src/pages/sync_mapping_settings.dart b/packages/neon/neon/lib/src/pages/sync_mapping_settings.dart new file mode 100644 index 00000000000..21a467353e1 --- /dev/null +++ b/packages/neon/neon/lib/src/pages/sync_mapping_settings.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/blocs/sync.dart'; +import 'package:neon/src/settings/widgets/checkbox_settings_tile.dart'; +import 'package:neon/src/settings/widgets/settings_category.dart'; +import 'package:neon/src/settings/widgets/settings_list.dart'; +import 'package:neon/src/sync/mapping.dart'; +import 'package:neon/src/utils/confirmation_dialog.dart'; +import 'package:provider/provider.dart'; + +class SyncMappingSettingsPage extends StatelessWidget { + const SyncMappingSettingsPage({ + required this.mapping, + super.key, + }); + + final SyncMapping mapping; + + @override + Widget build(final BuildContext context) { + final syncBloc = Provider.of(context, listen: false); + final options = syncBloc.getSyncMappingOptionsFor(mapping); + + return Scaffold( + appBar: AppBar( + actions: [ + IconButton( + onPressed: () async { + if (await showConfirmationDialog( + context, + AppLocalizations.of(context).syncOptionsRemoveConfirmation, + )) { + await syncBloc.removeMapping(mapping); + + if (context.mounted) { + Navigator.of(context).pop(); + } + } + }, + tooltip: AppLocalizations.of(context).syncOptionsRemove, + icon: Icon(MdiIcons.delete), + ), + IconButton( + onPressed: () async { + if (await showConfirmationDialog( + context, + AppLocalizations.of(context).settingsResetAllConfirmation, + )) { + options.reset(); + } + }, + tooltip: AppLocalizations.of(context).settingsResetAll, + icon: Icon(MdiIcons.cogRefresh), + ), + ], + ), + body: SettingsList( + categories: [ + SettingsCategory( + title: Text(AppLocalizations.of(context).optionsCategoryGeneral), + tiles: [ + CheckBoxSettingsTile( + option: options.automaticSync, + ), + ], + ), + ], + ), + ); + } +} diff --git a/packages/neon/neon/lib/src/router.dart b/packages/neon/neon/lib/src/router.dart index a0af9af94c4..e1f674ea5a6 100644 --- a/packages/neon/neon/lib/src/router.dart +++ b/packages/neon/neon/lib/src/router.dart @@ -10,13 +10,13 @@ import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/models/account.dart'; import 'package:neon/src/models/app_implementation.dart'; import 'package:neon/src/pages/account_settings.dart'; +import 'package:neon/src/pages/app_implementation_settings.dart'; import 'package:neon/src/pages/home.dart'; import 'package:neon/src/pages/login.dart'; import 'package:neon/src/pages/login_check_account.dart'; import 'package:neon/src/pages/login_check_server_status.dart'; import 'package:neon/src/pages/login_flow.dart'; import 'package:neon/src/pages/login_qrcode.dart'; -import 'package:neon/src/pages/nextcloud_app_settings.dart'; import 'package:neon/src/pages/route_not_found.dart'; import 'package:neon/src/pages/settings.dart'; import 'package:neon/src/utils/stream_listenable.dart'; @@ -378,7 +378,7 @@ class NextcloudAppSettingsRoute extends GoRouteData { final appImplementations = Provider.of>(context, listen: false); final appImplementation = appImplementations.tryFind(appid)!; - return NextcloudAppSettingsPage(appImplementation: appImplementation); + return AppImplementationSettingsPage(appImplementation: appImplementation); } } diff --git a/packages/neon/neon/lib/src/settings/models/storage.dart b/packages/neon/neon/lib/src/settings/models/storage.dart index df865c61462..d960f5d6f5f 100644 --- a/packages/neon/neon/lib/src/settings/models/storage.dart +++ b/packages/neon/neon/lib/src/settings/models/storage.dart @@ -24,6 +24,7 @@ abstract interface class Storable { enum StorageKeys implements Storable { apps._('app'), accounts._('accounts'), + sync._('sync'), global._('global'), lastUsedAccount._('last-used-account'), lastEndpoint._('last-endpoint'), diff --git a/packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart b/packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart index f042bcc6cf5..5b773507771 100644 --- a/packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart +++ b/packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart @@ -10,6 +10,7 @@ class CustomSettingsTile extends SettingsTile { this.leading, this.trailing, this.onTap, + this.onLongPress, super.key, }); @@ -18,6 +19,7 @@ class CustomSettingsTile extends SettingsTile { final Widget? leading; final Widget? trailing; final Function()? onTap; + final Function()? onLongPress; @override Widget build(final BuildContext context) => ListTile( @@ -26,5 +28,6 @@ class CustomSettingsTile extends SettingsTile { leading: leading, trailing: trailing, onTap: onTap, + onLongPress: onLongPress, ); } diff --git a/packages/neon/neon/lib/src/settings/widgets/settings_list.dart b/packages/neon/neon/lib/src/settings/widgets/settings_list.dart index 55642062ea5..3a70399d14d 100644 --- a/packages/neon/neon/lib/src/settings/widgets/settings_list.dart +++ b/packages/neon/neon/lib/src/settings/widgets/settings_list.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:neon/src/settings/widgets/settings_category.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; @visibleForTesting @@ -10,7 +9,7 @@ class SettingsList extends StatelessWidget { super.key, }); - final List categories; + final List categories; final String? initialCategory; int? _getIndex(final String? initialCategory) { diff --git a/packages/neon/neon/lib/src/sync/conflicts.dart b/packages/neon/neon/lib/src/sync/conflicts.dart new file mode 100644 index 00000000000..4911f84854b --- /dev/null +++ b/packages/neon/neon/lib/src/sync/conflicts.dart @@ -0,0 +1,18 @@ +import 'package:neon/src/models/account.dart'; +import 'package:neon/src/sync/implementation.dart'; +import 'package:neon/src/sync/mapping.dart'; +import 'package:synchronize/synchronize.dart'; + +class SyncConflicts { + SyncConflicts( + this.account, + this.implementation, + this.mapping, + this.conflicts, + ); + + final Account account; + final SyncImplementation, T1, T2> implementation; + final SyncMapping mapping; + final List> conflicts; +} diff --git a/packages/neon/neon/lib/src/sync/implementation.dart b/packages/neon/neon/lib/src/sync/implementation.dart new file mode 100644 index 00000000000..7a21545f9d6 --- /dev/null +++ b/packages/neon/neon/lib/src/sync/implementation.dart @@ -0,0 +1,37 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:neon/src/models/account.dart'; +import 'package:neon/src/sync/mapping.dart'; +import 'package:synchronize/synchronize.dart'; + +@immutable +abstract interface class SyncImplementation, T1, T2> { + String get appId; + + SyncSources getSources(final Account account, final S mapping); + + Map serializeMapping(final S mapping); + + S deserializeMapping(final Map json); + + FutureOr addMapping(final BuildContext context, final Account account); + + String getMappingDisplayTitle(final S mapping); + + String getMappingDisplaySubtitle(final S mapping); + + String getMappingId(final S mapping); + + Widget getConflictDetailsLocal(final BuildContext context, final T2 object); + + Widget getConflictDetailsRemote(final BuildContext context, final T1 object); +} + +extension SyncImplementationFind on Iterable { + SyncImplementation? tryFind(final String appId) => + singleWhereOrNull((final syncImplementation) => appId == syncImplementation.appId); + + SyncImplementation find(final String appId) => tryFind(appId)!; +} diff --git a/packages/neon/neon/lib/src/sync/mapping.dart b/packages/neon/neon/lib/src/sync/mapping.dart new file mode 100644 index 00000000000..48fcf49675f --- /dev/null +++ b/packages/neon/neon/lib/src/sync/mapping.dart @@ -0,0 +1,23 @@ +import 'package:meta/meta.dart'; +import 'package:synchronize/synchronize.dart'; + +abstract interface class SyncMapping { + String get accountId; + String get appId; + SyncJournal get journal; + + /// This method can be implemented to watch local or remote changes and update the status accordingly. + void watch(final Function() onUpdated) {} + + @mustBeOverridden + void dispose() {} + + @override + String toString() => 'SyncMapping(accountId: $accountId, appId: $appId)'; +} + +enum SyncMappingStatus { + unknown, + incomplete, + complete, +} diff --git a/packages/neon/neon/lib/src/utils/file_utils.dart b/packages/neon/neon/lib/src/utils/file_utils.dart new file mode 100644 index 00000000000..4df908063b7 --- /dev/null +++ b/packages/neon/neon/lib/src/utils/file_utils.dart @@ -0,0 +1,46 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_file_dialog/flutter_file_dialog.dart'; + +class FileUtils { + FileUtils._(); + + static Future saveFileWithPickDialog(final String fileName, final Uint8List data) async { + if (Platform.isAndroid || Platform.isIOS) { + // TODO: https://github.com/nextcloud/neon/issues/8 + return FlutterFileDialog.saveFile( + params: SaveFileDialogParams( + data: data, + fileName: fileName, + ), + ); + } else { + final result = await FilePicker.platform.saveFile( + fileName: fileName, + ); + if (result != null) { + await File(result).writeAsBytes(data); + } + + return result; + } + } + + static Future loadFileWithPickDialog({ + final bool withData = false, + final bool allowMultiple = false, + final FileType type = FileType.any, + }) async { + final result = await FilePicker.platform.pickFiles( + withData: withData, + allowMultiple: allowMultiple, + type: type, + ); + + return result; + } + + static Future pickDirectory() async => FilePicker.platform.getDirectoryPath(); +} diff --git a/packages/neon/neon/lib/src/utils/global_popups.dart b/packages/neon/neon/lib/src/utils/global_popups.dart index b0d830226c6..5c9cdf1c153 100644 --- a/packages/neon/neon/lib/src/utils/global_popups.dart +++ b/packages/neon/neon/lib/src/utils/global_popups.dart @@ -3,13 +3,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/blocs/accounts.dart'; import 'package:neon/src/blocs/first_launch.dart'; import 'package:neon/src/blocs/next_push.dart'; +import 'package:neon/src/blocs/sync.dart'; import 'package:neon/src/pages/settings.dart'; import 'package:neon/src/platform/platform.dart'; import 'package:neon/src/router.dart'; import 'package:neon/src/utils/global_options.dart'; +import 'package:neon/src/widgets/exception.dart'; +import 'package:neon/src/widgets/resolve_sync_conflicts_dialog.dart'; import 'package:provider/provider.dart'; +import 'package:synchronize/synchronize.dart'; import 'package:url_launcher/url_launcher_string.dart'; @internal @@ -45,11 +50,12 @@ class GlobalPopups { final globalOptions = Provider.of(context, listen: false); final firstLaunchBloc = Provider.of(context, listen: false); final nextPushBloc = Provider.of(context, listen: false); + final syncBloc = Provider.of(context, listen: false); _subscriptions.addAll([ if (NeonPlatform.instance.canUsePushNotifications) ...[ firstLaunchBloc.onFirstLaunch.listen((final _) { - assert(context.mounted, 'Context should be mounted'); + assert(_context.mounted, 'Context should be mounted'); if (!globalOptions.pushNotificationsEnabled.enabled) { return; } @@ -99,6 +105,31 @@ class GlobalPopups { ); }), ], + syncBloc.errors.listen((final error) { + assert(_context.mounted, 'Context should be mounted'); + NeonException.showSnackbar(_context, error); + }), + syncBloc.conflicts.listen((final conflicts) async { + assert(_context.mounted, 'Context should be mounted'); + + final providers = + Provider.of(context, listen: false).getAppsBlocFor(conflicts.account).appBlocProviders; + final result = await showDialog>( + context: _context, + builder: (final context) => MultiProvider( + providers: providers, + child: NeonResolveSyncConflictsDialog(conflicts: conflicts), + ), + ); + if (result == null) { + return; + } + + await syncBloc.syncMapping( + conflicts.mapping, + solutions: result, + ); + }), ]); _registered = true; diff --git a/packages/neon/neon/lib/src/utils/save_file.dart b/packages/neon/neon/lib/src/utils/save_file.dart deleted file mode 100644 index d8cab3c8369..00000000000 --- a/packages/neon/neon/lib/src/utils/save_file.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter_file_dialog/flutter_file_dialog.dart'; - -Future saveFileWithPickDialog(final String fileName, final Uint8List data) async { - if (Platform.isAndroid || Platform.isIOS) { - // TODO: https://github.com/nextcloud/neon/issues/8 - return FlutterFileDialog.saveFile( - params: SaveFileDialogParams( - data: data, - fileName: fileName, - ), - ); - } else { - final result = await FilePicker.platform.saveFile( - fileName: fileName, - ); - if (result != null) { - await File(result).writeAsBytes(data); - } - - return result; - } -} diff --git a/packages/neon/neon/lib/src/utils/sync_mapping_options.dart b/packages/neon/neon/lib/src/utils/sync_mapping_options.dart new file mode 100644 index 00000000000..1ed93e2abaf --- /dev/null +++ b/packages/neon/neon/lib/src/utils/sync_mapping_options.dart @@ -0,0 +1,45 @@ +import 'package:meta/meta.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/settings.dart'; +import 'package:neon/src/settings/models/option.dart'; +import 'package:neon/src/settings/models/storage.dart'; + +@internal +@immutable +class SyncMappingOptions { + SyncMappingOptions(this._storage); + + final AppStorage _storage; + + late final List