Browse Source

Merge pull request #556 from nextcloud/reactor/files_app

Reactor/files app
pull/578/head
Nikolas Rimikis 1 year ago committed by GitHub
parent
commit
73770655dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/app/integration_test/screenshot_test.dart
  2. 24
      packages/neon/neon_files/lib/blocs/files.dart
  3. 57
      packages/neon/neon_files/lib/models/file_details.dart
  4. 5
      packages/neon/neon_files/lib/neon_files.dart
  5. 32
      packages/neon/neon_files/lib/utils/download_task.dart
  6. 66
      packages/neon/neon_files/lib/utils/task.dart
  7. 30
      packages/neon/neon_files/lib/utils/upload_task.dart
  8. 163
      packages/neon/neon_files/lib/widgets/actions.dart
  9. 500
      packages/neon/neon_files/lib/widgets/browser_view.dart
  10. 135
      packages/neon/neon_files/lib/widgets/file_list_tile.dart

2
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:material_design_icons_flutter/material_design_icons_flutter.dart';
import 'package:neon/models.dart'; import 'package:neon/models.dart';
import 'package:neon/neon.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'; import 'package:shared_preferences/shared_preferences.dart';
class MemorySharedPreferences implements SharedPreferences { class MemorySharedPreferences implements SharedPreferences {

24
packages/neon/neon_files/lib/blocs/files.dart

@ -21,9 +21,7 @@ abstract class FilesBlocEvents {
} }
abstract class FilesBlocStates { abstract class FilesBlocStates {
BehaviorSubject<List<UploadTask>> get uploadTasks; BehaviorSubject<List<FilesTask>> get tasks;
BehaviorSubject<List<DownloadTask>> get downloadTasks;
} }
class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocStates { class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocStates {
@ -50,18 +48,14 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta
void dispose() { void dispose() {
_uploadQueue.dispose(); _uploadQueue.dispose();
_downloadQueue.dispose(); _downloadQueue.dispose();
unawaited(uploadTasks.close()); unawaited(tasks.close());
unawaited(downloadTasks.close());
options.uploadQueueParallelism.removeListener(_uploadParalelismListener); options.uploadQueueParallelism.removeListener(_uploadParalelismListener);
options.downloadQueueParallelism.removeListener(_downloadParalelismListener); options.downloadQueueParallelism.removeListener(_downloadParalelismListener);
} }
@override @override
BehaviorSubject<List<UploadTask>> uploadTasks = BehaviorSubject<List<UploadTask>>.seeded([]); BehaviorSubject<List<FilesTask>> tasks = BehaviorSubject<List<FilesTask>>.seeded([]);
@override
BehaviorSubject<List<DownloadTask>> downloadTasks = BehaviorSubject<List<DownloadTask>>.seeded([]);
@override @override
void addFavorite(final List<String> path) { void addFavorite(final List<String> path) {
@ -169,14 +163,14 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta
final file = File(localPath); final file = File(localPath);
// ignore: avoid_slow_async_io // ignore: avoid_slow_async_io
final stat = await file.stat(); final stat = await file.stat();
final task = UploadTask( final task = FilesUploadTask(
path: path, path: path,
size: stat.size, size: stat.size,
lastModified: stat.modified, lastModified: stat.modified,
); );
uploadTasks.add(uploadTasks.value..add(task)); tasks.add(tasks.value..add(task));
await _uploadQueue.add(() => task.execute(account.client, file.openRead())); 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, disableTimeout: true,
); );
@ -188,12 +182,12 @@ class FilesBloc extends InteractiveBloc implements FilesBlocEvents, FilesBlocSta
) async { ) async {
final sink = file.openWrite(); final sink = file.openWrite();
try { try {
final task = DownloadTask( final task = FilesDownloadTask(
path: path, path: path,
); );
downloadTasks.add(downloadTasks.value..add(task)); tasks.add(tasks.value..add(task));
await _downloadQueue.add(() => task.execute(account.client, sink)); await _downloadQueue.add(() => task.execute(account.client, sink));
downloadTasks.add(downloadTasks.value..removeWhere((final t) => t == task)); tasks.add(tasks.value..remove(task));
} finally { } finally {
await sink.close(); await sink.close();
} }

57
packages/neon/neon_files/lib/models/file_details.dart

@ -11,7 +11,58 @@ class FileDetails {
required this.lastModified, required this.lastModified,
required this.hasPreview, required this.hasPreview,
required this.isFavorite, required this.isFavorite,
}); }) : task = null;
FileDetails.fromWebDav({
required final WebDavFile file,
required final List<String> 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; String get name => path.last;
@ -30,4 +81,8 @@ class FileDetails {
final bool? hasPreview; final bool? hasPreview;
final bool? isFavorite; final bool? isFavorite;
final FilesTask? task;
bool get hasTask => task != null;
} }

5
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/platform.dart';
import 'package:neon/settings.dart'; import 'package:neon/settings.dart';
import 'package:neon/sort_box.dart'; import 'package:neon/sort_box.dart';
import 'package:neon/theme.dart';
import 'package:neon/utils.dart'; import 'package:neon/utils.dart';
import 'package:neon/widgets.dart'; import 'package:neon/widgets.dart';
import 'package:neon_files/l10n/localizations.dart'; import 'package:neon_files/l10n/localizations.dart';
import 'package:neon_files/routes.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:open_file/open_file.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@ -40,8 +40,7 @@ part 'options.dart';
part 'pages/details.dart'; part 'pages/details.dart';
part 'pages/main.dart'; part 'pages/main.dart';
part 'sort/files.dart'; part 'sort/files.dart';
part 'utils/download_task.dart'; part 'utils/task.dart';
part 'utils/upload_task.dart';
part 'widgets/browser_view.dart'; part 'widgets/browser_view.dart';
part 'widgets/file_preview.dart'; part 'widgets/file_preview.dart';

32
packages/neon/neon_files/lib/utils/download_task.dart

@ -1,32 +0,0 @@
part of '../neon_files.dart';
class DownloadTask {
DownloadTask({
required this.path,
});
final List<String> path;
final _streamController = StreamController<int>();
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;
}
}

66
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<String> path;
@protected
final streamController = StreamController<double>();
/// 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<List<int>> 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,
);
}
}

30
packages/neon/neon_files/lib/utils/upload_task.dart

@ -1,30 +0,0 @@
part of '../neon_files.dart';
class UploadTask {
UploadTask({
required this.path,
required this.size,
required this.lastModified,
});
final List<String> path;
final int size;
final DateTime lastModified;
final _streamController = StreamController<int>();
late final progress = _streamController.stream.asBroadcastStream();
Future execute(final NextcloudClient client, final Stream<List<int>> 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,
);
}
}

163
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<void> onSelected(final BuildContext context, final FilesFileAction action) async {
final bloc = Provider.of<FilesBloc>(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<List<String>?>(
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<List<String>?>(
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<FilesFileAction>(
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,
}

500
packages/neon/neon_files/lib/widgets/browser_view.dart

@ -36,171 +36,136 @@ class _FilesBrowserViewState extends State<FilesBrowserView> {
stream: widget.bloc.files, stream: widget.bloc.files,
builder: (final context, final files) => StreamBuilder<List<String>>( builder: (final context, final files) => StreamBuilder<List<String>>(
stream: widget.bloc.path, stream: widget.bloc.path,
builder: (final context, final pathSnapshot) => StreamBuilder<List<UploadTask>>( builder: (final context, final pathSnapshot) => StreamBuilder<List<FilesTask>>(
stream: widget.filesBloc.uploadTasks, stream: widget.filesBloc.tasks,
builder: (final context, final uploadTasksSnapshot) => StreamBuilder<List<DownloadTask>>( builder: (final context, final tasksSnapshot) => !pathSnapshot.hasData || !tasksSnapshot.hasData
stream: widget.filesBloc.downloadTasks, ? const SizedBox()
builder: (final context, final downloadTasksSnapshot) => !pathSnapshot.hasData || : BackButtonListener(
!uploadTasksSnapshot.hasData || onBackButtonPressed: () async {
!downloadTasksSnapshot.hasData final path = pathSnapshot.requireData;
? const SizedBox() if (path.isNotEmpty) {
: BackButtonListener( widget.bloc.setPath(path.sublist(0, path.length - 1));
onBackButtonPressed: () async { return true;
final path = pathSnapshot.requireData; }
if (path.isNotEmpty) { return false;
widget.bloc.setPath(path.sublist(0, path.length - 1)); },
return true; child: SortBoxBuilder<FilesSortProperty, WebDavFile>(
} sortBox: filesSortBox,
return false; sortPropertyOption: widget.bloc.options.filesSortPropertyOption,
}, sortBoxOrderOption: widget.bloc.options.filesSortBoxOrderOption,
child: SortBoxBuilder<FilesSortProperty, WebDavFile>( input: files.data,
sortBox: filesSortBox, builder: (final context, final sorted) => NeonListView<Widget>(
sortPropertyOption: widget.bloc.options.filesSortPropertyOption, scrollKey: 'files-${pathSnapshot.requireData.join('/')}',
sortBoxOrderOption: widget.bloc.options.filesSortBoxOrderOption, withFloatingActionButton: true,
input: files.data, items: [
builder: (final context, final sorted) => NeonListView<Widget>( for (final uploadTask in tasksSnapshot.requireData.whereType<FilesUploadTask>().where(
scrollKey: 'files-${pathSnapshot.requireData.join('/')}', (final task) =>
withFloatingActionButton: true, sorted?.where((final file) => _pathMatchesFile(task.path, file.name)).isEmpty ??
items: [ false,
for (final uploadTask in sorted == null )) ...[
? <UploadTask>[] FileListTile(
: uploadTasksSnapshot.requireData.where( context: context,
(final task) => details: FileDetails.fromUploadTask(
sorted.where((final file) => _pathMatchesFile(task.path, file.name)).isEmpty, task: uploadTask,
)) ...[
StreamBuilder<int>(
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,
),
), ),
], enableFileActions: widget.enableFileActions,
if (sorted != null) ...[ onPickFile: widget.onPickFile,
for (final file in sorted) ...[ ),
if (!widget.onlyShowDirectories || file.isDirectory) ...[ ],
Builder( if (sorted != null) ...[
builder: (final context) { for (final file in sorted) ...[
final matchingUploadTasks = uploadTasksSnapshot.requireData if (!widget.onlyShowDirectories || file.isDirectory) ...[
.where((final task) => _pathMatchesFile(task.path, file.name)); Builder(
final matchingDownloadTasks = downloadTasksSnapshot.requireData builder: (final context) {
.where((final task) => _pathMatchesFile(task.path, file.name)); final matchingTask = tasksSnapshot.requireData
.firstWhereOrNull((final task) => _pathMatchesFile(task.path, file.name));
return StreamBuilder<int?>( final details = matchingTask != null
stream: matchingUploadTasks.isNotEmpty ? FileDetails.fromTask(
? matchingUploadTasks.first.progress task: matchingTask,
: Stream.value(null), file: file,
builder: (final context, final uploadTaskProgressSnapshot) => )
StreamBuilder<int?>( : FileDetails.fromWebDav(
stream: matchingDownloadTasks.isNotEmpty file: file,
? matchingDownloadTasks.first.progress path: widget.bloc.path.value,
: Stream.value(null), );
builder: (final context, final downloadTaskProgressSnapshot) => _buildFile(
context: context, return FileListTile(
details: FileDetails( context: context,
path: [...widget.bloc.path.value, file.name], details: details,
isDirectory: matchingUploadTasks.isEmpty && file.isDirectory, enableFileActions: widget.enableFileActions,
size: matchingUploadTasks.isNotEmpty onPickFile: widget.onPickFile,
? 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,
),
),
);
},
),
],
], ],
], ],
], ],
isLoading: files.isLoading, ],
error: files.error, isLoading: files.isLoading,
onRefresh: widget.bloc.refresh, error: files.error,
builder: (final context, final widget) => widget, onRefresh: widget.bloc.refresh,
topScrollingChildren: [ builder: (final context, final widget) => widget,
Align( topScrollingChildren: [
alignment: Alignment.topLeft, Align(
child: Container( alignment: Alignment.topLeft,
margin: const EdgeInsets.symmetric( child: Container(
horizontal: 10, margin: const EdgeInsets.symmetric(
), horizontal: 10,
child: Wrap( ),
crossAxisAlignment: WrapCrossAlignment.center, child: Wrap(
children: <Widget>[ crossAxisAlignment: WrapCrossAlignment.center,
IconButton( children: <Widget>[
padding: EdgeInsets.zero, IconButton(
visualDensity: const VisualDensity( padding: EdgeInsets.zero,
horizontal: VisualDensity.minimumDensity, visualDensity: const VisualDensity(
vertical: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity,
), vertical: VisualDensity.minimumDensity,
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++) ...[ tooltip: AppLocalizations.of(context).goToPath(''),
Builder( icon: const Icon(
builder: (final context) { Icons.house,
final path = pathSnapshot.requireData.sublist(0, i + 1); size: 30,
return Tooltip( ),
message: AppLocalizations.of(context).goToPath(path.join('/')), onPressed: () {
excludeFromSemantics: true, widget.bloc.setPath([]);
child: TextButton( },
onPressed: () { ),
widget.bloc.setPath(path); for (var i = 0; i < pathSnapshot.requireData.length; i++) ...[
}, Builder(
child: Text( builder: (final context) {
pathSnapshot.requireData[i], final path = pathSnapshot.requireData.sublist(0, i + 1);
semanticsLabel: AppLocalizations.of(context).goToPath(path.join('/')), 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,
), ),
], )
] .toList(),
.intersperse(
const Icon(
Icons.keyboard_arrow_right,
size: 30,
),
)
.toList(),
),
), ),
), ),
], ),
), ],
), ),
), ),
), ),
), ),
), ),
); );
@ -209,233 +174,4 @@ class _FilesBrowserViewState extends State<FilesBrowserView> {
[...widget.bloc.path.value, name], [...widget.bloc.path.value, name],
path, 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<FilesFileAction>(
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<List<String>?>(
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<List<String>?>(
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,
} }

135
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<FilesBloc>(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<FilesBloc>(context);
Widget icon = Center(
child: details.hasTask
? StreamBuilder<double>(
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,
);
}
}
Loading…
Cancel
Save