1205 lines
47 KiB
Dart
1205 lines
47 KiB
Dart
import 'dart:ui';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:file_picker/file_picker.dart' hide FileType;
|
|
import 'package:path/path.dart' as p;
|
|
import 'dart:html' as html;
|
|
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 '../blocs/upload/upload_state.dart';
|
|
import '../models/file_item.dart';
|
|
import '../theme/app_theme.dart';
|
|
import '../theme/modern_glass_button.dart';
|
|
import 'document_viewer.dart';
|
|
import 'editor_page.dart';
|
|
import '../injection.dart';
|
|
import '../services/file_service.dart';
|
|
|
|
class FileExplorer extends StatefulWidget {
|
|
final String orgId;
|
|
|
|
const FileExplorer({super.key, required this.orgId});
|
|
|
|
@override
|
|
State<FileExplorer> createState() => _FileExplorerState();
|
|
}
|
|
|
|
class _FileExplorerState extends State<FileExplorer> {
|
|
String? _selectedFilePath;
|
|
bool _isSearching = false;
|
|
bool _showField = false;
|
|
final TextEditingController _searchController = TextEditingController();
|
|
String _searchQuery = '';
|
|
final Map<String, bool> _hovered = {};
|
|
|
|
String _getParentPath(String path) {
|
|
if (path == '/') return '/';
|
|
final parts = path.split('/').where((p) => p.isNotEmpty).toList();
|
|
if (parts.isEmpty) return '/';
|
|
parts.removeLast();
|
|
return '/${parts.join('/')}';
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
context.read<FileBrowserBloc>().add(
|
|
LoadDirectory(orgId: widget.orgId, path: '/'),
|
|
);
|
|
context.read<PermissionBloc>().add(LoadPermissions(widget.orgId));
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant FileExplorer oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.orgId != widget.orgId) {
|
|
// Reset and reload when switching between Personal and Org workspaces
|
|
final bloc = context.read<FileBrowserBloc>();
|
|
bloc.add(ResetFileBrowser());
|
|
bloc.add(LoadDirectory(orgId: widget.orgId, path: '/'));
|
|
}
|
|
}
|
|
|
|
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: [
|
|
const Text(
|
|
'New Folder',
|
|
style: TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
style: const TextStyle(color: AppTheme.primaryText),
|
|
cursorColor: AppTheme.accentColor,
|
|
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: const OutlineInputBorder(
|
|
borderRadius: BorderRadius.all(Radius.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) {
|
|
// Strip any leading/trailing slashes so we only send the bare folder name
|
|
final sanitized = folderName
|
|
.replaceAll(RegExp(r'^/+'), '')
|
|
.replaceAll(RegExp(r'/+$'), '');
|
|
if (sanitized.isNotEmpty) {
|
|
Navigator.of(context).pop(sanitized);
|
|
}
|
|
}
|
|
},
|
|
child: const Text(
|
|
'Create',
|
|
style: TextStyle(
|
|
color: AppTheme.accentColor,
|
|
decoration: TextDecoration.underline,
|
|
decorationColor: AppTheme.accentColor,
|
|
decorationThickness: 1.5,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _editFile(FileItem file) {
|
|
_showRenameDialog(file);
|
|
}
|
|
|
|
Future<void> _showRenameDialog(FileItem file) async {
|
|
final TextEditingController controller = TextEditingController(
|
|
text: file.name,
|
|
);
|
|
return showDialog<void>(
|
|
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(
|
|
'Rename ${file.type == FileType.folder ? 'Folder' : 'File'}',
|
|
style: TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
style: const TextStyle(color: AppTheme.primaryText),
|
|
cursorColor: AppTheme.accentColor,
|
|
decoration: InputDecoration(
|
|
hintText:
|
|
'Enter new ${file.type == FileType.folder ? 'folder' : 'file'} 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: const OutlineInputBorder(
|
|
borderRadius: BorderRadius.all(Radius.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 newName = controller.text.trim();
|
|
if (newName.isNotEmpty && newName != file.name) {
|
|
context.read<FileBrowserBloc>().add(
|
|
RenameFile(
|
|
orgId: widget.orgId,
|
|
path: file.path,
|
|
newName: newName,
|
|
),
|
|
);
|
|
Navigator.of(context).pop();
|
|
}
|
|
},
|
|
child: const Text(
|
|
'Rename',
|
|
style: TextStyle(
|
|
color: AppTheme.accentColor,
|
|
decoration: TextDecoration.underline,
|
|
decorationColor: AppTheme.accentColor,
|
|
decorationThickness: 1.5,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _downloadFile(FileItem file) async {
|
|
try {
|
|
final fileService = getIt<FileService>();
|
|
final downloadUrl = await fileService.getDownloadUrl(
|
|
widget.orgId,
|
|
file.path,
|
|
);
|
|
|
|
// For web, use the download URL with the ApiClient base URL (from DI)
|
|
final fullUrl = '${fileService.baseUrl}$downloadUrl';
|
|
|
|
// Trigger download via anchor element
|
|
html.AnchorElement(href: fullUrl)
|
|
..setAttribute('download', file.name)
|
|
..click();
|
|
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Downloading ${file.name}')));
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Download failed: $e')));
|
|
}
|
|
}
|
|
}
|
|
|
|
void _sendFile(FileItem file) {
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(SnackBar(content: Text('Send ${file.name}')));
|
|
}
|
|
|
|
Future<void> _deleteFile(FileItem file) async {
|
|
final confirmed = await showDialog<bool>(
|
|
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(
|
|
'Delete ${file.name}?',
|
|
style: TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'This action cannot be undone.',
|
|
style: TextStyle(color: AppTheme.primaryText),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text(
|
|
'Cancel',
|
|
style: TextStyle(color: AppTheme.primaryText),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: const Text(
|
|
'Delete',
|
|
style: TextStyle(
|
|
color: Colors.red,
|
|
decoration: TextDecoration.underline,
|
|
decorationColor: Colors.red,
|
|
decorationThickness: 1.5,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
if (confirmed == true) {
|
|
context.read<FileBrowserBloc>().add(
|
|
DeleteFile(orgId: widget.orgId, path: file.path),
|
|
);
|
|
}
|
|
}
|
|
|
|
Widget _buildTitle() {
|
|
const double titleWidth = 72.0;
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
height: 50,
|
|
child: Stack(
|
|
alignment: Alignment.centerLeft,
|
|
children: [
|
|
const Positioned(
|
|
left: 0,
|
|
child: 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,
|
|
),
|
|
splashColor: Colors.transparent,
|
|
highlightColor: Colors.transparent,
|
|
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;
|
|
});
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
right: 0,
|
|
top: 0,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(top: 2),
|
|
child: BlocBuilder<FileBrowserBloc, FileBrowserState>(
|
|
builder: (context, state) {
|
|
String currentSort = 'name';
|
|
bool isAscending = true;
|
|
if (state is DirectoryLoaded) {
|
|
currentSort = state.sortBy;
|
|
isAscending = state.isAscending;
|
|
}
|
|
return Row(
|
|
children: [
|
|
PopupMenuButton<String>(
|
|
color: AppTheme.accentColor.withAlpha(220),
|
|
position: PopupMenuPosition.under,
|
|
offset: const Offset(48, 8),
|
|
itemBuilder: (BuildContext context) => const [
|
|
PopupMenuItem(value: 'name', child: Text('Name')),
|
|
PopupMenuItem(value: 'date', child: Text('Date')),
|
|
PopupMenuItem(value: 'size', child: Text('Size')),
|
|
PopupMenuItem(value: 'type', child: Text('Type')),
|
|
],
|
|
onSelected: (value) {
|
|
context.read<FileBrowserBloc>().add(
|
|
ApplySort(value, isAscending: isAscending),
|
|
);
|
|
},
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.arrow_drop_down,
|
|
color: AppTheme.primaryText,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
currentSort == 'name'
|
|
? 'Name'
|
|
: currentSort == 'date'
|
|
? 'Date'
|
|
: currentSort == 'size'
|
|
? 'Size'
|
|
: 'Type',
|
|
style: const TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
icon: Icon(
|
|
isAscending
|
|
? Icons.arrow_upward
|
|
: Icons.arrow_downward,
|
|
color: AppTheme.accentColor,
|
|
),
|
|
splashColor: Colors.transparent,
|
|
highlightColor: Colors.transparent,
|
|
onPressed: () {
|
|
context.read<FileBrowserBloc>().add(
|
|
ApplySort(currentSort, isAscending: !isAscending),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
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),
|
|
cursorColor: AppTheme.accentColor,
|
|
decoration: InputDecoration(
|
|
hintText: 'Search Files...',
|
|
hintStyle: const TextStyle(color: AppTheme.secondaryText),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
vertical: 2,
|
|
horizontal: 12,
|
|
),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(24),
|
|
borderSide: BorderSide(
|
|
color: AppTheme.secondaryText.withValues(alpha: 0.5),
|
|
),
|
|
),
|
|
focusedBorder: const OutlineInputBorder(
|
|
borderRadius: BorderRadius.all(Radius.circular(24)),
|
|
borderSide: BorderSide(color: AppTheme.accentColor),
|
|
),
|
|
),
|
|
onChanged: (value) {
|
|
_searchQuery = value;
|
|
context.read<FileBrowserBloc>().add(
|
|
ApplyFilter(_searchQuery),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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 {
|
|
if (file.id == null || file.id!.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Error: File ID is missing')),
|
|
);
|
|
return;
|
|
}
|
|
_showDocumentViewer(widget.orgId, file.id!);
|
|
}
|
|
},
|
|
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: file.type == FileType.folder
|
|
? Icon(Icons.folder, color: AppTheme.primaryText, size: 36)
|
|
: Stack(
|
|
alignment: Alignment.bottomRight,
|
|
children: [
|
|
Icon(
|
|
Icons.insert_drive_file,
|
|
color: AppTheme.primaryText,
|
|
size: 36,
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 2, right: 6),
|
|
child: Text(
|
|
p.extension(file.name).toUpperCase(),
|
|
style: const TextStyle(
|
|
color: Colors.black,
|
|
fontSize: 6.25,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete, color: AppTheme.secondaryText),
|
|
splashColor: Colors.transparent,
|
|
highlightColor: Colors.transparent,
|
|
onPressed: () => _deleteFile(file),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocListener<UploadBloc, UploadState>(
|
|
listener: (context, uploadState) {
|
|
if (uploadState is UploadInProgress) {
|
|
// Show error if any upload failed
|
|
for (final upload in uploadState.uploads) {
|
|
if (upload.error != null && upload.error!.isNotEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Upload failed: ${upload.error}')),
|
|
);
|
|
}
|
|
}
|
|
final hasCompleted = uploadState.uploads.any((u) => u.isCompleted);
|
|
if (hasCompleted) {
|
|
final fbState = context.read<FileBrowserBloc>().state;
|
|
String currentPath = '/';
|
|
if (fbState is DirectoryLoaded) currentPath = fbState.currentPath;
|
|
if (fbState is DirectoryEmpty) currentPath = fbState.currentPath;
|
|
context.read<FileBrowserBloc>().add(
|
|
LoadDirectory(orgId: widget.orgId, path: currentPath),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
child: BlocBuilder<FileBrowserBloc, FileBrowserState>(
|
|
builder: (context, state) {
|
|
if (state is DirectoryLoading) {
|
|
return Center(
|
|
child: CircularProgressIndicator(color: AppTheme.accentColor),
|
|
);
|
|
}
|
|
|
|
if (state is DirectoryError) {
|
|
return Center(
|
|
child: Text(
|
|
'Error: ${state.error}',
|
|
style: const TextStyle(color: AppTheme.primaryText),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (state is DirectoryEmpty) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: AppTheme.primaryText.withValues(alpha: 0.5),
|
|
width: 1,
|
|
),
|
|
),
|
|
),
|
|
child: _buildTitle(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
BlocBuilder<PermissionBloc, PermissionState>(
|
|
builder: (context, permState) {
|
|
if (permState is PermissionLoaded &&
|
|
permState.capabilities.canWrite) {
|
|
return Row(
|
|
children: [
|
|
ModernGlassButton(
|
|
onPressed: () async {
|
|
final result = await FilePicker.platform
|
|
.pickFiles(withData: true);
|
|
if (result != null && result.files.isNotEmpty) {
|
|
final files = result.files
|
|
.map(
|
|
(file) => FileItem(
|
|
name: file.name,
|
|
// Parent path only; server uses filename from multipart
|
|
path: state.currentPath,
|
|
type: FileType.file,
|
|
size: file.size,
|
|
lastModified: DateTime.now(),
|
|
localPath: file.path,
|
|
bytes: file.bytes,
|
|
),
|
|
)
|
|
.toList();
|
|
context.read<UploadBloc>().add(
|
|
StartUpload(
|
|
files: files,
|
|
targetPath: state.currentPath,
|
|
orgId: widget.orgId,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
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: widget.orgId,
|
|
parentPath: '/',
|
|
folderName: folderName,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
child: const Row(
|
|
children: [
|
|
Icon(Icons.create_new_folder),
|
|
SizedBox(width: 8),
|
|
Text('New Folder'),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
// Only show back button and "Empty Folder" text if not at root
|
|
if (state.currentPath != '/')
|
|
Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(
|
|
Icons.arrow_back,
|
|
color: AppTheme.primaryText,
|
|
),
|
|
splashColor: Colors.transparent,
|
|
highlightColor: Colors.transparent,
|
|
onPressed: () {
|
|
final parentPath = _getParentPath(state.currentPath);
|
|
context.read<FileBrowserBloc>().add(
|
|
LoadDirectory(
|
|
orgId: widget.orgId,
|
|
path: parentPath,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const Text(
|
|
'Empty Folder',
|
|
style: TextStyle(color: AppTheme.primaryText),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (state is DirectoryLoaded) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
bottom: BorderSide(
|
|
color: AppTheme.primaryText.withValues(alpha: 0.5),
|
|
width: 1,
|
|
),
|
|
),
|
|
),
|
|
child: _buildTitle(),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Visibility(
|
|
visible: state.breadcrumbs.isNotEmpty,
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(
|
|
Icons.arrow_back,
|
|
color: AppTheme.primaryText,
|
|
),
|
|
onPressed: () {
|
|
final parentPath = _getParentPath(
|
|
state.currentPath,
|
|
);
|
|
context.read<FileBrowserBloc>().add(
|
|
LoadDirectory(
|
|
orgId: widget.orgId,
|
|
path: parentPath,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: state.breadcrumbs.map((breadcrumb) {
|
|
return TextButton(
|
|
onPressed: () {
|
|
context.read<FileBrowserBloc>().add(
|
|
NavigateToFolder(breadcrumb.path),
|
|
);
|
|
},
|
|
child: Text(
|
|
'/${breadcrumb.name}',
|
|
style: const TextStyle(
|
|
color: AppTheme.secondaryText,
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
),
|
|
BlocBuilder<PermissionBloc, PermissionState>(
|
|
builder: (context, permState) {
|
|
if (permState is PermissionLoaded &&
|
|
permState.capabilities.canWrite) {
|
|
return Row(
|
|
children: [
|
|
ModernGlassButton(
|
|
onPressed: () async {
|
|
final result = await FilePicker.platform
|
|
.pickFiles(withData: true);
|
|
if (result != null && result.files.isNotEmpty) {
|
|
final files = result.files
|
|
.map(
|
|
(file) => FileItem(
|
|
name: file.name,
|
|
// Parent path only; server uses filename from multipart
|
|
path: state.currentPath,
|
|
type: FileType.file,
|
|
size: file.size,
|
|
lastModified: DateTime.now(),
|
|
localPath: file.path,
|
|
bytes: file.bytes,
|
|
),
|
|
)
|
|
.toList();
|
|
context.read<UploadBloc>().add(
|
|
StartUpload(
|
|
files: files,
|
|
targetPath: state.currentPath,
|
|
orgId: widget.orgId,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
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: widget.orgId,
|
|
parentPath: state.currentPath,
|
|
folderName: folderName,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
child: const Row(
|
|
children: [
|
|
Icon(Icons.create_new_folder),
|
|
SizedBox(width: 8),
|
|
Text('New Folder'),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
Expanded(
|
|
child: ListView.builder(
|
|
itemCount: state.paginatedFiles.length,
|
|
itemBuilder: (context, index) {
|
|
final file = state.paginatedFiles[index];
|
|
final isSelected = _selectedFilePath == file.path;
|
|
final isHovered = _hovered[file.path] ?? false;
|
|
|
|
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,
|
|
),
|
|
),
|
|
child: _buildFileItem(
|
|
file,
|
|
isSelected,
|
|
isHovered,
|
|
isDraggedOver,
|
|
),
|
|
);
|
|
},
|
|
onAcceptWithDetails: (draggedFile) {
|
|
context.read<FileBrowserBloc>().add(
|
|
MoveFile(
|
|
orgId: widget.orgId,
|
|
sourcePath: draggedFile.data.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,
|
|
),
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
if (state.totalPages > 1) ...[
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(
|
|
Icons.chevron_left,
|
|
color: AppTheme.primaryText,
|
|
),
|
|
splashColor: Colors.transparent,
|
|
highlightColor: Colors.transparent,
|
|
onPressed: state.currentPage > 1
|
|
? () {
|
|
context.read<FileBrowserBloc>().add(
|
|
LoadPage(state.currentPage - 1),
|
|
);
|
|
}
|
|
: null,
|
|
),
|
|
Text(
|
|
'${state.currentPage} / ${state.totalPages}',
|
|
style: const TextStyle(
|
|
color: AppTheme.primaryText,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(
|
|
Icons.chevron_right,
|
|
color: AppTheme.primaryText,
|
|
),
|
|
splashColor: Colors.transparent,
|
|
highlightColor: Colors.transparent,
|
|
onPressed: state.currentPage < state.totalPages
|
|
? () {
|
|
context.read<FileBrowserBloc>().add(
|
|
LoadPage(state.currentPage + 1),
|
|
);
|
|
}
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showDocumentViewer(String orgId, String fileId) {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
barrierColor: Colors.transparent,
|
|
builder: (BuildContext context) {
|
|
return BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
|
child: Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
insetPadding: const EdgeInsets.all(16),
|
|
child: Container(
|
|
constraints: BoxConstraints(
|
|
maxWidth: MediaQuery.of(context).size.width * 0.9,
|
|
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.primaryBackground,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
|
width: 2,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.5),
|
|
blurRadius: 20,
|
|
spreadRadius: 5,
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(14),
|
|
child: DocumentViewerModal(
|
|
orgId: orgId,
|
|
fileId: fileId,
|
|
onEdit: () {
|
|
Navigator.of(context).pop();
|
|
_showDocumentEditor(orgId, fileId);
|
|
},
|
|
onClose: () => Navigator.of(context).pop(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
void _showDocumentEditor(String orgId, String fileId) {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
barrierColor: Colors.transparent,
|
|
builder: (BuildContext context) {
|
|
return BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
|
child: Dialog(
|
|
backgroundColor: Colors.transparent,
|
|
insetPadding: const EdgeInsets.all(16),
|
|
child: Container(
|
|
constraints: BoxConstraints(
|
|
maxWidth: MediaQuery.of(context).size.width * 0.9,
|
|
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppTheme.primaryBackground,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
|
width: 2,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.5),
|
|
blurRadius: 20,
|
|
spreadRadius: 5,
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(14),
|
|
child: EditorPageModal(
|
|
orgId: orgId,
|
|
fileId: fileId,
|
|
onClose: () => Navigator.of(context).pop(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|