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