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 createState() => _ClipboardBannerState(); } class _ClipboardBannerState extends State { 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 _paste() async { final cb = FileClipboard.instance; if (_busy || !_canPaste) return; setState(() => _busy = true); final operation = cb.operation; final errors = []; final invalidatedSourceFolders = {}; 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, ), ], ), ), ); }, ); }