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;
String _sortBy = 'name';
bool _isAscending = true;
String _currentFilter = '';
FileBrowserBloc(this._fileService) : super(DirectoryInitial()) {
on<LoadDirectory>(_onLoadDirectory);
@@ -22,6 +23,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
on<ApplySort>(_onApplySort);
on<ApplyFilter>(_onApplyFilter);
on<CreateFolder>(_onCreateFolder);
on<MoveFile>(_onMoveFile);
on<ResetFileBrowser>(_onResetFileBrowser);
on<LoadPage>(_onLoadPage);
on<ChangePageSize>(_onChangePageSize);
@@ -39,6 +41,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
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<FileBrowserEvent, FileBrowserState> {
_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<FileBrowserState> 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<FileBrowserEvent, FileBrowserState> {
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<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());
_currentOrgId = '';
_currentPath = '/';
_currentFilter = '';
_currentPage = 1;
_pageSize = 20;
_sortBy = 'name';
@@ -133,6 +175,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
final files = await _fileService.searchFiles(event.orgId, event.query);
_currentFiles = files;
_filteredFiles = files;
_currentFilter = '';
_currentPage = 1;
if (files.isEmpty) {
emit(DirectoryEmpty(_currentPath));

View File

@@ -91,3 +91,18 @@ class SearchFiles extends FileBrowserEvent {
@override
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),
),
),
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<FileExplorer> {
}
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<FileExplorer> {
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<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
Widget build(BuildContext context) {
return BlocBuilder<FileBrowserBloc, FileBrowserState>(
@@ -350,6 +436,7 @@ class _FileExplorerState extends State<FileExplorer> {
child: CircularProgressIndicator(color: AppTheme.accentColor),
);
}
if (state is DirectoryError) {
return Center(
child: Text(
@@ -358,6 +445,7 @@ class _FileExplorerState extends State<FileExplorer> {
),
);
}
if (state is DirectoryEmpty) {
return Padding(
padding: const EdgeInsets.all(16.0),
@@ -425,7 +513,7 @@ class _FileExplorerState extends State<FileExplorer> {
context.read<FileBrowserBloc>().add(
CreateFolder(
orgId: 'org1',
parentPath: state.currentPath,
parentPath: '/',
folderName: folderName,
),
);
@@ -456,21 +544,9 @@ class _FileExplorerState extends State<FileExplorer> {
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<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) {
return Padding(
padding: const EdgeInsets.all(16.0),
@@ -502,7 +579,6 @@ class _FileExplorerState extends State<FileExplorer> {
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<FileExplorer> {
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<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(
if (file.type == FileType.folder) {
return DragTarget<FileItem>(
builder: (context, candidate, rejected) {
final isDraggedOver = candidate.isNotEmpty;
return Draggable<FileItem>(
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<FileBrowserBloc>().add(
MoveFile(
orgId: 'org1',
sourcePath: draggedFile.path,
targetPath: file.path,
),
);
},
);
} 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) ...[
const SizedBox(height: 16),
Row(
@@ -779,6 +805,7 @@ class _FileExplorerState extends State<FileExplorer> {
),
);
}
return const SizedBox.shrink();
},
);

View File

@@ -6,5 +6,6 @@ abstract class FileRepository {
Future<void> uploadFile(String orgId, FileItem file);
Future<void> deleteFile(String orgId, String path);
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);
}

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
Future<List<FileItem>> searchFiles(String orgId, String query) async {
await Future.delayed(const Duration(seconds: 1));

View File

@@ -45,6 +45,17 @@ class FileService {
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 {
if (query.isEmpty) {
return [];