diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart index 7de2157e..011072bf 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart @@ -40,27 +40,42 @@ class CertificatesRepositoryImpl implements CertificatesRepository { Future uploadCertificate({ required String certificateType, required String name, - required String filePath, + String? filePath, + String? existingFileUri, DateTime? expiryDate, String? issuer, String? certificateNumber, }) async { - // 1. Upload the file to cloud storage - final FileUploadResponse uploadRes = await _uploadService.uploadFile( - filePath: filePath, - fileName: - 'staff_cert_${certificateType}_${DateTime.now().millisecondsSinceEpoch}.pdf', - visibility: FileVisibility.private, - ); + String fileUri; + String? signedUrl; - // 2. Generate a signed URL - final SignedUrlResponse signedUrlRes = - await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); + if (filePath != null) { + // NEW FILE: Full upload pipeline + // 1. Upload the file to cloud storage + final FileUploadResponse uploadRes = await _uploadService.uploadFile( + filePath: filePath, + fileName: + 'staff_cert_${certificateType}_${DateTime.now().millisecondsSinceEpoch}.pdf', + visibility: FileVisibility.private, + ); - // 3. Initiate verification + // 2. Generate a signed URL + final SignedUrlResponse signedUrlRes = + await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); + + fileUri = uploadRes.fileUri; + signedUrl = signedUrlRes.signedUrl; + } else if (existingFileUri != null) { + // EXISTING FILE: Metadata-only update — skip upload steps + fileUri = existingFileUri; + } else { + throw ArgumentError('Either filePath or existingFileUri must be provided'); + } + + // 3. Create verification (works for both new and existing files) final VerificationResponse verificationRes = await _verificationService.createVerification( - fileUri: uploadRes.fileUri, + fileUri: fileUri, type: 'certification', subjectType: 'worker', subjectId: certificateType, @@ -71,21 +86,21 @@ class CertificatesRepositoryImpl implements CertificatesRepository { }, ); - // 4. Save certificate via V2 API + // 4. Save/update certificate via V2 API (upserts on certificate_type) await _api.post( StaffEndpoints.certificates, data: { 'certificateType': certificateType, 'name': name, - 'fileUri': signedUrlRes.signedUrl, - 'expiresAt': expiryDate?.toIso8601String(), + if (signedUrl != null) 'fileUri': signedUrl, + 'expiresAt': expiryDate?.toUtc().toIso8601String(), 'issuer': issuer, 'certificateNumber': certificateNumber, 'verificationId': verificationRes.verificationId, }, ); - // 5. Return updated list + // 5. Return updated certificate final List certificates = await getCertificates(); return certificates.firstWhere( (StaffCertificate c) => c.certificateType == certificateType, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart index 93a85a47..9523a44b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart @@ -9,10 +9,15 @@ abstract interface class CertificatesRepository { Future> getCertificates(); /// Uploads a certificate file and saves the record. + /// + /// When [filePath] is provided, a new file is uploaded to cloud storage. + /// When only [existingFileUri] is provided, the existing stored file is + /// reused and only metadata (e.g. expiry date) is updated. Future uploadCertificate({ required String certificateType, required String name, - required String filePath, + String? filePath, + String? existingFileUri, DateTime? expiryDate, String? issuer, String? certificateNumber, diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart index 1794ef37..eac321ad 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart @@ -15,6 +15,7 @@ class UploadCertificateUseCase certificateType: params.certificateType, name: params.name, filePath: params.filePath, + existingFileUri: params.existingFileUri, expiryDate: params.expiryDate, issuer: params.issuer, certificateNumber: params.certificateNumber, @@ -25,14 +26,21 @@ class UploadCertificateUseCase /// Parameters for [UploadCertificateUseCase]. class UploadCertificateParams { /// Creates [UploadCertificateParams]. + /// + /// Either [filePath] (for a new file upload) or [existingFileUri] (for a + /// metadata-only update using an already-stored file) must be provided. UploadCertificateParams({ required this.certificateType, required this.name, - required this.filePath, + this.filePath, + this.existingFileUri, this.expiryDate, this.issuer, this.certificateNumber, - }); + }) : assert( + filePath != null || existingFileUri != null, + 'Either filePath or existingFileUri must be provided', + ); /// The type of certification (e.g. "FOOD_HYGIENE", "SIA_BADGE"). final String certificateType; @@ -40,8 +48,12 @@ class UploadCertificateParams { /// The name of the certificate. final String name; - /// The local file path to upload. - final String filePath; + /// The local file path to upload, or null when reusing an existing file. + final String? filePath; + + /// The remote URI of an already-uploaded file, used for metadata-only + /// updates (e.g. changing only the expiry date). + final String? existingFileUri; /// The expiry date of the certificate. final DateTime? expiryDate; diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart index 72a25abd..8b6de5c7 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart @@ -43,6 +43,12 @@ class _CertificateUploadPageState extends State { super.initState(); _cubit = Modular.get(); + // Pre-populate file path with existing remote URI when editing so + // the form is valid without re-picking a file. + if (widget.certificate?.fileUri != null) { + _cubit.setSelectedFilePath(widget.certificate!.fileUri); + } + if (widget.certificate != null) { _selectedExpiryDate = widget.certificate!.expiresAt; _issuerController.text = widget.certificate!.issuer ?? ''; @@ -148,9 +154,7 @@ class _CertificateUploadPageState extends State { @override Widget build(BuildContext context) { return BlocProvider.value( - value: _cubit..setSelectedFilePath( - widget.certificate?.fileUri, - ), + value: _cubit, child: BlocConsumer( listener: (BuildContext context, CertificateUploadState state) { if (state.status == CertificateUploadStatus.success) { @@ -222,18 +226,27 @@ class _CertificateUploadPageState extends State { padding: const EdgeInsets.all(UiConstants.space5), child: CertificateUploadActions( isAttested: state.isAttested, - isFormValid: state.selectedFilePath != null && + isFormValid: (state.selectedFilePath != null || + widget.certificate?.fileUri != null) && state.isAttested && _nameController.text.isNotEmpty, isUploading: state.status == CertificateUploadStatus.uploading, hasExistingCertificate: widget.certificate != null, onUploadPressed: () { + final String? selectedPath = state.selectedFilePath; + final bool isLocalFile = selectedPath != null && + !selectedPath.startsWith('http') && + !selectedPath.startsWith('gs://'); + BlocProvider.of(context) .uploadCertificate( UploadCertificateParams( certificateType: _selectedType, name: _nameController.text, - filePath: state.selectedFilePath!, + filePath: isLocalFile ? selectedPath : null, + existingFileUri: !isLocalFile + ? (selectedPath ?? widget.certificate?.fileUri) + : null, expiryDate: _selectedExpiryDate, issuer: _issuerController.text, certificateNumber: _numberController.text,