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:
@@ -40,27 +40,42 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
|
||||
Future<StaffCertificate> 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: <String, dynamic>{
|
||||
'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<StaffCertificate> certificates = await getCertificates();
|
||||
return certificates.firstWhere(
|
||||
(StaffCertificate c) => c.certificateType == certificateType,
|
||||
|
||||
@@ -9,10 +9,15 @@ abstract interface class CertificatesRepository {
|
||||
Future<List<StaffCertificate>> 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<StaffCertificate> uploadCertificate({
|
||||
required String certificateType,
|
||||
required String name,
|
||||
required String filePath,
|
||||
String? filePath,
|
||||
String? existingFileUri,
|
||||
DateTime? expiryDate,
|
||||
String? issuer,
|
||||
String? certificateNumber,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -43,6 +43,12 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
super.initState();
|
||||
_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) {
|
||||
_selectedExpiryDate = widget.certificate!.expiresAt;
|
||||
_issuerController.text = widget.certificate!.issuer ?? '';
|
||||
@@ -148,9 +154,7 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<CertificateUploadCubit>.value(
|
||||
value: _cubit..setSelectedFilePath(
|
||||
widget.certificate?.fileUri,
|
||||
),
|
||||
value: _cubit,
|
||||
child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>(
|
||||
listener: (BuildContext context, CertificateUploadState state) {
|
||||
if (state.status == CertificateUploadStatus.success) {
|
||||
@@ -222,18 +226,27 @@ class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
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<CertificateUploadCubit>(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,
|
||||
|
||||
Reference in New Issue
Block a user