diff --git a/packages/app/pubspec.lock b/packages/app/pubspec.lock index 143185d2..0ad16aff 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 5ec0f9d5..cb49ea1c 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 PathUri uri, final String localPath); - void syncFile(final PathUri uri); - void openFile(final PathUri uri, final String etag, final String? mimeType); void shareFileNative(final PathUri uri, final String etag); @@ -57,6 +55,21 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta @override BehaviorSubject> tasks = BehaviorSubject>.seeded([]); + @override + Future refresh() async { + await browser.refresh(); + } + + @override + void removeFavorite(final PathUri uri) { + wrapAction( + () async => account.client.webdav.proppatch( + uri, + set: WebDavProp(ocfavorite: 0), + ), + ); + } + @override void addFavorite(final PathUri uri) { wrapAction( @@ -109,21 +122,6 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta ); } - @override - Future refresh() async { - await browser.refresh(); - } - - @override - void removeFavorite(final PathUri uri) { - wrapAction( - () async => account.client.webdav.proppatch( - uri, - set: WebDavProp(ocfavorite: 0), - ), - ); - } - @override void rename(final PathUri uri, final String name) { wrapAction( @@ -134,27 +132,6 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta ); } - @override - void syncFile(final PathUri uri) { - wrapAction( - () async { - final file = File( - p.joinAll([ - await NeonPlatform.instance.userAccessibleAppDataPath, - account.humanReadableID, - 'files', - ...uri.pathSegments, - ]), - ); - if (!file.parent.existsSync()) { - file.parent.createSync(recursive: true); - } - await _downloadFile(uri, file); - }, - disableTimeout: true, - ); - } - @override void uploadFile(final PathUri uri, final String localPath) { wrapAction( diff --git a/packages/neon/neon_files/lib/neon_files.dart b/packages/neon/neon_files/lib/neon_files.dart index 31882f28..ab1a24bc 100644 --- a/packages/neon/neon_files/lib/neon_files.dart +++ b/packages/neon/neon_files/lib/neon_files.dart @@ -39,17 +39,20 @@ import 'package:neon/models.dart'; import 'package:neon/platform.dart'; import 'package:neon/settings.dart'; import 'package:neon/sort_box.dart'; +import 'package:neon/sync.dart'; import 'package:neon/theme.dart'; import 'package:neon/utils.dart'; import 'package:neon/widgets.dart'; import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/routes.dart'; +import 'package:neon_files/sync/mapping.dart'; import 'package:neon_files/widgets/file_list_tile.dart'; import 'package:nextcloud/core.dart' as core; import 'package:nextcloud/nextcloud.dart'; import 'package:open_file/open_file.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:queue/queue.dart'; import 'package:rxdart/rxdart.dart'; import 'package:share_plus/share_plus.dart'; @@ -65,9 +68,12 @@ part 'options.dart'; part 'pages/details.dart'; part 'pages/main.dart'; part 'sort/files.dart'; +part 'sync/implementation.dart'; +part 'sync/sources.dart'; part 'utils/task.dart'; part 'widgets/browser_view.dart'; part 'widgets/file_preview.dart'; +part 'widgets/file_tile.dart'; part 'widgets/navigator.dart'; class FilesApp extends AppImplementation { @@ -94,6 +100,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 00000000..902c9727 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/implementation.dart @@ -0,0 +1,105 @@ +part of '../neon_files.dart'; + +@immutable +class FilesSync implements SyncImplementation { + const FilesSync(); + + @override + String get appId => AppIDs.files; + + @override + Future getSources(final Account account, final FilesSyncMapping mapping) async { + // This shouldn't be necessary, but it sadly is because of https://github.com/flutter/flutter/issues/25659. + // Alternative would be to use https://pub.dev/packages/shared_storage, + // but to be efficient we'd need https://github.com/alexrintt/shared-storage/issues/91 + // or copy the files to the app cache (which is also not optimal). + if (Platform.isAndroid && !await Permission.manageExternalStorage.request().isGranted) { + throw const MissingPermissionException(Permission.manageExternalStorage); + } + return FilesSyncSources( + account.client, + mapping.remotePath, + mapping.localPath, + ); + } + + @override + Map 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: PathUri.cwd(), + ), + ); + filesBrowserBloc.dispose(); + if (remotePath == null) { + return null; + } + + final localPath = await FileUtils.pickDirectory(); + if (localPath == null) { + return null; + } + if (!context.mounted) { + return null; + } + + return FilesSyncMapping( + appId: AppIDs.files, + accountId: account.id, + remotePath: remotePath, + localPath: Directory(localPath), + journal: SyncJournal(), + ); + } + + @override + String getMappingDisplayTitle(final FilesSyncMapping mapping) => mapping.remotePath.toString(); + + @override + String getMappingDisplaySubtitle(final FilesSyncMapping mapping) => mapping.localPath.path; + + @override + String getMappingId(final FilesSyncMapping mapping) => + '${Uri.encodeComponent(mapping.remotePath.toString())}-${Uri.encodeComponent(mapping.localPath.path)}'; + + @override + Widget getConflictDetailsLocal(final BuildContext context, final FileSystemEntity object) { + final stat = object.statSync(); + return FilesFileTile( + showFullPath: true, + filesBloc: NeonProvider.of(context), + details: FileDetails( + uri: PathUri.parse(object.path), + size: stat.size, + etag: '', + mimeType: '', + lastModified: stat.modified, + hasPreview: false, + isFavorite: false, + ), + ); + } + + @override + Widget getConflictDetailsRemote(final BuildContext context, final WebDavFile object) => FilesFileTile( + showFullPath: true, + filesBloc: NeonProvider.of(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 00000000..8d29a668 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/mapping.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:neon/sync.dart'; +import 'package:nextcloud/webdav.dart' as webdav; +import 'package:nextcloud/webdav.dart'; +import 'package:universal_io/io.dart'; +import 'package:watcher/watcher.dart'; + +part 'mapping.g.dart'; + +@JsonSerializable() +class FilesSyncMapping implements SyncMapping { + 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 PathUri remotePath; + + @JsonKey( + fromJson: _directoryFromJson, + toJson: _directoryToJson, + ) + final Directory localPath; + + static Directory _directoryFromJson(final String value) => Directory(value); + static String _directoryToJson(final Directory value) => value.path; + + StreamSubscription? _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 00000000..e29f1479 --- /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 00000000..37e37fb8 --- /dev/null +++ b/packages/neon/neon_files/lib/sync/sources.dart @@ -0,0 +1,142 @@ +part of '../neon_files.dart'; + +class FilesSyncSources implements SyncSources { + FilesSyncSources( + final NextcloudClient client, + final PathUri 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 PathUri baseDir; + + final props = WebDavPropWithoutValues.fromBools( + davgetetag: true, + davgetlastmodified: true, + nchaspreview: true, + ocsize: true, + ocfavorite: true, + ); + + PathUri _uri(final SyncObject object) => baseDir.join(PathUri.parse(object.id)); + + @override + Future>> listObjects() async => (await client.webdav.propfind( + baseDir, + prop: props, + depth: WebDavDepth.infinity, + )) + .toWebDavFiles() + .sublist(1) + .map( + (final file) => ( + id: file.path.pathSegments.sublist(baseDir.pathSegments.length).join('/'), + data: file, + ), + ) + .toList(); + + @override + Future 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 { + if (object.data.isDirectory) { + final dir = Directory(p.join(baseDir.path, object.id))..createSync(); + return (id: object.id, data: dir); + } else { + final file = File(p.join(baseDir.path, object.id)); + await client.webdav.getFile(object.data.path, file); + await file.setLastModified(object.data.lastModified!); + return (id: object.id, data: file); + } + } + + @override + Future 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 bbe1656b..a01a6c17 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'; @@ -16,7 +15,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.uri, details.etag!); @@ -85,23 +83,6 @@ class FileActions extends StatelessWidget { if (result != null) { bloc.copy(details.uri, result.join(PathUri.parse(details.name))); } - case FilesFileAction.sync: - if (!context.mounted) { - return; - } - final sizeWarning = browserBloc.options.downloadSizeWarning.value; - if (sizeWarning != null && details.size != null && details.size! > sizeWarning) { - if (!(await showConfirmationDialog( - context, - FilesLocalizations.of(context).downloadConfirmSizeWarning( - filesize(sizeWarning), - filesize(details.size), - ), - ))) { - return; - } - } - bloc.syncFile(details.uri); case FilesFileAction.delete: if (!context.mounted) { return; @@ -152,13 +133,6 @@ class FileActions extends StatelessWidget { value: FilesFileAction.copy, child: Text(FilesLocalizations.of(context).actionCopy), ), - // TODO: https://github.com/provokateurin/nextcloud-neon/issues/4 - if (!details.isDirectory) ...[ - PopupMenuItem( - value: FilesFileAction.sync, - child: Text(FilesLocalizations.of(context).actionSync), - ), - ], PopupMenuItem( value: FilesFileAction.delete, child: Text(FilesLocalizations.of(context).actionDelete), @@ -175,6 +149,5 @@ enum FilesFileAction { rename, move, copy, - sync, delete, } 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 5d9a04b2..6b98bdfb 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 00000000..d988dfb0 --- /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.uri.path : details.name, + overflow: TextOverflow.ellipsis, + ), + subtitle: Row( + children: [ + if (details.lastModified != null) ...[ + RelativeTime( + date: details.lastModified!, + ), + ], + if (details.size != null && details.size! > 0) ...[ + const SizedBox( + width: 10, + ), + Text( + filesize(details.size, 1), + style: DefaultTextStyle.of(context).style.copyWith( + color: Colors.grey, + ), + ), + ], + ], + ), + leading: SizedBox.square( + dimension: 40, + child: icon, + ), + trailing: trailing, + ); + } +} diff --git a/packages/neon/neon_files/pubspec.yaml b/packages/neon/neon_files/pubspec.yaml index 75cab273..2128981f 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 @@ -33,14 +34,17 @@ dependencies: open_file: ^3.0.0 path: ^1.0.0 path_provider: ^2.0.0 + permission_handler: ^11.0.0 queue: ^3.0.0 rxdart: ^0.27.0 share_plus: ^7.0.0 universal_io: ^2.0.0 + watcher: ^1.1.0 dev_dependencies: build_runner: ^2.4.6 go_router_builder: ^2.3.4 + json_serializable: ^6.7.1 neon_lints: git: url: https://github.com/nextcloud/neon