diff --git a/packages/neon/neon_files/lib/src/app.dart b/packages/neon/neon_files/lib/src/app.dart index 5d7ac79860c..ddedb9ea2df 100644 --- a/packages/neon/neon_files/lib/src/app.dart +++ b/packages/neon/neon_files/lib/src/app.dart @@ -5,6 +5,7 @@ import 'package:neon_files/src/blocs/files.dart'; import 'package:neon_files/src/options.dart'; import 'package:neon_files/src/pages/main.dart'; import 'package:neon_files/src/routes.dart'; +import 'package:neon_files/src/sync/implementation.dart'; import 'package:neon_framework/models.dart'; import 'package:nextcloud/nextcloud.dart'; @@ -32,6 +33,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/src/sync/implementation.dart b/packages/neon/neon_files/lib/src/sync/implementation.dart new file mode 100644 index 00000000000..7614364a4a6 --- /dev/null +++ b/packages/neon/neon_files/lib/src/sync/implementation.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:neon_files/src/blocs/files.dart'; +import 'package:neon_files/src/models/file_details.dart'; +import 'package:neon_files/src/sync/mapping.dart'; +import 'package:neon_files/src/sync/sources.dart'; +import 'package:neon_files/src/utils/dialog.dart'; +import 'package:neon_files/src/widgets/file_tile.dart'; +import 'package:neon_framework/models.dart'; +import 'package:neon_framework/sync.dart'; +import 'package:neon_framework/utils.dart'; +import 'package:nextcloud/ids.dart'; +import 'package:nextcloud/webdav.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:universal_io/io.dart'; + +@immutable +class FilesSync implements SyncImplementation { + const FilesSync(); + + @override + String get id => AppIDs.files; + + @override + Future getSources(Account account, 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(FilesSyncMapping mapping) => mapping.toJson(); + + @override + FilesSyncMapping deserializeMapping(Map json) => FilesSyncMapping.fromJson(json); + + @override + Future addMapping(BuildContext context, Account account) async { + final remotePath = await showChooseFolderDialog(context, PathUri.cwd()); + 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(FilesSyncMapping mapping) { + final path = mapping.remotePath.toString(); + return path.substring(0, path.length - 1); + } + + @override + String getMappingDisplaySubtitle(FilesSyncMapping mapping) => mapping.localPath.path; + + @override + String getMappingId(FilesSyncMapping mapping) => + '${Uri.encodeComponent(mapping.remotePath.toString())}-${Uri.encodeComponent(mapping.localPath.path)}'; + + @override + Widget getConflictDetailsLocal(BuildContext context, 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(BuildContext context, WebDavFile object) => FilesFileTile( + showFullPath: true, + filesBloc: NeonProvider.of(context), + details: FileDetails.fromWebDav( + file: object, + ), + ); +} diff --git a/packages/neon/neon_files/lib/src/sync/mapping.dart b/packages/neon/neon_files/lib/src/sync/mapping.dart new file mode 100644 index 00000000000..14a2a0b3665 --- /dev/null +++ b/packages/neon/neon_files/lib/src/sync/mapping.dart @@ -0,0 +1,69 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:json_annotation/json_annotation.dart'; +import 'package:neon_framework/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(Map json) => _$FilesSyncMappingFromJson(json); + Map toJson() => _$FilesSyncMappingToJson(this); + + @override + final String accountId; + + @override + final String appId; + + @override + final SyncJournal journal; + + @JsonKey( + fromJson: PathUri.parse, + toJson: _pathUriToJson, + ) + final PathUri remotePath; + + static String _pathUriToJson(PathUri uri) => uri.toString(); + + @JsonKey( + fromJson: _directoryFromJson, + toJson: _directoryToJson, + ) + final Directory localPath; + + static Directory _directoryFromJson(String value) => Directory(value); + static String _directoryToJson(Directory value) => value.path; + + StreamSubscription? _subscription; + + @override + void watch(void Function() onUpdated) { + debugPrint('Watching file changes: $localPath'); + _subscription ??= DirectoryWatcher(localPath.path).events.listen( + (event) { + debugPrint('Registered file change: ${event.path} ${event.type}'); + onUpdated(); + }, + ); + } + + @override + void dispose() { + unawaited(_subscription?.cancel()); + } +} diff --git a/packages/neon/neon_files/lib/src/sync/mapping.g.dart b/packages/neon/neon_files/lib/src/sync/mapping.g.dart new file mode 100644 index 00000000000..e8d95d4b3b8 --- /dev/null +++ b/packages/neon/neon_files/lib/src/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: PathUri.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': FilesSyncMapping._pathUriToJson(instance.remotePath), + 'localPath': FilesSyncMapping._directoryToJson(instance.localPath), + }; diff --git a/packages/neon/neon_files/lib/src/sync/sources.dart b/packages/neon/neon_files/lib/src/sync/sources.dart new file mode 100644 index 00000000000..f7ae7358023 --- /dev/null +++ b/packages/neon/neon_files/lib/src/sync/sources.dart @@ -0,0 +1,144 @@ +import 'package:neon_framework/sync.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:path/path.dart' as p; +import 'package:universal_io/io.dart'; + +class FilesSyncSources implements SyncSources { + FilesSyncSources( + NextcloudClient client, + PathUri webdavBaseDir, + Directory ioBaseDir, + ) : sourceA = FilesSyncSourceWebDavFile(client, webdavBaseDir), + sourceB = FilesSyncSourceFileSystemEntity(client, ioBaseDir); + + @override + final SyncSource sourceA; + + @override + final SyncSource sourceB; + + @override + SyncConflictSolution? findSolution(SyncObject objectA, 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 = const WebDavPropWithoutValues.fromBools( + davgetetag: true, + davgetlastmodified: true, + nchaspreview: true, + ocsize: true, + ocfavorite: true, + ); + + PathUri _uri(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( + (file) => ( + id: file.path.pathSegments.sublist(baseDir.pathSegments.length).join('/'), + data: file, + ), + ) + .toList(); + + @override + Future getObjectETag(SyncObject object) async => object.data.isDirectory ? '' : object.data.etag!; + + @override + Future> writeObject(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(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( + (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(SyncObject object) async => + object.data is Directory ? '' : object.data.statSync().modified.millisecondsSinceEpoch.toString(); + + @override + Future> writeObject(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(SyncObject object) async => object.data.delete(); +} diff --git a/packages/neon/neon_files/lib/src/utils/dialog.dart b/packages/neon/neon_files/lib/src/utils/dialog.dart index 3773114e8e4..af85c9cf506 100644 --- a/packages/neon/neon_files/lib/src/utils/dialog.dart +++ b/packages/neon/neon_files/lib/src/utils/dialog.dart @@ -67,15 +67,14 @@ Future showUploadConfirmationDialog( ) ?? false; -/// Displays a [FilesChooseFolderDialog] to choose a new location for a file with the given [details]. +/// Displays a [FilesChooseFolderDialog] to choose a location for a file with the given [uri]. /// -/// Returns a future with the new location. -Future showChooseFolderDialog(BuildContext context, FileDetails details) async { +/// Returns a future with the location. +Future showChooseFolderDialog(BuildContext context, PathUri uri) async { final bloc = NeonProvider.of(context); - final originalUri = details.uri; final b = bloc.getNewFilesBrowserBloc( - initialUri: originalUri, + initialUri: uri, mode: FilesBrowserMode.selectDirectory, ); @@ -84,7 +83,6 @@ Future showChooseFolderDialog(BuildContext context, FileDetails detail builder: (context) => FilesChooseFolderDialog( bloc: b, filesBloc: bloc, - originalPath: originalUri, ), ); b.dispose(); diff --git a/packages/neon/neon_files/lib/src/widgets/actions.dart b/packages/neon/neon_files/lib/src/widgets/actions.dart index f3bc4172116..8c62406d6a5 100644 --- a/packages/neon/neon_files/lib/src/widgets/actions.dart +++ b/packages/neon/neon_files/lib/src/widgets/actions.dart @@ -54,7 +54,7 @@ class FileActions extends StatelessWidget { if (!context.mounted) { return; } - final result = await showChooseFolderDialog(context, details); + final result = await showChooseFolderDialog(context, details.uri); if (result != null) { bloc.move(details.uri, result.join(PathUri.parse(details.name))); @@ -64,7 +64,7 @@ class FileActions extends StatelessWidget { return; } - final result = await showChooseFolderDialog(context, details); + final result = await showChooseFolderDialog(context, details.uri); if (result != null) { bloc.copy(details.uri, result.join(PathUri.parse(details.name))); } diff --git a/packages/neon/neon_files/lib/src/widgets/dialog.dart b/packages/neon/neon_files/lib/src/widgets/dialog.dart index 1ceaf95b541..c1f1aa4414a 100644 --- a/packages/neon/neon_files/lib/src/widgets/dialog.dart +++ b/packages/neon/neon_files/lib/src/widgets/dialog.dart @@ -234,16 +234,12 @@ class FilesChooseFolderDialog extends StatelessWidget { const FilesChooseFolderDialog({ required this.bloc, required this.filesBloc, - this.originalPath, super.key, }); final FilesBrowserBloc bloc; final FilesBloc filesBloc; - /// The initial path to start at. - final PathUri? originalPath; - @override Widget build(BuildContext context) { final dialogTheme = NeonDialogTheme.of(context); diff --git a/packages/neon/neon_files/lib/src/widgets/file_list_tile.dart b/packages/neon/neon_files/lib/src/widgets/file_list_tile.dart index e0d29461536..c247582dcce 100644 --- a/packages/neon/neon_files/lib/src/widgets/file_list_tile.dart +++ b/packages/neon/neon_files/lib/src/widgets/file_list_tile.dart @@ -125,7 +125,7 @@ class _FileIcon extends StatelessWidget { child: Icon( AdaptiveIcons.star, size: smallIconSize, - color: Colors.yellow, + color: NcColors.starredColor, ), ), ], diff --git a/packages/neon/neon_files/lib/src/widgets/file_tile.dart b/packages/neon/neon_files/lib/src/widgets/file_tile.dart new file mode 100644 index 00000000000..4291009e5f5 --- /dev/null +++ b/packages/neon/neon_files/lib/src/widgets/file_tile.dart @@ -0,0 +1,101 @@ +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:neon_files/src/blocs/files.dart'; +import 'package:neon_files/src/models/file_details.dart'; +import 'package:neon_files/src/widgets/file_preview.dart'; +import 'package:neon_framework/theme.dart'; +import 'package:neon_framework/widgets.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(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 fae04c770ac..689c493e2a0 100644 --- a/packages/neon/neon_files/pubspec.yaml +++ b/packages/neon/neon_files/pubspec.yaml @@ -23,6 +23,7 @@ dependencies: go_router: ^13.0.0 image_picker: ^1.0.0 intl: ^0.18.0 + json_annotation: ^4.8.1 logging: ^1.0.0 meta: ^1.0.0 neon_framework: @@ -33,15 +34,18 @@ 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.8 custom_lint: ^0.6.2 go_router_builder: ^2.4.1 + json_serializable: ^6.7.1 neon_lints: git: url: https://github.com/nextcloud/neon