Merge pull request #556 from Oloodi/267-p0-comp-04-documents-screen
Completed some FE issues
This commit is contained in:
@@ -225,6 +225,21 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
pushNamed(StaffPaths.documents);
|
pushNamed(StaffPaths.documents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pushes the document upload page.
|
||||||
|
///
|
||||||
|
/// Parameters:
|
||||||
|
/// * [document] - The document metadata to upload
|
||||||
|
/// * [initialUrl] - Optional initial document URL
|
||||||
|
void toDocumentUpload({required StaffDocument document, String? initialUrl}) {
|
||||||
|
navigate(
|
||||||
|
StaffPaths.documentUpload,
|
||||||
|
arguments: <String, dynamic>{
|
||||||
|
'document': document,
|
||||||
|
'initialUrl': initialUrl,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Pushes the certificates management page.
|
/// Pushes the certificates management page.
|
||||||
///
|
///
|
||||||
/// Manage professional certificates (e.g., food handling, CPR).
|
/// Manage professional certificates (e.g., food handling, CPR).
|
||||||
|
|||||||
@@ -164,11 +164,17 @@ class StaffPaths {
|
|||||||
/// Store ID, work permits, and other required documentation.
|
/// Store ID, work permits, and other required documentation.
|
||||||
static const String documents = '/worker-main/documents/';
|
static const String documents = '/worker-main/documents/';
|
||||||
|
|
||||||
|
/// Document upload page.
|
||||||
|
static const String documentUpload = '/worker-main/documents/upload/';
|
||||||
|
|
||||||
/// Certificates management - professional certifications.
|
/// Certificates management - professional certifications.
|
||||||
///
|
///
|
||||||
/// Manage professional certificates (e.g., food handling, CPR, etc.).
|
/// Manage professional certificates (e.g., food handling, CPR, etc.).
|
||||||
static const String certificates = '/worker-main/certificates/';
|
static const String certificates = '/worker-main/certificates/';
|
||||||
|
|
||||||
|
/// Certificate upload page.
|
||||||
|
static const String certificateUpload = '/worker-main/certificates/upload/';
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// FINANCIAL INFORMATION
|
// FINANCIAL INFORMATION
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class VerificationService extends BaseCoreService {
|
|||||||
required String subjectType,
|
required String subjectType,
|
||||||
required String subjectId,
|
required String subjectId,
|
||||||
required String fileUri,
|
required String fileUri,
|
||||||
|
String? category,
|
||||||
Map<String, dynamic>? rules,
|
Map<String, dynamic>? rules,
|
||||||
}) async {
|
}) async {
|
||||||
final ApiResponse res = await action(() async {
|
final ApiResponse res = await action(() async {
|
||||||
@@ -27,6 +28,7 @@ class VerificationService extends BaseCoreService {
|
|||||||
'subjectType': subjectType,
|
'subjectType': subjectType,
|
||||||
'subjectId': subjectId,
|
'subjectId': subjectId,
|
||||||
'fileUri': fileUri,
|
'fileUri': fileUri,
|
||||||
|
if (category != null) 'category': category,
|
||||||
if (rules != null) 'rules': rules,
|
if (rules != null) 'rules': rules,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1050,6 +1050,15 @@
|
|||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"missing": "Missing",
|
"missing": "Missing",
|
||||||
"rejected": "Rejected"
|
"rejected": "Rejected"
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"instructions": "Please select a valid PDF file to upload.",
|
||||||
|
"submit": "Submit Document",
|
||||||
|
"select_pdf": "Select PDF File",
|
||||||
|
"attestation": "I certify that this document is genuine and valid.",
|
||||||
|
"success": "Document uploaded successfully",
|
||||||
|
"error": "Failed to upload document",
|
||||||
|
"replace": "Replace"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"staff_certificates": {
|
"staff_certificates": {
|
||||||
@@ -1078,13 +1087,16 @@
|
|||||||
},
|
},
|
||||||
"upload_modal": {
|
"upload_modal": {
|
||||||
"title": "Upload Certificate",
|
"title": "Upload Certificate",
|
||||||
|
"name_label": "Certificate Name",
|
||||||
|
"issuer_label": "Certificate Issuer",
|
||||||
"expiry_label": "Expiration Date (Optional)",
|
"expiry_label": "Expiration Date (Optional)",
|
||||||
"select_date": "Select date",
|
"select_date": "Select date",
|
||||||
"upload_file": "Upload File",
|
"upload_file": "Upload File",
|
||||||
"drag_drop": "Drag and drop or click to upload",
|
"drag_drop": "Drag and drop or click to upload",
|
||||||
"supported_formats": "PDF, JPG, PNG up to 10MB",
|
"supported_formats": "PDF, JPG, PNG up to 10MB",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save Certificate"
|
"save": "Save Certificate",
|
||||||
|
"success_snackbar": "Certificate successfully uploaded and pending verification"
|
||||||
},
|
},
|
||||||
"delete_modal": {
|
"delete_modal": {
|
||||||
"title": "Remove Certificate?",
|
"title": "Remove Certificate?",
|
||||||
|
|||||||
@@ -1050,6 +1050,15 @@
|
|||||||
"pending": "Pendiente",
|
"pending": "Pendiente",
|
||||||
"missing": "Faltante",
|
"missing": "Faltante",
|
||||||
"rejected": "Rechazado"
|
"rejected": "Rechazado"
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"instructions": "Por favor selecciona un archivo PDF válido para subir.",
|
||||||
|
"submit": "Enviar Documento",
|
||||||
|
"select_pdf": "Seleccionar Archivo PDF",
|
||||||
|
"attestation": "Certifico que este documento es genuino y válido.",
|
||||||
|
"success": "Documento subido exitosamente",
|
||||||
|
"error": "Error al subir el documento",
|
||||||
|
"replace": "Reemplazar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"staff_certificates": {
|
"staff_certificates": {
|
||||||
@@ -1078,13 +1087,16 @@
|
|||||||
},
|
},
|
||||||
"upload_modal": {
|
"upload_modal": {
|
||||||
"title": "Subir Certificado",
|
"title": "Subir Certificado",
|
||||||
|
"name_label": "Nombre del Certificado",
|
||||||
|
"issuer_label": "Emisor del Certificado",
|
||||||
"expiry_label": "Fecha de Expiraci\u00f3n (Opcional)",
|
"expiry_label": "Fecha de Expiraci\u00f3n (Opcional)",
|
||||||
"select_date": "Seleccionar fecha",
|
"select_date": "Seleccionar fecha",
|
||||||
"upload_file": "Subir Archivo",
|
"upload_file": "Subir Archivo",
|
||||||
"drag_drop": "Arrastra y suelta o haz clic para subir",
|
"drag_drop": "Arrastra y suelta o haz clic para subir",
|
||||||
"supported_formats": "PDF, JPG, PNG hasta 10MB",
|
"supported_formats": "PDF, JPG, PNG hasta 10MB",
|
||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"save": "Guardar Certificado"
|
"save": "Guardar Certificado",
|
||||||
|
"success_snackbar": "Certificado subido exitosamente y pendiente de verificaci\u00f3n"
|
||||||
},
|
},
|
||||||
"delete_modal": {
|
"delete_modal": {
|
||||||
"title": "\u00bfEliminar Certificado?",
|
"title": "\u00bfEliminar Certificado?",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||||
import '../../domain/repositories/staff_connector_repository.dart';
|
import '../../domain/repositories/staff_connector_repository.dart';
|
||||||
@@ -349,4 +350,395 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
|||||||
throw Exception('Error signing out: ${e.toString()}');
|
throw Exception('Error signing out: ${e.toString()}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<domain.StaffDocument>> getStaffDocuments() async {
|
||||||
|
return _service.run(() async {
|
||||||
|
final String staffId = await _service.getStaffId();
|
||||||
|
|
||||||
|
final List<QueryResult<Object, Object?>> results =
|
||||||
|
await Future.wait<QueryResult<Object, Object?>>(
|
||||||
|
<Future<QueryResult<Object, Object?>>>[
|
||||||
|
_service.connector.listDocuments().execute(),
|
||||||
|
_service.connector
|
||||||
|
.listStaffDocumentsByStaffId(staffId: staffId)
|
||||||
|
.execute(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final QueryResult<dc.ListDocumentsData, void> documentsRes =
|
||||||
|
results[0] as QueryResult<dc.ListDocumentsData, void>;
|
||||||
|
final QueryResult<
|
||||||
|
dc.ListStaffDocumentsByStaffIdData,
|
||||||
|
dc.ListStaffDocumentsByStaffIdVariables
|
||||||
|
>
|
||||||
|
staffDocsRes =
|
||||||
|
results[1]
|
||||||
|
as QueryResult<
|
||||||
|
dc.ListStaffDocumentsByStaffIdData,
|
||||||
|
dc.ListStaffDocumentsByStaffIdVariables
|
||||||
|
>;
|
||||||
|
|
||||||
|
final List<dc.ListStaffDocumentsByStaffIdStaffDocuments> staffDocs =
|
||||||
|
staffDocsRes.data.staffDocuments;
|
||||||
|
|
||||||
|
return documentsRes.data.documents.map((dc.ListDocumentsDocuments doc) {
|
||||||
|
// Find if this staff member has already uploaded this document
|
||||||
|
final dc.ListStaffDocumentsByStaffIdStaffDocuments? currentDoc =
|
||||||
|
staffDocs
|
||||||
|
.where(
|
||||||
|
(dc.ListStaffDocumentsByStaffIdStaffDocuments d) =>
|
||||||
|
d.documentId == doc.id,
|
||||||
|
)
|
||||||
|
.firstOrNull;
|
||||||
|
|
||||||
|
return domain.StaffDocument(
|
||||||
|
id: currentDoc?.id ?? '',
|
||||||
|
staffId: staffId,
|
||||||
|
documentId: doc.id,
|
||||||
|
name: doc.name,
|
||||||
|
description: doc.description,
|
||||||
|
status: currentDoc != null
|
||||||
|
? _mapDocumentStatus(currentDoc.status)
|
||||||
|
: domain.DocumentStatus.missing,
|
||||||
|
documentUrl: currentDoc?.documentUrl,
|
||||||
|
expiryDate: currentDoc?.expiryDate == null
|
||||||
|
? null
|
||||||
|
: DateTimeUtils.toDeviceTime(
|
||||||
|
currentDoc!.expiryDate!.toDateTime(),
|
||||||
|
),
|
||||||
|
verificationId: currentDoc?.verificationId,
|
||||||
|
verificationStatus: currentDoc != null
|
||||||
|
? _mapFromDCDocumentVerificationStatus(currentDoc.status)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsertStaffDocument({
|
||||||
|
required String documentId,
|
||||||
|
required String documentUrl,
|
||||||
|
domain.DocumentStatus? status,
|
||||||
|
String? verificationId,
|
||||||
|
}) async {
|
||||||
|
await _service.run(() async {
|
||||||
|
final String staffId = await _service.getStaffId();
|
||||||
|
final domain.Staff staff = await getStaffProfile();
|
||||||
|
|
||||||
|
await _service.connector
|
||||||
|
.upsertStaffDocument(
|
||||||
|
staffId: staffId,
|
||||||
|
staffName: staff.name,
|
||||||
|
documentId: documentId,
|
||||||
|
status: _mapToDCDocumentStatus(status),
|
||||||
|
)
|
||||||
|
.documentUrl(documentUrl)
|
||||||
|
.verificationId(verificationId)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
domain.DocumentStatus _mapDocumentStatus(
|
||||||
|
dc.EnumValue<dc.DocumentStatus> status,
|
||||||
|
) {
|
||||||
|
if (status is dc.Unknown) {
|
||||||
|
return domain.DocumentStatus.pending;
|
||||||
|
}
|
||||||
|
final dc.DocumentStatus value =
|
||||||
|
(status as dc.Known<dc.DocumentStatus>).value;
|
||||||
|
switch (value) {
|
||||||
|
case dc.DocumentStatus.VERIFIED:
|
||||||
|
return domain.DocumentStatus.verified;
|
||||||
|
case dc.DocumentStatus.PENDING:
|
||||||
|
return domain.DocumentStatus.pending;
|
||||||
|
case dc.DocumentStatus.MISSING:
|
||||||
|
return domain.DocumentStatus.missing;
|
||||||
|
case dc.DocumentStatus.UPLOADED:
|
||||||
|
case dc.DocumentStatus.EXPIRING:
|
||||||
|
return domain.DocumentStatus.pending;
|
||||||
|
case dc.DocumentStatus.PROCESSING:
|
||||||
|
case dc.DocumentStatus.AUTO_PASS:
|
||||||
|
case dc.DocumentStatus.AUTO_FAIL:
|
||||||
|
case dc.DocumentStatus.NEEDS_REVIEW:
|
||||||
|
case dc.DocumentStatus.APPROVED:
|
||||||
|
case dc.DocumentStatus.REJECTED:
|
||||||
|
case dc.DocumentStatus.ERROR:
|
||||||
|
if (value == dc.DocumentStatus.AUTO_PASS ||
|
||||||
|
value == dc.DocumentStatus.APPROVED) {
|
||||||
|
return domain.DocumentStatus.verified;
|
||||||
|
}
|
||||||
|
if (value == dc.DocumentStatus.AUTO_FAIL ||
|
||||||
|
value == dc.DocumentStatus.REJECTED ||
|
||||||
|
value == dc.DocumentStatus.ERROR) {
|
||||||
|
return domain.DocumentStatus.rejected;
|
||||||
|
}
|
||||||
|
return domain.DocumentStatus.pending;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
domain.DocumentVerificationStatus _mapFromDCDocumentVerificationStatus(
|
||||||
|
dc.EnumValue<dc.DocumentStatus> status,
|
||||||
|
) {
|
||||||
|
if (status is dc.Unknown) {
|
||||||
|
return domain.DocumentVerificationStatus.error;
|
||||||
|
}
|
||||||
|
final String name = (status as dc.Known<dc.DocumentStatus>).value.name;
|
||||||
|
switch (name) {
|
||||||
|
case 'PENDING':
|
||||||
|
return domain.DocumentVerificationStatus.pending;
|
||||||
|
case 'PROCESSING':
|
||||||
|
return domain.DocumentVerificationStatus.processing;
|
||||||
|
case 'AUTO_PASS':
|
||||||
|
return domain.DocumentVerificationStatus.autoPass;
|
||||||
|
case 'AUTO_FAIL':
|
||||||
|
return domain.DocumentVerificationStatus.autoFail;
|
||||||
|
case 'NEEDS_REVIEW':
|
||||||
|
return domain.DocumentVerificationStatus.needsReview;
|
||||||
|
case 'APPROVED':
|
||||||
|
return domain.DocumentVerificationStatus.approved;
|
||||||
|
case 'REJECTED':
|
||||||
|
return domain.DocumentVerificationStatus.rejected;
|
||||||
|
case 'VERIFIED':
|
||||||
|
return domain.DocumentVerificationStatus.approved;
|
||||||
|
case 'ERROR':
|
||||||
|
return domain.DocumentVerificationStatus.error;
|
||||||
|
default:
|
||||||
|
return domain.DocumentVerificationStatus.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dc.DocumentStatus _mapToDCDocumentStatus(domain.DocumentStatus? status) {
|
||||||
|
if (status == null) return dc.DocumentStatus.PENDING;
|
||||||
|
switch (status) {
|
||||||
|
case domain.DocumentStatus.verified:
|
||||||
|
return dc.DocumentStatus.VERIFIED;
|
||||||
|
case domain.DocumentStatus.pending:
|
||||||
|
return dc.DocumentStatus.PENDING;
|
||||||
|
case domain.DocumentStatus.missing:
|
||||||
|
return dc.DocumentStatus.MISSING;
|
||||||
|
case domain.DocumentStatus.rejected:
|
||||||
|
return dc.DocumentStatus.REJECTED;
|
||||||
|
case domain.DocumentStatus.expired:
|
||||||
|
return dc.DocumentStatus.EXPIRING;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<domain.StaffCertificate>> getStaffCertificates() async {
|
||||||
|
return _service.run(() async {
|
||||||
|
final String staffId = await _service.getStaffId();
|
||||||
|
final QueryResult<
|
||||||
|
dc.ListCertificatesByStaffIdData,
|
||||||
|
dc.ListCertificatesByStaffIdVariables
|
||||||
|
>
|
||||||
|
response = await _service.connector
|
||||||
|
.listCertificatesByStaffId(staffId: staffId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return response.data.certificates.map((
|
||||||
|
dc.ListCertificatesByStaffIdCertificates cert,
|
||||||
|
) {
|
||||||
|
return domain.StaffCertificate(
|
||||||
|
id: cert.id,
|
||||||
|
staffId: cert.staffId,
|
||||||
|
name: cert.name,
|
||||||
|
description: cert.description,
|
||||||
|
expiryDate: _service.toDateTime(cert.expiry),
|
||||||
|
status: _mapToDomainCertificateStatus(cert.status),
|
||||||
|
certificateUrl: cert.fileUrl,
|
||||||
|
icon: cert.icon,
|
||||||
|
certificationType: _mapToDomainComplianceType(cert.certificationType),
|
||||||
|
issuer: cert.issuer,
|
||||||
|
certificateNumber: cert.certificateNumber,
|
||||||
|
validationStatus: _mapToDomainValidationStatus(cert.validationStatus),
|
||||||
|
createdAt: _service.toDateTime(cert.createdAt),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> upsertStaffCertificate({
|
||||||
|
required domain.ComplianceType certificationType,
|
||||||
|
required String name,
|
||||||
|
required domain.StaffCertificateStatus status,
|
||||||
|
String? fileUrl,
|
||||||
|
DateTime? expiry,
|
||||||
|
String? issuer,
|
||||||
|
String? certificateNumber,
|
||||||
|
domain.StaffCertificateValidationStatus? validationStatus,
|
||||||
|
}) async {
|
||||||
|
await _service.run(() async {
|
||||||
|
final String staffId = await _service.getStaffId();
|
||||||
|
|
||||||
|
await _service.connector
|
||||||
|
.upsertStaffCertificate(
|
||||||
|
staffId: staffId,
|
||||||
|
certificationType: _mapToDCComplianceType(certificationType),
|
||||||
|
name: name,
|
||||||
|
status: _mapToDCCertificateStatus(status),
|
||||||
|
)
|
||||||
|
.fileUrl(fileUrl)
|
||||||
|
.expiry(_service.tryToTimestamp(expiry))
|
||||||
|
.issuer(issuer)
|
||||||
|
.certificateNumber(certificateNumber)
|
||||||
|
.validationStatus(_mapToDCValidationStatus(validationStatus))
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> deleteStaffCertificate({
|
||||||
|
required domain.ComplianceType certificationType,
|
||||||
|
}) async {
|
||||||
|
await _service.run(() async {
|
||||||
|
final String staffId = await _service.getStaffId();
|
||||||
|
|
||||||
|
await _service.connector
|
||||||
|
.deleteCertificate(
|
||||||
|
staffId: staffId,
|
||||||
|
certificationType: _mapToDCComplianceType(certificationType),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
domain.StaffCertificateStatus _mapToDomainCertificateStatus(
|
||||||
|
dc.EnumValue<dc.CertificateStatus> status,
|
||||||
|
) {
|
||||||
|
if (status is dc.Unknown) return domain.StaffCertificateStatus.notStarted;
|
||||||
|
final dc.CertificateStatus value =
|
||||||
|
(status as dc.Known<dc.CertificateStatus>).value;
|
||||||
|
switch (value) {
|
||||||
|
case dc.CertificateStatus.CURRENT:
|
||||||
|
return domain.StaffCertificateStatus.current;
|
||||||
|
case dc.CertificateStatus.EXPIRING_SOON:
|
||||||
|
return domain.StaffCertificateStatus.expiringSoon;
|
||||||
|
case dc.CertificateStatus.COMPLETED:
|
||||||
|
return domain.StaffCertificateStatus.completed;
|
||||||
|
case dc.CertificateStatus.PENDING:
|
||||||
|
return domain.StaffCertificateStatus.pending;
|
||||||
|
case dc.CertificateStatus.EXPIRED:
|
||||||
|
return domain.StaffCertificateStatus.expired;
|
||||||
|
case dc.CertificateStatus.EXPIRING:
|
||||||
|
return domain.StaffCertificateStatus.expiring;
|
||||||
|
case dc.CertificateStatus.NOT_STARTED:
|
||||||
|
return domain.StaffCertificateStatus.notStarted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dc.CertificateStatus _mapToDCCertificateStatus(
|
||||||
|
domain.StaffCertificateStatus status,
|
||||||
|
) {
|
||||||
|
switch (status) {
|
||||||
|
case domain.StaffCertificateStatus.current:
|
||||||
|
return dc.CertificateStatus.CURRENT;
|
||||||
|
case domain.StaffCertificateStatus.expiringSoon:
|
||||||
|
return dc.CertificateStatus.EXPIRING_SOON;
|
||||||
|
case domain.StaffCertificateStatus.completed:
|
||||||
|
return dc.CertificateStatus.COMPLETED;
|
||||||
|
case domain.StaffCertificateStatus.pending:
|
||||||
|
return dc.CertificateStatus.PENDING;
|
||||||
|
case domain.StaffCertificateStatus.expired:
|
||||||
|
return dc.CertificateStatus.EXPIRED;
|
||||||
|
case domain.StaffCertificateStatus.expiring:
|
||||||
|
return dc.CertificateStatus.EXPIRING;
|
||||||
|
case domain.StaffCertificateStatus.notStarted:
|
||||||
|
return dc.CertificateStatus.NOT_STARTED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
domain.ComplianceType _mapToDomainComplianceType(
|
||||||
|
dc.EnumValue<dc.ComplianceType> type,
|
||||||
|
) {
|
||||||
|
if (type is dc.Unknown) return domain.ComplianceType.other;
|
||||||
|
final dc.ComplianceType value = (type as dc.Known<dc.ComplianceType>).value;
|
||||||
|
switch (value) {
|
||||||
|
case dc.ComplianceType.BACKGROUND_CHECK:
|
||||||
|
return domain.ComplianceType.backgroundCheck;
|
||||||
|
case dc.ComplianceType.FOOD_HANDLER:
|
||||||
|
return domain.ComplianceType.foodHandler;
|
||||||
|
case dc.ComplianceType.RBS:
|
||||||
|
return domain.ComplianceType.rbs;
|
||||||
|
case dc.ComplianceType.LEGAL:
|
||||||
|
return domain.ComplianceType.legal;
|
||||||
|
case dc.ComplianceType.OPERATIONAL:
|
||||||
|
return domain.ComplianceType.operational;
|
||||||
|
case dc.ComplianceType.SAFETY:
|
||||||
|
return domain.ComplianceType.safety;
|
||||||
|
case dc.ComplianceType.TRAINING:
|
||||||
|
return domain.ComplianceType.training;
|
||||||
|
case dc.ComplianceType.LICENSE:
|
||||||
|
return domain.ComplianceType.license;
|
||||||
|
case dc.ComplianceType.OTHER:
|
||||||
|
return domain.ComplianceType.other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dc.ComplianceType _mapToDCComplianceType(domain.ComplianceType type) {
|
||||||
|
switch (type) {
|
||||||
|
case domain.ComplianceType.backgroundCheck:
|
||||||
|
return dc.ComplianceType.BACKGROUND_CHECK;
|
||||||
|
case domain.ComplianceType.foodHandler:
|
||||||
|
return dc.ComplianceType.FOOD_HANDLER;
|
||||||
|
case domain.ComplianceType.rbs:
|
||||||
|
return dc.ComplianceType.RBS;
|
||||||
|
case domain.ComplianceType.legal:
|
||||||
|
return dc.ComplianceType.LEGAL;
|
||||||
|
case domain.ComplianceType.operational:
|
||||||
|
return dc.ComplianceType.OPERATIONAL;
|
||||||
|
case domain.ComplianceType.safety:
|
||||||
|
return dc.ComplianceType.SAFETY;
|
||||||
|
case domain.ComplianceType.training:
|
||||||
|
return dc.ComplianceType.TRAINING;
|
||||||
|
case domain.ComplianceType.license:
|
||||||
|
return dc.ComplianceType.LICENSE;
|
||||||
|
case domain.ComplianceType.other:
|
||||||
|
return dc.ComplianceType.OTHER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
domain.StaffCertificateValidationStatus? _mapToDomainValidationStatus(
|
||||||
|
dc.EnumValue<dc.ValidationStatus>? status,
|
||||||
|
) {
|
||||||
|
if (status == null || status is dc.Unknown) return null;
|
||||||
|
final dc.ValidationStatus value =
|
||||||
|
(status as dc.Known<dc.ValidationStatus>).value;
|
||||||
|
switch (value) {
|
||||||
|
case dc.ValidationStatus.APPROVED:
|
||||||
|
return domain.StaffCertificateValidationStatus.approved;
|
||||||
|
case dc.ValidationStatus.PENDING_EXPERT_REVIEW:
|
||||||
|
return domain.StaffCertificateValidationStatus.pendingExpertReview;
|
||||||
|
case dc.ValidationStatus.REJECTED:
|
||||||
|
return domain.StaffCertificateValidationStatus.rejected;
|
||||||
|
case dc.ValidationStatus.AI_VERIFIED:
|
||||||
|
return domain.StaffCertificateValidationStatus.aiVerified;
|
||||||
|
case dc.ValidationStatus.AI_FLAGGED:
|
||||||
|
return domain.StaffCertificateValidationStatus.aiFlagged;
|
||||||
|
case dc.ValidationStatus.MANUAL_REVIEW_NEEDED:
|
||||||
|
return domain.StaffCertificateValidationStatus.manualReviewNeeded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dc.ValidationStatus? _mapToDCValidationStatus(
|
||||||
|
domain.StaffCertificateValidationStatus? status,
|
||||||
|
) {
|
||||||
|
if (status == null) return null;
|
||||||
|
switch (status) {
|
||||||
|
case domain.StaffCertificateValidationStatus.approved:
|
||||||
|
return dc.ValidationStatus.APPROVED;
|
||||||
|
case domain.StaffCertificateValidationStatus.pendingExpertReview:
|
||||||
|
return dc.ValidationStatus.PENDING_EXPERT_REVIEW;
|
||||||
|
case domain.StaffCertificateValidationStatus.rejected:
|
||||||
|
return dc.ValidationStatus.REJECTED;
|
||||||
|
case domain.StaffCertificateValidationStatus.aiVerified:
|
||||||
|
return dc.ValidationStatus.AI_VERIFIED;
|
||||||
|
case domain.StaffCertificateValidationStatus.aiFlagged:
|
||||||
|
return dc.ValidationStatus.AI_FLAGGED;
|
||||||
|
case domain.StaffCertificateValidationStatus.manualReviewNeeded:
|
||||||
|
return dc.ValidationStatus.MANUAL_REVIEW_NEEDED;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,4 +72,35 @@ abstract interface class StaffConnectorRepository {
|
|||||||
String? bio,
|
String? bio,
|
||||||
String? profilePictureUrl,
|
String? profilePictureUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Fetches the staff documents for the current authenticated user.
|
||||||
|
Future<List<StaffDocument>> getStaffDocuments();
|
||||||
|
|
||||||
|
/// Upserts staff document information.
|
||||||
|
Future<void> upsertStaffDocument({
|
||||||
|
required String documentId,
|
||||||
|
required String documentUrl,
|
||||||
|
DocumentStatus? status,
|
||||||
|
String? verificationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Fetches the staff certificates for the current authenticated user.
|
||||||
|
Future<List<StaffCertificate>> getStaffCertificates();
|
||||||
|
|
||||||
|
/// Upserts staff certificate information.
|
||||||
|
Future<void> upsertStaffCertificate({
|
||||||
|
required ComplianceType certificationType,
|
||||||
|
required String name,
|
||||||
|
required StaffCertificateStatus status,
|
||||||
|
String? fileUrl,
|
||||||
|
DateTime? expiry,
|
||||||
|
String? issuer,
|
||||||
|
String? certificateNumber,
|
||||||
|
StaffCertificateValidationStatus? validationStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Deletes a staff certificate.
|
||||||
|
Future<void> deleteStaffCertificate({
|
||||||
|
required ComplianceType certificationType,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ export 'src/adapters/financial/bank_account/bank_account_adapter.dart';
|
|||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
export 'src/entities/profile/staff_document.dart';
|
export 'src/entities/profile/staff_document.dart';
|
||||||
|
export 'src/entities/profile/document_verification_status.dart';
|
||||||
|
export 'src/entities/profile/staff_certificate.dart';
|
||||||
|
export 'src/entities/profile/compliance_type.dart';
|
||||||
|
export 'src/entities/profile/staff_certificate_status.dart';
|
||||||
|
export 'src/entities/profile/staff_certificate_validation_status.dart';
|
||||||
export 'src/entities/profile/attire_item.dart';
|
export 'src/entities/profile/attire_item.dart';
|
||||||
export 'src/entities/profile/attire_verification_status.dart';
|
export 'src/entities/profile/attire_verification_status.dart';
|
||||||
export 'src/entities/profile/relationship_type.dart';
|
export 'src/entities/profile/relationship_type.dart';
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/// Represents the broad category of a compliance certificate.
|
||||||
|
enum ComplianceType {
|
||||||
|
backgroundCheck('BACKGROUND_CHECK'),
|
||||||
|
foodHandler('FOOD_HANDLER'),
|
||||||
|
rbs('RBS'),
|
||||||
|
legal('LEGAL'),
|
||||||
|
operational('OPERATIONAL'),
|
||||||
|
safety('SAFETY'),
|
||||||
|
training('TRAINING'),
|
||||||
|
license('LICENSE'),
|
||||||
|
other('OTHER');
|
||||||
|
|
||||||
|
const ComplianceType(this.value);
|
||||||
|
|
||||||
|
/// The string value expected by the backend.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
/// Creates a [ComplianceType] from a string.
|
||||||
|
static ComplianceType fromString(String value) {
|
||||||
|
return ComplianceType.values.firstWhere(
|
||||||
|
(ComplianceType e) => e.value == value,
|
||||||
|
orElse: () => ComplianceType.other,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/// Represents the verification status of a compliance document.
|
||||||
|
enum DocumentVerificationStatus {
|
||||||
|
/// Job is created and waiting to be processed.
|
||||||
|
pending('PENDING'),
|
||||||
|
|
||||||
|
/// Job is currently being processed by machine or human.
|
||||||
|
processing('PROCESSING'),
|
||||||
|
|
||||||
|
/// Machine verification passed automatically.
|
||||||
|
autoPass('AUTO_PASS'),
|
||||||
|
|
||||||
|
/// Machine verification failed automatically.
|
||||||
|
autoFail('AUTO_FAIL'),
|
||||||
|
|
||||||
|
/// Machine results are inconclusive and require human review.
|
||||||
|
needsReview('NEEDS_REVIEW'),
|
||||||
|
|
||||||
|
/// Human reviewer approved the verification.
|
||||||
|
approved('APPROVED'),
|
||||||
|
|
||||||
|
/// Human reviewer rejected the verification.
|
||||||
|
rejected('REJECTED'),
|
||||||
|
|
||||||
|
/// An error occurred during processing.
|
||||||
|
error('ERROR');
|
||||||
|
|
||||||
|
const DocumentVerificationStatus(this.value);
|
||||||
|
|
||||||
|
/// The string value expected by the Core API.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
/// Creates a [DocumentVerificationStatus] from a string.
|
||||||
|
static DocumentVerificationStatus fromString(String value) {
|
||||||
|
return DocumentVerificationStatus.values.firstWhere(
|
||||||
|
(DocumentVerificationStatus e) => e.value == value,
|
||||||
|
orElse: () => DocumentVerificationStatus.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import 'compliance_type.dart';
|
||||||
|
import 'staff_certificate_status.dart';
|
||||||
|
import 'staff_certificate_validation_status.dart';
|
||||||
|
|
||||||
|
/// Represents a staff's compliance certificate record.
|
||||||
|
class StaffCertificate extends Equatable {
|
||||||
|
const StaffCertificate({
|
||||||
|
required this.id,
|
||||||
|
required this.staffId,
|
||||||
|
required this.name,
|
||||||
|
this.description,
|
||||||
|
this.expiryDate,
|
||||||
|
required this.status,
|
||||||
|
this.certificateUrl,
|
||||||
|
this.icon,
|
||||||
|
required this.certificationType,
|
||||||
|
this.issuer,
|
||||||
|
this.certificateNumber,
|
||||||
|
this.validationStatus,
|
||||||
|
this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The unique identifier of the certificate record.
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// The ID of the staff member.
|
||||||
|
final String staffId;
|
||||||
|
|
||||||
|
/// The display name of the certificate.
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// A description or details about the certificate.
|
||||||
|
final String? description;
|
||||||
|
|
||||||
|
/// The expiration date of the certificate.
|
||||||
|
final DateTime? expiryDate;
|
||||||
|
|
||||||
|
/// The current state of the certificate.
|
||||||
|
final StaffCertificateStatus status;
|
||||||
|
|
||||||
|
/// The URL of the stored certificate file/image.
|
||||||
|
final String? certificateUrl;
|
||||||
|
|
||||||
|
/// An icon to display for this certificate type.
|
||||||
|
final String? icon;
|
||||||
|
|
||||||
|
/// The category of compliance this certificate fits into.
|
||||||
|
final ComplianceType certificationType;
|
||||||
|
|
||||||
|
/// The issuing body or authority.
|
||||||
|
final String? issuer;
|
||||||
|
|
||||||
|
/// Document number or reference.
|
||||||
|
final String? certificateNumber;
|
||||||
|
|
||||||
|
/// Recent validation/verification results.
|
||||||
|
final StaffCertificateValidationStatus? validationStatus;
|
||||||
|
|
||||||
|
/// Creation timestamp.
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
/// Last update timestamp.
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[
|
||||||
|
id,
|
||||||
|
staffId,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
expiryDate,
|
||||||
|
status,
|
||||||
|
certificateUrl,
|
||||||
|
icon,
|
||||||
|
certificationType,
|
||||||
|
issuer,
|
||||||
|
certificateNumber,
|
||||||
|
validationStatus,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Creates a copy of this [StaffCertificate] with updated fields.
|
||||||
|
StaffCertificate copyWith({
|
||||||
|
String? id,
|
||||||
|
String? staffId,
|
||||||
|
String? name,
|
||||||
|
String? description,
|
||||||
|
DateTime? expiryDate,
|
||||||
|
StaffCertificateStatus? status,
|
||||||
|
String? certificateUrl,
|
||||||
|
String? icon,
|
||||||
|
ComplianceType? certificationType,
|
||||||
|
String? issuer,
|
||||||
|
String? certificateNumber,
|
||||||
|
StaffCertificateValidationStatus? validationStatus,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
return StaffCertificate(
|
||||||
|
id: id ?? this.id,
|
||||||
|
staffId: staffId ?? this.staffId,
|
||||||
|
name: name ?? this.name,
|
||||||
|
description: description ?? this.description,
|
||||||
|
expiryDate: expiryDate ?? this.expiryDate,
|
||||||
|
status: status ?? this.status,
|
||||||
|
certificateUrl: certificateUrl ?? this.certificateUrl,
|
||||||
|
icon: icon ?? this.icon,
|
||||||
|
certificationType: certificationType ?? this.certificationType,
|
||||||
|
issuer: issuer ?? this.issuer,
|
||||||
|
certificateNumber: certificateNumber ?? this.certificateNumber,
|
||||||
|
validationStatus: validationStatus ?? this.validationStatus,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/// Represents the current validity status of a staff certificate.
|
||||||
|
enum StaffCertificateStatus {
|
||||||
|
current('CURRENT'),
|
||||||
|
expiringSoon('EXPIRING_SOON'),
|
||||||
|
completed('COMPLETED'),
|
||||||
|
pending('PENDING'),
|
||||||
|
expired('EXPIRED'),
|
||||||
|
expiring('EXPIRING'),
|
||||||
|
notStarted('NOT_STARTED');
|
||||||
|
|
||||||
|
const StaffCertificateStatus(this.value);
|
||||||
|
|
||||||
|
/// The string value expected by the backend.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
/// Creates a [StaffCertificateStatus] from a string.
|
||||||
|
static StaffCertificateStatus fromString(String value) {
|
||||||
|
return StaffCertificateStatus.values.firstWhere(
|
||||||
|
(StaffCertificateStatus e) => e.value == value,
|
||||||
|
orElse: () => StaffCertificateStatus.notStarted,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// Represents the verification or review state for a staff certificate.
|
||||||
|
enum StaffCertificateValidationStatus {
|
||||||
|
approved('APPROVED'),
|
||||||
|
pendingExpertReview('PENDING_EXPERT_REVIEW'),
|
||||||
|
rejected('REJECTED'),
|
||||||
|
aiVerified('AI_VERIFIED'),
|
||||||
|
aiFlagged('AI_FLAGGED'),
|
||||||
|
manualReviewNeeded('MANUAL_REVIEW_NEEDED');
|
||||||
|
|
||||||
|
const StaffCertificateValidationStatus(this.value);
|
||||||
|
|
||||||
|
/// The string value expected by the backend.
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
/// Creates a [StaffCertificateValidationStatus] from a string.
|
||||||
|
static StaffCertificateValidationStatus fromString(String value) {
|
||||||
|
return StaffCertificateValidationStatus.values.firstWhere(
|
||||||
|
(StaffCertificateValidationStatus e) => e.value == value,
|
||||||
|
orElse: () => StaffCertificateValidationStatus.manualReviewNeeded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,12 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
import 'document_verification_status.dart';
|
||||||
|
|
||||||
/// Status of a compliance document.
|
/// Status of a compliance document.
|
||||||
enum DocumentStatus {
|
enum DocumentStatus { verified, pending, missing, rejected, expired }
|
||||||
verified,
|
|
||||||
pending,
|
|
||||||
missing,
|
|
||||||
rejected,
|
|
||||||
expired
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents a staff compliance document.
|
/// Represents a staff compliance document.
|
||||||
class StaffDocument extends Equatable {
|
class StaffDocument extends Equatable {
|
||||||
|
|
||||||
const StaffDocument({
|
const StaffDocument({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.staffId,
|
required this.staffId,
|
||||||
@@ -21,7 +16,10 @@ class StaffDocument extends Equatable {
|
|||||||
required this.status,
|
required this.status,
|
||||||
this.documentUrl,
|
this.documentUrl,
|
||||||
this.expiryDate,
|
this.expiryDate,
|
||||||
|
this.verificationId,
|
||||||
|
this.verificationStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The unique identifier of the staff document record.
|
/// The unique identifier of the staff document record.
|
||||||
final String id;
|
final String id;
|
||||||
|
|
||||||
@@ -46,15 +44,23 @@ class StaffDocument extends Equatable {
|
|||||||
/// The expiry date of the document.
|
/// The expiry date of the document.
|
||||||
final DateTime? expiryDate;
|
final DateTime? expiryDate;
|
||||||
|
|
||||||
|
/// The ID of the verification record.
|
||||||
|
final String? verificationId;
|
||||||
|
|
||||||
|
/// The detailed verification status.
|
||||||
|
final DocumentVerificationStatus? verificationStatus;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[
|
List<Object?> get props => <Object?>[
|
||||||
id,
|
id,
|
||||||
staffId,
|
staffId,
|
||||||
documentId,
|
documentId,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
status,
|
status,
|
||||||
documentUrl,
|
documentUrl,
|
||||||
expiryDate,
|
expiryDate,
|
||||||
];
|
verificationId,
|
||||||
|
verificationStatus,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class ProfileLevelBadge extends StatelessWidget {
|
|||||||
switch (status) {
|
switch (status) {
|
||||||
case StaffStatus.active:
|
case StaffStatus.active:
|
||||||
case StaffStatus.verified:
|
case StaffStatus.verified:
|
||||||
return 'Krower I';
|
return 'KROWER I';
|
||||||
case StaffStatus.pending:
|
case StaffStatus.pending:
|
||||||
case StaffStatus.completedProfile:
|
case StaffStatus.completedProfile:
|
||||||
return 'Pending';
|
return 'Pending';
|
||||||
|
|||||||
@@ -56,6 +56,11 @@ class OnboardingSection extends StatelessWidget {
|
|||||||
label: i18n.menu_items.attire,
|
label: i18n.menu_items.attire,
|
||||||
onTap: () => Modular.to.toAttire(),
|
onTap: () => Modular.to.toAttire(),
|
||||||
),
|
),
|
||||||
|
ProfileMenuItem(
|
||||||
|
icon: UiIcons.file,
|
||||||
|
label: i18n.menu_items.documents,
|
||||||
|
onTap: () => Modular.to.toDocuments(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# Certificate Upload & Verification Workflow
|
||||||
|
|
||||||
|
This document outlines the standardized workflow for handling certificate uploads, metadata capture, and persistence within the Krow mobile application. The certificates module follows the same layered architecture as the `documents` module (see `documents/IMPLEMENTATION_WORKFLOW.md`), with key differences to accommodate certificate-specific metadata (expiry date, issuer, certificate number, type).
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The workflow follows a 4-step lifecycle:
|
||||||
|
|
||||||
|
1. **Form Entry**: The user fills in certificate metadata (name, type, issuer, certificate number, expiry date).
|
||||||
|
2. **File Selection**: Picking a PDF, JPG, or PNG file locally via `FilePickerService`.
|
||||||
|
3. **Attestation**: Requiring the user to confirm the certificate is genuine before submission.
|
||||||
|
4. **Upload & Persistence**: Pushing the file to storage, initiating background verification, and saving the record to the database via Data Connect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Technical Stack
|
||||||
|
|
||||||
|
| Service | Responsibility |
|
||||||
|
|---------|----------------|
|
||||||
|
| `FilePickerService` | PDF/image file selection from device |
|
||||||
|
| `FileUploadService` | Uploads raw files to secure cloud storage |
|
||||||
|
| `SignedUrlService` | Generates secure internal links for verification access |
|
||||||
|
| `VerificationService` | Orchestrates AI or manual verification (category: `CERTIFICATE`) |
|
||||||
|
| `DataConnect` (Firebase) | Persists structured data and verification metadata via `upsertStaffCertificate` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Key Difference vs. Documents Module
|
||||||
|
|
||||||
|
| Aspect | Documents | Certificates |
|
||||||
|
|--------|-----------|--------------|
|
||||||
|
| Metadata captured | None (just a file) | `name`, `type` (`ComplianceType`), `expiryDate`, `issuer`, `certificateNumber` |
|
||||||
|
| Accepted file types | `pdf` only | `pdf`, `jpg`, `png` |
|
||||||
|
| Verification category | `DOCUMENT` | `CERTIFICATE` |
|
||||||
|
| Domain entity | `StaffDocument` | `StaffCertificate` |
|
||||||
|
| Status enum | `DocumentStatus` | `StaffCertificateStatus` |
|
||||||
|
| Validation status | `DocumentVerificationStatus` | `StaffCertificateValidationStatus` |
|
||||||
|
| Repository | `DocumentsRepository` | `CertificatesRepository` |
|
||||||
|
| Data Connect method | `upsertStaffDocument` | `upsertStaffCertificate` |
|
||||||
|
| Navigator helper | `StaffNavigator.toDocumentUpload` | `StaffNavigator.toCertificateUpload` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Implementation Status
|
||||||
|
|
||||||
|
### ✅ Completed — Presentation Layer
|
||||||
|
|
||||||
|
#### Routing
|
||||||
|
- `StaffPaths.certificateUpload` constant used in `StaffPaths.childRoute` within `StaffCertificatesModule`.
|
||||||
|
- `StaffNavigator.toCertificateUpload({StaffCertificate? certificate})` type-safe navigation helper wires the route argument.
|
||||||
|
|
||||||
|
#### Domain Layer
|
||||||
|
- `CertificatesRepository` interface defined with three methods:
|
||||||
|
- `getCertificates()` — fetch the current staff member's certificates.
|
||||||
|
- `uploadCertificate(...)` — upload file + trigger verification + persist record.
|
||||||
|
- `upsertCertificate(...)` — metadata-only update (no file re-upload).
|
||||||
|
- `deleteCertificate(...)` — remove a certificate by `ComplianceType`.
|
||||||
|
- `UploadCertificateUseCase` wraps `uploadCertificate`; takes `UploadCertificateParams`:
|
||||||
|
- `certificationType` (`ComplianceType`) — required
|
||||||
|
- `name` (`String`) — required
|
||||||
|
- `filePath` (`String`) — required
|
||||||
|
- `expiryDate` (`DateTime?`) — optional
|
||||||
|
- `issuer` (`String?`) — optional
|
||||||
|
- `certificateNumber` (`String?`) — optional
|
||||||
|
- `GetCertificatesUseCase` wraps `getCertificates()`.
|
||||||
|
- `UpsertCertificateUseCase` wraps `upsertCertificate()` for metadata-only saves.
|
||||||
|
- `DeleteCertificateUseCase` wraps `deleteCertificate()`.
|
||||||
|
|
||||||
|
#### State Management
|
||||||
|
- `CertificateUploadStatus` enum: `initial | uploading | success | failure`
|
||||||
|
- `CertificateUploadState` (Equatable): tracks `status`, `isAttested`, `updatedCertificate`, `errorMessage`
|
||||||
|
- `CertificateUploadCubit`:
|
||||||
|
- Guards upload behind `state.isAttested == true`.
|
||||||
|
- On success: emits `success` with the returned `StaffCertificate`.
|
||||||
|
- On failure: emits `failure` with the error message key via `BlocErrorHandler`.
|
||||||
|
|
||||||
|
#### UI — `CertificateUploadPage`
|
||||||
|
|
||||||
|
Accepts an optional `StaffCertificate? certificate` as a route argument. When provided, the form is pre-populated for editing; when `null`, the page is in "new certificate" mode.
|
||||||
|
|
||||||
|
**Form fields:**
|
||||||
|
- **Certificate Name** (`TextEditingController _nameController`) — required.
|
||||||
|
- **Certificate Type** (`ComplianceType? _selectedType`) — `DropdownButton`, defaults to `ComplianceType.other`.
|
||||||
|
- **Issuer** (`TextEditingController _issuerController`) — optional.
|
||||||
|
- **Certificate Number** (`TextEditingController _numberController`) — optional.
|
||||||
|
- **Expiry Date** (`DateTime? _selectedExpiryDate`) — date picker; defaults to 1 year from today.
|
||||||
|
|
||||||
|
**File selection:**
|
||||||
|
- `FilePickerService.pickFile(allowedExtensions: ['pdf', 'jpg', 'png'])`.
|
||||||
|
- Selected file path stored in `String? _selectedFilePath`.
|
||||||
|
|
||||||
|
**Attestation & submission:**
|
||||||
|
- Attestation checkbox must be checked before submitting (mirrors Documents pattern).
|
||||||
|
- Submit button enabled only when: a file is selected AND attestation is checked.
|
||||||
|
- Loading state: `CircularProgressIndicator` replaces the submit button while `CertificateUploadStatus.uploading`.
|
||||||
|
- On `success`: shows `UiSnackbar` (success type) and calls `Modular.to.pop()`.
|
||||||
|
- On `failure`: shows `UiSnackbar` (error type); stays on page for retry.
|
||||||
|
|
||||||
|
**App bar:**
|
||||||
|
- `UiAppBar` with `certificate?.name ?? t.staff_certificates.upload_modal.title` as title.
|
||||||
|
|
||||||
|
#### UI Guidelines (Consistent with Documents)
|
||||||
|
Follow the same patterns defined in `documents/IMPLEMENTATION_WORKFLOW.md §3`:
|
||||||
|
1. **Header & Instructions**: `UiAppBar` title = certificate name (or modal title). Body instructions use `UiTypography.body1m.textPrimary`.
|
||||||
|
2. **File Selection Card**:
|
||||||
|
- Empty: neutral/primary bordered card inviting file pick.
|
||||||
|
- Selected: `UiColors.bgPopup` card with `UiConstants.radiusLg` rounding, `UiColors.primary` border, truncated file name, and explicit "Replace" action.
|
||||||
|
3. **Bottom Footer / Attestation**: Fixed to `bottomNavigationBar` inside `SafeArea` + `Padding`; submit state tightly coupled to both file presence and attestation.
|
||||||
|
|
||||||
|
#### Module Wiring — `StaffCertificatesModule`
|
||||||
|
|
||||||
|
Binds (all lazy singletons unless noted):
|
||||||
|
| Binding | Scope |
|
||||||
|
|---------|-------|
|
||||||
|
| `CertificatesRepository` → `CertificatesRepositoryImpl` | Lazy singleton |
|
||||||
|
| `GetCertificatesUseCase` | Lazy singleton |
|
||||||
|
| `DeleteCertificateUseCase` | Lazy singleton |
|
||||||
|
| `UpsertCertificateUseCase` | Lazy singleton |
|
||||||
|
| `UploadCertificateUseCase` | Lazy singleton |
|
||||||
|
| `CertificatesCubit` | Lazy singleton |
|
||||||
|
| `CertificateUploadCubit` | **Per-use** (non-singleton via `i.add<>`) |
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
- `StaffPaths.childRoute(StaffPaths.certificates, StaffPaths.certificates)` → `CertificatesPage`
|
||||||
|
- `StaffPaths.childRoute(StaffPaths.certificates, StaffPaths.certificateUpload)` → `CertificateUploadPage(certificate: r.args.data is StaffCertificate ? … : null)`
|
||||||
|
|
||||||
|
#### `CertificatesPage` Integration
|
||||||
|
- `CertificateCard.onTap` navigates to `CertificateUploadPage` with the `StaffCertificate` as route data.
|
||||||
|
- After returning from the upload page, `loadCertificates()` is awaited to refresh the list (fixes `unawaited_futures` lint).
|
||||||
|
|
||||||
|
#### Localization
|
||||||
|
- Keys live under `staff_certificates.*` in `en.i18n.json` and `es.i18n.json`.
|
||||||
|
- Relevant keys: `upload_modal.title`, `upload_modal.success_snackbar`, `upload_modal.name_label`, `upload_modal.replace`, `error_loading`.
|
||||||
|
- Codegen: `dart run slang` generates `TranslationsStaffCertificatesEn` and its Spanish counterpart.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Completed — Data Layer
|
||||||
|
|
||||||
|
#### Data Connect Integration
|
||||||
|
- `StaffConnectorRepository` interface includes `getStaffCertificates()` and `upsertStaffCertificate()`.
|
||||||
|
- `deleteStaffCertificate(certificationType:)` implemented for certificate removal.
|
||||||
|
- SDK regenerated via: `make dataconnect-generate-sdk ENV=dev`.
|
||||||
|
|
||||||
|
#### Repository Implementation — `CertificatesRepositoryImpl`
|
||||||
|
|
||||||
|
Constructor injects `FileUploadService`, `SignedUrlService`, `VerificationService`; uses `DataConnectService.instance` for Data Connect calls.
|
||||||
|
|
||||||
|
**`uploadCertificate()` — 5-step orchestration:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. FileUploadService.uploadFile(...)
|
||||||
|
→ fileName: 'staff_cert_<type>_<timestamp>.pdf'
|
||||||
|
→ visibility: FileVisibility.private
|
||||||
|
Returns FileUploadResponse { fileUri }
|
||||||
|
|
||||||
|
2. SignedUrlService.createSignedUrl(fileUri: uploadRes.fileUri)
|
||||||
|
→ Generates an internal signed link for verification access
|
||||||
|
|
||||||
|
3. VerificationService.createVerification(...)
|
||||||
|
→ fileUri, type: certificationType.value
|
||||||
|
→ category: 'CERTIFICATE' ← distinguishes from DOCUMENT
|
||||||
|
→ subjectType: 'STAFF'
|
||||||
|
→ subjectId: await _service.getStaffId()
|
||||||
|
|
||||||
|
4. StaffConnectorRepository.upsertStaffCertificate(...)
|
||||||
|
→ certificationType, name, status: pending
|
||||||
|
→ fileUrl: uploadRes.fileUri, expiry, issuer, certificateNumber
|
||||||
|
→ validationStatus: pendingExpertReview
|
||||||
|
|
||||||
|
5. getCertificates() → find & return the matching StaffCertificate
|
||||||
|
```
|
||||||
|
|
||||||
|
**`upsertCertificate()` — metadata-only update:**
|
||||||
|
- Directly calls `upsertStaffCertificate(...)` without any file upload or verification step.
|
||||||
|
- Used for editing certificate details after initial upload.
|
||||||
|
|
||||||
|
**`deleteCertificate()` — record removal:**
|
||||||
|
- Calls `deleteStaffCertificate(certificationType:)`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. State Management Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
CertificateUploadStatus
|
||||||
|
├── initial — page just opened / form being filled
|
||||||
|
├── uploading — upload + verification in progress
|
||||||
|
├── success — certificate saved; navigate back (pop)
|
||||||
|
└── failure — error; stay on page; show snackbar
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cubit guards:**
|
||||||
|
- Upload is blocked unless `state.isAttested == true`.
|
||||||
|
- Submit button enabled only when both a file is selected AND attestation is checked.
|
||||||
|
- Uses `BlocErrorHandler` mixin for consistent error emission.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. StaffCertificateStatus Mapping Reference
|
||||||
|
|
||||||
|
The backend uses a richer enum than the domain layer. Standard mapping:
|
||||||
|
|
||||||
|
| Backend / Validation Status | Domain `StaffCertificateStatus` | Notes |
|
||||||
|
|-----------------------------|----------------------------------|-------|
|
||||||
|
| `VERIFIED` / `AUTO_PASS` / `APPROVED` | `verified` | Fully or AI-approved |
|
||||||
|
| `UPLOADED` / `PENDING` / `PROCESSING` / `NEEDS_REVIEW` / `EXPIRING` | `pending` | Upload received; processing or awaiting renewal |
|
||||||
|
| `AUTO_FAIL` / `REJECTED` / `ERROR` | `rejected` | AI or manual rejection / system error |
|
||||||
|
| `MISSING` | `missing` | Not yet uploaded |
|
||||||
|
|
||||||
|
`StaffCertificateValidationStatus` preserves the full backend granularity for detailed UI feedback (e.g., showing "Pending Expert Review" vs. "Auto-Failed").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Future Considerations
|
||||||
|
|
||||||
|
1. **Expiry Notifications**: Certificates approaching expiry (domain status `pending` / backend `EXPIRING`) should surface a nudge in `CertificatesPage` or via push notification.
|
||||||
|
2. **Re-verification on Edit**: When a certificate's file is replaced via `uploadCertificate`, a new verification job is triggered. Decide whether editing only metadata (via `upsertCertificate`) should also trigger re-verification.
|
||||||
|
3. **Multiple Files per Certificate**: The current schema supports a single `fileUrl`. If multi-page certificates become a requirement, the Data Connect schema and `CertificatesRepository` interface will need extending.
|
||||||
|
4. **Shared Compliance UI Components**: The file selection card and attestation footer patterns are duplicated between `DocumentUploadPage` and `CertificateUploadPage`. Consider extracting them into a shared `compliance_upload_widgets` package to reduce duplication.
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:firebase_data_connect/src/core/ref.dart';
|
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||||
@@ -6,73 +5,111 @@ import 'package:krow_domain/krow_domain.dart' as domain;
|
|||||||
import '../../domain/repositories/certificates_repository.dart';
|
import '../../domain/repositories/certificates_repository.dart';
|
||||||
|
|
||||||
/// Implementation of [CertificatesRepository] using Data Connect.
|
/// Implementation of [CertificatesRepository] using Data Connect.
|
||||||
///
|
class CertificatesRepositoryImpl implements CertificatesRepository {
|
||||||
/// This class handles the communication with the backend via [DataConnectService].
|
CertificatesRepositoryImpl({
|
||||||
/// It maps raw generated data types to clean [domain.StaffDocument] entities.
|
required FileUploadService uploadService,
|
||||||
class CertificatesRepositoryImpl
|
required SignedUrlService signedUrlService,
|
||||||
implements CertificatesRepository {
|
required VerificationService verificationService,
|
||||||
|
}) : _service = DataConnectService.instance,
|
||||||
|
_uploadService = uploadService,
|
||||||
|
_signedUrlService = signedUrlService,
|
||||||
|
_verificationService = verificationService;
|
||||||
|
|
||||||
/// Creates a [CertificatesRepositoryImpl].
|
|
||||||
CertificatesRepositoryImpl() : _service = DataConnectService.instance;
|
|
||||||
/// The Data Connect service instance.
|
|
||||||
final DataConnectService _service;
|
final DataConnectService _service;
|
||||||
|
final FileUploadService _uploadService;
|
||||||
|
final SignedUrlService _signedUrlService;
|
||||||
|
final VerificationService _verificationService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<domain.StaffDocument>> getCertificates() async {
|
Future<List<domain.StaffCertificate>> getCertificates() async {
|
||||||
|
return _service.getStaffRepository().getStaffCertificates();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<domain.StaffCertificate> uploadCertificate({
|
||||||
|
required domain.ComplianceType certificationType,
|
||||||
|
required String name,
|
||||||
|
required String filePath,
|
||||||
|
DateTime? expiryDate,
|
||||||
|
String? issuer,
|
||||||
|
String? certificateNumber,
|
||||||
|
}) async {
|
||||||
return _service.run(() async {
|
return _service.run(() async {
|
||||||
|
// 1. Upload the file to cloud storage
|
||||||
|
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||||
|
filePath: filePath,
|
||||||
|
fileName:
|
||||||
|
'staff_cert_${certificationType.name}_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||||
|
visibility: domain.FileVisibility.private,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Generate a signed URL for verification service to access the file
|
||||||
|
// Wait, verification service might need this or just the URI.
|
||||||
|
// Following DocumentRepository behavior:
|
||||||
|
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
|
||||||
|
|
||||||
|
// 3. Initiate verification
|
||||||
|
// 3. Initiate verification
|
||||||
final String staffId = await _service.getStaffId();
|
final String staffId = await _service.getStaffId();
|
||||||
|
await _verificationService.createVerification(
|
||||||
|
fileUri: uploadRes.fileUri,
|
||||||
|
type: certificationType.value,
|
||||||
|
category: 'CERTIFICATE',
|
||||||
|
subjectType: 'STAFF',
|
||||||
|
subjectId: staffId,
|
||||||
|
);
|
||||||
|
|
||||||
// Execute the query via DataConnect generated SDK
|
// 4. Update/Create Certificate in Data Connect
|
||||||
final QueryResult<ListStaffDocumentsByStaffIdData, ListStaffDocumentsByStaffIdVariables> result =
|
await _service.getStaffRepository().upsertStaffCertificate(
|
||||||
await _service.connector
|
certificationType: certificationType,
|
||||||
.listStaffDocumentsByStaffId(staffId: staffId)
|
name: name,
|
||||||
.execute();
|
status: domain.StaffCertificateStatus.pending,
|
||||||
|
fileUrl: uploadRes.fileUri,
|
||||||
|
expiry: expiryDate,
|
||||||
|
issuer: issuer,
|
||||||
|
certificateNumber: certificateNumber,
|
||||||
|
validationStatus:
|
||||||
|
domain.StaffCertificateValidationStatus.pendingExpertReview,
|
||||||
|
);
|
||||||
|
|
||||||
// Map the generated SDK types to pure Domain entities
|
// 5. Return updated list or the specific certificate
|
||||||
return result.data.staffDocuments
|
final List<domain.StaffCertificate> certificates =
|
||||||
.map((ListStaffDocumentsByStaffIdStaffDocuments doc) =>
|
await getCertificates();
|
||||||
_mapToDomain(doc))
|
return certificates.firstWhere(
|
||||||
.toList();
|
(domain.StaffCertificate c) => c.certificationType == certificationType,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps the Data Connect [ListStaffDocumentsByStaffIdStaffDocuments] to a domain [domain.StaffDocument].
|
@override
|
||||||
domain.StaffDocument _mapToDomain(
|
Future<void> upsertCertificate({
|
||||||
ListStaffDocumentsByStaffIdStaffDocuments doc,
|
required domain.ComplianceType certificationType,
|
||||||
) {
|
required String name,
|
||||||
return domain.StaffDocument(
|
required domain.StaffCertificateStatus status,
|
||||||
id: doc.id,
|
String? fileUrl,
|
||||||
staffId: doc.staffId,
|
DateTime? expiry,
|
||||||
documentId: doc.documentId,
|
String? issuer,
|
||||||
name: doc.document.name,
|
String? certificateNumber,
|
||||||
description: null, // Description not available in this query response
|
domain.StaffCertificateValidationStatus? validationStatus,
|
||||||
status: _mapStatus(doc.status),
|
}) async {
|
||||||
documentUrl: doc.documentUrl,
|
await _service.getStaffRepository().upsertStaffCertificate(
|
||||||
expiryDate: doc.expiryDate == null
|
certificationType: certificationType,
|
||||||
? null
|
name: name,
|
||||||
: DateTimeUtils.toDeviceTime(doc.expiryDate!.toDateTime()),
|
status: status,
|
||||||
|
fileUrl: fileUrl,
|
||||||
|
expiry: expiry,
|
||||||
|
issuer: issuer,
|
||||||
|
certificateNumber: certificateNumber,
|
||||||
|
validationStatus: validationStatus,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps the Data Connect [DocumentStatus] enum to the domain [domain.DocumentStatus].
|
@override
|
||||||
domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) {
|
Future<void> deleteCertificate({
|
||||||
if (status is Known<DocumentStatus>) {
|
required domain.ComplianceType certificationType,
|
||||||
switch (status.value) {
|
}) async {
|
||||||
case DocumentStatus.VERIFIED:
|
return _service.getStaffRepository().deleteStaffCertificate(
|
||||||
return domain.DocumentStatus.verified;
|
certificationType: certificationType,
|
||||||
case DocumentStatus.PENDING:
|
);
|
||||||
return domain.DocumentStatus.pending;
|
|
||||||
case DocumentStatus.MISSING:
|
|
||||||
return domain.DocumentStatus.missing;
|
|
||||||
case DocumentStatus.UPLOADED:
|
|
||||||
return domain.DocumentStatus.pending;
|
|
||||||
case DocumentStatus.EXPIRING:
|
|
||||||
// 'EXPIRING' in backend is treated as 'verified' in domain,
|
|
||||||
// as the document is strictly valid until the expiry date.
|
|
||||||
return domain.DocumentStatus.verified;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback for unknown status
|
|
||||||
return domain.DocumentStatus.pending;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,31 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
abstract interface class CertificatesRepository {
|
abstract interface class CertificatesRepository {
|
||||||
/// Fetches the list of compliance certificates for the current staff member.
|
/// Fetches the list of compliance certificates for the current staff member.
|
||||||
///
|
///
|
||||||
/// Returns a list of [StaffDocument] entities.
|
/// Returns a list of [StaffCertificate] entities.
|
||||||
Future<List<StaffDocument>> getCertificates();
|
Future<List<StaffCertificate>> getCertificates();
|
||||||
|
|
||||||
|
/// Uploads a certificate file and saves the record.
|
||||||
|
Future<StaffCertificate> uploadCertificate({
|
||||||
|
required ComplianceType certificationType,
|
||||||
|
required String name,
|
||||||
|
required String filePath,
|
||||||
|
DateTime? expiryDate,
|
||||||
|
String? issuer,
|
||||||
|
String? certificateNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Deletes a staff certificate.
|
||||||
|
Future<void> deleteCertificate({required ComplianceType certificationType});
|
||||||
|
|
||||||
|
/// Upserts a certificate record (metadata only).
|
||||||
|
Future<void> upsertCertificate({
|
||||||
|
required ComplianceType certificationType,
|
||||||
|
required String name,
|
||||||
|
required StaffCertificateStatus status,
|
||||||
|
String? fileUrl,
|
||||||
|
DateTime? expiry,
|
||||||
|
String? issuer,
|
||||||
|
String? certificateNumber,
|
||||||
|
StaffCertificateValidationStatus? validationStatus,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import '../repositories/certificates_repository.dart';
|
||||||
|
|
||||||
|
/// Use case for deleting a staff compliance certificate.
|
||||||
|
class DeleteCertificateUseCase extends UseCase<ComplianceType, void> {
|
||||||
|
/// Creates a [DeleteCertificateUseCase].
|
||||||
|
DeleteCertificateUseCase(this._repository);
|
||||||
|
final CertificatesRepository _repository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> call(ComplianceType certificationType) {
|
||||||
|
return _repository.deleteCertificate(certificationType: certificationType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,7 @@ import '../repositories/certificates_repository.dart';
|
|||||||
///
|
///
|
||||||
/// Delegates the data retrieval to the [CertificatesRepository].
|
/// Delegates the data retrieval to the [CertificatesRepository].
|
||||||
/// Follows the strict one-to-one mapping between action and use case.
|
/// Follows the strict one-to-one mapping between action and use case.
|
||||||
class GetCertificatesUseCase extends NoInputUseCase<List<StaffDocument>> {
|
class GetCertificatesUseCase extends NoInputUseCase<List<StaffCertificate>> {
|
||||||
|
|
||||||
/// Creates a [GetCertificatesUseCase].
|
/// Creates a [GetCertificatesUseCase].
|
||||||
///
|
///
|
||||||
/// Requires a [CertificatesRepository] to access the certificates data source.
|
/// Requires a [CertificatesRepository] to access the certificates data source.
|
||||||
@@ -15,7 +14,7 @@ class GetCertificatesUseCase extends NoInputUseCase<List<StaffDocument>> {
|
|||||||
final CertificatesRepository _repository;
|
final CertificatesRepository _repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<StaffDocument>> call() {
|
Future<List<StaffCertificate>> call() {
|
||||||
return _repository.getCertificates();
|
return _repository.getCertificates();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import '../repositories/certificates_repository.dart';
|
||||||
|
|
||||||
|
/// Use case for uploading a staff compliance certificate.
|
||||||
|
class UploadCertificateUseCase
|
||||||
|
extends UseCase<UploadCertificateParams, StaffCertificate> {
|
||||||
|
/// Creates an [UploadCertificateUseCase].
|
||||||
|
UploadCertificateUseCase(this._repository);
|
||||||
|
final CertificatesRepository _repository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<StaffCertificate> call(UploadCertificateParams params) {
|
||||||
|
return _repository.uploadCertificate(
|
||||||
|
certificationType: params.certificationType,
|
||||||
|
name: params.name,
|
||||||
|
filePath: params.filePath,
|
||||||
|
expiryDate: params.expiryDate,
|
||||||
|
issuer: params.issuer,
|
||||||
|
certificateNumber: params.certificateNumber,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for [UploadCertificateUseCase].
|
||||||
|
class UploadCertificateParams {
|
||||||
|
/// Creates [UploadCertificateParams].
|
||||||
|
UploadCertificateParams({
|
||||||
|
required this.certificationType,
|
||||||
|
required this.name,
|
||||||
|
required this.filePath,
|
||||||
|
this.expiryDate,
|
||||||
|
this.issuer,
|
||||||
|
this.certificateNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The type of certification.
|
||||||
|
final ComplianceType certificationType;
|
||||||
|
|
||||||
|
/// The name of the certificate.
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// The local file path to upload.
|
||||||
|
final String filePath;
|
||||||
|
|
||||||
|
/// The expiry date of the certificate.
|
||||||
|
final DateTime? expiryDate;
|
||||||
|
|
||||||
|
/// The issuer of the certificate.
|
||||||
|
final String? issuer;
|
||||||
|
|
||||||
|
/// The certificate number.
|
||||||
|
final String? certificateNumber;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import '../repositories/certificates_repository.dart';
|
||||||
|
|
||||||
|
/// Use case for upserting a staff compliance certificate.
|
||||||
|
class UpsertCertificateUseCase extends UseCase<UpsertCertificateParams, void> {
|
||||||
|
/// Creates an [UpsertCertificateUseCase].
|
||||||
|
UpsertCertificateUseCase(this._repository);
|
||||||
|
final CertificatesRepository _repository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> call(UpsertCertificateParams params) {
|
||||||
|
return _repository.upsertCertificate(
|
||||||
|
certificationType: params.certificationType,
|
||||||
|
name: params.name,
|
||||||
|
status: params.status,
|
||||||
|
fileUrl: params.fileUrl,
|
||||||
|
expiry: params.expiry,
|
||||||
|
issuer: params.issuer,
|
||||||
|
certificateNumber: params.certificateNumber,
|
||||||
|
validationStatus: params.validationStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for [UpsertCertificateUseCase].
|
||||||
|
class UpsertCertificateParams {
|
||||||
|
/// Creates [UpsertCertificateParams].
|
||||||
|
UpsertCertificateParams({
|
||||||
|
required this.certificationType,
|
||||||
|
required this.name,
|
||||||
|
required this.status,
|
||||||
|
this.fileUrl,
|
||||||
|
this.expiry,
|
||||||
|
this.issuer,
|
||||||
|
this.certificateNumber,
|
||||||
|
this.validationStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The type of certification.
|
||||||
|
final ComplianceType certificationType;
|
||||||
|
|
||||||
|
/// The name of the certificate.
|
||||||
|
final String name;
|
||||||
|
|
||||||
|
/// The status of the certificate.
|
||||||
|
final StaffCertificateStatus status;
|
||||||
|
|
||||||
|
/// The URL of the certificate file.
|
||||||
|
final String? fileUrl;
|
||||||
|
|
||||||
|
/// The expiry date of the certificate.
|
||||||
|
final DateTime? expiry;
|
||||||
|
|
||||||
|
/// The issuer of the certificate.
|
||||||
|
final String? issuer;
|
||||||
|
|
||||||
|
/// The certificate number.
|
||||||
|
final String? certificateNumber;
|
||||||
|
|
||||||
|
/// The validation status of the certificate.
|
||||||
|
final StaffCertificateValidationStatus? validationStatus;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import '../../../domain/usecases/upload_certificate_usecase.dart';
|
||||||
|
import 'certificate_upload_state.dart';
|
||||||
|
|
||||||
|
class CertificateUploadCubit extends Cubit<CertificateUploadState>
|
||||||
|
with BlocErrorHandler<CertificateUploadState> {
|
||||||
|
CertificateUploadCubit(this._uploadCertificateUseCase)
|
||||||
|
: super(const CertificateUploadState());
|
||||||
|
|
||||||
|
final UploadCertificateUseCase _uploadCertificateUseCase;
|
||||||
|
|
||||||
|
void setAttested(bool value) {
|
||||||
|
emit(state.copyWith(isAttested: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> uploadCertificate(UploadCertificateParams params) async {
|
||||||
|
if (!state.isAttested) return;
|
||||||
|
|
||||||
|
emit(state.copyWith(status: CertificateUploadStatus.uploading));
|
||||||
|
await handleError(
|
||||||
|
emit: emit,
|
||||||
|
action: () async {
|
||||||
|
final StaffCertificate certificate = await _uploadCertificateUseCase(
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: CertificateUploadStatus.success,
|
||||||
|
updatedCertificate: certificate,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (String errorKey) => state.copyWith(
|
||||||
|
status: CertificateUploadStatus.failure,
|
||||||
|
errorMessage: errorKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
enum CertificateUploadStatus { initial, uploading, success, failure }
|
||||||
|
|
||||||
|
class CertificateUploadState extends Equatable {
|
||||||
|
const CertificateUploadState({
|
||||||
|
this.status = CertificateUploadStatus.initial,
|
||||||
|
this.isAttested = false,
|
||||||
|
this.updatedCertificate,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final CertificateUploadStatus status;
|
||||||
|
final bool isAttested;
|
||||||
|
final StaffCertificate? updatedCertificate;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
CertificateUploadState copyWith({
|
||||||
|
CertificateUploadStatus? status,
|
||||||
|
bool? isAttested,
|
||||||
|
StaffCertificate? updatedCertificate,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return CertificateUploadState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
isAttested: isAttested ?? this.isAttested,
|
||||||
|
updatedCertificate: updatedCertificate ?? this.updatedCertificate,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[
|
||||||
|
status,
|
||||||
|
isAttested,
|
||||||
|
updatedCertificate,
|
||||||
|
errorMessage,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -2,23 +2,27 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../../../domain/usecases/get_certificates_usecase.dart';
|
import '../../../domain/usecases/get_certificates_usecase.dart';
|
||||||
|
import '../../../domain/usecases/delete_certificate_usecase.dart';
|
||||||
import 'certificates_state.dart';
|
import 'certificates_state.dart';
|
||||||
|
|
||||||
class CertificatesCubit extends Cubit<CertificatesState>
|
class CertificatesCubit extends Cubit<CertificatesState>
|
||||||
with BlocErrorHandler<CertificatesState> {
|
with BlocErrorHandler<CertificatesState> {
|
||||||
|
CertificatesCubit(
|
||||||
CertificatesCubit(this._getCertificatesUseCase)
|
this._getCertificatesUseCase,
|
||||||
: super(const CertificatesState()) {
|
this._deleteCertificateUseCase,
|
||||||
|
) : super(const CertificatesState()) {
|
||||||
loadCertificates();
|
loadCertificates();
|
||||||
}
|
}
|
||||||
|
|
||||||
final GetCertificatesUseCase _getCertificatesUseCase;
|
final GetCertificatesUseCase _getCertificatesUseCase;
|
||||||
|
final DeleteCertificateUseCase _deleteCertificateUseCase;
|
||||||
|
|
||||||
Future<void> loadCertificates() async {
|
Future<void> loadCertificates() async {
|
||||||
emit(state.copyWith(status: CertificatesStatus.loading));
|
emit(state.copyWith(status: CertificatesStatus.loading));
|
||||||
await handleError(
|
await handleError(
|
||||||
emit: emit,
|
emit: emit,
|
||||||
action: () async {
|
action: () async {
|
||||||
final List<StaffDocument> certificates =
|
final List<StaffCertificate> certificates =
|
||||||
await _getCertificatesUseCase();
|
await _getCertificatesUseCase();
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -27,12 +31,25 @@ class CertificatesCubit extends Cubit<CertificatesState>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError:
|
onError: (String errorKey) => state.copyWith(
|
||||||
(String errorKey) => state.copyWith(
|
status: CertificatesStatus.failure,
|
||||||
status: CertificatesStatus.failure,
|
errorMessage: errorKey,
|
||||||
errorMessage: errorKey,
|
),
|
||||||
),
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteCertificate(ComplianceType type) async {
|
||||||
|
emit(state.copyWith(status: CertificatesStatus.loading));
|
||||||
|
await handleError(
|
||||||
|
emit: emit,
|
||||||
|
action: () async {
|
||||||
|
await _deleteCertificateUseCase(type);
|
||||||
|
await loadCertificates();
|
||||||
|
},
|
||||||
|
onError: (String errorKey) => state.copyWith(
|
||||||
|
status: CertificatesStatus.failure,
|
||||||
|
errorMessage: errorKey,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,19 +4,19 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
enum CertificatesStatus { initial, loading, success, failure }
|
enum CertificatesStatus { initial, loading, success, failure }
|
||||||
|
|
||||||
class CertificatesState extends Equatable {
|
class CertificatesState extends Equatable {
|
||||||
|
|
||||||
const CertificatesState({
|
const CertificatesState({
|
||||||
this.status = CertificatesStatus.initial,
|
this.status = CertificatesStatus.initial,
|
||||||
List<StaffDocument>? certificates,
|
List<StaffCertificate>? certificates,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
}) : certificates = certificates ?? const <StaffDocument>[];
|
}) : certificates = certificates ?? const <StaffCertificate>[];
|
||||||
|
|
||||||
final CertificatesStatus status;
|
final CertificatesStatus status;
|
||||||
final List<StaffDocument> certificates;
|
final List<StaffCertificate> certificates;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
CertificatesState copyWith({
|
CertificatesState copyWith({
|
||||||
CertificatesStatus? status,
|
CertificatesStatus? status,
|
||||||
List<StaffDocument>? certificates,
|
List<StaffCertificate>? certificates,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
}) {
|
}) {
|
||||||
return CertificatesState(
|
return CertificatesState(
|
||||||
@@ -31,7 +31,10 @@ class CertificatesState extends Equatable {
|
|||||||
|
|
||||||
/// The number of verified certificates.
|
/// The number of verified certificates.
|
||||||
int get completedCount => certificates
|
int get completedCount => certificates
|
||||||
.where((StaffDocument doc) => doc.status == DocumentStatus.verified)
|
.where(
|
||||||
|
(StaffCertificate cert) =>
|
||||||
|
cert.validationStatus == StaffCertificateValidationStatus.approved,
|
||||||
|
)
|
||||||
.length;
|
.length;
|
||||||
|
|
||||||
/// The total number of certificates.
|
/// The total number of certificates.
|
||||||
|
|||||||
@@ -0,0 +1,361 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../blocs/certificate_upload/certificate_upload_cubit.dart';
|
||||||
|
import '../blocs/certificate_upload/certificate_upload_state.dart';
|
||||||
|
import '../../domain/usecases/upload_certificate_usecase.dart';
|
||||||
|
|
||||||
|
/// Page for uploading a certificate with metadata (expiry, issuer, etc).
|
||||||
|
class CertificateUploadPage extends StatefulWidget {
|
||||||
|
const CertificateUploadPage({super.key, this.certificate});
|
||||||
|
|
||||||
|
/// The certificate being edited, or null for a new one.
|
||||||
|
final StaffCertificate? certificate;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CertificateUploadPage> createState() => _CertificateUploadPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||||
|
String? _selectedFilePath;
|
||||||
|
DateTime? _selectedExpiryDate;
|
||||||
|
final TextEditingController _issuerController = TextEditingController();
|
||||||
|
final TextEditingController _numberController = TextEditingController();
|
||||||
|
final TextEditingController _nameController = TextEditingController();
|
||||||
|
|
||||||
|
ComplianceType? _selectedType;
|
||||||
|
|
||||||
|
final FilePickerService _filePicker = Modular.get<FilePickerService>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.certificate != null) {
|
||||||
|
_selectedExpiryDate = widget.certificate!.expiryDate;
|
||||||
|
_issuerController.text = widget.certificate!.issuer ?? '';
|
||||||
|
_numberController.text = widget.certificate!.certificateNumber ?? '';
|
||||||
|
_nameController.text = widget.certificate!.name;
|
||||||
|
_selectedType = widget.certificate!.certificationType;
|
||||||
|
} else {
|
||||||
|
_selectedType = ComplianceType.other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_issuerController.dispose();
|
||||||
|
_numberController.dispose();
|
||||||
|
_nameController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickFile() async {
|
||||||
|
final String? path = await _filePicker.pickFile(
|
||||||
|
allowedExtensions: <String>['pdf', 'jpg', 'png'],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (path != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedFilePath = path;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _selectDate() async {
|
||||||
|
final DateTime? picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate:
|
||||||
|
_selectedExpiryDate ?? DateTime.now().add(const Duration(days: 365)),
|
||||||
|
firstDate: DateTime.now(),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 3650)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedExpiryDate = picked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider<CertificateUploadCubit>(
|
||||||
|
create: (BuildContext _) => Modular.get<CertificateUploadCubit>(),
|
||||||
|
child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>(
|
||||||
|
listener: (BuildContext context, CertificateUploadState state) {
|
||||||
|
if (state.status == CertificateUploadStatus.success) {
|
||||||
|
UiSnackbar.show(
|
||||||
|
context,
|
||||||
|
message: t.staff_certificates.upload_modal.success_snackbar,
|
||||||
|
type: UiSnackbarType.success,
|
||||||
|
);
|
||||||
|
Modular.to.pop(); // Returns to certificates list
|
||||||
|
} else if (state.status == CertificateUploadStatus.failure) {
|
||||||
|
UiSnackbar.show(
|
||||||
|
context,
|
||||||
|
message: state.errorMessage ?? t.staff_certificates.error_loading,
|
||||||
|
type: UiSnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (BuildContext context, CertificateUploadState state) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: UiAppBar(
|
||||||
|
title:
|
||||||
|
widget.certificate?.name ??
|
||||||
|
t.staff_certificates.upload_modal.title,
|
||||||
|
onLeadingPressed: () => Modular.to.pop(),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
t
|
||||||
|
.staff_documents
|
||||||
|
.upload
|
||||||
|
.instructions, // Reusing instructions logic
|
||||||
|
style: UiTypography.body1m.textPrimary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
|
||||||
|
// Name Field
|
||||||
|
Text(
|
||||||
|
t.staff_certificates.upload_modal.name_label,
|
||||||
|
style: UiTypography.body2m.textPrimary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
TextField(
|
||||||
|
controller: _nameController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "e.g. Food Handler Permit",
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
|
||||||
|
// Expiry Date Field
|
||||||
|
Text(
|
||||||
|
t.staff_certificates.upload_modal.expiry_label,
|
||||||
|
style: UiTypography.body2m.textPrimary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
InkWell(
|
||||||
|
onTap: _selectDate,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space4,
|
||||||
|
vertical: UiConstants.space3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
UiIcons.calendar,
|
||||||
|
size: 20,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Text(
|
||||||
|
_selectedExpiryDate != null
|
||||||
|
? DateFormat(
|
||||||
|
'MMM dd, yyyy',
|
||||||
|
).format(_selectedExpiryDate!)
|
||||||
|
: t.staff_certificates.upload_modal.select_date,
|
||||||
|
style: _selectedExpiryDate != null
|
||||||
|
? UiTypography.body1m.textPrimary
|
||||||
|
: UiTypography.body1m.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
|
||||||
|
// Issuer Field
|
||||||
|
Text(
|
||||||
|
t.staff_certificates.upload_modal.issuer_label,
|
||||||
|
style: UiTypography.body2m.textPrimary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
TextField(
|
||||||
|
controller: _issuerController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "e.g. Department of Health",
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
|
||||||
|
// File Selector
|
||||||
|
Text(
|
||||||
|
t.staff_certificates.upload_modal.upload_file,
|
||||||
|
style: UiTypography.body2m.textPrimary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
_FileSelector(
|
||||||
|
selectedFilePath: _selectedFilePath,
|
||||||
|
onTap: _pickFile,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
// Attestation
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Checkbox(
|
||||||
|
value: state.isAttested,
|
||||||
|
onChanged: (bool? val) =>
|
||||||
|
BlocProvider.of<CertificateUploadCubit>(
|
||||||
|
context,
|
||||||
|
).setAttested(val ?? false),
|
||||||
|
activeColor: UiColors.primary,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
t.staff_documents.upload.attestation,
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed:
|
||||||
|
(_selectedFilePath != null &&
|
||||||
|
state.isAttested &&
|
||||||
|
_nameController.text.isNotEmpty)
|
||||||
|
? () =>
|
||||||
|
BlocProvider.of<CertificateUploadCubit>(
|
||||||
|
context,
|
||||||
|
).uploadCertificate(
|
||||||
|
UploadCertificateParams(
|
||||||
|
certificationType: _selectedType!,
|
||||||
|
name: _nameController.text,
|
||||||
|
filePath: _selectedFilePath!,
|
||||||
|
expiryDate: _selectedExpiryDate,
|
||||||
|
issuer: _issuerController.text,
|
||||||
|
certificateNumber: _numberController.text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: UiColors.primary,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: UiConstants.space4,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: state.status == CertificateUploadStatus.uploading
|
||||||
|
? const CircularProgressIndicator(
|
||||||
|
color: Colors.white,
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
t.staff_certificates.upload_modal.save,
|
||||||
|
style: UiTypography.body1m.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FileSelector extends StatelessWidget {
|
||||||
|
const _FileSelector({this.selectedFilePath, required this.onTap});
|
||||||
|
|
||||||
|
final String? selectedFilePath;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (selectedFilePath != null) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.tagActive,
|
||||||
|
border: Border.all(color: UiColors.primary),
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(UiIcons.file, color: UiColors.primary),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
selectedFilePath!.split('/').last,
|
||||||
|
style: UiTypography.body1m.textPrimary,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
t.staff_documents.upload.replace,
|
||||||
|
style: UiTypography.body3m.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
height: 120,
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: UiColors.border, style: BorderStyle.solid),
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
color: UiColors.background,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(UiIcons.uploadCloud, size: 32, color: UiColors.primary),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
Text(
|
||||||
|
t.staff_certificates.upload_modal.drag_drop,
|
||||||
|
style: UiTypography.body2m,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
t.staff_certificates.upload_modal.supported_formats,
|
||||||
|
style: UiTypography.body3r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'package:design_system/design_system.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
|
||||||
@@ -9,7 +10,6 @@ import '../blocs/certificates/certificates_cubit.dart';
|
|||||||
import '../blocs/certificates/certificates_state.dart';
|
import '../blocs/certificates/certificates_state.dart';
|
||||||
import '../widgets/add_certificate_card.dart';
|
import '../widgets/add_certificate_card.dart';
|
||||||
import '../widgets/certificate_card.dart';
|
import '../widgets/certificate_card.dart';
|
||||||
import '../widgets/certificate_upload_modal.dart';
|
|
||||||
import '../widgets/certificates_header.dart';
|
import '../widgets/certificates_header.dart';
|
||||||
|
|
||||||
/// Page for viewing and managing staff certificates.
|
/// Page for viewing and managing staff certificates.
|
||||||
@@ -39,10 +39,10 @@ class CertificatesPage extends StatelessWidget {
|
|||||||
body: Center(
|
body: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
state.errorMessage != null
|
state.errorMessage != null
|
||||||
? translateErrorKey(state.errorMessage!)
|
? translateErrorKey(state.errorMessage!)
|
||||||
: t.staff_certificates.error_loading,
|
: t.staff_certificates.error_loading,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: UiTypography.body2r.textSecondary,
|
style: UiTypography.body2r.textSecondary,
|
||||||
),
|
),
|
||||||
@@ -51,7 +51,7 @@ class CertificatesPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<StaffDocument> documents = state.certificates;
|
final List<StaffCertificate> documents = state.certificates;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: UiColors.background, // Matches 0xFFF8FAFC
|
backgroundColor: UiColors.background, // Matches 0xFFF8FAFC
|
||||||
@@ -65,25 +65,32 @@ class CertificatesPage extends StatelessWidget {
|
|||||||
Transform.translate(
|
Transform.translate(
|
||||||
offset: const Offset(0, -48),
|
offset: const Offset(0, -48),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space5,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
...documents.map((StaffDocument doc) => CertificateCard(
|
...documents.map(
|
||||||
document: doc,
|
(StaffCertificate doc) => CertificateCard(
|
||||||
onUpload: () => _showUploadModal(context, doc),
|
certificate: doc,
|
||||||
onEditExpiry: () => _showEditExpiryDialog(context, doc),
|
onUpload: () => _navigateToUpload(context, doc),
|
||||||
onRemove: () => _showRemoveConfirmation(context, doc),
|
onEditExpiry: () =>
|
||||||
onView: () {
|
_showEditExpiryDialog(context, doc),
|
||||||
UiSnackbar.show(
|
onRemove: () =>
|
||||||
context,
|
_showRemoveConfirmation(context, doc),
|
||||||
message: t.staff_certificates.card.opened_snackbar,
|
onView: () {
|
||||||
type: UiSnackbarType.success,
|
UiSnackbar.show(
|
||||||
);
|
context,
|
||||||
},
|
message:
|
||||||
)),
|
t.staff_certificates.card.opened_snackbar,
|
||||||
|
type: UiSnackbarType.success,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
AddCertificateCard(
|
AddCertificateCard(
|
||||||
onTap: () => _showUploadModal(context, null),
|
onTap: () => _navigateToUpload(context, null),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space8),
|
const SizedBox(height: UiConstants.space8),
|
||||||
],
|
],
|
||||||
@@ -98,28 +105,29 @@ class CertificatesPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showUploadModal(BuildContext context, StaffDocument? document) {
|
Future<void> _navigateToUpload(
|
||||||
showModalBottomSheet<void>(
|
BuildContext context,
|
||||||
context: context,
|
StaffCertificate? certificate,
|
||||||
isScrollControlled: true,
|
) async {
|
||||||
backgroundColor: Colors.transparent,
|
await Modular.to.pushNamed(
|
||||||
builder: (BuildContext context) => CertificateUploadModal(
|
StaffPaths.certificateUpload,
|
||||||
document: document,
|
arguments: certificate,
|
||||||
onSave: () {
|
|
||||||
// TODO: Implement upload via Cubit
|
|
||||||
// Modular.get<CertificatesCubit>().uploadCertificate(...);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
onCancel: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
// Reload certificates after returning from the upload page
|
||||||
|
await Modular.get<CertificatesCubit>().loadCertificates();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showEditExpiryDialog(BuildContext context, StaffDocument document) {
|
void _showEditExpiryDialog(
|
||||||
_showUploadModal(context, document);
|
BuildContext context,
|
||||||
|
StaffCertificate certificate,
|
||||||
|
) {
|
||||||
|
_navigateToUpload(context, certificate);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showRemoveConfirmation(BuildContext context, StaffDocument document) {
|
void _showRemoveConfirmation(
|
||||||
|
BuildContext context,
|
||||||
|
StaffCertificate certificate,
|
||||||
|
) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) => AlertDialog(
|
builder: (BuildContext context) => AlertDialog(
|
||||||
@@ -132,8 +140,9 @@ class CertificatesPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// TODO: Implement delete via Cubit
|
Modular.get<CertificatesCubit>().deleteCertificate(
|
||||||
// Modular.get<CertificatesCubit>().deleteCertificate(document.id);
|
certificate.certificationType,
|
||||||
|
);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(foregroundColor: UiColors.destructive),
|
style: TextButton.styleFrom(foregroundColor: UiColors.destructive),
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
class CertificateCard extends StatelessWidget {
|
class CertificateCard extends StatelessWidget {
|
||||||
|
|
||||||
const CertificateCard({
|
const CertificateCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.document,
|
required this.certificate,
|
||||||
this.onUpload,
|
this.onUpload,
|
||||||
this.onEditExpiry,
|
this.onEditExpiry,
|
||||||
this.onRemove,
|
this.onRemove,
|
||||||
this.onView,
|
this.onView,
|
||||||
});
|
});
|
||||||
final StaffDocument document;
|
|
||||||
|
final StaffCertificate certificate;
|
||||||
final VoidCallback? onUpload;
|
final VoidCallback? onUpload;
|
||||||
final VoidCallback? onEditExpiry;
|
final VoidCallback? onEditExpiry;
|
||||||
final VoidCallback? onRemove;
|
final VoidCallback? onRemove;
|
||||||
@@ -22,21 +22,30 @@ class CertificateCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Determine UI state from document
|
// Determine UI state from certificate
|
||||||
final bool isComplete = document.status == DocumentStatus.verified;
|
final bool isComplete =
|
||||||
// Todo: Better logic for expring. Assuming if expiryDate is close.
|
certificate.validationStatus ==
|
||||||
// Prototype used 'EXPIRING' status. We map this logic:
|
StaffCertificateValidationStatus.approved;
|
||||||
final bool isExpiring = _isExpiring(document.expiryDate);
|
final bool isExpiring =
|
||||||
final bool isExpired = _isExpired(document.expiryDate);
|
certificate.status == StaffCertificateStatus.expiring ||
|
||||||
|
certificate.status == StaffCertificateStatus.expiringSoon;
|
||||||
|
final bool isExpired = certificate.status == StaffCertificateStatus.expired;
|
||||||
|
|
||||||
// Override isComplete if expiring or expired
|
// Override isComplete if expiring or expired
|
||||||
final bool showComplete = isComplete && !isExpired && !isExpiring;
|
final bool showComplete = isComplete && !isExpired && !isExpiring;
|
||||||
|
|
||||||
final bool isPending = document.status == DocumentStatus.pending;
|
final bool isPending =
|
||||||
final bool isNotStarted = document.status == DocumentStatus.missing || document.status == DocumentStatus.rejected;
|
certificate.validationStatus ==
|
||||||
|
StaffCertificateValidationStatus.pendingExpertReview;
|
||||||
|
final bool isNotStarted =
|
||||||
|
certificate.status == StaffCertificateStatus.notStarted ||
|
||||||
|
certificate.validationStatus ==
|
||||||
|
StaffCertificateValidationStatus.rejected;
|
||||||
|
|
||||||
// UI Properties helper
|
// UI Properties helper
|
||||||
final _CertificateUiProps uiProps = _getUiProps(document.documentId);
|
final _CertificateUiProps uiProps = _getUiProps(
|
||||||
|
certificate.certificationType,
|
||||||
|
);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: UiConstants.space4),
|
margin: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||||
@@ -64,12 +73,14 @@ class CertificateCard extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.accent.withValues(alpha: 0.2), // Yellow tint
|
color: UiColors.accent.withValues(alpha: 0.2), // Yellow tint
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(color: UiColors.accent.withValues(alpha: 0.4)),
|
bottom: BorderSide(
|
||||||
|
color: UiColors.accent.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Icon(
|
const Icon(
|
||||||
UiIcons.warning,
|
UiIcons.warning,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: UiColors.textPrimary,
|
color: UiColors.textPrimary,
|
||||||
@@ -78,13 +89,14 @@ class CertificateCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
isExpired
|
isExpired
|
||||||
? t.staff_certificates.card.expired
|
? t.staff_certificates.card.expired
|
||||||
: t.staff_certificates.card.expires_in_days(days: _daysUntilExpiry(document.expiryDate)),
|
: t.staff_certificates.card.expires_in_days(
|
||||||
|
days: _daysUntilExpiry(certificate.expiryDate),
|
||||||
|
),
|
||||||
style: UiTypography.body3m.textPrimary,
|
style: UiTypography.body3m.textPrimary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -151,12 +163,12 @@ class CertificateCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
document.name,
|
certificate.name,
|
||||||
style: UiTypography.body1m.textPrimary,
|
style: UiTypography.body1m.textPrimary,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
document.description ?? '', // Optional description
|
certificate.description ?? '',
|
||||||
style: UiTypography.body3r.textSecondary,
|
style: UiTypography.body3r.textSecondary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -170,11 +182,10 @@ class CertificateCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
if (showComplete)
|
||||||
if (showComplete) _buildCompleteStatus(document.expiryDate),
|
_buildCompleteStatus(certificate.expiryDate),
|
||||||
|
if (isExpiring || isExpired)
|
||||||
if (isExpiring || isExpired) _buildExpiringStatus(context, document.expiryDate),
|
_buildExpiringStatus(context, certificate.expiryDate),
|
||||||
|
|
||||||
if (isNotStarted)
|
if (isNotStarted)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -207,7 +218,6 @@ class CertificateCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (showComplete || isExpiring || isExpired) ...<Widget>[
|
if (showComplete || isExpiring || isExpired) ...<Widget>[
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -281,7 +291,9 @@ class CertificateCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (expiryDate != null)
|
if (expiryDate != null)
|
||||||
Text(
|
Text(
|
||||||
t.staff_certificates.card.exp(date: DateFormat('MMM d, yyyy').format(expiryDate)),
|
t.staff_certificates.card.exp(
|
||||||
|
date: DateFormat('MMM d, yyyy').format(expiryDate),
|
||||||
|
),
|
||||||
style: UiTypography.body3r.textSecondary,
|
style: UiTypography.body3r.textSecondary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -308,9 +320,7 @@ class CertificateCard extends StatelessWidget {
|
|||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
Text(
|
Text(
|
||||||
t.staff_certificates.card.expiring_soon,
|
t.staff_certificates.card.expiring_soon,
|
||||||
style: UiTypography.body2m.copyWith(
|
style: UiTypography.body2m.copyWith(color: UiColors.primary),
|
||||||
color: UiColors.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -318,7 +328,9 @@ class CertificateCard extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Text(
|
child: Text(
|
||||||
t.staff_certificates.card.exp(date: DateFormat('MMM d, yyyy').format(expiryDate)),
|
t.staff_certificates.card.exp(
|
||||||
|
date: DateFormat('MMM d, yyyy').format(expiryDate),
|
||||||
|
),
|
||||||
style: UiTypography.body3r.copyWith(
|
style: UiTypography.body3r.copyWith(
|
||||||
color: UiColors.textSecondary,
|
color: UiColors.textSecondary,
|
||||||
),
|
),
|
||||||
@@ -330,10 +342,7 @@ class CertificateCard extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
_buildIconButton(UiIcons.eye, onView),
|
_buildIconButton(UiIcons.eye, onView),
|
||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
_buildSmallOutlineButton(
|
_buildSmallOutlineButton(t.staff_certificates.card.renew, onUpload),
|
||||||
t.staff_certificates.card.renew,
|
|
||||||
onUpload,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -347,12 +356,9 @@ class CertificateCard extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: UiColors.transparent,
|
color: UiColors.transparent,
|
||||||
border: Border.all(
|
|
||||||
color: UiColors.transparent,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(icon, size: 16, color: UiColors.textSecondary),
|
child: Icon(icon, size: 16, color: UiColors.textSecondary),
|
||||||
@@ -365,10 +371,10 @@ class CertificateCard extends StatelessWidget {
|
|||||||
return OutlinedButton(
|
return OutlinedButton(
|
||||||
onPressed: onTap,
|
onPressed: onTap,
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
side: BorderSide(color: UiColors.primary.withValues(alpha: 0.4)), // Primary with opacity
|
side: BorderSide(
|
||||||
shape: RoundedRectangleBorder(
|
color: UiColors.primary.withValues(alpha: 0.4),
|
||||||
borderRadius: UiConstants.radiusFull,
|
), // Primary with opacity
|
||||||
),
|
shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusFull),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
|
||||||
minimumSize: const Size(0, 32),
|
minimumSize: const Size(0, 32),
|
||||||
),
|
),
|
||||||
@@ -379,30 +385,19 @@ class CertificateCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isExpiring(DateTime? expiry) {
|
|
||||||
if (expiry == null) return false;
|
|
||||||
final int days = expiry.difference(DateTime.now()).inDays;
|
|
||||||
return days >= 0 && days <= 30; // Close to expiry but not expired
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isExpired(DateTime? expiry) {
|
|
||||||
if (expiry == null) return false;
|
|
||||||
return expiry.difference(DateTime.now()).inDays < 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int _daysUntilExpiry(DateTime? expiry) {
|
int _daysUntilExpiry(DateTime? expiry) {
|
||||||
if (expiry == null) return 0;
|
if (expiry == null) return 0;
|
||||||
return expiry.difference(DateTime.now()).inDays;
|
return expiry.difference(DateTime.now()).inDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock mapping for UI props based on ID
|
// Mock mapping for UI props based on ID
|
||||||
_CertificateUiProps _getUiProps(String id) {
|
_CertificateUiProps _getUiProps(ComplianceType type) {
|
||||||
switch (id) {
|
switch (type) {
|
||||||
case 'background':
|
case ComplianceType.backgroundCheck:
|
||||||
return _CertificateUiProps(UiIcons.fileCheck, UiColors.primary);
|
return _CertificateUiProps(UiIcons.fileCheck, UiColors.primary);
|
||||||
case 'food_handler':
|
case ComplianceType.foodHandler:
|
||||||
return _CertificateUiProps(UiIcons.utensils, UiColors.primary);
|
return _CertificateUiProps(UiIcons.utensils, UiColors.primary);
|
||||||
case 'rbs':
|
case ComplianceType.rbs:
|
||||||
return _CertificateUiProps(UiIcons.wine, UiColors.foreground);
|
return _CertificateUiProps(UiIcons.wine, UiColors.foreground);
|
||||||
default:
|
default:
|
||||||
// Default generic icon
|
// Default generic icon
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
/// Modal for uploading or editing a certificate expiry.
|
/// Modal for uploading or editing a certificate expiry.
|
||||||
class CertificateUploadModal extends StatelessWidget {
|
class CertificateUploadModal extends StatelessWidget {
|
||||||
|
|
||||||
const CertificateUploadModal({
|
const CertificateUploadModal({
|
||||||
super.key,
|
super.key,
|
||||||
this.document,
|
this.certificate,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
required this.onCancel,
|
required this.onCancel,
|
||||||
});
|
});
|
||||||
/// The document being edited, or null for a new upload.
|
|
||||||
// ignore: unused_field
|
/// The certificate being edited, or null for a new upload.
|
||||||
final dynamic
|
final StaffCertificate? certificate;
|
||||||
document; // Using dynamic for now as we don't import domain here to avoid direct coupling if possible, but actually we should import domain.
|
|
||||||
// Ideally, widgets should be dumb. Let's import domain.
|
|
||||||
|
|
||||||
final VoidCallback onSave;
|
final VoidCallback onSave;
|
||||||
final VoidCallback onCancel;
|
final VoidCallback onCancel;
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
import 'data/repositories_impl/certificates_repository_impl.dart';
|
import 'data/repositories_impl/certificates_repository_impl.dart';
|
||||||
import 'domain/repositories/certificates_repository.dart';
|
import 'domain/repositories/certificates_repository.dart';
|
||||||
import 'domain/usecases/get_certificates_usecase.dart';
|
import 'domain/usecases/get_certificates_usecase.dart';
|
||||||
|
import 'domain/usecases/delete_certificate_usecase.dart';
|
||||||
|
import 'domain/usecases/upsert_certificate_usecase.dart';
|
||||||
|
import 'domain/usecases/upload_certificate_usecase.dart';
|
||||||
import 'presentation/blocs/certificates/certificates_cubit.dart';
|
import 'presentation/blocs/certificates/certificates_cubit.dart';
|
||||||
|
import 'presentation/blocs/certificate_upload/certificate_upload_cubit.dart';
|
||||||
|
import 'presentation/pages/certificate_upload_page.dart';
|
||||||
import 'presentation/pages/certificates_page.dart';
|
import 'presentation/pages/certificates_page.dart';
|
||||||
|
|
||||||
class StaffCertificatesModule extends Module {
|
class StaffCertificatesModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
i.addLazySingleton<CertificatesRepository>(CertificatesRepositoryImpl.new);
|
i.addLazySingleton<CertificatesRepository>(CertificatesRepositoryImpl.new);
|
||||||
i.addLazySingleton(GetCertificatesUseCase.new);
|
i.addLazySingleton<GetCertificatesUseCase>(GetCertificatesUseCase.new);
|
||||||
i.addLazySingleton(CertificatesCubit.new);
|
i.addLazySingleton<DeleteCertificateUseCase>(DeleteCertificateUseCase.new);
|
||||||
|
i.addLazySingleton<UpsertCertificateUseCase>(UpsertCertificateUseCase.new);
|
||||||
|
i.addLazySingleton<UploadCertificateUseCase>(UploadCertificateUseCase.new);
|
||||||
|
i.addLazySingleton<CertificatesCubit>(CertificatesCubit.new);
|
||||||
|
i.add<CertificateUploadCubit>(CertificateUploadCubit.new);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -21,5 +32,16 @@ class StaffCertificatesModule extends Module {
|
|||||||
StaffPaths.childRoute(StaffPaths.certificates, StaffPaths.certificates),
|
StaffPaths.childRoute(StaffPaths.certificates, StaffPaths.certificates),
|
||||||
child: (_) => const CertificatesPage(),
|
child: (_) => const CertificatesPage(),
|
||||||
);
|
);
|
||||||
|
r.child(
|
||||||
|
StaffPaths.childRoute(
|
||||||
|
StaffPaths.certificates,
|
||||||
|
StaffPaths.certificateUpload,
|
||||||
|
),
|
||||||
|
child: (BuildContext context) => CertificateUploadPage(
|
||||||
|
certificate: r.args.data is StaffCertificate
|
||||||
|
? r.args.data as StaffCertificate
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ dependencies:
|
|||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_bloc: ^8.1.0
|
flutter_bloc: ^8.1.0
|
||||||
equatable: ^2.0.5
|
equatable: ^2.0.5
|
||||||
|
intl: ^0.20.0
|
||||||
get_it: ^7.6.0
|
get_it: ^7.6.0
|
||||||
flutter_modular: ^6.3.0
|
flutter_modular: ^6.3.0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Document Upload & Verification Workflow
|
||||||
|
|
||||||
|
This document outlines the standardized workflow for handling file uploads, verification, and persistence within the Krow mobile application. This pattern is based on the `attire` module and is used as the reference for the `documents` and `certificates` modules.
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
The workflow follows a 4-step lifecycle:
|
||||||
|
|
||||||
|
1. **Selection**: Picking a PDF file locally via `FilePickerService`.
|
||||||
|
2. **Attestation**: Requiring the user to confirm the document is genuine before submission.
|
||||||
|
3. **Upload & Verification**: Pushing the file to storage and initiating a background verification job.
|
||||||
|
4. **Persistence**: Saving the record with its verification status to the database via Data Connect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Technical Stack
|
||||||
|
|
||||||
|
| Service | Responsibility |
|
||||||
|
|---------|----------------|
|
||||||
|
| `FilePickerService` | PDF/file selection from device |
|
||||||
|
| `FileUploadService` | Uploads raw files to secure cloud storage |
|
||||||
|
| `SignedUrlService` | Generates secure internal/public links for viewing |
|
||||||
|
| `VerificationService` | Orchestrates AI or manual verification |
|
||||||
|
| `DataConnect` (Firebase) | Persists structured data and verification metadata |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Implementation Status
|
||||||
|
|
||||||
|
### ✅ Completed — Presentation Layer
|
||||||
|
|
||||||
|
#### Routing
|
||||||
|
- `StaffPaths.documentUpload` constant added to `core/lib/src/routing/staff/route_paths.dart`
|
||||||
|
- `StaffNavigator.toDocumentUpload({required StaffDocument document})` type-safe navigation helper added to `core/lib/src/routing/staff/navigator.dart`
|
||||||
|
|
||||||
|
#### Domain Layer
|
||||||
|
- `DocumentsRepository.uploadDocument(String documentId, String filePath)` method added to the repository interface.
|
||||||
|
- `UploadDocumentUseCase` created at `domain/usecases/upload_document_usecase.dart`, wrapping the repository call.
|
||||||
|
- `UploadDocumentArguments` value object holds `documentId` and `filePath`.
|
||||||
|
|
||||||
|
#### State Management
|
||||||
|
- `DocumentUploadStatus` enum: `initial | uploading | success | failure`
|
||||||
|
- `DocumentUploadState` (Equatable): tracks `status`, `isAttested`, `updatedDocument`, `errorMessage`
|
||||||
|
- `DocumentUploadCubit`: guards upload behind attestation check; emits success/failure; typed result as `StaffDocument`
|
||||||
|
|
||||||
|
#### UI — `DocumentUploadPage`
|
||||||
|
- Accepts `StaffDocument document` and optional `String? initialUrl` as route arguments
|
||||||
|
- Refactored into specialized widgets for maintainability:
|
||||||
|
- `DocumentFileSelector`: Handles the file picking logic and "empty state" UI.
|
||||||
|
- `DocumentSelectedCard`: Displays the selected file with "Replace" action.
|
||||||
|
- `DocumentAttestationCheckbox`: Isolated checkbox logic for legal confirmation.
|
||||||
|
- `DocumentUploadFooter`: Sticky bottom bar containing the checkbox and submission button.
|
||||||
|
- PDF file picker via `FilePickerService.pickFile(allowedExtensions: ['pdf'])`
|
||||||
|
- Attestation checkbox must be checked before the submit button is enabled
|
||||||
|
- Loading state: shows `CircularProgressIndicator` while uploading (replaces button — mirrors attire pattern)
|
||||||
|
- On success: shows `UiSnackbar` and calls `Modular.to.toDocuments()` to return to the list
|
||||||
|
- On failure: shows `UiSnackbar` with error message; stays on page for retry
|
||||||
|
|
||||||
|
#### UI Guidelines (For Documents & Certificates)
|
||||||
|
To ensure a consistent experience across all compliance uploads (documents, certificates), adhere to the following UI patterns:
|
||||||
|
1. **Header & Instructions:** Use `UiAppBar` with the item name as the title and description as the subtitle. Provide clear instructions at the top of the body (`UiTypography.body1m.textPrimary`).
|
||||||
|
2. **File Selection Card:**
|
||||||
|
- When empty: Show a neutral/primary bordered card inviting the user to pick a file.
|
||||||
|
- When selected: Show an elegant card with `UiColors.bgPopup`, rounded corners (`UiConstants.radiusLg`), bordered by `UiColors.primary`.
|
||||||
|
- The selected card must contain an identifying icon, the truncated file name, and an explicit inline action (e.g., "Replace" or "Upload") using `UiTypography.body3m.textSecondary`.
|
||||||
|
- **Do not** embed native PDF viewers or link out to external readers.
|
||||||
|
3. **Bottom Footer / Attestation:**
|
||||||
|
- Fix the attestation checkbox and the submit button to the bottom using `bottomNavigationBar` wrapped in a `SafeArea` and `Padding`.
|
||||||
|
- The submit button state must be tightly coupled to both the file presence and the attestation state.
|
||||||
|
|
||||||
|
#### Module Wiring — `StaffDocumentsModule`
|
||||||
|
- `UploadDocumentUseCase` bound as lazy singleton
|
||||||
|
- `DocumentUploadCubit` bound (non-singleton, per-use)
|
||||||
|
- Upload route registered: `StaffPaths.documentUpload` → `DocumentUploadPage`
|
||||||
|
- Route arguments extracted: `data['document']` as `StaffDocument`, `data['initialUrl']` as `String?`
|
||||||
|
|
||||||
|
#### `DocumentsPage` Integration
|
||||||
|
- `DocumentCard.onTap` now calls `Modular.to.toDocumentUpload(document: doc)` instead of the old placeholder `pushNamed('./details')`
|
||||||
|
|
||||||
|
#### Localization
|
||||||
|
- Added `staff_documents.upload.*` keys to `en.i18n.json` and `es.i18n.json`
|
||||||
|
- Strings: `instructions`, `submit`, `select_pdf`, `attestation`, `success`, `error`
|
||||||
|
- Codegen (`dart run slang`) produces `TranslationsStaffDocumentsUploadEn` and its Spanish counterpart
|
||||||
|
|
||||||
|
#### DocumentStatus Mapping
|
||||||
|
- `DocumentStatus` mapping is centralized in `StaffConnectorRepositoryImpl`, collapsing complex backend states into domain levels:
|
||||||
|
- `VERIFIED`, `AUTO_PASS`, `APPROVED` → `verified`
|
||||||
|
- `UPLOADED`, `PENDING`, `PROCESSING`, `NEEDS_REVIEW`, `EXPIRING` → `pending`
|
||||||
|
- `AUTO_FAIL`, `REJECTED`, `ERROR` → `rejected`
|
||||||
|
- `MISSING` → `missing`
|
||||||
|
- A new `DocumentVerificationStatus` enum captures the full granularity of the backend state for detailed UI feedback (e.g., showing "Auto Fail" vs "Rejected").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ Completed — Data Layer
|
||||||
|
|
||||||
|
#### Data Connect Integration
|
||||||
|
- `StaffConnectorRepository` interface updated with `getStaffDocuments()` and `upsertStaffDocument()`.
|
||||||
|
- `upsertStaffDocument` mutation in `backend/dataconnect/connector/staffDocument/mutations.gql` updated to accept `verificationId`.
|
||||||
|
- `getStaffDocumentByKey` and `listStaffDocumentsByStaffId` queries updated to include `verificationId`.
|
||||||
|
- SDK regenerated: `make dataconnect-generate-sdk ENV=dev`.
|
||||||
|
|
||||||
|
#### Repository Implementation — `StaffConnectorRepositoryImpl`
|
||||||
|
- **`getStaffDocuments()`**:
|
||||||
|
- Uses `Future.wait` to simultaneously fetch the full list of available `Document` types and the current staff's `StaffDocument` records.
|
||||||
|
- Maps the master list of `Document` entities, joining them with any existing `StaffDocument` entry.
|
||||||
|
- This ensures the UI always shows all required documents, even if they haven't been uploaded yet (status: `missing`).
|
||||||
|
- Populates `verificationId` and `verificationStatus` for presentation layer mapping.
|
||||||
|
- **`upsertStaffDocument()`**:
|
||||||
|
- Handles the upsert (create or update) of a staff document record.
|
||||||
|
- Explicitly passes `documentUrl` and `verificationId`, ensuring metadata is persisted alongside the file reference.
|
||||||
|
- **Status Mapping**:
|
||||||
|
- `_mapDocumentStatus`: Collapses backend statuses into domain `verified | pending | rejected | missing`.
|
||||||
|
- `_mapFromDCDocumentVerificationStatus`: Preserves the full granularity of the backend status for the UI/presentation layer.
|
||||||
|
|
||||||
|
#### Feature Repository — `DocumentsRepositoryImpl`
|
||||||
|
- Fixed to ensure all cross-cutting services (`FileUploadService`, `VerificationService`) are properly orchestrated.
|
||||||
|
- **Verification Integration**: When a document is uploaded, a verification job is triggered, and its `verificationId` is saved to Data Connect immediately. This allows the UI to show a "Processing" state while the background job runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. DocumentStatus Mapping Reference
|
||||||
|
|
||||||
|
The backend uses a richer enum than the domain layer. The mapping is standardized as follows:
|
||||||
|
|
||||||
|
| Backend `DocumentStatus` | Domain `DocumentStatus` | Notes |
|
||||||
|
|--------------------------|-------------------------|-------|
|
||||||
|
| `VERIFIED` | `verified` | Fully approved |
|
||||||
|
| `AUTO_PASS` | `verified` | AI approved |
|
||||||
|
| `APPROVED` | `verified` | Manually approved |
|
||||||
|
| `UPLOADED` | `pending` | File received, not yet processed |
|
||||||
|
| `PENDING` | `pending` | Queued for verification |
|
||||||
|
| `PROCESSING` | `pending` | AI analysis in progress |
|
||||||
|
| `NEEDS_REVIEW` | `pending` | AI unsure, human review needed |
|
||||||
|
| `EXPIRING` | `pending` | Approaching expiry (treated as pending for renewal) |
|
||||||
|
| `MISSING` | `missing` | Document not yet uploaded |
|
||||||
|
| `AUTO_FAIL` | `rejected` | AI rejected |
|
||||||
|
| `REJECTED` | `rejected` | Manually rejected |
|
||||||
|
| `ERROR` | `rejected` | System error during verification |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. State Management Reference
|
||||||
|
|
||||||
|
```
|
||||||
|
DocumentUploadStatus
|
||||||
|
├── initial — page just opened
|
||||||
|
├── uploading — upload + verification in progress
|
||||||
|
├── success — document saved; navigate back with result
|
||||||
|
└── failure — error; stay on page; show snackbar
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cubit guards:**
|
||||||
|
- Upload is blocked unless `state.isAttested == true`
|
||||||
|
- Button is only enabled when both a file is selected AND attestation is checked
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Future Considerations: Certificates
|
||||||
|
|
||||||
|
The implementation of the **Certificates** module should follow this exact pattern with a few key differences:
|
||||||
|
1. **Repository**: Use `StaffConnectorRepository.getStaffCertificates()` and `upsertStaffCertificate()`.
|
||||||
|
2. **Metadata**: Certificates often require an `expiryDate` and `issueingBody` which should be captured during the upload step if not already present in the schema.
|
||||||
|
3. **Verification**: If using the same `VerificationService`, ensure the `category` is set to `CERTIFICATE` instead of `DOCUMENT` to trigger appropriate verification logic.
|
||||||
|
4. **UI**: Mirror the `DocumentUploadPage` design but update the instructions and translation keys to reference certificates.
|
||||||
@@ -6,79 +6,91 @@ import 'package:krow_domain/krow_domain.dart' as domain;
|
|||||||
import '../../domain/repositories/documents_repository.dart';
|
import '../../domain/repositories/documents_repository.dart';
|
||||||
|
|
||||||
/// Implementation of [DocumentsRepository] using Data Connect.
|
/// Implementation of [DocumentsRepository] using Data Connect.
|
||||||
class DocumentsRepositoryImpl
|
class DocumentsRepositoryImpl implements DocumentsRepository {
|
||||||
implements DocumentsRepository {
|
DocumentsRepositoryImpl({
|
||||||
|
required FileUploadService uploadService,
|
||||||
|
required SignedUrlService signedUrlService,
|
||||||
|
required VerificationService verificationService,
|
||||||
|
}) : _service = DataConnectService.instance,
|
||||||
|
_uploadService = uploadService,
|
||||||
|
_signedUrlService = signedUrlService,
|
||||||
|
_verificationService = verificationService;
|
||||||
|
|
||||||
DocumentsRepositoryImpl() : _service = DataConnectService.instance;
|
|
||||||
final DataConnectService _service;
|
final DataConnectService _service;
|
||||||
|
final FileUploadService _uploadService;
|
||||||
|
final SignedUrlService _signedUrlService;
|
||||||
|
final VerificationService _verificationService;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<domain.StaffDocument>> getDocuments() async {
|
Future<List<domain.StaffDocument>> getDocuments() async {
|
||||||
return _service.run(() async {
|
return _service.getStaffRepository().getStaffDocuments();
|
||||||
final String staffId = await _service.getStaffId();
|
|
||||||
|
|
||||||
/// MOCK IMPLEMENTATION
|
|
||||||
/// To be replaced with real data connect query when available
|
|
||||||
return <domain.StaffDocument>[
|
|
||||||
domain.StaffDocument(
|
|
||||||
id: 'doc1',
|
|
||||||
staffId: staffId,
|
|
||||||
documentId: 'd1',
|
|
||||||
name: 'Work Permit',
|
|
||||||
description: 'Valid work permit document',
|
|
||||||
status: domain.DocumentStatus.verified,
|
|
||||||
documentUrl: 'https://example.com/documents/work_permit.pdf',
|
|
||||||
expiryDate: DateTime.now().add(const Duration(days: 365)),
|
|
||||||
),
|
|
||||||
domain.StaffDocument(
|
|
||||||
id: 'doc2',
|
|
||||||
staffId: staffId,
|
|
||||||
documentId: 'd2',
|
|
||||||
name: 'Health and Safety Training',
|
|
||||||
description: 'Certificate of completion for health and safety training',
|
|
||||||
status: domain.DocumentStatus.pending,
|
|
||||||
documentUrl: 'https://example.com/documents/health_safety.pdf',
|
|
||||||
expiryDate: DateTime.now().add(const Duration(days: 180)),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
domain.StaffDocument _mapToDomain(
|
@override
|
||||||
ListStaffDocumentsByStaffIdStaffDocuments doc,
|
Future<domain.StaffDocument> uploadDocument(
|
||||||
) {
|
String documentId,
|
||||||
return domain.StaffDocument(
|
String filePath,
|
||||||
id: doc.id,
|
) async {
|
||||||
staffId: doc.staffId,
|
return _service.run(() async {
|
||||||
documentId: doc.documentId,
|
// 1. Upload the file to cloud storage
|
||||||
name: doc.document.name,
|
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||||
description: null, // Description not available in data source
|
filePath: filePath,
|
||||||
status: _mapStatus(doc.status),
|
fileName: 'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||||
documentUrl: doc.documentUrl,
|
visibility: domain.FileVisibility.private,
|
||||||
expiryDate: doc.expiryDate == null
|
);
|
||||||
? null
|
|
||||||
: DateTimeUtils.toDeviceTime(doc.expiryDate!.toDateTime()),
|
// 2. Generate a signed URL for verification service to access the file
|
||||||
);
|
final SignedUrlResponse signedUrlRes = await _signedUrlService
|
||||||
|
.createSignedUrl(fileUri: uploadRes.fileUri);
|
||||||
|
|
||||||
|
// 3. Initiate verification
|
||||||
|
final String staffId = await _service.getStaffId();
|
||||||
|
final VerificationResponse verificationRes = await _verificationService
|
||||||
|
.createVerification(
|
||||||
|
fileUri: uploadRes.fileUri,
|
||||||
|
type: documentId, // Assuming documentId aligns with type
|
||||||
|
subjectType: 'STAFF',
|
||||||
|
subjectId: staffId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Update/Create StaffDocument in Data Connect
|
||||||
|
await _service.getStaffRepository().upsertStaffDocument(
|
||||||
|
documentId: documentId,
|
||||||
|
documentUrl: uploadRes.fileUri,
|
||||||
|
status: domain.DocumentStatus.pending,
|
||||||
|
verificationId: verificationRes.verificationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Return the updated document state
|
||||||
|
final List<domain.StaffDocument> documents = await getDocuments();
|
||||||
|
return documents.firstWhere(
|
||||||
|
(domain.StaffDocument d) => d.documentId == documentId,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) {
|
domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) {
|
||||||
if (status is Known<DocumentStatus>) {
|
if (status is Known<DocumentStatus>) {
|
||||||
switch (status.value) {
|
switch (status.value) {
|
||||||
case DocumentStatus.VERIFIED:
|
case DocumentStatus.VERIFIED:
|
||||||
|
case DocumentStatus.AUTO_PASS:
|
||||||
|
case DocumentStatus.APPROVED:
|
||||||
return domain.DocumentStatus.verified;
|
return domain.DocumentStatus.verified;
|
||||||
case DocumentStatus.PENDING:
|
case DocumentStatus.PENDING:
|
||||||
|
case DocumentStatus.UPLOADED:
|
||||||
|
case DocumentStatus.PROCESSING:
|
||||||
|
case DocumentStatus.NEEDS_REVIEW:
|
||||||
|
case DocumentStatus.EXPIRING:
|
||||||
return domain.DocumentStatus.pending;
|
return domain.DocumentStatus.pending;
|
||||||
case DocumentStatus.MISSING:
|
case DocumentStatus.MISSING:
|
||||||
return domain.DocumentStatus.missing;
|
return domain.DocumentStatus.missing;
|
||||||
case DocumentStatus.UPLOADED:
|
case DocumentStatus.AUTO_FAIL:
|
||||||
case DocumentStatus.EXPIRING:
|
case DocumentStatus.REJECTED:
|
||||||
return domain.DocumentStatus.pending;
|
case DocumentStatus.ERROR:
|
||||||
|
return domain.DocumentStatus.rejected;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Default to pending for Unknown or unhandled cases
|
// Default to pending for Unknown or unhandled cases
|
||||||
return domain.DocumentStatus.pending;
|
return domain.DocumentStatus.pending;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,7 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
abstract interface class DocumentsRepository {
|
abstract interface class DocumentsRepository {
|
||||||
/// Fetches the list of compliance documents for the current staff member.
|
/// Fetches the list of compliance documents for the current staff member.
|
||||||
Future<List<StaffDocument>> getDocuments();
|
Future<List<StaffDocument>> getDocuments();
|
||||||
|
|
||||||
|
/// Uploads a document for the current staff member.
|
||||||
|
Future<StaffDocument> uploadDocument(String documentId, String filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import '../repositories/documents_repository.dart';
|
||||||
|
|
||||||
|
class UploadDocumentUseCase
|
||||||
|
extends UseCase<UploadDocumentArguments, StaffDocument> {
|
||||||
|
UploadDocumentUseCase(this._repository);
|
||||||
|
final DocumentsRepository _repository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<StaffDocument> call(UploadDocumentArguments arguments) {
|
||||||
|
return _repository.uploadDocument(arguments.documentId, arguments.filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UploadDocumentArguments {
|
||||||
|
const UploadDocumentArguments({
|
||||||
|
required this.documentId,
|
||||||
|
required this.filePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String documentId;
|
||||||
|
final String filePath;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import 'package:staff_documents/src/domain/usecases/upload_document_usecase.dart';
|
||||||
|
|
||||||
|
import 'document_upload_state.dart';
|
||||||
|
|
||||||
|
/// Manages the lifecycle of a document upload operation.
|
||||||
|
///
|
||||||
|
/// Handles attestation validation, file submission, and reports
|
||||||
|
/// success/failure back to the UI through [DocumentUploadState].
|
||||||
|
class DocumentUploadCubit extends Cubit<DocumentUploadState> {
|
||||||
|
DocumentUploadCubit(this._uploadDocumentUseCase)
|
||||||
|
: super(const DocumentUploadState());
|
||||||
|
|
||||||
|
final UploadDocumentUseCase _uploadDocumentUseCase;
|
||||||
|
|
||||||
|
/// Updates the user's attestation status.
|
||||||
|
void setAttested(bool value) {
|
||||||
|
emit(state.copyWith(isAttested: value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploads the selected document if the user has attested.
|
||||||
|
///
|
||||||
|
/// Requires [state.isAttested] to be true before proceeding.
|
||||||
|
Future<void> uploadDocument(String documentId, String filePath) async {
|
||||||
|
if (!state.isAttested) return;
|
||||||
|
|
||||||
|
emit(state.copyWith(status: DocumentUploadStatus.uploading));
|
||||||
|
|
||||||
|
try {
|
||||||
|
final StaffDocument updatedDoc = await _uploadDocumentUseCase(
|
||||||
|
UploadDocumentArguments(documentId: documentId, filePath: filePath),
|
||||||
|
);
|
||||||
|
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: DocumentUploadStatus.success,
|
||||||
|
updatedDocument: updatedDoc,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
emit(
|
||||||
|
state.copyWith(
|
||||||
|
status: DocumentUploadStatus.failure,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
|
enum DocumentUploadStatus { initial, uploading, success, failure }
|
||||||
|
|
||||||
|
class DocumentUploadState extends Equatable {
|
||||||
|
const DocumentUploadState({
|
||||||
|
this.status = DocumentUploadStatus.initial,
|
||||||
|
this.isAttested = false,
|
||||||
|
this.documentUrl,
|
||||||
|
this.updatedDocument,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final DocumentUploadStatus status;
|
||||||
|
final bool isAttested;
|
||||||
|
final String? documentUrl;
|
||||||
|
final StaffDocument? updatedDocument;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
DocumentUploadState copyWith({
|
||||||
|
DocumentUploadStatus? status,
|
||||||
|
bool? isAttested,
|
||||||
|
String? documentUrl,
|
||||||
|
StaffDocument? updatedDocument,
|
||||||
|
String? errorMessage,
|
||||||
|
}) {
|
||||||
|
return DocumentUploadState(
|
||||||
|
status: status ?? this.status,
|
||||||
|
isAttested: isAttested ?? this.isAttested,
|
||||||
|
documentUrl: documentUrl ?? this.documentUrl,
|
||||||
|
updatedDocument: updatedDocument ?? this.updatedDocument,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[
|
||||||
|
status,
|
||||||
|
isAttested,
|
||||||
|
documentUrl,
|
||||||
|
updatedDocument,
|
||||||
|
errorMessage,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
|
||||||
|
import '../blocs/document_upload/document_upload_cubit.dart';
|
||||||
|
import '../blocs/document_upload/document_upload_state.dart';
|
||||||
|
import '../widgets/document_upload/document_attestation_checkbox.dart';
|
||||||
|
import '../widgets/document_upload/document_file_selector.dart';
|
||||||
|
import '../widgets/document_upload/document_upload_footer.dart';
|
||||||
|
|
||||||
|
/// Allows staff to select and submit a single PDF document for verification.
|
||||||
|
///
|
||||||
|
/// Mirrors the pattern used in [AttireCapturePage] for a consistent upload flow:
|
||||||
|
/// file selection → attestation → submit → poll for result.
|
||||||
|
class DocumentUploadPage extends StatefulWidget {
|
||||||
|
const DocumentUploadPage({
|
||||||
|
super.key,
|
||||||
|
required this.document,
|
||||||
|
this.initialUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The staff document descriptor for the item being uploaded.
|
||||||
|
final StaffDocument document;
|
||||||
|
|
||||||
|
/// Optional URL of an already-uploaded document.
|
||||||
|
final String? initialUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DocumentUploadPage> createState() => _DocumentUploadPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DocumentUploadPageState extends State<DocumentUploadPage> {
|
||||||
|
String? _selectedFilePath;
|
||||||
|
final FilePickerService _filePicker = Modular.get<FilePickerService>();
|
||||||
|
|
||||||
|
Future<void> _pickFile() async {
|
||||||
|
final String? path = await _filePicker.pickFile(
|
||||||
|
allowedExtensions: <String>['pdf'],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (path != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedFilePath = path;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider<DocumentUploadCubit>(
|
||||||
|
create: (BuildContext _) => Modular.get<DocumentUploadCubit>(),
|
||||||
|
child: BlocConsumer<DocumentUploadCubit, DocumentUploadState>(
|
||||||
|
listener: (BuildContext context, DocumentUploadState state) {
|
||||||
|
if (state.status == DocumentUploadStatus.success) {
|
||||||
|
UiSnackbar.show(
|
||||||
|
context,
|
||||||
|
message: t.staff_documents.upload.success,
|
||||||
|
type: UiSnackbarType.success,
|
||||||
|
);
|
||||||
|
Modular.to.toDocuments();
|
||||||
|
} else if (state.status == DocumentUploadStatus.failure) {
|
||||||
|
UiSnackbar.show(
|
||||||
|
context,
|
||||||
|
message: state.errorMessage ?? t.staff_documents.upload.error,
|
||||||
|
type: UiSnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
builder: (BuildContext context, DocumentUploadState state) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: UiAppBar(
|
||||||
|
title: widget.document.name,
|
||||||
|
subtitle: widget.document.description,
|
||||||
|
onLeadingPressed: () => Modular.to.toDocuments(),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
t.staff_documents.upload.instructions,
|
||||||
|
style: UiTypography.body1m.textPrimary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
DocumentFileSelector(
|
||||||
|
selectedFilePath: _selectedFilePath,
|
||||||
|
onTap: _pickFile,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
bottomNavigationBar: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space5,
|
||||||
|
vertical: UiConstants.space4,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: <Widget>[
|
||||||
|
DocumentAttestationCheckbox(
|
||||||
|
isAttested: state.isAttested,
|
||||||
|
onChanged: (bool value) =>
|
||||||
|
BlocProvider.of<DocumentUploadCubit>(
|
||||||
|
context,
|
||||||
|
).setAttested(value),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
DocumentUploadFooter(
|
||||||
|
isUploading:
|
||||||
|
state.status == DocumentUploadStatus.uploading,
|
||||||
|
canSubmit: _selectedFilePath != null && state.isAttested,
|
||||||
|
onSubmit: () => BlocProvider.of<DocumentUploadCubit>(
|
||||||
|
context,
|
||||||
|
).uploadDocument(widget.document.id, _selectedFilePath!),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
// ignore: depend_on_referenced_packages
|
|
||||||
import 'package:core_localization/core_localization.dart';
|
|
||||||
|
|
||||||
import '../blocs/documents/documents_cubit.dart';
|
import '../blocs/documents/documents_cubit.dart';
|
||||||
import '../blocs/documents/documents_state.dart';
|
import '../blocs/documents/documents_state.dart';
|
||||||
@@ -14,89 +14,79 @@ import '../widgets/documents_progress_card.dart';
|
|||||||
class DocumentsPage extends StatelessWidget {
|
class DocumentsPage extends StatelessWidget {
|
||||||
const DocumentsPage({super.key});
|
const DocumentsPage({super.key});
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final DocumentsCubit cubit = Modular.get<DocumentsCubit>();
|
|
||||||
|
|
||||||
if (cubit.state.status == DocumentsStatus.initial) {
|
|
||||||
cubit.loadDocuments();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: UiAppBar(
|
||||||
elevation: 0,
|
title: t.staff_documents.title,
|
||||||
leading: IconButton(
|
showBackButton: true,
|
||||||
icon: const Icon(UiIcons.arrowLeft, color: UiColors.iconSecondary),
|
onLeadingPressed: () => Modular.to.toProfile(),
|
||||||
onPressed: () => Modular.to.pop(),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
t.staff_documents.title,
|
|
||||||
style: UiTypography.headline3m.copyWith(
|
|
||||||
color: UiColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
bottom: PreferredSize(
|
|
||||||
preferredSize: const Size.fromHeight(1.0),
|
|
||||||
child: Container(color: UiColors.border, height: 1.0),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
body: BlocBuilder<DocumentsCubit, DocumentsState>(
|
body: BlocProvider<DocumentsCubit>(
|
||||||
bloc: cubit,
|
create: (BuildContext context) =>
|
||||||
builder: (BuildContext context, DocumentsState state) {
|
Modular.get<DocumentsCubit>()..loadDocuments(),
|
||||||
if (state.status == DocumentsStatus.loading) {
|
child: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||||
return const Center(
|
builder: (BuildContext context, DocumentsState state) {
|
||||||
child: CircularProgressIndicator(
|
if (state.status == DocumentsStatus.loading) {
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(UiColors.primary),
|
return const Center(
|
||||||
),
|
child: CircularProgressIndicator(
|
||||||
);
|
valueColor: AlwaysStoppedAnimation<Color>(UiColors.primary),
|
||||||
}
|
),
|
||||||
if (state.status == DocumentsStatus.failure) {
|
);
|
||||||
return Center(
|
}
|
||||||
child: Padding(
|
if (state.status == DocumentsStatus.failure) {
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
|
child: Text(
|
||||||
|
state.errorMessage != null
|
||||||
|
? (state.errorMessage!.contains('errors.')
|
||||||
|
? translateErrorKey(state.errorMessage!)
|
||||||
|
: t.staff_documents.list.error(
|
||||||
|
message: state.errorMessage!,
|
||||||
|
))
|
||||||
|
: t.staff_documents.list.error(message: 'Unknown'),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: UiTypography.body1m.copyWith(
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state.documents.isEmpty) {
|
||||||
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
state.errorMessage != null
|
t.staff_documents.list.empty,
|
||||||
? (state.errorMessage!.contains('errors.')
|
style: UiTypography.body1m.copyWith(
|
||||||
? translateErrorKey(state.errorMessage!)
|
color: UiColors.textSecondary,
|
||||||
: t.staff_documents.list.error(message: state.errorMessage!))
|
),
|
||||||
: t.staff_documents.list.error(message: 'Unknown'),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: UiTypography.body1m.copyWith(color: UiColors.textSecondary),
|
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
if (state.documents.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Text(
|
|
||||||
t.staff_documents.list.empty,
|
|
||||||
style: UiTypography.body1m.copyWith(color: UiColors.textSecondary),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: UiConstants.space5,
|
horizontal: UiConstants.space5,
|
||||||
vertical: UiConstants.space6,
|
vertical: UiConstants.space6,
|
||||||
),
|
|
||||||
children: <Widget>[
|
|
||||||
DocumentsProgressCard(
|
|
||||||
completedCount: state.completedCount,
|
|
||||||
totalCount: state.totalCount,
|
|
||||||
progress: state.progress,
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
children: <Widget>[
|
||||||
...state.documents.map(
|
DocumentsProgressCard(
|
||||||
(StaffDocument doc) => DocumentCard(
|
completedCount: state.completedCount,
|
||||||
document: doc,
|
totalCount: state.totalCount,
|
||||||
onTap: () => Modular.to.pushNamed('./details', arguments: doc.id),
|
progress: state.progress,
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: UiConstants.space4),
|
||||||
],
|
...state.documents.map(
|
||||||
);
|
(StaffDocument doc) => DocumentCard(
|
||||||
},
|
document: doc,
|
||||||
|
onTap: () => Modular.to.toDocumentUpload(document: doc),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
// ignore: depend_on_referenced_packages
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
|
||||||
|
/// A labeled checkbox confirming the document is genuine before submission.
|
||||||
|
///
|
||||||
|
/// Renders an attestation statement alongside a checkbox. The [onChanged]
|
||||||
|
/// callback is fired whenever the user toggles the checkbox.
|
||||||
|
class DocumentAttestationCheckbox extends StatelessWidget {
|
||||||
|
const DocumentAttestationCheckbox({
|
||||||
|
super.key,
|
||||||
|
required this.isAttested,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether the user has currently checked the attestation box.
|
||||||
|
final bool isAttested;
|
||||||
|
|
||||||
|
/// Called with the new value when the checkbox is toggled.
|
||||||
|
final ValueChanged<bool> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Checkbox(
|
||||||
|
value: isAttested,
|
||||||
|
onChanged: (bool? value) => onChanged(value ?? false),
|
||||||
|
activeColor: UiColors.primary,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
t.staff_documents.upload.attestation,
|
||||||
|
style: UiTypography.body2r.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
// ignore: depend_on_referenced_packages
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
|
||||||
|
import 'document_selected_card.dart';
|
||||||
|
|
||||||
|
/// Displays a tappable card that prompts the user to pick a PDF file.
|
||||||
|
///
|
||||||
|
/// Shows the selected file name when a file has been chosen, or an
|
||||||
|
/// upload icon with a prompt when no file is selected yet.
|
||||||
|
class DocumentFileSelector extends StatelessWidget {
|
||||||
|
const DocumentFileSelector({
|
||||||
|
super.key,
|
||||||
|
required this.onTap,
|
||||||
|
this.selectedFilePath,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Called when the user taps the selector to pick a file.
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
/// The local path of the currently selected file, or null if none chosen.
|
||||||
|
final String? selectedFilePath;
|
||||||
|
|
||||||
|
bool get _hasFile => selectedFilePath != null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_hasFile) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
child: DocumentSelectedCard(selectedFilePath: selectedFilePath!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
child: Container(
|
||||||
|
height: 180,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.bgPopup,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(
|
||||||
|
UiIcons.upload,
|
||||||
|
size: 48,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
Text(
|
||||||
|
t.staff_documents.upload.select_pdf,
|
||||||
|
style: UiTypography.body2m.textError,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// A card that displays the selected document file name and an icon.
|
||||||
|
class DocumentSelectedCard extends StatelessWidget {
|
||||||
|
const DocumentSelectedCard({super.key, required this.selectedFilePath});
|
||||||
|
|
||||||
|
/// The local path of the currently selected file.
|
||||||
|
final String selectedFilePath;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.bgPopup,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: UiColors.primary),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.primary.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(UiConstants.space2),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(UiIcons.file, color: UiColors.primary, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
selectedFilePath.split('/').last,
|
||||||
|
style: UiTypography.body1m.textPrimary,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
// ignore: depend_on_referenced_packages
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
|
||||||
|
/// Renders the bottom action area of the document upload page.
|
||||||
|
///
|
||||||
|
/// Shows a [CircularProgressIndicator] while [isUploading] is true,
|
||||||
|
/// otherwise shows a primary submit button. The button is only enabled
|
||||||
|
/// when both a file has been selected and the user has attested.
|
||||||
|
class DocumentUploadFooter extends StatelessWidget {
|
||||||
|
const DocumentUploadFooter({
|
||||||
|
super.key,
|
||||||
|
required this.isUploading,
|
||||||
|
required this.canSubmit,
|
||||||
|
required this.onSubmit,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Whether the upload is currently in progress.
|
||||||
|
final bool isUploading;
|
||||||
|
|
||||||
|
/// Whether all preconditions (file selected + attested) have been met.
|
||||||
|
final bool canSubmit;
|
||||||
|
|
||||||
|
/// Called when the user taps the submit button.
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (isUploading) {
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(UiConstants.space4),
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(UiColors.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return UiButton.primary(
|
||||||
|
fullWidth: true,
|
||||||
|
onPressed: canSubmit ? onSubmit : null,
|
||||||
|
text: t.staff_documents.upload.submit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,17 +1,30 @@
|
|||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import 'data/repositories_impl/documents_repository_impl.dart';
|
import 'data/repositories_impl/documents_repository_impl.dart';
|
||||||
import 'domain/repositories/documents_repository.dart';
|
import 'domain/repositories/documents_repository.dart';
|
||||||
import 'domain/usecases/get_documents_usecase.dart';
|
import 'domain/usecases/get_documents_usecase.dart';
|
||||||
|
import 'domain/usecases/upload_document_usecase.dart';
|
||||||
import 'presentation/blocs/documents/documents_cubit.dart';
|
import 'presentation/blocs/documents/documents_cubit.dart';
|
||||||
|
import 'presentation/blocs/document_upload/document_upload_cubit.dart';
|
||||||
import 'presentation/pages/documents_page.dart';
|
import 'presentation/pages/documents_page.dart';
|
||||||
|
import 'presentation/pages/document_upload_page.dart';
|
||||||
|
|
||||||
class StaffDocumentsModule extends Module {
|
class StaffDocumentsModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
i.addLazySingleton<DocumentsRepository>(DocumentsRepositoryImpl.new);
|
i.addLazySingleton<DocumentsRepository>(
|
||||||
|
() => DocumentsRepositoryImpl(
|
||||||
|
uploadService: i.get<FileUploadService>(),
|
||||||
|
signedUrlService: i.get<SignedUrlService>(),
|
||||||
|
verificationService: i.get<VerificationService>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
i.addLazySingleton(GetDocumentsUseCase.new);
|
i.addLazySingleton(GetDocumentsUseCase.new);
|
||||||
i.addLazySingleton(DocumentsCubit.new);
|
i.addLazySingleton(UploadDocumentUseCase.new);
|
||||||
|
|
||||||
|
i.add(DocumentsCubit.new);
|
||||||
|
i.add(DocumentUploadCubit.new);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -20,5 +33,12 @@ class StaffDocumentsModule extends Module {
|
|||||||
StaffPaths.childRoute(StaffPaths.documents, StaffPaths.documents),
|
StaffPaths.childRoute(StaffPaths.documents, StaffPaths.documents),
|
||||||
child: (_) => const DocumentsPage(),
|
child: (_) => const DocumentsPage(),
|
||||||
);
|
);
|
||||||
|
r.child(
|
||||||
|
StaffPaths.childRoute(StaffPaths.documents, StaffPaths.documentUpload),
|
||||||
|
child: (_) => DocumentUploadPage(
|
||||||
|
document: r.args.data['document'] as StaffDocument,
|
||||||
|
initialUrl: r.args.data['initialUrl'] as String?,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ dependencies:
|
|||||||
equatable: ^2.0.5
|
equatable: ^2.0.5
|
||||||
firebase_auth: ^6.1.4
|
firebase_auth: ^6.1.4
|
||||||
firebase_data_connect: ^0.2.2+2
|
firebase_data_connect: ^0.2.2+2
|
||||||
|
|
||||||
# Architecture Packages
|
# Architecture Packages
|
||||||
design_system:
|
design_system:
|
||||||
path: ../../../../../design_system
|
path: ../../../../../design_system
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ mutation upsertStaffDocument(
|
|||||||
$status: DocumentStatus!
|
$status: DocumentStatus!
|
||||||
$documentUrl: String
|
$documentUrl: String
|
||||||
$expiryDate: Timestamp
|
$expiryDate: Timestamp
|
||||||
|
$verificationId: String
|
||||||
) @auth(level: USER) {
|
) @auth(level: USER) {
|
||||||
staffDocument_upsert(
|
staffDocument_upsert(
|
||||||
data: {
|
data: {
|
||||||
@@ -67,6 +68,7 @@ mutation upsertStaffDocument(
|
|||||||
status: $status
|
status: $status
|
||||||
documentUrl: $documentUrl
|
documentUrl: $documentUrl
|
||||||
expiryDate: $expiryDate
|
expiryDate: $expiryDate
|
||||||
|
verificationId: $verificationId
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ query getStaffDocumentByKey(
|
|||||||
status
|
status
|
||||||
documentUrl
|
documentUrl
|
||||||
expiryDate
|
expiryDate
|
||||||
|
verificationId
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
document {
|
document {
|
||||||
@@ -39,6 +40,7 @@ query listStaffDocumentsByStaffId(
|
|||||||
status
|
status
|
||||||
documentUrl
|
documentUrl
|
||||||
expiryDate
|
expiryDate
|
||||||
|
verificationId
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
document {
|
document {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Document @table(name: "documents") {
|
|||||||
name: String!
|
name: String!
|
||||||
description: String
|
description: String
|
||||||
documentType: DocumentType!
|
documentType: DocumentType!
|
||||||
|
isMandatory: Boolean @default(expr: "false")
|
||||||
createdAt: Timestamp @default(expr: "request.time")
|
createdAt: Timestamp @default(expr: "request.time")
|
||||||
updatedAt: Timestamp @default(expr: "request.time")
|
updatedAt: Timestamp @default(expr: "request.time")
|
||||||
createdBy: String
|
createdBy: String
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
enum DocumentStatus {
|
enum DocumentStatus {
|
||||||
UPLOADED
|
|
||||||
PENDING
|
PENDING
|
||||||
|
PROCESSING
|
||||||
|
AUTO_PASS
|
||||||
|
AUTO_FAIL
|
||||||
|
NEEDS_REVIEW
|
||||||
|
APPROVED
|
||||||
|
REJECTED
|
||||||
|
ERROR
|
||||||
|
UPLOADED
|
||||||
EXPIRING
|
EXPIRING
|
||||||
MISSING
|
MISSING
|
||||||
VERIFIED
|
VERIFIED
|
||||||
@@ -12,9 +19,16 @@ type StaffDocument @table(name: "staff_documents", key: ["staffId", "documentId"
|
|||||||
staffName: String!
|
staffName: String!
|
||||||
documentId: UUID!
|
documentId: UUID!
|
||||||
document: Document! @ref(fields: "documentId", references: "id")
|
document: Document! @ref(fields: "documentId", references: "id")
|
||||||
status: DocumentStatus!
|
|
||||||
|
status: DocumentStatus! @default(expr: "'PENDING'")
|
||||||
documentUrl: String
|
documentUrl: String
|
||||||
expiryDate: Timestamp
|
expiryDate: Timestamp
|
||||||
|
|
||||||
|
# Verification Metadata (Align with Attire flow)
|
||||||
|
verificationId: String
|
||||||
|
verifiedAt: Timestamp
|
||||||
|
rejectionReason: String
|
||||||
|
|
||||||
createdAt: Timestamp @default(expr: "request.time")
|
createdAt: Timestamp @default(expr: "request.time")
|
||||||
updatedAt: Timestamp @default(expr: "request.time")
|
updatedAt: Timestamp @default(expr: "request.time")
|
||||||
createdBy: String
|
createdBy: String
|
||||||
|
|||||||
Reference in New Issue
Block a user