Support reusing existing certificate file URI

Allow uploading or updating certificates without forcing a re-upload of an already-stored file. Repository API now accepts an optional filePath or existingFileUri and branches: a new local file is uploaded and a signed URL generated, while an existingFileUri performs a metadata-only update. Verification creation now uses the resolved fileUri; API payload uses the signed URL when available and normalizes expiry to UTC. Domain usecase and params were updated (with an assert ensuring one of filePath or existingFileUri is provided). Presentation page pre-populates the file field for edits, relaxes form validation to accept existing remote URIs, and distinguishes local vs remote paths when invoking the upload use case.
This commit is contained in:
Achintha Isuru
2026-03-18 14:59:04 -04:00
parent 3a5f2cc9c6
commit 0c8a5bb15b
4 changed files with 72 additions and 27 deletions

View File

@@ -40,27 +40,42 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
Future<StaffCertificate> uploadCertificate({ Future<StaffCertificate> uploadCertificate({
required String certificateType, required String certificateType,
required String name, required String name,
required String filePath, String? filePath,
String? existingFileUri,
DateTime? expiryDate, DateTime? expiryDate,
String? issuer, String? issuer,
String? certificateNumber, String? certificateNumber,
}) async { }) async {
// 1. Upload the file to cloud storage String fileUri;
final FileUploadResponse uploadRes = await _uploadService.uploadFile( String? signedUrl;
filePath: filePath,
fileName:
'staff_cert_${certificateType}_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: FileVisibility.private,
);
// 2. Generate a signed URL if (filePath != null) {
final SignedUrlResponse signedUrlRes = // NEW FILE: Full upload pipeline
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri); // 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 = final VerificationResponse verificationRes =
await _verificationService.createVerification( await _verificationService.createVerification(
fileUri: uploadRes.fileUri, fileUri: fileUri,
type: 'certification', type: 'certification',
subjectType: 'worker', subjectType: 'worker',
subjectId: certificateType, 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( await _api.post(
StaffEndpoints.certificates, StaffEndpoints.certificates,
data: <String, dynamic>{ data: <String, dynamic>{
'certificateType': certificateType, 'certificateType': certificateType,
'name': name, 'name': name,
'fileUri': signedUrlRes.signedUrl, if (signedUrl != null) 'fileUri': signedUrl,
'expiresAt': expiryDate?.toIso8601String(), 'expiresAt': expiryDate?.toUtc().toIso8601String(),
'issuer': issuer, 'issuer': issuer,
'certificateNumber': certificateNumber, 'certificateNumber': certificateNumber,
'verificationId': verificationRes.verificationId, 'verificationId': verificationRes.verificationId,
}, },
); );
// 5. Return updated list // 5. Return updated certificate
final List<StaffCertificate> certificates = await getCertificates(); final List<StaffCertificate> certificates = await getCertificates();
return certificates.firstWhere( return certificates.firstWhere(
(StaffCertificate c) => c.certificateType == certificateType, (StaffCertificate c) => c.certificateType == certificateType,

View File

@@ -9,10 +9,15 @@ abstract interface class CertificatesRepository {
Future<List<StaffCertificate>> getCertificates(); Future<List<StaffCertificate>> getCertificates();
/// Uploads a certificate file and saves the record. /// 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<StaffCertificate> uploadCertificate({ Future<StaffCertificate> uploadCertificate({
required String certificateType, required String certificateType,
required String name, required String name,
required String filePath, String? filePath,
String? existingFileUri,
DateTime? expiryDate, DateTime? expiryDate,
String? issuer, String? issuer,
String? certificateNumber, String? certificateNumber,

View File

@@ -15,6 +15,7 @@ class UploadCertificateUseCase
certificateType: params.certificateType, certificateType: params.certificateType,
name: params.name, name: params.name,
filePath: params.filePath, filePath: params.filePath,
existingFileUri: params.existingFileUri,
expiryDate: params.expiryDate, expiryDate: params.expiryDate,
issuer: params.issuer, issuer: params.issuer,
certificateNumber: params.certificateNumber, certificateNumber: params.certificateNumber,
@@ -25,14 +26,21 @@ class UploadCertificateUseCase
/// Parameters for [UploadCertificateUseCase]. /// Parameters for [UploadCertificateUseCase].
class UploadCertificateParams { class UploadCertificateParams {
/// Creates [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({ UploadCertificateParams({
required this.certificateType, required this.certificateType,
required this.name, required this.name,
required this.filePath, this.filePath,
this.existingFileUri,
this.expiryDate, this.expiryDate,
this.issuer, this.issuer,
this.certificateNumber, this.certificateNumber,
}); }) : assert(
filePath != null || existingFileUri != null,
'Either filePath or existingFileUri must be provided',
);
/// The type of certification (e.g. "FOOD_HYGIENE", "SIA_BADGE"). /// The type of certification (e.g. "FOOD_HYGIENE", "SIA_BADGE").
final String certificateType; final String certificateType;
@@ -40,8 +48,12 @@ class UploadCertificateParams {
/// The name of the certificate. /// The name of the certificate.
final String name; final String name;
/// The local file path to upload. /// The local file path to upload, or null when reusing an existing file.
final String filePath; 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. /// The expiry date of the certificate.
final DateTime? expiryDate; final DateTime? expiryDate;

View File

@@ -43,6 +43,12 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
super.initState(); super.initState();
_cubit = Modular.get<CertificateUploadCubit>(); _cubit = Modular.get<CertificateUploadCubit>();
// 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) { if (widget.certificate != null) {
_selectedExpiryDate = widget.certificate!.expiresAt; _selectedExpiryDate = widget.certificate!.expiresAt;
_issuerController.text = widget.certificate!.issuer ?? ''; _issuerController.text = widget.certificate!.issuer ?? '';
@@ -148,9 +154,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<CertificateUploadCubit>.value( return BlocProvider<CertificateUploadCubit>.value(
value: _cubit..setSelectedFilePath( value: _cubit,
widget.certificate?.fileUri,
),
child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>( child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>(
listener: (BuildContext context, CertificateUploadState state) { listener: (BuildContext context, CertificateUploadState state) {
if (state.status == CertificateUploadStatus.success) { if (state.status == CertificateUploadStatus.success) {
@@ -222,18 +226,27 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
padding: const EdgeInsets.all(UiConstants.space5), padding: const EdgeInsets.all(UiConstants.space5),
child: CertificateUploadActions( child: CertificateUploadActions(
isAttested: state.isAttested, isAttested: state.isAttested,
isFormValid: state.selectedFilePath != null && isFormValid: (state.selectedFilePath != null ||
widget.certificate?.fileUri != null) &&
state.isAttested && state.isAttested &&
_nameController.text.isNotEmpty, _nameController.text.isNotEmpty,
isUploading: state.status == CertificateUploadStatus.uploading, isUploading: state.status == CertificateUploadStatus.uploading,
hasExistingCertificate: widget.certificate != null, hasExistingCertificate: widget.certificate != null,
onUploadPressed: () { onUploadPressed: () {
final String? selectedPath = state.selectedFilePath;
final bool isLocalFile = selectedPath != null &&
!selectedPath.startsWith('http') &&
!selectedPath.startsWith('gs://');
BlocProvider.of<CertificateUploadCubit>(context) BlocProvider.of<CertificateUploadCubit>(context)
.uploadCertificate( .uploadCertificate(
UploadCertificateParams( UploadCertificateParams(
certificateType: _selectedType, certificateType: _selectedType,
name: _nameController.text, name: _nameController.text,
filePath: state.selectedFilePath!, filePath: isLocalFile ? selectedPath : null,
existingFileUri: !isLocalFile
? (selectedPath ?? widget.certificate?.fileUri)
: null,
expiryDate: _selectedExpiryDate, expiryDate: _selectedExpiryDate,
issuer: _issuerController.text, issuer: _issuerController.text,
certificateNumber: _numberController.text, certificateNumber: _numberController.text,