Browse Source

WIP

pull/24/head
jld3103 3 years ago
parent
commit
cb031834e8
No known key found for this signature in database
GPG Key ID: 9062417B9E8EB7B3
  1. 1
      packages/neon/android/app/src/main/AndroidManifest.xml
  2. 62
      packages/neon/lib/app.dart
  3. 19
      packages/neon/lib/l10n/en.arb
  4. 72
      packages/neon/lib/l10n/localizations.dart
  5. 38
      packages/neon/lib/l10n/localizations_en.dart
  6. 5
      packages/neon/lib/src/apps/files/app.dart
  7. 38
      packages/neon/lib/src/apps/files/blocs/files.dart
  8. 7
      packages/neon/lib/src/apps/files/blocs/files.rxb.g.dart
  9. 216
      packages/neon/lib/src/apps/files/blocs/sync.dart
  10. 98
      packages/neon/lib/src/apps/files/blocs/sync.rxb.g.dart
  11. 2
      packages/neon/lib/src/apps/files/dialogs/choose_create.dart
  12. 4
      packages/neon/lib/src/apps/files/dialogs/choose_folder.dart
  13. 218
      packages/neon/lib/src/apps/files/dialogs/sync_conflict.dart
  14. 4
      packages/neon/lib/src/apps/files/models/file_details.dart
  15. 31
      packages/neon/lib/src/apps/files/models/sync_mapping.dart
  16. 21
      packages/neon/lib/src/apps/files/models/sync_mapping.g.dart
  17. 2
      packages/neon/lib/src/apps/files/pages/details.dart
  18. 4
      packages/neon/lib/src/apps/files/pages/main.dart
  19. 26
      packages/neon/lib/src/apps/files/utils/download_task.dart
  20. 22
      packages/neon/lib/src/apps/files/utils/upload_task.dart
  21. 468
      packages/neon/lib/src/apps/files/widgets/browser_view.dart
  22. 2
      packages/neon/lib/src/apps/files/widgets/file_preview.dart
  23. 95
      packages/neon/lib/src/apps/files/widgets/file_tile.dart
  24. 38
      packages/neon/lib/src/apps/files/widgets/sync_status_icon.dart
  25. 3
      packages/neon/lib/src/blocs/apps.dart
  26. 8
      packages/neon/lib/src/blocs/push_notifications.dart
  27. 12
      packages/neon/lib/src/models/account.dart
  28. 6
      packages/neon/lib/src/neon.dart
  29. 8
      packages/neon/lib/src/pages/home/home.dart
  30. 54
      packages/neon/lib/src/pages/home/widgets/files_sync_listener.dart
  31. 192
      packages/neon/lib/src/pages/settings/account_specific_settings.dart
  32. 6
      packages/neon/lib/src/pages/settings/settings.dart
  33. 3
      packages/neon/lib/src/platform/abstract.dart
  34. 6
      packages/neon/lib/src/platform/android.dart
  35. 1
      packages/neon/lib/src/platform/linux.dart
  36. 40
      packages/neon/lib/src/utils/file_utils.dart
  37. 22
      packages/neon/lib/src/utils/save_file.dart
  38. 7
      packages/neon/lib/src/utils/theme.dart
  39. 4
      packages/neon/pubspec.lock
  40. 3
      packages/neon/pubspec.yaml
  41. 2
      packages/nextcloud/lib/nextcloud.dart
  42. 40
      packages/nextcloud/lib/src/sync/action.dart
  43. 45
      packages/nextcloud/lib/src/sync/conflict.dart
  44. 30
      packages/nextcloud/lib/src/sync/object.dart
  45. 38
      packages/nextcloud/lib/src/sync/sources.dart
  46. 167
      packages/nextcloud/lib/src/sync/sources/webdav_io_sources.dart
  47. 54
      packages/nextcloud/lib/src/sync/status.dart
  48. 39
      packages/nextcloud/lib/src/sync/status_entry.dart
  49. 229
      packages/nextcloud/lib/src/sync/sync.dart
  50. 59
      packages/nextcloud/lib/src/webdav/client.dart
  51. 1
      packages/nextcloud/pubspec.yaml
  52. 469
      packages/nextcloud/test/sync_test.dart
  53. 8
      packages/nextcloud/test/webdav_test.dart
  54. 3
      packages/settings/lib/src/widgets/custom_settings_tile.dart

1
packages/neon/android/app/src/main/AndroidManifest.xml

@ -2,6 +2,7 @@
package="de.provokateurin.neon">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<application
android:label="Neon"

62
packages/neon/lib/app.dart

