third commit

This commit is contained in:
Leon Bösche
2025-12-16 21:19:26 +01:00
parent bd9dc5e485
commit b0a6da9a0d
8 changed files with 614 additions and 105 deletions

View File

@@ -9,6 +9,10 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
String _currentOrgId = '';
String _currentPath = '/';
List<FileItem> _currentFiles = [];
List<FileItem> _filteredFiles = [];
int _currentPage = 1;
int _pageSize = 20;
String _sortBy = 'name';
FileBrowserBloc(this._fileService) : super(DirectoryInitial()) {
on<LoadDirectory>(_onLoadDirectory);
@@ -18,6 +22,9 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
on<ApplyFilter>(_onApplyFilter);
on<CreateFolder>(_onCreateFolder);
on<ResetFileBrowser>(_onResetFileBrowser);
on<LoadPage>(_onLoadPage);
on<ChangePageSize>(_onChangePageSize);
on<SearchFiles>(_onSearchFiles);
}
void _onLoadDirectory(
@@ -29,19 +36,13 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
_currentPath = event.path;
try {
final files = await _fileService.getFiles(event.orgId, event.path);
final breadcrumbs = _generateBreadcrumbs(event.path);
_currentFiles = files;
_currentFiles = _sortFiles(files, _sortBy);
_filteredFiles = _currentFiles;
_currentPage = 1;
if (files.isEmpty) {
emit(DirectoryEmpty());
} else {
emit(
DirectoryLoaded(
files: files,
filteredFiles: files,
breadcrumbs: breadcrumbs,
currentPath: event.path,
),
);
_emitLoadedState(emit);
}
} catch (e) {
emit(DirectoryError(e.toString()));
@@ -64,24 +65,21 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
}
void _onApplySort(ApplySort event, Emitter<FileBrowserState> emit) {
// Implement sorting
// For now, just refresh
add(RefreshDirectory());
_sortBy = event.sortBy;
_currentFiles = _sortFiles(_currentFiles, _sortBy);
_filteredFiles = _currentFiles
.where((f) => f.name.toLowerCase().contains(''))
.toList(); // Re-apply filter if any, but since filter is separate, perhaps need to track filter
_currentPage = 1;
_emitLoadedState(emit);
}
void _onApplyFilter(ApplyFilter event, Emitter<FileBrowserState> emit) {
// Implement filtering
final filtered = _currentFiles
_filteredFiles = _currentFiles
.where((f) => f.name.toLowerCase().contains(event.filter.toLowerCase()))
.toList();
emit(
DirectoryLoaded(
files: _currentFiles,
filteredFiles: filtered,
breadcrumbs: _generateBreadcrumbs(_currentPath),
currentPath: _currentPath,
),
);
_currentPage = 1;
_emitLoadedState(emit);
}
void _onCreateFolder(
@@ -110,6 +108,77 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
emit(DirectoryInitial());
_currentOrgId = '';
_currentPath = '/';
_currentPage = 1;
_pageSize = 20;
_sortBy = 'name';
}
void _onLoadPage(LoadPage event, Emitter<FileBrowserState> emit) {
_currentPage = event.page;
_emitLoadedState(emit);
}
void _onChangePageSize(ChangePageSize event, Emitter<FileBrowserState> emit) {
_pageSize = event.pageSize;
_currentPage = 1; // Reset to first page
_emitLoadedState(emit);
}
void _onSearchFiles(SearchFiles event, Emitter<FileBrowserState> emit) async {
emit(DirectoryLoading());
try {
final files = await _fileService.searchFiles(event.orgId, event.query);
_currentFiles = files;
_filteredFiles = files;
_currentPage = 1;
if (files.isEmpty) {
emit(DirectoryEmpty());
} else {
_emitLoadedState(emit);
}
} catch (e) {
emit(DirectoryError(e.toString()));
}
}
void _emitLoadedState(Emitter<FileBrowserState> emit) {
final paginatedFiles = _getPaginatedFiles();
final totalPages = (_filteredFiles.length / _pageSize).ceil();
emit(
DirectoryLoaded(
files: _currentFiles,
filteredFiles: _filteredFiles,
paginatedFiles: paginatedFiles,
breadcrumbs: _generateBreadcrumbs(_currentPath),
currentPath: _currentPath,
currentPage: _currentPage,
pageSize: _pageSize,
totalPages: totalPages,
),
);
}
List<FileItem> _getPaginatedFiles() {
final start = (_currentPage - 1) * _pageSize;
final end = start + _pageSize;
return _filteredFiles.sublist(start, end.clamp(0, _filteredFiles.length));
}
List<FileItem> _sortFiles(List<FileItem> files, String sortBy) {
final sorted = List<FileItem>.from(files);
sorted.sort((a, b) {
switch (sortBy) {
case 'name':
return a.name.compareTo(b.name);
case 'date':
return b.lastModified.compareTo(a.lastModified);
case 'size':
return b.size.compareTo(a.size);
default:
return a.name.compareTo(b.name);
}
});
return sorted;
}
List<Breadcrumb> _generateBreadcrumbs(String path) {

View File

@@ -62,3 +62,31 @@ class CreateFolder extends FileBrowserEvent {
}
class ResetFileBrowser extends FileBrowserEvent {}
class LoadPage extends FileBrowserEvent {
final int page;
const LoadPage(this.page);
@override
List<Object> get props => [page];
}
class ChangePageSize extends FileBrowserEvent {
final int pageSize;
const ChangePageSize(this.pageSize);
@override
List<Object> get props => [pageSize];
}
class SearchFiles extends FileBrowserEvent {
final String orgId;
final String query;
const SearchFiles({required this.orgId, required this.query});
@override
List<Object> get props => [orgId, query];
}

View File

@@ -25,18 +25,35 @@ class DirectoryLoading extends FileBrowserState {}
class DirectoryLoaded extends FileBrowserState {
final List<FileItem> files;
final List<FileItem> filteredFiles;
final List<FileItem> paginatedFiles;
final List<Breadcrumb> breadcrumbs;
final String currentPath;
final int currentPage;
final int pageSize;
final int totalPages;
const DirectoryLoaded({
required this.files,
required this.filteredFiles,
required this.paginatedFiles,
required this.breadcrumbs,
required this.currentPath,
required this.currentPage,
required this.pageSize,
required this.totalPages,
});
@override
List<Object> get props => [files, filteredFiles, breadcrumbs, currentPath];
List<Object> get props => [
files,
filteredFiles,
paginatedFiles,
breadcrumbs,
currentPath,
currentPage,
pageSize,
totalPages,
];
}
class DirectoryEmpty extends FileBrowserState {}

View File

@@ -7,22 +7,28 @@ import 'blocs/organization/organization_bloc.dart';
import 'blocs/permission/permission_bloc.dart';
import 'blocs/file_browser/file_browser_bloc.dart';
import 'blocs/upload/upload_bloc.dart';
import 'services/file_service.dart';
import 'repositories/mock_file_repository.dart';
import 'theme/app_theme.dart';
import 'services/file_service.dart';
import 'pages/home_page.dart';
import 'pages/login_form.dart';
import 'pages/file_explorer.dart';
import 'pages/document_viewer.dart';
import 'theme/app_theme.dart';
final GoRouter _router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => const HomePage()),
GoRoute(path: '/login', builder: (context, state) => const LoginForm()),
GoRoute(
path: '/viewer/:fileId',
builder: (context, state) =>
DocumentViewer(fileId: state.pathParameters['fileId']!),
),
GoRoute(
path: '/org/:orgId/drive',
builder: (context, state) => const FileExplorer(),
),
],
);

