refactored broad range of the application, split files, modularized calendar and file views, centralized bottom sheets and clipboard handling, and implemented unit test coverage
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user