✨ Site file management able to upload site
This commit is contained in:
@@ -41,16 +41,40 @@ class FileManagementSection extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
IconButton(
|
PopupMenuButton<String>(
|
||||||
onPressed: () async {
|
icon: const Icon(Symbols.upload),
|
||||||
// Open file upload dialog
|
onSelected: (String choice) async {
|
||||||
final selectedFiles = await FilePicker.platform.pickFiles(
|
List<File> files = [];
|
||||||
allowMultiple: true,
|
List<Map<String, dynamic>>? results;
|
||||||
type: FileType.any,
|
if (choice == 'files') {
|
||||||
);
|
final selectedFiles = await FilePicker.platform.pickFiles(
|
||||||
|
allowMultiple: true,
|
||||||
if (selectedFiles == null || selectedFiles.files.isEmpty) {
|
type: FileType.any,
|
||||||
return; // User canceled
|
);
|
||||||
|
if (selectedFiles == null ||
|
||||||
|
selectedFiles.files.isEmpty) {
|
||||||
|
return; // User canceled
|
||||||
|
}
|
||||||
|
files =
|
||||||
|
selectedFiles.files
|
||||||
|
.map((f) => File(f.path!))
|
||||||
|
.toList();
|
||||||
|
} else if (choice == 'folder') {
|
||||||
|
final dirPath =
|
||||||
|
await FilePicker.platform.getDirectoryPath();
|
||||||
|
if (dirPath == null) return;
|
||||||
|
results = await _getFilesRecursive(dirPath);
|
||||||
|
files = results.map((m) => m['file'] as File).toList();
|
||||||
|
if (files.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'No files found in the selected folder',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
@@ -61,11 +85,12 @@ class FileManagementSection extends HookConsumerWidget {
|
|||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
builder:
|
builder:
|
||||||
(context) => FileUploadDialog(
|
(context) => FileUploadDialog(
|
||||||
selectedFiles:
|
selectedFiles: files,
|
||||||
selectedFiles.files
|
|
||||||
.map((f) => File(f.path!))
|
|
||||||
.toList(),
|
|
||||||
site: site,
|
site: site,
|
||||||
|
relativePaths:
|
||||||
|
results
|
||||||
|
?.map((m) => m['relativePath'] as String)
|
||||||
|
.toList(),
|
||||||
onUploadComplete: () {
|
onUploadComplete: () {
|
||||||
// Refresh file list
|
// Refresh file list
|
||||||
ref.invalidate(
|
ref.invalidate(
|
||||||
@@ -75,10 +100,34 @@ class FileManagementSection extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
icon: const Icon(Symbols.upload),
|
itemBuilder:
|
||||||
visualDensity: const VisualDensity(
|
(BuildContext context) => [
|
||||||
horizontal: -4,
|
const PopupMenuItem<String>(
|
||||||
vertical: -4,
|
value: 'files',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.file_copy),
|
||||||
|
Gap(12),
|
||||||
|
Text('Files'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem<String>(
|
||||||
|
value: 'folder',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Symbols.folder),
|
||||||
|
Gap(12),
|
||||||
|
Text('Folder'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
style: ButtonStyle(
|
||||||
|
visualDensity: const VisualDensity(
|
||||||
|
horizontal: -4,
|
||||||
|
vertical: -4,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -146,4 +195,26 @@ class FileManagementSection extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> _getFilesRecursive(String dirPath) async {
|
||||||
|
final List<Map<String, dynamic>> results = [];
|
||||||
|
try {
|
||||||
|
await for (final entity in Directory(dirPath).list(recursive: true)) {
|
||||||
|
if (entity is File) {
|
||||||
|
String relativePath = entity.path.substring(dirPath.length);
|
||||||
|
if (relativePath.startsWith('/')) {
|
||||||
|
relativePath = relativePath.substring(1);
|
||||||
|
}
|
||||||
|
if (relativePath.isEmpty) continue;
|
||||||
|
results.add({
|
||||||
|
'file': File(entity.path),
|
||||||
|
'relativePath': relativePath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Handle error if needed
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,14 @@ class FileUploadDialog extends HookConsumerWidget {
|
|||||||
final List<File> selectedFiles;
|
final List<File> selectedFiles;
|
||||||
final SnPublicationSite site;
|
final SnPublicationSite site;
|
||||||
final VoidCallback onUploadComplete;
|
final VoidCallback onUploadComplete;
|
||||||
|
final List<String>? relativePaths;
|
||||||
|
|
||||||
const FileUploadDialog({
|
const FileUploadDialog({
|
||||||
super.key,
|
super.key,
|
||||||
required this.selectedFiles,
|
required this.selectedFiles,
|
||||||
required this.site,
|
required this.site,
|
||||||
required this.onUploadComplete,
|
required this.onUploadComplete,
|
||||||
|
this.relativePaths,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -29,7 +31,9 @@ class FileUploadDialog extends HookConsumerWidget {
|
|||||||
selectedFiles
|
selectedFiles
|
||||||
.map(
|
.map(
|
||||||
(file) => {
|
(file) => {
|
||||||
'fileName': file.path.split('/').last,
|
'fileName':
|
||||||
|
relativePaths?[selectedFiles.indexOf(file)] ??
|
||||||
|
file.path.split('/').last,
|
||||||
'progress': 0.0,
|
'progress': 0.0,
|
||||||
'status':
|
'status':
|
||||||
'pending', // 'pending', 'uploading', 'completed', 'error'
|
'pending', // 'pending', 'uploading', 'completed', 'error'
|
||||||
@@ -39,6 +43,26 @@ class FileUploadDialog extends HookConsumerWidget {
|
|||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Calculate overall progress
|
||||||
|
final overallProgress =
|
||||||
|
progressStates.value.isNotEmpty
|
||||||
|
? progressStates.value
|
||||||
|
.map((e) => e['progress'] as double)
|
||||||
|
.reduce((a, b) => a + b) /
|
||||||
|
progressStates.value.length
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
final overallStatus =
|
||||||
|
progressStates.value.isEmpty
|
||||||
|
? 'pending'
|
||||||
|
: progressStates.value.every((e) => e['status'] == 'completed')
|
||||||
|
? 'completed'
|
||||||
|
: progressStates.value.any((e) => e['status'] == 'error')
|
||||||
|
? 'error'
|
||||||
|
: progressStates.value.any((e) => e['status'] == 'uploading')
|
||||||
|
? 'uploading'
|
||||||
|
: 'pending';
|
||||||
|
|
||||||
final uploadFile = useCallback((
|
final uploadFile = useCallback((
|
||||||
String basePath,
|
String basePath,
|
||||||
File file,
|
File file,
|
||||||
@@ -52,7 +76,7 @@ class FileUploadDialog extends HookConsumerWidget {
|
|||||||
siteFilesNotifierProvider((siteId: site.id, path: null)).notifier,
|
siteFilesNotifierProvider((siteId: site.id, path: null)).notifier,
|
||||||
);
|
);
|
||||||
|
|
||||||
final fileName = file.path.split('/').last;
|
final fileName = relativePaths?[index] ?? file.path.split('/').last;
|
||||||
final uploadPath =
|
final uploadPath =
|
||||||
basePath.endsWith('/')
|
basePath.endsWith('/')
|
||||||
? '$basePath$fileName'
|
? '$basePath$fileName'
|
||||||
@@ -91,7 +115,6 @@ class FileUploadDialog extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isUploading.value = false;
|
isUploading.value = false;
|
||||||
onUploadComplete();
|
|
||||||
|
|
||||||
// Close dialog if all uploads completed successfully
|
// Close dialog if all uploads completed successfully
|
||||||
if (progressStates.value.every(
|
if (progressStates.value.every(
|
||||||
@@ -101,6 +124,7 @@ class FileUploadDialog extends HookConsumerWidget {
|
|||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('All files uploaded successfully')),
|
const SnackBar(content: Text('All files uploaded successfully')),
|
||||||
);
|
);
|
||||||
|
onUploadComplete();
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,133 +178,57 @@ class FileUploadDialog extends HookConsumerWidget {
|
|||||||
onTapOutside:
|
onTapOutside:
|
||||||
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
(_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
),
|
),
|
||||||
const Gap(20),
|
|
||||||
Text(
|
|
||||||
'Ready to upload ${selectedFiles.length} file${selectedFiles.length == 1 ? '' : 's'}:',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
|
||||||
),
|
|
||||||
const Gap(16),
|
const Gap(16),
|
||||||
...selectedFiles.map((file) {
|
Card(
|
||||||
final index = selectedFiles.indexOf(file);
|
child: Column(
|
||||||
final progressState = progressStates.value[index];
|
children: [
|
||||||
final fileName = file.path.split('/').last;
|
// Overall progress
|
||||||
final fileSize = file.lengthSync();
|
Padding(
|
||||||
final fileSizeText =
|
padding: const EdgeInsets.all(16),
|
||||||
fileSize < 1024 * 1024
|
child: Column(
|
||||||
? '${(fileSize / 1024).toStringAsFixed(1)} KB'
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
: '${(fileSize / (1024 * 1024)).toStringAsFixed(1)} MB';
|
children: [
|
||||||
|
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Symbols.description,
|
|
||||||
size: 20,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const Gap(8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
fileName,
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium
|
|
||||||
?.copyWith(fontWeight: FontWeight.w500),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
fileSizeText,
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodySmall?.copyWith(
|
|
||||||
color:
|
|
||||||
Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (progressState['status'] == 'uploading') ...[
|
|
||||||
const Gap(8),
|
|
||||||
LinearProgressIndicator(
|
|
||||||
value: progressState['progress'],
|
|
||||||
backgroundColor:
|
|
||||||
Theme.of(context).colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
const Gap(4),
|
|
||||||
Text(
|
Text(
|
||||||
'Uploading... ${(progressState['progress'] * 100).toStringAsFixed(0)}%',
|
'${(overallProgress * 100).toStringAsFixed(0)}% completed',
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
context,
|
|
||||||
).textTheme.bodySmall?.copyWith(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
] else if (progressState['status'] == 'completed') ...[
|
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Row(
|
LinearProgressIndicator(value: overallProgress),
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
Symbols.check_circle,
|
|
||||||
color: Colors.green,
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
const Gap(4),
|
|
||||||
Text(
|
|
||||||
'Completed',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.green,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
] else if (progressState['status'] == 'error') ...[
|
|
||||||
const Gap(8),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Icon(Symbols.error, color: Colors.red, size: 16),
|
|
||||||
const Gap(4),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
progressState['error'] ?? 'Upload failed',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.red,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
// Show the final upload path when not uploading
|
|
||||||
if (!isUploading.value &&
|
|
||||||
progressState['status'] != 'uploading') ...[
|
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Text(
|
Text(
|
||||||
'Will upload to: ${pathController.text.endsWith('/') ? pathController.text : '${pathController.text}/'}$fileName',
|
_getOverallStatusText(overallStatus),
|
||||||
style: Theme.of(
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
context,
|
|
||||||
).textTheme.bodySmall?.copyWith(
|
|
||||||
color:
|
|
||||||
Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
// Divider
|
||||||
);
|
const Divider(height: 0),
|
||||||
}),
|
// File list in expansion
|
||||||
|
ExpansionTile(
|
||||||
|
title: Text('${selectedFiles.length} files to upload'),
|
||||||
|
initiallyExpanded: selectedFiles.length <= 10,
|
||||||
|
children:
|
||||||
|
selectedFiles.map((file) {
|
||||||
|
final index = selectedFiles.indexOf(file);
|
||||||
|
final progressState = progressStates.value[index];
|
||||||
|
final displayName =
|
||||||
|
progressState['fileName'] as String;
|
||||||
|
return ListTile(
|
||||||
|
leading: _getStatusIcon(
|
||||||
|
progressState['status'] as String,
|
||||||
|
),
|
||||||
|
title: Text(displayName),
|
||||||
|
subtitle: Text(
|
||||||
|
'Size: ${(file.lengthSync() / 1024).toStringAsFixed(1)} KB',
|
||||||
|
),
|
||||||
|
dense: true,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const Gap(24),
|
const Gap(24),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -312,4 +260,30 @@ class FileUploadDialog extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Icon _getStatusIcon(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return const Icon(Symbols.check_circle, color: Colors.green);
|
||||||
|
case 'uploading':
|
||||||
|
return const Icon(Symbols.sync);
|
||||||
|
case 'error':
|
||||||
|
return const Icon(Symbols.error, color: Colors.red);
|
||||||
|
default:
|
||||||
|
return const Icon(Symbols.pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getOverallStatusText(String status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed':
|
||||||
|
return 'All uploads completed';
|
||||||
|
case 'error':
|
||||||
|
return 'Some uploads failed';
|
||||||
|
case 'uploading':
|
||||||
|
return 'Uploading in progress';
|
||||||
|
default:
|
||||||
|
return 'Ready to upload';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user