Compare commits

...

3 Commits

Author SHA1 Message Date
jld3103 dfa7332eea
feat(neon_files): Implement file syncing 1 year ago
jld3103 be4dbb7073
feat(neon): Implement syncing 1 year ago
jld3103 30deb2e654
feat(synchronize): Init 1 year ago
  1. 1
      commitlint.yaml
  2. 15
      packages/app/pubspec.lock
  3. 4
      packages/app/pubspec_overrides.yaml
  4. 28
      packages/neon/neon/lib/l10n/en.arb
  5. 96
      packages/neon/neon/lib/l10n/localizations.dart
  6. 50
      packages/neon/neon/lib/l10n/localizations_en.dart
  7. 6
      packages/neon/neon/lib/neon.dart
  8. 8
      packages/neon/neon/lib/src/blocs/apps.dart
  9. 272
      packages/neon/neon/lib/src/blocs/sync.dart
  10. 5
      packages/neon/neon/lib/src/models/app_implementation.dart
  11. 156
      packages/neon/neon/lib/src/pages/app_implementation_settings.dart
  12. 74
      packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart
  13. 4
      packages/neon/neon/lib/src/pages/settings.dart
  14. 72
      packages/neon/neon/lib/src/pages/sync_mapping_settings.dart
  15. 6
      packages/neon/neon/lib/src/router.dart
  16. 3
      packages/neon/neon/lib/src/settings/models/storage.dart
  17. 3
      packages/neon/neon/lib/src/settings/widgets/custom_settings_tile.dart
  18. 18
      packages/neon/neon/lib/src/sync/models/conflicts.dart
  19. 43
      packages/neon/neon/lib/src/sync/models/implementation.dart
  20. 23
      packages/neon/neon/lib/src/sync/models/mapping.dart
  21. 115
      packages/neon/neon/lib/src/sync/widgets/resolve_sync_conflicts_dialog.dart
  22. 49
      packages/neon/neon/lib/src/sync/widgets/sync_conflict_card.dart
  23. 52
      packages/neon/neon/lib/src/utils/file_utils.dart
  24. 35
      packages/neon/neon/lib/src/utils/global_popups.dart
  25. 32
      packages/neon/neon/lib/src/utils/save_file.dart
  26. 30
      packages/neon/neon/lib/src/utils/sync_mapping_options.dart
  27. 14
      packages/neon/neon/lib/src/widgets/adaptive_widgets/list_tile.dart
  28. 44
      packages/neon/neon/lib/src/widgets/sync_status_icon.dart
  29. 4
      packages/neon/neon/lib/sync.dart
  30. 1
      packages/neon/neon/lib/utils.dart
  31. 4
      packages/neon/neon/pubspec.yaml
  32. 4
      packages/neon/neon/pubspec_overrides.yaml
  33. 4
      packages/neon/neon_dashboard/pubspec_overrides.yaml
  34. 53
      packages/neon/neon_files/lib/blocs/files.dart
  35. 9
      packages/neon/neon_files/lib/neon_files.dart
  36. 105
      packages/neon/neon_files/lib/sync/implementation.dart
  37. 63
      packages/neon/neon_files/lib/sync/mapping.dart
  38. 23
      packages/neon/neon_files/lib/sync/mapping.g.dart
  39. 142
      packages/neon/neon_files/lib/sync/sources.dart
  40. 27
      packages/neon/neon_files/lib/widgets/actions.dart
  41. 2
      packages/neon/neon_files/lib/widgets/file_list_tile.dart
  42. 94
      packages/neon/neon_files/lib/widgets/file_tile.dart
  43. 4
      packages/neon/neon_files/pubspec.yaml
  44. 4
      packages/neon/neon_files/pubspec_overrides.yaml
  45. 4
      packages/neon/neon_news/pubspec_overrides.yaml
  46. 4
      packages/neon/neon_notes/pubspec_overrides.yaml
  47. 4
      packages/neon/neon_notifications/pubspec_overrides.yaml
  48. 1
      packages/synchronize/LICENSE
  49. 3
      packages/synchronize/README.md
  50. 1
      packages/synchronize/analysis_options.yaml
  51. 60
      packages/synchronize/lib/src/action.dart
  52. 61
      packages/synchronize/lib/src/conflict.dart
  53. 33
      packages/synchronize/lib/src/journal.dart
  54. 15
      packages/synchronize/lib/src/journal.g.dart
  55. 52
      packages/synchronize/lib/src/journal_entry.dart
  56. 19
      packages/synchronize/lib/src/journal_entry.g.dart
  57. 12
      packages/synchronize/lib/src/object.dart
  58. 39
      packages/synchronize/lib/src/sources.dart
  59. 246
      packages/synchronize/lib/src/sync.dart
  60. 6
      packages/synchronize/lib/synchronize.dart
  61. 20
      packages/synchronize/pubspec.yaml
  62. 4
      packages/synchronize/pubspec_overrides.yaml
  63. 542
      packages/synchronize/test/sync_test.dart

1
commitlint.yaml

@ -26,3 +26,4 @@ rules:
- neon_lints
- nextcloud
- sort_box
- synchronize

15
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:
@ -1390,6 +1397,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
web:
dependency: transitive
description:

4
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

28
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"
}
}
}
}

96
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<NeonLocalizations> {

50
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';
}
}

