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,11 +40,17 @@ 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 {
|
||||||
|
String fileUri;
|
||||||
|
String? signedUrl;
|
||||||
|
|
||||||
|
if (filePath != null) {
|
||||||
|
// NEW FILE: Full upload pipeline
|
||||||
// 1. Upload the file to cloud storage
|
// 1. Upload the file to cloud storage
|
||||||
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
@@ -57,10 +63,19 @@ class CertificatesRepositoryImpl implements CertificatesRepository {
|
|||||||
final SignedUrlResponse signedUrlRes =
|
final SignedUrlResponse signedUrlRes =
|
||||||
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
|
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
|
||||||
|
|
||||||
// 3. Initiate verification
|
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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user