View File

@@ -6,10 +6,13 @@ import '../blocs/file_browser/file_browser_bloc.dart';
import '../blocs/file_browser/file_browser_event.dart';
import '../blocs/file_browser/file_browser_state.dart';
import '../blocs/permission/permission_bloc.dart';
import '../blocs/permission/permission_event.dart';
import '../blocs/permission/permission_state.dart';
import '../blocs/upload/upload_bloc.dart';
import '../blocs/upload/upload_event.dart';
import '../models/file_item.dart';
import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart';
class FileExplorer extends StatefulWidget {
const FileExplorer({super.key});
@@ -19,6 +22,18 @@ class FileExplorer extends StatefulWidget {
}
class _FileExplorerState extends State<FileExplorer> {
String? _selectedFilePath;
bool _isSearching = false;
bool _showField = false;
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
@@ -26,6 +41,217 @@ class _FileExplorerState extends State<FileExplorer> {
context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: 'org1', path: '/'),
);
context.read<PermissionBloc>().add(LoadPermissions('org1'));
}
Future<String?> _showCreateFolderDialog(BuildContext context) async {
final TextEditingController controller = TextEditingController();
return showDialog<String>(
context: context,
builder: (BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 300),
child: Container(
decoration: AppTheme.glassDecoration,
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'New Folder',
style: const TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
TextField(
controller: controller,
autofocus: true,
style: const TextStyle(color: AppTheme.primaryText),
decoration: InputDecoration(
hintText: 'Enter folder name',
hintStyle: const TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppTheme.accentColor.withValues(alpha: 0.5),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: AppTheme.secondaryText.withValues(alpha: 0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(color: AppTheme.accentColor),
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text(
'Cancel',
style: TextStyle(color: AppTheme.primaryText),
),
),
TextButton(
onPressed: () {
final folderName = controller.text.trim();
if (folderName.isNotEmpty) {
final nameWithSlash = folderName.startsWith('/')
? folderName
: '/$folderName';
Navigator.of(context).pop(nameWithSlash);
}
},
child: const Text(
'Create',
style: TextStyle(color: AppTheme.accentColor),
),
),
],
),
],
),
),
),
);
},
);
}
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}')));
}
Widget _buildTitle() {
const double titleWidth = 72.0;
return SizedBox(
width: double.infinity,
height: 50,
child: Stack(
alignment: Alignment.centerLeft,
children: [
Positioned(
left: 0,
child: const Text(
'/Drive',
style: TextStyle(
fontSize: 24,
color: AppTheme.primaryText,
fontWeight: FontWeight.bold,
),
),
),
AnimatedPositioned(
left: _isSearching ? titleWidth + 250.0 : titleWidth,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: Padding(
padding: const EdgeInsets.only(top: 2),
child: IconButton(
icon: Icon(
_isSearching ? Icons.close : Icons.search,
color: AppTheme.accentColor,
),
onPressed: () {
if (_isSearching) {
setState(() {
_showField = false;
_isSearching = false;
_searchController.clear();
_searchQuery = '';
context.read<FileBrowserBloc>().add(ApplyFilter(''));
});
} else {
setState(() {
_isSearching = true;
});
Future.delayed(const Duration(milliseconds: 150), () {
setState(() {
_showField = true;
});
});
}
},
),
),
),
if (_showField)
AnimatedPositioned(
left: titleWidth,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: SizedBox(
width: 250,
child: TextField(
controller: _searchController,
autofocus: true,
style: const TextStyle(color: AppTheme.primaryText),
decoration: InputDecoration(
hintText: 'Search Files...',
hintStyle: const TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide(
color: AppTheme.secondaryText.withValues(alpha: 0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide(color: AppTheme.accentColor),
),
),
onChanged: (value) {
_searchQuery = value;
context.read<FileBrowserBloc>().add(
ApplyFilter(_searchQuery),
);
},
),
),
),
],
),
);
}
@override
@@ -39,7 +265,7 @@ class _FileExplorerState extends State<FileExplorer> {
return Center(
child: Text(
'Error: ${state.error}',
style: const TextStyle(color: Colors.white),
style: const TextStyle(color: AppTheme.primaryText),
),
);
}
@@ -49,16 +275,25 @@ class _FileExplorerState extends State<FileExplorer> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Drive',
style: TextStyle(fontSize: 24, color: Colors.white),
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: AppTheme.primaryText.withValues(alpha: 0.5),
width: 1,
),
),
),
child: _buildTitle(),
),
const SizedBox(height: 16),
// Back button
Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
icon: const Icon(
Icons.arrow_back,
color: AppTheme.primaryText,
),
onPressed: () {
context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: 'org1', path: '/'),
@@ -67,7 +302,7 @@ class _FileExplorerState extends State<FileExplorer> {
),
const Text(
'Empty Folder',
style: TextStyle(color: Colors.white),
style: TextStyle(color: AppTheme.primaryText),
),
],
),
@@ -76,9 +311,12 @@ class _FileExplorerState extends State<FileExplorer> {
builder: (context, permState) {
if (permState is PermissionLoaded &&
permState.capabilities.canWrite) {
return ElevatedButton(
return Row(
children: [
ModernGlassButton(
onPressed: () async {
final result = await FilePicker.platform.pickFiles();
final result = await FilePicker.platform
.pickFiles();
if (result != null && result.files.isNotEmpty) {
final files = result.files
.map(
@@ -100,7 +338,39 @@ class _FileExplorerState extends State<FileExplorer> {
);
}
},
child: const Text('Upload File'),
child: const Row(
children: [
Icon(Icons.upload),
SizedBox(width: 8),
Text('Upload File'),
],
),
),
const SizedBox(width: 16),
ModernGlassButton(
onPressed: () async {
final folderName = await _showCreateFolderDialog(
context,
);
if (folderName != null && folderName.isNotEmpty) {
context.read<FileBrowserBloc>().add(
CreateFolder(
orgId: 'org1',
parentPath: '/',
folderName: folderName,
),
);
}
},
child: const Row(
children: [
Icon(Icons.create_new_folder),
SizedBox(width: 8),
Text('New Folder'),
],
),
),
],
);
}
return const SizedBox.shrink();
@@ -116,9 +386,16 @@ class _FileExplorerState extends State<FileExplorer> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Drive',
style: TextStyle(fontSize: 24, color: Colors.white),
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: AppTheme.primaryText.withValues(alpha: 0.5),
width: 1,
),
),
),
child: _buildTitle(),
),
const SizedBox(height: 16),
// Breadcrumbs and back button
@@ -131,7 +408,7 @@ class _FileExplorerState extends State<FileExplorer> {
IconButton(
icon: const Icon(
Icons.arrow_back,
color: Colors.white,
color: AppTheme.primaryText,
),
onPressed: () {
context.read<FileBrowserBloc>().add(
@@ -153,7 +430,7 @@ class _FileExplorerState extends State<FileExplorer> {
child: Text(
'${breadcrumb.name}/',
style: const TextStyle(
color: Colors.white70,
color: AppTheme.secondaryText,
),
),
);
@@ -171,9 +448,12 @@ class _FileExplorerState extends State<FileExplorer> {
builder: (context, permState) {
if (permState is PermissionLoaded &&
permState.capabilities.canWrite) {
return ElevatedButton(
return Row(
children: [
ModernGlassButton(
onPressed: () async {
final result = await FilePicker.platform.pickFiles();
final result = await FilePicker.platform
.pickFiles();
if (result != null && result.files.isNotEmpty) {
final files = result.files
.map(
@@ -195,7 +475,40 @@ class _FileExplorerState extends State<FileExplorer> {
);
}
},
child: const Text('Upload File'),
child: const Row(
children: [
Icon(Icons.upload),
SizedBox(width: 8),
Text('Upload File'),
],
),
),
const SizedBox(width: 16),
ModernGlassButton(
onPressed: () async {
final folderName = await _showCreateFolderDialog(
context,
);
if (folderName != null && folderName.isNotEmpty) {
context.read<FileBrowserBloc>().add(
CreateFolder(
orgId: 'org1',
parentPath: state.currentPath,
folderName: folderName,
),
);
}
},
child: const Row(
children: [
Icon(Icons.create_new_folder),
SizedBox(width: 8),
Text('New Folder'),
],
),
),
],
);
}
return const SizedBox.shrink();
@@ -204,27 +517,15 @@ class _FileExplorerState extends State<FileExplorer> {
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: state.files.length,
itemCount: state.filteredFiles.length,
itemBuilder: (context, index) {
final file = state.files[index];
return ListTile(
leading: Icon(
file.type == FileType.folder
? Icons.folder
: Icons.insert_drive_file,
color: Colors.white,
),
title: Text(
file.name,
style: const TextStyle(color: Colors.white),
),
subtitle: Text(
file.type == FileType.folder
? 'Folder'
: 'File - ${file.size} bytes',
style: const TextStyle(color: Colors.white70),
),
final file = state.filteredFiles[index];
final isSelected = _selectedFilePath == file.path;
return GestureDetector(
onTap: () {
setState(() {
_selectedFilePath = file.path;
});
if (file.type == FileType.folder) {
context.read<FileBrowserBloc>().add(
NavigateToFolder(file.path),
@@ -234,6 +535,78 @@ class _FileExplorerState extends State<FileExplorer> {
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)
: 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,
),
onPressed: () => _editFile(file),
),
IconButton(
icon: const Icon(
Icons.download,
color: AppTheme.secondaryText,
),
onPressed: () => _downloadFile(file),
),
IconButton(
icon: const Icon(
Icons.send,
color: AppTheme.secondaryText,
),
onPressed: () => _sendFile(file),
),
],
),
),
),
);
},
),

View File

@@ -6,4 +6,5 @@ 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<List<FileItem>> searchFiles(String orgId, String query);
}

View File

@@ -78,4 +78,12 @@ class MockFileRepository implements FileRepository {
),
);
}
@override
Future<List<FileItem>> searchFiles(String orgId, String query) async {
await Future.delayed(const Duration(seconds: 1));
return _files
.where((f) => f.name.toLowerCase().contains(query.toLowerCase()))
.toList();
}
}

View File

@@ -44,4 +44,11 @@ class FileService {
}
await _fileRepository.createFolder(orgId, parentPath, folderName);
}
Future<List<FileItem>> searchFiles(String orgId, String query) async {
if (query.isEmpty) {
return [];
}
return await _fileRepository.searchFiles(orgId, query);
}
}