move files per drag and drop

This commit is contained in:
Leon Bösche
2025-12-17 01:28:29 +01:00
parent 04877d1727
commit 6e5d36a1ce
6 changed files with 249 additions and 130 deletions

View File

@@ -14,6 +14,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
int _pageSize = 20; int _pageSize = 20;
String _sortBy = 'name'; String _sortBy = 'name';
bool _isAscending = true; bool _isAscending = true;
String _currentFilter = '';
FileBrowserBloc(this._fileService) : super(DirectoryInitial()) { FileBrowserBloc(this._fileService) : super(DirectoryInitial()) {
on<LoadDirectory>(_onLoadDirectory); on<LoadDirectory>(_onLoadDirectory);
@@ -22,6 +23,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
on<ApplySort>(_onApplySort); on<ApplySort>(_onApplySort);
on<ApplyFilter>(_onApplyFilter); on<ApplyFilter>(_onApplyFilter);
on<CreateFolder>(_onCreateFolder); on<CreateFolder>(_onCreateFolder);
on<MoveFile>(_onMoveFile);
on<ResetFileBrowser>(_onResetFileBrowser); on<ResetFileBrowser>(_onResetFileBrowser);
on<LoadPage>(_onLoadPage); on<LoadPage>(_onLoadPage);
on<ChangePageSize>(_onChangePageSize); on<ChangePageSize>(_onChangePageSize);
@@ -39,6 +41,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
final files = await _fileService.getFiles(event.orgId, event.path); final files = await _fileService.getFiles(event.orgId, event.path);
_currentFiles = _sortFiles(files, _sortBy, _isAscending); _currentFiles = _sortFiles(files, _sortBy, _isAscending);
_filteredFiles = _currentFiles; _filteredFiles = _currentFiles;
_currentFilter = '';
_currentPage = 1; _currentPage = 1;
if (files.isEmpty) { if (files.isEmpty) {
emit(DirectoryEmpty(_currentPath)); emit(DirectoryEmpty(_currentPath));
@@ -70,15 +73,16 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
_isAscending = event.isAscending; _isAscending = event.isAscending;
_currentFiles = _sortFiles(_currentFiles, _sortBy, _isAscending); _currentFiles = _sortFiles(_currentFiles, _sortBy, _isAscending);
_filteredFiles = _currentFiles _filteredFiles = _currentFiles
.where((f) => f.name.toLowerCase().contains('')) .where((f) => f.name.toLowerCase().contains(_currentFilter))
.toList(); // Re-apply filter if any, but since filter is separate, perhaps need to track filter .toList();
_currentPage = 1; _currentPage = 1;
_emitLoadedState(emit); _emitLoadedState(emit);
} }
void _onApplyFilter(ApplyFilter event, Emitter<FileBrowserState> emit) { void _onApplyFilter(ApplyFilter event, Emitter<FileBrowserState> emit) {
_currentFilter = event.filter.toLowerCase();
_filteredFiles = _currentFiles _filteredFiles = _currentFiles
.where((f) => f.name.toLowerCase().contains(event.filter.toLowerCase())) .where((f) => f.name.toLowerCase().contains(_currentFilter))
.toList(); .toList();
_currentPage = 1; _currentPage = 1;
_emitLoadedState(emit); _emitLoadedState(emit);
@@ -94,12 +98,49 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
event.parentPath, event.parentPath,
event.folderName, event.folderName,
); );
// Refresh the directory to show the new folder // Add the new folder to local state if in current directory
add(LoadDirectory(orgId: event.orgId, path: event.parentPath)); 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) { } catch (e) {
// For now, emit error state or handle appropriately emit(DirectoryError(e.toString()));
// Since states don't have error for create, perhaps refresh anyway }
add(LoadDirectory(orgId: event.orgId, path: event.parentPath)); }
void _onMoveFile(MoveFile event, Emitter<FileBrowserState> 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<FileBrowserEvent, FileBrowserState> {
emit(DirectoryInitial()); emit(DirectoryInitial());
_currentOrgId = ''; _currentOrgId = '';
_currentPath = '/'; _currentPath = '/';
_currentFilter = '';
_currentPage = 1; _currentPage = 1;
_pageSize = 20; _pageSize = 20;
_sortBy = 'name'; _sortBy = 'name';
@@ -133,6 +175,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
final files = await _fileService.searchFiles(event.orgId, event.query); final files = await _fileService.searchFiles(event.orgId, event.query);
_currentFiles = files; _currentFiles = files;
_filteredFiles = files; _filteredFiles = files;
_currentFilter = '';
_currentPage = 1; _currentPage = 1;
if (files.isEmpty) { if (files.isEmpty) {
emit(DirectoryEmpty(_currentPath)); emit(DirectoryEmpty(_currentPath));

View File

@@ -91,3 +91,18 @@ class SearchFiles extends FileBrowserEvent {
@override @override
List<Object> get props => [orgId, query]; List<Object> 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<Object> get props => [orgId, sourcePath, targetPath];
}

View File

@@ -93,11 +93,9 @@ class _FileExplorerState extends State<FileExplorer> {
color: AppTheme.secondaryText.withValues(alpha: 0.5), color: AppTheme.secondaryText.withValues(alpha: 0.5),
), ),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.all(Radius.circular(16)),
borderSide: const BorderSide( borderSide: BorderSide(color: AppTheme.accentColor),
color: AppTheme.accentColor,
),
), ),
), ),
), ),
@@ -144,21 +142,18 @@ class _FileExplorerState extends State<FileExplorer> {
} }
void _editFile(FileItem file) { void _editFile(FileItem file) {
// Placeholder for edit action
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text('Edit ${file.name}'))); ).showSnackBar(SnackBar(content: Text('Edit ${file.name}')));
} }
void _downloadFile(FileItem file) { void _downloadFile(FileItem file) {
// Placeholder for download action
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text('Download ${file.name}'))); ).showSnackBar(SnackBar(content: Text('Download ${file.name}')));
} }
void _sendFile(FileItem file) { void _sendFile(FileItem file) {
// Placeholder for send action
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text('Send ${file.name}'))); ).showSnackBar(SnackBar(content: Text('Send ${file.name}')));
@@ -322,9 +317,9 @@ class _FileExplorerState extends State<FileExplorer> {
color: AppTheme.secondaryText.withValues(alpha: 0.5), color: AppTheme.secondaryText.withValues(alpha: 0.5),
), ),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.all(Radius.circular(24)),
borderSide: const BorderSide(color: AppTheme.accentColor), borderSide: BorderSide(color: AppTheme.accentColor),
), ),
), ),
onChanged: (value) { onChanged: (value) {
@@ -341,6 +336,97 @@ class _FileExplorerState extends State<FileExplorer> {
); );
} }
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<FileBrowserBloc>().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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<FileBrowserBloc, FileBrowserState>( return BlocBuilder<FileBrowserBloc, FileBrowserState>(
@@ -350,6 +436,7 @@ class _FileExplorerState extends State<FileExplorer> {
child: CircularProgressIndicator(color: AppTheme.accentColor), child: CircularProgressIndicator(color: AppTheme.accentColor),
); );
} }
if (state is DirectoryError) { if (state is DirectoryError) {
return Center( return Center(
child: Text( child: Text(
@@ -358,6 +445,7 @@ class _FileExplorerState extends State<FileExplorer> {
), ),
); );
} }
if (state is DirectoryEmpty) { if (state is DirectoryEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -425,7 +513,7 @@ class _FileExplorerState extends State<FileExplorer> {
context.read<FileBrowserBloc>().add( context.read<FileBrowserBloc>().add(
CreateFolder( CreateFolder(
orgId: 'org1', orgId: 'org1',
parentPath: state.currentPath, parentPath: '/',
folderName: folderName, folderName: folderName,
), ),
); );
@@ -456,21 +544,9 @@ class _FileExplorerState extends State<FileExplorer> {
splashColor: Colors.transparent, splashColor: Colors.transparent,
highlightColor: Colors.transparent, highlightColor: Colors.transparent,
onPressed: () { onPressed: () {
final parentPath = state.currentPath == '/' final currentPath = '/';
? '/'
: state.currentPath
.substring(
0,
state.currentPath.lastIndexOf('/'),
)
.isEmpty
? '/'
: state.currentPath.substring(
0,
state.currentPath.lastIndexOf('/'),
);
context.read<FileBrowserBloc>().add( context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: 'org1', path: parentPath), LoadDirectory(orgId: 'org1', path: currentPath),
); );
}, },
), ),
@@ -484,6 +560,7 @@ class _FileExplorerState extends State<FileExplorer> {
), ),
); );
} }
if (state is DirectoryLoaded) { if (state is DirectoryLoaded) {
return Padding( return Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -502,7 +579,6 @@ class _FileExplorerState extends State<FileExplorer> {
child: _buildTitle(), child: _buildTitle(),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// Breadcrumbs and back button
Visibility( Visibility(
visible: state.breadcrumbs.isNotEmpty, visible: state.breadcrumbs.isNotEmpty,
child: Column( child: Column(
@@ -625,111 +701,61 @@ class _FileExplorerState extends State<FileExplorer> {
final file = state.paginatedFiles[index]; final file = state.paginatedFiles[index];
final isSelected = _selectedFilePath == file.path; final isSelected = _selectedFilePath == file.path;
final isHovered = _hovered[file.path] ?? false; final isHovered = _hovered[file.path] ?? false;
return MouseRegion(
onEnter: (_) => if (file.type == FileType.folder) {
setState(() => _hovered[file.path] = true), return DragTarget<FileItem>(
onExit: (_) => builder: (context, candidate, rejected) {
setState(() => _hovered[file.path] = false), final isDraggedOver = candidate.isNotEmpty;
child: GestureDetector( return Draggable<FileItem>(
onTap: () { data: file,
setState(() { feedback: Opacity(
_selectedFilePath = file.path; opacity: 0.8,
}); child: Icon(
if (file.type == FileType.folder) { Icons.folder,
context.read<FileBrowserBloc>().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(
color: AppTheme.primaryText, color: AppTheme.primaryText,
size: 48,
), ),
), ),
subtitle: Text( child: _buildFileItem(
file.type == FileType.folder file,
? 'Folder' isSelected,
: 'File - ${file.size} bytes', isHovered,
style: const TextStyle( isDraggedOver,
color: AppTheme.secondaryText,
),
), ),
trailing: Row( );
mainAxisSize: MainAxisSize.min, },
children: [ onAccept: (draggedFile) {
IconButton( context.read<FileBrowserBloc>().add(
icon: const Icon( MoveFile(
Icons.edit, orgId: 'org1',
color: AppTheme.secondaryText, sourcePath: draggedFile.path,
), targetPath: file.path,
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),
),
],
), ),
);
},
);
} else {
return Draggable<FileItem>(
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) ...[ if (state.totalPages > 1) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
@@ -779,6 +805,7 @@ class _FileExplorerState extends State<FileExplorer> {
), ),
); );
} }
return const SizedBox.shrink(); return const SizedBox.shrink();
}, },
); );

View File

@@ -6,5 +6,6 @@ abstract class FileRepository {
Future<void> uploadFile(String orgId, FileItem file); Future<void> uploadFile(String orgId, FileItem file);
Future<void> deleteFile(String orgId, String path); Future<void> deleteFile(String orgId, String path);
Future<void> createFolder(String orgId, String parentPath, String folderName); Future<void> createFolder(String orgId, String parentPath, String folderName);
Future<void> moveFile(String orgId, String sourcePath, String targetPath);
Future<List<FileItem>> searchFiles(String orgId, String query); Future<List<FileItem>> searchFiles(String orgId, String query);
} }

View File

@@ -222,6 +222,28 @@ class MockFileRepository implements FileRepository {
); );
} }
@override
Future<void> 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 @override
Future<List<FileItem>> searchFiles(String orgId, String query) async { Future<List<FileItem>> searchFiles(String orgId, String query) async {
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));

View File

@@ -45,6 +45,17 @@ class FileService {
await _fileRepository.createFolder(orgId, parentPath, folderName); await _fileRepository.createFolder(orgId, parentPath, folderName);
} }
Future<void> 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<List<FileItem>> searchFiles(String orgId, String query) async { Future<List<FileItem>> searchFiles(String orgId, String query) async {
if (query.isEmpty) { if (query.isEmpty) {
return []; return [];