diff --git a/packages/app/integration_test/screenshot_test.dart b/packages/app/integration_test/screenshot_test.dart index f108b1c8..14bec1f1 100644 --- a/packages/app/integration_test/screenshot_test.dart +++ b/packages/app/integration_test/screenshot_test.dart @@ -9,7 +9,7 @@ import 'package:integration_test/integration_test.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:neon/models.dart'; import 'package:neon/neon.dart'; -import 'package:neon_files/neon_files.dart'; +import 'package:neon_files/widgets/actions.dart'; import 'package:shared_preferences/shared_preferences.dart'; class MemorySharedPreferences implements SharedPreferences { diff --git a/packages/neon/neon_files/lib/blocs/files.dart b/packages/neon/neon_files/lib/blocs/files.dart index 414ad37e..d9bee11f 100644 --- a/packages/neon/neon_files/lib/blocs/files.dart +++ b/packages/neon/neon_files/lib/blocs/files.dart @@ -21,9 +21,7 @@ abstract class FilesBlocEvents { } abstract class FilesBlocStates { - BehaviorSubject> get uploadTasks; - - BehaviorSubject> get downloadTasks; + BehaviorSubject> get tasks; } class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocStates { @@ -50,18 +48,14 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta void dispose() { _uploadQueue.dispose(); _downloadQueue.dispose(); - unawaited(uploadTasks.close()); - unawaited(downloadTasks.close()); + unawaited(tasks.close()); options.uploadQueueParallelism.removeListener(_uploadParalelismListener); options.downloadQueueParallelism.removeListener(_downloadParalelismListener); } @override - BehaviorSubject> uploadTasks = BehaviorSubject>.seeded([]); - - @override - BehaviorSubject> downloadTasks = BehaviorSubject>.seeded([]); + BehaviorSubject> tasks = BehaviorSubject>.seeded([]); @override void addFavorite(final List path) { @@ -169,14 +163,14 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta final file = File(localPath); // ignore: avoid_slow_async_io final stat = await file.stat(); - final task = UploadTask( + final task = FilesUploadTask( path: path, size: stat.size, lastModified: stat.modified, ); - uploadTasks.add(uploadTasks.value..add(task)); + tasks.add(tasks.value..add(task)); await _uploadQueue.add(() => task.execute(account.client, file.openRead())); - uploadTasks.add(uploadTasks.value..removeWhere((final t) => t == task)); + tasks.add(tasks.value..remove(task)); }, disableTimeout: true, ); @@ -188,12 +182,12 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta ) async { final sink = file.openWrite(); try { - final task = DownloadTask( + final task = FilesDownloadTask( path: path, ); - downloadTasks.add(downloadTasks.value..add(task)); + tasks.add(tasks.value..add(task)); await _downloadQueue.add(() => task.execute(account.client, sink)); - downloadTasks.add(downloadTasks.value..removeWhere((final t) => t == task)); + tasks.add(tasks.value..remove(task)); } finally { await sink.close(); } diff --git a/packages/neon/neon_files/lib/models/file_details.dart b/packages/neon/neon_files/lib/models/file_details.dart index dec33ff5..c999be2c 100644 --- a/packages/neon/neon_files/lib/models/file_details.dart +++ b/packages/neon/neon_files/lib/models/file_details.dart @@ -11,7 +11,58 @@ class FileDetails { required this.lastModified, required this.hasPreview, required this.isFavorite, - }); + }) : task = null; + + FileDetails.fromWebDav({ + required final WebDavFile file, + required final List path, + }) : path = List.from(path)..add(file.name), + isDirectory = file.isDirectory, + size = file.size, + etag = file.etag, + mimeType = file.mimeType, + lastModified = file.lastModified, + hasPreview = file.hasPreview, + isFavorite = file.favorite, + task = null; + + FileDetails.fromUploadTask({ + required FilesUploadTask this.task, + }) : path = task.path, + size = task.size, + lastModified = task.lastModified, + isDirectory = false, + etag = null, + mimeType = null, + hasPreview = null, + isFavorite = null; + + FileDetails.fromDownloadTask({ + required FilesDownloadTask this.task, + required final WebDavFile file, + }) : path = task.path, + isDirectory = file.isDirectory, + size = file.size, + etag = file.etag, + mimeType = file.mimeType, + lastModified = file.lastModified, + hasPreview = file.hasPreview, + isFavorite = file.favorite; + + factory FileDetails.fromTask({ + required final FilesTask task, + required final WebDavFile file, + }) { + switch (task) { + case FilesUploadTask(): + return FileDetails.fromUploadTask(task: task); + case FilesDownloadTask(): + return FileDetails.fromDownloadTask( + task: task, + file: file, + ); + } + } String get name => path.last; @@ -30,4 +81,8 @@ class FileDetails { final bool? hasPreview; final bool? isFavorite; + + final FilesTask? task; + + bool get hasTask => task != null; } diff --git a/packages/neon/neon_files/lib/neon_files.dart b/packages/neon/neon_files/lib/neon_files.dart index dbd95530..2f01f134 100644 --- a/packages/neon/neon_files/lib/neon_files.dart +++ b/packages/neon/neon_files/lib/neon_files.dart @@ -19,11 +19,11 @@ import 'package:neon/nextcloud.dart'; import 'package:neon/platform.dart'; import 'package:neon/settings.dart'; import 'package:neon/sort_box.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/widgets/file_list_tile.dart'; import 'package:open_file/open_file.dart'; import 'package:path/path.dart' as p; import 'package:provider/provider.dart'; @@ -40,8 +40,7 @@ part 'options.dart'; part 'pages/details.dart'; part 'pages/main.dart'; part 'sort/files.dart'; -part 'utils/download_task.dart'; -part 'utils/upload_task.dart'; +part 'utils/task.dart'; part 'widgets/browser_view.dart'; part 'widgets/file_preview.dart'; diff --git a/packages/neon/neon_files/lib/utils/download_task.dart b/packages/neon/neon_files/lib/utils/download_task.dart deleted file mode 100644 index 424fc04a..00000000 --- a/packages/neon/neon_files/lib/utils/download_task.dart +++ /dev/null @@ -1,32 +0,0 @@ -part of '../neon_files.dart'; - -class DownloadTask { - DownloadTask({ - required this.path, - }); - - final List path; - - final _streamController = StreamController(); - late final progress = _streamController.stream.asBroadcastStream(); - - Future execute(final NextcloudClient client, final IOSink sink) async { - final completer = Completer(); - - final response = await client.webdav.getStream(path.join('/')); - var downloaded = 0; - - response.listen((final chunk) async { - sink.add(chunk); - - downloaded += chunk.length; - _streamController.add((downloaded / response.contentLength * 100).toInt()); - - if (downloaded >= response.contentLength) { - completer.complete(); - } - }); - - return completer.future; - } -} diff --git a/packages/neon/neon_files/lib/utils/task.dart b/packages/neon/neon_files/lib/utils/task.dart new file mode 100644 index 00000000..09dcc1dc --- /dev/null +++ b/packages/neon/neon_files/lib/utils/task.dart @@ -0,0 +1,66 @@ +part of '../neon_files.dart'; + +sealed class FilesTask { + FilesTask({ + required this.path, + }); + + final List path; + + @protected + final streamController = StreamController(); + + /// Task progress in percent [0, 1]. + late final progress = streamController.stream.asBroadcastStream(); +} + +class FilesDownloadTask extends FilesTask { + FilesDownloadTask({ + required super.path, + }); + + Future execute(final NextcloudClient client, final IOSink sink) async { + final completer = Completer(); + + final response = await client.webdav.getStream(path.join('/')); + var downloaded = 0; + + response.listen((final chunk) async { + sink.add(chunk); + + downloaded += chunk.length; + streamController.add(downloaded / response.contentLength); + + if (downloaded >= response.contentLength) { + completer.complete(); + } + }); + + return completer.future; + } +} + +class FilesUploadTask extends FilesTask { + FilesUploadTask({ + required super.path, + required this.size, + required this.lastModified, + }); + + final int size; + final DateTime lastModified; + + Future execute(final NextcloudClient client, final Stream> stream) async { + var uploaded = 0; + await client.webdav.putStream( + stream.map((final chunk) { + uploaded += chunk.length; + streamController.add(uploaded / size); + + return Uint8List.fromList(chunk); + }), + path.join('/'), + lastModified: lastModified, + ); + } +} diff --git a/packages/neon/neon_files/lib/utils/upload_task.dart b/packages/neon/neon_files/lib/utils/upload_task.dart deleted file mode 100644 index 905f9caf..00000000 --- a/packages/neon/neon_files/lib/utils/upload_task.dart +++ /dev/null @@ -1,30 +0,0 @@ -part of '../neon_files.dart'; - -class UploadTask { - UploadTask({ - required this.path, - required this.size, - required this.lastModified, - }); - - final List path; - final int size; - final DateTime lastModified; - - final _streamController = StreamController(); - late final progress = _streamController.stream.asBroadcastStream(); - - Future execute(final NextcloudClient client, final Stream> stream) async { - var uploaded = 0; - await client.webdav.putStream( - stream.map((final chunk) { - uploaded += chunk.length; - _streamController.add((uploaded / size * 100).toInt()); - - return Uint8List.fromList(chunk); - }), - path.join('/'), - lastModified: lastModified, - ); - } -} diff --git a/packages/neon/neon_files/lib/widgets/actions.dart b/packages/neon/neon_files/lib/widgets/actions.dart new file mode 100644 index 00000000..59896af1 --- /dev/null +++ b/packages/neon/neon_files/lib/widgets/actions.dart @@ -0,0 +1,163 @@ +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:neon/utils.dart'; +import 'package:neon_files/l10n/localizations.dart'; +import 'package:neon_files/neon_files.dart'; +import 'package:provider/provider.dart'; + +class FileActions extends StatelessWidget { + const FileActions({ + required this.details, + super.key, + }); + + final FileDetails details; + + Future onSelected(final BuildContext context, final FilesFileAction action) async { + final bloc = Provider.of(context, listen: false); + final browserBloc = bloc.browser; + switch (action) { + case FilesFileAction.toggleFavorite: + if (details.isFavorite ?? false) { + bloc.removeFavorite(details.path); + } else { + bloc.addFavorite(details.path); + } + break; + case FilesFileAction.details: + await Navigator.of(context).push( + MaterialPageRoute( + builder: (final context) => FilesDetailsPage( + bloc: bloc, + details: details, + ), + ), + ); + break; + case FilesFileAction.rename: + final result = await showRenameDialog( + context: context, + title: + details.isDirectory ? AppLocalizations.of(context).folderRename : AppLocalizations.of(context).fileRename, + value: details.name, + ); + if (result != null) { + bloc.rename(details.path, result); + } + break; + case FilesFileAction.move: + final b = bloc.getNewFilesBrowserBloc(); + final originalPath = details.path.sublist(0, details.path.length - 1); + b.setPath(originalPath); + final result = await showDialog?>( + context: context, + builder: (final context) => FilesChooseFolderDialog( + bloc: b, + filesBloc: bloc, + originalPath: originalPath, + ), + ); + b.dispose(); + if (result != null) { + bloc.move(details.path, result..add(details.name)); + } + break; + case FilesFileAction.copy: + final b = bloc.getNewFilesBrowserBloc(); + final originalPath = details.path.sublist(0, details.path.length - 1); + b.setPath(originalPath); + final result = await showDialog?>( + context: context, + builder: (final context) => FilesChooseFolderDialog( + bloc: b, + filesBloc: bloc, + originalPath: originalPath, + ), + ); + b.dispose(); + if (result != null) { + bloc.copy(details.path, result..add(details.name)); + } + break; + case FilesFileAction.sync: + final sizeWarning = browserBloc.options.downloadSizeWarning.value; + if (sizeWarning != null && details.size != null && details.size! > sizeWarning) { + if (!(await showConfirmationDialog( + context, + AppLocalizations.of(context).downloadConfirmSizeWarning( + filesize(sizeWarning), + filesize(details.size), + ), + ))) { + return; + } + } + bloc.syncFile(details.path); + break; + case FilesFileAction.delete: + if (await showConfirmationDialog( + context, + details.isDirectory + ? AppLocalizations.of(context).folderDeleteConfirm(details.name) + : AppLocalizations.of(context).fileDeleteConfirm(details.name), + )) { + bloc.delete(details.path); + } + break; + } + } + + @override + Widget build(final BuildContext context) => PopupMenuButton( + itemBuilder: (final context) => [ + if (details.isFavorite != null) ...[ + PopupMenuItem( + value: FilesFileAction.toggleFavorite, + child: Text( + details.isFavorite! + ? AppLocalizations.of(context).removeFromFavorites + : AppLocalizations.of(context).addToFavorites, + ), + ), + ], + PopupMenuItem( + value: FilesFileAction.details, + child: Text(AppLocalizations.of(context).details), + ), + PopupMenuItem( + value: FilesFileAction.rename, + child: Text(AppLocalizations.of(context).actionRename), + ), + PopupMenuItem( + value: FilesFileAction.move, + child: Text(AppLocalizations.of(context).actionMove), + ), + PopupMenuItem( + value: FilesFileAction.copy, + child: Text(AppLocalizations.of(context).actionCopy), + ), + // TODO: https://github.com/provokateurin/nextcloud-neon/issues/4 + if (!details.isDirectory) ...[ + PopupMenuItem( + value: FilesFileAction.sync, + child: Text(AppLocalizations.of(context).actionSync), + ), + ], + PopupMenuItem( + value: FilesFileAction.delete, + child: Text(AppLocalizations.of(context).actionDelete), + ), + ], + onSelected: (final action) async => onSelected(context, action), + ); +} + +enum FilesFileAction { + toggleFavorite, + details, + 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 543529f9..b65d5cfd 100644 --- a/packages/neon/neon_files/lib/widgets/browser_view.dart +++ b/packages/neon/neon_files/lib/widgets/browser_view.dart @@ -36,171 +36,136 @@ class _FilesBrowserViewState extends State { stream: widget.bloc.files, builder: (final context, final files) => StreamBuilder>( stream: widget.bloc.path, - builder: (final context, final pathSnapshot) => StreamBuilder>( - stream: widget.filesBloc.uploadTasks, - builder: (final context, final uploadTasksSnapshot) => StreamBuilder>( - stream: widget.filesBloc.downloadTasks, - builder: (final context, final downloadTasksSnapshot) => !pathSnapshot.hasData || - !uploadTasksSnapshot.hasData || - !downloadTasksSnapshot.hasData - ? const SizedBox() - : BackButtonListener( - onBackButtonPressed: () async { - final path = pathSnapshot.requireData; - if (path.isNotEmpty) { - widget.bloc.setPath(path.sublist(0, path.length - 1)); - return true; - } - return false; - }, - child: SortBoxBuilder( - sortBox: filesSortBox, - sortPropertyOption: widget.bloc.options.filesSortPropertyOption, - sortBoxOrderOption: widget.bloc.options.filesSortBoxOrderOption, - input: files.data, - builder: (final context, final sorted) => NeonListView( - scrollKey: 'files-${pathSnapshot.requireData.join('/')}', - withFloatingActionButton: true, - items: [ - for (final uploadTask in sorted == null - ? [] - : uploadTasksSnapshot.requireData.where( - (final task) => - sorted.where((final file) => _pathMatchesFile(task.path, file.name)).isEmpty, - )) ...[ - StreamBuilder( - stream: uploadTask.progress, - builder: (final context, final uploadTaskProgressSnapshot) => - !uploadTaskProgressSnapshot.hasData - ? const SizedBox() - : _buildFile( - context: context, - details: FileDetails( - path: uploadTask.path, - isDirectory: false, - size: uploadTask.size, - etag: null, - mimeType: null, - lastModified: uploadTask.lastModified, - hasPreview: null, - isFavorite: null, - ), - uploadProgress: uploadTaskProgressSnapshot.data, - downloadProgress: null, - ), + builder: (final context, final pathSnapshot) => StreamBuilder>( + stream: widget.filesBloc.tasks, + builder: (final context, final tasksSnapshot) => !pathSnapshot.hasData || !tasksSnapshot.hasData + ? const SizedBox() + : BackButtonListener( + onBackButtonPressed: () async { + final path = pathSnapshot.requireData; + if (path.isNotEmpty) { + widget.bloc.setPath(path.sublist(0, path.length - 1)); + return true; + } + return false; + }, + child: SortBoxBuilder( + sortBox: filesSortBox, + sortPropertyOption: widget.bloc.options.filesSortPropertyOption, + sortBoxOrderOption: widget.bloc.options.filesSortBoxOrderOption, + input: files.data, + builder: (final context, final sorted) => NeonListView( + scrollKey: 'files-${pathSnapshot.requireData.join('/')}', + withFloatingActionButton: true, + items: [ + for (final uploadTask in tasksSnapshot.requireData.whereType().where( + (final task) => + sorted?.where((final file) => _pathMatchesFile(task.path, file.name)).isEmpty ?? + false, + )) ...[ + FileListTile( + context: context, + details: FileDetails.fromUploadTask( + task: uploadTask, ), - ], - if (sorted != null) ...[ - for (final file in sorted) ...[ - if (!widget.onlyShowDirectories || file.isDirectory) ...[ - Builder( - builder: (final context) { - final matchingUploadTasks = uploadTasksSnapshot.requireData - .where((final task) => _pathMatchesFile(task.path, file.name)); - final matchingDownloadTasks = downloadTasksSnapshot.requireData - .where((final task) => _pathMatchesFile(task.path, file.name)); + enableFileActions: widget.enableFileActions, + onPickFile: widget.onPickFile, + ), + ], + if (sorted != null) ...[ + for (final file in sorted) ...[ + if (!widget.onlyShowDirectories || file.isDirectory) ...[ + Builder( + builder: (final context) { + final matchingTask = tasksSnapshot.requireData + .firstWhereOrNull((final task) => _pathMatchesFile(task.path, file.name)); - return StreamBuilder( - stream: matchingUploadTasks.isNotEmpty - ? matchingUploadTasks.first.progress - : Stream.value(null), - builder: (final context, final uploadTaskProgressSnapshot) => - StreamBuilder( - stream: matchingDownloadTasks.isNotEmpty - ? matchingDownloadTasks.first.progress - : Stream.value(null), - builder: (final context, final downloadTaskProgressSnapshot) => _buildFile( - context: context, - details: FileDetails( - path: [...widget.bloc.path.value, file.name], - isDirectory: matchingUploadTasks.isEmpty && file.isDirectory, - size: matchingUploadTasks.isNotEmpty - ? matchingUploadTasks.first.size - : file.size, - etag: matchingUploadTasks.isNotEmpty ? null : file.etag, - mimeType: matchingUploadTasks.isNotEmpty ? null : file.mimeType, - lastModified: matchingUploadTasks.isNotEmpty - ? matchingUploadTasks.first.lastModified - : file.lastModified, - hasPreview: matchingUploadTasks.isNotEmpty ? null : file.hasPreview, - isFavorite: matchingUploadTasks.isNotEmpty ? null : file.favorite, - ), - uploadProgress: uploadTaskProgressSnapshot.data, - downloadProgress: downloadTaskProgressSnapshot.data, - ), - ), - ); - }, - ), - ], + final details = matchingTask != null + ? FileDetails.fromTask( + task: matchingTask, + file: file, + ) + : FileDetails.fromWebDav( + file: file, + path: widget.bloc.path.value, + ); + + return FileListTile( + context: context, + details: details, + enableFileActions: widget.enableFileActions, + onPickFile: widget.onPickFile, + ); + }, + ), ], ], ], - isLoading: files.isLoading, - error: files.error, - onRefresh: widget.bloc.refresh, - builder: (final context, final widget) => widget, - topScrollingChildren: [ - Align( - alignment: Alignment.topLeft, - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 10, - ), - child: Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - children: [ - IconButton( - padding: EdgeInsets.zero, - visualDensity: const VisualDensity( - horizontal: VisualDensity.minimumDensity, - vertical: VisualDensity.minimumDensity, - ), - tooltip: AppLocalizations.of(context).goToPath(''), - icon: const Icon( - Icons.house, - size: 30, - ), - onPressed: () { - widget.bloc.setPath([]); - }, + ], + isLoading: files.isLoading, + error: files.error, + onRefresh: widget.bloc.refresh, + builder: (final context, final widget) => widget, + topScrollingChildren: [ + Align( + alignment: Alignment.topLeft, + child: Container( + margin: const EdgeInsets.symmetric( + horizontal: 10, + ), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + IconButton( + padding: EdgeInsets.zero, + visualDensity: const VisualDensity( + horizontal: VisualDensity.minimumDensity, + vertical: VisualDensity.minimumDensity, ), - for (var i = 0; i < pathSnapshot.requireData.length; i++) ...[ - Builder( - builder: (final context) { - final path = pathSnapshot.requireData.sublist(0, i + 1); - return Tooltip( - message: AppLocalizations.of(context).goToPath(path.join('/')), - excludeFromSemantics: true, - child: TextButton( - onPressed: () { - widget.bloc.setPath(path); - }, - child: Text( - pathSnapshot.requireData[i], - semanticsLabel: AppLocalizations.of(context).goToPath(path.join('/')), - ), + tooltip: AppLocalizations.of(context).goToPath(''), + icon: const Icon( + Icons.house, + size: 30, + ), + onPressed: () { + widget.bloc.setPath([]); + }, + ), + for (var i = 0; i < pathSnapshot.requireData.length; i++) ...[ + Builder( + builder: (final context) { + final path = pathSnapshot.requireData.sublist(0, i + 1); + return Tooltip( + message: AppLocalizations.of(context).goToPath(path.join('/')), + excludeFromSemantics: true, + child: TextButton( + onPressed: () { + widget.bloc.setPath(path); + }, + child: Text( + pathSnapshot.requireData[i], + semanticsLabel: AppLocalizations.of(context).goToPath(path.join('/')), ), - ); - }, + ), + ); + }, + ), + ], + ] + .intersperse( + const Icon( + Icons.keyboard_arrow_right, + size: 30, ), - ], - ] - .intersperse( - const Icon( - Icons.keyboard_arrow_right, - size: 30, - ), - ) - .toList(), - ), + ) + .toList(), ), ), - ], - ), + ), + ], ), ), - ), + ), ), ), ); @@ -209,233 +174,4 @@ class _FilesBrowserViewState extends State { [...widget.bloc.path.value, name], path, ); - - Widget _buildFile({ - required final BuildContext context, - required final FileDetails details, - required final int? uploadProgress, - required final int? downloadProgress, - }) => - ListTile( - // When the ETag is null it means we are uploading this file right now - onTap: details.isDirectory || details.etag != null - ? () async { - if (details.isDirectory) { - widget.bloc.setPath(details.path); - } else { - if (widget.onPickFile != null) { - widget.onPickFile!.call(details); - } - } - } - : null, - title: Text( - 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: Stack( - children: [ - 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: widget.filesBloc, - details: details, - withBackground: true, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - ), - if (details.isFavorite ?? false) ...[ - const Align( - alignment: Alignment.bottomRight, - child: Icon( - Icons.star, - size: 14, - color: NcColors.starredColor, - ), - ), - ], - ], - ), - ), - trailing: uploadProgress == null && downloadProgress == null && widget.enableFileActions - ? PopupMenuButton( - itemBuilder: (final context) => [ - if (details.isFavorite != null) ...[ - PopupMenuItem( - value: FilesFileAction.toggleFavorite, - child: Text( - details.isFavorite! - ? AppLocalizations.of(context).removeFromFavorites - : AppLocalizations.of(context).addToFavorites, - ), - ), - ], - PopupMenuItem( - value: FilesFileAction.details, - child: Text(AppLocalizations.of(context).details), - ), - PopupMenuItem( - value: FilesFileAction.rename, - child: Text(AppLocalizations.of(context).actionRename), - ), - PopupMenuItem( - value: FilesFileAction.move, - child: Text(AppLocalizations.of(context).actionMove), - ), - PopupMenuItem( - value: FilesFileAction.copy, - child: Text(AppLocalizations.of(context).actionCopy), - ), - // TODO: https://github.com/nextcloud/neon/issues/4 - if (!details.isDirectory) ...[ - PopupMenuItem( - value: FilesFileAction.sync, - child: Text(AppLocalizations.of(context).actionSync), - ), - ], - PopupMenuItem( - value: FilesFileAction.delete, - child: Text(AppLocalizations.of(context).actionDelete), - ), - ], - onSelected: (final action) async { - switch (action) { - case FilesFileAction.toggleFavorite: - if (details.isFavorite ?? false) { - widget.filesBloc.removeFavorite(details.path); - } else { - widget.filesBloc.addFavorite(details.path); - } - break; - case FilesFileAction.details: - await Navigator.of(context).push( - MaterialPageRoute( - builder: (final context) => FilesDetailsPage( - bloc: widget.filesBloc, - details: details, - ), - ), - ); - break; - case FilesFileAction.rename: - final result = await showRenameDialog( - context: context, - title: details.isDirectory - ? AppLocalizations.of(context).folderRename - : AppLocalizations.of(context).fileRename, - value: details.name, - ); - if (result != null) { - widget.filesBloc.rename(details.path, result); - } - break; - case FilesFileAction.move: - final b = widget.filesBloc.getNewFilesBrowserBloc(); - final originalPath = details.path.sublist(0, details.path.length - 1); - b.setPath(originalPath); - final result = await showDialog?>( - context: context, - builder: (final context) => FilesChooseFolderDialog( - bloc: b, - filesBloc: widget.filesBloc, - originalPath: originalPath, - ), - ); - b.dispose(); - if (result != null) { - widget.filesBloc.move(details.path, result..add(details.name)); - } - break; - case FilesFileAction.copy: - final b = widget.filesBloc.getNewFilesBrowserBloc(); - final originalPath = details.path.sublist(0, details.path.length - 1); - b.setPath(originalPath); - final result = await showDialog?>( - context: context, - builder: (final context) => FilesChooseFolderDialog( - bloc: b, - filesBloc: widget.filesBloc, - originalPath: originalPath, - ), - ); - b.dispose(); - if (result != null) { - widget.filesBloc.copy(details.path, result..add(details.name)); - } - break; - case FilesFileAction.sync: - final sizeWarning = widget.bloc.options.downloadSizeWarning.value; - if (sizeWarning != null && details.size != null && details.size! > sizeWarning) { - if (!(await showConfirmationDialog( - context, - AppLocalizations.of(context).downloadConfirmSizeWarning( - filesize(sizeWarning), - filesize(details.size), - ), - ))) { - return; - } - } - widget.filesBloc.syncFile(details.path); - break; - case FilesFileAction.delete: - if (await showConfirmationDialog( - context, - details.isDirectory - ? AppLocalizations.of(context).folderDeleteConfirm(details.name) - : AppLocalizations.of(context).fileDeleteConfirm(details.name), - )) { - widget.filesBloc.delete(details.path); - } - break; - } - }, - ) - : const SizedBox.square( - dimension: 48, - ), - ); -} - -enum FilesFileAction { - toggleFavorite, - details, - 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 new file mode 100644 index 00000000..9cb70a40 --- /dev/null +++ b/packages/neon/neon_files/lib/widgets/file_list_tile.dart @@ -0,0 +1,135 @@ +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; +import 'package:neon/widgets.dart'; +import 'package:neon_files/neon_files.dart'; +import 'package:neon_files/widgets/actions.dart'; +import 'package:provider/provider.dart'; + +class FileListTile extends StatelessWidget { + const FileListTile({ + required this.context, + required this.details, + this.enableFileActions = true, + this.onPickFile, + super.key, + }); + + final BuildContext context; + final FileDetails details; + final bool enableFileActions; + final Function(FileDetails)? onPickFile; + + @override + Widget build(final BuildContext context) { + final bloc = Provider.of(context); + final browserBloc = bloc.browser; + + // When the ETag is null it means we are uploading this file right now + final onTap = details.isDirectory || details.etag != null + ? () { + if (details.isDirectory) { + browserBloc.setPath(details.path); + } else { + onPickFile?.call(details); + } + } + : null; + + return ListTile( + onTap: onTap, + title: Text( + 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: _FileIcon( + details: details, + ), + trailing: !details.hasTask && enableFileActions + ? FileActions(details: details) + : const SizedBox.square( + dimension: 48, + ), + ); + } +} + +class _FileIcon extends StatelessWidget { + const _FileIcon({ + required this.details, + }); + + final FileDetails details; + + @override + Widget build(final BuildContext context) { + final bloc = Provider.of(context); + + Widget icon = Center( + child: details.hasTask + ? StreamBuilder( + stream: details.task!.progress, + builder: (final context, final progress) => Column( + children: [ + Icon( + switch (details.task!) { + FilesUploadTask() => MdiIcons.upload, + FilesDownloadTask() => MdiIcons.download, + }, + color: Theme.of(context).colorScheme.primary, + ), + LinearProgressIndicator( + value: progress.data, + ), + ], + ), + ) + : FilePreview( + bloc: bloc, + 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: Colors.yellow, + ), + ) + ], + ); + } + + return SizedBox.square( + dimension: 40, + child: icon, + ); + } +}