6
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<void> runNeon({
globalOptions,
disabled: nextPushDisabled,
);
final syncBloc = SyncBloc(
accountsBloc,
appImplementations,
);
runApp(
MultiProvider(
@ -74,6 +79,7 @@ Future<void> runNeon({
NeonProvider<AccountsBloc>.value(value: accountsBloc),
NeonProvider<FirstLaunchBloc>.value(value: firstLaunchBloc),
NeonProvider<NextPushBloc>.value(value: nextPushBloc),
NeonProvider<SyncBloc>.value(value: syncBloc),
Provider<Iterable<AppImplementation>>(
create: (final _) => appImplementations,
dispose: (final _, final appImplementations) => appImplementations.disposeAll(),

8
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<T extends Bloc>(final AppImplementation<T, dynamic> 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<T extends Bloc>(final String appId) =>
_allAppImplementations.tryFind(appId)?.getBloc(_account) as T?;
/// Returns the active [Bloc] for every registered [AppImplementation] wrapped in a Provider.
List<Provider<Bloc>> get appBlocProviders =>
_allAppImplementations.map((final appImplementation) => appImplementation.blocProvider).toList();

272
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<dynamic, dynamic> mapping);
/// Removes an existing [mapping] that will no longer be synced.
void removeMapping(final SyncMapping<dynamic, dynamic> mapping);
/// Explicitly trigger a sync for the [mapping].
/// [solutions] can be use to apply solutions for conflicts.
void syncMapping(
final SyncMapping<dynamic, dynamic> mapping, {
final Map<String, SyncConflictSolution> solutions = const {},
});
}
abstract interface class SyncBlocStates {
/// Map of [SyncMapping]s and their [SyncMappingStatus]es
BehaviorSubject<Map<SyncMapping<dynamic, dynamic>, SyncMappingStatus>> get mappingStatuses;
/// Stream of conflicts that have arisen during syncing.
Stream<SyncConflicts<dynamic, dynamic>> get conflicts;
}
class SyncBloc extends InteractiveBloc implements SyncBlocEvents, SyncBlocStates {
SyncBloc(
this._accountsBloc,
final Iterable<AppImplementation> 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<SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic>> _syncImplementations;
late final NeonTimer _timer;
final _conflictsController = StreamController<SyncConflicts<dynamic, dynamic>>();
final _watchControllers = <String, StreamController<void>>{};
final _syncMappingOptions = <String, 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<SyncConflicts<dynamic, dynamic>> conflicts = _conflictsController.stream.asBroadcastStream();
@override
final BehaviorSubject<Map<SyncMapping<dynamic, dynamic>, SyncMappingStatus>> mappingStatuses = BehaviorSubject();
@override
Future<void> refresh() async {
for (final mapping in mappingStatuses.value.keys) {
await _updateMapping(mapping);
}
}
@override
Future<void> addMapping(final SyncMapping<dynamic, dynamic> 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<void> removeMapping(final SyncMapping<dynamic, dynamic> 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<void> syncMapping(
final SyncMapping<dynamic, dynamic> mapping, {
final Map<String, SyncConflictSolution> 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<dynamic, dynamic>(
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<void> _updateMapping(final SyncMapping<dynamic, dynamic> 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<SyncMappingStatus> _getMappingStatus(
final Account account,
final SyncMapping<dynamic, dynamic> 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 = <SyncMapping<dynamic, dynamic>>[];
if (_storage.hasValue()) {
final serializedMappings = (json.decode(_storage.getString()!) as Map<String, dynamic>)
.map((final key, final value) => MapEntry(key, (value as List).map((final e) => e as Map<String, dynamic>)));
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<void> _saveMappings() async {
debugPrint('Saving mappings');
final serializedMappings = <String, List<Map<String, dynamic>>>{};
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<dynamic, dynamic> mapping) {
final syncImplementation = _syncImplementations.find(mapping.appId);
if (_watchControllers.containsKey(syncImplementation.getMappingId(mapping))) {
return;
}
// ignore: close_sinks
final controller = StreamController<void>();
// 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<dynamic, dynamic> mapping) {
final syncImplementation = _syncImplementations.find(mapping.appId);
final id = syncImplementation.getGlobalUniqueMappingId(mapping);
return _syncMappingOptions[id] ??= SyncMappingOptions(
AppStorage(StorageKeys.sync, id),
);
}
}

5
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<T extends Bloc, R extends NextcloudAppOptions>
@protected
T buildBloc(final Account account);
/// Optional [SyncImplementation] for this [AppImplementation].
SyncImplementation<SyncMapping<dynamic, dynamic>, 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

156
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<AccountsBloc>(context, listen: false);
final syncBloc = Provider.of<SyncBloc>(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<void>(
builder: (final context) => SyncMappingSettingsPage(
mapping: mappingStatus.key,
),
),
);
},
),
],
CustomSettingsTile(
title: ElevatedButton.icon(
onPressed: () async {
final account = await showDialog<Account>(
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,
),
),
),
);
}
}

74
packages/neon/neon/lib/src/pages/nextcloud_app_settings.dart

@ -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,
),
),
),
);
}
}

4
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<SettingsPage> {
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());

72
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<dynamic, dynamic> mapping;
@override
Widget build(final BuildContext context) {
final syncBloc = Provider.of<SyncBloc>(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,
),
],
),
],
),
);
}
}

6
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<Iterable<AppImplementation>>(context);
final appImplementation = appImplementations.tryFind(appid)!;
return NextcloudAppSettingsPage(appImplementation: appImplementation);
return AppImplementationSettingsPage(appImplementation: appImplementation);
}
}

3
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'),

3
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<void> Function()? onTap;
final FutureOr<void> 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,
);
}

18
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<T1, T2> {
SyncConflicts(
this.account,
this.implementation,
this.mapping,
this.conflicts,
);
final Account account;
final SyncImplementation<SyncMapping<T1, T2>, T1, T2> implementation;
final SyncMapping<T1, T2> mapping;
final List<SyncConflict<T1, T2>> conflicts;
}

43
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<S extends SyncMapping<T1, T2>, T1, T2> {
String get appId;
FutureOr<SyncSources<T1, T2>> getSources(final Account account, final S mapping);
Map<String, dynamic> serializeMapping(final S mapping);
S deserializeMapping(final Map<String, dynamic> json);
FutureOr<S?> 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<SyncMapping<dynamic, dynamic>, dynamic, dynamic> {
String getGlobalUniqueMappingId(final SyncMapping<dynamic, dynamic> mapping) =>
'${mapping.accountId}-${mapping.appId}-${getMappingId(mapping)}';
}
extension SyncImplementationFind on Iterable<SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic>> {
SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic>? tryFind(final String appId) =>
singleWhereOrNull((final syncImplementation) => appId == syncImplementation.appId);
SyncImplementation<SyncMapping<dynamic, dynamic>, dynamic, dynamic> find(final String appId) => tryFind(appId)!;
}

23
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<T1, T2> {
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,
}

115
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<T1, T2> extends StatefulWidget {
const NeonResolveSyncConflictsDialog({
required this.conflicts,
super.key,
});
final SyncConflicts<T1, T2> conflicts;
@override
State<NeonResolveSyncConflictsDialog<T1, T2>> createState() => _NeonResolveSyncConflictsDialogState<T1, T2>();
}
class _NeonResolveSyncConflictsDialogState<T1, T2> extends State<NeonResolveSyncConflictsDialog<T1, T2>> {
var _index = 0;
final _solutions = <String, SyncConflictSolution>{};
SyncConflict<T1, T2> 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,
),
),
);
}
}

49
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,
],
),
),
);
}

52
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<String?> 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<FilePickerResult?> 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<String?> pickDirectory() async => FilePicker.platform.getDirectoryPath();
}

35
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<GlobalOptions>(context);
final firstLaunchBloc = NeonProvider.of<FirstLaunchBloc>(context);
final nextPushBloc = NeonProvider.of<NextPushBloc>(context);
final syncBloc = NeonProvider.of<SyncBloc>(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<AccountsBloc>(context).getAppsBlocFor(conflicts.account).appBlocProviders;
final result = await showDialog<Map<String, SyncConflictSolution>>(
context: _context,
builder: (final context) => MultiProvider(
providers: providers,
child: NeonResolveSyncConflictsDialog(conflicts: conflicts),
),
);
if (result == null) {
return;
}
await syncBloc.syncMapping(
conflicts.mapping,
solutions: result,
);
}),
]);
}
}

32
packages/neon/neon/lib/src/utils/save_file.dart

@ -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<String?> 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;
}
}

30
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<Option<dynamic>> 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;
}

14
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<void> 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<void> 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<void> Function()? onLongPress;
/// {@template neon.AdaptiveListTile.enabled}
/// Whether this list tile is interactive.
///

44
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,
);
}
}

4
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';

1
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';

4
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

4
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

4
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

53
packages/neon/neon_files/lib/blocs/files.dart

