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);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Manage professional certificates (e.g., food handling, CPR).
|
||||
|
||||
@@ -164,11 +164,17 @@ class StaffPaths {
|
||||
/// Store ID, work permits, and other required documentation.
|
||||
static const String documents = '/worker-main/documents/';
|
||||
|
||||
/// Document upload page.
|
||||
static const String documentUpload = '/worker-main/documents/upload/';
|
||||
|
||||
/// Certificates management - professional certifications.
|
||||
///
|
||||
/// Manage professional certificates (e.g., food handling, CPR, etc.).
|
||||
static const String certificates = '/worker-main/certificates/';
|
||||
|
||||
/// Certificate upload page.
|
||||
static const String certificateUpload = '/worker-main/certificates/upload/';
|
||||
|
||||
// ==========================================================================
|
||||
// FINANCIAL INFORMATION
|
||||
// ==========================================================================
|
||||
|
||||
@@ -17,6 +17,7 @@ class VerificationService extends BaseCoreService {
|
||||
required String subjectType,
|
||||
required String subjectId,
|
||||
required String fileUri,
|
||||
String? category,
|
||||
Map<String, dynamic>? rules,
|
||||
}) async {
|
||||
final ApiResponse res = await action(() async {
|
||||
@@ -27,6 +28,7 @@ class VerificationService extends BaseCoreService {
|
||||
'subjectType': subjectType,
|
||||
'subjectId': subjectId,
|
||||
'fileUri': fileUri,
|
||||
if (category != null) 'category': category,
|
||||
if (rules != null) 'rules': rules,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1050,6 +1050,15 @@
|
||||
"pending": "Pending",
|
||||
"missing": "Missing",
|
||||
"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": {
|
||||
@@ -1078,13 +1087,16 @@
|
||||
},
|
||||
"upload_modal": {
|
||||
"title": "Upload Certificate",
|
||||
"name_label": "Certificate Name",
|
||||
"issuer_label": "Certificate Issuer",
|
||||
"expiry_label": "Expiration Date (Optional)",
|
||||
"select_date": "Select date",
|
||||
"upload_file": "Upload File",
|
||||
"drag_drop": "Drag and drop or click to upload",
|
||||
"supported_formats": "PDF, JPG, PNG up to 10MB",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save Certificate"
|
||||
"save": "Save Certificate",
|
||||
"success_snackbar": "Certificate successfully uploaded and pending verification"
|
||||
},
|
||||
"delete_modal": {
|
||||
"title": "Remove Certificate?",
|
||||
|
||||
@@ -1050,6 +1050,15 @@
|
||||
"pending": "Pendiente",
|
||||
"missing": "Faltante",
|
||||
"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": {
|
||||
@@ -1078,13 +1087,16 @@
|
||||
},
|
||||
"upload_modal": {
|
||||
"title": "Subir Certificado",
|
||||
"name_label": "Nombre del Certificado",
|
||||
"issuer_label": "Emisor del Certificado",
|
||||
"expiry_label": "Fecha de Expiraci\u00f3n (Opcional)",
|
||||
"select_date": "Seleccionar fecha",
|
||||
"upload_file": "Subir Archivo",
|
||||
"drag_drop": "Arrastra y suelta o haz clic para subir",
|
||||
"supported_formats": "PDF, JPG, PNG hasta 10MB",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar Certificado"
|
||||
"save": "Guardar Certificado",
|
||||
"success_snackbar": "Certificado subido exitosamente y pendiente de verificaci\u00f3n"
|
||||
},
|
||||
"delete_modal": {
|
||||
"title": "\u00bfEliminar Certificado?",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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_domain/krow_domain.dart' as domain;
|
||||
import '../../domain/repositories/staff_connector_repository.dart';
|
||||
@@ -349,4 +350,395 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
||||
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? 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
|
||||
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_verification_status.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 'document_verification_status.dart';
|
||||
|
||||
/// Status of a compliance document.
|
||||
enum DocumentStatus {
|
||||
verified,
|
||||
pending,
|
||||
missing,
|
||||
rejected,
|
||||
expired
|
||||
}
|
||||
enum DocumentStatus { verified, pending, missing, rejected, expired }
|
||||
|
||||
/// Represents a staff compliance document.
|
||||
class StaffDocument extends Equatable {
|
||||
|
||||
const StaffDocument({
|
||||
required this.id,
|
||||
required this.staffId,
|
||||
@@ -21,7 +16,10 @@ class StaffDocument extends Equatable {
|
||||
required this.status,
|
||||
this.documentUrl,
|
||||
this.expiryDate,
|
||||
this.verificationId,
|
||||
this.verificationStatus,
|
||||
});
|
||||
|
||||
/// The unique identifier of the staff document record.
|
||||
final String id;
|
||||
|
||||
@@ -46,15 +44,23 @@ class StaffDocument extends Equatable {
|
||||
/// The expiry date of the document.
|
||||
final DateTime? expiryDate;
|
||||
|
||||
/// The ID of the verification record.
|
||||
final String? verificationId;
|
||||
|
||||
/// The detailed verification status.
|
||||
final DocumentVerificationStatus? verificationStatus;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
staffId,
|
||||
documentId,
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
documentUrl,
|
||||
expiryDate,
|
||||
];
|
||||
id,
|
||||
staffId,
|
||||
documentId,
|
||||
name,
|
||||
description,
|
||||
status,
|
||||
documentUrl,
|
||||
expiryDate,
|
||||
verificationId,
|
||||
verificationStatus,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class ProfileLevelBadge extends StatelessWidget {
|
||||
switch (status) {
|
||||
case StaffStatus.active:
|
||||
case StaffStatus.verified:
|
||||
return 'Krower I';
|
||||
return 'KROWER I';
|
||||
case StaffStatus.pending:
|
||||
case StaffStatus.completedProfile:
|
||||
return 'Pending';
|
||||
|
||||
@@ -56,6 +56,11 @@ class OnboardingSection extends StatelessWidget {
|
||||
label: i18n.menu_items.attire,
|
||||
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_data_connect/krow_data_connect.dart';
|
||||
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';
|
||||
|
||||
/// Implementation of [CertificatesRepository] using Data Connect.
|
||||
///
|
||||
/// This class handles the communication with the backend via [DataConnectService].
|
||||
/// It maps raw generated data types to clean [domain.StaffDocument] entities.
|
||||
class CertificatesRepositoryImpl
|
||||
implements CertificatesRepository {
|
||||
class CertificatesRepositoryImpl implements CertificatesRepository {
|
||||
CertificatesRepositoryImpl({
|
||||
required FileUploadService uploadService,
|
||||
required SignedUrlService signedUrlService,
|
||||
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 FileUploadService _uploadService;
|
||||
final SignedUrlService _signedUrlService;
|
||||
final VerificationService _verificationService;
|
||||
|
||||
@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 {
|
||||
// 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();
|
||||
await _verificationService.createVerification(
|
||||
fileUri: uploadRes.fileUri,
|
||||
type: certificationType.value,
|
||||
category: 'CERTIFICATE',
|
||||
subjectType: 'STAFF',
|
||||
subjectId: staffId,
|
||||
);
|
||||
|
||||
// Execute the query via DataConnect generated SDK
|
||||
final QueryResult<ListStaffDocumentsByStaffIdData, ListStaffDocumentsByStaffIdVariables> result =
|
||||
await _service.connector
|
||||
.listStaffDocumentsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
// 4. Update/Create Certificate in Data Connect
|
||||
await _service.getStaffRepository().upsertStaffCertificate(
|
||||
certificationType: certificationType,
|
||||
name: name,
|
||||
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
|
||||
return result.data.staffDocuments
|
||||
.map((ListStaffDocumentsByStaffIdStaffDocuments doc) =>
|
||||
_mapToDomain(doc))
|
||||
.toList();
|
||||
// 5. Return updated list or the specific certificate
|
||||
final List<domain.StaffCertificate> certificates =
|
||||
await getCertificates();
|
||||
return certificates.firstWhere(
|
||||
(domain.StaffCertificate c) => c.certificationType == certificationType,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Maps the Data Connect [ListStaffDocumentsByStaffIdStaffDocuments] to a domain [domain.StaffDocument].
|
||||
domain.StaffDocument _mapToDomain(
|
||||
ListStaffDocumentsByStaffIdStaffDocuments doc,
|
||||
) {
|
||||
return domain.StaffDocument(
|
||||
id: doc.id,
|
||||
staffId: doc.staffId,
|
||||
documentId: doc.documentId,
|
||||
name: doc.document.name,
|
||||
description: null, // Description not available in this query response
|
||||
status: _mapStatus(doc.status),
|
||||
documentUrl: doc.documentUrl,
|
||||
expiryDate: doc.expiryDate == null
|
||||
? null
|
||||
: DateTimeUtils.toDeviceTime(doc.expiryDate!.toDateTime()),
|
||||
@override
|
||||
Future<void> upsertCertificate({
|
||||
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.getStaffRepository().upsertStaffCertificate(
|
||||
certificationType: certificationType,
|
||||
name: name,
|
||||
status: status,
|
||||
fileUrl: fileUrl,
|
||||
expiry: expiry,
|
||||
issuer: issuer,
|
||||
certificateNumber: certificateNumber,
|
||||
validationStatus: validationStatus,
|
||||
);
|
||||
}
|
||||
|
||||
/// Maps the Data Connect [DocumentStatus] enum to the domain [domain.DocumentStatus].
|
||||
domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) {
|
||||
if (status is Known<DocumentStatus>) {
|
||||
switch (status.value) {
|
||||
case DocumentStatus.VERIFIED:
|
||||
return domain.DocumentStatus.verified;
|
||||
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;
|
||||
@override
|
||||
Future<void> deleteCertificate({
|
||||
required domain.ComplianceType certificationType,
|
||||
}) async {
|
||||
return _service.getStaffRepository().deleteStaffCertificate(
|
||||
certificationType: certificationType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,31 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
abstract interface class CertificatesRepository {
|
||||
/// Fetches the list of compliance certificates for the current staff member.
|
||||
///
|
||||
/// Returns a list of [StaffDocument] entities.
|
||||
Future<List<StaffDocument>> getCertificates();
|
||||
/// Returns a list of [StaffCertificate] entities.
|
||||
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].
|
||||
/// 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].
|
||||
///
|
||||
/// Requires a [CertificatesRepository] to access the certificates data source.
|
||||
@@ -15,7 +14,7 @@ class GetCertificatesUseCase extends NoInputUseCase<List<StaffDocument>> {
|
||||
final CertificatesRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<StaffDocument>> call() {
|
||||
Future<List<StaffCertificate>> call() {
|
||||
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_domain/krow_domain.dart';
|
||||
import '../../../domain/usecases/get_certificates_usecase.dart';
|
||||
import '../../../domain/usecases/delete_certificate_usecase.dart';
|
||||
import 'certificates_state.dart';
|
||||
|
||||
class CertificatesCubit extends Cubit<CertificatesState>
|
||||
with BlocErrorHandler<CertificatesState> {
|
||||
|
||||
CertificatesCubit(this._getCertificatesUseCase)
|
||||
: super(const CertificatesState()) {
|
||||
CertificatesCubit(
|
||||
this._getCertificatesUseCase,
|
||||
this._deleteCertificateUseCase,
|
||||
) : super(const CertificatesState()) {
|
||||
loadCertificates();
|
||||
}
|
||||
|
||||
final GetCertificatesUseCase _getCertificatesUseCase;
|
||||
final DeleteCertificateUseCase _deleteCertificateUseCase;
|
||||
|
||||
Future<void> loadCertificates() async {
|
||||
emit(state.copyWith(status: CertificatesStatus.loading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final List<StaffDocument> certificates =
|
||||
final List<StaffCertificate> certificates =
|
||||
await _getCertificatesUseCase();
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -27,12 +31,25 @@ class CertificatesCubit extends Cubit<CertificatesState>
|
||||
),
|
||||
);
|
||||
},
|
||||
onError:
|
||||
(String errorKey) => state.copyWith(
|
||||
status: CertificatesStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: CertificatesStatus.failure,
|
||||
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 }
|
||||
|
||||
class CertificatesState extends Equatable {
|
||||
|
||||
const CertificatesState({
|
||||
this.status = CertificatesStatus.initial,
|
||||
List<StaffDocument>? certificates,
|
||||
List<StaffCertificate>? certificates,
|
||||
this.errorMessage,
|
||||
}) : certificates = certificates ?? const <StaffDocument>[];
|
||||
}) : certificates = certificates ?? const <StaffCertificate>[];
|
||||
|
||||
final CertificatesStatus status;
|
||||
final List<StaffDocument> certificates;
|
||||
final List<StaffCertificate> certificates;
|
||||
final String? errorMessage;
|
||||
|
||||
CertificatesState copyWith({
|
||||
CertificatesStatus? status,
|
||||
List<StaffDocument>? certificates,
|
||||
List<StaffCertificate>? certificates,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return CertificatesState(
|
||||
@@ -31,7 +31,10 @@ class CertificatesState extends Equatable {
|
||||
|
||||
/// The number of verified certificates.
|
||||
int get completedCount => certificates
|
||||
.where((StaffDocument doc) => doc.status == DocumentStatus.verified)
|
||||
.where(
|
||||
(StaffCertificate cert) =>
|
||||
cert.validationStatus == StaffCertificateValidationStatus.approved,
|
||||
)
|
||||
.length;
|
||||
|
||||
/// 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_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';
|
||||
|
||||
@@ -9,7 +10,6 @@ import '../blocs/certificates/certificates_cubit.dart';
|
||||
import '../blocs/certificates/certificates_state.dart';
|
||||
import '../widgets/add_certificate_card.dart';
|
||||
import '../widgets/certificate_card.dart';
|
||||
import '../widgets/certificate_upload_modal.dart';
|
||||
import '../widgets/certificates_header.dart';
|
||||
|
||||
/// Page for viewing and managing staff certificates.
|
||||
@@ -39,10 +39,10 @@ class CertificatesPage extends StatelessWidget {
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
state.errorMessage != null
|
||||
? translateErrorKey(state.errorMessage!)
|
||||
: t.staff_certificates.error_loading,
|
||||
child: Text(
|
||||
state.errorMessage != null
|
||||
? translateErrorKey(state.errorMessage!)
|
||||
: t.staff_certificates.error_loading,
|
||||
textAlign: TextAlign.center,
|
||||
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(
|
||||
backgroundColor: UiColors.background, // Matches 0xFFF8FAFC
|
||||
@@ -65,25 +65,32 @@ class CertificatesPage extends StatelessWidget {
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -48),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
...documents.map((StaffDocument doc) => CertificateCard(
|
||||
document: doc,
|
||||
onUpload: () => _showUploadModal(context, doc),
|
||||
onEditExpiry: () => _showEditExpiryDialog(context, doc),
|
||||
onRemove: () => _showRemoveConfirmation(context, doc),
|
||||
onView: () {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: t.staff_certificates.card.opened_snackbar,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
},
|
||||
)),
|
||||
...documents.map(
|
||||
(StaffCertificate doc) => CertificateCard(
|
||||
certificate: doc,
|
||||
onUpload: () => _navigateToUpload(context, doc),
|
||||
onEditExpiry: () =>
|
||||
_showEditExpiryDialog(context, doc),
|
||||
onRemove: () =>
|
||||
_showRemoveConfirmation(context, doc),
|
||||
onView: () {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message:
|
||||
t.staff_certificates.card.opened_snackbar,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
AddCertificateCard(
|
||||
onTap: () => _showUploadModal(context, null),
|
||||
onTap: () => _navigateToUpload(context, null),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
],
|
||||
@@ -98,28 +105,29 @@ class CertificatesPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showUploadModal(BuildContext context, StaffDocument? document) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (BuildContext context) => CertificateUploadModal(
|
||||
document: document,
|
||||
onSave: () {
|
||||
// TODO: Implement upload via Cubit
|
||||
// Modular.get<CertificatesCubit>().uploadCertificate(...);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
onCancel: () => Navigator.pop(context),
|
||||
),
|
||||
Future<void> _navigateToUpload(
|
||||
BuildContext context,
|
||||
StaffCertificate? certificate,
|
||||
) async {
|
||||
await Modular.to.pushNamed(
|
||||
StaffPaths.certificateUpload,
|
||||
arguments: certificate,
|
||||
);
|
||||
// Reload certificates after returning from the upload page
|
||||
await Modular.get<CertificatesCubit>().loadCertificates();
|
||||
}
|
||||
|
||||
void _showEditExpiryDialog(BuildContext context, StaffDocument document) {
|
||||
_showUploadModal(context, document);
|
||||
void _showEditExpiryDialog(
|
||||
BuildContext context,
|
||||
StaffCertificate certificate,
|
||||
) {
|
||||
_navigateToUpload(context, certificate);
|
||||
}
|
||||
|
||||
void _showRemoveConfirmation(BuildContext context, StaffDocument document) {
|
||||
void _showRemoveConfirmation(
|
||||
BuildContext context,
|
||||
StaffCertificate certificate,
|
||||
) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
@@ -132,8 +140,9 @@ class CertificatesPage extends StatelessWidget {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// TODO: Implement delete via Cubit
|
||||
// Modular.get<CertificatesCubit>().deleteCertificate(document.id);
|
||||
Modular.get<CertificatesCubit>().deleteCertificate(
|
||||
certificate.certificationType,
|
||||
);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: UiColors.destructive),
|
||||
|
||||
@@ -5,16 +5,16 @@ import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
class CertificateCard extends StatelessWidget {
|
||||
|
||||
const CertificateCard({
|
||||
super.key,
|
||||
required this.document,
|
||||
required this.certificate,
|
||||
this.onUpload,
|
||||
this.onEditExpiry,
|
||||
this.onRemove,
|
||||
this.onView,
|
||||
});
|
||||
final StaffDocument document;
|
||||
|
||||
final StaffCertificate certificate;
|
||||
final VoidCallback? onUpload;
|
||||
final VoidCallback? onEditExpiry;
|
||||
final VoidCallback? onRemove;
|
||||
@@ -22,21 +22,30 @@ class CertificateCard extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Determine UI state from document
|
||||
final bool isComplete = document.status == DocumentStatus.verified;
|
||||
// Todo: Better logic for expring. Assuming if expiryDate is close.
|
||||
// Prototype used 'EXPIRING' status. We map this logic:
|
||||
final bool isExpiring = _isExpiring(document.expiryDate);
|
||||
final bool isExpired = _isExpired(document.expiryDate);
|
||||
|
||||
// Determine UI state from certificate
|
||||
final bool isComplete =
|
||||
certificate.validationStatus ==
|
||||
StaffCertificateValidationStatus.approved;
|
||||
final bool isExpiring =
|
||||
certificate.status == StaffCertificateStatus.expiring ||
|
||||
certificate.status == StaffCertificateStatus.expiringSoon;
|
||||
final bool isExpired = certificate.status == StaffCertificateStatus.expired;
|
||||
|
||||
// Override isComplete if expiring or expired
|
||||
final bool showComplete = isComplete && !isExpired && !isExpiring;
|
||||
|
||||
final bool isPending = document.status == DocumentStatus.pending;
|
||||
final bool isNotStarted = document.status == DocumentStatus.missing || document.status == DocumentStatus.rejected;
|
||||
|
||||
final bool isPending =
|
||||
certificate.validationStatus ==
|
||||
StaffCertificateValidationStatus.pendingExpertReview;
|
||||
final bool isNotStarted =
|
||||
certificate.status == StaffCertificateStatus.notStarted ||
|
||||
certificate.validationStatus ==
|
||||
StaffCertificateValidationStatus.rejected;
|
||||
|
||||
// UI Properties helper
|
||||
final _CertificateUiProps uiProps = _getUiProps(document.documentId);
|
||||
final _CertificateUiProps uiProps = _getUiProps(
|
||||
certificate.certificationType,
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||
@@ -64,12 +73,14 @@ class CertificateCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.accent.withValues(alpha: 0.2), // Yellow tint
|
||||
border: Border(
|
||||
bottom: BorderSide(color: UiColors.accent.withValues(alpha: 0.4)),
|
||||
bottom: BorderSide(
|
||||
color: UiColors.accent.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
const Icon(
|
||||
UiIcons.warning,
|
||||
size: 16,
|
||||
color: UiColors.textPrimary,
|
||||
@@ -78,13 +89,14 @@ class CertificateCard extends StatelessWidget {
|
||||
Text(
|
||||
isExpired
|
||||
? 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Row(
|
||||
@@ -151,12 +163,12 @@ class CertificateCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
document.name,
|
||||
certificate.name,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
document.description ?? '', // Optional description
|
||||
certificate.description ?? '',
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
@@ -170,11 +182,10 @@ class CertificateCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
if (showComplete) _buildCompleteStatus(document.expiryDate),
|
||||
|
||||
if (isExpiring || isExpired) _buildExpiringStatus(context, document.expiryDate),
|
||||
|
||||
if (showComplete)
|
||||
_buildCompleteStatus(certificate.expiryDate),
|
||||
if (isExpiring || isExpired)
|
||||
_buildExpiringStatus(context, certificate.expiryDate),
|
||||
if (isNotStarted)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
@@ -207,7 +218,6 @@ class CertificateCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (showComplete || isExpiring || isExpired) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
SizedBox(
|
||||
@@ -281,7 +291,9 @@ class CertificateCard extends StatelessWidget {
|
||||
),
|
||||
if (expiryDate != null)
|
||||
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,
|
||||
),
|
||||
],
|
||||
@@ -308,9 +320,7 @@ class CertificateCard extends StatelessWidget {
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
t.staff_certificates.card.expiring_soon,
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
style: UiTypography.body2m.copyWith(color: UiColors.primary),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -318,7 +328,9 @@ class CertificateCard extends StatelessWidget {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
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(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
@@ -330,10 +342,7 @@ class CertificateCard extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
_buildIconButton(UiIcons.eye, onView),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
_buildSmallOutlineButton(
|
||||
t.staff_certificates.card.renew,
|
||||
onUpload,
|
||||
),
|
||||
_buildSmallOutlineButton(t.staff_certificates.card.renew, onUpload),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -347,12 +356,9 @@ class CertificateCard extends StatelessWidget {
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: UiColors.transparent,
|
||||
border: Border.all(
|
||||
color: UiColors.transparent,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(icon, size: 16, color: UiColors.textSecondary),
|
||||
@@ -365,10 +371,10 @@ class CertificateCard extends StatelessWidget {
|
||||
return OutlinedButton(
|
||||
onPressed: onTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: UiColors.primary.withValues(alpha: 0.4)), // Primary with opacity
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UiConstants.radiusFull,
|
||||
),
|
||||
side: BorderSide(
|
||||
color: UiColors.primary.withValues(alpha: 0.4),
|
||||
), // Primary with opacity
|
||||
shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusFull),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
|
||||
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) {
|
||||
if (expiry == null) return 0;
|
||||
return expiry.difference(DateTime.now()).inDays;
|
||||
}
|
||||
|
||||
// Mock mapping for UI props based on ID
|
||||
_CertificateUiProps _getUiProps(String id) {
|
||||
switch (id) {
|
||||
case 'background':
|
||||
_CertificateUiProps _getUiProps(ComplianceType type) {
|
||||
switch (type) {
|
||||
case ComplianceType.backgroundCheck:
|
||||
return _CertificateUiProps(UiIcons.fileCheck, UiColors.primary);
|
||||
case 'food_handler':
|
||||
case ComplianceType.foodHandler:
|
||||
return _CertificateUiProps(UiIcons.utensils, UiColors.primary);
|
||||
case 'rbs':
|
||||
case ComplianceType.rbs:
|
||||
return _CertificateUiProps(UiIcons.wine, UiColors.foreground);
|
||||
default:
|
||||
// Default generic icon
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Modal for uploading or editing a certificate expiry.
|
||||
class CertificateUploadModal extends StatelessWidget {
|
||||
|
||||
const CertificateUploadModal({
|
||||
super.key,
|
||||
this.document,
|
||||
this.certificate,
|
||||
required this.onSave,
|
||||
required this.onCancel,
|
||||
});
|
||||
/// The document being edited, or null for a new upload.
|
||||
// ignore: unused_field
|
||||
final dynamic
|
||||
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.
|
||||
|
||||
/// The certificate being edited, or null for a new upload.
|
||||
final StaffCertificate? certificate;
|
||||
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'data/repositories_impl/certificates_repository_impl.dart';
|
||||
import 'domain/repositories/certificates_repository.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/certificate_upload/certificate_upload_cubit.dart';
|
||||
import 'presentation/pages/certificate_upload_page.dart';
|
||||
import 'presentation/pages/certificates_page.dart';
|
||||
|
||||
class StaffCertificatesModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
i.addLazySingleton<CertificatesRepository>(CertificatesRepositoryImpl.new);
|
||||
i.addLazySingleton(GetCertificatesUseCase.new);
|
||||
i.addLazySingleton(CertificatesCubit.new);
|
||||
i.addLazySingleton<GetCertificatesUseCase>(GetCertificatesUseCase.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
|
||||
@@ -21,5 +32,16 @@ class StaffCertificatesModule extends Module {
|
||||
StaffPaths.childRoute(StaffPaths.certificates, StaffPaths.certificates),
|
||||
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
|
||||
flutter_bloc: ^8.1.0
|
||||
equatable: ^2.0.5
|
||||
intl: ^0.20.0
|
||||
get_it: ^7.6.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';
|
||||
|
||||
/// Implementation of [DocumentsRepository] using Data Connect.
|
||||
class DocumentsRepositoryImpl
|
||||
implements DocumentsRepository {
|
||||
class DocumentsRepositoryImpl 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 FileUploadService _uploadService;
|
||||
final SignedUrlService _signedUrlService;
|
||||
final VerificationService _verificationService;
|
||||
|
||||
@override
|
||||
Future<List<domain.StaffDocument>> getDocuments() async {
|
||||
return _service.run(() async {
|
||||
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)),
|
||||
),
|
||||
];
|
||||
|
||||
});
|
||||
return _service.getStaffRepository().getStaffDocuments();
|
||||
}
|
||||
|
||||
domain.StaffDocument _mapToDomain(
|
||||
ListStaffDocumentsByStaffIdStaffDocuments doc,
|
||||
) {
|
||||
return domain.StaffDocument(
|
||||
id: doc.id,
|
||||
staffId: doc.staffId,
|
||||
documentId: doc.documentId,
|
||||
name: doc.document.name,
|
||||
description: null, // Description not available in data source
|
||||
status: _mapStatus(doc.status),
|
||||
documentUrl: doc.documentUrl,
|
||||
expiryDate: doc.expiryDate == null
|
||||
? null
|
||||
: DateTimeUtils.toDeviceTime(doc.expiryDate!.toDateTime()),
|
||||
);
|
||||
@override
|
||||
Future<domain.StaffDocument> uploadDocument(
|
||||
String documentId,
|
||||
String filePath,
|
||||
) async {
|
||||
return _service.run(() async {
|
||||
// 1. Upload the file to cloud storage
|
||||
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
|
||||
filePath: filePath,
|
||||
fileName: 'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf',
|
||||
visibility: domain.FileVisibility.private,
|
||||
);
|
||||
|
||||
// 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) {
|
||||
if (status is Known<DocumentStatus>) {
|
||||
switch (status.value) {
|
||||
case DocumentStatus.VERIFIED:
|
||||
case DocumentStatus.AUTO_PASS:
|
||||
case DocumentStatus.APPROVED:
|
||||
return domain.DocumentStatus.verified;
|
||||
case DocumentStatus.PENDING:
|
||||
case DocumentStatus.UPLOADED:
|
||||
case DocumentStatus.PROCESSING:
|
||||
case DocumentStatus.NEEDS_REVIEW:
|
||||
case DocumentStatus.EXPIRING:
|
||||
return domain.DocumentStatus.pending;
|
||||
case DocumentStatus.MISSING:
|
||||
return domain.DocumentStatus.missing;
|
||||
case DocumentStatus.UPLOADED:
|
||||
case DocumentStatus.EXPIRING:
|
||||
return domain.DocumentStatus.pending;
|
||||
case DocumentStatus.AUTO_FAIL:
|
||||
case DocumentStatus.REJECTED:
|
||||
case DocumentStatus.ERROR:
|
||||
return domain.DocumentStatus.rejected;
|
||||
}
|
||||
}
|
||||
// Default to pending for Unknown or unhandled cases
|
||||
return domain.DocumentStatus.pending;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,4 +6,7 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
abstract interface class DocumentsRepository {
|
||||
/// Fetches the list of compliance documents for the current staff member.
|
||||
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: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';
|
||||
// ignore: depend_on_referenced_packages
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
|
||||
import '../blocs/documents/documents_cubit.dart';
|
||||
import '../blocs/documents/documents_state.dart';
|
||||
@@ -14,89 +14,79 @@ import '../widgets/documents_progress_card.dart';
|
||||
class DocumentsPage extends StatelessWidget {
|
||||
const DocumentsPage({super.key});
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final DocumentsCubit cubit = Modular.get<DocumentsCubit>();
|
||||
|
||||
if (cubit.state.status == DocumentsStatus.initial) {
|
||||
cubit.loadDocuments();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(UiIcons.arrowLeft, color: UiColors.iconSecondary),
|
||||
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),
|
||||
),
|
||||
appBar: UiAppBar(
|
||||
title: t.staff_documents.title,
|
||||
showBackButton: true,
|
||||
onLeadingPressed: () => Modular.to.toProfile(),
|
||||
),
|
||||
body: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
bloc: cubit,
|
||||
builder: (BuildContext context, DocumentsState state) {
|
||||
if (state.status == DocumentsStatus.loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(UiColors.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state.status == DocumentsStatus.failure) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
body: BlocProvider<DocumentsCubit>(
|
||||
create: (BuildContext context) =>
|
||||
Modular.get<DocumentsCubit>()..loadDocuments(),
|
||||
child: BlocBuilder<DocumentsCubit, DocumentsState>(
|
||||
builder: (BuildContext context, DocumentsState state) {
|
||||
if (state.status == DocumentsStatus.loading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(UiColors.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state.status == DocumentsStatus.failure) {
|
||||
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(
|
||||
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),
|
||||
t.staff_documents.list.empty,
|
||||
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(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
vertical: UiConstants.space6,
|
||||
),
|
||||
children: <Widget>[
|
||||
DocumentsProgressCard(
|
||||
completedCount: state.completedCount,
|
||||
totalCount: state.totalCount,
|
||||
progress: state.progress,
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
vertical: UiConstants.space6,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
...state.documents.map(
|
||||
(StaffDocument doc) => DocumentCard(
|
||||
document: doc,
|
||||
onTap: () => Modular.to.pushNamed('./details', arguments: doc.id),
|
||||
children: <Widget>[
|
||||
DocumentsProgressCard(
|
||||
completedCount: state.completedCount,
|
||||
totalCount: state.totalCount,
|
||||
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:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'data/repositories_impl/documents_repository_impl.dart';
|
||||
import 'domain/repositories/documents_repository.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/document_upload/document_upload_cubit.dart';
|
||||
import 'presentation/pages/documents_page.dart';
|
||||
import 'presentation/pages/document_upload_page.dart';
|
||||
|
||||
class StaffDocumentsModule extends Module {
|
||||
@override
|
||||
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(DocumentsCubit.new);
|
||||
i.addLazySingleton(UploadDocumentUseCase.new);
|
||||
|
||||
i.add(DocumentsCubit.new);
|
||||
i.add(DocumentUploadCubit.new);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -20,5 +33,12 @@ class StaffDocumentsModule extends Module {
|
||||
StaffPaths.childRoute(StaffPaths.documents, StaffPaths.documents),
|
||||
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
|
||||
firebase_auth: ^6.1.4
|
||||
firebase_data_connect: ^0.2.2+2
|
||||
|
||||
# Architecture Packages
|
||||
design_system:
|
||||
path: ../../../../../design_system
|
||||
|
||||
@@ -58,6 +58,7 @@ mutation upsertStaffDocument(
|
||||
$status: DocumentStatus!
|
||||
$documentUrl: String
|
||||
$expiryDate: Timestamp
|
||||
$verificationId: String
|
||||
) @auth(level: USER) {
|
||||
staffDocument_upsert(
|
||||
data: {
|
||||
@@ -67,6 +68,7 @@ mutation upsertStaffDocument(
|
||||
status: $status
|
||||
documentUrl: $documentUrl
|
||||
expiryDate: $expiryDate
|
||||
verificationId: $verificationId
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ query getStaffDocumentByKey(
|
||||
status
|
||||
documentUrl
|
||||
expiryDate
|
||||
verificationId
|
||||
createdAt
|
||||
updatedAt
|
||||
document {
|
||||
@@ -39,6 +40,7 @@ query listStaffDocumentsByStaffId(
|
||||
status
|
||||
documentUrl
|
||||
expiryDate
|
||||
verificationId
|
||||
createdAt
|
||||
updatedAt
|
||||
document {
|
||||
|
||||
@@ -14,6 +14,7 @@ type Document @table(name: "documents") {
|
||||
name: String!
|
||||
description: String
|
||||
documentType: DocumentType!
|
||||
isMandatory: Boolean @default(expr: "false")
|
||||
createdAt: Timestamp @default(expr: "request.time")
|
||||
updatedAt: Timestamp @default(expr: "request.time")
|
||||
createdBy: String
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
enum DocumentStatus {
|
||||
UPLOADED
|
||||
PENDING
|
||||
PROCESSING
|
||||
AUTO_PASS
|
||||
AUTO_FAIL
|
||||
NEEDS_REVIEW
|
||||
APPROVED
|
||||
REJECTED
|
||||
ERROR
|
||||
UPLOADED
|
||||
EXPIRING
|
||||
MISSING
|
||||
VERIFIED
|
||||
@@ -12,9 +19,16 @@ type StaffDocument @table(name: "staff_documents", key: ["staffId", "documentId"
|
||||
staffName: String!
|
||||
documentId: UUID!
|
||||
document: Document! @ref(fields: "documentId", references: "id")
|
||||
status: DocumentStatus!
|
||||
|
||||
status: DocumentStatus! @default(expr: "'PENDING'")
|
||||
documentUrl: String
|
||||
expiryDate: Timestamp
|
||||
|
||||
# Verification Metadata (Align with Attire flow)
|
||||
verificationId: String
|
||||
verifiedAt: Timestamp
|
||||
rejectionReason: String
|
||||
|
||||
createdAt: Timestamp @default(expr: "request.time")
|
||||
updatedAt: Timestamp @default(expr: "request.time")
|
||||
createdBy: String
|
||||
|
||||
Reference in New Issue
Block a user