From 6e5d36a1ce4140c1ffdbf9bd1ada35ef1012d16c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Wed, 17 Dec 2025 01:28:29 +0100 Subject: [PATCH] move files per drag and drop --- .../blocs/file_browser/file_browser_bloc.dart | 59 +++- .../file_browser/file_browser_event.dart | 15 + b0esche_cloud/lib/pages/file_explorer.dart | 271 ++++++++++-------- .../lib/repositories/file_repository.dart | 1 + .../repositories/mock_file_repository.dart | 22 ++ b0esche_cloud/lib/services/file_service.dart | 11 + 6 files changed, 249 insertions(+), 130 deletions(-) diff --git a/b0esche_cloud/lib/blocs/file_browser/file_browser_bloc.dart b/b0esche_cloud/lib/blocs/file_browser/file_browser_bloc.dart index b62c8dc..c2ca519 100644 --- a/b0esche_cloud/lib/blocs/file_browser/file_browser_bloc.dart +++ b/b0esche_cloud/lib/blocs/file_browser/file_browser_bloc.dart @@ -14,6 +14,7 @@ class FileBrowserBloc extends Bloc { int _pageSize = 20; String _sortBy = 'name'; bool _isAscending = true; + String _currentFilter = ''; FileBrowserBloc(this._fileService) : super(DirectoryInitial()) { on(_onLoadDirectory); @@ -22,6 +23,7 @@ class FileBrowserBloc extends Bloc { on(_onApplySort); on(_onApplyFilter); on(_onCreateFolder); + on(_onMoveFile); on(_onResetFileBrowser); on(_onLoadPage); on(_onChangePageSize); @@ -39,6 +41,7 @@ class FileBrowserBloc extends Bloc { final files = await _fileService.getFiles(event.orgId, event.path); _currentFiles = _sortFiles(files, _sortBy, _isAscending); _filteredFiles = _currentFiles; + _currentFilter = ''; _currentPage = 1; if (files.isEmpty) { emit(DirectoryEmpty(_currentPath)); @@ -70,15 +73,16 @@ class FileBrowserBloc extends Bloc { _isAscending = event.isAscending; _currentFiles = _sortFiles(_currentFiles, _sortBy, _isAscending); _filteredFiles = _currentFiles - .where((f) => f.name.toLowerCase().contains('')) - .toList(); // Re-apply filter if any, but since filter is separate, perhaps need to track filter + .where((f) => f.name.toLowerCase().contains(_currentFilter)) + .toList(); _currentPage = 1; _emitLoadedState(emit); } void _onApplyFilter(ApplyFilter event, Emitter emit) { + _currentFilter = event.filter.toLowerCase(); _filteredFiles = _currentFiles - .where((f) => f.name.toLowerCase().contains(event.filter.toLowerCase())) + .where((f) => f.name.toLowerCase().contains(_currentFilter)) .toList(); _currentPage = 1; _emitLoadedState(emit); @@ -94,12 +98,49 @@ class FileBrowserBloc extends Bloc { event.parentPath, event.folderName, ); - // Refresh the directory to show the new folder - add(LoadDirectory(orgId: event.orgId, path: event.parentPath)); + // Add the new folder to local state if in current directory + if (event.parentPath == _currentPath) { + final newFolder = FileItem( + name: event.folderName, + path: '${event.parentPath}/${event.folderName}', + type: FileType.folder, + size: 0, + lastModified: DateTime.now(), + ); + _currentFiles.add(newFolder); + _currentFiles = _sortFiles(_currentFiles, _sortBy, _isAscending); + _filteredFiles = _currentFiles + .where((f) => f.name.toLowerCase().contains(_currentFilter)) + .toList(); + _emitLoadedState(emit); + } else { + add(LoadDirectory(orgId: event.orgId, path: event.parentPath)); + } } catch (e) { - // For now, emit error state or handle appropriately - // Since states don't have error for create, perhaps refresh anyway - add(LoadDirectory(orgId: event.orgId, path: event.parentPath)); + emit(DirectoryError(e.toString())); + } + } + + void _onMoveFile(MoveFile event, Emitter emit) async { + try { + await _fileService.moveFile( + event.orgId, + event.sourcePath, + event.targetPath, + ); + // If moving into current directory, reload to get the new file details + if (event.targetPath == _currentPath) { + add(LoadDirectory(orgId: event.orgId, path: _currentPath)); + } else { + // Remove the moved file if it was in current directory + _currentFiles.removeWhere((f) => f.path == event.sourcePath); + _filteredFiles = _currentFiles + .where((f) => f.name.toLowerCase().contains(_currentFilter)) + .toList(); + _emitLoadedState(emit); + } + } catch (e) { + emit(DirectoryError(e.toString())); } } @@ -110,6 +151,7 @@ class FileBrowserBloc extends Bloc { emit(DirectoryInitial()); _currentOrgId = ''; _currentPath = '/'; + _currentFilter = ''; _currentPage = 1; _pageSize = 20; _sortBy = 'name'; @@ -133,6 +175,7 @@ class FileBrowserBloc extends Bloc { final files = await _fileService.searchFiles(event.orgId, event.query); _currentFiles = files; _filteredFiles = files; + _currentFilter = ''; _currentPage = 1; if (files.isEmpty) { emit(DirectoryEmpty(_currentPath)); diff --git a/b0esche_cloud/lib/blocs/file_browser/file_browser_event.dart b/b0esche_cloud/lib/blocs/file_browser/file_browser_event.dart index 50b3ee4..5084248 100644 --- a/b0esche_cloud/lib/blocs/file_browser/file_browser_event.dart +++ b/b0esche_cloud/lib/blocs/file_browser/file_browser_event.dart @@ -91,3 +91,18 @@ class SearchFiles extends FileBrowserEvent { @override List get props => [orgId, query]; } + +class MoveFile extends FileBrowserEvent { + final String orgId; + final String sourcePath; + final String targetPath; + + const MoveFile({ + required this.orgId, + required this.sourcePath, + required this.targetPath, + }); + + @override + List get props => [orgId, sourcePath, targetPath]; +} diff --git a/b0esche_cloud/lib/pages/file_explorer.dart b/b0esche_cloud/lib/pages/file_explorer.dart index 30c2339..c74eb0e 100644 --- a/b0esche_cloud/lib/pages/file_explorer.dart +++ b/b0esche_cloud/lib/pages/file_explorer.dart @@ -93,11 +93,9 @@ class _FileExplorerState extends State { color: AppTheme.secondaryText.withValues(alpha: 0.5), ), ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16), - borderSide: const BorderSide( - color: AppTheme.accentColor, - ), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(16)), + borderSide: BorderSide(color: AppTheme.accentColor), ), ), ), @@ -144,21 +142,18 @@ class _FileExplorerState extends State { } void _editFile(FileItem file) { - // Placeholder for edit action ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Edit ${file.name}'))); } void _downloadFile(FileItem file) { - // Placeholder for download action ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Download ${file.name}'))); } void _sendFile(FileItem file) { - // Placeholder for send action ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text('Send ${file.name}'))); @@ -322,9 +317,9 @@ class _FileExplorerState extends State { color: AppTheme.secondaryText.withValues(alpha: 0.5), ), ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(24), - borderSide: const BorderSide(color: AppTheme.accentColor), + focusedBorder: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(24)), + borderSide: BorderSide(color: AppTheme.accentColor), ), ), onChanged: (value) { @@ -341,6 +336,97 @@ class _FileExplorerState extends State { ); } + Widget _buildFileItem( + FileItem file, + bool isSelected, + bool isHovered, + bool isDraggedOver, + ) { + return MouseRegion( + onEnter: (_) => setState(() => _hovered[file.path] = true), + onExit: (_) => setState(() => _hovered[file.path] = false), + child: GestureDetector( + onTap: () { + setState(() { + _selectedFilePath = file.path; + }); + if (file.type == FileType.folder) { + context.read().add(NavigateToFolder(file.path)); + } else { + context.go('/viewer/${file.name}'); + } + }, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: isSelected + ? Border.all( + color: AppTheme.accentColor.withValues(alpha: 0.4), + width: 2, + ) + : isDraggedOver + ? Border.all(color: Colors.blue, width: 2) + : null, + color: isSelected + ? AppTheme.accentColor.withValues(alpha: 0.08) + : isHovered + ? Colors.white.withValues(alpha: 0.05) + : isDraggedOver + ? Colors.blue.withValues(alpha: 0.1) + : Colors.transparent, + ), + child: ListTile( + leading: Icon( + file.type == FileType.folder + ? Icons.folder + : Icons.insert_drive_file, + color: AppTheme.primaryText, + ), + title: Text( + file.type == FileType.folder + ? (file.name.startsWith('/') ? file.name : '/${file.name}') + : file.name, + style: const TextStyle(color: AppTheme.primaryText), + ), + subtitle: Text( + file.type == FileType.folder + ? 'Folder' + : 'File - ${file.size} bytes', + style: const TextStyle(color: AppTheme.secondaryText), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit, color: AppTheme.secondaryText), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + onPressed: () => _editFile(file), + ), + IconButton( + icon: const Icon( + Icons.download, + color: AppTheme.secondaryText, + ), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + onPressed: () => _downloadFile(file), + ), + IconButton( + icon: const Icon(Icons.send, color: AppTheme.secondaryText), + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + onPressed: () => _sendFile(file), + ), + ], + ), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { return BlocBuilder( @@ -350,6 +436,7 @@ class _FileExplorerState extends State { child: CircularProgressIndicator(color: AppTheme.accentColor), ); } + if (state is DirectoryError) { return Center( child: Text( @@ -358,6 +445,7 @@ class _FileExplorerState extends State { ), ); } + if (state is DirectoryEmpty) { return Padding( padding: const EdgeInsets.all(16.0), @@ -425,7 +513,7 @@ class _FileExplorerState extends State { context.read().add( CreateFolder( orgId: 'org1', - parentPath: state.currentPath, + parentPath: '/', folderName: folderName, ), ); @@ -456,21 +544,9 @@ class _FileExplorerState extends State { splashColor: Colors.transparent, highlightColor: Colors.transparent, onPressed: () { - final parentPath = state.currentPath == '/' - ? '/' - : state.currentPath - .substring( - 0, - state.currentPath.lastIndexOf('/'), - ) - .isEmpty - ? '/' - : state.currentPath.substring( - 0, - state.currentPath.lastIndexOf('/'), - ); + final currentPath = '/'; context.read().add( - LoadDirectory(orgId: 'org1', path: parentPath), + LoadDirectory(orgId: 'org1', path: currentPath), ); }, ), @@ -484,6 +560,7 @@ class _FileExplorerState extends State { ), ); } + if (state is DirectoryLoaded) { return Padding( padding: const EdgeInsets.all(16.0), @@ -502,7 +579,6 @@ class _FileExplorerState extends State { child: _buildTitle(), ), const SizedBox(height: 16), - // Breadcrumbs and back button Visibility( visible: state.breadcrumbs.isNotEmpty, child: Column( @@ -625,111 +701,61 @@ class _FileExplorerState extends State { final file = state.paginatedFiles[index]; final isSelected = _selectedFilePath == file.path; final isHovered = _hovered[file.path] ?? false; - return MouseRegion( - onEnter: (_) => - setState(() => _hovered[file.path] = true), - onExit: (_) => - setState(() => _hovered[file.path] = false), - child: GestureDetector( - onTap: () { - setState(() { - _selectedFilePath = file.path; - }); - if (file.type == FileType.folder) { - context.read().add( - NavigateToFolder(file.path), - ); - } else { - // Open viewer - context.go('/viewer/${file.name}'); - } - }, - child: Container( - margin: const EdgeInsets.symmetric( - vertical: 4, - horizontal: 8, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: isSelected - ? Border.all( - color: AppTheme.accentColor.withValues( - alpha: 0.4, - ), - width: 2, - ) - : null, - color: isSelected - ? AppTheme.accentColor.withValues(alpha: 0.08) - : isHovered - ? Colors.white.withValues(alpha: 0.05) - : Colors.transparent, - ), - child: ListTile( - leading: Icon( - file.type == FileType.folder - ? Icons.folder - : Icons.insert_drive_file, - color: AppTheme.primaryText, - ), - title: Text( - file.type == FileType.folder - ? (file.name.startsWith('/') - ? file.name - : '/${file.name}') - : file.name, - style: const TextStyle( + + if (file.type == FileType.folder) { + return DragTarget( + builder: (context, candidate, rejected) { + final isDraggedOver = candidate.isNotEmpty; + return Draggable( + data: file, + feedback: Opacity( + opacity: 0.8, + child: Icon( + Icons.folder, color: AppTheme.primaryText, + size: 48, ), ), - subtitle: Text( - file.type == FileType.folder - ? 'Folder' - : 'File - ${file.size} bytes', - style: const TextStyle( - color: AppTheme.secondaryText, - ), + child: _buildFileItem( + file, + isSelected, + isHovered, + isDraggedOver, ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon( - Icons.edit, - color: AppTheme.secondaryText, - ), - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - onPressed: () => _editFile(file), - ), - IconButton( - icon: const Icon( - Icons.download, - color: AppTheme.secondaryText, - ), - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - onPressed: () => _downloadFile(file), - ), - IconButton( - icon: const Icon( - Icons.send, - color: AppTheme.secondaryText, - ), - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - onPressed: () => _sendFile(file), - ), - ], + ); + }, + onAccept: (draggedFile) { + context.read().add( + MoveFile( + orgId: 'org1', + sourcePath: draggedFile.path, + targetPath: file.path, ), + ); + }, + ); + } else { + return Draggable( + data: file, + feedback: Opacity( + opacity: 0.8, + child: Icon( + Icons.insert_drive_file, + color: AppTheme.primaryText, + size: 48, ), ), - ), - ); + child: _buildFileItem( + file, + isSelected, + isHovered, + false, + ), + ); + } }, ), ), - // Pagination controls if (state.totalPages > 1) ...[ const SizedBox(height: 16), Row( @@ -779,6 +805,7 @@ class _FileExplorerState extends State { ), ); } + return const SizedBox.shrink(); }, ); diff --git a/b0esche_cloud/lib/repositories/file_repository.dart b/b0esche_cloud/lib/repositories/file_repository.dart index 5a88ef6..833af9b 100644 --- a/b0esche_cloud/lib/repositories/file_repository.dart +++ b/b0esche_cloud/lib/repositories/file_repository.dart @@ -6,5 +6,6 @@ abstract class FileRepository { Future uploadFile(String orgId, FileItem file); Future deleteFile(String orgId, String path); Future createFolder(String orgId, String parentPath, String folderName); + Future moveFile(String orgId, String sourcePath, String targetPath); Future> searchFiles(String orgId, String query); } diff --git a/b0esche_cloud/lib/repositories/mock_file_repository.dart b/b0esche_cloud/lib/repositories/mock_file_repository.dart index 95ab311..c3a87b1 100644 --- a/b0esche_cloud/lib/repositories/mock_file_repository.dart +++ b/b0esche_cloud/lib/repositories/mock_file_repository.dart @@ -222,6 +222,28 @@ class MockFileRepository implements FileRepository { ); } + @override + Future moveFile( + String orgId, + String sourcePath, + String targetPath, + ) async { + await Future.delayed(const Duration(seconds: 1)); + final fileIndex = _files.indexWhere((f) => f.path == sourcePath); + if (fileIndex != -1) { + final file = _files[fileIndex]; + final newName = file.path.split('/').last; + final newPath = targetPath == '/' ? '/$newName' : '$targetPath/$newName'; + _files[fileIndex] = FileItem( + name: file.name, + path: newPath, + type: file.type, + size: file.size, + lastModified: DateTime.now(), + ); + } + } + @override Future> searchFiles(String orgId, String query) async { await Future.delayed(const Duration(seconds: 1)); diff --git a/b0esche_cloud/lib/services/file_service.dart b/b0esche_cloud/lib/services/file_service.dart index 55cb2e3..801393f 100644 --- a/b0esche_cloud/lib/services/file_service.dart +++ b/b0esche_cloud/lib/services/file_service.dart @@ -45,6 +45,17 @@ class FileService { await _fileRepository.createFolder(orgId, parentPath, folderName); } + Future moveFile( + String orgId, + String sourcePath, + String targetPath, + ) async { + if (sourcePath.isEmpty || targetPath.isEmpty) { + throw Exception('Paths cannot be empty'); + } + await _fileRepository.moveFile(orgId, sourcePath, targetPath); + } + Future> searchFiles(String orgId, String query) async { if (query.isEmpty) { return [];