diff --git a/packages/neon/android/app/src/main/AndroidManifest.xml b/packages/neon/android/app/src/main/AndroidManifest.xml index b004a462..f9d05620 100644 --- a/packages/neon/android/app/src/main/AndroidManifest.xml +++ b/packages/neon/android/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ package="de.provokateurin.neon"> + 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 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 with WidgetsBindingObserver { } @override - Widget build(final BuildContext context) => StreamBuilder( - stream: _platformBrightness, - builder: (final context, final platformBrightnessSnapshot) => StreamBuilder( - stream: widget.globalOptions.themeMode.stream, - builder: (final context, final themeModeSnapshot) => StreamBuilder( - 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( + create: (final _) => _filesSyncBloc, + child: StreamBuilder( + stream: _platformBrightness, + builder: (final context, final platformBrightnessSnapshot) => StreamBuilder( + stream: widget.globalOptions.themeMode.stream, + builder: (final context, final themeModeSnapshot) => StreamBuilder( + 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(), + ); + }, + ), ), ), ); diff --git a/packages/neon/lib/l10n/en.arb b/packages/neon/lib/l10n/en.arb index 251c5bba..f95bcd5a 100644 --- a/packages/neon/lib/l10n/en.arb +++ b/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", diff --git a/packages/neon/lib/l10n/localizations.dart b/packages/neon/lib/l10n/localizations.dart index 24c1e57d..a2de5cda 100644 --- a/packages/neon/lib/l10n/localizations.dart +++ b/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: diff --git a/packages/neon/lib/l10n/localizations_en.dart b/packages/neon/lib/l10n/localizations_en.dart index d4497969..5e5255c8 100644 --- a/packages/neon/lib/l10n/localizations_en.dart +++ b/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'; diff --git a/packages/neon/lib/src/apps/files/app.dart b/packages/neon/lib/src/apps/files/app.dart index 480c3eb1..08a5bb5f 100644 --- a/packages/neon/lib/src/apps/files/app.dart +++ b/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 { FilesApp(super.sharedPreferences, super.requestManager, super.platform); diff --git a/packages/neon/lib/src/apps/files/blocs/files.dart b/packages/neon/lib/src/apps/files/blocs/files.dart index c643a7c4..caa2a105 100644 --- a/packages/neon/lib/src/apps/files/blocs/files.dart +++ b/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 path, final String localPath); - void syncFile(final List path); - void openFile(final List path, final String etag, final String? mimeType); void delete(final List 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 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; } } diff --git a/packages/neon/lib/src/apps/files/blocs/files.rxb.g.dart b/packages/neon/lib/src/apps/files/blocs/files.rxb.g.dart index 3cede1bf..24db75ba 100644 --- a/packages/neon/lib/src/apps/files/blocs/files.rxb.g.dart +++ b/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>(); - /// Т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 path) => _$syncFileEvent.add(path); - @override void openFile( List 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(); diff --git a/packages/neon/lib/src/apps/files/blocs/sync.dart b/packages/neon/lib/src/apps/files/blocs/sync.dart new file mode 100644 index 00000000..43f98b27 --- /dev/null +++ b/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 solutions); +} + +abstract class FilesSyncBlocStates { + BehaviorSubject> get mappingStatuses; + + Stream get conflicts; + + Stream 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( + sources, + event.mapping.status, + event.solutions, + ); + if (conflicts.isNotEmpty) { + _conflictsController.add(FilesSyncConflicts(event.mapping, conflicts)); + } + + _mappingStatusesSubject.add( + Map.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((final m) => FilesSyncMapping.fromJson(json.decode(m) as Map)) + .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 = {}; + 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>.seeded({}); + final _conflictsController = StreamController(); + final _errorsStreamController = StreamController(); + final _watchers = >{}; + + @override + void dispose() { + unawaited(_mappingStatusesSubject.close()); + unawaited(_conflictsController.close()); + unawaited(_errorsStreamController.close()); + super.dispose(); + } + + @override + BehaviorSubject> _mapToMappingStatusesState() => _mappingStatusesSubject; + + @override + Stream _mapToConflictsState() => _conflictsController.stream.asBroadcastStream(); + + @override + Stream _mapToErrorsState() => _errorsStreamController.stream.asBroadcastStream(); +} + +class FilesSyncConflicts { + FilesSyncConflicts( + this.mapping, + this.conflicts, + ); + + final FilesSyncMapping mapping; + final List> conflicts; +} diff --git a/packages/neon/lib/src/apps/files/blocs/sync.rxb.g.dart b/packages/neon/lib/src/apps/files/blocs/sync.rxb.g.dart new file mode 100644 index 00000000..7292f9b9 --- /dev/null +++ b/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(); + + /// Тhe [Subject] where events sink to by calling [removeMapping] + final _$removeMappingEvent = PublishSubject(); + + /// Тhe [Subject] where events sink to by calling [syncMapping] + final _$syncMappingEvent = PublishSubject<_SyncMappingEventArgs>(); + + /// The state of [mappingStatuses] implemented in [_mapToMappingStatusesState] + late final BehaviorSubject> _mappingStatusesState = _mapToMappingStatusesState(); + + /// The state of [conflicts] implemented in [_mapToConflictsState] + late final Stream _conflictsState = _mapToConflictsState(); + + /// The state of [errors] implemented in [_mapToErrorsState] + late final Stream _errorsState = _mapToErrorsState(); + + @override + void addMapping(FilesSyncMapping mapping) => _$addMappingEvent.add(mapping); + + @override + void removeMapping(FilesSyncMapping mapping) => _$removeMappingEvent.add(mapping); + + @override + void syncMapping( + FilesSyncMapping mapping, + Map solutions, + ) => + _$syncMappingEvent.add(_SyncMappingEventArgs( + mapping, + solutions, + )); + + @override + BehaviorSubject> get mappingStatuses => _mappingStatusesState; + + @override + Stream get conflicts => _conflictsState; + + @override + Stream get errors => _errorsState; + + BehaviorSubject> _mapToMappingStatusesState(); + + Stream _mapToConflictsState(); + + Stream _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 solutions; +} diff --git a/packages/neon/lib/src/apps/files/dialogs/choose_create.dart b/packages/neon/lib/src/apps/files/dialogs/choose_create.dart index 38d26278..976f1b49 100644 --- a/packages/neon/lib/src/apps/files/dialogs/choose_create.dart +++ b/packages/neon/lib/src/apps/files/dialogs/choose_create.dart @@ -16,7 +16,7 @@ class FilesChooseCreateDialog extends StatefulWidget { class _FilesChooseCreateDialogState extends State { Future uploadFromPick(final FileType type) async { - final result = await FilePicker.platform.pickFiles( + final result = await FileUtils.loadFileWithPickDialog( allowMultiple: true, type: type, ); diff --git a/packages/neon/lib/src/apps/files/dialogs/choose_folder.dart b/packages/neon/lib/src/apps/files/dialogs/choose_folder.dart index 17c42f20..2b41604c 100644 --- a/packages/neon/lib/src/apps/files/dialogs/choose_folder.dart +++ b/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 originalPath; + final List? originalPath; @override Widget build(final BuildContext context) => AlertDialog( diff --git a/packages/neon/lib/src/apps/files/dialogs/sync_conflict.dart b/packages/neon/lib/src/apps/files/dialogs/sync_conflict.dart new file mode 100644 index 00000000..747462d2 --- /dev/null +++ b/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 createState() => _FilesSyncConflictDialogState(); +} + +class _FilesSyncConflictDialogState extends State { + var _all = false; + SyncConflictSolution? _allSolution; + + var _index = 0; + final _solutions = {}; + + 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) ...[ + ...{ + 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( + 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( + 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( + 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( + 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), + ), + ], + ], + ); + } +} diff --git a/packages/neon/lib/src/apps/files/models/file_details.dart b/packages/neon/lib/src/apps/files/models/file_details.dart index 167f3471..1ba8a561 100644 --- a/packages/neon/lib/src/apps/files/models/file_details.dart +++ b/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, diff --git a/packages/neon/lib/src/apps/files/models/sync_mapping.dart b/packages/neon/lib/src/apps/files/models/sync_mapping.dart new file mode 100644 index 00000000..4af63321 --- /dev/null +++ b/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 json) => _$FilesSyncMappingFromJson(json); + Map toJson() => _$FilesSyncMappingToJson(this); + + final String accountId; + + final List remotePath; + + final String localPath; + + @JsonKey( + fromJson: syncStatusFromJson, + toJson: syncStatusToJson, + ) + late final SyncStatus status; +} diff --git a/packages/neon/lib/src/apps/files/models/sync_mapping.g.dart b/packages/neon/lib/src/apps/files/models/sync_mapping.g.dart new file mode 100644 index 00000000..ec2433c5 --- /dev/null +++ b/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 json) => FilesSyncMapping( + accountId: json['accountId'] as String, + remotePath: (json['remotePath'] as List).map((e) => e as String).toList(), + localPath: json['localPath'] as String, + status: syncStatusFromJson(json['status'] as List), + ); + +Map _$FilesSyncMappingToJson(FilesSyncMapping instance) => { + 'accountId': instance.accountId, + 'remotePath': instance.remotePath, + 'localPath': instance.localPath, + 'status': syncStatusToJson(instance.status), + }; diff --git a/packages/neon/lib/src/apps/files/pages/details.dart b/packages/neon/lib/src/apps/files/pages/details.dart index ecc695d6..3d738c2e 100644 --- a/packages/neon/lib/src/apps/files/pages/details.dart +++ b/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( diff --git a/packages/neon/lib/src/apps/files/pages/main.dart b/packages/neon/lib/src/apps/files/pages/main.dart index adbae938..14e35afa 100644 --- a/packages/neon/lib/src/apps/files/pages/main.dart +++ b/packages/neon/lib/src/apps/files/pages/main.dart @@ -27,6 +27,10 @@ class _FilesMainPageState extends State { 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( diff --git a/packages/neon/lib/src/apps/files/utils/download_task.dart b/packages/neon/lib/src/apps/files/utils/download_task.dart index f4af3532..88c0359c 100644 --- a/packages/neon/lib/src/apps/files/utils/download_task.dart +++ b/packages/neon/lib/src/apps/files/utils/download_task.dart @@ -10,23 +10,13 @@ class DownloadTask { final _streamController = StreamController(); 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()); + }, + ); } } diff --git a/packages/neon/lib/src/apps/files/utils/upload_task.dart b/packages/neon/lib/src/apps/files/utils/upload_task.dart index 6a52f431..240d9159 100644 --- a/packages/neon/lib/src/apps/files/utils/upload_task.dart +++ b/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 path; - final int size; - final DateTime lastModified; + final FileStat stat; final _streamController = StreamController(); late final progress = _streamController.stream.asBroadcastStream(); - Future execute(final NextcloudClient client, final Stream> 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()); + }, ); } } diff --git a/packages/neon/lib/src/apps/files/widgets/browser_view.dart b/packages/neon/lib/src/apps/files/widgets/browser_view.dart index 2b3b51d6..a7c88d22 100644 --- a/packages/neon/lib/src/apps/files/widgets/browser_view.dart +++ b/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 { + late final FilesSyncBloc _filesSyncBloc; + @override void initState() { super.initState(); + _filesSyncBloc = RxBlocProvider.of(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( + 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?>( + 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?>( + 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(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>( bloc: widget.bloc, @@ -147,20 +295,32 @@ class _FilesBrowserViewState extends State { 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 { ? 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 { [...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( - 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?>( - 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?>( - 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 { diff --git a/packages/neon/lib/src/apps/files/widgets/file_preview.dart b/packages/neon/lib/src/apps/files/widgets/file_preview.dart index be74267a..124aff2b 100644 --- a/packages/neon/lib/src/apps/files/widgets/file_preview.dart +++ b/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; diff --git a/packages/neon/lib/src/apps/files/widgets/file_tile.dart b/packages/neon/lib/src/apps/files/widgets/file_tile.dart new file mode 100644 index 00000000..60871996 --- /dev/null +++ b/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, + ); +} diff --git a/packages/neon/lib/src/apps/files/widgets/sync_status_icon.dart b/packages/neon/lib/src/apps/files/widgets/sync_status_icon.dart new file mode 100644 index 00000000..aab7d128 --- /dev/null +++ b/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, + ); + } +} diff --git a/packages/neon/lib/src/blocs/apps.dart b/packages/neon/lib/src/blocs/apps.dart index a8b9e1e5..eb5f4153 100644 --- a/packages/neon/lib/src/blocs/apps.dart +++ b/packages/neon/lib/src/blocs/apps.dart @@ -131,6 +131,9 @@ class AppsBloc extends $AppsBloc { return bloc as T; } + T getAppBlocByID(final String id) => + getAppBloc(_appImplementationsSubject.value.data!.singleWhere((final app) => app.id == id)); + @override void dispose() { unawaited(_appsSubject.close()); diff --git a/packages/neon/lib/src/blocs/push_notifications.dart b/packages/neon/lib/src/blocs/push_notifications.dart index 95595ed1..3e5bd5ad 100644 --- a/packages/neon/lib/src/blocs/push_notifications.dart +++ b/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; diff --git a/packages/neon/lib/src/models/account.dart b/packages/neon/lib/src/models/account.dart index 0dae71cf..e176f642 100644 --- a/packages/neon/lib/src/models/account.dart +++ b/packages/neon/lib/src/models/account.dart @@ -69,6 +69,18 @@ class Account { } } +extension AccountsFind on List { + Account? find(final String id) { + for (final account in this) { + if (account.id == id) { + return account; + } + } + + return null; + } +} + Map _idCache = {}; extension NextcloudClientHelpers on NextcloudClient { diff --git a/packages/neon/lib/src/neon.dart b/packages/neon/lib/src/neon.dart index d6ba2e6a..70ad14e1 100644 --- a/packages/neon/lib/src/neon.dart +++ b/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'; diff --git a/packages/neon/lib/src/pages/home/home.dart b/packages/neon/lib/src/pages/home/home.dart index 55083d6b..b983b364 100644 --- a/packages/neon/lib/src/pages/home/home.dart +++ b/packages/neon/lib/src/pages/home/home.dart @@ -442,6 +442,7 @@ class _HomePageState extends State with tray.TrayListener, WindowListe builder: (final context) => AccountSpecificSettingsPage( bloc: accountsBloc, account: account, + appsBloc: _appsBloc, ), ), ); @@ -589,7 +590,9 @@ class _HomePageState extends State 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 with tray.TrayListener, WindowListe ), body: Column( children: [ + FilesSyncListener( + appsBloc: _appsBloc, + ), ServerStatus( account: widget.account, ), diff --git a/packages/neon/lib/src/pages/home/widgets/files_sync_listener.dart b/packages/neon/lib/src/pages/home/widgets/files_sync_listener.dart new file mode 100644 index 00000000..81e732c6 --- /dev/null +++ b/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 createState() => _FilesSyncListenerState(); +} + +class _FilesSyncListenerState extends State { + @override + void initState() { + super.initState(); + + final filesSyncBloc = RxBlocProvider.of(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) { + 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(); +} diff --git a/packages/neon/lib/src/pages/settings/account_specific_settings.dart b/packages/neon/lib/src/pages/settings/account_specific_settings.dart index 2aa8e184..532039f0 100644 --- a/packages/neon/lib/src/pages/settings/account_specific_settings.dart +++ b/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 createState() => _AccountSpecificSettingsPageState(); +} + +class _AccountSpecificSettingsPageState extends State { + 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(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( - 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>( + stream: _filesSyncBloc.mappingStatuses, + builder: (final context, final mappingsSnapshot) => + StandardRxResultBuilder( + 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 ?? {}).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>(context, listen: false) + .singleWhere((final a) => a.id == 'files'); + final filesBloc = widget.appsBloc.getAppBloc(appImplementation); + final b = filesBloc.getNewFilesBrowserBloc(); + final remotePath = await showDialog?>( + 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, - ), - ], - ), - ], + ], + ), + ], + ), ), ), ); diff --git a/packages/neon/lib/src/pages/settings/settings.dart b/packages/neon/lib/src/pages/settings/settings.dart index af973ab7..01ec532d 100644 --- a/packages/neon/lib/src/pages/settings/settings.dart +++ b/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 createState() => _SettingsPageState(); } @@ -185,6 +188,7 @@ class _SettingsPageState extends State { builder: (final context) => AccountSpecificSettingsPage( bloc: accountsBloc, account: account, + appsBloc: widget.appsBloc, ), ), ); @@ -245,7 +249,7 @@ class _SettingsPageState extends State { ), ), ); - 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); diff --git a/packages/neon/lib/src/platform/abstract.dart b/packages/neon/lib/src/platform/abstract.dart index 1c49580f..50ec170f 100644 --- a/packages/neon/lib/src/platform/abstract.dart +++ b/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 Function() getApplicationCachePath; - final Future Function() getUserAccessibleAppDataPath; - final Future Function()? init; } diff --git a/packages/neon/lib/src/platform/android.dart b/packages/neon/lib/src/platform/android.dart index f1752a80..9eb03fac 100644 --- a/packages/neon/lib/src/platform/android.dart +++ b/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); - }, ); } diff --git a/packages/neon/lib/src/platform/linux.dart b/packages/neon/lib/src/platform/linux.dart index 9301c336..77caff05 100644 --- a/packages/neon/lib/src/platform/linux.dart +++ b/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; diff --git a/packages/neon/lib/src/utils/file_utils.dart b/packages/neon/lib/src/utils/file_utils.dart new file mode 100644 index 00000000..4cf165ab --- /dev/null +++ b/packages/neon/lib/src/utils/file_utils.dart @@ -0,0 +1,40 @@ +part of '../neon.dart'; + +class FileUtils { + static Future 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 loadFileWithPickDialog({ + final bool withData = false, + final bool allowMultiple = false, + final FileType type = FileType.any, + }) async { + final result = await FilePicker.platform.pickFiles( + withData: withData, + allowMultiple: allowMultiple, + type: type, + ); + + return result; + } + + static Future pickDirectory() async => FilePicker.platform.getDirectoryPath(); +} diff --git a/packages/neon/lib/src/utils/save_file.dart b/packages/neon/lib/src/utils/save_file.dart deleted file mode 100644 index 373001b0..00000000 --- a/packages/neon/lib/src/utils/save_file.dart +++ /dev/null @@ -1,22 +0,0 @@ -part of '../neon.dart'; - -Future 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; - } -} diff --git a/packages/neon/lib/src/utils/theme.dart b/packages/neon/lib/src/utils/theme.dart index a78f1221..f0e56591 100644 --- a/packages/neon/lib/src/utils/theme.dart +++ b/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, ), diff --git a/packages/neon/pubspec.lock b/packages/neon/pubspec.lock index 159d239f..da4c279c 100644 --- a/packages/neon/pubspec.lock +++ b/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" diff --git a/packages/neon/pubspec.yaml b/packages/neon/pubspec.yaml index 0f315033..f36a1ce3 100644 --- a/packages/neon/pubspec.yaml +++ b/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: diff --git a/packages/nextcloud/lib/nextcloud.dart b/packages/nextcloud/lib/nextcloud.dart index c92b3394..4111c99f 100644 --- a/packages/nextcloud/lib/nextcloud.dart +++ b/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'; diff --git a/packages/nextcloud/lib/src/sync/action.dart b/packages/nextcloud/lib/src/sync/action.dart new file mode 100644 index 00000000..095b866a --- /dev/null +++ b/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 {} + +/// Action to delete object from A +class SyncActionDeleteFromA extends SyncAction { + // ignore: public_member_api_docs + SyncActionDeleteFromA(this.object); + + // ignore: public_member_api_docs + final SyncObject object; +} + +/// Action to delete object from B +class SyncActionDeleteFromB extends SyncAction { + // ignore: public_member_api_docs + SyncActionDeleteFromB(this.object); + + // ignore: public_member_api_docs + final SyncObject object; +} + +/// Action to write object to A +class SyncActionWriteToA extends SyncAction { + // ignore: public_member_api_docs + SyncActionWriteToA(this.object); + + // ignore: public_member_api_docs + final SyncObject object; +} + +/// Action to write object to B +class SyncActionWriteToB extends SyncAction { + // ignore: public_member_api_docs + SyncActionWriteToB(this.object); + + // ignore: public_member_api_docs + final SyncObject object; +} diff --git a/packages/nextcloud/lib/src/sync/conflict.dart b/packages/nextcloud/lib/src/sync/conflict.dart new file mode 100644 index 00000000..cec38128 --- /dev/null +++ b/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 { + // 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 objectA; + + /// Object B involved in the conflict. + final SyncObject 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, +} diff --git a/packages/nextcloud/lib/src/sync/object.dart b/packages/nextcloud/lib/src/sync/object.dart new file mode 100644 index 00000000..790556b0 --- /dev/null +++ b/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 { + // 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 on List> { + // ignore: public_member_api_docs + SyncObject? find(final String id) { + for (final object in this) { + if (object.id == id) { + return object; + } + } + + return null; + } +} diff --git a/packages/nextcloud/lib/src/sync/sources.dart b/packages/nextcloud/lib/src/sync/sources.dart new file mode 100644 index 00000000..cd08fcbc --- /dev/null +++ b/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 { + /// List all the objects of type [T1] of the source. + Future>> listObjectsA(); + + /// List all the objects of type [T2] of the source. + Future>> 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 getObjectETagA(final SyncObject 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 getObjectETagB(final SyncObject object); + + /// Writes the given [object] of type [T1] to the source. + Future> writeObjectA(final SyncObject object); + + /// Writes the given [object] of type [T2] to the source. + Future> writeObjectB(final SyncObject object); + + /// Deletes the given [object] of type [T1] from the source. + Future deleteObjectA(final SyncObject object); + + /// Deletes the given [object] of type [T2] from the source. + Future deleteObjectB(final SyncObject object); + + /// Sorts the actions before executing them. Useful e.g. for creating directories before creating files and deleting files before deleting directories. + List> sortActions(final List> actions); + + /// Automatically find a solution for conflicts that don't matter. Useful e.g. for ignoring new directories. + SyncConflictSolution? findSolution(final SyncConflict conflict); +} diff --git a/packages/nextcloud/lib/src/sync/sources/webdav_io_sources.dart b/packages/nextcloud/lib/src/sync/sources/webdav_io_sources.dart new file mode 100644 index 00000000..20c003a7 --- /dev/null +++ b/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 { + // 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 webdavBaseDir; + + /// Base directory on the local filesystem. + final String ioBaseDir; + + /// Extra props to request from the WebDAV server + final List extraProps; + + String _path(final SyncObject object) => [...webdavBaseDir, object.id].join('/'); + + @override + Future>> 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( + id, + file, + ); + }, + ).toList(); + + @override + Future>> listObjectsB() async => Directory(ioBaseDir) + .listSync(recursive: true) + .map( + (final e) => SyncObject( + p.relative( + e.path, + from: ioBaseDir, + ), + e, + ), + ) + .toList(); + + @override + Future getObjectETagA(final SyncObject object) async => + object.data.isDirectory ? '' : object.data.etag!; + + @override + Future getObjectETagB(final SyncObject object) async => + object.data is Directory ? '' : object.data.statSync().modified.millisecondsSinceEpoch.toString(); + + @override + Future> writeObjectA(final SyncObject 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(object.id, dir); + } else { + final file = File(p.join(ioBaseDir, object.id)); + await client.webdav.downloadFile(path, file); + return SyncObject(object.id, file); + } + } + + @override + Future> writeObjectB(final SyncObject 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(object.id, await client.webdav.getProps(_path(object))); + } + + @override + Future deleteObjectA(final SyncObject object) async => client.webdav.delete(_path(object)); + + @override + Future deleteObjectB(final SyncObject object) async => object.data.delete(); + + @override + List> sortActions( + final List> actions, + ) { + final addActions = >[]; + final removeActions = >[]; + 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> _innerSortActions( + final List> actions, + ) => + actions..sort((final a, final b) => _idForAction(a).compareTo(_idForAction(b))); + + String _idForAction(final SyncAction action) { + if (action is SyncActionWriteToA) { + return action.object.id; + } else if (action is SyncActionWriteToB) { + return action.object.id; + } else if (action is SyncActionDeleteFromA) { + return action.object.id; + } else if (action is SyncActionDeleteFromB) { + return action.object.id; + } else { + throw Exception('illegal action for getting id'); + } + } + + @override + SyncConflictSolution? findSolution(final SyncConflict conflict) { + if (conflict.objectA.data.isDirectory && conflict.objectB.data is Directory) { + return SyncConflictSolution.overwriteA; + } + + return null; + } +} diff --git a/packages/nextcloud/lib/src/sync/status.dart b/packages/nextcloud/lib/src/sync/status.dart new file mode 100644 index 00000000..a0849fac --- /dev/null +++ b/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 _entries; + + /// All entries contained in the status. + List 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; + 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(); diff --git a/packages/nextcloud/lib/src/sync/status_entry.dart b/packages/nextcloud/lib/src/sync/status_entry.dart new file mode 100644 index 00000000..1ef919b7 --- /dev/null +++ b/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 { + // ignore: public_member_api_docs + SyncStatusEntry? find(final String id) { + for (final entry in this) { + if (entry.id == id) { + return entry; + } + } + + return null; + } +} diff --git a/packages/nextcloud/lib/src/sync/sync.dart b/packages/nextcloud/lib/src/sync/sync.dart new file mode 100644 index 00000000..ffed7155 --- /dev/null +++ b/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>> sync( + final SyncSources sources, + final SyncStatus syncStatus, + final Map conflictSolutions, +) async => + executeSyncDiff( + sources, + syncStatus, + await computeSyncDiff( + sources, + syncStatus, + conflictSolutions, + ), + ); + +/// Differences between the two sources +class SyncDiff { + // ignore: public_member_api_docs + SyncDiff( + this.actions, + this.conflicts, + ); + + /// Actions required to solve the difference + final List> actions; + + /// Conflicts without solutions that need to be solved + final List> conflicts; +} + +/// Executes the actions required to solve the difference +Future>> executeSyncDiff( + final SyncSources sources, + final SyncStatus syncStatus, + final SyncDiff sync, +) async { + for (final action in sync.actions) { + if (action is SyncActionDeleteFromA) { + await sources.deleteObjectA(action.object); + syncStatus.removeEntry(action.object.id); + } else if (action is SyncActionDeleteFromB) { + await sources.deleteObjectB(action.object); + syncStatus.removeEntry(action.object.id); + } else if (action is SyncActionWriteToA) { + 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) { + 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> computeSyncDiff( + final SyncSources sources, + final SyncStatus syncStatus, + final Map conflictSolutions, +) async { + final actions = >[]; + var conflicts = >[]; + 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 = >[]; + 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, + ); +} diff --git a/packages/nextcloud/lib/src/webdav/client.dart b/packages/nextcloud/lib/src/webdav/client.dart index 2d3633d6..259e7bfb 100644 --- a/packages/nextcloud/lib/src/webdav/client.dart +++ b/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 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> ls( final String remotePath, { final Set? 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); diff --git a/packages/nextcloud/pubspec.yaml b/packages/nextcloud/pubspec.yaml index 16958c77..81d4c697 100644 --- a/packages/nextcloud/pubspec.yaml +++ b/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 diff --git a/packages/nextcloud/test/sync_test.dart b/packages/nextcloud/test/sync_test.dart new file mode 100644 index 00000000..7c2c725c --- /dev/null +++ b/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 { + TestSyncSources( + this.stateA, + this.stateB, + ); + + final Map stateA; + final Map stateB; + + @override + Future>> listObjectsA() async => + stateA.keys.map((final key) => SyncObject(key, stateA[key]!)).toList(); + + @override + Future>> listObjectsB() async => + stateB.keys.map((final key) => SyncObject(key, stateB[key]!)).toList(); + + @override + Future getObjectETagA(final SyncObject object) async => etagA(object.data.content); + + @override + Future getObjectETagB(final SyncObject object) async => etagB(object.data.content); + + @override + Future> writeObjectA(final SyncObject object) async { + final wrap = WrapB(object.data.content); + stateB[object.id] = wrap; + return SyncObject(object.id, wrap); + } + + @override + Future> writeObjectB(final SyncObject object) async { + final wrap = WrapA(object.data.content); + stateA[object.id] = wrap; + return SyncObject(object.id, wrap); + } + + @override + Future deleteObjectA(final SyncObject object) async => stateA.remove(object.id); + + @override + Future deleteObjectB(final SyncObject object) async => stateB.remove(object.id); + + @override + List> sortActions(final List> actions) => actions; + + @override + SyncConflictSolution? findSolution(final SyncConflict 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)); + }); + }); + }); + }); + }); + }); +} diff --git a/packages/nextcloud/test/webdav_test.dart b/packages/nextcloud/test/webdav_test.dart index b06453f8..23fe9f17 100644 --- a/packages/nextcloud/test/webdav_test.dart +++ b/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)); diff --git a/packages/settings/lib/src/widgets/custom_settings_tile.dart b/packages/settings/lib/src/widgets/custom_settings_tile.dart index 7ed787a5..579ce73f 100644 --- a/packages/settings/lib/src/widgets/custom_settings_tile.dart +++ b/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, ); }