diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index 0cdc11e0..5456d1e6 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -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: { + 'document': document, + 'initialUrl': initialUrl, + }, + ); + } + /// Pushes the certificates management page. /// /// Manage professional certificates (e.g., food handling, CPR). diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index f3bb8428..42b159d3 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -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 // ========================================================================== diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart index 73390819..3dd72b79 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart @@ -17,6 +17,7 @@ class VerificationService extends BaseCoreService { required String subjectType, required String subjectId, required String fileUri, + String? category, Map? 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, }, ); diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 740e0370..14d3e946 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -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?", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 9a991a74..5db8cb8a 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -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?", diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart index 24f01a00..60e1ebe7 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -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> getStaffDocuments() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final List> results = + await Future.wait>( + >>[ + _service.connector.listDocuments().execute(), + _service.connector + .listStaffDocumentsByStaffId(staffId: staffId) + .execute(), + ], + ); + + final QueryResult documentsRes = + results[0] as QueryResult; + final QueryResult< + dc.ListStaffDocumentsByStaffIdData, + dc.ListStaffDocumentsByStaffIdVariables + > + staffDocsRes = + results[1] + as QueryResult< + dc.ListStaffDocumentsByStaffIdData, + dc.ListStaffDocumentsByStaffIdVariables + >; + + final List 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 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 status, + ) { + if (status is dc.Unknown) { + return domain.DocumentStatus.pending; + } + final dc.DocumentStatus value = + (status as dc.Known).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 status, + ) { + if (status is dc.Unknown) { + return domain.DocumentVerificationStatus.error; + } + final String name = (status as dc.Known).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> 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 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 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 status, + ) { + if (status is dc.Unknown) return domain.StaffCertificateStatus.notStarted; + final dc.CertificateStatus value = + (status as dc.Known).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 type, + ) { + if (type is dc.Unknown) return domain.ComplianceType.other; + final dc.ComplianceType value = (type as dc.Known).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? status, + ) { + if (status == null || status is dc.Unknown) return null; + final dc.ValidationStatus value = + (status as dc.Known).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; + } + } } diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart index 3bd3c9e7..d4b04da7 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart @@ -72,4 +72,35 @@ abstract interface class StaffConnectorRepository { String? bio, String? profilePictureUrl, }); + + /// Fetches the staff documents for the current authenticated user. + Future> getStaffDocuments(); + + /// Upserts staff document information. + Future upsertStaffDocument({ + required String documentId, + required String documentUrl, + DocumentStatus? status, + String? verificationId, + }); + + /// Fetches the staff certificates for the current authenticated user. + Future> getStaffCertificates(); + + /// Upserts staff certificate information. + Future 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 deleteStaffCertificate({ + required ComplianceType certificationType, + }); } diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 87167b9e..87b22493 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -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'; diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/compliance_type.dart b/apps/mobile/packages/domain/lib/src/entities/profile/compliance_type.dart new file mode 100644 index 00000000..ce5533ce --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/compliance_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, + ); + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/document_verification_status.dart b/apps/mobile/packages/domain/lib/src/entities/profile/document_verification_status.dart new file mode 100644 index 00000000..99b47bb8 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/document_verification_status.dart @@ -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, + ); + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate.dart b/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate.dart new file mode 100644 index 00000000..50b3b952 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate.dart @@ -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 get props => [ + 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, + ); + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_status.dart b/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_status.dart new file mode 100644 index 00000000..b39e096c --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_status.dart @@ -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, + ); + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_validation_status.dart b/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_validation_status.dart new file mode 100644 index 00000000..19d30358 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_validation_status.dart @@ -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, + ); + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/staff_document.dart b/apps/mobile/packages/domain/lib/src/entities/profile/staff_document.dart index 01305436..d8dd5958 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/staff_document.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/staff_document.dart @@ -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 get props => [ - id, - staffId, - documentId, - name, - description, - status, - documentUrl, - expiryDate, - ]; + id, + staffId, + documentId, + name, + description, + status, + documentUrl, + expiryDate, + verificationId, + verificationStatus, + ]; } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart index 3661e192..9514a463 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/header/profile_level_badge.dart @@ -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'; diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart index 327e58ea..c704efd5 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart @@ -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(), + ), ], ), ], diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/IMPLEMENTATION_WORKFLOW.md b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/IMPLEMENTATION_WORKFLOW.md new file mode 100644 index 00000000..7d5f1751 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/IMPLEMENTATION_WORKFLOW.md @@ -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__.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. diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart index afbb94c5..137fc5a5 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart @@ -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> getCertificates() async { + Future> getCertificates() async { + return _service.getStaffRepository().getStaffCertificates(); + } + + @override + Future 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 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 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 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 status) { - if (status is Known) { - 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 deleteCertificate({ + required domain.ComplianceType certificationType, + }) async { + return _service.getStaffRepository().deleteStaffCertificate( + certificationType: certificationType, + ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart index b87081df..9a21fc22 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/repositories/certificates_repository.dart @@ -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> getCertificates(); + /// Returns a list of [StaffCertificate] entities. + Future> getCertificates(); + + /// Uploads a certificate file and saves the record. + Future uploadCertificate({ + required ComplianceType certificationType, + required String name, + required String filePath, + DateTime? expiryDate, + String? issuer, + String? certificateNumber, + }); + + /// Deletes a staff certificate. + Future deleteCertificate({required ComplianceType certificationType}); + + /// Upserts a certificate record (metadata only). + Future upsertCertificate({ + required ComplianceType certificationType, + required String name, + required StaffCertificateStatus status, + String? fileUrl, + DateTime? expiry, + String? issuer, + String? certificateNumber, + StaffCertificateValidationStatus? validationStatus, + }); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/delete_certificate_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/delete_certificate_usecase.dart new file mode 100644 index 00000000..f8104461 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/delete_certificate_usecase.dart @@ -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 { + /// Creates a [DeleteCertificateUseCase]. + DeleteCertificateUseCase(this._repository); + final CertificatesRepository _repository; + + @override + Future call(ComplianceType certificationType) { + return _repository.deleteCertificate(certificationType: certificationType); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/get_certificates_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/get_certificates_usecase.dart index 16e56d06..014ddee4 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/get_certificates_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/get_certificates_usecase.dart @@ -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> { - +class GetCertificatesUseCase extends NoInputUseCase> { /// Creates a [GetCertificatesUseCase]. /// /// Requires a [CertificatesRepository] to access the certificates data source. @@ -15,7 +14,7 @@ class GetCertificatesUseCase extends NoInputUseCase> { final CertificatesRepository _repository; @override - Future> call() { + Future> call() { return _repository.getCertificates(); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart new file mode 100644 index 00000000..8e26f9ba --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart @@ -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 { + /// Creates an [UploadCertificateUseCase]. + UploadCertificateUseCase(this._repository); + final CertificatesRepository _repository; + + @override + Future 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; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upsert_certificate_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upsert_certificate_usecase.dart new file mode 100644 index 00000000..6773e499 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upsert_certificate_usecase.dart @@ -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 { + /// Creates an [UpsertCertificateUseCase]. + UpsertCertificateUseCase(this._repository); + final CertificatesRepository _repository; + + @override + Future 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; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart new file mode 100644 index 00000000..4fd0266f --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart @@ -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 + with BlocErrorHandler { + CertificateUploadCubit(this._uploadCertificateUseCase) + : super(const CertificateUploadState()); + + final UploadCertificateUseCase _uploadCertificateUseCase; + + void setAttested(bool value) { + emit(state.copyWith(isAttested: value)); + } + + Future 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, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_state.dart new file mode 100644 index 00000000..31ea5991 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_state.dart @@ -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 get props => [ + status, + isAttested, + updatedCertificate, + errorMessage, + ]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_cubit.dart index 49bbb5f8..59a6e56a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_cubit.dart @@ -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 with BlocErrorHandler { - - CertificatesCubit(this._getCertificatesUseCase) - : super(const CertificatesState()) { + CertificatesCubit( + this._getCertificatesUseCase, + this._deleteCertificateUseCase, + ) : super(const CertificatesState()) { loadCertificates(); } + final GetCertificatesUseCase _getCertificatesUseCase; + final DeleteCertificateUseCase _deleteCertificateUseCase; Future loadCertificates() async { emit(state.copyWith(status: CertificatesStatus.loading)); await handleError( emit: emit, action: () async { - final List certificates = + final List certificates = await _getCertificatesUseCase(); emit( state.copyWith( @@ -27,12 +31,25 @@ class CertificatesCubit extends Cubit ), ); }, - onError: - (String errorKey) => state.copyWith( - status: CertificatesStatus.failure, - errorMessage: errorKey, - ), + onError: (String errorKey) => state.copyWith( + status: CertificatesStatus.failure, + errorMessage: errorKey, + ), + ); + } + + Future 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, + ), ); } } - diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_state.dart index 76992e62..6d64c969 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificates/certificates_state.dart @@ -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? certificates, + List? certificates, this.errorMessage, - }) : certificates = certificates ?? const []; + }) : certificates = certificates ?? const []; + final CertificatesStatus status; - final List certificates; + final List certificates; final String? errorMessage; CertificatesState copyWith({ CertificatesStatus? status, - List? certificates, + List? 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. diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart new file mode 100644 index 00000000..6f9da08e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart @@ -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 createState() => _CertificateUploadPageState(); +} + +class _CertificateUploadPageState extends State { + String? _selectedFilePath; + DateTime? _selectedExpiryDate; + final TextEditingController _issuerController = TextEditingController(); + final TextEditingController _numberController = TextEditingController(); + final TextEditingController _nameController = TextEditingController(); + + ComplianceType? _selectedType; + + final FilePickerService _filePicker = Modular.get(); + + @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 _pickFile() async { + final String? path = await _filePicker.pickFile( + allowedExtensions: ['pdf', 'jpg', 'png'], + ); + + if (path != null) { + setState(() { + _selectedFilePath = path; + }); + } + } + + Future _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( + create: (BuildContext _) => Modular.get(), + child: BlocConsumer( + 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: [ + 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: [ + 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: [ + // Attestation + Row( + children: [ + Checkbox( + value: state.isAttested, + onChanged: (bool? val) => + BlocProvider.of( + 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( + 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: [ + 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: [ + 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, + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart index 0a1893a5..c5946362 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificates_page.dart @@ -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 documents = state.certificates; + final List 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: [ - ...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( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (BuildContext context) => CertificateUploadModal( - document: document, - onSave: () { - // TODO: Implement upload via Cubit - // Modular.get().uploadCertificate(...); - Navigator.pop(context); - }, - onCancel: () => Navigator.pop(context), - ), + Future _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().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( context: context, builder: (BuildContext context) => AlertDialog( @@ -132,8 +140,9 @@ class CertificatesPage extends StatelessWidget { ), TextButton( onPressed: () { - // TODO: Implement delete via Cubit - // Modular.get().deleteCertificate(document.id); + Modular.get().deleteCertificate( + certificate.certificationType, + ); Navigator.pop(context); }, style: TextButton.styleFrom(foregroundColor: UiColors.destructive), diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_card.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_card.dart index 491f4f43..403a3165 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_card.dart @@ -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: [ - 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: [ 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) ...[ 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: [ _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 diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_modal.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_modal.dart index 52b576a9..7ade30f8 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_modal.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/widgets/certificate_upload_modal.dart @@ -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; diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/staff_certificates_module.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/staff_certificates_module.dart index 1d444c0b..dd910afe 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/staff_certificates_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/staff_certificates_module.dart @@ -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(CertificatesRepositoryImpl.new); - i.addLazySingleton(GetCertificatesUseCase.new); - i.addLazySingleton(CertificatesCubit.new); + i.addLazySingleton(GetCertificatesUseCase.new); + i.addLazySingleton(DeleteCertificateUseCase.new); + i.addLazySingleton(UpsertCertificateUseCase.new); + i.addLazySingleton(UploadCertificateUseCase.new); + i.addLazySingleton(CertificatesCubit.new); + i.add(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, + ), + ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/pubspec.yaml index e98a60a7..05fd996d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/pubspec.yaml @@ -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 diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/IMPLEMENTATION_WORKFLOW.md b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/IMPLEMENTATION_WORKFLOW.md new file mode 100644 index 00000000..4e8d6bbe --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/IMPLEMENTATION_WORKFLOW.md @@ -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. diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart index 8b9cdcd4..c32e3e88 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart @@ -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> 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( - 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 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 documents = await getDocuments(); + return documents.firstWhere( + (domain.StaffDocument d) => d.documentId == documentId, + ); + }); } domain.DocumentStatus _mapStatus(EnumValue status) { if (status is Known) { 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; } } - - diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/repositories/documents_repository.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/repositories/documents_repository.dart index 26f6a7db..2ecd3faf 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/repositories/documents_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/repositories/documents_repository.dart @@ -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> getDocuments(); + + /// Uploads a document for the current staff member. + Future uploadDocument(String documentId, String filePath); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/upload_document_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/upload_document_usecase.dart new file mode 100644 index 00000000..13dfa2f3 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/upload_document_usecase.dart @@ -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 { + UploadDocumentUseCase(this._repository); + final DocumentsRepository _repository; + + @override + Future 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; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart new file mode 100644 index 00000000..8cb036a6 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart @@ -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 { + 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 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(), + ), + ); + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart new file mode 100644 index 00000000..a737b1a1 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart @@ -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 get props => [ + status, + isAttested, + documentUrl, + updatedDocument, + errorMessage, + ]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart new file mode 100644 index 00000000..975fee10 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart @@ -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 createState() => _DocumentUploadPageState(); +} + +class _DocumentUploadPageState extends State { + String? _selectedFilePath; + final FilePickerService _filePicker = Modular.get(); + + Future _pickFile() async { + final String? path = await _filePicker.pickFile( + allowedExtensions: ['pdf'], + ); + + if (path != null) { + setState(() { + _selectedFilePath = path; + }); + } + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext _) => Modular.get(), + child: BlocConsumer( + 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: [ + 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: [ + DocumentAttestationCheckbox( + isAttested: state.isAttested, + onChanged: (bool value) => + BlocProvider.of( + context, + ).setAttested(value), + ), + const SizedBox(height: UiConstants.space4), + DocumentUploadFooter( + isUploading: + state.status == DocumentUploadStatus.uploading, + canSubmit: _selectedFilePath != null && state.isAttested, + onSubmit: () => BlocProvider.of( + context, + ).uploadDocument(widget.document.id, _selectedFilePath!), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart index dbb95c1c..884aad75 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart @@ -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(); - - 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( - bloc: cubit, - builder: (BuildContext context, DocumentsState state) { - if (state.status == DocumentsStatus.loading) { - return const Center( - child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(UiColors.primary), - ), - ); - } - if (state.status == DocumentsStatus.failure) { - return Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space4), + body: BlocProvider( + create: (BuildContext context) => + Modular.get()..loadDocuments(), + child: BlocBuilder( + builder: (BuildContext context, DocumentsState state) { + if (state.status == DocumentsStatus.loading) { + return const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(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: [ - 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: [ + 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), + ), + ), + ], + ); + }, + ), ), ); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_attestation_checkbox.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_attestation_checkbox.dart new file mode 100644 index 00000000..51f14338 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_attestation_checkbox.dart @@ -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 onChanged; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Checkbox( + value: isAttested, + onChanged: (bool? value) => onChanged(value ?? false), + activeColor: UiColors.primary, + ), + Expanded( + child: Text( + t.staff_documents.upload.attestation, + style: UiTypography.body2r.textPrimary, + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_file_selector.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_file_selector.dart new file mode 100644 index 00000000..4c112749 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_file_selector.dart @@ -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: [ + 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, + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_selected_card.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_selected_card.dart new file mode 100644 index 00000000..5a27ce1c --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_selected_card.dart @@ -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: [ + 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, + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_upload_footer.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_upload_footer.dart new file mode 100644 index 00000000..314932ff --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/widgets/document_upload/document_upload_footer.dart @@ -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(UiColors.primary), + ), + ), + ); + } + + return UiButton.primary( + fullWidth: true, + onPressed: canSubmit ? onSubmit : null, + text: t.staff_documents.upload.submit, + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart index 8193497e..58dda25d 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart @@ -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(DocumentsRepositoryImpl.new); + i.addLazySingleton( + () => DocumentsRepositoryImpl( + uploadService: i.get(), + signedUrlService: i.get(), + verificationService: i.get(), + ), + ); 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?, + ), + ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/pubspec.yaml index c7f1438f..b099e9da 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/pubspec.yaml @@ -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 diff --git a/backend/dataconnect/connector/staffDocument/mutations.gql b/backend/dataconnect/connector/staffDocument/mutations.gql index ebec5fba..eba48ff3 100644 --- a/backend/dataconnect/connector/staffDocument/mutations.gql +++ b/backend/dataconnect/connector/staffDocument/mutations.gql @@ -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 } ) } diff --git a/backend/dataconnect/connector/staffDocument/queries.gql b/backend/dataconnect/connector/staffDocument/queries.gql index 77e6cc6f..e0660353 100644 --- a/backend/dataconnect/connector/staffDocument/queries.gql +++ b/backend/dataconnect/connector/staffDocument/queries.gql @@ -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 { diff --git a/backend/dataconnect/schema/document.gql b/backend/dataconnect/schema/document.gql index b0657b10..2d1b6cd0 100644 --- a/backend/dataconnect/schema/document.gql +++ b/backend/dataconnect/schema/document.gql @@ -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 diff --git a/backend/dataconnect/schema/staffDocument.gql b/backend/dataconnect/schema/staffDocument.gql index 65c26058..35a803ab 100644 --- a/backend/dataconnect/schema/staffDocument.gql +++ b/backend/dataconnect/schema/staffDocument.gql @@ -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