From be4dbb70733fa447c6f980b0a89f3ba747925ef4 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 | 28 +- .../neon/neon/lib/l10n/localizations.dart | 96 +++++++ .../neon/neon/lib/l10n/localizations_en.dart | 50 ++++ packages/neon/neon/lib/neon.dart | 6 + packages/neon/neon/lib/src/blocs/apps.dart | 8 + packages/neon/neon/lib/src/blocs/sync.dart | 272 ++++++++++++++++++ .../lib/src/models/app_implementation.dart | 5 + .../pages/app_implementation_settings.dart | 156 ++++++++++ .../lib/src/pages/nextcloud_app_settings.dart | 74 ----- .../neon/neon/lib/src/pages/settings.dart | 4 +- .../lib/src/pages/sync_mapping_settings.dart | 72 +++++ packages/neon/neon/lib/src/router.dart | 6 +- .../neon/lib/src/settings/models/storage.dart | 3 + .../widgets/custom_settings_tile.dart | 3 + .../neon/lib/src/sync/models/conflicts.dart | 18 ++ .../lib/src/sync/models/implementation.dart | 43 +++ .../neon/lib/src/sync/models/mapping.dart | 23 ++ .../resolve_sync_conflicts_dialog.dart | 115 ++++++++ .../src/sync/widgets/sync_conflict_card.dart | 49 ++++ .../neon/neon/lib/src/utils/file_utils.dart | 52 ++++ .../neon/lib/src/utils/global_popups.dart | 35 ++- .../neon/neon/lib/src/utils/save_file.dart | 32 --- .../lib/src/utils/sync_mapping_options.dart | 30 ++ .../widgets/adaptive_widgets/list_tile.dart | 14 + .../lib/src/widgets/sync_status_icon.dart | 44 +++ 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_dashboard/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 +- 36 files changed, 1162 insertions(+), 120 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/models/conflicts.dart create mode 100644 packages/neon/neon/lib/src/sync/models/implementation.dart create mode 100644 packages/neon/neon/lib/src/sync/models/mapping.dart create mode 100644 packages/neon/neon/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart create mode 100644 packages/neon/neon/lib/src/sync/widgets/sync_conflict_card.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/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 ca92ca8e..143185d2 100644 --- a/packages/app/pubspec.lock +++ b/packages/app/pubspec.lock @@ -1174,6 +1174,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 e896bfc2..eb48b49a 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,neon_dashboard +# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,neon_dashboard,neon_files,neon_lints,neon_news,neon_notes,neon_notifications,nextcloud,sort_box,synchronize dependency_overrides: dynamite_runtime: path: ../dynamite/dynamite_runtime @@ -22,3 +22,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 cfc0f28e..90197e16 100644 --- a/packages/neon/neon/lib/l10n/en.arb +++ b/packages/neon/neon/lib/l10n/en.arb @@ -84,6 +84,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.", @@ -124,6 +128,7 @@ "optionsCategoryStartup": "Startup", "optionsCategorySystemTray": "System tray", "optionsCategoryNavigation": "Navigation", + "optionsCategorySync": "Synchronization", "optionsSortOrderAscending": "Ascending", "optionsSortOrderDescending": "Descending", "globalOptionsThemeMode": "Theme mode", @@ -180,5 +185,26 @@ "accountOptionsAutomatic": "Automatic", "licenses": "Licenses", "sourceCode": "Source code", - "issueTracker": "Report a bug or request a feature" + "issueTracker": "Report a bug or request a feature", + "syncOptionsAdd": "Add synchronization", + "syncOptionsRemove": "Remove synchronization", + "syncOptionsSyncNow": "Synchronize now", + "syncOptionsStatusUnknown": "Unknown synchronization status", + "syncOptionsStatusIncomplete": "Not completely synchronized", + "syncOptionsStatusComplete": "Completely synchronized", + "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 b588aaf2..469b8e11 100644 --- a/packages/neon/neon/lib/l10n/localizations.dart +++ b/packages/neon/neon/lib/l10n/localizations.dart @@ -311,6 +311,30 @@ abstract class NeonLocalizations { /// **'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: @@ -467,6 +491,12 @@ abstract class NeonLocalizations { /// **'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,72 @@ abstract class NeonLocalizations { /// In en, this message translates to: /// **'Report a bug or request a feature'** String get issueTracker; + + /// 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 @syncOptionsSyncNow. + /// + /// In en, this message translates to: + /// **'Synchronize now'** + String get syncOptionsSyncNow; + + /// No description provided for @syncOptionsStatusUnknown. + /// + /// In en, this message translates to: + /// **'Unknown synchronization status'** + String get syncOptionsStatusUnknown; + + /// No description provided for @syncOptionsStatusIncomplete. + /// + /// In en, this message translates to: + /// **'Not completely synchronized'** + String get syncOptionsStatusIncomplete; + + /// No description provided for @syncOptionsStatusComplete. + /// + /// In en, this message translates to: + /// **'Completely synchronized'** + String get syncOptionsStatusComplete; + + /// 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 _NeonLocalizationsDelegate extends LocalizationsDelegate { diff --git a/packages/neon/neon/lib/l10n/localizations_en.dart b/packages/neon/neon/lib/l10n/localizations_en.dart index e8df967a..ef5cc843 100644 --- a/packages/neon/neon/lib/l10n/localizations_en.dart +++ b/packages/neon/neon/lib/l10n/localizations_en.dart @@ -147,6 +147,18 @@ class NeonLocalizationsEn extends NeonLocalizations { @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'; @@ -230,6 +242,9 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get optionsCategoryNavigation => 'Navigation'; + @override + String get optionsCategorySync => 'Synchronization'; + @override String get optionsSortOrderAscending => 'Ascending'; @@ -345,4 +360,39 @@ class NeonLocalizationsEn extends NeonLocalizations { @override String get issueTracker => 'Report a bug or request a feature'; + + @override + String get syncOptionsAdd => 'Add synchronization'; + + @override + String get syncOptionsRemove => 'Remove synchronization'; + + @override + String get syncOptionsSyncNow => 'Synchronize now'; + + @override + String get syncOptionsStatusUnknown => 'Unknown synchronization status'; + + @override + String get syncOptionsStatusIncomplete => 'Not completely synchronized'; + + @override + String get syncOptionsStatusComplete => 'Completely synchronized'; + + @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 c7fe8520..70418eac 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/models/disposable.dart'; @@ -66,6 +67,10 @@ Future runNeon({ globalOptions, disabled: nextPushDisabled, ); + final syncBloc = SyncBloc( + accountsBloc, + appImplementations, + ); runApp( MultiProvider( @@ -74,6 +79,7 @@ Future runNeon({ NeonProvider.value(value: accountsBloc), NeonProvider.value(value: firstLaunchBloc), NeonProvider.value(value: nextPushBloc), + NeonProvider.value(value: syncBloc), Provider>( create: (final _) => appImplementations, dispose: (final _, final appImplementations) => appImplementations.disposeAll(), diff --git a/packages/neon/neon/lib/src/blocs/apps.dart b/packages/neon/neon/lib/src/blocs/apps.dart index b65a67a3..4a68351a 100644 --- a/packages/neon/neon/lib/src/blocs/apps.dart +++ b/packages/neon/neon/lib/src/blocs/apps.dart @@ -243,9 +243,17 @@ class AppsBloc extends InteractiveBloc implements AppsBlocEvents, AppsBlocStates /// Returns the active [Bloc] for the given [appImplementation]. /// /// If no bloc exists yet a new one will be instantiated and cached in [AppImplementation.blocsCache]. + /// See [getAppBlocByID] for getting the [Bloc] by the [AppImplementation.id]. T getAppBloc(final AppImplementation appImplementation) => appImplementation.getBloc(_account); + /// Returns the active [Bloc] for the given [appId]. + /// + /// If no bloc exists yet a new one will be instantiated and cached in [AppImplementation.blocsCache]. + /// See [getAppBloc] for getting the [Bloc] by the [AppImplementation]. + T? getAppBlocByID(final String appId) => + _allAppImplementations.tryFind(appId)?.getBloc(_account) as T?; + /// Returns the active [Bloc] for every registered [AppImplementation] wrapped in a Provider. 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 00000000..11546816 --- /dev/null +++ b/packages/neon/neon/lib/src/blocs/sync.dart @@ -0,0 +1,272 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.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/models/conflicts.dart'; +import 'package:neon/src/sync/models/implementation.dart'; +import 'package:neon/src/sync/models/mapping.dart'; +import 'package:neon/src/utils/sync_mapping_options.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, SyncMappingStatus>> 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, dynamic, dynamic>> _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()); + + super.dispose(); + } + + @override + late final Stream> conflicts = _conflictsController.stream.asBroadcastStream(); + + @override + final BehaviorSubject, SyncMappingStatus>> 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; + } + + try { + final implementation = _syncImplementations.find(mapping.appId); + final sources = await implementation.getSources(account, mapping); + + final diff = await computeSyncDiff( + sources, + mapping.journal, + conflictSolutions: solutions, + keepSkipsAsConflicts: true, + ); + debugPrint('Journal: ${mapping.journal}'); + 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 = await 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 = syncImplementation.getGlobalUniqueMappingId(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 b70cffa1..90cd55e3 100644 --- a/packages/neon/neon/lib/src/models/app_implementation.dart +++ b/packages/neon/neon/lib/src/models/app_implementation.dart @@ -12,6 +12,8 @@ import 'package:neon/src/models/account_cache.dart'; import 'package:neon/src/models/disposable.dart'; import 'package:neon/src/settings/models/options_collection.dart'; import 'package:neon/src/settings/models/storage.dart'; +import 'package:neon/src/sync/models/implementation.dart'; +import 'package:neon/src/sync/models/mapping.dart'; import 'package:neon/src/utils/provider.dart'; import 'package:neon/src/widgets/drawer_destination.dart'; import 'package:nextcloud/core.dart' as core; @@ -82,6 +84,9 @@ abstract class AppImplementation @protected T buildBloc(final Account account); + /// Optional [SyncImplementation] for this [AppImplementation]. + SyncImplementation, dynamic, dynamic>? get syncImplementation => null; + /// The [Provider] building the bloc [T] the currently active account. /// /// Blocs will not be disposed on disposal of the provider. You must handle 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 00000000..4f8e0e9c --- /dev/null +++ b/packages/neon/neon/lib/src/pages/app_implementation_settings.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:meta/meta.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/widgets/custom_settings_tile.dart'; +import 'package:neon/src/settings/widgets/option_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_selection_dialog.dart'; +import 'package:neon/src/widgets/sync_status_icon.dart'; +import 'package:neon/src/widgets/user_avatar.dart'; +import 'package:provider/provider.dart'; + +@internal +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, + NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)), + )) { + appImplementation.options.reset(); + } + }, + tooltip: NeonLocalizations.of(context).settingsResetFor(appImplementation.name(context)), + icon: const 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) : NeonLocalizations.of(context).optionsCategoryOther, + ), + tiles: [ + for (final option + in appImplementation.options.options.where((final option) => option.category == category)) ...[ + OptionSettingsTile(option: option), + ], + ], + ), + ], + ], + if (appImplementation.syncImplementation != null) ...[ + StreamBuilder( + stream: syncBloc.mappingStatuses, + builder: (final context, final mappingStatuses) => !mappingStatuses.hasData + ? const SizedBox.shrink() + : SettingsCategory( + title: Text(NeonLocalizations.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); + }, + tooltip: NeonLocalizations.of(context).syncOptionsSyncNow, + 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) => const NeonAccountSelectionDialog(), + ); + 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: const Icon(MdiIcons.cloudSync), + label: Text(NeonLocalizations.of(context).syncOptionsAdd), + ), + ), + ], + ), + ), + ], + ], + ); + + return Scaffold( + resizeToAvoidBottomInset: false, + appBar: appBar, + body: SafeArea( + child: 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 b26b5451..00000000 --- a/packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:meta/meta.dart'; -import 'package:neon/l10n/localizations.dart'; -import 'package:neon/src/models/app_implementation.dart'; -import 'package:neon/src/settings/widgets/option_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'; - -@internal -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, - NeonLocalizations.of(context).settingsResetForConfirmation(appImplementation.name(context)), - )) { - appImplementation.options.reset(); - } - }, - tooltip: NeonLocalizations.of(context).settingsResetFor(appImplementation.name(context)), - icon: const 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) : NeonLocalizations.of(context).optionsCategoryOther, - ), - tiles: [ - for (final option - in appImplementation.options.options.where((final option) => option.category == category)) ...[ - OptionSettingsTile(option: option), - ], - ], - ), - ], - ], - ], - ); - - return Scaffold( - resizeToAvoidBottomInset: false, - appBar: appBar, - body: SafeArea( - child: 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 c2eb3098..1f0057e9 100644 --- a/packages/neon/neon/lib/src/pages/settings.dart +++ b/packages/neon/neon/lib/src/pages/settings.dart @@ -20,9 +20,9 @@ import 'package:neon/src/theme/branding.dart'; import 'package:neon/src/theme/dialog.dart'; import 'package:neon/src/utils/adaptive.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/provider.dart'; -import 'package:neon/src/utils/save_file.dart'; import 'package:neon/src/widgets/error.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:url_launcher/url_launcher_string.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 00000000..27bee63c --- /dev/null +++ b/packages/neon/neon/lib/src/pages/sync_mapping_settings.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/blocs/sync.dart'; +import 'package:neon/src/settings/widgets/option_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/models/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, + NeonLocalizations.of(context).syncOptionsRemoveConfirmation, + )) { + await syncBloc.removeMapping(mapping); + + if (context.mounted) { + Navigator.of(context).pop(); + } + } + }, + tooltip: NeonLocalizations.of(context).syncOptionsRemove, + icon: const Icon(MdiIcons.delete), + ), + IconButton( + onPressed: () async { + if (await showConfirmationDialog( + context, + NeonLocalizations.of(context).settingsResetAllConfirmation, + )) { + options.reset(); + } + }, + tooltip: NeonLocalizations.of(context).settingsResetAll, + icon: const Icon(MdiIcons.cogRefresh), + ), + ], + ), + body: SettingsList( + categories: [ + SettingsCategory( + title: Text(NeonLocalizations.of(context).optionsCategoryGeneral), + tiles: [ + ToggleSettingsTile( + option: options.automaticSync, + ), + ], + ), + ], + ), + ); + } +} diff --git a/packages/neon/neon/lib/src/router.dart b/packages/neon/neon/lib/src/router.dart index 5ba8289f..c3b9d752 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_qr_code.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/provider.dart'; @@ -439,7 +439,7 @@ class _AddAccountCheckAccountRoute extends LoginCheckAccountRoute { } /// {@template AppRoutes.NextcloudAppSettingsRoute} -/// Route for the the [NextcloudAppSettingsPage]. +/// Route for the the [AppImplementationSettingsPage]. /// {@endtemplate} @immutable class NextcloudAppSettingsRoute extends GoRouteData { @@ -456,7 +456,7 @@ class NextcloudAppSettingsRoute extends GoRouteData { final appImplementations = NeonProvider.of>(context); 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 9dd1f394..fea850af 100644 --- a/packages/neon/neon/lib/src/settings/models/storage.dart +++ b/packages/neon/neon/lib/src/settings/models/storage.dart @@ -70,6 +70,9 @@ enum StorageKeys implements Storable { /// The key for the `Account`s and their `AccountSpecificOptions`. accounts._('accounts'), + /// The key for the `SyncImplementation`s. + sync._('sync'), + /// The key for the `GlobalOptions`. global._('global'), 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 6161651b..0d992c5a 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 @@ -13,6 +13,7 @@ class CustomSettingsTile extends SettingsTile { this.leading, this.trailing, this.onTap, + this.onLongPress, super.key, }); @@ -21,6 +22,7 @@ class CustomSettingsTile extends SettingsTile { final Widget? leading; final Widget? trailing; final FutureOr Function()? onTap; + final FutureOr Function()? onLongPress; @override Widget build(final BuildContext context) => AdaptiveListTile( @@ -29,5 +31,6 @@ class CustomSettingsTile extends SettingsTile { leading: leading, trailing: trailing, onTap: onTap, + onLongPress: onLongPress, ); } diff --git a/packages/neon/neon/lib/src/sync/models/conflicts.dart b/packages/neon/neon/lib/src/sync/models/conflicts.dart new file mode 100644 index 00000000..68cfa5b7 --- /dev/null +++ b/packages/neon/neon/lib/src/sync/models/conflicts.dart @@ -0,0 +1,18 @@ +import 'package:neon/src/models/account.dart'; +import 'package:neon/src/sync/models/implementation.dart'; +import 'package:neon/src/sync/models/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/models/implementation.dart b/packages/neon/neon/lib/src/sync/models/implementation.dart new file mode 100644 index 00000000..40f0404d --- /dev/null +++ b/packages/neon/neon/lib/src/sync/models/implementation.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:neon/src/models/account.dart'; +import 'package:neon/src/sync/models/mapping.dart'; +import 'package:synchronize/synchronize.dart'; + +@immutable +abstract interface class SyncImplementation, T1, T2> { + String get appId; + + FutureOr> 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 SyncImplementationGlobalUniqueMappingId + on SyncImplementation, dynamic, dynamic> { + String getGlobalUniqueMappingId(final SyncMapping mapping) => + '${mapping.accountId}-${mapping.appId}-${getMappingId(mapping)}'; +} + +extension SyncImplementationFind on Iterable, dynamic, dynamic>> { + SyncImplementation, dynamic, dynamic>? tryFind(final String appId) => + singleWhereOrNull((final syncImplementation) => appId == syncImplementation.appId); + + SyncImplementation, dynamic, dynamic> find(final String appId) => tryFind(appId)!; +} diff --git a/packages/neon/neon/lib/src/sync/models/mapping.dart b/packages/neon/neon/lib/src/sync/models/mapping.dart new file mode 100644 index 00000000..a5a5150c --- /dev/null +++ b/packages/neon/neon/lib/src/sync/models/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 void 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/sync/widgets/resolve_sync_conflicts_dialog.dart b/packages/neon/neon/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart new file mode 100644 index 00000000..ed7ddc75 --- /dev/null +++ b/packages/neon/neon/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/sync/models/conflicts.dart'; +import 'package:neon/src/sync/widgets/sync_conflict_card.dart'; +import 'package:neon/src/theme/dialog.dart'; +import 'package:synchronize/synchronize.dart'; + +class NeonResolveSyncConflictsDialog extends StatefulWidget { + const NeonResolveSyncConflictsDialog({ + required this.conflicts, + super.key, + }); + + final SyncConflicts conflicts; + + @override + State> createState() => _NeonResolveSyncConflictsDialogState(); +} + +class _NeonResolveSyncConflictsDialogState extends State> { + var _index = 0; + final _solutions = {}; + + SyncConflict get conflict => widget.conflicts.conflicts[_index]; + + SyncConflictSolution? get selectedSolution => _solutions[conflict.id]; + + void onSolution(final SyncConflictSolution solution) { + setState(() { + _solutions[conflict.id] = solution; + }); + } + + bool get isFirst => _index == 0; + bool get isLast => _index == widget.conflicts.conflicts.length - 1; + + @override + Widget build(final BuildContext context) { + final body = Column( + children: [ + Text( + NeonLocalizations.of(context).syncResolveConflictsTitle( + widget.conflicts.conflicts.length, + NeonLocalizations.of(context).appImplementationName(widget.conflicts.implementation.appId), + ), + style: Theme.of(context).textTheme.headlineMedium, + ), + const Divider(), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SyncConflictCard( + title: NeonLocalizations.of(context).syncResolveConflictsLocal, + solution: SyncConflictSolution.overwriteA, + selected: selectedSolution == SyncConflictSolution.overwriteA, + onSelected: onSolution, + child: widget.conflicts.implementation.getConflictDetailsLocal(context, conflict.objectB.data), + ), + SyncConflictCard( + title: NeonLocalizations.of(context).syncResolveConflictsRemote, + solution: SyncConflictSolution.overwriteB, + selected: selectedSolution == SyncConflictSolution.overwriteB, + onSelected: onSolution, + child: widget.conflicts.implementation.getConflictDetailsRemote(context, conflict.objectA.data), + ), + ], + ), + const Divider(), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + OutlinedButton( + onPressed: () { + if (isFirst) { + Navigator.of(context).pop(); + } else { + setState(() { + _index--; + }); + } + }, + child: Text( + isFirst ? NeonLocalizations.of(context).actionCancel : NeonLocalizations.of(context).actionPrevious, + ), + ), + ElevatedButton( + onPressed: () { + if (isLast) { + Navigator.of(context).pop(_solutions); + } else { + setState(() { + _index++; + }); + } + }, + child: Text( + isLast ? NeonLocalizations.of(context).actionFinish : NeonLocalizations.of(context).actionNext, + ), + ), + ], + ), + ], + ); + + return Dialog( + child: IntrinsicHeight( + child: Container( + padding: const EdgeInsets.all(24), + constraints: NeonDialogTheme.of(context).constraints, + child: body, + ), + ), + ); + } +} diff --git a/packages/neon/neon/lib/src/sync/widgets/sync_conflict_card.dart b/packages/neon/neon/lib/src/sync/widgets/sync_conflict_card.dart new file mode 100644 index 00000000..b82b0d88 --- /dev/null +++ b/packages/neon/neon/lib/src/sync/widgets/sync_conflict_card.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:synchronize/synchronize.dart'; + +class SyncConflictCard extends StatelessWidget { + const SyncConflictCard({ + required this.title, + required this.child, + required this.selected, + required this.solution, + required this.onSelected, + super.key, + }); + + final String title; + final Widget child; + final bool selected; + final SyncConflictSolution solution; + final void Function(SyncConflictSolution solution) onSelected; + + @override + Widget build(final BuildContext context) => Card( + shape: selected + ? RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of(context).colorScheme.onBackground, + ), + ) + : null, + child: InkWell( + onTap: () { + onSelected(solution); + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + title, + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + child, + ], + ), + ), + ); +} 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 00000000..04929f20 --- /dev/null +++ b/packages/neon/neon/lib/src/utils/file_utils.dart @@ -0,0 +1,52 @@ +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_file_dialog/flutter_file_dialog.dart'; +import 'package:neon/src/platform/platform.dart'; +import 'package:universal_io/io.dart'; + +class FileUtils { + FileUtils._(); + + /// Displays a dialog for selecting a location where to save a file with the [data] content. + /// + /// Set the the suggested [fileName] to use when saving the file. + /// + /// Returns the path of the saved file or `null` if the operation was cancelled. + static Future saveFileWithPickDialog(final String fileName, final Uint8List data) async { + if (NeonPlatform.instance.shouldUseFileDialog) { + // 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 897e28f5..c2c29733 100644 --- a/packages/neon/neon/lib/src/utils/global_popups.dart +++ b/packages/neon/neon/lib/src/utils/global_popups.dart @@ -3,13 +3,19 @@ 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/sync/widgets/resolve_sync_conflicts_dialog.dart'; import 'package:neon/src/utils/global_options.dart'; import 'package:neon/src/utils/provider.dart'; +import 'package:neon/src/widgets/error.dart'; +import 'package:provider/provider.dart'; +import 'package:synchronize/synchronize.dart'; import 'package:url_launcher/url_launcher_string.dart'; /// Singleton class managing global popups. @@ -62,10 +68,11 @@ class GlobalPopups { final globalOptions = NeonProvider.of(context); final firstLaunchBloc = NeonProvider.of(context); final nextPushBloc = NeonProvider.of(context); + final syncBloc = NeonProvider.of(context); if (NeonPlatform.instance.canUsePushNotifications) { _subscriptions.addAll([ firstLaunchBloc.onFirstLaunch.listen((final _) { - assert(context.mounted, 'Context should be mounted'); + assert(_context.mounted, 'Context should be mounted'); if (!globalOptions.pushNotificationsEnabled.enabled) { return; } @@ -116,5 +123,31 @@ class GlobalPopups { }), ]); } + _subscriptions.addAll([ + syncBloc.errors.listen((final error) { + assert(_context.mounted, 'Context should be mounted'); + NeonError.showSnackbar(_context, error); + }), + syncBloc.conflicts.listen((final conflicts) async { + assert(_context.mounted, 'Context should be mounted'); + + final providers = NeonProvider.of(context).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, + ); + }), + ]); } } 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 d25c5286..00000000 --- a/packages/neon/neon/lib/src/utils/save_file.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:typed_data'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter_file_dialog/flutter_file_dialog.dart'; -import 'package:neon/src/platform/platform.dart'; -import 'package:universal_io/io.dart'; - -/// Displays a dialog for selecting a location where to save a file with the [data] content. -/// -/// Set the the suggested [fileName] to use when saving the file. -/// -/// Returns the path of the saved file or `null` if the operation was cancelled. -Future saveFileWithPickDialog(final String fileName, final Uint8List data) async { - if (NeonPlatform.instance.shouldUseFileDialog) { - // 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 00000000..d8432546 --- /dev/null +++ b/packages/neon/neon/lib/src/utils/sync_mapping_options.dart @@ -0,0 +1,30 @@ +import 'package:meta/meta.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/settings.dart'; + +@internal +@immutable +class SyncMappingOptions extends OptionsCollection { + SyncMappingOptions(super.storage); + + @override + late final List> options = [ + automaticSync, + ]; + + late final automaticSync = ToggleOption( + storage: storage, + key: SyncMappingOptionKeys.automaticSync, + label: (final context) => NeonLocalizations.of(context).syncOptionsAutomaticSync, + defaultValue: true, + ); +} + +enum SyncMappingOptionKeys implements Storable { + automaticSync._('automatic-sync'); + + const SyncMappingOptionKeys._(this.value); + + @override + final String value; +} diff --git a/packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart b/packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart index b7d2f120..849fbcf9 100644 --- a/packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart +++ b/packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart @@ -16,6 +16,7 @@ class AdaptiveListTile extends StatelessWidget { this.leading, this.trailing, this.onTap, + this.onLongPress, super.key, }) : additionalInfo = null; @@ -30,6 +31,7 @@ class AdaptiveListTile extends StatelessWidget { this.leading, this.trailing, this.onTap, + this.onLongPress, super.key, }) : subtitle = additionalInfo; @@ -76,6 +78,18 @@ class AdaptiveListTile extends StatelessWidget { /// {@endtemplate} final FutureOr Function()? onTap; + /// {@template neon.AdaptiveListTile.onLongPress} + /// The [onLongPress] function is called when a user long presses on the[AdaptiveListTile]. + /// If left `null`, the [AdaptiveListTile] will not react to long presses. + /// + /// If the platform is a Cupertino one and this is a `Future Function()`, + /// then the [AdaptiveListTile] remains activated until the returned future is + /// awaited. This is according to iOS behavior. + /// However, if this function is a `void Function()`, then the tile is active + /// only for the duration of invocation. + /// {@endtemplate} + final FutureOr Function()? onLongPress; + /// {@template neon.AdaptiveListTile.enabled} /// Whether this list tile is interactive. /// diff --git a/packages/neon/neon/lib/src/widgets/sync_status_icon.dart b/packages/neon/neon/lib/src/widgets/sync_status_icon.dart new file mode 100644 index 00000000..7ad3d919 --- /dev/null +++ b/packages/neon/neon/lib/src/widgets/sync_status_icon.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:neon/l10n/localizations.dart'; +import 'package:neon/src/sync/models/mapping.dart'; +import 'package:neon/src/theme/colors.dart'; + +class SyncStatusIcon extends StatelessWidget { + const SyncStatusIcon({ + required this.status, + this.size, + super.key, + }); + + final SyncMappingStatus status; + final double? size; + + @override + Widget build(final BuildContext context) { + final (icon, color, semanticLabel) = switch (status) { + SyncMappingStatus.unknown => ( + MdiIcons.cloudQuestion, + NcColors.error, + NeonLocalizations.of(context).syncOptionsStatusUnknown, + ), + SyncMappingStatus.incomplete => ( + MdiIcons.cloudSync, + NcColors.warning, + NeonLocalizations.of(context).syncOptionsStatusIncomplete, + ), + SyncMappingStatus.complete => ( + MdiIcons.cloudCheck, + NcColors.success, + NeonLocalizations.of(context).syncOptionsStatusComplete, + ), + }; + + return Icon( + icon, + color: color, + size: size, + semanticLabel: semanticLabel, + ); + } +} diff --git a/packages/neon/neon/lib/sync.dart b/packages/neon/neon/lib/sync.dart new file mode 100644 index 00000000..ffa733cd --- /dev/null +++ b/packages/neon/neon/lib/sync.dart @@ -0,0 +1,4 @@ +export 'package:neon/src/sync/models/conflicts.dart'; +export 'package:neon/src/sync/models/implementation.dart'; +export 'package:neon/src/sync/models/mapping.dart'; +export 'package:synchronize/synchronize.dart'; diff --git a/packages/neon/neon/lib/utils.dart b/packages/neon/neon/lib/utils.dart index f3d8012e..59173a44 100644 --- a/packages/neon/neon/lib/utils.dart +++ b/packages/neon/neon/lib/utils.dart @@ -2,6 +2,7 @@ export 'package:neon/l10n/localizations.dart'; export 'package:neon/src/utils/app_route.dart'; export 'package:neon/src/utils/confirmation_dialog.dart'; export 'package:neon/src/utils/exceptions.dart'; +export 'package:neon/src/utils/file_utils.dart'; export 'package:neon/src/utils/hex_color.dart'; export 'package:neon/src/utils/provider.dart'; export 'package:neon/src/utils/rename_dialog.dart'; diff --git a/packages/neon/neon/pubspec.yaml b/packages/neon/neon/pubspec.yaml index a8ace978..891b41c5 100644 --- a/packages/neon/neon/pubspec.yaml +++ b/packages/neon/neon/pubspec.yaml @@ -50,6 +50,10 @@ dependencies: path: packages/sort_box sqflite: ^2.0.0 sqflite_common_ffi: ^2.2.8-2 + synchronize: + git: + url: https://github.com/nextcloud/neon + path: packages/synchronize tray_manager: ^0.2.0 unifiedpush: ^5.0.0 unifiedpush_android: ^2.0.0 diff --git a/packages/neon/neon/pubspec_overrides.yaml b/packages/neon/neon/pubspec_overrides.yaml index 3ea13b42..e5c89426 100644 --- a/packages/neon/neon/pubspec_overrides.yaml +++ b/packages/neon/neon/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,nextcloud,sort_box,neon_lints +# melos_managed_dependency_overrides: dynamite_runtime,nextcloud,sort_box,neon_lints,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -8,3 +8,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_dashboard/pubspec_overrides.yaml b/packages/neon/neon_dashboard/pubspec_overrides.yaml index e247d2d9..b52ca2c1 100644 --- a/packages/neon/neon_dashboard/pubspec_overrides.yaml +++ b/packages/neon/neon_dashboard/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon,neon_lints,nextcloud,sort_box +# melos_managed_dependency_overrides: dynamite_runtime,neon,neon_lints,nextcloud,sort_box,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -10,3 +10,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_files/pubspec_overrides.yaml b/packages/neon/neon_files/pubspec_overrides.yaml index 84933267..06ac1faf 100644 --- a/packages/neon/neon_files/pubspec_overrides.yaml +++ b/packages/neon/neon_files/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,nextcloud,sort_box,neon_lints +# melos_managed_dependency_overrides: dynamite_runtime,file_icons,neon,nextcloud,sort_box,neon_lints,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -12,3 +12,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_news/pubspec_overrides.yaml b/packages/neon/neon_news/pubspec_overrides.yaml index 3bec69c6..d5cb1778 100644 --- a/packages/neon/neon_news/pubspec_overrides.yaml +++ b/packages/neon/neon_news/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints +# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -10,3 +10,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_notes/pubspec_overrides.yaml b/packages/neon/neon_notes/pubspec_overrides.yaml index 3bec69c6..d5cb1778 100644 --- a/packages/neon/neon_notes/pubspec_overrides.yaml +++ b/packages/neon/neon_notes/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints +# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -10,3 +10,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize diff --git a/packages/neon/neon_notifications/pubspec_overrides.yaml b/packages/neon/neon_notifications/pubspec_overrides.yaml index 3bec69c6..d5cb1778 100644 --- a/packages/neon/neon_notifications/pubspec_overrides.yaml +++ b/packages/neon/neon_notifications/pubspec_overrides.yaml @@ -1,4 +1,4 @@ -# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints +# melos_managed_dependency_overrides: dynamite_runtime,neon,nextcloud,sort_box,neon_lints,synchronize dependency_overrides: dynamite_runtime: path: ../../dynamite/dynamite_runtime @@ -10,3 +10,5 @@ dependency_overrides: path: ../../nextcloud sort_box: path: ../../sort_box + synchronize: + path: ../../synchronize