diff --git a/docs/projects.sh b/docs/projects.sh index d2a5f13..a9cf944 100644 --- a/docs/projects.sh +++ b/docs/projects.sh @@ -139,6 +139,14 @@ curl --location 'https://land.dbiz.com//api/method/upload_file' \ --form 'docname="p9ti8veq2g"' \ --form 'optimize="true"' +#delete image file of project +curl --location 'https://land.dbiz.com//api/method/frappe.desk.form.utils.remove_attach' \ +--header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \ +--header 'X-Frappe-Csrf-Token: 6ff3be4d1f887dbebf86ba4502b05d94b30c0b0569de49b74a7171a9' \ +--form 'fid="67803d2e95"' \ #file id to be deleted +--form 'dt="Architectural Project"' \ #doctye +--form 'dn="p9ti8veq2g"' #docname + #get detail of a project curl --location 'https://land.dbiz.com//api/method/building_material.building_material.api.project.get_detail' \ --header 'Cookie: sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; full_name=Ha%20Duy%20Lam; sid=a0cbe3ea6f9a7e9cf083bbe3139eada68d2357eac0167bcc66cda17d; system_user=yes; user_id=lamhd%40gmail.com; user_image=/files/avatar_0986788766_1763627962.jpg' \ diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index 0cbb3d4..33a6fd4 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -315,6 +315,11 @@ class ApiConstants { static const String getProjectDetail = '/building_material.building_material.api.project.get_detail'; + /// Delete project file/attachment (requires sid and csrf_token) + /// POST /api/method/frappe.desk.form.utils.remove_attach + /// Form-data: { "fid": "file_id", "dt": "Architectural Project", "dn": "project_name" } + static const String removeProjectFile = '/frappe.desk.form.utils.remove_attach'; + /// Create new project (legacy endpoint - may be deprecated) /// POST /projects static const String createProject = '/projects'; diff --git a/lib/features/projects/data/datasources/submissions_remote_datasource.dart b/lib/features/projects/data/datasources/submissions_remote_datasource.dart index 2f19c9a..420cc0d 100644 --- a/lib/features/projects/data/datasources/submissions_remote_datasource.dart +++ b/lib/features/projects/data/datasources/submissions_remote_datasource.dart @@ -43,6 +43,14 @@ abstract class SubmissionsRemoteDataSource { required String projectName, required String filePath, }); + + /// Delete a file from a project submission + /// [fileId] is the file ID to delete + /// [projectName] is the project name (docname) + Future deleteProjectFile({ + required String fileId, + required String projectName, + }); } /// Submissions Remote Data Source Implementation @@ -303,4 +311,41 @@ class SubmissionsRemoteDataSourceImpl implements SubmissionsRemoteDataSource { throw Exception('Failed to upload project file: $e'); } } + + /// Delete a file from a project submission + /// + /// Calls: POST /api/method/frappe.desk.form.utils.remove_attach + /// Form-data: fid, dt, dn + @override + Future deleteProjectFile({ + required String fileId, + required String projectName, + }) async { + try { + final formData = FormData.fromMap({ + 'fid': fileId, + 'dt': 'Architectural Project', + 'dn': projectName, + }); + + final response = await _dioClient.post>( + '${ApiConstants.frappeApiMethod}${ApiConstants.removeProjectFile}', + data: formData, + ); + + final data = response.data; + if (data == null) { + throw Exception('No data received from deleteProjectFile API'); + } + + // Check for error in response + if (data['exc_type'] != null || data['exception'] != null) { + final errorMessage = + data['_server_messages'] ?? data['exception'] ?? 'Unknown error'; + throw Exception('API error: $errorMessage'); + } + } catch (e) { + throw Exception('Failed to delete project file: $e'); + } + } } diff --git a/lib/features/projects/data/repositories/submissions_repository_impl.dart b/lib/features/projects/data/repositories/submissions_repository_impl.dart index 0d3c763..f05b9de 100644 --- a/lib/features/projects/data/repositories/submissions_repository_impl.dart +++ b/lib/features/projects/data/repositories/submissions_repository_impl.dart @@ -171,4 +171,19 @@ class SubmissionsRepositoryImpl implements SubmissionsRepository { rethrow; } } + + @override + Future deleteProjectFile({ + required String fileId, + required String projectName, + }) async { + try { + return await _remoteDataSource.deleteProjectFile( + fileId: fileId, + projectName: projectName, + ); + } catch (e) { + rethrow; + } + } } diff --git a/lib/features/projects/domain/repositories/submissions_repository.dart b/lib/features/projects/domain/repositories/submissions_repository.dart index 5fdd785..50499e1 100644 --- a/lib/features/projects/domain/repositories/submissions_repository.dart +++ b/lib/features/projects/domain/repositories/submissions_repository.dart @@ -54,4 +54,12 @@ abstract class SubmissionsRepository { required String projectName, required String filePath, }); + + /// Delete a file from a project submission + /// [fileId] is the file ID to delete + /// [projectName] is the project name (docname) + Future deleteProjectFile({ + required String fileId, + required String projectName, + }); } diff --git a/lib/features/projects/presentation/pages/submission_create_page.dart b/lib/features/projects/presentation/pages/submission_create_page.dart index 99f66bc..c47e730 100644 --- a/lib/features/projects/presentation/pages/submission_create_page.dart +++ b/lib/features/projects/presentation/pages/submission_create_page.dart @@ -51,6 +51,7 @@ class _SubmissionCreatePageState extends ConsumerState { List _existingFiles = []; // Existing files from API bool _isSubmitting = false; bool _isLoadingDetail = false; + String? _deletingFileId; // Track which file is being deleted /// Whether we're editing an existing submission bool get isEditing => widget.submission != null; @@ -858,49 +859,76 @@ class _SubmissionCreatePageState extends ConsumerState { Widget _buildExistingFileItem(ProjectFile file, int index) { final fileName = file.fileUrl.split('/').last; + final isDeleting = _deletingFileId == file.id; return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: const Color(0xFFF8F9FA), - border: Border.all(color: AppColors.success), + border: Border.all( + color: isDeleting ? AppColors.grey500 : AppColors.success, + ), borderRadius: BorderRadius.circular(6), ), child: Row( children: [ - // Network image - ClipRRect( - borderRadius: BorderRadius.circular(4), - child: CachedNetworkImage( - imageUrl: file.fileUrl, - width: 48, - height: 48, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - width: 48, - height: 48, - color: AppColors.grey100, - child: const Center( - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), + // Network image with delete overlay + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: CachedNetworkImage( + imageUrl: file.fileUrl, + width: 48, + height: 48, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + width: 48, + height: 48, + color: AppColors.grey100, + child: const Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + errorWidget: (context, url, error) => Container( + width: 48, + height: 48, + color: AppColors.grey100, + child: const Center( + child: FaIcon( + FontAwesomeIcons.image, + size: 24, + color: AppColors.grey500, + ), + ), ), ), ), - errorWidget: (context, url, error) => Container( - width: 48, - height: 48, - color: AppColors.grey100, - child: const Center( - child: FaIcon( - FontAwesomeIcons.image, - size: 24, - color: AppColors.grey500, + // Deleting overlay + if (isDeleting) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(4), + ), + child: const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.white), + ), + ), + ), ), ), - ), - ), + ], ), const SizedBox(width: 12), Expanded( @@ -909,33 +937,185 @@ class _SubmissionCreatePageState extends ConsumerState { children: [ Text( fileName, - style: const TextStyle( + style: TextStyle( fontWeight: FontWeight.w500, - color: AppColors.grey900, + color: isDeleting ? AppColors.grey500 : AppColors.grey900, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), - const Text( - 'Đã tải lên', + Text( + isDeleting ? 'Đang xóa...' : 'Đã tải lên', style: TextStyle( fontSize: 12, - color: AppColors.success, + color: isDeleting ? AppColors.grey500 : AppColors.success, ), ), ], ), ), - // Checkmark icon - const FaIcon( - FontAwesomeIcons.circleCheck, - size: 16, - color: AppColors.success, + // Delete button or checkmark + if (!_isSubmitting && !isDeleting) + IconButton( + icon: const FaIcon( + FontAwesomeIcons.trash, + size: 16, + color: AppColors.danger, + ), + onPressed: () => _showDeleteConfirmDialog(file), + tooltip: 'Xóa ảnh', + ) + else if (!isDeleting) + const FaIcon( + FontAwesomeIcons.circleCheck, + size: 16, + color: AppColors.success, + ), + ], + ), + ); + } + + /// Show confirmation dialog before deleting an existing file + Future _showDeleteConfirmDialog(ProjectFile file) async { + final fileName = file.fileUrl.split('/').last; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Row( + children: [ + FaIcon( + FontAwesomeIcons.triangleExclamation, + color: AppColors.danger, + size: 20, + ), + SizedBox(width: 12), + Text('Xác nhận xóa'), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Bạn có chắc chắn muốn xóa ảnh này?'), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.grey100, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: CachedNetworkImage( + imageUrl: file.fileUrl, + width: 48, + height: 48, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + width: 48, + height: 48, + color: AppColors.grey100, + ), + errorWidget: (context, url, error) => Container( + width: 48, + height: 48, + color: AppColors.grey100, + child: const Center(child: FaIcon(FontAwesomeIcons.image, size: 20)), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + fileName, + style: const TextStyle( + fontSize: 13, + color: AppColors.grey500, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + const SizedBox(height: 12), + const Text( + 'Hành động này không thể hoàn tác.', + style: TextStyle( + fontSize: 13, + color: AppColors.danger, + fontStyle: FontStyle.italic, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Hủy'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.danger, + foregroundColor: Colors.white, + ), + child: const Text('Xóa'), ), ], ), ); + + if (confirmed == true && mounted) { + await _deleteExistingFile(file); + } + } + + /// Delete an existing file from the project + Future _deleteExistingFile(ProjectFile file) async { + if (!isEditing || widget.submission == null) return; + + setState(() => _deletingFileId = file.id); + + try { + final repository = await ref.read(submissionsRepositoryProvider.future); + + await repository.deleteProjectFile( + fileId: file.id, + projectName: widget.submission!.submissionId, + ); + + if (!mounted) return; + + // Remove from local list + setState(() { + _existingFiles = _existingFiles.where((f) => f.id != file.id).toList(); + _deletingFileId = null; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Đã xóa ảnh thành công'), + backgroundColor: AppColors.success, + ), + ); + } catch (e) { + if (mounted) { + setState(() => _deletingFileId = null); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Lỗi xóa ảnh: ${e.toString().replaceAll('Exception: ', '')}'), + backgroundColor: AppColors.danger, + ), + ); + } + } } Widget _buildSubmitButton() {