@ -3,9 +3,11 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/apps/files/blocs/sync.dart';
import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/neon.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:provider/provider.dart';
import 'package:rxdart/rxdart.dart';
import 'package:shared_preferences/shared_preferences.dart';
@ -37,6 +39,8 @@ class _NeonAppState extends State<NeonApp> with WidgetsBindingObserver {
WidgetsBinding.instance.window.platformBrightness,
);
late FilesSyncBloc _filesSyncBloc;
@override
void didChangePlatformBrightness() {
_platformBrightness.add(WidgetsBinding.instance.window.platformBrightness);
@ -48,6 +52,11 @@ class _NeonAppState extends State<NeonApp> with WidgetsBindingObserver {
void initState() {
super.initState();
_filesSyncBloc = FilesSyncBloc(
Storage('files-sync', widget.sharedPreferences),
widget.accountsBloc,
);
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback((final _) {
@ -92,31 +101,34 @@ class _NeonAppState extends State<NeonApp> with WidgetsBindingObserver {
}
@override
Widget build(final BuildContext context) => StreamBuilder<Brightness>(
stream: _platformBrightness,
builder: (final context, final platformBrightnessSnapshot) => StreamBuilder<ThemeMode>(
stream: widget.globalOptions.themeMode.stream,
builder: (final context, final themeModeSnapshot) => StreamBuilder<bool>(
stream: widget.globalOptions.themeOLEDAsDark.stream,
builder: (final context, final themeOLEDAsDarkSnapshot) {
if (!platformBrightnessSnapshot.hasData ||
!themeOLEDAsDarkSnapshot.hasData ||
!themeModeSnapshot.hasData) {
return Container();
}
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
navigatorKey: _navigatorKey,
theme: getThemeFromNextcloudTheme(
_userTheme,
themeModeSnapshot.data!,
platformBrightnessSnapshot.data!,
oledAsDark: themeOLEDAsDarkSnapshot.data!,
),
home: Container(),
);
},
Widget build(final BuildContext context) => Provider<FilesSyncBloc>(
create: (final _) => _filesSyncBloc,
child: StreamBuilder<Brightness>(
stream: _platformBrightness,
builder: (final context, final platformBrightnessSnapshot) => StreamBuilder<ThemeMode>(
stream: widget.globalOptions.themeMode.stream,
builder: (final context, final themeModeSnapshot) => StreamBuilder<bool>(
stream: widget.globalOptions.themeOLEDAsDark.stream,
builder: (final context, final themeOLEDAsDarkSnapshot) {
if (!platformBrightnessSnapshot.hasData ||
!themeOLEDAsDarkSnapshot.hasData ||
!themeModeSnapshot.hasData) {
return Container();
}
return MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
navigatorKey: _navigatorKey,
theme: getThemeFromNextcloudTheme(
_userTheme,
themeModeSnapshot.data!,
platformBrightnessSnapshot.data!,
oledAsDark: themeOLEDAsDarkSnapshot.data!,
),
home: Container(),
);
},
),
),
),
);

19
packages/neon/lib/l10n/en.arb

@ -48,6 +48,10 @@
"no": "No",
"close": "Close",
"retry": "Retry",
"cancel": "Cancel",
"previous": "Previous",
"next": "Next",
"finish": "Finish",
"showSlashHide": "Show/Hide",
"exit": "Exit",
"disabled": "Disabled",
@ -181,6 +185,21 @@
}
}
},
"filesSyncNConflicts": "{n} file conflicts",
"@filesSyncNConflicts": {
"placeholders": {
"n": {
"type": "int"
}
}
},
"filesSyncForAllConflicts": "Apply for all conflicts",
"filesSyncLocal": "Local",
"filesSyncRemote": "Remote",
"filesSyncSkip": "Skip",
"filesSyncMappings": "File sync mappings",
"filesSyncAddMapping": "Add file sync mapping",
"filesSyncConfirmRemoveMapping": "Are you sure you want to remove this file sync mapping?",
"filesOptionsShowPreviews": "Show previews for files",
"filesOptionsUploadQueueParallelism": "Upload queue parallelism",
"filesOptionsDownloadQueueParallelism": "Download queue parallelism",

72
packages/neon/lib/l10n/localizations.dart

@ -251,6 +251,30 @@ abstract class AppLocalizations {
/// **'Retry'**
String get retry;
/// No description provided for @cancel.
///
/// In en, this message translates to:
/// **'Cancel'**
String get cancel;
/// No description provided for @previous.
///
/// In en, this message translates to:
/// **'Previous'**
String get previous;
/// No description provided for @next.
///
/// In en, this message translates to:
/// **'Next'**
String get next;
/// No description provided for @finish.
///
/// In en, this message translates to:
/// **'Finish'**
String get finish;
/// No description provided for @showSlashHide.
///
/// In en, this message translates to:
@ -683,6 +707,54 @@ abstract class AppLocalizations {
/// **'Are you sure you want to download a file that is bigger than {warningSize} ({actualSize})?'**
String filesConfirmDownloadSizeWarning(String warningSize, String actualSize);
/// No description provided for @filesSyncNConflicts.
///
/// In en, this message translates to:
/// **'{n} file conflicts'**
String filesSyncNConflicts(int n);
/// No description provided for @filesSyncForAllConflicts.
///
/// In en, this message translates to:
/// **'Apply for all conflicts'**
String get filesSyncForAllConflicts;
/// No description provided for @filesSyncLocal.
///
/// In en, this message translates to:
/// **'Local'**
String get filesSyncLocal;
/// No description provided for @filesSyncRemote.
///
/// In en, this message translates to:
/// **'Remote'**
String get filesSyncRemote;
/// No description provided for @filesSyncSkip.
///
/// In en, this message translates to:
/// **'Skip'**
String get filesSyncSkip;
/// No description provided for @filesSyncMappings.
///
/// In en, this message translates to:
/// **'File sync mappings'**
String get filesSyncMappings;
/// No description provided for @filesSyncAddMapping.
///
/// In en, this message translates to:
/// **'Add file sync mapping'**
String get filesSyncAddMapping;
/// No description provided for @filesSyncConfirmRemoveMapping.
///
/// In en, this message translates to:
/// **'Are you sure you want to remove this file sync mapping?'**
String get filesSyncConfirmRemoveMapping;
/// No description provided for @filesOptionsShowPreviews.
///
/// In en, this message translates to:

38
packages/neon/lib/l10n/localizations_en.dart

@ -94,6 +94,18 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get retry => 'Retry';
@override
String get cancel => 'Cancel';
@override
String get previous => 'Previous';
@override
String get next => 'Next';
@override
String get finish => 'Finish';
@override
String get showSlashHide => 'Show/Hide';
@ -326,6 +338,32 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Are you sure you want to download a file that is bigger than $warningSize ($actualSize)?';
}
@override
String filesSyncNConflicts(int n) {
return '$n file conflicts';
}
@override
String get filesSyncForAllConflicts => 'Apply for all conflicts';
@override
String get filesSyncLocal => 'Local';
@override
String get filesSyncRemote => 'Remote';
@override
String get filesSyncSkip => 'Skip';
@override
String get filesSyncMappings => 'File sync mappings';
@override
String get filesSyncAddMapping => 'Add file sync mapping';
@override
String get filesSyncConfirmRemoveMapping => 'Are you sure you want to remove this file sync mapping?';
@override
String get filesOptionsShowPreviews => 'Show previews for files';

5
packages/neon/lib/src/apps/files/app.dart

@ -17,6 +17,8 @@ import 'package:material_design_icons_flutter/material_design_icons_flutter.dart
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/apps/files/blocs/browser.dart';
import 'package:neon/src/apps/files/blocs/files.dart';
import 'package:neon/src/apps/files/blocs/sync.dart';
import 'package:neon/src/apps/files/models/sync_mapping.dart';
import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/blocs/apps.dart';
import 'package:neon/src/models/account.dart';
@ -30,6 +32,7 @@ import 'package:settings/settings.dart';
part 'dialogs/choose_create.dart';
part 'dialogs/choose_folder.dart';
part 'dialogs/create_folder.dart';
part 'dialogs/sync_conflict.dart';
part 'models/file_details.dart';
part 'options.dart';
part 'pages/details.dart';
@ -38,6 +41,8 @@ part 'utils/download_task.dart';
part 'utils/upload_task.dart';
part 'widgets/browser_view.dart';
part 'widgets/file_preview.dart';
part 'widgets/file_tile.dart';
part 'widgets/sync_status_icon.dart';
class FilesApp extends AppImplementation<FilesBloc, FilesAppSpecificOptions> {
FilesApp(super.sharedPreferences, super.requestManager, super.platform);

38
packages/neon/lib/src/apps/files/blocs/files.dart

@ -4,7 +4,6 @@ import 'dart:io';
import 'package:flutter/material.dart';
import 'package:neon/src/apps/files/app.dart';
import 'package:neon/src/apps/files/blocs/browser.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/neon.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:open_file/open_file.dart';
@ -20,8 +19,6 @@ abstract class FilesBlocEvents {
void uploadFile(final List<String> path, final String localPath);
void syncFile(final List<String> path);
void openFile(final List<String> path, final String etag, final String? mimeType);
void delete(final List<String> path);
@ -64,37 +61,15 @@ class FilesBloc extends $FilesBloc {
final stat = await file.stat();
final task = UploadTask(
path: event.path,
size: stat.size,
lastModified: stat.modified,
stat: stat,
);
_uploadTasksSubject.add(_uploadTasksSubject.value..add(task));
await _uploadQueue.add(() => task.execute(client, file.openRead()));
await _uploadQueue.add(() => task.execute(client, file));
_uploadTasksSubject.add(_uploadTasksSubject.value..removeWhere((final t) => t == task));
},
);
});
_$syncFileEvent.listen((final path) {
final stream = _requestManager.wrapWithoutCache(
() async {
final file = File(
p.join(
await _platform.getUserAccessibleAppDataPath(),
client.humanReadableID,
'files',
path.join(Platform.pathSeparator),
),
);
if (!file.parent.existsSync()) {
file.parent.createSync(recursive: true);
}
return _downloadFile(path, file);
},
disableTimeout: true,
).asBroadcastStream();
stream.whereError().listen(_errorsStreamController.add);
});
_$openFileEvent.listen((final event) {
_wrapAction(
true,
@ -185,17 +160,12 @@ class FilesBloc extends $FilesBloc {
final List<String> path,
final File file,
) async {
final sink = file.openWrite();
try {
final task = DownloadTask(
path: path,
);
final task = DownloadTask(path: path);
_downloadTasksSubject.add(_downloadTasksSubject.value..add(task));
await _downloadQueue.add(() => task.execute(client, sink));
await _downloadQueue.add(() => task.execute(client, file));
_downloadTasksSubject.add(_downloadTasksSubject.value..removeWhere((final t) => t == task));
await sink.close();
} catch (e) {
await sink.close();
rethrow;
}
}

7
packages/neon/lib/src/apps/files/blocs/files.rxb.g.dart

@ -24,9 +24,6 @@ abstract class $FilesBloc extends RxBlocBase implements FilesBlocEvents, FilesBl
/// Тhe [Subject] where events sink to by calling [uploadFile]
final _$uploadFileEvent = PublishSubject<_UploadFileEventArgs>();
/// Тhe [Subject] where events sink to by calling [syncFile]
final _$syncFileEvent = PublishSubject<List<String>>();
/// Тhe [Subject] where events sink to by calling [openFile]
final _$openFileEvent = PublishSubject<_OpenFileEventArgs>();
@ -70,9 +67,6 @@ abstract class $FilesBloc extends RxBlocBase implements FilesBlocEvents, FilesBl
localPath,
));
@override
void syncFile(List<String> path) => _$syncFileEvent.add(path);
@override
void openFile(
List<String> path,
@ -149,7 +143,6 @@ abstract class $FilesBloc extends RxBlocBase implements FilesBlocEvents, FilesBl
void dispose() {
_$refreshEvent.close();
_$uploadFileEvent.close();
_$syncFileEvent.close();
_$openFileEvent.close();
_$deleteEvent.close();
_$renameEvent.close();

216
packages/neon/lib/src/apps/files/blocs/sync.dart

@ -0,0 +1,216 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:neon/src/apps/files/models/sync_mapping.dart';
import 'package:neon/src/blocs/accounts.dart';
import 'package:neon/src/models/account.dart';
import 'package:neon/src/neon.dart';
import 'package:nextcloud/nextcloud.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:rx_bloc/rx_bloc.dart';
import 'package:rxdart/rxdart.dart';
import 'package:watcher/watcher.dart';
part 'sync.rxb.g.dart';
abstract class FilesSyncBlocEvents {
void addMapping(final FilesSyncMapping mapping);
void removeMapping(final FilesSyncMapping mapping);
void syncMapping(final FilesSyncMapping mapping, final Map<String, SyncConflictSolution> solutions);
}
abstract class FilesSyncBlocStates {
BehaviorSubject<Map<FilesSyncMapping, bool?>> get mappingStatuses;
Stream<FilesSyncConflicts> get conflicts;
Stream<Exception> get errors;
}
@RxBloc()
class FilesSyncBloc extends $FilesSyncBloc {
FilesSyncBloc(
this._storage,
this._accountsBloc,
) {
_$addMappingEvent.listen((final mapping) async {
_mappingStatusesSubject.add({
..._mappingStatusesSubject.value,
mapping: null,
});
await _saveMappings();
// Directly trigger sync after adding the mapping
syncMapping(mapping, {});
});
_$removeMappingEvent.listen((final mapping) async {
_mappingStatusesSubject.add(
Map.fromEntries(
_mappingStatusesSubject.value.entries.where(
(final e) =>
e.key.accountId != mapping.accountId &&
e.key.localPath != mapping.localPath &&
e.key.remotePath != mapping.remotePath,
),
),
);
await _saveMappings();
});
_$syncMappingEvent.listen((final event) async {
final account = _accountsBloc.accounts.value.find(event.mapping.accountId);
if (account == null) {
removeMapping(event.mapping);
return;
}
// This shouldn't be necessary, but it sadly is because of https://github.com/flutter/flutter/issues/25659.
// Alternative would be to use https://pub.dev/packages/shared_storage,
// but to be efficient we'd need https://github.com/alexrintt/shared-storage/issues/91
// or copy the files to the app cache (which is also not optimal).
if (Platform.isAndroid && !await Permission.manageExternalStorage.request().isGranted) {
return;
}
try {
final sources = _sourcesForMapping(account, event.mapping);
final conflicts = await sync<WebDavFile, FileSystemEntity>(
sources,
event.mapping.status,
event.solutions,
);
if (conflicts.isNotEmpty) {
_conflictsController.add(FilesSyncConflicts(event.mapping, conflicts));
}
_mappingStatusesSubject.add(
Map<FilesSyncMapping, bool?>.fromEntries(
_mappingStatusesSubject.value.entries.map(
(final e) => MapEntry(e.key, event.mapping == e.key ? conflicts.isEmpty : e.value),
),
),
);
} catch (e) {
_errorsStreamController.add(e as Exception);
}
await _saveMappings();
});
mappingStatuses.listen((final statuses) async {
for (final mapping in statuses.keys) {
if (_watchers[mapping] == null) {
_watchers[mapping] = DirectoryWatcher(mapping.localPath).events.listen(
(final watchEvent) {
print('watchEvent: $watchEvent');
},
);
}
}
for (final entries in _watchers.entries.toList()) {
if (statuses[entries.key] == null) {
await entries.value.cancel();
_watchers.remove(entries.key);
}
}
});
_loadMappings();
unawaited(_syncMappingStatuses());
}
void _loadMappings() {
if (_storage.containsKey(_keyMappings)) {
_mappingStatusesSubject.add(
{
for (final mapping in _storage
.getStringList(_keyMappings)!
.map<FilesSyncMapping>((final m) => FilesSyncMapping.fromJson(json.decode(m) as Map<String, dynamic>))
.toList()) ...{
mapping: null,
},
},
);
}
}
Future _saveMappings() async {
await _storage.setStringList(
_keyMappings,
_mappingStatusesSubject.value.keys.map((final m) => json.encode(m.toJson())).toList(),
);
}
Future _syncMappingStatuses() async {
final statuses = <FilesSyncMapping, bool?>{};
final mappings = _mappingStatusesSubject.value.keys.toList();
for (final mapping in mappings) {
final account = _accountsBloc.accounts.value.find(mapping.accountId);
if (account == null) {
removeMapping(mapping);
continue;
}
try {
final sources = _sourcesForMapping(account, mapping);
final diff = await computeSyncDiff(sources, mapping.status, {});
statuses[mapping] = diff.actions.isEmpty && diff.conflicts.isEmpty;
} on Exception catch (e) {
_errorsStreamController.add(e);
}
}
_mappingStatusesSubject.add(statuses);
}
WebDavIOSyncSources _sourcesForMapping(final Account account, final FilesSyncMapping mapping) => WebDavIOSyncSources(
account.client,
mapping.remotePath,
mapping.localPath,
extraProps: [
WebDavProps.davLastModified,
WebDavProps.ncHasPreview,
WebDavProps.ocSize,
WebDavProps.ocFavorite,
],
);
final Storage _storage;
final AccountsBloc _accountsBloc;
final _keyMappings = 'mappings';
final _mappingStatusesSubject = BehaviorSubject<Map<FilesSyncMapping, bool?>>.seeded({});
final _conflictsController = StreamController<FilesSyncConflicts>();
final _errorsStreamController = StreamController<Exception>();
final _watchers = <FilesSyncMapping, StreamSubscription<WatchEvent>>{};
@override
void dispose() {
unawaited(_mappingStatusesSubject.close());
unawaited(_conflictsController.close());
unawaited(_errorsStreamController.close());
super.dispose();
}
@override
BehaviorSubject<Map<FilesSyncMapping, bool?>> _mapToMappingStatusesState() => _mappingStatusesSubject;
@override
Stream<FilesSyncConflicts> _mapToConflictsState() => _conflictsController.stream.asBroadcastStream();
@override
Stream<Exception> _mapToErrorsState() => _errorsStreamController.stream.asBroadcastStream();
}
class FilesSyncConflicts {
FilesSyncConflicts(
this.mapping,
this.conflicts,
);
final FilesSyncMapping mapping;
final List<SyncConflict<WebDavFile, FileSystemEntity>> conflicts;
}

98
packages/neon/lib/src/apps/files/blocs/sync.rxb.g.dart

@ -0,0 +1,98 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// **************************************************************************
// Generator: RxBlocGeneratorForAnnotation
// **************************************************************************
part of 'sync.dart';
/// Used as a contractor for the bloc, events and states classes
/// {@nodoc}
abstract class FilesSyncBlocType extends RxBlocTypeBase {
FilesSyncBlocEvents get events;
FilesSyncBlocStates get states;
}
/// [$FilesSyncBloc] extended by the [FilesSyncBloc]
/// {@nodoc}
abstract class $FilesSyncBloc extends RxBlocBase
implements FilesSyncBlocEvents, FilesSyncBlocStates, FilesSyncBlocType {
final _compositeSubscription = CompositeSubscription();
/// Тhe [Subject] where events sink to by calling [addMapping]
final _$addMappingEvent = PublishSubject<FilesSyncMapping>();
/// Тhe [Subject] where events sink to by calling [removeMapping]
final _$removeMappingEvent = PublishSubject<FilesSyncMapping>();
/// Тhe [Subject] where events sink to by calling [syncMapping]
final _$syncMappingEvent = PublishSubject<_SyncMappingEventArgs>();
/// The state of [mappingStatuses] implemented in [_mapToMappingStatusesState]
late final BehaviorSubject<Map<FilesSyncMapping, bool?>> _mappingStatusesState = _mapToMappingStatusesState();
/// The state of [conflicts] implemented in [_mapToConflictsState]
late final Stream<FilesSyncConflicts> _conflictsState = _mapToConflictsState();
/// The state of [errors] implemented in [_mapToErrorsState]
late final Stream<Exception> _errorsState = _mapToErrorsState();
@override
void addMapping(FilesSyncMapping mapping) => _$addMappingEvent.add(mapping);
@override
void removeMapping(FilesSyncMapping mapping) => _$removeMappingEvent.add(mapping);
@override
void syncMapping(
FilesSyncMapping mapping,
Map<String, SyncConflictSolution> solutions,
) =>
_$syncMappingEvent.add(_SyncMappingEventArgs(
mapping,
solutions,
));
@override
BehaviorSubject<Map<FilesSyncMapping, bool?>> get mappingStatuses => _mappingStatusesState;
@override
Stream<FilesSyncConflicts> get conflicts => _conflictsState;
@override
Stream<Exception> get errors => _errorsState;
BehaviorSubject<Map<FilesSyncMapping, bool?>> _mapToMappingStatusesState();
Stream<FilesSyncConflicts> _mapToConflictsState();
Stream<Exception> _mapToErrorsState();
@override
FilesSyncBlocEvents get events => this;
@override
FilesSyncBlocStates get states => this;
@override
void dispose() {
_$addMappingEvent.close();
_$removeMappingEvent.close();
_$syncMappingEvent.close();
_compositeSubscription.dispose();
super.dispose();
}
}
/// Helps providing the arguments in the [Subject.add] for
/// [FilesSyncBlocEvents.syncMapping] event
class _SyncMappingEventArgs {
const _SyncMappingEventArgs(
this.mapping,
this.solutions,
);
final FilesSyncMapping mapping;
final Map<String, SyncConflictSolution> solutions;
}

2
packages/neon/lib/src/apps/files/dialogs/choose_create.dart

@ -16,7 +16,7 @@ class FilesChooseCreateDialog extends StatefulWidget {
class _FilesChooseCreateDialogState extends State<FilesChooseCreateDialog> {
Future uploadFromPick(final FileType type) async {
final result = await FilePicker.platform.pickFiles(
final result = await FileUtils.loadFileWithPickDialog(
allowMultiple: true,
type: type,
);

4
packages/neon/lib/src/apps/files/dialogs/choose_folder.dart

@ -4,14 +4,14 @@ class FilesChooseFolderDialog extends StatelessWidget {
const FilesChooseFolderDialog({
required this.bloc,
required this.filesBloc,
required this.originalPath,
this.originalPath,
super.key,
});
final FilesBrowserBloc bloc;
final FilesBloc filesBloc;
final List<String> originalPath;
final List<String>? originalPath;
@override
Widget build(final BuildContext context) => AlertDialog(

218
packages/neon/lib/src/apps/files/dialogs/sync_conflict.dart

@ -0,0 +1,218 @@
part of '../app.dart';
class FilesSyncConflictDialog extends StatefulWidget {
const FilesSyncConflictDialog({
required this.bloc,
required this.conflicts,
super.key,
});
final FilesBloc bloc;
final FilesSyncConflicts conflicts;
@override
State<FilesSyncConflictDialog> createState() => _FilesSyncConflictDialogState();
}
class _FilesSyncConflictDialogState extends State<FilesSyncConflictDialog> {
var _all = false;
SyncConflictSolution? _allSolution;
var _index = 0;
final _solutions = <String, SyncConflictSolution>{};
final visualDensity = const VisualDensity(
horizontal: -4,
vertical: -4,
);
@override
Widget build(final BuildContext context) {
final conflict = widget.conflicts.conflicts[_index];
return AlertDialog(
title: Text(AppLocalizations.of(context).filesSyncNConflicts(widget.conflicts.conflicts.length)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.conflicts.conflicts.length > 1) ...[
CheckboxListTile(
visualDensity: visualDensity,
value: _all,
onChanged: (final value) {
setState(() {
_all = value!;
});
},
title: Text(AppLocalizations.of(context).filesSyncForAllConflicts),
),
const Divider(),
],
if (_all) ...[
...<String, SyncConflictSolution>{
AppLocalizations.of(context).filesSyncLocal: SyncConflictSolution.overwriteA,
AppLocalizations.of(context).filesSyncRemote: SyncConflictSolution.overwriteB,
AppLocalizations.of(context).filesSyncSkip: SyncConflictSolution.skip,
}.entries.map(
(final e) => ListTile(
visualDensity: visualDensity,
title: Text(
e.key,
style: Theme.of(context).textTheme.titleMedium,
),
trailing: Radio<SyncConflictSolution>(
value: e.value,
groupValue: _allSolution,
onChanged: (final solution) {
setState(() {
_allSolution = solution!;
});
},
),
),
),
] else ...[
Text(
conflict.id,
style: Theme.of(context).textTheme.titleLarge,
),
Builder(
builder: (final context) {
final file = conflict.objectB.data;
final stat = file.statSync();
return FilesFileTile(
visualDensity: visualDensity,
filesBloc: widget.bloc,
titleOverride: Text(AppLocalizations.of(context).filesSyncLocal),
details: FilesFileDetails(
path: conflict.id.split('/'),
isDirectory: file is Directory,
size: stat.size,
etag: '',
mimeType: '',
lastModified: stat.modified,
hasPreview: false,
isFavorite: false,
),
onTap: (final details) {
setState(() {
_solutions[conflict.id] = SyncConflictSolution.overwriteA;
});
},
trailing: Radio<SyncConflictSolution>(
value: SyncConflictSolution.overwriteA,
groupValue: _solutions[conflict.id],
onChanged: (final solution) {
setState(() {
_solutions[conflict.id] = solution!;
});
},
),
);
},
),
Builder(
builder: (final context) {
final file = conflict.objectA.data;
return FilesFileTile(
visualDensity: visualDensity,
filesBloc: widget.bloc,
titleOverride: Text(AppLocalizations.of(context).filesSyncRemote),
details: FilesFileDetails(
path: conflict.id.split('/'),
isDirectory: file.isDirectory,
size: file.size!,
etag: '',
mimeType: file.mimeType,
lastModified: file.lastModified!,
hasPreview: file.hasPreview,
isFavorite: conflict.objectA.data.favorite,
),
onTap: (final details) {
setState(() {
_solutions[conflict.id] = SyncConflictSolution.overwriteB;
});
},
trailing: Radio<SyncConflictSolution>(
value: SyncConflictSolution.overwriteB,
groupValue: _solutions[conflict.id],
onChanged: (final solution) {
setState(() {
_solutions[conflict.id] = solution!;
});
},
),
);
},
),
ListTile(
visualDensity: visualDensity,
title: Text(AppLocalizations.of(context).filesSyncSkip),
leading: const SizedBox(
height: 40,
width: 40,
),
onTap: () {
setState(() {
_solutions[conflict.id] = SyncConflictSolution.skip;
});
},
trailing: Radio<SyncConflictSolution>(
value: SyncConflictSolution.skip,
groupValue: _solutions[conflict.id],
onChanged: (final solution) {
setState(() {
_solutions[conflict.id] = solution!;
});
},
),
),
],
],
),
actionsAlignment: MainAxisAlignment.spaceBetween,
actions: [
if (_index > 0 && !_all) ...[
OutlinedButton(
onPressed: () {
setState(() {
_index--;
});
},
child: Text(AppLocalizations.of(context).previous),
),
] else ...[
OutlinedButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(AppLocalizations.of(context).cancel),
),
],
if (_index < widget.conflicts.conflicts.length - 1 && !_all) ...[
ElevatedButton(
onPressed: () {
setState(() {
_index++;
});
},
child: Text(AppLocalizations.of(context).next),
),
] else ...[
ElevatedButton(
onPressed: () {
if (_all && _allSolution == null) {
return;
}
if (!_all && _solutions.length != widget.conflicts.conflicts.length) {
return;
}
Navigator.of(context).pop(_all ? _allSolution : _solutions);
},
child: Text(AppLocalizations.of(context).finish),
),
],
],
);
}
}

4
packages/neon/lib/src/apps/files/models/file_details.dart

@ -1,7 +1,7 @@
part of '../app.dart';
class FileDetails {
FileDetails({
class FilesFileDetails {
FilesFileDetails({
required this.path,
required this.isDirectory,
required this.size,

31
packages/neon/lib/src/apps/files/models/sync_mapping.dart

@ -0,0 +1,31 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:nextcloud/nextcloud.dart';
part 'sync_mapping.g.dart';
@JsonSerializable()
class FilesSyncMapping {
FilesSyncMapping({
required this.accountId,
required this.remotePath,
required this.localPath,
final SyncStatus? status,
}) {
this.status = status ?? SyncStatus([]);
}
factory FilesSyncMapping.fromJson(final Map<String, dynamic> json) => _$FilesSyncMappingFromJson(json);
Map<String, dynamic> toJson() => _$FilesSyncMappingToJson(this);
final String accountId;
final List<String> remotePath;
final String localPath;
@JsonKey(
fromJson: syncStatusFromJson,
toJson: syncStatusToJson,
)
late final SyncStatus status;
}

21
packages/neon/lib/src/apps/files/models/sync_mapping.g.dart

@ -0,0 +1,21 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sync_mapping.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
FilesSyncMapping _$FilesSyncMappingFromJson(Map<String, dynamic> json) => FilesSyncMapping(
accountId: json['accountId'] as String,
remotePath: (json['remotePath'] as List<dynamic>).map((e) => e as String).toList(),
localPath: json['localPath'] as String,
status: syncStatusFromJson(json['status'] as List),
);
Map<String, dynamic> _$FilesSyncMappingToJson(FilesSyncMapping instance) => <String, dynamic>{
'accountId': instance.accountId,
'remotePath': instance.remotePath,
'localPath': instance.localPath,
'status': syncStatusToJson(instance.status),
};

2
packages/neon/lib/src/apps/files/pages/details.dart

@ -8,7 +8,7 @@ class FilesDetailsPage extends StatelessWidget {
});
final FilesBloc bloc;
final FileDetails details;
final FilesFileDetails details;
@override
Widget build(final BuildContext context) => Scaffold(

4
packages/neon/lib/src/apps/files/pages/main.dart

@ -27,6 +27,10 @@ class _FilesMainPageState extends State<FilesMainPage> {
bloc: widget.bloc.browser,
filesBloc: widget.bloc,
onPickFile: (final details) async {
if (details.etag == null) {
// When the ETag is null we are uploading the file right now
return;
}
final sizeWarning = widget.bloc.options.downloadSizeWarning.value;
if (sizeWarning != null && details.size > sizeWarning) {
if (!(await showConfirmationDialog(

26
packages/neon/lib/src/apps/files/utils/download_task.dart

@ -10,23 +10,13 @@ class DownloadTask {
final _streamController = StreamController<int>();
late final progress = _streamController.stream.asBroadcastStream();
Future execute(final NextcloudClient client, final IOSink sink) async {
final completer = Completer();
final response = await client.webdav.downloadStream(path.join('/'));
var downloaded = 0;
response.listen((final chunk) async {
sink.add(chunk);
downloaded += chunk.length;
_streamController.add((downloaded / response.contentLength * 100).toInt());
if (downloaded >= response.contentLength) {
completer.complete();
}
});
return completer.future;
Future execute(final NextcloudClient client, final File file) async {
await client.webdav.downloadFile(
path.join('/'),
file,
onProgress: (final progress) {
_streamController.add(progress.toInt());
},
);
}
}

22
packages/neon/lib/src/apps/files/utils/upload_task.dart

@ -3,27 +3,23 @@ part of '../app.dart';
class UploadTask {
UploadTask({
required this.path,
required this.size,
required this.lastModified,
required this.stat,
});
final List<String> path;
final int size;
final DateTime lastModified;
final FileStat stat;
final _streamController = StreamController<int>();
late final progress = _streamController.stream.asBroadcastStream();
Future execute(final NextcloudClient client, final Stream<List<int>> stream) async {
var uploaded = 0;
await client.webdav.uploadStream(
stream.map((final chunk) {
uploaded += chunk.length;
_streamController.add((uploaded / size * 100).toInt());
return Uint8List.fromList(chunk);
}),
Future execute(final NextcloudClient client, final File file) async {
await client.webdav.uploadFile(
file,
stat,
path.join('/'),
onProgress: (final progress) {
_streamController.add(progress.toInt());
},
);
}
}

468
packages/neon/lib/src/apps/files/widgets/browser_view.dart

@ -8,13 +8,15 @@ class FilesBrowserView extends StatefulWidget {
this.enableFileActions = true,
this.enableCreateActions = true,
this.onlyShowDirectories = false,
super.key,
// ignore: prefer_asserts_with_message
}) : assert((onPickFile == null) == onlyShowDirectories);
super.key, // ignore: prefer_asserts_with_message
}) : assert(
(onPickFile == null) == onlyShowDirectories,
'can not pick files when only showing directories and can not show only directories when picking files',
);
final FilesBrowserBloc bloc;
final FilesBloc filesBloc;
final Function(FileDetails)? onPickFile;
final Function(FilesFileDetails)? onPickFile;
final bool enableFileActions;
final bool enableCreateActions;
final bool onlyShowDirectories;
@ -24,15 +26,161 @@ class FilesBrowserView extends StatefulWidget {
}
class _FilesBrowserViewState extends State<FilesBrowserView> {
late final FilesSyncBloc _filesSyncBloc;
@override
void initState() {
super.initState();
_filesSyncBloc = RxBlocProvider.of<FilesSyncBloc>(context);
widget.bloc.errors.listen((final error) {
ExceptionWidget.showSnackbar(context, error);
});
}
void _onPickFile(final FilesFileDetails details) {
if (details.isDirectory) {
widget.bloc.setPath(details.path);
} else {
widget.onPickFile?.call(details);
}
}
Widget _buildFileActions(final FilesFileDetails details) => PopupMenuButton<FilesFileAction>(
itemBuilder: (final context) => [
if (details.isFavorite != null) ...[
PopupMenuItem(
value: FilesFileAction.toggleFavorite,
child: Text(
details.isFavorite!
? AppLocalizations.of(context).filesRemoveFromFavorites
: AppLocalizations.of(context).filesAddToFavorites,
),
),
],
PopupMenuItem(
value: FilesFileAction.details,
child: Text(AppLocalizations.of(context).filesDetails),
),
PopupMenuItem(
value: FilesFileAction.rename,
child: Text(AppLocalizations.of(context).rename),
),
PopupMenuItem(
value: FilesFileAction.move,
child: Text(AppLocalizations.of(context).move),
),
PopupMenuItem(
value: FilesFileAction.copy,
child: Text(AppLocalizations.of(context).copy),
),
if (details.isDirectory) ...[
PopupMenuItem(
value: FilesFileAction.sync,
child: Text(AppLocalizations.of(context).filesSync),
),
],
PopupMenuItem(
value: FilesFileAction.delete,
child: Text(AppLocalizations.of(context).delete),
),
],
onSelected: (final action) async {
switch (action) {
case FilesFileAction.toggleFavorite:
if (details.isFavorite ?? false) {
widget.filesBloc.removeFavorite(details.path);
} else {
widget.filesBloc.addFavorite(details.path);
}
break;
case FilesFileAction.details:
await Navigator.of(context).push(
MaterialPageRoute(
builder: (final context) => FilesDetailsPage(
bloc: widget.filesBloc,
details: details,
),
),
);
break;
case FilesFileAction.rename:
final result = await showRenameDialog(
context: context,
title: details.isDirectory
? AppLocalizations.of(context).filesRenameFolder
: AppLocalizations.of(context).filesRenameFile,
value: details.name,
);
if (result != null) {
widget.filesBloc.rename(details.path, result);
}
break;
case FilesFileAction.move:
final b = widget.filesBloc.getNewFilesBrowserBloc();
final originalPath = details.path.sublist(0, details.path.length - 1);
b.setPath(originalPath);
final result = await showDialog<List<String>?>(
context: context,
builder: (final context) => FilesChooseFolderDialog(
bloc: b,
filesBloc: widget.filesBloc,
originalPath: originalPath,
),
);
b.dispose();
if (result != null) {
widget.filesBloc.move(details.path, result..add(details.name));
}
break;
case FilesFileAction.copy:
final b = widget.filesBloc.getNewFilesBrowserBloc();
final originalPath = details.path.sublist(0, details.path.length - 1);
b.setPath(originalPath);
final result = await showDialog<List<String>?>(
context: context,
builder: (final context) => FilesChooseFolderDialog(
bloc: b,
filesBloc: widget.filesBloc,
originalPath: originalPath,
),
);
b.dispose();
if (result != null) {
widget.filesBloc.copy(details.path, result..add(details.name));
}
break;
case FilesFileAction.sync:
// TODO: Check if a mapping already exists, then only sync not add
final localPath = await FileUtils.pickDirectory();
if (localPath == null || !mounted) {
return;
}
_filesSyncBloc.addMapping(
FilesSyncMapping(
accountId: RxBlocProvider.of<AccountsBloc>(context).activeAccount.value!.id,
remotePath: details.path,
localPath: localPath,
),
);
break;
case FilesFileAction.delete:
if (await showConfirmationDialog(
context,
details.isDirectory
? AppLocalizations.of(context).filesDeleteFolderConfirm(details.name)
: AppLocalizations.of(context).filesDeleteFileConfirm(details.name),
)) {
widget.filesBloc.delete(details.path);
}
break;
}
},
);
@override
Widget build(final BuildContext context) => StandardRxResultBuilder<FilesBrowserBloc, List<WebDavFile>>(
bloc: widget.bloc,
@ -147,20 +295,32 @@ class _FilesBrowserViewState extends State<FilesBrowserView> {
builder: (final context, final uploadTaskProgressSnapshot) =>
!uploadTaskProgressSnapshot.hasData
? Container()
: _buildFile(
context: context,
details: FileDetails(
path: uploadTask.path,
isDirectory: false,
size: uploadTask.size,
etag: null,
mimeType: null,
lastModified: uploadTask.lastModified,
hasPreview: null,
isFavorite: null,
),
uploadProgress: uploadTaskProgressSnapshot.data!,
downloadProgress: null,
: Builder(
builder: (final context) {
final details = FilesFileDetails(
path: uploadTask.path,
isDirectory: false,
size: uploadTask.stat.size,
etag: null,
mimeType: null,
lastModified: uploadTask.stat.modified,
hasPreview: null,
isFavorite: null,
);
return FilesFileTile(
filesBloc: widget.filesBloc,
details: details,
trailing: !uploadTaskProgressSnapshot.hasData &&
widget.enableFileActions
? _buildFileActions(details)
: const SizedBox(
width: 48,
height: 48,
),
onTap: _onPickFile,
uploadProgress: uploadTaskProgressSnapshot.data!,
);
},
),
),
],
@ -183,26 +343,41 @@ class _FilesBrowserViewState extends State<FilesBrowserView> {
? matchingDownloadTasks.first.progress
: Stream.value(null),
builder: (final context, final downloadTaskProgressSnapshot) =>
_buildFile(
context: context,
details: FileDetails(
path: [...widget.bloc.path.value, file.name],
isDirectory: matchingUploadTasks.isEmpty && file.isDirectory,
size: matchingUploadTasks.isNotEmpty
? matchingUploadTasks.first.size
: file.size!,
etag: matchingUploadTasks.isNotEmpty ? null : file.etag,
mimeType: matchingUploadTasks.isNotEmpty ? null : file.mimeType,
lastModified: matchingUploadTasks.isNotEmpty
? matchingUploadTasks.first.lastModified
: file.lastModified!,
hasPreview:
matchingUploadTasks.isNotEmpty ? null : file.hasPreview,
isFavorite:
matchingUploadTasks.isNotEmpty ? null : file.favorite,
),
uploadProgress: uploadTaskProgressSnapshot.data,
downloadProgress: downloadTaskProgressSnapshot.data,
Builder(
builder: (final context) {
final details = FilesFileDetails(
path: [...widget.bloc.path.value, file.name],
isDirectory: matchingUploadTasks.isEmpty && file.isDirectory,
size: matchingUploadTasks.isNotEmpty
? matchingUploadTasks.first.stat.size
: file.size!,
etag: matchingUploadTasks.isNotEmpty ? null : file.etag,
mimeType:
matchingUploadTasks.isNotEmpty ? null : file.mimeType,
lastModified: matchingUploadTasks.isNotEmpty
? matchingUploadTasks.first.stat.modified
: file.lastModified!,
hasPreview:
matchingUploadTasks.isNotEmpty ? null : file.hasPreview,
isFavorite:
matchingUploadTasks.isNotEmpty ? null : file.favorite,
);
return FilesFileTile(
filesBloc: widget.filesBloc,
details: details,
trailing: !uploadTaskProgressSnapshot.hasData &&
!downloadTaskProgressSnapshot.hasData &&
widget.enableFileActions
? _buildFileActions(details)
: const SizedBox(
width: 48,
height: 48,
),
onTap: _onPickFile,
uploadProgress: uploadTaskProgressSnapshot.data,
downloadProgress: downloadTaskProgressSnapshot.data,
);
},
),
),
);
@ -242,223 +417,6 @@ class _FilesBrowserViewState extends State<FilesBrowserView> {
[...widget.bloc.path.value, name],
path,
);
Widget _buildFile({
required final BuildContext context,
required final FileDetails details,
required final int? uploadProgress,
required final int? downloadProgress,
}) =>
ListTile(
// When the ETag is null it means we are uploading this file right now
onTap: details.isDirectory || details.etag != null
? () async {
if (details.isDirectory) {
widget.bloc.setPath(details.path);
} else {
if (widget.onPickFile != null) {
widget.onPickFile!.call(details);
}
}
}
: null,
title: Text(
details.name,
overflow: TextOverflow.ellipsis,
),
subtitle: Row(
children: [
Text(CustomTimeAgo.format(details.lastModified)),
if (details.size > 0) ...[
const SizedBox(
width: 10,
),
Text(
filesize(details.size, 1),
style: DefaultTextStyle.of(context).style.copyWith(
color: Colors.grey,
),
),
],
],
),
leading: SizedBox(
height: 40,
width: 40,
child: Stack(
children: [
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: widget.filesBloc,
details: details,
withBackground: true,
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
),
if (details.isFavorite ?? false) ...[
const Align(
alignment: Alignment.bottomRight,
child: Icon(
Icons.star,
size: 14,
color: Colors.yellow,
),
),
],
],
),
),
trailing: uploadProgress == null && downloadProgress == null && widget.enableFileActions
? PopupMenuButton<FilesFileAction>(
itemBuilder: (final context) => [
if (details.isFavorite != null) ...[
PopupMenuItem(
value: FilesFileAction.toggleFavorite,
child: Text(
details.isFavorite!
? AppLocalizations.of(context).filesRemoveFromFavorites
: AppLocalizations.of(context).filesAddToFavorites,
),
),
],
PopupMenuItem(
value: FilesFileAction.details,
child: Text(AppLocalizations.of(context).filesDetails),
),
PopupMenuItem(
value: FilesFileAction.rename,
child: Text(AppLocalizations.of(context).rename),
),
PopupMenuItem(
value: FilesFileAction.move,
child: Text(AppLocalizations.of(context).move),
),
PopupMenuItem(
value: FilesFileAction.copy,
child: Text(AppLocalizations.of(context).copy),
),
// TODO: https://github.com/jld3103/nextcloud-neon/issues/4
if (!details.isDirectory) ...[
PopupMenuItem(
value: FilesFileAction.sync,
child: Text(AppLocalizations.of(context).filesSync),
),
],
PopupMenuItem(
value: FilesFileAction.delete,
child: Text(AppLocalizations.of(context).delete),
),
],
onSelected: (final action) async {
switch (action) {
case FilesFileAction.toggleFavorite:
if (details.isFavorite ?? false) {
widget.filesBloc.removeFavorite(details.path);
} else {
widget.filesBloc.addFavorite(details.path);
}
break;
case FilesFileAction.details:
await Navigator.of(context).push(
MaterialPageRoute(
builder: (final context) => FilesDetailsPage(
bloc: widget.filesBloc,
details: details,
),
),
);
break;
case FilesFileAction.rename:
final result = await showRenameDialog(
context: context,
title: details.isDirectory
? AppLocalizations.of(context).filesRenameFolder
: AppLocalizations.of(context).filesRenameFile,
value: details.name,
);
if (result != null) {
widget.filesBloc.rename(details.path, result);
}
break;
case FilesFileAction.move:
final b = widget.filesBloc.getNewFilesBrowserBloc();
final originalPath = details.path.sublist(0, details.path.length - 1);
b.setPath(originalPath);
final result = await showDialog<List<String>?>(
context: context,
builder: (final context) => FilesChooseFolderDialog(
bloc: b,
filesBloc: widget.filesBloc,
originalPath: originalPath,
),
);
b.dispose();
if (result != null) {
widget.filesBloc.move(details.path, result..add(details.name));
}
break;
case FilesFileAction.copy:
final b = widget.filesBloc.getNewFilesBrowserBloc();
final originalPath = details.path.sublist(0, details.path.length - 1);
b.setPath(originalPath);
final result = await showDialog<List<String>?>(
context: context,
builder: (final context) => FilesChooseFolderDialog(
bloc: b,
filesBloc: widget.filesBloc,
originalPath: originalPath,
),
);
b.dispose();
if (result != null) {
widget.filesBloc.copy(details.path, result..add(details.name));
}
break;
case FilesFileAction.sync:
final sizeWarning = widget.bloc.options.downloadSizeWarning.value;
if (sizeWarning != null && details.size > sizeWarning) {
if (!(await showConfirmationDialog(
context,
AppLocalizations.of(context).filesConfirmDownloadSizeWarning(
filesize(sizeWarning),
filesize(details.size),
),
))) {
return;
}
}
widget.filesBloc.syncFile(details.path);
break;
case FilesFileAction.delete:
if (await showConfirmationDialog(
context,
details.isDirectory
? AppLocalizations.of(context).filesDeleteFolderConfirm(details.name)
: AppLocalizations.of(context).filesDeleteFileConfirm(details.name),
)) {
widget.filesBloc.delete(details.path);
}
break;
}
},
)
: const SizedBox(
width: 48,
height: 48,
),
);
}
enum FilesFileAction {

2
packages/neon/lib/src/apps/files/widgets/file_preview.dart

@ -16,7 +16,7 @@ class FilePreview extends StatelessWidget {
);
final FilesBloc bloc;
final FileDetails details;
final FilesFileDetails details;
final int width;
final int height;
final Color? color;

95
packages/neon/lib/src/apps/files/widgets/file_tile.dart

@ -0,0 +1,95 @@
part of '../app.dart';
class FilesFileTile extends StatelessWidget {
const FilesFileTile({
required this.filesBloc,
required this.details,
this.titleOverride,
this.trailing,
this.visualDensity,
this.onTap,
this.uploadProgress,
this.downloadProgress,
super.key,
});
final FilesBloc filesBloc;
final FilesFileDetails details;
final Widget? titleOverride;
final Widget? trailing;
final VisualDensity? visualDensity;
final Function(FilesFileDetails)? onTap;
final int? uploadProgress;
final int? downloadProgress;
@override
Widget build(final BuildContext context) => ListTile(
visualDensity: visualDensity,
onTap: () {
onTap?.call(details);
},
title: titleOverride ??
Text(
details.name,
overflow: TextOverflow.ellipsis,
),
subtitle: Row(
children: [
Text(CustomTimeAgo.format(details.lastModified)),
if (details.size > 0) ...[
const SizedBox(
width: 10,
),
Flexible(
child: Text(
filesize(details.size, 1),
style: DefaultTextStyle.of(context).style.copyWith(
color: Colors.grey,
),
overflow: TextOverflow.ellipsis,
),
),
],
],
),
leading: SizedBox(
height: 40,
width: 40,
child: Stack(
children: [
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) ...[
const Align(
alignment: Alignment.bottomRight,
child: Icon(
Icons.star,
size: 14,
color: Colors.yellow,
),
),
],
],
),
),
trailing: trailing,
);
}

38
packages/neon/lib/src/apps/files/widgets/sync_status_icon.dart

@ -0,0 +1,38 @@
part of '../app.dart';
class FilesSyncStatusIcon extends StatelessWidget {
const FilesSyncStatusIcon({
required this.status,
this.size,
super.key,
});
final bool? status;
final double? size;
@override
Widget build(final BuildContext context) {
// Status unknown
if (status == null) {
return Icon(
Icons.cloud_off,
color: Colors.red,
size: size,
);
}
// Partially synced
if (!status!) {
return Icon(
Icons.cloud_queue,
color: Colors.orange,
size: size,
);
}
// Completely sync
return Icon(
Icons.cloud_done,
color: Colors.green,
size: size,
);
}
}

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

@ -131,6 +131,9 @@ class AppsBloc extends $AppsBloc {
return bloc as T;
}
T getAppBlocByID<T extends RxBlocBase>(final String id) =>
getAppBloc(_appImplementationsSubject.value.data!.singleWhere((final app) => app.id == id));
@override
void dispose() {
unawaited(_appsSubject.close());

8
packages/neon/lib/src/blocs/push_notifications.dart

@ -46,13 +46,7 @@ class PushNotificationsBloc extends $PushNotificationsBloc {
Future _setupUnifiedPush() async {
await UnifiedPush.initialize(
onNewEndpoint: (final endpoint, final instance) async {
Account? account;
for (final a in _accountsBloc.accounts.value) {
if (a.id == instance) {
account = a;
break;
}
}
final account = _accountsBloc.accounts.value.find(instance);
if (account == null) {
debugPrint('Account for $instance not found, can not process endpoint');
return;

12
packages/neon/lib/src/models/account.dart

@ -69,6 +69,18 @@ class Account {
}
}
extension AccountsFind on List<Account> {
Account? find(final String id) {
for (final account in this) {
if (account.id == id) {
return account;
}
}
return null;
}
}
Map<String, String> _idCache = {};
extension NextcloudClientHelpers on NextcloudClient {

6
packages/neon/lib/src/neon.dart

@ -21,6 +21,9 @@ import 'package:intl/intl_standalone.dart';
import 'package:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:neon/l10n/localizations.dart';
import 'package:neon/src/apps/files/app.dart';
import 'package:neon/src/apps/files/blocs/files.dart';
import 'package:neon/src/apps/files/blocs/sync.dart';
import 'package:neon/src/apps/files/models/sync_mapping.dart';
import 'package:neon/src/apps/news/app.dart';
import 'package:neon/src/apps/notes/app.dart';
import 'package:neon/src/apps/notifications/app.dart';
@ -53,6 +56,7 @@ import 'package:window_manager/window_manager.dart';
import 'package:xdg_directories/xdg_directories.dart' as xdg;
part 'pages/home/home.dart';
part 'pages/home/widgets/files_sync_listener.dart';
part 'pages/home/widgets/server_status.dart';
part 'pages/login/login.dart';
part 'pages/settings/account_specific_settings.dart';
@ -68,6 +72,7 @@ part 'utils/app_implementation.dart';
part 'utils/confirmation_dialog.dart';
part 'utils/custom_timeago.dart';
part 'utils/env.dart';
part 'utils/file_utils.dart';
part 'utils/global.dart';
part 'utils/global_options.dart';
part 'utils/hex_color.dart';
@ -76,7 +81,6 @@ part 'utils/nextcloud_app_specific_options.dart';
part 'utils/push_utils.dart';
part 'utils/rename_dialog.dart';
part 'utils/request_manager.dart';
part 'utils/save_file.dart';
part 'utils/settings_export_helper.dart';
part 'utils/sort_box_builder.dart';
part 'utils/sort_box_order_option_values.dart';

8
packages/neon/lib/src/pages/home/home.dart

@ -442,6 +442,7 @@ class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListe
builder: (final context) => AccountSpecificSettingsPage(
bloc: accountsBloc,
account: account,
appsBloc: _appsBloc,
),
),
);
@ -589,7 +590,9 @@ class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListe
onTap: () async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (final context) => const SettingsPage(),
builder: (final context) => SettingsPage(
appsBloc: _appsBloc,
),
),
);
},
@ -599,6 +602,9 @@ class _HomePageState extends State<HomePage> with tray.TrayListener, WindowListe
),
body: Column(
children: [
FilesSyncListener(
appsBloc: _appsBloc,
),
ServerStatus(
account: widget.account,
),

54
packages/neon/lib/src/pages/home/widgets/files_sync_listener.dart

@ -0,0 +1,54 @@
part of '../../../neon.dart';
class FilesSyncListener extends StatefulWidget {
const FilesSyncListener({
required this.appsBloc,
super.key,
});
final AppsBloc appsBloc;
@override
State<FilesSyncListener> createState() => _FilesSyncListenerState();
}
class _FilesSyncListenerState extends State<FilesSyncListener> {
@override
void initState() {
super.initState();
final filesSyncBloc = RxBlocProvider.of<FilesSyncBloc>(context);
filesSyncBloc.errors.listen((final error) {
ExceptionWidget.showSnackbar(context, error);
});
filesSyncBloc.conflicts.listen((final conflicts) async {
if (mounted) {
final result = await showDialog(
context: context,
builder: (final context) => FilesSyncConflictDialog(
bloc: widget.appsBloc.getAppBlocByID('files'),
conflicts: conflicts,
),
);
if (result == null) {
return;
}
if (result is Map<String, SyncConflictSolution>) {
filesSyncBloc.syncMapping(conflicts.mapping, result);
} else if (result is SyncConflictSolution) {
filesSyncBloc.syncMapping(
conflicts.mapping,
{
for (final c in conflicts.conflicts) ...{
c.id: result,
},
},
);
}
}
});
}
@override
Widget build(final BuildContext context) => Container();
}

192
packages/neon/lib/src/pages/settings/account_specific_settings.dart

@ -1,18 +1,34 @@
part of '../../neon.dart';
class AccountSpecificSettingsPage extends StatelessWidget {
AccountSpecificSettingsPage({
class AccountSpecificSettingsPage extends StatefulWidget {
const AccountSpecificSettingsPage({
required this.bloc,
required this.account,
required this.appsBloc,
super.key,
});
final AccountsBloc bloc;
final Account account;
final AppsBloc appsBloc;
late final _options = bloc.getOptions(account)!;
late final _userDetailsBloc = bloc.getUserDetailsBloc(account);
late final _name = account.client.humanReadableID;
@override
State<AccountSpecificSettingsPage> createState() => _AccountSpecificSettingsPageState();
}
class _AccountSpecificSettingsPageState extends State<AccountSpecificSettingsPage> {
late final _options = widget.bloc.getOptions(widget.account)!;
late final _userDetailsBloc = widget.bloc.getUserDetailsBloc(widget.account);
late final _name = widget.account.client.humanReadableID;
late final FilesSyncBloc _filesSyncBloc;
@override
void initState() {
super.initState();
_filesSyncBloc = RxBlocProvider.of<FilesSyncBloc>(context);
}
@override
Widget build(final BuildContext context) => Scaffold(
@ -24,9 +40,9 @@ class AccountSpecificSettingsPage extends StatelessWidget {
onPressed: () async {
if (await showConfirmationDialog(
context,
AppLocalizations.of(context).accountOptionsRemoveConfirm(account.client.humanReadableID),
AppLocalizations.of(context).accountOptionsRemoveConfirm(widget.account.client.humanReadableID),
)) {
bloc.removeAccount(account);
widget.bloc.removeAccount(widget.account);
Navigator.of(context).pop();
}
},
@ -45,58 +61,130 @@ class AccountSpecificSettingsPage extends StatelessWidget {
),
],
),
body: StandardRxResultBuilder<UserDetailsBloc, ProvisioningApiUserDetails>(
bloc: _userDetailsBloc,
state: (final bloc) => bloc.userDetails,
builder: (final context, final userDetailsData, final userDetailsError, final userDetailsLoading, final _) =>
SettingsList(
categories: [
SettingsCategory(
title: Text(AppLocalizations.of(context).accountOptionsCategoryStorageInfo),
tiles: [
CustomSettingsTile(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (userDetailsData != null) ...[
LinearProgressIndicator(
value: userDetailsData.quota!.relative! / 100,
backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
const SizedBox(
height: 10,
),
Text(
AppLocalizations.of(context).accountOptionsQuotaUsedOf(
filesize(userDetailsData.quota!.used!, 1),
filesize(userDetailsData.quota!.total!, 1),
userDetailsData.quota!.relative!.toString(),
body: StreamBuilder<Map<FilesSyncMapping, bool?>>(
stream: _filesSyncBloc.mappingStatuses,
builder: (final context, final mappingsSnapshot) =>
StandardRxResultBuilder<UserDetailsBloc, ProvisioningApiUserDetails>(
bloc: _userDetailsBloc,
state: (final bloc) => bloc.userDetails,
builder:
(final context, final userDetailsData, final userDetailsError, final userDetailsLoading, final _) =>
SettingsList(
categories: [
SettingsCategory(
title: Text(AppLocalizations.of(context).accountOptionsCategoryStorageInfo),
tiles: [
CustomSettingsTile(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (userDetailsData != null) ...[
LinearProgressIndicator(
value: userDetailsData.quota!.relative! / 100,
backgroundColor: Theme.of(context).colorScheme.primary.withOpacity(0.3),
),
const SizedBox(
height: 10,
),
Text(
AppLocalizations.of(context).accountOptionsQuotaUsedOf(
filesize(userDetailsData.quota!.used!, 1),
filesize(userDetailsData.quota!.total!, 1),
userDetailsData.quota!.relative!.toString(),
),
),
],
ExceptionWidget(
userDetailsError,
onRetry: () {
_userDetailsBloc.refresh();
},
),
CustomLinearProgressIndicator(
visible: userDetailsLoading,
),
],
ExceptionWidget(
userDetailsError,
onRetry: () {
_userDetailsBloc.refresh();
),
),
],
),
SettingsCategory(
title: Text(AppLocalizations.of(context).optionsCategoryGeneral),
tiles: [
DropdownButtonSettingsTile(
option: _options.initialApp,
),
],
),
SettingsCategory(
title: Text(AppLocalizations.of(context).filesSyncMappings),
tiles: [
for (final entry in (mappingsSnapshot.data ?? <FilesSyncMapping, bool?>{}).entries) ...[
if (entry.key.accountId == widget.account.id) ...[
CustomSettingsTile(
title: Text(entry.key.localPath),
subtitle: Text(entry.key.remotePath.join('/')),
leading: FilesSyncStatusIcon(
status: entry.value,
size: 40,
),
trailing: IconButton(
icon: const Icon(Icons.sync),
onPressed: () {
_filesSyncBloc.syncMapping(entry.key, {});
},
),
onLongPress: () async {
if (await showConfirmationDialog(
context,
AppLocalizations.of(context).filesSyncConfirmRemoveMapping,
)) {
_filesSyncBloc.removeMapping(entry.key);
}
},
),
CustomLinearProgressIndicator(
visible: userDetailsLoading,
),
],
],
CustomSettingsTile(
title: ElevatedButton.icon(
onPressed: () async {
final appImplementation = Provider.of<List<AppImplementation>>(context, listen: false)
.singleWhere((final a) => a.id == 'files');
final filesBloc = widget.appsBloc.getAppBloc<FilesBloc>(appImplementation);
final b = filesBloc.getNewFilesBrowserBloc();
final remotePath = await showDialog<List<String>?>(
context: context,
builder: (final context) => FilesChooseFolderDialog(
bloc: b,
filesBloc: filesBloc,
),
);
b.dispose();
if (remotePath == null) {
return;
}
final localPath = await FileUtils.pickDirectory();
if (localPath == null || !mounted) {
return;
}
_filesSyncBloc.addMapping(
FilesSyncMapping(
accountId: widget.account.id,
remotePath: remotePath,
localPath: localPath,
),
);
},
icon: const Icon(MdiIcons.folderPlus),
label: Text(AppLocalizations.of(context).filesSyncAddMapping),
),
),
),
],
),
SettingsCategory(
title: Text(AppLocalizations.of(context).optionsCategoryGeneral),
tiles: [
DropdownButtonSettingsTile(
option: _options.initialApp,
),
],
),
],
],
),
],
),
),
),
);

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

@ -2,9 +2,12 @@ part of '../../neon.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({
required this.appsBloc,
super.key,
});
final AppsBloc appsBloc;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
@ -185,6 +188,7 @@ class _SettingsPageState extends State<SettingsPage> {
builder: (final context) => AccountSpecificSettingsPage(
bloc: accountsBloc,
account: account,
appsBloc: widget.appsBloc,
),
),
);
@ -245,7 +249,7 @@ class _SettingsPageState extends State<SettingsPage> {
),
),
);
await saveFileWithPickDialog(fileName, Uint8List.fromList(utf8.encode(data)));
await FileUtils.saveFileWithPickDialog(fileName, Uint8List.fromList(utf8.encode(data)));
} catch (e, s) {
debugPrint(e.toString());
debugPrintStack(stackTrace: s);

3
packages/neon/lib/src/platform/abstract.dart

@ -10,7 +10,6 @@ abstract class NeonPlatform {
required this.canUseCamera,
required this.canUsePushNotifications,
required this.getApplicationCachePath,
required this.getUserAccessibleAppDataPath,
this.init,
});
@ -30,7 +29,5 @@ abstract class NeonPlatform {
final Future<String> Function() getApplicationCachePath;
final Future<String> Function() getUserAccessibleAppDataPath;
final Future Function()? init;
}

6
packages/neon/lib/src/platform/android.dart

@ -11,11 +11,5 @@ class AndroidNeonPlatform extends NeonPlatform {
canUseCamera: true,
canUsePushNotifications: true,
getApplicationCachePath: () async => (await getTemporaryDirectory()).absolute.path,
getUserAccessibleAppDataPath: () async {
if (!await Permission.storage.request().isGranted) {
throw MissingPermissionException(Permission.storage);
}
return p.join((await getExternalStorageDirectory())!.path);
},
);
}

1
packages/neon/lib/src/platform/linux.dart

@ -14,7 +14,6 @@ class LinuxNeonPlatform extends NeonPlatform {
xdg.cacheHome.absolute.path,
'de.provokateurin.neon',
),
getUserAccessibleAppDataPath: () async => p.join(Platform.environment['HOME']!, 'Neon'),
init: () async {
sqfliteFfiInit();
databaseFactory = databaseFactoryFfi;

40
packages/neon/lib/src/utils/file_utils.dart

@ -0,0 +1,40 @@
part of '../neon.dart';
class FileUtils {
static Future<String?> saveFileWithPickDialog(final String fileName, final Uint8List data) async {
if (Platform.isAndroid || Platform.isIOS) {
// TODO: https://github.com/jld3103/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();
}

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

@ -1,22 +0,0 @@
part of '../neon.dart';
Future<String?> saveFileWithPickDialog(final String fileName, final Uint8List data) async {
if (Platform.isAndroid || Platform.isIOS) {
// TODO: https://github.com/jld3103/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;
}
}

7
packages/neon/lib/src/utils/theme.dart

@ -124,6 +124,13 @@ ThemeData getThemeFromNextcloudTheme(
elevation: ButtonStyleButton.allOrNull(0),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
side: BorderSide(
color: primaryColor,
),
),
),
popupMenuTheme: PopupMenuThemeData(
color: canvasColor,
),

4
packages/neon/pubspec.lock

@ -252,7 +252,7 @@ packages:
source: hosted
version: "5.0.2"
flutter_driver:
dependency: "direct dev"
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
@ -1276,7 +1276,7 @@ packages:
source: hosted
version: "0.2.0"
watcher:
dependency: transitive
dependency: "direct main"
description:
name: watcher
url: "https://pub.dartlang.org"

3
packages/neon/pubspec.yaml

@ -56,6 +56,7 @@ dependencies:
unifiedpush: ^4.0.1
url_launcher: ^6.0.18
wakelock: ^0.6.1+2
watcher: ^1.0.1
webview_flutter: ^3.0.0
window_manager: ^0.2.5
xdg_directories: ^0.2.0+1
@ -65,8 +66,6 @@ dependency_overrides:
dev_dependencies:
build_runner: ^2.1.7
flutter_driver:
sdk: flutter
flutter_test:
sdk: flutter
integration_test:

2
packages/nextcloud/lib/nextcloud.dart

@ -1,5 +1,6 @@
library nextcloud;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
@ -13,6 +14,7 @@ import 'package:xml/xml.dart' as xml;
export 'package:crypton/crypton.dart' show RSAKeypair, RSAPublicKey, RSAPrivateKey;
export 'src/nextcloud.openapi.dart';
export 'src/sync/sync.dart';
part 'src/app_type.dart';
part 'src/client.dart';

40
packages/nextcloud/lib/src/sync/action.dart

@ -0,0 +1,40 @@
part of 'sync.dart';
/// Action to be executed in the sync process
abstract class SyncAction<T1, T2> {}
/// Action to delete object from A
class SyncActionDeleteFromA<T1, T2> extends SyncAction<T1, T2> {
// ignore: public_member_api_docs
SyncActionDeleteFromA(this.object);
// ignore: public_member_api_docs
final SyncObject<T1> object;
}
/// Action to delete object from B
class SyncActionDeleteFromB<T1, T2> extends SyncAction<T1, T2> {
// ignore: public_member_api_docs
SyncActionDeleteFromB(this.object);
// ignore: public_member_api_docs
final SyncObject<T2> object;
}
/// Action to write object to A
class SyncActionWriteToA<T1, T2> extends SyncAction<T1, T2> {
// ignore: public_member_api_docs
SyncActionWriteToA(this.object);
// ignore: public_member_api_docs
final SyncObject<T2> object;
}
/// Action to write object to B
class SyncActionWriteToB<T1, T2> extends SyncAction<T1, T2> {
// ignore: public_member_api_docs
SyncActionWriteToB(this.object);
// ignore: public_member_api_docs
final SyncObject<T1> object;
}

45
packages/nextcloud/lib/src/sync/conflict.dart

@ -0,0 +1,45 @@
part of 'sync.dart';
/// Contains information about a conflict that appeared during sync.
class SyncConflict<T1, T2> {
// ignore: public_member_api_docs
SyncConflict({
required this.id,
required this.type,
required this.objectA,
required this.objectB,
});
/// 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;
}
/// 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,
}

30
packages/nextcloud/lib/src/sync/object.dart

@ -0,0 +1,30 @@
part of 'sync.dart';
/// Wraps the actual data contained on each side.
class SyncObject<T> {
// ignore: public_member_api_docs
SyncObject(
this.id,
this.data,
);
/// Id of the object.
final String id;
/// Actual data of the object, can be anything.
final T data;
}
// ignore: public_member_api_docs
extension SyncObjectsFind<T> on List<SyncObject<T>> {
// ignore: public_member_api_docs
SyncObject<T>? find(final String id) {
for (final object in this) {
if (object.id == id) {
return object;
}
}
return null;
}
}

38
packages/nextcloud/lib/src/sync/sources.dart

@ -0,0 +1,38 @@
part of 'sync.dart';
/// The sources the sync uses to sync from and to.
abstract class SyncSources<T1, T2> {
/// List all the objects of type [T1] of the source.
Future<List<SyncObject<T1>>> listObjectsA();
/// List all the objects of type [T2] of the source.
Future<List<SyncObject<T2>>> listObjectsB();
/// Calculates the ETag of a given [object] of type [T1].
///
/// Should 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
Future<String> getObjectETagA(final SyncObject<T1> object);
/// Calculates the ETag of a given [object] of type [T2].
///
/// Should 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
Future<String> getObjectETagB(final SyncObject<T2> object);
/// Writes the given [object] of type [T1] to the source.
Future<SyncObject<T2>> writeObjectA(final SyncObject<T1> object);
/// Writes the given [object] of type [T2] to the source.
Future<SyncObject<T1>> writeObjectB(final SyncObject<T2> object);
/// Deletes the given [object] of type [T1] from the source.
Future deleteObjectA(final SyncObject<T1> object);
/// Deletes the given [object] of type [T2] from the source.
Future deleteObjectB(final SyncObject<T2> object);
/// Sorts the actions before executing them. Useful e.g. for creating directories before creating files and deleting files before deleting directories.
List<SyncAction<T1, T2>> sortActions(final List<SyncAction<T1, T2>> actions);
/// Automatically find a solution for conflicts that don't matter. Useful e.g. for ignoring new directories.
SyncConflictSolution? findSolution(final SyncConflict<T1, T2> conflict);
}

167
packages/nextcloud/lib/src/sync/sources/webdav_io_sources.dart

@ -0,0 +1,167 @@
part of '../sync.dart';
/// [SyncSources] to sync from [WebDavFile]s to [FileSystemEntity]s
class WebDavIOSyncSources extends SyncSources<WebDavFile, FileSystemEntity> {
// ignore: public_member_api_docs
WebDavIOSyncSources(
this.client,
this.webdavBaseDir,
this.ioBaseDir, {
this.extraProps = const [],
});
/// [NextcloudClient] used by the WebDAV part.
final NextcloudClient client;
/// Base directory on the WebDAV server.
final List<String> webdavBaseDir;
/// Base directory on the local filesystem.
final String ioBaseDir;
/// Extra props to request from the WebDAV server
final List<WebDavProps> extraProps;
String _path(final SyncObject object) => [...webdavBaseDir, object.id].join('/');
@override
Future<List<SyncObject<WebDavFile>>> listObjectsA() async => (await client.webdav.ls(
webdavBaseDir.join('/'),
props: [
WebDavProps.davResourceType,
WebDavProps.davETag,
...extraProps,
].map((final p) => p.name).toSet(),
depth: 'infinity',
))
.map(
(final file) {
var id = file.path;
if (webdavBaseDir.isNotEmpty) {
id = id.replaceFirst('/${webdavBaseDir.join('/')}', '');
}
id = id.replaceFirst('/', '');
if (id.endsWith('/')) {
id = id.substring(0, id.length - 1);
}
return SyncObject<WebDavFile>(
id,
file,
);
},
).toList();
@override
Future<List<SyncObject<FileSystemEntity>>> listObjectsB() async => Directory(ioBaseDir)
.listSync(recursive: true)
.map(
(final e) => SyncObject<FileSystemEntity>(
p.relative(
e.path,
from: ioBaseDir,
),
e,
),
)
.toList();
@override
Future<String> getObjectETagA(final SyncObject<WebDavFile> object) async =>
object.data.isDirectory ? '' : object.data.etag!;
@override
Future<String> getObjectETagB(final SyncObject<FileSystemEntity> object) async =>
object.data is Directory ? '' : object.data.statSync().modified.millisecondsSinceEpoch.toString();
@override
Future<SyncObject<FileSystemEntity>> writeObjectA(final SyncObject<WebDavFile> object) async {
var path = object.data.path;
if (path.startsWith('/')) {
path = path.substring(1);
}
if (object.data.isDirectory) {
final dir = Directory(p.join(ioBaseDir, object.id))..createSync();
return SyncObject<FileSystemEntity>(object.id, dir);
} else {
final file = File(p.join(ioBaseDir, object.id));
await client.webdav.downloadFile(path, file);
return SyncObject<FileSystemEntity>(object.id, file);
}
}
@override
Future<SyncObject<WebDavFile>> writeObjectB(final SyncObject<FileSystemEntity> object) async {
if (object.data is File) {
await client.webdav.uploadFile(
object.data as File,
object.data.statSync(),
_path(object),
);
} else if (object.data is Directory) {
await client.webdav.mkdirs(_path(object));
} else {
print('Unable to sync FileSystemEntity of type ${object.data.runtimeType}');
}
return SyncObject<WebDavFile>(object.id, await client.webdav.getProps(_path(object)));
}
@override
Future deleteObjectA(final SyncObject<WebDavFile> object) async => client.webdav.delete(_path(object));
@override
Future deleteObjectB(final SyncObject<FileSystemEntity> object) async => object.data.delete();
@override
List<SyncAction<WebDavFile, FileSystemEntity>> sortActions(
final List<SyncAction<WebDavFile, FileSystemEntity>> actions,
) {
final addActions = <SyncAction<WebDavFile, FileSystemEntity>>[];
final removeActions = <SyncAction<WebDavFile, FileSystemEntity>>[];
for (final action in actions) {
if (action is SyncActionWriteToA) {
addActions.add(action);
} else if (action is SyncActionWriteToB) {
addActions.add(action);
} else if (action is SyncActionDeleteFromA) {
removeActions.add(action);
} else if (action is SyncActionDeleteFromB) {
removeActions.add(action);
} else {
throw Exception('illegal action for sorting');
}
}
return [
..._innerSortActions(addActions),
..._innerSortActions(removeActions).reversed,
];
}
List<SyncAction<WebDavFile, FileSystemEntity>> _innerSortActions(
final List<SyncAction<WebDavFile, FileSystemEntity>> actions,
) =>
actions..sort((final a, final b) => _idForAction(a).compareTo(_idForAction(b)));
String _idForAction(final SyncAction<WebDavFile, FileSystemEntity> action) {
if (action is SyncActionWriteToA<WebDavFile, FileSystemEntity>) {
return action.object.id;
} else if (action is SyncActionWriteToB<WebDavFile, FileSystemEntity>) {
return action.object.id;
} else if (action is SyncActionDeleteFromA<WebDavFile, FileSystemEntity>) {
return action.object.id;
} else if (action is SyncActionDeleteFromB<WebDavFile, FileSystemEntity>) {
return action.object.id;
} else {
throw Exception('illegal action for getting id');
}
}
@override
SyncConflictSolution? findSolution(final SyncConflict<WebDavFile, FileSystemEntity> conflict) {
if (conflict.objectA.data.isDirectory && conflict.objectB.data is Directory) {
return SyncConflictSolution.overwriteA;
}
return null;
}
}

54
packages/nextcloud/lib/src/sync/status.dart

@ -0,0 +1,54 @@
part of 'sync.dart';
/// Contains the local state of the whole synced.
///
/// Used for detecting changes and new or deleted files.
class SyncStatus {
// ignore: public_member_api_docs
SyncStatus(this._entries);
final List<SyncStatusEntry> _entries;
/// All entries contained in the status.
List<SyncStatusEntry> get entries => _entries;
/// Update an [entry].
void updateEntry(final SyncStatusEntry entry) {
for (var i = 0; i < _entries.length; i++) {
if (_entries[i].id == entry.id) {
_entries[i] = entry;
return;
}
}
_entries.add(entry);
}
/// Remove an entry by it's [id].
void removeEntry(final String id) =>
_entries.replaceRange(0, _entries.length, _entries.where((final entry) => entry.id != id));
}
// ignore: public_member_api_docs
SyncStatus syncStatusFromJson(final List data) => SyncStatus(
data.map(
(final a) {
final b = a as Map<String, dynamic>;
return SyncStatusEntry(
b['id'] as String,
b['etagA'] as String,
b['etagB'] as String,
);
},
).toList(),
);
// ignore: public_member_api_docs
List syncStatusToJson(final SyncStatus status) => status.entries
.map(
(final e) => {
'id': e.id,
'etagA': e.etagA,
'etagB': e.etagB,
},
)
.toList();

39
packages/nextcloud/lib/src/sync/status_entry.dart

@ -0,0 +1,39 @@
part of 'sync.dart';
/// Stores a single entry in the [SyncStatus].
///
/// It contains an [id] and ETags for each object, [etagA] and [etagB] respectively.
class SyncStatusEntry {
// ignore: public_member_api_docs
SyncStatusEntry(
this.id,
this.etagA,
this.etagB,
);
// ignore: public_member_api_docs
final String id;
/// ETag of the object A.
final String etagA;
/// ETag of the object B.
final String etagB;
@override
String toString() => 'SyncStatusEntry(id: $id, etagA: $etagA, etagB: $etagB)';
}
// ignore: public_member_api_docs
extension SyncStatusEntriesFind on List<SyncStatusEntry> {
// ignore: public_member_api_docs
SyncStatusEntry? find(final String id) {
for (final entry in this) {
if (entry.id == id) {
return entry;
}
}
return null;
}
}

229
packages/nextcloud/lib/src/sync/sync.dart

@ -0,0 +1,229 @@
library syncer;
import 'dart:io';
import 'package:nextcloud/nextcloud.dart';
import 'package:path/path.dart' as p;
part 'action.dart';
part 'conflict.dart';
part 'object.dart';
part 'sources.dart';
part 'sources/webdav_io_sources.dart';
part 'status.dart';
part 'status_entry.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 SyncStatus syncStatus,
final Map<String, SyncConflictSolution> conflictSolutions,
) async =>
executeSyncDiff<T1, T2>(
sources,
syncStatus,
await computeSyncDiff<T1, T2>(
sources,
syncStatus,
conflictSolutions,
),
);
/// Differences between the two sources
class SyncDiff<T1, T2> {
// ignore: public_member_api_docs
SyncDiff(
this.actions,
this.conflicts,
);
/// Actions required to solve the difference
final List<SyncAction<T1, T2>> actions;
/// Conflicts without solutions that need to be solved
final List<SyncConflict<T1, T2>> conflicts;
}
/// Executes the actions required to solve the difference
Future<List<SyncConflict<T1, T2>>> executeSyncDiff<T1, T2>(
final SyncSources<T1, T2> sources,
final SyncStatus syncStatus,
final SyncDiff<T1, T2> sync,
) async {
for (final action in sync.actions) {
if (action is SyncActionDeleteFromA<T1, T2>) {
await sources.deleteObjectA(action.object);
syncStatus.removeEntry(action.object.id);
} else if (action is SyncActionDeleteFromB<T1, T2>) {
await sources.deleteObjectB(action.object);
syncStatus.removeEntry(action.object.id);
} else if (action is SyncActionWriteToA<T1, T2>) {
final objectA = await sources.writeObjectB(action.object);
syncStatus.updateEntry(
SyncStatusEntry(
action.object.id,
await sources.getObjectETagA(objectA),
await sources.getObjectETagB(action.object),
),
);
} else if (action is SyncActionWriteToB<T1, T2>) {
final objectB = await sources.writeObjectA(action.object);
syncStatus.updateEntry(
SyncStatusEntry(
action.object.id,
await sources.getObjectETagA(action.object),
await sources.getObjectETagB(objectB),
),
);
}
}
return sync.conflicts;
}
/// 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 SyncStatus syncStatus,
final Map<String, SyncConflictSolution> conflictSolutions,
) async {
final actions = <SyncAction<T1, T2>>[];
var conflicts = <SyncConflict<T1, T2>>[];
var objectsA = await sources.listObjectsA();
var objectsB = await sources.listObjectsB();
for (final objectA in objectsA) {
final objectB = objectsB.find(objectA.id);
final statusEntry = syncStatus.entries.find(objectA.id);
// If the ID exists on side A and the status, but not on B, it has been deleted on B. Delete it from A and the status.
if (statusEntry != null && objectB == null) {
actions.add(SyncActionDeleteFromA(objectA));
continue;
}
// If the ID exists on side A and side B, but not in status, we can not just create it in status, since the two items might contain different content each.
if (objectB != null && statusEntry == null) {
conflicts.add(
SyncConflict(
id: objectA.id,
type: SyncConflictType.bothNew,
objectA: objectA,
objectB: objectB,
),
);
continue;
}
// If the ID exists on side A, but not on B or the status, it must have been created on A. Copy the item from A to B and also insert it into status.
if (objectB == null || statusEntry == null) {
actions.add(SyncActionWriteToB(objectA));
continue;
}
}
for (final objectB in objectsB) {
final objectA = objectsA.find(objectB.id);
final statusEntry = syncStatus.entries.find(objectB.id);
// If the ID exists on side B and the status, but not on A, it has been deleted on A. Delete it from B and the status.
if (statusEntry != null && objectA == null) {
actions.add(SyncActionDeleteFromB(objectB));
continue;
}
// If the ID exists on side B and side A, but not in status, we can not just create it in status, since the two items might contain different content each.
if (objectA != null && statusEntry == null) {
conflicts.add(
SyncConflict(
id: objectA.id,
type: SyncConflictType.bothNew,
objectA: objectA,
objectB: objectB,
),
);
continue;
}
// If the ID exists on side B, but not on A or the status, it must have been created on B. Copy the item from B to A and also insert it into status.
if (objectA == null || statusEntry == null) {
actions.add(SyncActionWriteToA(objectB));
continue;
}
}
objectsA = await sources.listObjectsA();
objectsB = await sources.listObjectsB();
final entries = syncStatus.entries.toList();
for (final entry in entries) {
final objectA = objectsA.find(entry.id);
final objectB = objectsB.find(entry.id);
// Remove all entries from status that don't exist anymore
if (objectA == null && objectB == null) {
syncStatus.removeEntry(entry.id);
continue;
}
if (objectA != null && objectB != null) {
final changedA = entry.etagA != await sources.getObjectETagA(objectA);
final changedB = entry.etagB != await sources.getObjectETagB(objectB);
if (changedA && changedB) {
conflicts.add(
SyncConflict(
id: objectA.id,
type: SyncConflictType.bothChanged,
objectA: objectA,
objectB: objectB,
),
);
continue;
}
if (changedA && !changedB) {
actions.add(SyncActionWriteToB(objectA));
continue;
}
if (changedB && !changedA) {
actions.add(SyncActionWriteToA(objectB));
continue;
}
}
}
// Set of conflicts by id
conflicts = conflicts
.map((final conflict) => conflict.id)
.toSet()
.map((final id) => conflicts.firstWhere((final conflict) => conflict.id == id))
.toList();
final unsolvedConflicts = <SyncConflict<T1, T2>>[];
for (final conflict in conflicts) {
final solution = conflictSolutions[conflict.id] ?? sources.findSolution(conflict);
if (solution != null) {
switch (solution) {
case SyncConflictSolution.overwriteA:
actions.add(SyncActionWriteToA(conflict.objectB));
break;
case SyncConflictSolution.overwriteB:
actions.add(SyncActionWriteToB(conflict.objectA));
break;
case SyncConflictSolution.skip:
break;
}
} else {
unsolvedConflicts.add(conflict);
}
}
return SyncDiff(
sources.sortActions(actions),
unsolvedConflicts,
);
}

59
packages/nextcloud/lib/src/webdav/client.dart

@ -190,15 +190,27 @@ class WebDavClient {
data: localData,
);
/// Stream the content from [file] to [remotePath]
Future uploadFile(
final File file,
final FileStat fileStat,
final String remotePath, {
final Function(double progres)? onProgress,
}) async {
var uploaded = 0;
await uploadStream(
file.openRead().map((final chunk) {
uploaded += chunk.length;
onProgress?.call(uploaded / fileStat.size * 100);
return Uint8List.fromList(chunk);
}),
remotePath,
);
}
/// download [remotePath] and store the response file contents to String
Future<Uint8List> download(final String remotePath) async => Uint8List.fromList(
(await (await _send(
'GET',
_constructPath(remotePath),
[200],
))
.join())
.codeUnits,
await (await downloadStream(remotePath)).bodyBytes,
);
/// download [remotePath] and store the response file contents to ByteStream
@ -208,18 +220,51 @@ class WebDavClient {
[200],
);
/// download [remotePath] and stream the content into [file]
Future downloadFile(
final String remotePath,
final File file, {
final Function(double progress)? onProgress,
}) async {
final sink = file.openWrite();
final response = await downloadStream(remotePath);
if (response.contentLength > 0) {
final completer = Completer();
var downloaded = 0;
response.listen((final chunk) async {
sink.add(chunk);
downloaded += chunk.length;
onProgress?.call(downloaded / response.contentLength * 100);
if (downloaded >= response.contentLength) {
completer.complete();
}
});
await completer.future;
}
await sink.close();
}
/// list the directories and files under given [remotePath].
///
/// Optionally populates the given [props] on the returned files.
/// [depth] can be '0', '1' or 'infinity'.
Future<List<WebDavFile>> ls(
final String remotePath, {
final Set<String>? props,
final String? depth,
}) async {
final response = await _send(
'PROPFIND',
_constructPath(remotePath),
[207, 301],
data: Stream.value(Uint8List.fromList(utf8.encode(_buildPropsRequest(props ?? {})))),
headers: {
if (depth != null) ...{
'Depth': depth,
},
},
);
if (response.statusCode == 301) {
return ls(response.headers['location']!.first);

1
packages/nextcloud/pubspec.yaml

@ -11,6 +11,7 @@ dependencies:
intl: ^0.17.0
json_annotation: ^4.6.0
meta: ^1.7.0
path: ^1.8.1
version: ^3.0.2
xml: ^6.1.0

469
packages/nextcloud/test/sync_test.dart

@ -0,0 +1,469 @@
import 'dart:convert';
import 'dart:math';
import 'package:crypto/crypto.dart';
import 'package:nextcloud/nextcloud.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 TestSyncSources extends SyncSources<WrapA, WrapB> {
TestSyncSources(
this.stateA,
this.stateB,
);
final Map<String, WrapA> stateA;
final Map<String, WrapB> stateB;
@override
Future<List<SyncObject<WrapA>>> listObjectsA() async =>
stateA.keys.map((final key) => SyncObject<WrapA>(key, stateA[key]!)).toList();
@override
Future<List<SyncObject<WrapB>>> listObjectsB() async =>
stateB.keys.map((final key) => SyncObject<WrapB>(key, stateB[key]!)).toList();
@override
Future<String> getObjectETagA(final SyncObject<WrapA> object) async => etagA(object.data.content);
@override
Future<String> getObjectETagB(final SyncObject<WrapB> object) async => etagB(object.data.content);
@override
Future<SyncObject<WrapB>> writeObjectA(final SyncObject<WrapA> object) async {
final wrap = WrapB(object.data.content);
stateB[object.id] = wrap;
return SyncObject<WrapB>(object.id, wrap);
}
@override
Future<SyncObject<WrapA>> writeObjectB(final SyncObject<WrapB> object) async {
final wrap = WrapA(object.data.content);
stateA[object.id] = wrap;
return SyncObject<WrapA>(object.id, wrap);
}
@override
Future deleteObjectA(final SyncObject<WrapA> object) async => stateA.remove(object.id);
@override
Future deleteObjectB(final SyncObject<WrapB> object) async => stateB.remove(object.id);
@override
List<SyncAction<WrapA, WrapB>> sortActions(final List<SyncAction<WrapA, WrapB>> actions) => actions;
@override
SyncConflictSolution? findSolution(final SyncConflict<WrapA, WrapB> conflict) => 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 main() async {
group('sync', () {
group('stub', () {
test('all empty', () async {
final sources = TestSyncSources({}, {});
final status = SyncStatus([]);
final conflicts = await sync(sources, status, {});
expect(conflicts, isEmpty);
expect(sources.stateA, isEmpty);
expect(sources.stateB, isEmpty);
expect(status.entries, isEmpty);
});
group('copy', () {
group('missing', () {
test('to A', () async {
const id = '123';
const content = '456';
final sources = TestSyncSources(
{},
{
id: WrapB(content),
},
);
final status = SyncStatus([]);
final conflicts = await sync(sources, status, {});
expect(conflicts, isEmpty);
expect(sources.stateA, hasLength(1));
expect(sources.stateA[id]!.content, content);
expect(sources.stateB, hasLength(1));
expect(sources.stateB[id]!.content, content);
expect(status.entries, hasLength(1));
expect(status.entries.find(id)!.etagA, etagA(content));
expect(status.entries.find(id)!.etagB, etagB(content));
});
test('to B', () async {
const id = '123';
const content = '456';
final sources = TestSyncSources(
{
id: WrapA(content),
},
{},
);
final status = SyncStatus([]);
final conflicts = await sync(sources, status, {});
expect(conflicts, isEmpty);
expect(sources.stateA, hasLength(1));
expect(sources.stateA[id]!.content, content);
expect(sources.stateB, hasLength(1));
expect(sources.stateB[id]!.content, content);
expect(status.entries, hasLength(1));
expect(status.entries.find(id)!.etagA, etagA(content));
expect(status.entries.find(id)!.etagB, etagB(content));
});
});
group('changed', () {
test('to A', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final sources = TestSyncSources(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final status = SyncStatus([
SyncStatusEntry(id, etagA(contentA), randomEtag()),
]);
final conflicts = await sync(sources, status, {});
expect(conflicts, isEmpty);
expect(sources.stateA, hasLength(1));
expect(sources.stateA[id]!.content, contentB);
expect(sources.stateB, hasLength(1));
expect(sources.stateB[id]!.content, contentB);
expect(status.entries, hasLength(1));
expect(status.entries.find(id)!.etagA, etagA(contentB));
expect(status.entries.find(id)!.etagB, etagB(contentB));
});
test('to B', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final sources = TestSyncSources(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final status = SyncStatus([
SyncStatusEntry(id, randomEtag(), etagB(contentB)),
]);
final conflicts = await sync(sources, status, {});
expect(conflicts, isEmpty);
expect(sources.stateA, hasLength(1));
expect(sources.stateA[id]!.content, contentA);
expect(sources.stateB, hasLength(1));
expect(sources.stateB[id]!.content, contentA);
expect(status.entries, hasLength(1));
expect(status.entries.find(id)!.etagA, etagA(contentA));
expect(status.entries.find(id)!.etagB, etagB(contentA));
});
});
});
group('delete', () {
test('from A', () async {
const id = '123';
const content = '456';
final sources = TestSyncSources(
{
id: WrapA(content),
},
{},
);
final status = SyncStatus([
SyncStatusEntry(id, etagA(content), etagB(content)),
]);
final conflicts = await sync(sources, status, {});
expect(conflicts, isEmpty);
expect(sources.stateA, isEmpty);
expect(sources.stateB, isEmpty);
expect(status.entries, isEmpty);
});
test('from B', () async {
const id = '123';
const content = '456';
final sources = TestSyncSources(
{},
{
id: WrapB(content),
},
);
final status = SyncStatus([
SyncStatusEntry(id, etagA(content), etagB(content)),
]);
final conflicts = await sync(sources, status, {});
expect(conflicts, isEmpty);
expect(sources.stateA, isEmpty);
expect(sources.stateB, isEmpty);
expect(status.entries, isEmpty);
});
test('from status', () async {
const id = '123';
const content = '456';
final sources = TestSyncSources({}, {});
final status = SyncStatus([
SyncStatusEntry(id, etagA(content), etagB(content)),
]);
final conflicts = await sync(sources, status, {});
expect(conflicts, isEmpty);
expect(sources.stateA, isEmpty);
expect(sources.stateB, isEmpty);
expect(status.entries, isEmpty);
});
});
group('conflict', () {
test('status missing', () async {
const id = '123';
const content = '456';
final sources = TestSyncSources(
{
id: WrapA(content),
},
{
id: WrapB(content),
},
);
final status = SyncStatus([]);
final conflicts = await sync(sources, status, {});
expect(conflicts, hasLength(1));
expect(conflicts[0].type, SyncConflictType.bothNew);
expect(sources.stateA, hasLength(1));
expect(sources.stateA[id]!.content, content);
expect(sources.stateB, hasLength(1));
expect(sources.stateB[id]!.content, content);
expect(status.entries, isEmpty);
});
test('both changed', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final sources = TestSyncSources(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final status = SyncStatus([
SyncStatusEntry(id, randomEtag(), randomEtag()),
]);
final conflicts = await sync(sources, status, {});
expect(conflicts, hasLength(1));
expect(conflicts[0].type, SyncConflictType.bothChanged);
expect(sources.stateA, hasLength(1));
expect(sources.stateA[id]!.content, contentA);
expect(sources.stateB, hasLength(1));
expect(sources.stateB[id]!.content, contentB);
expect(status.entries, hasLength(1));
});
group('solution', () {
group('status missing', () {
test('skip', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final sources = TestSyncSources(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final status = SyncStatus([]);
final conflicts = await sync(sources, status, {
id: SyncConflictSolution.skip,
});
expect(conflicts, isEmpty);
expect(sources.stateA, hasLength(1));
expect(sources.stateA[id]!.content, contentA);
expect(sources.stateB, hasLength(1));
expect(sources.stateB[id]!.content, contentB);
expect(status.entries, isEmpty);
});
test('overwrite A', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final sources = TestSyncSources(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final status = SyncStatus([]);
final conflicts = await sync(sources, status, {
id: SyncConflictSolution.overwriteA,
});
expect(conflicts, isEmpty);
expect(sources.stateA, hasLength(1));
expect(sources.stateA[id]!.content, contentB);
expect(sources.stateB, hasLength(1));
expect(sources.stateB[id]!.content, contentB);
expect(status.entries, hasLength(1));
expect(status.entries.find(id)!.etagA, etagA(contentB));
expect(status.entries.find(id)!.etagB, etagB(contentB));
});
test('overwrite B', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final sources = TestSyncSources(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final status = SyncStatus([]);
final conflicts = await sync(sources, status, {
id: SyncConflictSolution.overwriteB,
});
expect(conflicts, isEmpty);
expect(sources.stateA, hasLength(1));
expect(sources.stateA[id]!.content, contentA);
expect(sources.stateB, hasLength(1));
expect(sources.stateB[id]!.content, contentA);
expect(status.entries, hasLength(1));
expect(status.entries.find(id)!.etagA, etagA(contentA));
expect(status.entries.find(id)!.etagB, etagB(contentA));
});
});
group('both changed', () {
test('skip', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final sources = TestSyncSources(
{
id: WrapA(contentA),
},
{
id: WrapB(contentB),
},
);
final status = SyncStatus([
SyncStatusEntry(id, randomEtag(), randomEtag()),
]);
final conflicts = await sync(sources, status, {
id: SyncConflictSolution.skip,
});
expect(conflicts, isEmpty);
expect(sources.stateA, hasLength(1));
expect(sources.stateA[id]!.content, contentA);
expect(sources.stateB, hasLength(1));
expect(sources.stateB[id]!.content, contentB);
expect(status.entries, hasLength(1));
});
test('overwrite A', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final sources = TestSyncSources({
id: WrapA(contentA),
}, {
id: WrapB(contentB),
});
final status = SyncStatus([
SyncStatusEntry(id, randomEtag(), randomEtag()),
]);
final conflicts = await sync(sources, status, {
id: SyncConflictSolution.overwriteA,
});
expect(conflicts, isEmpty);
expect(sources.stateA, hasLength(1));
expect(sources.stateA[id]!.content, contentB);
expect(sources.stateB, hasLength(1));
expect(sources.stateB[id]!.content, contentB);
expect(status.entries, hasLength(1));
expect(status.entries.find(id)!.etagA, etagA(contentB));
expect(status.entries.find(id)!.etagB, etagB(contentB));
});
test('overwrite B', () async {
const id = '123';
const contentA = '456';
const contentB = '789';
final sources = TestSyncSources({
id: WrapA(contentA),
}, {
id: WrapB(contentB),
});
final status = SyncStatus([
SyncStatusEntry(id, randomEtag(), randomEtag()),
]);
final conflicts = await sync(sources, status, {
id: SyncConflictSolution.overwriteB,
});
expect(conflicts, isEmpty);
expect(sources.stateA, hasLength(1));
expect(sources.stateA[id]!.content, contentA);
expect(sources.stateB, hasLength(1));
expect(sources.stateB[id]!.content, contentA);
expect(status.entries, hasLength(1));
expect(status.entries.find(id)!.etagA, etagA(contentA));
expect(status.entries.find(id)!.etagB, etagB(contentA));
});
});
});
});
});
});
}

8
packages/nextcloud/test/webdav_test.dart

@ -47,6 +47,14 @@ Future main() async {
expect(file.size!, 50598);
});
test('List directory recursively', () async {
final files = await client.webdav.ls(
'/',
depth: 'infinity',
);
expect(files, hasLength(35));
});
test('Create directory', () async {
final response = await client.webdav.mkdir('test');
expect(response.statusCode, equals(201));

3
packages/settings/lib/src/widgets/custom_settings_tile.dart

@ -7,6 +7,7 @@ class CustomSettingsTile extends SettingsTile {
this.leading,
this.trailing,
this.onTap,
this.onLongPress,
super.key,
});
@ -15,6 +16,7 @@ class CustomSettingsTile extends SettingsTile {
final Widget? leading;
final Widget? trailing;
final Function()? onTap;
final Function()? onLongPress;
@override
Widget build(final BuildContext context) => ListTile(
@ -23,5 +25,6 @@ class CustomSettingsTile extends SettingsTile {
leading: leading,
trailing: trailing,
onTap: onTap,
onLongPress: onLongPress,
);
}

Loading…
Cancel
Save