@ -3,8 +3,6 @@ part of '../neon_files.dart';
abstract interface class FilesBlocEvents {
void uploadFile(final PathUri uri, final String localPath);
void syncFile(final PathUri uri);
void openFile(final PathUri uri, final String etag, final String? mimeType);
void shareFileNative(final PathUri uri, final String etag);
@ -57,6 +55,21 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta
@override
BehaviorSubject<List<FilesTask>> tasks = BehaviorSubject<List<FilesTask>>.seeded([]);
@override
Future<void> refresh() async {
await browser.refresh();
}
@override
void removeFavorite(final PathUri uri) {
wrapAction(
() async => account.client.webdav.proppatch(
uri,
set: WebDavProp(ocfavorite: 0),
),
);
}
@override
void addFavorite(final PathUri uri) {
wrapAction(
@ -109,21 +122,6 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta
);
}
@override
Future<void> refresh() async {
await browser.refresh();
}
@override
void removeFavorite(final PathUri uri) {
wrapAction(
() async => account.client.webdav.proppatch(
uri,
set: WebDavProp(ocfavorite: 0),
),
);
}
@override
void rename(final PathUri uri, final String name) {
wrapAction(
@ -134,27 +132,6 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta
);
}
@override
void syncFile(final PathUri uri) {
wrapAction(
() async {
final file = File(
p.joinAll([
await NeonPlatform.instance.userAccessibleAppDataPath,
account.humanReadableID,
'files',
...uri.pathSegments,
]),
);
if (!file.parent.existsSync()) {
file.parent.createSync(recursive: true);
}
await _downloadFile(uri, file);
},
disableTimeout: true,
);
}
@override
void uploadFile(final PathUri uri, final String localPath) {
wrapAction(

9
packages/neon/neon_files/lib/neon_files.dart

@ -39,17 +39,20 @@ import 'package:neon/models.dart';
import 'package:neon/platform.dart';
import 'package:neon/settings.dart';
import 'package:neon/sort_box.dart';
import 'package:neon/sync.dart';
import 'package:neon/theme.dart';
import 'package:neon/utils.dart';
import 'package:neon/widgets.dart';
import 'package:neon_files/l10n/localizations.dart';
import 'package:neon_files/routes.dart';
import 'package:neon_files/sync/mapping.dart';
import 'package:neon_files/widgets/file_list_tile.dart';
import 'package:nextcloud/core.dart' as core;
import 'package:nextcloud/nextcloud.dart';
import 'package:open_file/open_file.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:queue/queue.dart';
import 'package:rxdart/rxdart.dart';
import 'package:share_plus/share_plus.dart';
@ -65,9 +68,12 @@ part 'options.dart';
part 'pages/details.dart';
part 'pages/main.dart';
part 'sort/files.dart';
part 'sync/implementation.dart';
part 'sync/sources.dart';
part 'utils/task.dart';
part 'widgets/browser_view.dart';
part 'widgets/file_preview.dart';
part 'widgets/file_tile.dart';
part 'widgets/navigator.dart';
class FilesApp extends AppImplementation<FilesBloc, FilesAppSpecificOptions> {
@ -94,6 +100,9 @@ class FilesApp extends AppImplementation<FilesBloc, FilesAppSpecificOptions> {
@override
final Widget page = const FilesMainPage();
@override
final FilesSync syncImplementation = const FilesSync();
@override
final RouteBase route = $filesAppRoute;
}

105
packages/neon/neon_files/lib/sync/implementation.dart

@ -0,0 +1,105 @@
part of '../neon_files.dart';
@immutable
class FilesSync implements SyncImplementation<FilesSyncMapping, WebDavFile, FileSystemEntity> {
const FilesSync();
@override
String get appId => AppIDs.files;
@override
Future<FilesSyncSources> getSources(final Account account, final FilesSyncMapping mapping) async {
// 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) {
throw const MissingPermissionException(Permission.manageExternalStorage);
}
return FilesSyncSources(
account.client,
mapping.remotePath,
mapping.localPath,
);
}
@override
Map<String, dynamic> serializeMapping(final FilesSyncMapping mapping) => mapping.toJson();
@override
FilesSyncMapping deserializeMapping(final Map<String, dynamic> json) => FilesSyncMapping.fromJson(json);
@override
Future<FilesSyncMapping?> addMapping(final BuildContext context, final Account account) async {
final accountsBloc = NeonProvider.of<AccountsBloc>(context);
final appsBloc = accountsBloc.getAppsBlocFor(account);
final filesBloc = appsBloc.getAppBlocByID(AppIDs.files)! as FilesBloc;
final filesBrowserBloc = filesBloc.getNewFilesBrowserBloc();
final remotePath = await showDialog<PathUri>(
context: context,
builder: (final context) => FilesChooseFolderDialog(
bloc: filesBrowserBloc,
filesBloc: filesBloc,
originalPath: PathUri.cwd(),
),
);
filesBrowserBloc.dispose();
if (remotePath == null) {
return null;
}
final localPath = await FileUtils.pickDirectory();
if (localPath == null) {
return null;
}
if (!context.mounted) {
return null;
}
return FilesSyncMapping(
appId: AppIDs.files,
accountId: account.id,
remotePath: remotePath,
localPath: Directory(localPath),
journal: SyncJournal(),
);
}
@override
String getMappingDisplayTitle(final FilesSyncMapping mapping) => mapping.remotePath.toString();
@override
String getMappingDisplaySubtitle(final FilesSyncMapping mapping) => mapping.localPath.path;
@override
String getMappingId(final FilesSyncMapping mapping) =>
'${Uri.encodeComponent(mapping.remotePath.toString())}-${Uri.encodeComponent(mapping.localPath.path)}';
@override
Widget getConflictDetailsLocal(final BuildContext context, final FileSystemEntity object) {
final stat = object.statSync();
return FilesFileTile(
showFullPath: true,
filesBloc: NeonProvider.of<FilesBloc>(context),
details: FileDetails(
uri: PathUri.parse(object.path),
size: stat.size,
etag: '',
mimeType: '',
lastModified: stat.modified,
hasPreview: false,
isFavorite: false,
),
);
}
@override
Widget getConflictDetailsRemote(final BuildContext context, final WebDavFile object) => FilesFileTile(
showFullPath: true,
filesBloc: NeonProvider.of<FilesBloc>(context),
details: FileDetails.fromWebDav(
file: object,
),
);
}

63
packages/neon/neon_files/lib/sync/mapping.dart

@ -0,0 +1,63 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:neon/sync.dart';
import 'package:nextcloud/webdav.dart' as webdav;
import 'package:nextcloud/webdav.dart';
import 'package:universal_io/io.dart';
import 'package:watcher/watcher.dart';
part 'mapping.g.dart';
@JsonSerializable()
class FilesSyncMapping implements SyncMapping<webdav.WebDavFile, FileSystemEntity> {
FilesSyncMapping({
required this.accountId,
required this.appId,
required this.journal,
required this.remotePath,
required this.localPath,
});
factory FilesSyncMapping.fromJson(final Map<String, dynamic> json) => _$FilesSyncMappingFromJson(json);
Map<String, dynamic> toJson() => _$FilesSyncMappingToJson(this);
@override
final String accountId;
@override
final String appId;
@override
final SyncJournal journal;
final PathUri remotePath;
@JsonKey(
fromJson: _directoryFromJson,
toJson: _directoryToJson,
)
final Directory localPath;
static Directory _directoryFromJson(final String value) => Directory(value);
static String _directoryToJson(final Directory value) => value.path;
StreamSubscription<WatchEvent>? _subscription;
@override
void watch(final void Function() onUpdated) {
debugPrint('Watching file changes: $localPath');
_subscription ??= DirectoryWatcher(localPath.path).events.listen(
(final event) {
debugPrint('Registered file change: ${event.path} ${event.type}');
onUpdated();
},
);
}
@override
void dispose() {
unawaited(_subscription?.cancel());
}
}

23
packages/neon/neon_files/lib/sync/mapping.g.dart

@ -0,0 +1,23 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'mapping.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
FilesSyncMapping _$FilesSyncMappingFromJson(Map<String, dynamic> json) => FilesSyncMapping(
accountId: json['accountId'] as String,
appId: json['appId'] as String,
journal: SyncJournal.fromJson(json['journal'] as Map<String, dynamic>),
remotePath: Uri.parse(json['remotePath'] as String),
localPath: FilesSyncMapping._directoryFromJson(json['localPath'] as String),
);
Map<String, dynamic> _$FilesSyncMappingToJson(FilesSyncMapping instance) => <String, dynamic>{
'accountId': instance.accountId,
'appId': instance.appId,
'journal': instance.journal,
'remotePath': instance.remotePath.toString(),
'localPath': FilesSyncMapping._directoryToJson(instance.localPath),
};

142
packages/neon/neon_files/lib/sync/sources.dart

@ -0,0 +1,142 @@
part of '../neon_files.dart';
class FilesSyncSources implements SyncSources<WebDavFile, FileSystemEntity> {
FilesSyncSources(
final NextcloudClient client,
final PathUri webdavBaseDir,
final Directory ioBaseDir,
) : sourceA = FilesSyncSourceWebDavFile(client, webdavBaseDir),
sourceB = FilesSyncSourceFileSystemEntity(client, ioBaseDir);
@override
final SyncSource<WebDavFile, FileSystemEntity> sourceA;
@override
final SyncSource<FileSystemEntity, WebDavFile> sourceB;
@override
SyncConflictSolution? findSolution(final SyncObject<WebDavFile> objectA, final SyncObject<FileSystemEntity> objectB) {
if (objectA.data.isDirectory && objectB.data is Directory) {
return SyncConflictSolution.overwriteA;
}
return null;
}
}
class FilesSyncSourceWebDavFile implements SyncSource<WebDavFile, FileSystemEntity> {
FilesSyncSourceWebDavFile(
this.client,
this.baseDir,
);
/// [NextcloudClient] used by the WebDAV part.
final NextcloudClient client;
/// Base directory on the WebDAV server.
final PathUri baseDir;
final props = WebDavPropWithoutValues.fromBools(
davgetetag: true,
davgetlastmodified: true,
nchaspreview: true,
ocsize: true,
ocfavorite: true,
);
PathUri _uri(final SyncObject<dynamic> object) => baseDir.join(PathUri.parse(object.id));
@override
Future<List<SyncObject<WebDavFile>>> listObjects() async => (await client.webdav.propfind(
baseDir,
prop: props,
depth: WebDavDepth.infinity,
))
.toWebDavFiles()
.sublist(1)
.map(
(final file) => (
id: file.path.pathSegments.sublist(baseDir.pathSegments.length).join('/'),
data: file,
),
)
.toList();
@override
Future<String> getObjectETag(final SyncObject<WebDavFile> object) async =>
object.data.isDirectory ? '' : object.data.etag!;
@override
Future<SyncObject<WebDavFile>> writeObject(final SyncObject<FileSystemEntity> object) async {
if (object.data is File) {
final stat = await object.data.stat();
await client.webdav.putFile(
object.data as File,
stat,
_uri(object),
lastModified: stat.modified,
);
} else if (object.data is Directory) {
await client.webdav.mkcol(_uri(object));
} else {
throw Exception('Unable to sync FileSystemEntity of type ${object.data.runtimeType}');
}
return (
id: object.id,
data: (await client.webdav.propfind(
_uri(object),
prop: props,
depth: WebDavDepth.zero,
))
.toWebDavFiles()
.single,
);
}
@override
Future<void> deleteObject(final SyncObject<WebDavFile> object) async => client.webdav.delete(_uri(object));
}
class FilesSyncSourceFileSystemEntity implements SyncSource<FileSystemEntity, WebDavFile> {
FilesSyncSourceFileSystemEntity(
this.client,
this.baseDir,
);
/// [NextcloudClient] used by the WebDAV part.
final NextcloudClient client;
/// Base directory on the local filesystem.
final Directory baseDir;
@override
Future<List<SyncObject<FileSystemEntity>>> listObjects() async => baseDir.listSync(recursive: true).map(
(final e) {
var path = p.relative(e.path, from: baseDir.path);
if (path.endsWith('/')) {
path = path.substring(0, path.length - 1);
}
return (id: path, data: e);
},
).toList();
@override
Future<String> getObjectETag(final SyncObject<FileSystemEntity> object) async =>
object.data is Directory ? '' : object.data.statSync().modified.millisecondsSinceEpoch.toString();
@override
Future<SyncObject<FileSystemEntity>> writeObject(final SyncObject<WebDavFile> object) async {
if (object.data.isDirectory) {
final dir = Directory(p.join(baseDir.path, object.id))..createSync();
return (id: object.id, data: dir);
} else {
final file = File(p.join(baseDir.path, object.id));
await client.webdav.getFile(object.data.path, file);
await file.setLastModified(object.data.lastModified!);
return (id: object.id, data: file);
}
}
@override
Future<void> deleteObject(final SyncObject<FileSystemEntity> object) async => object.data.delete();
}

27
packages/neon/neon_files/lib/widgets/actions.dart

@ -1,4 +1,3 @@
import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart';
import 'package:neon/platform.dart';
import 'package:neon/utils.dart';
@ -16,7 +15,6 @@ class FileActions extends StatelessWidget {
Future<void> onSelected(final BuildContext context, final FilesFileAction action) async {
final bloc = NeonProvider.of<FilesBloc>(context);
final browserBloc = bloc.browser;
switch (action) {
case FilesFileAction.share:
bloc.shareFileNative(details.uri, details.etag!);
@ -85,23 +83,6 @@ class FileActions extends StatelessWidget {
if (result != null) {
bloc.copy(details.uri, result.join(PathUri.parse(details.name)));
}
case FilesFileAction.sync:
if (!context.mounted) {
return;
}
final sizeWarning = browserBloc.options.downloadSizeWarning.value;
if (sizeWarning != null && details.size != null && details.size! > sizeWarning) {
if (!(await showConfirmationDialog(
context,
FilesLocalizations.of(context).downloadConfirmSizeWarning(
filesize(sizeWarning),
filesize(details.size),
),
))) {
return;
}
}
bloc.syncFile(details.uri);
case FilesFileAction.delete:
if (!context.mounted) {
return;
@ -152,13 +133,6 @@ class FileActions extends StatelessWidget {
value: FilesFileAction.copy,
child: Text(FilesLocalizations.of(context).actionCopy),
),
// TODO: https://github.com/provokateurin/nextcloud-neon/issues/4
if (!details.isDirectory) ...[
PopupMenuItem(
value: FilesFileAction.sync,
child: Text(FilesLocalizations.of(context).actionSync),
),
],
PopupMenuItem(
value: FilesFileAction.delete,
child: Text(FilesLocalizations.of(context).actionDelete),
@ -175,6 +149,5 @@ enum FilesFileAction {
rename,
move,
copy,
sync,
delete,
}

2
packages/neon/neon_files/lib/widgets/file_list_tile.dart

@ -128,7 +128,7 @@ class _FileIcon extends StatelessWidget {
child: Icon(
Icons.star,
size: smallIconSize,
color: Colors.yellow,
color: NcColors.starredColor,
),
),
],

94
packages/neon/neon_files/lib/widgets/file_tile.dart

@ -0,0 +1,94 @@
part of '../neon_files.dart';
class FilesFileTile extends StatelessWidget {
const FilesFileTile({
required this.filesBloc,
required this.details,
this.trailing,
this.onTap,
this.uploadProgress,
this.downloadProgress,
this.showFullPath = false,
super.key,
});
final FilesBloc filesBloc;
final FileDetails details;
final Widget? trailing;
final GestureTapCallback? onTap;
final int? uploadProgress;
final int? downloadProgress;
final bool showFullPath;
@override
Widget build(final BuildContext context) {
Widget icon = Center(
child: uploadProgress != null || downloadProgress != null
? Column(
children: [
Icon(
uploadProgress != null ? MdiIcons.upload : MdiIcons.download,
color: Theme.of(context).colorScheme.primary,
),
LinearProgressIndicator(
value: (uploadProgress ?? downloadProgress)! / 100,
),
],
)
: FilePreview(
bloc: filesBloc,
details: details,
withBackground: true,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
);
if (details.isFavorite ?? false) {
icon = Stack(
children: [
icon,
const Align(
alignment: Alignment.bottomRight,
child: Icon(
Icons.star,
size: 14,
color: NcColors.starredColor,
),
),
],
);
}
return ListTile(
onTap: onTap,
title: Text(
showFullPath ? details.uri.path : details.name,
overflow: TextOverflow.ellipsis,
),
subtitle: Row(
children: [
if (details.lastModified != null) ...[
RelativeTime(
date: details.lastModified!,
),
],
if (details.size != null && details.size! > 0) ...[
const SizedBox(
width: 10,
),
Text(
filesize(details.size, 1),
style: DefaultTextStyle.of(context).style.copyWith(
color: Colors.grey,
),
),
],
],
),
leading: SizedBox.square(
dimension: 40,
child: icon,
),
trailing: trailing,
);
}
}

4
packages/neon/neon_files/pubspec.yaml

@ -22,6 +22,7 @@ dependencies:
go_router: ^12.0.0
image_picker: ^1.0.0
intl: ^0.18.0
json_annotation: ^4.8.1
neon:
git:
url: https://github.com/nextcloud/neon
@ -33,14 +34,17 @@ dependencies:
open_file: ^3.0.0
path: ^1.0.0
path_provider: ^2.0.0
permission_handler: ^11.0.0
queue: ^3.0.0
rxdart: ^0.27.0
share_plus: ^7.0.0
universal_io: ^2.0.0
watcher: ^1.1.0
dev_dependencies:
build_runner: ^2.4.6
go_router_builder: ^2.3.4
json_serializable: ^6.7.1
neon_lints:
git:
url: https://github.com/nextcloud/neon

4
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

4
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

4
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

4
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

1
packages/synchronize/LICENSE

@ -0,0 +1 @@
../../LICENSE

3
packages/synchronize/README.md

@ -0,0 +1,3 @@
# synchronize
A simple generic implementation of https://unterwaditzer.net/2016/sync-algorithm.html

1
packages/synchronize/analysis_options.yaml

@ -0,0 +1 @@
include: package:neon_lints/dart.yaml

60
packages/synchronize/lib/src/action.dart

@ -0,0 +1,60 @@
import 'package:meta/meta.dart';
import 'package:synchronize/src/object.dart';
/// Action to be executed in the sync process.
@internal
@immutable
sealed class SyncAction<T> {
/// Creates a new action.
const SyncAction(this.object);
/// The object that is part of the action.
final SyncObject<T> object;
@override
String toString() => 'SyncAction<$T>(object: $object)';
}
/// Action to delete on object from A.
@internal
@immutable
interface class SyncActionDeleteFromA<T1, T2> extends SyncAction<T1> {
/// Creates a new action to delete an object from A.
const SyncActionDeleteFromA(super.object);
@override
String toString() => 'SyncActionDeleteFromA<$T1, $T2>(object: $object)';
}
/// Action to delete an object from B.
@internal
@immutable
interface class SyncActionDeleteFromB<T1, T2> extends SyncAction<T2> {
/// Creates a new action to delete an object from B.
const SyncActionDeleteFromB(super.object);
@override
String toString() => 'SyncActionDeleteFromB<$T1, $T2>(object: $object)';
}
/// Action to write an object to A.
@internal
@immutable
interface class SyncActionWriteToA<T1, T2> extends SyncAction<T2> {
/// Creates a new action to write an object to A.
const SyncActionWriteToA(super.object);
@override
String toString() => 'SyncActionWriteToA<$T1, $T2>(object: $object)';
}
/// Action to write an object to B.
@internal
@immutable
interface class SyncActionWriteToB<T1, T2> extends SyncAction<T1> {
/// Creates a new action to write an object to B.
const SyncActionWriteToB(super.object);
@override
String toString() => 'SyncActionWriteToB<$T1, $T2>(object: $object)';
}

61
packages/synchronize/lib/src/conflict.dart

@ -0,0 +1,61 @@
import 'package:meta/meta.dart';
import 'package:synchronize/src/object.dart';
/// Contains information about a conflict that appeared during sync.
@immutable
class SyncConflict<T1, T2> {
/// Creates a new conflict.
const SyncConflict({
required this.id,
required this.type,
required this.objectA,
required this.objectB,
this.skipped = false,
});
/// Id of the objects involved in the conflict.
final String id;
/// Type of the conflict that appeared. See [SyncConflictType] for more info.
final SyncConflictType type;
/// Object A involved in the conflict.
final SyncObject<T1> objectA;
/// Object B involved in the conflict.
final SyncObject<T2> objectB;
/// Whether the conflict was skipped by the user, useful for ignoring it later on.
final bool skipped;
@override
bool operator ==(final dynamic other) => other is SyncConflict && other.id == id;
@override
int get hashCode => id.hashCode;
@override
String toString() =>
'SyncConflict<$T1, $T2>(id: $id, type: $type, objectA: $objectA, objectB: $objectB, skipped: $skipped)';
}
/// Types of conflicts that can appear during sync.
enum SyncConflictType {
/// New objects with the same id exist on both sides.
bothNew,
/// Both objects with the same id have changed.
bothChanged,
}
/// Ways to resolve [SyncConflict]s.
enum SyncConflictSolution {
/// Overwrite the content of object A with the content of object B.
overwriteA,
/// Overwrite the content of object B with the content of object A.
overwriteB,
/// Skip the conflict and just do nothing.
skip,
}

33
packages/synchronize/lib/src/journal.dart

@ -0,0 +1,33 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:synchronize/src/journal_entry.dart';
part 'journal.g.dart';
/// Contains the journal.
///
/// Used for detecting changes and new or deleted files.
@JsonSerializable()
class SyncJournal {
/// Creates a new journal.
// Note: This must not be const as otherwise the entries are not modifiable when a const set is used!
SyncJournal([final Set<SyncJournalEntry>? entries]) : entries = entries ?? {};
/// Deserializes a journal from [json].
factory SyncJournal.fromJson(final Map<String, dynamic> json) => _$SyncJournalFromJson(json);
/// Serializes a journal to JSON.
Map<String, dynamic> toJson() => _$SyncJournalToJson(this);
/// All entries contained in the journal.
final Set<SyncJournalEntry> entries;
/// Updates an [entry].
void updateEntry(final SyncJournalEntry entry) {
entries
..remove(entry)
..add(entry);
}
@override
String toString() => 'SyncJournal(entries: $entries)';
}

15
packages/synchronize/lib/src/journal.g.dart

@ -0,0 +1,15 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'journal.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SyncJournal _$SyncJournalFromJson(Map<String, dynamic> json) => SyncJournal(
(json['entries'] as List<dynamic>).map((e) => SyncJournalEntry.fromJson(e as Map<String, dynamic>)).toSet(),
);
Map<String, dynamic> _$SyncJournalToJson(SyncJournal instance) => <String, dynamic>{
'entries': instance.entries.toList(),
};

52
packages/synchronize/lib/src/journal_entry.dart

@ -0,0 +1,52 @@
import 'package:collection/collection.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:meta/meta.dart';
import 'package:synchronize/src/journal.dart';
part 'journal_entry.g.dart';
/// Stores a single entry in the [SyncJournal].
///
/// It contains an [id] and ETags for each object, [etagA] and [etagB] respectively.
@immutable
@JsonSerializable()
class SyncJournalEntry {
/// Creates a new journal entry.
const SyncJournalEntry(
this.id,
this.etagA,
this.etagB,
);
/// Deserializes a journal entry from [json].
factory SyncJournalEntry.fromJson(final Map<String, dynamic> json) => _$SyncJournalEntryFromJson(json);
/// Serializes a journal entry to JSON.
Map<String, dynamic> toJson() => _$SyncJournalEntryToJson(this);
/// Unique ID of the journal entry.
final String id;
/// ETag of the object A.
final String etagA;
/// ETag of the object B.
final String etagB;
@override
bool operator ==(final Object other) => other is SyncJournalEntry && other.id == id;
@override
int get hashCode => id.hashCode;
@override
String toString() => 'SyncJournalEntry(id: $id, etagA: $etagA, etagB: $etagB)';
}
/// Extension to find a [SyncJournalEntry].
extension SyncJournalEntriesFind on Iterable<SyncJournalEntry> {
/// Finds the first [SyncJournalEntry] that has the [SyncJournalEntry.id] set to [id].
///
/// Returns `null` if no matching [SyncJournalEntry] was found.
SyncJournalEntry? tryFind(final String id) => firstWhereOrNull((final entry) => entry.id == id);
}

19
packages/synchronize/lib/src/journal_entry.g.dart

@ -0,0 +1,19 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'journal_entry.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SyncJournalEntry _$SyncJournalEntryFromJson(Map<String, dynamic> json) => SyncJournalEntry(
json['id'] as String,
json['etagA'] as String,
json['etagB'] as String,
);
Map<String, dynamic> _$SyncJournalEntryToJson(SyncJournalEntry instance) => <String, dynamic>{
'id': instance.id,
'etagA': instance.etagA,
'etagB': instance.etagB,
};

12
packages/synchronize/lib/src/object.dart

@ -0,0 +1,12 @@
import 'package:collection/collection.dart';
/// Wraps the actual data contained on each side.
typedef SyncObject<T> = ({String id, T data});
/// Extension to find a [SyncObject].
extension SyncObjectsFind<T> on Iterable<SyncObject<T>> {
/// Finds the first [SyncObject] that has the `id` set to [id].
///
/// Returns `null` if no matching [SyncObject] was found.
SyncObject<T>? tryFind(final String id) => firstWhereOrNull((final object) => object.id == id);
}

39
packages/synchronize/lib/src/sources.dart

@ -0,0 +1,39 @@
import 'dart:async';
import 'package:meta/meta.dart';
import 'package:synchronize/src/conflict.dart';
import 'package:synchronize/src/object.dart';
/// The source the sync uses to sync from and to.
@immutable
abstract interface class SyncSource<T1, T2> {
/// List all the objects.
FutureOr<List<SyncObject<T1>>> listObjects();
/// Calculates the ETag of a given [object].
///
/// Must be something easy to compute like the mtime of a file and preferably not the hash of the whole content in order to be fast.
FutureOr<String> getObjectETag(final SyncObject<T1> object);
/// Writes the given [object].
FutureOr<SyncObject<T1>> writeObject(final SyncObject<T2> object);
/// Deletes the given [object].
FutureOr<void> deleteObject(final SyncObject<T1> object);
}
/// The sources the sync uses to sync from and to.
@immutable
abstract interface class SyncSources<T1, T2> {
/// Source A.
SyncSource<T1, T2> get sourceA;
/// Source B.
SyncSource<T2, T1> get sourceB;
/// Automatically find a solution for conflicts that don't matter. Useful e.g. for ignoring new directories.
SyncConflictSolution? findSolution(final SyncObject<T1> objectA, final SyncObject<T2> objectB);
@override
String toString() => 'SyncSources<$T1, $T2>(sourceA: $sourceA, sourceB: $sourceB)';
}

246
packages/synchronize/lib/src/sync.dart

@ -0,0 +1,246 @@
import 'package:synchronize/src/action.dart';
import 'package:synchronize/src/conflict.dart';
import 'package:synchronize/src/journal.dart';
import 'package:synchronize/src/journal_entry.dart';
import 'package:synchronize/src/object.dart';
import 'package:synchronize/src/sources.dart';
/// Sync between two [SyncSources]s.
///
/// This implementation follows https://unterwaditzer.net/2016/sync-algorithm.html in a generic and abstract way
/// and should work for any two kinds of sources and objects.
Future<List<SyncConflict<T1, T2>>> sync<T1, T2>(
final SyncSources<T1, T2> sources,
final SyncJournal journal, {
final Map<String, SyncConflictSolution>? conflictSolutions,
final bool keepSkipsAsConflicts = false,
}) async {
final diff = await computeSyncDiff<T1, T2>(
sources,
journal,
conflictSolutions: conflictSolutions,
keepSkipsAsConflicts: keepSkipsAsConflicts,
);
await executeSyncDiff<T1, T2>(
sources,
journal,
diff,
);
return diff.conflicts;
}
/// Differences between the two sources.
class SyncDiff<T1, T2> {
/// Creates a new diff.
SyncDiff(
this.actions,
this.conflicts,
);
/// Actions required to solve the difference.
final List<SyncAction<dynamic>> actions;
/// Conflicts without solutions that need to be solved.
final List<SyncConflict<T1, T2>> conflicts;
}
/// Executes the actions required to solve the difference.
Future<void> executeSyncDiff<T1, T2>(
final SyncSources<T1, T2> sources,
final SyncJournal journal,
final SyncDiff<T1, T2> diff,
) async {
for (final action in diff.actions) {
switch (action) {
case SyncActionDeleteFromA():
await sources.sourceA.deleteObject(action.object as SyncObject<T1>);
journal.entries.removeWhere((final entry) => entry.id == action.object.id);
case SyncActionDeleteFromB():
await sources.sourceB.deleteObject(action.object as SyncObject<T2>);
journal.entries.removeWhere((final entry) => entry.id == action.object.id);
case SyncActionWriteToA():
final objectA = await sources.sourceA.writeObject(action.object as SyncObject<T2>);
journal.updateEntry(
SyncJournalEntry(
action.object.id,
await sources.sourceA.getObjectETag(objectA),
await sources.sourceB.getObjectETag(action.object as SyncObject<T2>),
),
);
case SyncActionWriteToB():
final objectB = await sources.sourceB.writeObject(action.object as SyncObject<T1>);
journal.updateEntry(
SyncJournalEntry(
action.object.id,
await sources.sourceA.getObjectETag(action.object as SyncObject<T1>),
await sources.sourceB.getObjectETag(objectB),
),
);
}
}
}
/// Computes the difference, useful for displaying if a sync is up to date.
Future<SyncDiff<T1, T2>> computeSyncDiff<T1, T2>(
final SyncSources<T1, T2> sources,
final SyncJournal journal, {
final Map<String, SyncConflictSolution>? conflictSolutions,
final bool keepSkipsAsConflicts = false,
}) async {
final actions = <SyncAction<dynamic>>[];
final conflicts = <SyncConflict<T1, T2>>{};
var objectsA = await sources.sourceA.listObjects();
var objectsB = await sources.sourceB.listObjects();
for (final objectA in objectsA) {
final objectB = objectsB.tryFind(objectA.id);
final journalEntry = journal.entries.tryFind(objectA.id);
// If the ID exists on side A and the journal, but not on B, it has been deleted on B. Delete it from A and the journal.
if (journalEntry != null && objectB == null) {
actions.add(SyncActionDeleteFromA<T1, T2>(objectA));
continue;
}
// If the ID exists on side A and side B, but not in journal, we can not just create it in journal, since the two items might contain different content each.
if (objectB != null && journalEntry == null) {
conflicts.add(
SyncConflict<T1, T2>(
id: objectA.id,
type: SyncConflictType.bothNew,
objectA: objectA,
objectB: objectB,
),
);
continue;
}
// If the ID exists on side A, but not on B or the journal, it must have been created on A. Copy the item from A to B and also insert it into journal.
if (objectB == null || journalEntry == null) {
actions.add(SyncActionWriteToB<T1, T2>(objectA));
continue;
}
}
for (final objectB in objectsB) {
final objectA = objectsA.tryFind(objectB.id);
final journalEntry = journal.entries.tryFind(objectB.id);
// If the ID exists on side B and the journal, but not on A, it has been deleted on A. Delete it from B and the journal.
if (journalEntry != null && objectA == null) {
actions.add(SyncActionDeleteFromB<T1, T2>(objectB));
continue;
}
// If the ID exists on side B and side A, but not in journal, we can not just create it in journal, since the two items might contain different content each.
if (objectA != null && journalEntry == null) {
conflicts.add(
SyncConflict<T1, T2>(
id: objectA.id,
type: SyncConflictType.bothNew,
objectA: objectA,
objectB: objectB,
),
);
continue;
}
// If the ID exists on side B, but not on A or the journal, it must have been created on B. Copy the item from B to A and also insert it into journal.
if (objectA == null || journalEntry == null) {
actions.add(SyncActionWriteToA<T1, T2>(objectB));
continue;
}
}
objectsA = await sources.sourceA.listObjects();
objectsB = await sources.sourceB.listObjects();
final entries = journal.entries.toList();
for (final entry in entries) {
final objectA = objectsA.tryFind(entry.id);
final objectB = objectsB.tryFind(entry.id);
// Remove all entries from journal that don't exist anymore
if (objectA == null && objectB == null) {
journal.entries.removeWhere((final e) => e.id == entry.id);
continue;
}
if (objectA != null && objectB != null) {
final changedA = entry.etagA != await sources.sourceA.getObjectETag(objectA);
final changedB = entry.etagB != await sources.sourceB.getObjectETag(objectB);
if (changedA && changedB) {
conflicts.add(
SyncConflict<T1, T2>(
id: objectA.id,
type: SyncConflictType.bothChanged,
objectA: objectA,
objectB: objectB,
),
);
continue;
}
if (changedA && !changedB) {
actions.add(SyncActionWriteToB<T1, T2>(objectA));
continue;
}
if (changedB && !changedA) {
actions.add(SyncActionWriteToA<T1, T2>(objectB));
continue;
}
}
}
final unsolvedConflicts = <SyncConflict<T1, T2>>[];
for (final conflict in conflicts) {
final solution = conflictSolutions?[conflict.id] ?? sources.findSolution(conflict.objectA, conflict.objectB);
switch (solution) {
case SyncConflictSolution.overwriteA:
actions.add(SyncActionWriteToA<T1, T2>(conflict.objectB));
case SyncConflictSolution.overwriteB:
actions.add(SyncActionWriteToB<T1, T2>(conflict.objectA));
case SyncConflictSolution.skip:
if (keepSkipsAsConflicts) {
unsolvedConflicts.add(
SyncConflict<T1, T2>(
id: conflict.id,
type: conflict.type,
objectA: conflict.objectA,
objectB: conflict.objectB,
skipped: true,
),
);
}
case null:
unsolvedConflicts.add(conflict);
}
}
return SyncDiff<T1, T2>(
_sortActions(actions),
unsolvedConflicts,
);
}
List<SyncAction<dynamic>> _sortActions(final List<SyncAction<dynamic>> actions) {
final addActions = <SyncAction<dynamic>>[];
final removeActions = <SyncAction<dynamic>>[];
for (final action in actions) {
switch (action) {
case SyncActionWriteToA():
addActions.add(action);
case SyncActionWriteToB():
addActions.add(action);
case SyncActionDeleteFromA():
removeActions.add(action);
case SyncActionDeleteFromB():
removeActions.add(action);
}
}
return _innerSortActions(addActions)..addAll(_innerSortActions(removeActions).reversed);
}
List<SyncAction<dynamic>> _innerSortActions(final List<SyncAction<dynamic>> actions) =>
actions..sort((final a, final b) => a.object.id.compareTo(b.object.id));

6
packages/synchronize/lib/synchronize.dart

@ -0,0 +1,6 @@
export 'package:synchronize/src/conflict.dart';
export 'package:synchronize/src/journal.dart';
export 'package:synchronize/src/journal_entry.dart';
export 'package:synchronize/src/object.dart';
export 'package:synchronize/src/sources.dart';
export 'package:synchronize/src/sync.dart';

20
packages/synchronize/pubspec.yaml

@ -0,0 +1,20 @@
name: synchronize
version: 1.0.0
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
collection: ^1.0.0
json_annotation: ^4.8.1
meta: ^1.0.0
dev_dependencies:
build_runner: ^2.4.6
crypto: ^3.0.0
json_serializable: ^6.7.1
neon_lints:
git:
url: https://github.com/nextcloud/neon
path: packages/neon_lints
test: ^1.24.9

4
packages/synchronize/pubspec_overrides.yaml

@ -0,0 +1,4 @@
# melos_managed_dependency_overrides: neon_lints
dependency_overrides:
neon_lints:
path: ../neon_lints

542
packages/synchronize/test/sync_test.dart

@ -0,0 +1,542 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:synchronize/synchronize.dart';
import 'package:test/test.dart';
abstract class Wrap {
Wrap(this.content);
final String content;
}
class WrapA extends Wrap {
WrapA(super.content);
}
class WrapB extends Wrap {
WrapB(super.content);
}
class TestSyncState {
TestSyncState(
this.stateA,
this.stateB,
);
final Map<String, WrapA> stateA;
final Map<String, WrapB> stateB;
}
class TestSyncSourceA implements SyncSource<WrapA, WrapB> {
TestSyncSourceA(this.state);
final Map<String, WrapA> state;
@override
Future<List<SyncObject<WrapA>>> listObjects() async =>
state.keys.map((final key) => (id: key, data: state[key]!)).toList();
@override
Future<String> getObjectETag(final SyncObject<WrapA> object) async => etagA(object.data.content);
@override
Future<SyncObject<WrapA>> writeObject(final SyncObject<WrapB> object) async {
final wrap = WrapA(object.data.content);
state[object.id] = wrap;
return (id: object.id, data: wrap);
}
@override
Future<void> deleteObject(final SyncObject<WrapA> object) async => state.remove(object.id);
}
class TestSyncSourceB implements SyncSource<WrapB, WrapA> {
TestSyncSourceB(this.state);
final Map<String, WrapB> state;
@override
Future<List<SyncObject<WrapB>>> listObjects() async =>
state.keys.map((final key) => (id: key, data: state[key]!)).toList();
@override
Future<String> getObjectETag(final SyncObject<WrapB> object) async => etagB(object.data.content);
@override
Future<SyncObject<WrapB>> writeObject(final SyncObject<WrapA> object) async {
final wrap = WrapB(object.data.content);
state[object.id] = wrap;
return (id: object.id, data: wrap);
}
@override
Future<void> deleteObject(final SyncObject<WrapB> object) async => state.remove(object.id);
}
class TestSyncSources implements SyncSources<WrapA, WrapB> {
TestSyncSources(
this.sourceA,
this.sourceB,
);
factory TestSyncSources.fromState(final TestSyncState state) => TestSyncSources(
TestSyncSourceA(state.stateA),
TestSyncSourceB(state.stateB),
);
@override
final SyncSource<WrapA, WrapB> sourceA;
@override
final SyncSource<WrapB, WrapA> sourceB;
@override
SyncConflictSolution? findSolution(final SyncObject<WrapA> objectA, final SyncObject<WrapB> objectB) => null;
}
String etagA(final String content) => sha1.convert(utf8.encode('A$content')).toString();
String etagB(final String content) => sha1.convert(utf8.encode('B$content')).toString();
String randomEtag() => sha1.convert(utf8.encode(Random().nextDouble().toString())).toString();
Future<void> main() async {
group('sync', () {
group('stub', () {
test('all empty', () async {
final state = TestSyncState({}, {});
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal();
final conflicts = await sync(sources, journal);
expect(conflicts, isEmpty);
expect(state.stateA, isEmpty);
expect(state.stateB, isEmpty);
expect(journal.entries, isEmpty);
});
group('copy', () {
group('missing', () {
test('to A', () async {
const id = '123';
const content = '456';
final state = TestSyncState(
{},
{
id: WrapB(content),
},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal();
final conflicts = await sync(sources, journal);
expect(conflicts, isEmpty);
expect(state.stateA, hasLength(1));
expect(state.stateA[id]!.content, content);
expect(state.stateB, hasLength(1));
expect(state.stateB[id]!.content, content);
expect(journal.entries, hasLength(1));
expect(journal.entries.tryFind(id)!.etagA, etagA(content));
expect(journal.entries.tryFind(id)!.etagB, etagB(content));
});
test('to B', () async {
const id = '123';
const content = '456';
final state = TestSyncState(
{
id: WrapA(content),
},
{},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal();
final conflicts = await sync(sources, journal);
expect(conflicts, isEmpty);
expect(state.stateA, hasLength(1));
expect(state.stateA[id]!.content, content);
expect(state.stateB, hasLength(1));
expect(state.stateB[id]!.content, content);
expect(journal.entries, hasLength(1));
expect(journal.entries.tryFind(id)!.etagA, etagA(content));
expect(journal.entries.tryFind(id)!.etagB, etagB(content));
});
});
group('changed', () {
test('to A', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final state = TestSyncState(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal({
SyncJournalEntry(id, etagA(contentA), randomEtag()),
});
final conflicts = await sync(sources, journal);
expect(conflicts, isEmpty);
expect(state.stateA, hasLength(1));
expect(state.stateA[id]!.content, contentB);
expect(state.stateB, hasLength(1));
expect(state.stateB[id]!.content, contentB);
expect(journal.entries, hasLength(1));
expect(journal.entries.tryFind(id)!.etagA, etagA(contentB));
expect(journal.entries.tryFind(id)!.etagB, etagB(contentB));
});
test('to B', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final state = TestSyncState(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal({
SyncJournalEntry(id, randomEtag(), etagB(contentB)),
});
final conflicts = await sync(sources, journal);
expect(conflicts, isEmpty);
expect(state.stateA, hasLength(1));
expect(state.stateA[id]!.content, contentA);
expect(state.stateB, hasLength(1));
expect(state.stateB[id]!.content, contentA);
expect(journal.entries, hasLength(1));
expect(journal.entries.tryFind(id)!.etagA, etagA(contentA));
expect(journal.entries.tryFind(id)!.etagB, etagB(contentA));
});
});
});
group('delete', () {
test('from A', () async {
const id = '123';
const content = '456';
final state = TestSyncState(
{
id: WrapA(content),
},
{},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal({
SyncJournalEntry(id, etagA(content), etagB(content)),
});
final conflicts = await sync(sources, journal);
expect(conflicts, isEmpty);
expect(state.stateA, isEmpty);
expect(state.stateB, isEmpty);
expect(journal.entries, isEmpty);
});
test('from B', () async {
const id = '123';
const content = '456';
final state = TestSyncState(
{},
{
id: WrapB(content),
},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal({
SyncJournalEntry(id, etagA(content), etagB(content)),
});
final conflicts = await sync(sources, journal);
expect(conflicts, isEmpty);
expect(state.stateA, isEmpty);
expect(state.stateB, isEmpty);
expect(journal.entries, isEmpty);
});
test('from journal', () async {
const id = '123';
const content = '456';
final state = TestSyncState({}, {});
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal({
SyncJournalEntry(id, etagA(content), etagB(content)),
});
final conflicts = await sync(sources, journal);
expect(conflicts, isEmpty);
expect(state.stateA, isEmpty);
expect(state.stateB, isEmpty);
expect(journal.entries, isEmpty);
});
});
group('conflict', () {
test('journal missing', () async {
const id = '123';
const content = '456';
final state = TestSyncState(
{
id: WrapA(content),
},
{
id: WrapB(content),
},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal();
final conflicts = await sync(sources, journal);
expect(conflicts, hasLength(1));
expect(conflicts[0].type, SyncConflictType.bothNew);
expect(state.stateA, hasLength(1));
expect(state.stateA[id]!.content, content);
expect(state.stateB, hasLength(1));
expect(state.stateB[id]!.content, content);
expect(journal.entries, isEmpty);
});
test('both changed', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final state = TestSyncState(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal({
SyncJournalEntry(id, randomEtag(), randomEtag()),
});
final conflicts = await sync(sources, journal);
expect(conflicts, hasLength(1));
expect(conflicts[0].type, SyncConflictType.bothChanged);
expect(state.stateA, hasLength(1));
expect(state.stateA[id]!.content, contentA);
expect(state.stateB, hasLength(1));
expect(state.stateB[id]!.content, contentB);
expect(journal.entries, hasLength(1));
});
group('solution', () {
group('journal missing', () {
test('skip', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final state = TestSyncState(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal();
final conflicts = await sync(
sources,
journal,
conflictSolutions: {
id: SyncConflictSolution.skip,
},
);
expect(conflicts, isEmpty);
expect(state.stateA, hasLength(1));
expect(state.stateA[id]!.content, contentA);
expect(state.stateB, hasLength(1));
expect(state.stateB[id]!.content, contentB);
expect(journal.entries, isEmpty);
});
test('overwrite A', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final state = TestSyncState(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal();
final conflicts = await sync(
sources,
journal,
conflictSolutions: {
id: SyncConflictSolution.overwriteA,
},
);
expect(conflicts, isEmpty);
expect(state.stateA, hasLength(1));
expect(state.stateA[id]!.content, contentB);
expect(state.stateB, hasLength(1));
expect(state.stateB[id]!.content, contentB);
expect(journal.entries, hasLength(1));
expect(journal.entries.tryFind(id)!.etagA, etagA(contentB));
expect(journal.entries.tryFind(id)!.etagB, etagB(contentB));
});
test('overwrite B', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final state = TestSyncState(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal();
final conflicts = await sync(
sources,
journal,
conflictSolutions: {
id: SyncConflictSolution.overwriteB,
},
);
expect(conflicts, isEmpty);
expect(state.stateA, hasLength(1));
expect(state.stateA[id]!.content, contentA);
expect(state.stateB, hasLength(1));
expect(state.stateB[id]!.content, contentA);
expect(journal.entries, hasLength(1));
expect(journal.entries.tryFind(id)!.etagA, etagA(contentA));
expect(journal.entries.tryFind(id)!.etagB, etagB(contentA));
});
});
group('both changed', () {
test('skip', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final state = TestSyncState(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal({
SyncJournalEntry(id, randomEtag(), randomEtag()),
});
final conflicts = await sync(
sources,
journal,
conflictSolutions: {
id: SyncConflictSolution.skip,
},
);
expect(conflicts, isEmpty);
expect(state.stateA, hasLength(1));
expect(state.stateA[id]!.content, contentA);
expect(state.stateB, hasLength(1));
expect(state.stateB[id]!.content, contentB);
expect(journal.entries, hasLength(1));
});
test('overwrite A', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final state = TestSyncState(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal({
SyncJournalEntry(id, randomEtag(), randomEtag()),
});
final conflicts = await sync(
sources,
journal,
conflictSolutions: {
id: SyncConflictSolution.overwriteA,
},
);
expect(conflicts, isEmpty);
expect(state.stateA, hasLength(1));
expect(state.stateA[id]!.content, contentB);
expect(state.stateB, hasLength(1));
expect(state.stateB[id]!.content, contentB);
expect(journal.entries, hasLength(1));
expect(journal.entries.tryFind(id)!.etagA, etagA(contentB));
expect(journal.entries.tryFind(id)!.etagB, etagB(contentB));
});
test('overwrite B', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final state = TestSyncState(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final sources = TestSyncSources.fromState(state);
final journal = SyncJournal({
SyncJournalEntry(id, randomEtag(), randomEtag()),
});
final conflicts = await sync(
sources,
journal,
conflictSolutions: {
id: SyncConflictSolution.overwriteB,
},
);
expect(conflicts, isEmpty);
expect(state.stateA, hasLength(1));
expect(state.stateA[id]!.content, contentA);
expect(state.stateB, hasLength(1));
expect(state.stateB[id]!.content, contentA);
expect(journal.entries, hasLength(1));
expect(journal.entries.tryFind(id)!.etagA, etagA(contentA));
expect(journal.entries.tryFind(id)!.etagB, etagB(contentA));
});
});
});
});
});
});
}
Loading…
Cancel
Save