From 425bf19a9b322e751889d6ca11e23e004f207667 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Thu, 26 Feb 2026 23:28:59 -0500 Subject: [PATCH] feat: Implement full certificate management with upload, upsert, delete, and new domain models for staff certificates and their statuses. --- .../lib/src/routing/staff/route_paths.dart | 3 + .../verification/verification_service.dart | 2 + .../lib/src/l10n/en.i18n.json | 8 +- .../staff_connector_repository_impl.dart | 217 +++++++++++ .../staff_connector_repository.dart | 20 + .../packages/domain/lib/krow_domain.dart | 4 + .../src/entities/profile/compliance_type.dart | 25 ++ .../entities/profile/staff_certificate.dart | 120 ++++++ .../profile/staff_certificate_status.dart | 23 ++ .../staff_certificate_validation_status.dart | 22 ++ .../certificates/IMPLEMENTATION_WORKFLOW.md | 220 +++++++++++ .../certificates_repository_impl.dart | 170 +++++---- .../repositories/certificates_repository.dart | 29 +- .../usecases/delete_certificate_usecase.dart | 15 + .../usecases/get_certificates_usecase.dart | 5 +- .../usecases/upload_certificate_usecase.dart | 54 +++ .../usecases/upsert_certificate_usecase.dart | 63 +++ .../certificate_upload_cubit.dart | 41 ++ .../certificate_upload_state.dart | 40 ++ .../certificates/certificates_cubit.dart | 37 +- .../certificates/certificates_state.dart | 15 +- .../pages/certificate_upload_page.dart | 361 ++++++++++++++++++ .../presentation/pages/certificates_page.dart | 89 +++-- .../widgets/certificate_card.dart | 113 +++--- .../widgets/certificate_upload_modal.dart | 12 +- .../lib/src/staff_certificates_module.dart | 26 +- .../compliance/certificates/pubspec.yaml | 1 + 27 files changed, 1525 insertions(+), 210 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/compliance_type.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_status.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/profile/staff_certificate_validation_status.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/IMPLEMENTATION_WORKFLOW.md create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/delete_certificate_usecase.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upload_certificate_usecase.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/domain/usecases/upsert_certificate_usecase.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_cubit.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/blocs/certificate_upload/certificate_upload_state.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/presentation/pages/certificate_upload_page.dart 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 89ec84a0..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 @@ -172,6 +172,9 @@ class StaffPaths { /// 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 c077ea63..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 @@ -1057,7 +1057,8 @@ "select_pdf": "Select PDF File", "attestation": "I certify that this document is genuine and valid.", "success": "Document uploaded successfully", - "error": "Failed to upload document" + "error": "Failed to upload document", + "replace": "Replace" } }, "staff_certificates": { @@ -1086,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/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 57047403..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 @@ -524,4 +524,221 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { 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 6e60cb22..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 @@ -83,4 +83,24 @@ abstract interface class StaffConnectorRepository { 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 f78390ce..87b22493 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -78,6 +78,10 @@ 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/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/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 08ab2e55..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,98 +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 { - /// Creates a [CertificatesRepositoryImpl]. - CertificatesRepositoryImpl() : _service = DataConnectService.instance; + CertificatesRepositoryImpl({ + required FileUploadService uploadService, + required SignedUrlService signedUrlService, + required VerificationService verificationService, + }) : _service = DataConnectService.instance, + _uploadService = uploadService, + _signedUrlService = signedUrlService, + _verificationService = verificationService; - /// 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< - ListStaffDocumentsByStaffIdData, - ListStaffDocumentsByStaffIdVariables - > - result = await _service.connector - .listStaffDocumentsByStaffId(staffId: staffId) - .execute(); + // 4. Update/Create Certificate in Data Connect + await _service.getStaffRepository().upsertStaffCertificate( + certificationType: certificationType, + name: name, + status: domain.StaffCertificateStatus.pending, + fileUrl: uploadRes.fileUri, + expiry: expiryDate, + issuer: issuer, + certificateNumber: certificateNumber, + validationStatus: + domain.StaffCertificateValidationStatus.pendingExpertReview, + ); - // Map the generated SDK types to pure Domain entities - return result.data.staffDocuments - .map( - (ListStaffDocumentsByStaffIdStaffDocuments doc) => - _mapToDomain(doc), - ) - .toList(); + // 5. Return updated list or the specific certificate + final List 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; - case DocumentStatus.PROCESSING: - // TODO: Handle this case. - throw UnimplementedError(); - case DocumentStatus.AUTO_PASS: - // TODO: Handle this case. - throw UnimplementedError(); - case DocumentStatus.AUTO_FAIL: - // TODO: Handle this case. - throw UnimplementedError(); - case DocumentStatus.NEEDS_REVIEW: - // TODO: Handle this case. - throw UnimplementedError(); - case DocumentStatus.APPROVED: - // TODO: Handle this case. - throw UnimplementedError(); - case DocumentStatus.REJECTED: - // TODO: Handle this case. - throw UnimplementedError(); - case DocumentStatus.ERROR: - // TODO: Handle this case. - throw UnimplementedError(); - } - } - // 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