diff --git a/packages/app/pubspec.lock b/packages/app/pubspec.lock index b8c66ab476d..ca7d500924a 100644 --- a/packages/app/pubspec.lock +++ b/packages/app/pubspec.lock @@ -1397,6 +1397,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" web: dependency: transitive description: diff --git a/packages/neon/neon_files/lib/blocs/files.dart b/packages/neon/neon_files/lib/blocs/files.dart index 2711c40be75..4f1fde0448b 100644 --- a/packages/neon/neon_files/lib/blocs/files.dart +++ b/packages/neon/neon_files/lib/blocs/files.dart @@ -3,8 +3,6 @@ part of '../neon_files.dart'; abstract interface 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 shareFileNative(final List path, final String etag); @@ -57,6 +55,21 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta @override BehaviorSubject> tasks = BehaviorSubject>.seeded([]); + @override + Future refresh() async { + await browser.refresh(); + } + + @override + void removeFavorite(final List path) { + wrapAction( + () async => account.client.webdav.proppatch( + Uri(pathSegments: path), + set: WebDavProp(ocfavorite: 0), + ), + ); + } + @override void addFavorite(final List path) { wrapAction( @@ -109,21 +122,6 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta ); } - @override - Future refresh() async { - await browser.refresh(); - } - - @override - void removeFavorite(final List path) { - wrapAction( - () async => account.client.webdav.proppatch( - Uri(pathSegments: path), - set: WebDavProp(ocfavorite: 0), - ), - ); - } - @override void rename(final List path, final String name) { wrapAction( @@ -134,27 +132,6 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta ); } - @override - void syncFile(final List path) { - wrapAction( - () async { - final file = File( - p.join( - await NeonPlatform.instance.userAccessibleAppDataPath, - account.humanReadableID, - 'files', - path.join(Platform.pathSeparator), - ), - ); - if (!file.parent.existsSync()) { - file.parent.createSync(recursive: true); - } - await _downloadFile(path, file); - }, - disableTimeout: true, - ); - } - @override void uploadFile(final List path, final String localPath) { wrapAction( diff --git a/packages/neon/neon_files/lib/models/file_details.dart b/packages/neon/neon_files/lib/models/file_details.dart index c0d61b28bcf..5e0eb911370 100644 --- a/packages/neon/neon_files/lib/models/file_details.dart +++ b/packages/neon/neon_files/lib/models/file_details.dart @@ -15,8 +15,7 @@ class FileDetails { FileDetails.fromWebDav({ required final WebDavFile file, - required final List path, - }) : path = List.from(path)..add(file.name), + }) : path = file.path.split('/').where((final element) => element.isNotEmpty).toList(), isDirectory = file.isDirectory, size = file.size, etag = file.etag, diff --git a/packages/neon/neon_files/lib/neon_files.dart b/packages/neon/neon_files/lib/neon_files.dart index 31882f28375..c546ff48d96 100644 --- a/packages/neon/neon_files/lib/neon_files.dart +++ b/packages/neon/neon_files/lib/neon_files.dart @@ -39,11 +39,13 @@ import 'package:neon/models.dart'; import 'package:neon/platform.dart'; import 'package:neon/settings.dart'; import 'package:neon/sort_box.dart'; +import 'package:neon/sync.dart'; import 'package:neon/theme.dart'; import 'package:neon/utils.dart'; import 'package:neon/widgets.dart'; import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/routes.dart'; +import 'package:neon_files/sync/mapping.dart'; import 'package:neon_files/widgets/file_list_tile.dart'; import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/nextcloud.dart'; @@ -65,9 +67,12 @@ part 'options.dart'; part 'pages/details.dart'; part 'pages/main.dart'; part 'sort/files.dart'; +part 'sync/implementation.dart'; +part 'sync/sources.dart'; part 'utils/task.dart'; part 'widgets/browser_view.dart'; part 'widgets/file_preview.dart'; +part 'widgets/file_tile.dart'; part 'widgets/navigator.dart'; class FilesApp extends AppImplementation { @@ -94,6 +99,9 @@ class FilesApp extends AppImplementation { @override final Widget page = const FilesMainPage(); + @override + final FilesSync syncImplementation = const FilesSync(); + @override final RouteBase route = $filesAppRoute; } diff --git a/packages/neon/neon_files/lib/sync/implementation.dart b/packages/neon/neon_files/lib/sync/implementation.dart new file mode 100644 index 00000000000..2677bd0a691 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/implementation.dart @@ -0,0 +1,97 @@ +part of '../neon_files.dart'; + +@immutable +class FilesSync implements SyncImplementation { + const FilesSync(); + + @override + String get appId => AppIDs.files; + + @override + FilesSyncSources getSources(final Account account, final FilesSyncMapping mapping) => FilesSyncSources( + account.client, + mapping.remotePath, + mapping.localPath, + ); + + @override + Map serializeMapping(final FilesSyncMapping mapping) => mapping.toJson(); + + @override + FilesSyncMapping deserializeMapping(final Map json) => FilesSyncMapping.fromJson(json); + + @override + Future addMapping(final BuildContext context, final Account account) async { + final accountsBloc = NeonProvider.of(context); + final appsBloc = accountsBloc.getAppsBlocFor(account); + final filesBloc = appsBloc.getAppBlocByID(AppIDs.files)! as FilesBloc; + final filesBrowserBloc = filesBloc.getNewFilesBrowserBloc(); + + final remotePath = await showDialog?>( + context: context, + builder: (final context) => FilesChooseFolderDialog( + bloc: filesBrowserBloc, + filesBloc: filesBloc, + originalPath: const [], + ), + ); + filesBrowserBloc.dispose(); + if (remotePath == null) { + return null; + } + + final localPath = await FileUtils.pickDirectory(); + if (localPath == null) { + return null; + } + if (!context.mounted) { + return null; + } + + return FilesSyncMapping( + appId: AppIDs.files, + accountId: account.id, + remotePath: Uri(pathSegments: remotePath), + localPath: Directory(localPath), + journal: SyncJournal(), + ); + } + + @override + String getMappingDisplayTitle(final FilesSyncMapping mapping) => mapping.remotePath.toString(); + + @override + String getMappingDisplaySubtitle(final FilesSyncMapping mapping) => mapping.localPath.path; + + @override + String getMappingId(final FilesSyncMapping mapping) => + '${Uri.encodeComponent(mapping.remotePath.toString())}-${Uri.encodeComponent(mapping.localPath.path)}'; + + @override + Widget getConflictDetailsLocal(final BuildContext context, final FileSystemEntity object) { + final stat = object.statSync(); + return FilesFileTile( + showFullPath: true, + filesBloc: NeonProvider.of(context), + details: FileDetails( + path: object.path.split('/'), + isDirectory: object is Directory, + size: stat.size, + etag: '', + mimeType: '', + lastModified: stat.modified, + hasPreview: false, + isFavorite: false, + ), + ); + } + + @override + Widget getConflictDetailsRemote(final BuildContext context, final WebDavFile object) => FilesFileTile( + showFullPath: true, + filesBloc: NeonProvider.of(context), + details: FileDetails.fromWebDav( + file: object, + ), + ); +} diff --git a/packages/neon/neon_files/lib/sync/mapping.dart b/packages/neon/neon_files/lib/sync/mapping.dart new file mode 100644 index 00000000000..d86fa214809 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/mapping.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:neon/sync.dart'; +import 'package:nextcloud/webdav.dart' as webdav; +import 'package:watcher/watcher.dart'; + +part 'mapping.g.dart'; + +@JsonSerializable() +class FilesSyncMapping implements SyncMapping { + FilesSyncMapping({ + required this.accountId, + required this.appId, + required this.journal, + required this.remotePath, + required this.localPath, + }); + + factory FilesSyncMapping.fromJson(final Map json) => _$FilesSyncMappingFromJson(json); + Map toJson() => _$FilesSyncMappingToJson(this); + + @override + final String accountId; + + @override + final String appId; + + @override + final SyncJournal journal; + + final Uri remotePath; + + @JsonKey( + fromJson: _directoryFromJson, + toJson: _directoryToJson, + ) + final Directory localPath; + + static Directory _directoryFromJson(final String value) => Directory(value); + static String _directoryToJson(final Directory value) => value.path; + + StreamSubscription? _subscription; + + @override + void watch(final void Function() onUpdated) { + debugPrint('Watching file changes: $localPath'); + _subscription ??= DirectoryWatcher(localPath.path).events.listen( + (final event) { + debugPrint('Registered file change: ${event.path} ${event.type}'); + onUpdated(); + }, + ); + } + + @override + void dispose() { + unawaited(_subscription?.cancel()); + } +} diff --git a/packages/neon/neon_files/lib/sync/mapping.g.dart b/packages/neon/neon_files/lib/sync/mapping.g.dart new file mode 100644 index 00000000000..e29f147936e --- /dev/null +++ b/packages/neon/neon_files/lib/sync/mapping.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'mapping.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FilesSyncMapping _$FilesSyncMappingFromJson(Map json) => FilesSyncMapping( + accountId: json['accountId'] as String, + appId: json['appId'] as String, + journal: SyncJournal.fromJson(json['journal'] as Map), + remotePath: Uri.parse(json['remotePath'] as String), + localPath: FilesSyncMapping._directoryFromJson(json['localPath'] as String), + ); + +Map _$FilesSyncMappingToJson(FilesSyncMapping instance) => { + 'accountId': instance.accountId, + 'appId': instance.appId, + 'journal': instance.journal, + 'remotePath': instance.remotePath.toString(), + 'localPath': FilesSyncMapping._directoryToJson(instance.localPath), + }; diff --git a/packages/neon/neon_files/lib/sync/sources.dart b/packages/neon/neon_files/lib/sync/sources.dart new file mode 100644 index 00000000000..2db0a9e88d1 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/sources.dart @@ -0,0 +1,149 @@ +part of '../neon_files.dart'; + +class FilesSyncSources implements SyncSources { + FilesSyncSources( + final NextcloudClient client, + final Uri webdavBaseDir, + final Directory ioBaseDir, + ) : sourceA = FilesSyncSourceWebDavFile(client, webdavBaseDir), + sourceB = FilesSyncSourceFileSystemEntity(client, ioBaseDir); + + @override + final SyncSource sourceA; + + @override + final SyncSource sourceB; + + @override + SyncConflictSolution? findSolution(final SyncObject objectA, final SyncObject objectB) { + if (objectA.data.isDirectory && objectB.data is Directory) { + return SyncConflictSolution.overwriteA; + } + + return null; + } +} + +class FilesSyncSourceWebDavFile implements SyncSource { + FilesSyncSourceWebDavFile( + this.client, + this.baseDir, + ); + + /// [NextcloudClient] used by the WebDAV part. + final NextcloudClient client; + + /// Base directory on the WebDAV server. + final Uri baseDir; + + final props = WebDavPropWithoutValues.fromBools( + davgetetag: true, + davgetlastmodified: true, + nchaspreview: true, + ocsize: true, + ocfavorite: true, + ); + + Uri _uri(final SyncObject object) => + Uri(pathSegments: [...baseDir.pathSegments, ...Uri(path: object.id).pathSegments]); + + @override + Future>> listObjects() async => (await client.webdav.propfind( + baseDir, + prop: props, + depth: WebDavDepth.infinity, + )) + .toWebDavFiles() + .sublist(1) + .map( + (final file) { + var path = file.path.substring(baseDir.path.length + 2 /*Leading and trailing slashes*/); + if (path.endsWith('/')) { + path = path.substring(0, path.length - 1); + } + return (id: path, data: file); + }, + ).toList(); + + @override + Future getObjectETag(final SyncObject object) async => + object.data.isDirectory ? '' : object.data.etag!; + + @override + Future> writeObject(final SyncObject object) async { + if (object.data is File) { + final stat = await object.data.stat(); + await client.webdav.putFile( + object.data as File, + stat, + _uri(object), + lastModified: stat.modified, + ); + } else if (object.data is Directory) { + await client.webdav.mkcol(_uri(object)); + } else { + throw Exception('Unable to sync FileSystemEntity of type ${object.data.runtimeType}'); + } + return ( + id: object.id, + data: (await client.webdav.propfind( + _uri(object), + prop: props, + depth: WebDavDepth.zero, + )) + .toWebDavFiles() + .single, + ); + } + + @override + Future deleteObject(final SyncObject object) async => client.webdav.delete(_uri(object)); +} + +class FilesSyncSourceFileSystemEntity implements SyncSource { + FilesSyncSourceFileSystemEntity( + this.client, + this.baseDir, + ); + + /// [NextcloudClient] used by the WebDAV part. + final NextcloudClient client; + + /// Base directory on the local filesystem. + final Directory baseDir; + + @override + Future>> listObjects() async => baseDir.listSync(recursive: true).map( + (final e) { + var path = p.relative(e.path, from: baseDir.path); + if (path.endsWith('/')) { + path = path.substring(0, path.length - 1); + } + return (id: path, data: e); + }, + ).toList(); + + @override + Future getObjectETag(final SyncObject object) async => + object.data is Directory ? '' : object.data.statSync().modified.millisecondsSinceEpoch.toString(); + + @override + Future> writeObject(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(baseDir.path, object.id))..createSync(); + return (id: object.id, data: dir); + } else { + final file = File(p.join(baseDir.path, object.id)); + await client.webdav.getFile(Uri(path: path), file); + await file.setLastModified(object.data.lastModified!); + return (id: object.id, data: file); + } + } + + @override + Future deleteObject(final SyncObject object) async => object.data.delete(); +} diff --git a/packages/neon/neon_files/lib/widgets/actions.dart b/packages/neon/neon_files/lib/widgets/actions.dart index aa9eb789101..5ec067996fd 100644 --- a/packages/neon/neon_files/lib/widgets/actions.dart +++ b/packages/neon/neon_files/lib/widgets/actions.dart @@ -1,4 +1,3 @@ -import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:neon/platform.dart'; import 'package:neon/utils.dart'; @@ -15,7 +14,6 @@ class FileActions extends StatelessWidget { Future onSelected(final BuildContext context, final FilesFileAction action) async { final bloc = NeonProvider.of(context); - final browserBloc = bloc.browser; switch (action) { case FilesFileAction.share: bloc.shareFileNative(details.path, details.etag!); @@ -84,23 +82,6 @@ class FileActions extends StatelessWidget { if (result != null) { bloc.copy(details.path, result..add(details.name)); } - case FilesFileAction.sync: - if (!context.mounted) { - return; - } - final sizeWarning = browserBloc.options.downloadSizeWarning.value; - if (sizeWarning != null && details.size != null && details.size! > sizeWarning) { - if (!(await showConfirmationDialog( - context, - FilesLocalizations.of(context).downloadConfirmSizeWarning( - filesize(sizeWarning), - filesize(details.size), - ), - ))) { - return; - } - } - bloc.syncFile(details.path); case FilesFileAction.delete: if (!context.mounted) { return; @@ -151,13 +132,6 @@ class FileActions extends StatelessWidget { value: FilesFileAction.copy, child: Text(FilesLocalizations.of(context).actionCopy), ), - // TODO: https://github.com/provokateurin/nextcloud-neon/issues/4 - if (!details.isDirectory) ...[ - PopupMenuItem( - value: FilesFileAction.sync, - child: Text(FilesLocalizations.of(context).actionSync), - ), - ], PopupMenuItem( value: FilesFileAction.delete, child: Text(FilesLocalizations.of(context).actionDelete), @@ -174,6 +148,5 @@ enum FilesFileAction { rename, move, copy, - sync, delete, } diff --git a/packages/neon/neon_files/lib/widgets/browser_view.dart b/packages/neon/neon_files/lib/widgets/browser_view.dart index e8f138611d8..e1877787fda 100644 --- a/packages/neon/neon_files/lib/widgets/browser_view.dart +++ b/packages/neon/neon_files/lib/widgets/browser_view.dart @@ -101,7 +101,6 @@ class _FilesBrowserViewState extends State { ) : FileDetails.fromWebDav( file: file, - path: widget.bloc.path.value, ); return FileListTile( diff --git a/packages/neon/neon_files/lib/widgets/file_list_tile.dart b/packages/neon/neon_files/lib/widgets/file_list_tile.dart index 85426f9fec5..92dfddbb584 100644 --- a/packages/neon/neon_files/lib/widgets/file_list_tile.dart +++ b/packages/neon/neon_files/lib/widgets/file_list_tile.dart @@ -128,7 +128,7 @@ class _FileIcon extends StatelessWidget { child: Icon( Icons.star, size: smallIconSize, - color: Colors.yellow, + color: NcColors.starredColor, ), ), ], diff --git a/packages/neon/neon_files/lib/widgets/file_tile.dart b/packages/neon/neon_files/lib/widgets/file_tile.dart new file mode 100644 index 00000000000..aaf54f491df --- /dev/null +++ b/packages/neon/neon_files/lib/widgets/file_tile.dart @@ -0,0 +1,94 @@ +part of '../neon_files.dart'; + +class FilesFileTile extends StatelessWidget { + const FilesFileTile({ + required this.filesBloc, + required this.details, + this.trailing, + this.onTap, + this.uploadProgress, + this.downloadProgress, + this.showFullPath = false, + super.key, + }); + + final FilesBloc filesBloc; + final FileDetails details; + final Widget? trailing; + final GestureTapCallback? onTap; + final int? uploadProgress; + final int? downloadProgress; + final bool showFullPath; + + @override + Widget build(final BuildContext context) { + Widget icon = Center( + child: uploadProgress != null || downloadProgress != null + ? Column( + children: [ + Icon( + uploadProgress != null ? MdiIcons.upload : MdiIcons.download, + color: Theme.of(context).colorScheme.primary, + ), + LinearProgressIndicator( + value: (uploadProgress ?? downloadProgress)! / 100, + ), + ], + ) + : FilePreview( + bloc: filesBloc, + details: details, + withBackground: true, + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + ); + if (details.isFavorite ?? false) { + icon = Stack( + children: [ + icon, + const Align( + alignment: Alignment.bottomRight, + child: Icon( + Icons.star, + size: 14, + color: NcColors.starredColor, + ), + ), + ], + ); + } + + return ListTile( + onTap: onTap, + title: Text( + showFullPath ? details.path.join('/') : details.name, + overflow: TextOverflow.ellipsis, + ), + subtitle: Row( + children: [ + if (details.lastModified != null) ...[ + RelativeTime( + date: details.lastModified!, + ), + ], + if (details.size != null && details.size! > 0) ...[ + const SizedBox( + width: 10, + ), + Text( + filesize(details.size, 1), + style: DefaultTextStyle.of(context).style.copyWith( + color: Colors.grey, + ), + ), + ], + ], + ), + leading: SizedBox.square( + dimension: 40, + child: icon, + ), + trailing: trailing, + ); + } +} diff --git a/packages/neon/neon_files/pubspec.yaml b/packages/neon/neon_files/pubspec.yaml index 75cab273462..304f2db3aa1 100644 --- a/packages/neon/neon_files/pubspec.yaml +++ b/packages/neon/neon_files/pubspec.yaml @@ -22,6 +22,7 @@ dependencies: go_router: ^12.0.0 image_picker: ^1.0.0 intl: ^0.18.0 + json_annotation: ^4.8.1 neon: git: url: https://github.com/nextcloud/neon @@ -37,10 +38,12 @@ dependencies: rxdart: ^0.27.0 share_plus: ^7.0.0 universal_io: ^2.0.0 + watcher: ^1.1.0 dev_dependencies: build_runner: ^2.4.6 go_router_builder: ^2.3.4 + json_serializable: ^6.7.1 neon_lints: git: url: https://github.com/nextcloud/neon