import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:nextcloud/nextcloud.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; import '../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; import '../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/modules/files/bloc/files_bloc.dart'; import '../../../state/app/modules/files/bloc/files_state.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../utils/cache_invalidation_bus.dart'; import '../../../utils/file_clipboard.dart'; import '../../../widget/async_action_button.dart'; import '../../../widget/file_pick.dart'; import '../../../widget/placeholder_view.dart'; import 'files_upload_dialog.dart'; import 'widgets/file_element.dart'; class BetterSortOption { String displayName; int Function(CacheableFile, CacheableFile) compare; IconData icon; BetterSortOption({required this.displayName, required this.icon, required this.compare}); } enum SortOption { name, date, size } class SortOptions { static Map options = { SortOption.name: BetterSortOption( displayName: 'Name', icon: Icons.sort_by_alpha_outlined, compare: (a, b) => a.name.compareTo(b.name), ), SortOption.date: BetterSortOption( displayName: 'Datum', icon: Icons.history_outlined, compare: (a, b) => a.modifiedAt!.compareTo(b.modifiedAt!), ), SortOption.size: BetterSortOption( displayName: 'Größe', icon: Icons.sd_card_outlined, compare: (a, b) { if (a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0; if (a.size == null) return 0; if (b.size == null) return 1; return a.size!.compareTo(b.size!); }, ), }; static BetterSortOption getOption(SortOption option) => options[option]!; } class Files extends StatelessWidget { final List path; Files({List? path, super.key}) : path = path ?? []; @override Widget build(BuildContext context) => BlocModule>( create: (_) => FilesBloc(initialPath: path), child: (context, _, _) => _FilesView(path: path), ); } class _FilesView extends StatefulWidget { final List path; const _FilesView({required this.path}); @override State<_FilesView> createState() => _FilesViewState(); } class _FilesViewState extends State<_FilesView> { late final SettingsCubit settings; late SortOption currentSort; late bool currentSortDirection; late final StreamSubscription _invalidationSub; // Cache key in FilesBloc's pathString format: '/' for root, otherwise // segments joined without leading/trailing slash. String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/'); @override void initState() { super.initState(); settings = context.read(); currentSort = settings.val().fileSettings.sortBy; currentSortDirection = settings.val().fileSettings.ascending; _invalidationSub = CacheInvalidationBus.listFilesStream.listen(_onInvalidation); } void _onInvalidation(String invalidatedPath) { if (!mounted) return; if (invalidatedPath != _myPathString) return; context.read().refresh(); } @override void dispose() { _invalidationSub.cancel(); super.dispose(); } Future mediaUpload(List? paths) async { if (paths == null) return; final bloc = context.read(); unawaited(pushScreen( context, withNavBar: false, screen: FilesUploadDialog( filePaths: paths, remotePath: widget.path.join('/'), onUploadFinished: (_) => bloc.refresh(), ), )); } @override Widget build(BuildContext context) { final bloc = context.read(); return Scaffold( appBar: AppBar( title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), actions: [ PopupMenuButton( icon: Icon(currentSortDirection ? Icons.text_rotate_up : Icons.text_rotation_down), itemBuilder: (context) => [true, false] .map((e) => PopupMenuItem( value: e, enabled: e != currentSortDirection, child: Row( children: [ Icon( e ? Icons.text_rotate_up : Icons.text_rotation_down, color: Theme.of(context).colorScheme.onSurface, ), const SizedBox(width: 15), Text(e ? 'Aufsteigend' : 'Absteigend'), ], ), )) .toList(), onSelected: (e) { setState(() { currentSortDirection = e; settings.val(write: true).fileSettings.ascending = e; }); }, ), PopupMenuButton( icon: const Icon(Icons.sort), itemBuilder: (context) => SortOptions.options.keys .map((key) => PopupMenuItem( value: key, enabled: key != currentSort, child: Row( children: [ Icon(SortOptions.getOption(key).icon, color: Theme.of(context).colorScheme.onSurface), const SizedBox(width: 15), Text(SortOptions.getOption(key).displayName), ], ), )) .toList(), onSelected: (e) { setState(() { currentSort = e; settings.val(write: true).fileSettings.sortBy = e; }); }, ), ], ), floatingActionButton: FloatingActionButton( heroTag: 'uploadFile', backgroundColor: Theme.of(context).primaryColor, onPressed: () => _showAddDialog(context, bloc), child: const Icon(Icons.add), ), body: Column( children: [ _ClipboardBanner(currentFolder: _currentFolderPath, onPasteDone: bloc.refresh), Expanded( child: LoadableStateConsumer( isReady: (state) => state.listing != null, child: (state, _) { final listing = state.listing!; if (listing.files.isEmpty) { return const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer'); } final files = listing.sortBy( sortOption: currentSort, foldersToTop: context.watch().val().fileSettings.sortFoldersToTop, reversed: currentSortDirection, ); return ListView.builder( padding: EdgeInsets.zero, itemCount: files.length, itemBuilder: (context, index) => FileElement(files[index], widget.path, bloc.refresh), ); }, ), ), ], ), ); } // Relative folder path matching the WebDAV format used by `CacheableFile.path` // (no leading slash; trailing slash for non-root). Empty string means root. String get _currentFolderPath => widget.path.isEmpty ? '' : '${widget.path.join('/')}/'; void _showAddDialog(BuildContext context, FilesBloc bloc) { showDialog( context: context, builder: (dialogCtx) => SimpleDialog(children: [ ListTile( leading: const Icon(Icons.create_new_folder_outlined), title: const Text('Ordner erstellen'), onTap: () { Navigator.of(dialogCtx).pop(); _showCreateFolderDialog(context, bloc); }, ), ListTile( leading: const Icon(Icons.upload_file), title: const Text('Aus Dateien hochladen'), onTap: () { FilePick.documentPick().then(mediaUpload); Navigator.of(dialogCtx).pop(); }, ), ListTile( leading: const Icon(Icons.add_a_photo_outlined), title: const Text('Aus Galerie hochladen'), onTap: () { FilePick.multipleGalleryPick().then((value) { if (value != null) mediaUpload(value.map((e) => e.path).toList()); }); Navigator.of(dialogCtx).pop(); }, ), ListTile( leading: const Icon(Icons.camera_alt_outlined), title: const Text('Foto aufnehmen'), onTap: () { FilePick.cameraPick().then((image) { if (image != null) mediaUpload([image.path]); }); Navigator.of(dialogCtx).pop(); }, ), ]), ); } void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) { final inputController = TextEditingController(); showDialog( context: context, builder: (dialogCtx) => AlertDialog( title: const Text('Neuer Ordner'), content: TextField( controller: inputController, decoration: const InputDecoration(labelText: 'Name'), autofocus: true, ), actions: [ AsyncDialogAction( confirmLabel: 'Ordner erstellen', onConfirm: () async { if (inputController.text.trim().isEmpty) { throw Exception('Bitte einen Namen eingeben.'); } await bloc.createFolder(inputController.text.trim()); }, ), ], ), ); } } class _ClipboardBanner extends StatefulWidget { const _ClipboardBanner({required this.currentFolder, required this.onPasteDone}); final String currentFolder; final void Function() onPasteDone; @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 _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) { await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Einfügen teilweise fehlgeschlagen'), content: SingleChildScrollView(child: Text(errors.join('\n\n'))), actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: const Text('OK'))], ), ); } } @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, ), ], ), ), ); }, ); }