jld3103
1 year ago
11 changed files with 464 additions and 66 deletions
@ -0,0 +1,105 @@ |
|||||||
|
part of '../neon_files.dart'; |
||||||
|
|
||||||
|
@immutable |
||||||
|
class FilesSync implements SyncImplementation<FilesSyncMapping, WebDavFile, FileSystemEntity> { |
||||||
|
const FilesSync(); |
||||||
|
|
||||||
|
@override |
||||||
|
String get appId => AppIDs.files; |
||||||
|
|
||||||
|
@override |
||||||
|
Future<FilesSyncSources> 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<String, dynamic> serializeMapping(final FilesSyncMapping mapping) => mapping.toJson(); |
||||||
|
|
||||||
|
@override |
||||||
|
FilesSyncMapping deserializeMapping(final Map<String, dynamic> json) => FilesSyncMapping.fromJson(json); |
||||||
|
|
||||||
|
@override |
||||||
|
Future<FilesSyncMapping?> addMapping(final BuildContext context, final Account account) async { |
||||||
|
final accountsBloc = NeonProvider.of<AccountsBloc>(context); |
||||||
|
final appsBloc = accountsBloc.getAppsBlocFor(account); |
||||||
|
final filesBloc = appsBloc.getAppBlocByID(AppIDs.files)! as FilesBloc; |
||||||
|
final filesBrowserBloc = filesBloc.getNewFilesBrowserBloc(); |
||||||
|
|
||||||
|
final remotePath = await showDialog<PathUri>( |
||||||
|
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<FilesBloc>(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<FilesBloc>(context), |
||||||
|
details: FileDetails.fromWebDav( |
||||||
|
file: object, |
||||||
|
), |
||||||
|
); |
||||||
|
} |
@ -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<webdav.WebDavFile, FileSystemEntity> { |
||||||
|
FilesSyncMapping({ |
||||||
|
required this.accountId, |
||||||
|
required this.appId, |
||||||
|
required this.journal, |
||||||
|
required this.remotePath, |
||||||
|
required this.localPath, |
||||||
|
}); |
||||||
|
|
||||||
|
factory FilesSyncMapping.fromJson(final Map<String, dynamic> json) => _$FilesSyncMappingFromJson(json); |
||||||
|
Map<String, dynamic> 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<WatchEvent>? _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()); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,23 @@ |
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND |
||||||
|
|
||||||
|
part of 'mapping.dart'; |
||||||
|
|
||||||
|
// ************************************************************************** |
||||||
|
// JsonSerializableGenerator |
||||||
|
// ************************************************************************** |
||||||
|
|
||||||
|
FilesSyncMapping _$FilesSyncMappingFromJson(Map<String, dynamic> json) => FilesSyncMapping( |
||||||
|
accountId: json['accountId'] as String, |
||||||
|
appId: json['appId'] as String, |
||||||
|
journal: SyncJournal.fromJson(json['journal'] as Map<String, dynamic>), |
||||||
|
remotePath: Uri.parse(json['remotePath'] as String), |
||||||
|
localPath: FilesSyncMapping._directoryFromJson(json['localPath'] as String), |
||||||
|
); |
||||||
|
|
||||||
|
Map<String, dynamic> _$FilesSyncMappingToJson(FilesSyncMapping instance) => <String, dynamic>{ |
||||||
|
'accountId': instance.accountId, |
||||||
|
'appId': instance.appId, |
||||||
|
'journal': instance.journal, |
||||||
|
'remotePath': instance.remotePath.toString(), |
||||||
|
'localPath': FilesSyncMapping._directoryToJson(instance.localPath), |
||||||
|
}; |
@ -0,0 +1,142 @@ |
|||||||
|
part of '../neon_files.dart'; |
||||||
|
|
||||||
|
class FilesSyncSources implements SyncSources<WebDavFile, FileSystemEntity> { |
||||||
|
FilesSyncSources( |
||||||
|
final NextcloudClient client, |
||||||
|
final PathUri webdavBaseDir, |
||||||
|
final Directory ioBaseDir, |
||||||
|
) : sourceA = FilesSyncSourceWebDavFile(client, webdavBaseDir), |
||||||
|
sourceB = FilesSyncSourceFileSystemEntity(client, ioBaseDir); |
||||||
|
|
||||||
|
@override |
||||||
|
final SyncSource<WebDavFile, FileSystemEntity> sourceA; |
||||||
|
|
||||||
|
@override |
||||||
|
final SyncSource<FileSystemEntity, WebDavFile> sourceB; |
||||||
|
|
||||||
|
@override |
||||||
|
SyncConflictSolution? findSolution(final SyncObject<WebDavFile> objectA, final SyncObject<FileSystemEntity> objectB) { |
||||||
|
if (objectA.data.isDirectory && objectB.data is Directory) { |
||||||
|
return SyncConflictSolution.overwriteA; |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class FilesSyncSourceWebDavFile implements SyncSource<WebDavFile, FileSystemEntity> { |
||||||
|
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<dynamic> object) => baseDir.join(PathUri.parse(object.id)); |
||||||
|
|
||||||
|
@override |
||||||
|
Future<List<SyncObject<WebDavFile>>> 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<String> getObjectETag(final SyncObject<WebDavFile> object) async => |
||||||
|
object.data.isDirectory ? '' : object.data.etag!; |
||||||
|
|
||||||
|
@override |
||||||
|
Future<SyncObject<WebDavFile>> writeObject(final SyncObject<FileSystemEntity> 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<void> deleteObject(final SyncObject<WebDavFile> object) async => client.webdav.delete(_uri(object)); |
||||||
|
} |
||||||
|
|
||||||
|
class FilesSyncSourceFileSystemEntity implements SyncSource<FileSystemEntity, WebDavFile> { |
||||||
|
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<List<SyncObject<FileSystemEntity>>> 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<String> getObjectETag(final SyncObject<FileSystemEntity> object) async => |
||||||
|
object.data is Directory ? '' : object.data.statSync().modified.millisecondsSinceEpoch.toString(); |
||||||
|
|
||||||
|
@override |
||||||
|
Future<SyncObject<FileSystemEntity>> writeObject(final SyncObject<WebDavFile> 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<void> deleteObject(final SyncObject<FileSystemEntity> object) async => object.data.delete(); |
||||||
|
} |
@ -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, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue