174 lines
5.8 KiB
Dart
174 lines
5.8 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:nextcloud/nextcloud.dart';
|
|
|
|
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
|
|
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
|
|
import '../../../../utils/file_clipboard.dart';
|
|
import '../../../../widget/info_dialog.dart';
|
|
|
|
/// Banner that appears at the top of a Files folder while there is something
|
|
/// in the file clipboard. Shows the cut/copy state and offers a "Hier
|
|
/// einfügen" button.
|
|
class ClipboardBanner extends StatefulWidget {
|
|
final String currentFolder;
|
|
final VoidCallback onPasteDone;
|
|
|
|
const ClipboardBanner({
|
|
required this.currentFolder,
|
|
required this.onPasteDone,
|
|
super.key,
|
|
});
|
|
|
|
@override
|
|
State<ClipboardBanner> createState() => _ClipboardBannerState();
|
|
}
|
|
|
|
class _ClipboardBannerState extends State<ClipboardBanner> {
|
|
bool _busy = false;
|
|
|
|
// All paths here are relative to the WebDAV root (matching `CacheableFile.path`).
|
|
// Root is the empty string ''. Folders end with '/'.
|
|
String _normalised(String path) {
|
|
final stripped = path.replaceAll(RegExp(r'^/+|/+$'), '');
|
|
return stripped.isEmpty ? '' : '$stripped/';
|
|
}
|
|
|
|
String _joinPath(String folder, String name, {required bool isDirectory}) =>
|
|
isDirectory ? '$folder$name/' : '$folder$name';
|
|
|
|
// Disabled when:
|
|
// - clipboard is empty
|
|
// - we'd be pasting a folder into itself or one of its descendants
|
|
// - every entry already lives in the current folder (paste would be a no-op)
|
|
bool get _canPaste {
|
|
final cb = FileClipboard.instance;
|
|
if (cb.isEmpty) return false;
|
|
final dst = _normalised(widget.currentFolder);
|
|
var atLeastOneActionable = false;
|
|
for (final f in cb.files) {
|
|
if (f.isDirectory) {
|
|
final src = _normalised(f.path);
|
|
if (dst == src || dst.startsWith(src)) return false;
|
|
}
|
|
final destination = _joinPath(
|
|
widget.currentFolder,
|
|
f.name,
|
|
isDirectory: f.isDirectory,
|
|
);
|
|
if (destination != f.path) atLeastOneActionable = true;
|
|
}
|
|
return atLeastOneActionable;
|
|
}
|
|
|
|
// Cache key format used by ListFilesCache (matches FilesBloc's pathString:
|
|
// relative, no leading or trailing slash; root is '/').
|
|
String _parentCacheKey(String relativePath) {
|
|
final stripped = relativePath.replaceAll(RegExp(r'^/+|/+$'), '');
|
|
if (!stripped.contains('/')) return '/';
|
|
final parts = stripped.split('/')..removeLast();
|
|
return parts.isEmpty ? '/' : parts.join('/');
|
|
}
|
|
|
|
Future<void> _paste() async {
|
|
final cb = FileClipboard.instance;
|
|
if (_busy || !_canPaste) return;
|
|
setState(() => _busy = true);
|
|
final operation = cb.operation;
|
|
final errors = <String>[];
|
|
final invalidatedSourceFolders = <String>{};
|
|
try {
|
|
final webdav = await WebdavApi.webdav;
|
|
for (final file in cb.files) {
|
|
final destination = _joinPath(
|
|
widget.currentFolder,
|
|
file.name,
|
|
isDirectory: file.isDirectory,
|
|
);
|
|
if (destination == file.path) continue;
|
|
try {
|
|
if (operation == FileClipboardOperation.cut) {
|
|
await webdav.move(
|
|
PathUri.parse(file.path),
|
|
PathUri.parse(destination),
|
|
);
|
|
invalidatedSourceFolders.add(_parentCacheKey(file.path));
|
|
} else {
|
|
await webdav.copy(
|
|
PathUri.parse(file.path),
|
|
PathUri.parse(destination),
|
|
);
|
|
}
|
|
} on Object catch (e) {
|
|
errors.add('${file.name}: $e');
|
|
}
|
|
}
|
|
// After cut, the source folders no longer contain the moved files. Drop
|
|
// their cached listings so the next visit fetches fresh data instead of
|
|
// briefly showing the moved file as still present.
|
|
for (final folder in invalidatedSourceFolders) {
|
|
await ListFilesCache.invalidate(folder);
|
|
}
|
|
if (operation == FileClipboardOperation.cut) cb.clear();
|
|
widget.onPasteDone();
|
|
} finally {
|
|
if (mounted) setState(() => _busy = false);
|
|
}
|
|
if (errors.isNotEmpty && mounted) {
|
|
InfoDialog.show(
|
|
context,
|
|
errors.join('\n\n'),
|
|
copyable: true,
|
|
title: 'Einfügen teilweise fehlgeschlagen',
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => ListenableBuilder(
|
|
listenable: FileClipboard.instance,
|
|
builder: (context, _) {
|
|
final cb = FileClipboard.instance;
|
|
if (cb.isEmpty) return const SizedBox.shrink();
|
|
final cut = cb.operation == FileClipboardOperation.cut;
|
|
final count = cb.files.length;
|
|
final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente';
|
|
return Material(
|
|
color: Theme.of(context).colorScheme.secondaryContainer,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
cut ? Icons.drive_file_move_outline : Icons.copy_outlined,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
cut ? '$label verschieben' : '$label kopieren',
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: _busy || !_canPaste ? null : _paste,
|
|
child: _busy
|
|
? const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('Hier einfügen'),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Verwerfen',
|
|
icon: const Icon(Icons.close, size: 20),
|
|
onPressed: _busy ? null : cb.clear,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|