feat: Implement full certificate management with upload, upsert, delete, and new domain models for staff certificates and their statuses.

This commit is contained in:
Achintha Isuru
2026-02-26 23:28:59 -05:00
parent ca0ba258e2
commit 425bf19a9b
27 changed files with 1525 additions and 210 deletions

View File

@@ -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
// ==========================================================================

View File

@@ -17,6 +17,7 @@ class VerificationService extends BaseCoreService {
required String subjectType,
required String subjectId,
required String fileUri,
String? category,
Map<String, dynamic>? rules,
}) async {
final ApiResponse res = await action(() async {
@@ -27,6 +28,7 @@ class VerificationService extends BaseCoreService {
'subjectType': subjectType,
'subjectId': subjectId,
'fileUri': fileUri,
if (category != null) 'category': category,
if (rules != null) 'rules': rules,
},
);

View File

@@ -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?",

View File

@@ -524,4 +524,221 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
return dc.DocumentStatus.EXPIRING;
}
}
@override
Future<List<domain.StaffCertificate>> getStaffCertificates() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.ListCertificatesByStaffIdData,
dc.ListCertificatesByStaffIdVariables
>
response = await _service.connector
.listCertificatesByStaffId(staffId: staffId)
.execute();
return response.data.certificates.map((
dc.ListCertificatesByStaffIdCertificates cert,
) {
return domain.StaffCertificate(
id: cert.id,
staffId: cert.staffId,
name: cert.name,
description: cert.description,
expiryDate: _service.toDateTime(cert.expiry),
status: _mapToDomainCertificateStatus(cert.status),
certificateUrl: cert.fileUrl,
icon: cert.icon,
certificationType: _mapToDomainComplianceType(cert.certificationType),
issuer: cert.issuer,
certificateNumber: cert.certificateNumber,
validationStatus: _mapToDomainValidationStatus(cert.validationStatus),
createdAt: _service.toDateTime(cert.createdAt),
);
}).toList();
});
}
@override
Future<void> upsertStaffCertificate({
required domain.ComplianceType certificationType,
required String name,
required domain.StaffCertificateStatus status,
String? fileUrl,
DateTime? expiry,
String? issuer,
String? certificateNumber,
domain.StaffCertificateValidationStatus? validationStatus,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
await _service.connector
.upsertStaffCertificate(
staffId: staffId,
certificationType: _mapToDCComplianceType(certificationType),
name: name,
status: _mapToDCCertificateStatus(status),
)
.fileUrl(fileUrl)
.expiry(_service.tryToTimestamp(expiry))
.issuer(issuer)
.certificateNumber(certificateNumber)
.validationStatus(_mapToDCValidationStatus(validationStatus))
.execute();
});
}
@override
Future<void> deleteStaffCertificate({
required domain.ComplianceType certificationType,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
await _service.connector
.deleteCertificate(
staffId: staffId,
certificationType: _mapToDCComplianceType(certificationType),
)
.execute();
});
}
domain.StaffCertificateStatus _mapToDomainCertificateStatus(
dc.EnumValue<dc.CertificateStatus> status,
) {
if (status is dc.Unknown) return domain.StaffCertificateStatus.notStarted;
final dc.CertificateStatus value =
(status as dc.Known<dc.CertificateStatus>).value;
switch (value) {
case dc.CertificateStatus.CURRENT:
return domain.StaffCertificateStatus.current;
case dc.CertificateStatus.EXPIRING_SOON:
return domain.StaffCertificateStatus.expiringSoon;
case dc.CertificateStatus.COMPLETED:
return domain.StaffCertificateStatus.completed;
case dc.CertificateStatus.PENDING:
return domain.StaffCertificateStatus.pending;
case dc.CertificateStatus.EXPIRED:
return domain.StaffCertificateStatus.expired;
case dc.CertificateStatus.EXPIRING:
return domain.StaffCertificateStatus.expiring;
case dc.CertificateStatus.NOT_STARTED:
return domain.StaffCertificateStatus.notStarted;
}
}
dc.CertificateStatus _mapToDCCertificateStatus(
domain.StaffCertificateStatus status,
) {
switch (status) {
case domain.StaffCertificateStatus.current:
return dc.CertificateStatus.CURRENT;
case domain.StaffCertificateStatus.expiringSoon:
return dc.CertificateStatus.EXPIRING_SOON;
case domain.StaffCertificateStatus.completed:
return dc.CertificateStatus.COMPLETED;
case domain.StaffCertificateStatus.pending:
return dc.CertificateStatus.PENDING;
case domain.StaffCertificateStatus.expired:
return dc.CertificateStatus.EXPIRED;
case domain.StaffCertificateStatus.expiring:
return dc.CertificateStatus.EXPIRING;
case domain.StaffCertificateStatus.notStarted:
return dc.CertificateStatus.NOT_STARTED;
}
}
domain.ComplianceType _mapToDomainComplianceType(
dc.EnumValue<dc.ComplianceType> type,
) {
if (type is dc.Unknown) return domain.ComplianceType.other;
final dc.ComplianceType value = (type as dc.Known<dc.ComplianceType>).value;
switch (value) {
case dc.ComplianceType.BACKGROUND_CHECK:
return domain.ComplianceType.backgroundCheck;
case dc.ComplianceType.FOOD_HANDLER:
return domain.ComplianceType.foodHandler;
case dc.ComplianceType.RBS:
return domain.ComplianceType.rbs;
case dc.ComplianceType.LEGAL:
return domain.ComplianceType.legal;
case dc.ComplianceType.OPERATIONAL:
return domain.ComplianceType.operational;
case dc.ComplianceType.SAFETY:
return domain.ComplianceType.safety;
case dc.ComplianceType.TRAINING:
return domain.ComplianceType.training;
case dc.ComplianceType.LICENSE:
return domain.ComplianceType.license;
case dc.ComplianceType.OTHER:
return domain.ComplianceType.other;
}
}
dc.ComplianceType _mapToDCComplianceType(domain.ComplianceType type) {
switch (type) {
case domain.ComplianceType.backgroundCheck:
return dc.ComplianceType.BACKGROUND_CHECK;
case domain.ComplianceType.foodHandler:
return dc.ComplianceType.FOOD_HANDLER;
case domain.ComplianceType.rbs:
return dc.ComplianceType.RBS;
case domain.ComplianceType.legal:
return dc.ComplianceType.LEGAL;
case domain.ComplianceType.operational:
return dc.ComplianceType.OPERATIONAL;
case domain.ComplianceType.safety:
return dc.ComplianceType.SAFETY;
case domain.ComplianceType.training:
return dc.ComplianceType.TRAINING;
case domain.ComplianceType.license:
return dc.ComplianceType.LICENSE;
case domain.ComplianceType.other:
return dc.ComplianceType.OTHER;
}
}
domain.StaffCertificateValidationStatus? _mapToDomainValidationStatus(
dc.EnumValue<dc.ValidationStatus>? status,
) {
if (status == null || status is dc.Unknown) return null;
final dc.ValidationStatus value =
(status as dc.Known<dc.ValidationStatus>).value;
switch (value) {
case dc.ValidationStatus.APPROVED:
return domain.StaffCertificateValidationStatus.approved;
case dc.ValidationStatus.PENDING_EXPERT_REVIEW:
return domain.StaffCertificateValidationStatus.pendingExpertReview;
case dc.ValidationStatus.REJECTED:
return domain.StaffCertificateValidationStatus.rejected;
case dc.ValidationStatus.AI_VERIFIED:
return domain.StaffCertificateValidationStatus.aiVerified;
case dc.ValidationStatus.AI_FLAGGED:
return domain.StaffCertificateValidationStatus.aiFlagged;
case dc.ValidationStatus.MANUAL_REVIEW_NEEDED:
return domain.StaffCertificateValidationStatus.manualReviewNeeded;
}
}
dc.ValidationStatus? _mapToDCValidationStatus(
domain.StaffCertificateValidationStatus? status,
) {
if (status == null) return null;
switch (status) {
case domain.StaffCertificateValidationStatus.approved:
return dc.ValidationStatus.APPROVED;
case domain.StaffCertificateValidationStatus.pendingExpertReview:
return dc.ValidationStatus.PENDING_EXPERT_REVIEW;
case domain.StaffCertificateValidationStatus.rejected:
return dc.ValidationStatus.REJECTED;
case domain.StaffCertificateValidationStatus.aiVerified:
return dc.ValidationStatus.AI_VERIFIED;
case domain.StaffCertificateValidationStatus.aiFlagged:
return dc.ValidationStatus.AI_FLAGGED;
case domain.StaffCertificateValidationStatus.manualReviewNeeded:
return dc.ValidationStatus.MANUAL_REVIEW_NEEDED;
}
}
}

View File

@@ -83,4 +83,24 @@ abstract interface class StaffConnectorRepository {
DocumentStatus? status,
String? verificationId,
});
/// Fetches the staff certificates for the current authenticated user.
Future<List<StaffCertificate>> getStaffCertificates();
/// Upserts staff certificate information.
Future<void> upsertStaffCertificate({
required ComplianceType certificationType,
required String name,
required StaffCertificateStatus status,
String? fileUrl,
DateTime? expiry,
String? issuer,
String? certificateNumber,
StaffCertificateValidationStatus? validationStatus,
});
/// Deletes a staff certificate.
Future<void> deleteStaffCertificate({
required ComplianceType certificationType,
});
}

View File

@@ -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';

View File

@@ -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,
);
}
}

View File

@@ -0,0 +1,120 @@
import 'package:equatable/equatable.dart';
import 'compliance_type.dart';
import 'staff_certificate_status.dart';
import 'staff_certificate_validation_status.dart';
/// Represents a staff's compliance certificate record.
class StaffCertificate extends Equatable {
const StaffCertificate({
required this.id,
required this.staffId,
required this.name,
this.description,
this.expiryDate,
required this.status,
this.certificateUrl,
this.icon,
required this.certificationType,
this.issuer,
this.certificateNumber,
this.validationStatus,
this.createdAt,
this.updatedAt,
});
/// The unique identifier of the certificate record.
final String id;
/// The ID of the staff member.
final String staffId;
/// The display name of the certificate.
final String name;
/// A description or details about the certificate.
final String? description;
/// The expiration date of the certificate.
final DateTime? expiryDate;
/// The current state of the certificate.
final StaffCertificateStatus status;
/// The URL of the stored certificate file/image.
final String? certificateUrl;
/// An icon to display for this certificate type.
final String? icon;
/// The category of compliance this certificate fits into.
final ComplianceType certificationType;
/// The issuing body or authority.
final String? issuer;
/// Document number or reference.
final String? certificateNumber;
/// Recent validation/verification results.
final StaffCertificateValidationStatus? validationStatus;
/// Creation timestamp.
final DateTime? createdAt;
/// Last update timestamp.
final DateTime? updatedAt;
@override
List<Object?> get props => <Object?>[
id,
staffId,
name,
description,
expiryDate,
status,
certificateUrl,
icon,
certificationType,
issuer,
certificateNumber,
validationStatus,
createdAt,
updatedAt,
];
/// Creates a copy of this [StaffCertificate] with updated fields.
StaffCertificate copyWith({
String? id,
String? staffId,
String? name,
String? description,
DateTime? expiryDate,
StaffCertificateStatus? status,
String? certificateUrl,
String? icon,
ComplianceType? certificationType,
String? issuer,
String? certificateNumber,
StaffCertificateValidationStatus? validationStatus,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return StaffCertificate(
id: id ?? this.id,
staffId: staffId ?? this.staffId,
name: name ?? this.name,
description: description ?? this.description,
expiryDate: expiryDate ?? this.expiryDate,
status: status ?? this.status,
certificateUrl: certificateUrl ?? this.certificateUrl,
icon: icon ?? this.icon,
certificationType: certificationType ?? this.certificationType,
issuer: issuer ?? this.issuer,
certificateNumber: certificateNumber ?? this.certificateNumber,
validationStatus: validationStatus ?? this.validationStatus,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -0,0 +1,220 @@
# Certificate Upload & Verification Workflow
This document outlines the standardized workflow for handling certificate uploads, metadata capture, and persistence within the Krow mobile application. The certificates module follows the same layered architecture as the `documents` module (see `documents/IMPLEMENTATION_WORKFLOW.md`), with key differences to accommodate certificate-specific metadata (expiry date, issuer, certificate number, type).
## 1. Overview
The workflow follows a 4-step lifecycle:
1. **Form Entry**: The user fills in certificate metadata (name, type, issuer, certificate number, expiry date).
2. **File Selection**: Picking a PDF, JPG, or PNG file locally via `FilePickerService`.
3. **Attestation**: Requiring the user to confirm the certificate is genuine before submission.
4. **Upload & Persistence**: Pushing the file to storage, initiating background verification, and saving the record to the database via Data Connect.
---
## 2. Technical Stack
| Service | Responsibility |
|---------|----------------|
| `FilePickerService` | PDF/image file selection from device |
| `FileUploadService` | Uploads raw files to secure cloud storage |
| `SignedUrlService` | Generates secure internal links for verification access |
| `VerificationService` | Orchestrates AI or manual verification (category: `CERTIFICATE`) |
| `DataConnect` (Firebase) | Persists structured data and verification metadata via `upsertStaffCertificate` |
---
## 3. Key Difference vs. Documents Module
| Aspect | Documents | Certificates |
|--------|-----------|--------------|
| Metadata captured | None (just a file) | `name`, `type` (`ComplianceType`), `expiryDate`, `issuer`, `certificateNumber` |
| Accepted file types | `pdf` only | `pdf`, `jpg`, `png` |
| Verification category | `DOCUMENT` | `CERTIFICATE` |
| Domain entity | `StaffDocument` | `StaffCertificate` |
| Status enum | `DocumentStatus` | `StaffCertificateStatus` |
| Validation status | `DocumentVerificationStatus` | `StaffCertificateValidationStatus` |
| Repository | `DocumentsRepository` | `CertificatesRepository` |
| Data Connect method | `upsertStaffDocument` | `upsertStaffCertificate` |
| Navigator helper | `StaffNavigator.toDocumentUpload` | `StaffNavigator.toCertificateUpload` |
---
## 4. Implementation Status
### ✅ Completed — Presentation Layer
#### Routing
- `StaffPaths.certificateUpload` constant used in `StaffPaths.childRoute` within `StaffCertificatesModule`.
- `StaffNavigator.toCertificateUpload({StaffCertificate? certificate})` type-safe navigation helper wires the route argument.
#### Domain Layer
- `CertificatesRepository` interface defined with three methods:
- `getCertificates()` — fetch the current staff member's certificates.
- `uploadCertificate(...)` — upload file + trigger verification + persist record.
- `upsertCertificate(...)` — metadata-only update (no file re-upload).
- `deleteCertificate(...)` — remove a certificate by `ComplianceType`.
- `UploadCertificateUseCase` wraps `uploadCertificate`; takes `UploadCertificateParams`:
- `certificationType` (`ComplianceType`) — required
- `name` (`String`) — required
- `filePath` (`String`) — required
- `expiryDate` (`DateTime?`) — optional
- `issuer` (`String?`) — optional
- `certificateNumber` (`String?`) — optional
- `GetCertificatesUseCase` wraps `getCertificates()`.
- `UpsertCertificateUseCase` wraps `upsertCertificate()` for metadata-only saves.
- `DeleteCertificateUseCase` wraps `deleteCertificate()`.
#### State Management
- `CertificateUploadStatus` enum: `initial | uploading | success | failure`
- `CertificateUploadState` (Equatable): tracks `status`, `isAttested`, `updatedCertificate`, `errorMessage`
- `CertificateUploadCubit`:
- Guards upload behind `state.isAttested == true`.
- On success: emits `success` with the returned `StaffCertificate`.
- On failure: emits `failure` with the error message key via `BlocErrorHandler`.
#### UI — `CertificateUploadPage`
Accepts an optional `StaffCertificate? certificate` as a route argument. When provided, the form is pre-populated for editing; when `null`, the page is in "new certificate" mode.
**Form fields:**
- **Certificate Name** (`TextEditingController _nameController`) — required.
- **Certificate Type** (`ComplianceType? _selectedType`) — `DropdownButton`, defaults to `ComplianceType.other`.
- **Issuer** (`TextEditingController _issuerController`) — optional.
- **Certificate Number** (`TextEditingController _numberController`) — optional.
- **Expiry Date** (`DateTime? _selectedExpiryDate`) — date picker; defaults to 1 year from today.
**File selection:**
- `FilePickerService.pickFile(allowedExtensions: ['pdf', 'jpg', 'png'])`.
- Selected file path stored in `String? _selectedFilePath`.
**Attestation & submission:**
- Attestation checkbox must be checked before submitting (mirrors Documents pattern).
- Submit button enabled only when: a file is selected AND attestation is checked.
- Loading state: `CircularProgressIndicator` replaces the submit button while `CertificateUploadStatus.uploading`.
- On `success`: shows `UiSnackbar` (success type) and calls `Modular.to.pop()`.
- On `failure`: shows `UiSnackbar` (error type); stays on page for retry.
**App bar:**
- `UiAppBar` with `certificate?.name ?? t.staff_certificates.upload_modal.title` as title.
#### UI Guidelines (Consistent with Documents)
Follow the same patterns defined in `documents/IMPLEMENTATION_WORKFLOW.md §3`:
1. **Header & Instructions**: `UiAppBar` title = certificate name (or modal title). Body instructions use `UiTypography.body1m.textPrimary`.
2. **File Selection Card**:
- Empty: neutral/primary bordered card inviting file pick.
- Selected: `UiColors.bgPopup` card with `UiConstants.radiusLg` rounding, `UiColors.primary` border, truncated file name, and explicit "Replace" action.
3. **Bottom Footer / Attestation**: Fixed to `bottomNavigationBar` inside `SafeArea` + `Padding`; submit state tightly coupled to both file presence and attestation.
#### Module Wiring — `StaffCertificatesModule`
Binds (all lazy singletons unless noted):
| Binding | Scope |
|---------|-------|
| `CertificatesRepository``CertificatesRepositoryImpl` | Lazy singleton |
| `GetCertificatesUseCase` | Lazy singleton |
| `DeleteCertificateUseCase` | Lazy singleton |
| `UpsertCertificateUseCase` | Lazy singleton |
| `UploadCertificateUseCase` | Lazy singleton |
| `CertificatesCubit` | Lazy singleton |
| `CertificateUploadCubit` | **Per-use** (non-singleton via `i.add<>`) |
Routes:
- `StaffPaths.childRoute(StaffPaths.certificates, StaffPaths.certificates)``CertificatesPage`
- `StaffPaths.childRoute(StaffPaths.certificates, StaffPaths.certificateUpload)``CertificateUploadPage(certificate: r.args.data is StaffCertificate ? … : null)`
#### `CertificatesPage` Integration
- `CertificateCard.onTap` navigates to `CertificateUploadPage` with the `StaffCertificate` as route data.
- After returning from the upload page, `loadCertificates()` is awaited to refresh the list (fixes `unawaited_futures` lint).
#### Localization
- Keys live under `staff_certificates.*` in `en.i18n.json` and `es.i18n.json`.
- Relevant keys: `upload_modal.title`, `upload_modal.success_snackbar`, `upload_modal.name_label`, `upload_modal.replace`, `error_loading`.
- Codegen: `dart run slang` generates `TranslationsStaffCertificatesEn` and its Spanish counterpart.
---
### ✅ Completed — Data Layer
#### Data Connect Integration
- `StaffConnectorRepository` interface includes `getStaffCertificates()` and `upsertStaffCertificate()`.
- `deleteStaffCertificate(certificationType:)` implemented for certificate removal.
- SDK regenerated via: `make dataconnect-generate-sdk ENV=dev`.
#### Repository Implementation — `CertificatesRepositoryImpl`
Constructor injects `FileUploadService`, `SignedUrlService`, `VerificationService`; uses `DataConnectService.instance` for Data Connect calls.
**`uploadCertificate()` — 5-step orchestration:**
```
1. FileUploadService.uploadFile(...)
→ fileName: 'staff_cert_<type>_<timestamp>.pdf'
→ visibility: FileVisibility.private
Returns FileUploadResponse { fileUri }
2. SignedUrlService.createSignedUrl(fileUri: uploadRes.fileUri)
→ Generates an internal signed link for verification access
3. VerificationService.createVerification(...)
→ fileUri, type: certificationType.value
→ category: 'CERTIFICATE' ← distinguishes from DOCUMENT
→ subjectType: 'STAFF'
→ subjectId: await _service.getStaffId()
4. StaffConnectorRepository.upsertStaffCertificate(...)
→ certificationType, name, status: pending
→ fileUrl: uploadRes.fileUri, expiry, issuer, certificateNumber
→ validationStatus: pendingExpertReview
5. getCertificates() → find & return the matching StaffCertificate
```
**`upsertCertificate()` — metadata-only update:**
- Directly calls `upsertStaffCertificate(...)` without any file upload or verification step.
- Used for editing certificate details after initial upload.
**`deleteCertificate()` — record removal:**
- Calls `deleteStaffCertificate(certificationType:)`.
---
## 5. State Management Reference
```
CertificateUploadStatus
├── initial — page just opened / form being filled
├── uploading — upload + verification in progress
├── success — certificate saved; navigate back (pop)
└── failure — error; stay on page; show snackbar
```
**Cubit guards:**
- Upload is blocked unless `state.isAttested == true`.
- Submit button enabled only when both a file is selected AND attestation is checked.
- Uses `BlocErrorHandler` mixin for consistent error emission.
---
## 6. StaffCertificateStatus Mapping Reference
The backend uses a richer enum than the domain layer. Standard mapping:
| Backend / Validation Status | Domain `StaffCertificateStatus` | Notes |
|-----------------------------|----------------------------------|-------|
| `VERIFIED` / `AUTO_PASS` / `APPROVED` | `verified` | Fully or AI-approved |
| `UPLOADED` / `PENDING` / `PROCESSING` / `NEEDS_REVIEW` / `EXPIRING` | `pending` | Upload received; processing or awaiting renewal |
| `AUTO_FAIL` / `REJECTED` / `ERROR` | `rejected` | AI or manual rejection / system error |
| `MISSING` | `missing` | Not yet uploaded |
`StaffCertificateValidationStatus` preserves the full backend granularity for detailed UI feedback (e.g., showing "Pending Expert Review" vs. "Auto-Failed").
---
## 7. Future Considerations
1. **Expiry Notifications**: Certificates approaching expiry (domain status `pending` / backend `EXPIRING`) should surface a nudge in `CertificatesPage` or via push notification.
2. **Re-verification on Edit**: When a certificate's file is replaced via `uploadCertificate`, a new verification job is triggered. Decide whether editing only metadata (via `upsertCertificate`) should also trigger re-verification.
3. **Multiple Files per Certificate**: The current schema supports a single `fileUrl`. If multi-page certificates become a requirement, the Data Connect schema and `CertificatesRepository` interface will need extending.
4. **Shared Compliance UI Components**: The file selection card and attestation footer patterns are duplicated between `DocumentUploadPage` and `CertificateUploadPage`. Consider extracting them into a shared `compliance_upload_widgets` package to reduce duplication.

View File

@@ -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<List<domain.StaffDocument>> getCertificates() async {
Future<List<domain.StaffCertificate>> getCertificates() async {
return _service.getStaffRepository().getStaffCertificates();
}
@override
Future<domain.StaffCertificate> uploadCertificate({
required domain.ComplianceType certificationType,
required String name,
required String filePath,
DateTime? expiryDate,
String? issuer,
String? certificateNumber,
}) async {
return _service.run(() async {
// 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName:
'staff_cert_${certificationType.name}_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: domain.FileVisibility.private,
);
// 2. Generate a signed URL for verification service to access the file
// Wait, verification service might need this or just the URI.
// Following DocumentRepository behavior:
await _signedUrlService.createSignedUrl(fileUri: uploadRes.fileUri);
// 3. Initiate verification
// 3. Initiate verification
final String staffId = await _service.getStaffId();
await _verificationService.createVerification(
fileUri: uploadRes.fileUri,
type: certificationType.value,
category: 'CERTIFICATE',
subjectType: 'STAFF',
subjectId: staffId,
);
// Execute the query via DataConnect generated SDK
final QueryResult<
ListStaffDocumentsByStaffIdData,
ListStaffDocumentsByStaffIdVariables
>
result = await _service.connector
.listStaffDocumentsByStaffId(staffId: staffId)
.execute();
// 4. Update/Create Certificate in Data Connect
await _service.getStaffRepository().upsertStaffCertificate(
certificationType: certificationType,
name: name,
status: domain.StaffCertificateStatus.pending,
fileUrl: uploadRes.fileUri,
expiry: expiryDate,
issuer: issuer,
certificateNumber: certificateNumber,
validationStatus:
domain.StaffCertificateValidationStatus.pendingExpertReview,
);
// Map the generated SDK types to pure Domain entities
return result.data.staffDocuments
.map(
(ListStaffDocumentsByStaffIdStaffDocuments doc) =>
_mapToDomain(doc),
)
.toList();
// 5. Return updated list or the specific certificate
final List<domain.StaffCertificate> certificates =
await getCertificates();
return certificates.firstWhere(
(domain.StaffCertificate c) => c.certificationType == certificationType,
);
});
}
/// Maps the Data Connect [ListStaffDocumentsByStaffIdStaffDocuments] to a domain [domain.StaffDocument].
domain.StaffDocument _mapToDomain(
ListStaffDocumentsByStaffIdStaffDocuments doc,
) {
return domain.StaffDocument(
id: doc.id,
staffId: doc.staffId,
documentId: doc.documentId,
name: doc.document.name,
description: null, // Description not available in this query response
status: _mapStatus(doc.status),
documentUrl: doc.documentUrl,
expiryDate: doc.expiryDate == null
? null
: DateTimeUtils.toDeviceTime(doc.expiryDate!.toDateTime()),
@override
Future<void> upsertCertificate({
required domain.ComplianceType certificationType,
required String name,
required domain.StaffCertificateStatus status,
String? fileUrl,
DateTime? expiry,
String? issuer,
String? certificateNumber,
domain.StaffCertificateValidationStatus? validationStatus,
}) async {
await _service.getStaffRepository().upsertStaffCertificate(
certificationType: certificationType,
name: name,
status: status,
fileUrl: fileUrl,
expiry: expiry,
issuer: issuer,
certificateNumber: certificateNumber,
validationStatus: validationStatus,
);
}
/// Maps the Data Connect [DocumentStatus] enum to the domain [domain.DocumentStatus].
domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) {
if (status is Known<DocumentStatus>) {
switch (status.value) {
case DocumentStatus.VERIFIED:
return domain.DocumentStatus.verified;
case DocumentStatus.PENDING:
return domain.DocumentStatus.pending;
case DocumentStatus.MISSING:
return domain.DocumentStatus.missing;
case DocumentStatus.UPLOADED:
return domain.DocumentStatus.pending;
case DocumentStatus.EXPIRING:
// 'EXPIRING' in backend is treated as 'verified' in domain,
// as the document is strictly valid until the expiry date.
return domain.DocumentStatus.verified;
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<void> deleteCertificate({
required domain.ComplianceType certificationType,
}) async {
return _service.getStaffRepository().deleteStaffCertificate(
certificationType: certificationType,
);
}
}

View File

@@ -7,6 +7,31 @@ import 'package:krow_domain/krow_domain.dart';
abstract interface class CertificatesRepository {
/// Fetches the list of compliance certificates for the current staff member.
///
/// Returns a list of [StaffDocument] entities.
Future<List<StaffDocument>> getCertificates();
/// Returns a list of [StaffCertificate] entities.
Future<List<StaffCertificate>> getCertificates();
/// Uploads a certificate file and saves the record.
Future<StaffCertificate> uploadCertificate({
required ComplianceType certificationType,
required String name,
required String filePath,
DateTime? expiryDate,
String? issuer,
String? certificateNumber,
});
/// Deletes a staff certificate.
Future<void> deleteCertificate({required ComplianceType certificationType});
/// Upserts a certificate record (metadata only).
Future<void> upsertCertificate({
required ComplianceType certificationType,
required String name,
required StaffCertificateStatus status,
String? fileUrl,
DateTime? expiry,
String? issuer,
String? certificateNumber,
StaffCertificateValidationStatus? validationStatus,
});
}

View File

@@ -0,0 +1,15 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/certificates_repository.dart';
/// Use case for deleting a staff compliance certificate.
class DeleteCertificateUseCase extends UseCase<ComplianceType, void> {
/// Creates a [DeleteCertificateUseCase].
DeleteCertificateUseCase(this._repository);
final CertificatesRepository _repository;
@override
Future<void> call(ComplianceType certificationType) {
return _repository.deleteCertificate(certificationType: certificationType);
}
}

View File

@@ -6,8 +6,7 @@ import '../repositories/certificates_repository.dart';
///
/// Delegates the data retrieval to the [CertificatesRepository].
/// Follows the strict one-to-one mapping between action and use case.
class GetCertificatesUseCase extends NoInputUseCase<List<StaffDocument>> {
class GetCertificatesUseCase extends NoInputUseCase<List<StaffCertificate>> {
/// Creates a [GetCertificatesUseCase].
///
/// Requires a [CertificatesRepository] to access the certificates data source.
@@ -15,7 +14,7 @@ class GetCertificatesUseCase extends NoInputUseCase<List<StaffDocument>> {
final CertificatesRepository _repository;
@override
Future<List<StaffDocument>> call() {
Future<List<StaffCertificate>> call() {
return _repository.getCertificates();
}
}

View File

@@ -0,0 +1,54 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/certificates_repository.dart';
/// Use case for uploading a staff compliance certificate.
class UploadCertificateUseCase
extends UseCase<UploadCertificateParams, StaffCertificate> {
/// Creates an [UploadCertificateUseCase].
UploadCertificateUseCase(this._repository);
final CertificatesRepository _repository;
@override
Future<StaffCertificate> call(UploadCertificateParams params) {
return _repository.uploadCertificate(
certificationType: params.certificationType,
name: params.name,
filePath: params.filePath,
expiryDate: params.expiryDate,
issuer: params.issuer,
certificateNumber: params.certificateNumber,
);
}
}
/// Parameters for [UploadCertificateUseCase].
class UploadCertificateParams {
/// Creates [UploadCertificateParams].
UploadCertificateParams({
required this.certificationType,
required this.name,
required this.filePath,
this.expiryDate,
this.issuer,
this.certificateNumber,
});
/// The type of certification.
final ComplianceType certificationType;
/// The name of the certificate.
final String name;
/// The local file path to upload.
final String filePath;
/// The expiry date of the certificate.
final DateTime? expiryDate;
/// The issuer of the certificate.
final String? issuer;
/// The certificate number.
final String? certificateNumber;
}

View File

@@ -0,0 +1,63 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/certificates_repository.dart';
/// Use case for upserting a staff compliance certificate.
class UpsertCertificateUseCase extends UseCase<UpsertCertificateParams, void> {
/// Creates an [UpsertCertificateUseCase].
UpsertCertificateUseCase(this._repository);
final CertificatesRepository _repository;
@override
Future<void> call(UpsertCertificateParams params) {
return _repository.upsertCertificate(
certificationType: params.certificationType,
name: params.name,
status: params.status,
fileUrl: params.fileUrl,
expiry: params.expiry,
issuer: params.issuer,
certificateNumber: params.certificateNumber,
validationStatus: params.validationStatus,
);
}
}
/// Parameters for [UpsertCertificateUseCase].
class UpsertCertificateParams {
/// Creates [UpsertCertificateParams].
UpsertCertificateParams({
required this.certificationType,
required this.name,
required this.status,
this.fileUrl,
this.expiry,
this.issuer,
this.certificateNumber,
this.validationStatus,
});
/// The type of certification.
final ComplianceType certificationType;
/// The name of the certificate.
final String name;
/// The status of the certificate.
final StaffCertificateStatus status;
/// The URL of the certificate file.
final String? fileUrl;
/// The expiry date of the certificate.
final DateTime? expiry;
/// The issuer of the certificate.
final String? issuer;
/// The certificate number.
final String? certificateNumber;
/// The validation status of the certificate.
final StaffCertificateValidationStatus? validationStatus;
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../../domain/usecases/upload_certificate_usecase.dart';
import 'certificate_upload_state.dart';
class CertificateUploadCubit extends Cubit<CertificateUploadState>
with BlocErrorHandler<CertificateUploadState> {
CertificateUploadCubit(this._uploadCertificateUseCase)
: super(const CertificateUploadState());
final UploadCertificateUseCase _uploadCertificateUseCase;
void setAttested(bool value) {
emit(state.copyWith(isAttested: value));
}
Future<void> uploadCertificate(UploadCertificateParams params) async {
if (!state.isAttested) return;
emit(state.copyWith(status: CertificateUploadStatus.uploading));
await handleError(
emit: emit,
action: () async {
final StaffCertificate certificate = await _uploadCertificateUseCase(
params,
);
emit(
state.copyWith(
status: CertificateUploadStatus.success,
updatedCertificate: certificate,
),
);
},
onError: (String errorKey) => state.copyWith(
status: CertificateUploadStatus.failure,
errorMessage: errorKey,
),
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
enum CertificateUploadStatus { initial, uploading, success, failure }
class CertificateUploadState extends Equatable {
const CertificateUploadState({
this.status = CertificateUploadStatus.initial,
this.isAttested = false,
this.updatedCertificate,
this.errorMessage,
});
final CertificateUploadStatus status;
final bool isAttested;
final StaffCertificate? updatedCertificate;
final String? errorMessage;
CertificateUploadState copyWith({
CertificateUploadStatus? status,
bool? isAttested,
StaffCertificate? updatedCertificate,
String? errorMessage,
}) {
return CertificateUploadState(
status: status ?? this.status,
isAttested: isAttested ?? this.isAttested,
updatedCertificate: updatedCertificate ?? this.updatedCertificate,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => <Object?>[
status,
isAttested,
updatedCertificate,
errorMessage,
];
}

View File

@@ -2,23 +2,27 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../../domain/usecases/get_certificates_usecase.dart';
import '../../../domain/usecases/delete_certificate_usecase.dart';
import 'certificates_state.dart';
class CertificatesCubit extends Cubit<CertificatesState>
with BlocErrorHandler<CertificatesState> {
CertificatesCubit(this._getCertificatesUseCase)
: super(const CertificatesState()) {
CertificatesCubit(
this._getCertificatesUseCase,
this._deleteCertificateUseCase,
) : super(const CertificatesState()) {
loadCertificates();
}
final GetCertificatesUseCase _getCertificatesUseCase;
final DeleteCertificateUseCase _deleteCertificateUseCase;
Future<void> loadCertificates() async {
emit(state.copyWith(status: CertificatesStatus.loading));
await handleError(
emit: emit,
action: () async {
final List<StaffDocument> certificates =
final List<StaffCertificate> certificates =
await _getCertificatesUseCase();
emit(
state.copyWith(
@@ -27,12 +31,25 @@ class CertificatesCubit extends Cubit<CertificatesState>
),
);
},
onError:
(String errorKey) => state.copyWith(
onError: (String errorKey) => state.copyWith(
status: CertificatesStatus.failure,
errorMessage: errorKey,
),
);
}
Future<void> deleteCertificate(ComplianceType type) async {
emit(state.copyWith(status: CertificatesStatus.loading));
await handleError(
emit: emit,
action: () async {
await _deleteCertificateUseCase(type);
await loadCertificates();
},
onError: (String errorKey) => state.copyWith(
status: CertificatesStatus.failure,
errorMessage: errorKey,
),
);
}
}

View File

@@ -4,19 +4,19 @@ import 'package:krow_domain/krow_domain.dart';
enum CertificatesStatus { initial, loading, success, failure }
class CertificatesState extends Equatable {
const CertificatesState({
this.status = CertificatesStatus.initial,
List<StaffDocument>? certificates,
List<StaffCertificate>? certificates,
this.errorMessage,
}) : certificates = certificates ?? const <StaffDocument>[];
}) : certificates = certificates ?? const <StaffCertificate>[];
final CertificatesStatus status;
final List<StaffDocument> certificates;
final List<StaffCertificate> certificates;
final String? errorMessage;
CertificatesState copyWith({
CertificatesStatus? status,
List<StaffDocument>? certificates,
List<StaffCertificate>? certificates,
String? errorMessage,
}) {
return CertificatesState(
@@ -31,7 +31,10 @@ class CertificatesState extends Equatable {
/// The number of verified certificates.
int get completedCount => certificates
.where((StaffDocument doc) => doc.status == DocumentStatus.verified)
.where(
(StaffCertificate cert) =>
cert.validationStatus == StaffCertificateValidationStatus.approved,
)
.length;
/// The total number of certificates.

View File

@@ -0,0 +1,361 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
import 'package:intl/intl.dart';
import '../blocs/certificate_upload/certificate_upload_cubit.dart';
import '../blocs/certificate_upload/certificate_upload_state.dart';
import '../../domain/usecases/upload_certificate_usecase.dart';
/// Page for uploading a certificate with metadata (expiry, issuer, etc).
class CertificateUploadPage extends StatefulWidget {
const CertificateUploadPage({super.key, this.certificate});
/// The certificate being edited, or null for a new one.
final StaffCertificate? certificate;
@override
State<CertificateUploadPage> createState() => _CertificateUploadPageState();
}
class _CertificateUploadPageState extends State<CertificateUploadPage> {
String? _selectedFilePath;
DateTime? _selectedExpiryDate;
final TextEditingController _issuerController = TextEditingController();
final TextEditingController _numberController = TextEditingController();
final TextEditingController _nameController = TextEditingController();
ComplianceType? _selectedType;
final FilePickerService _filePicker = Modular.get<FilePickerService>();
@override
void initState() {
super.initState();
if (widget.certificate != null) {
_selectedExpiryDate = widget.certificate!.expiryDate;
_issuerController.text = widget.certificate!.issuer ?? '';
_numberController.text = widget.certificate!.certificateNumber ?? '';
_nameController.text = widget.certificate!.name;
_selectedType = widget.certificate!.certificationType;
} else {
_selectedType = ComplianceType.other;
}
}
@override
void dispose() {
_issuerController.dispose();
_numberController.dispose();
_nameController.dispose();
super.dispose();
}
Future<void> _pickFile() async {
final String? path = await _filePicker.pickFile(
allowedExtensions: <String>['pdf', 'jpg', 'png'],
);
if (path != null) {
setState(() {
_selectedFilePath = path;
});
}
}
Future<void> _selectDate() async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate:
_selectedExpiryDate ?? DateTime.now().add(const Duration(days: 365)),
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 3650)),
);
if (picked != null) {
setState(() {
_selectedExpiryDate = picked;
});
}
}
@override
Widget build(BuildContext context) {
return BlocProvider<CertificateUploadCubit>(
create: (BuildContext _) => Modular.get<CertificateUploadCubit>(),
child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>(
listener: (BuildContext context, CertificateUploadState state) {
if (state.status == CertificateUploadStatus.success) {
UiSnackbar.show(
context,
message: t.staff_certificates.upload_modal.success_snackbar,
type: UiSnackbarType.success,
);
Modular.to.pop(); // Returns to certificates list
} else if (state.status == CertificateUploadStatus.failure) {
UiSnackbar.show(
context,
message: state.errorMessage ?? t.staff_certificates.error_loading,
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, CertificateUploadState state) {
return Scaffold(
appBar: UiAppBar(
title:
widget.certificate?.name ??
t.staff_certificates.upload_modal.title,
onLeadingPressed: () => Modular.to.pop(),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t
.staff_documents
.upload
.instructions, // Reusing instructions logic
style: UiTypography.body1m.textPrimary,
),
const SizedBox(height: UiConstants.space6),
// Name Field
Text(
t.staff_certificates.upload_modal.name_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
TextField(
controller: _nameController,
decoration: InputDecoration(
hintText: "e.g. Food Handler Permit",
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
const SizedBox(height: UiConstants.space4),
// Expiry Date Field
Text(
t.staff_certificates.upload_modal.expiry_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
InkWell(
onTap: _selectDate,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
border: Border.all(color: UiColors.border),
borderRadius: UiConstants.radiusLg,
),
child: Row(
children: <Widget>[
const Icon(
UiIcons.calendar,
size: 20,
color: UiColors.textSecondary,
),
const SizedBox(width: UiConstants.space3),
Text(
_selectedExpiryDate != null
? DateFormat(
'MMM dd, yyyy',
).format(_selectedExpiryDate!)
: t.staff_certificates.upload_modal.select_date,
style: _selectedExpiryDate != null
? UiTypography.body1m.textPrimary
: UiTypography.body1m.textSecondary,
),
],
),
),
),
const SizedBox(height: UiConstants.space4),
// Issuer Field
Text(
t.staff_certificates.upload_modal.issuer_label,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
TextField(
controller: _issuerController,
decoration: InputDecoration(
hintText: "e.g. Department of Health",
border: OutlineInputBorder(
borderRadius: UiConstants.radiusLg,
),
),
),
const SizedBox(height: UiConstants.space4),
// File Selector
Text(
t.staff_certificates.upload_modal.upload_file,
style: UiTypography.body2m.textPrimary,
),
const SizedBox(height: UiConstants.space2),
_FileSelector(
selectedFilePath: _selectedFilePath,
onTap: _pickFile,
),
],
),
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// Attestation
Row(
children: <Widget>[
Checkbox(
value: state.isAttested,
onChanged: (bool? val) =>
BlocProvider.of<CertificateUploadCubit>(
context,
).setAttested(val ?? false),
activeColor: UiColors.primary,
),
Expanded(
child: Text(
t.staff_documents.upload.attestation,
style: UiTypography.body3r.textSecondary,
),
),
],
),
const SizedBox(height: UiConstants.space4),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed:
(_selectedFilePath != null &&
state.isAttested &&
_nameController.text.isNotEmpty)
? () =>
BlocProvider.of<CertificateUploadCubit>(
context,
).uploadCertificate(
UploadCertificateParams(
certificationType: _selectedType!,
name: _nameController.text,
filePath: _selectedFilePath!,
expiryDate: _selectedExpiryDate,
issuer: _issuerController.text,
certificateNumber: _numberController.text,
),
)
: null,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
padding: const EdgeInsets.symmetric(
vertical: UiConstants.space4,
),
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusLg,
),
),
child: state.status == CertificateUploadStatus.uploading
? const CircularProgressIndicator(
color: Colors.white,
)
: Text(
t.staff_certificates.upload_modal.save,
style: UiTypography.body1m.white,
),
),
),
],
),
),
),
);
},
),
);
}
}
class _FileSelector extends StatelessWidget {
const _FileSelector({this.selectedFilePath, required this.onTap});
final String? selectedFilePath;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
if (selectedFilePath != null) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.tagActive,
border: Border.all(color: UiColors.primary),
borderRadius: UiConstants.radiusLg,
),
child: Row(
children: <Widget>[
const Icon(UiIcons.file, color: UiColors.primary),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(
selectedFilePath!.split('/').last,
style: UiTypography.body1m.textPrimary,
overflow: TextOverflow.ellipsis,
),
),
Text(
t.staff_documents.upload.replace,
style: UiTypography.body3m.primary,
),
],
),
),
);
}
return InkWell(
onTap: onTap,
child: Container(
height: 120,
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: UiColors.border, style: BorderStyle.solid),
borderRadius: UiConstants.radiusLg,
color: UiColors.background,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(UiIcons.uploadCloud, size: 32, color: UiColors.primary),
const SizedBox(height: UiConstants.space2),
Text(
t.staff_certificates.upload_modal.drag_drop,
style: UiTypography.body2m,
),
Text(
t.staff_certificates.upload_modal.supported_formats,
style: UiTypography.body3r.textSecondary,
),
],
),
),
);
}
}

View File

@@ -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.
@@ -51,7 +51,7 @@ class CertificatesPage extends StatelessWidget {
);
}
final List<StaffDocument> documents = state.certificates;
final List<StaffCertificate> documents = state.certificates;
return Scaffold(
backgroundColor: UiColors.background, // Matches 0xFFF8FAFC
@@ -65,25 +65,32 @@ class CertificatesPage extends StatelessWidget {
Transform.translate(
offset: const Offset(0, -48),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: Column(
children: <Widget>[
...documents.map((StaffDocument doc) => CertificateCard(
document: doc,
onUpload: () => _showUploadModal(context, doc),
onEditExpiry: () => _showEditExpiryDialog(context, doc),
onRemove: () => _showRemoveConfirmation(context, doc),
...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,
message:
t.staff_certificates.card.opened_snackbar,
type: UiSnackbarType.success,
);
},
)),
),
),
const SizedBox(height: UiConstants.space4),
AddCertificateCard(
onTap: () => _showUploadModal(context, null),
onTap: () => _navigateToUpload(context, null),
),
const SizedBox(height: UiConstants.space8),
],
@@ -98,28 +105,29 @@ class CertificatesPage extends StatelessWidget {
);
}
void _showUploadModal(BuildContext context, StaffDocument? document) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) => CertificateUploadModal(
document: document,
onSave: () {
// TODO: Implement upload via Cubit
// Modular.get<CertificatesCubit>().uploadCertificate(...);
Navigator.pop(context);
},
onCancel: () => Navigator.pop(context),
),
Future<void> _navigateToUpload(
BuildContext context,
StaffCertificate? certificate,
) async {
await Modular.to.pushNamed(
StaffPaths.certificateUpload,
arguments: certificate,
);
// Reload certificates after returning from the upload page
await Modular.get<CertificatesCubit>().loadCertificates();
}
void _showEditExpiryDialog(BuildContext context, StaffDocument document) {
_showUploadModal(context, document);
void _showEditExpiryDialog(
BuildContext context,
StaffCertificate certificate,
) {
_navigateToUpload(context, certificate);
}
void _showRemoveConfirmation(BuildContext context, StaffDocument document) {
void _showRemoveConfirmation(
BuildContext context,
StaffCertificate certificate,
) {
showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
@@ -132,8 +140,9 @@ class CertificatesPage extends StatelessWidget {
),
TextButton(
onPressed: () {
// TODO: Implement delete via Cubit
// Modular.get<CertificatesCubit>().deleteCertificate(document.id);
Modular.get<CertificatesCubit>().deleteCertificate(
certificate.certificationType,
);
Navigator.pop(context);
},
style: TextButton.styleFrom(foregroundColor: UiColors.destructive),

View File

@@ -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,7 +73,9 @@ 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(
@@ -78,13 +89,14 @@ class CertificateCard extends StatelessWidget {
Text(
isExpired
? t.staff_certificates.card.expired
: t.staff_certificates.card.expires_in_days(days: _daysUntilExpiry(document.expiryDate)),
: t.staff_certificates.card.expires_in_days(
days: _daysUntilExpiry(certificate.expiryDate),
),
style: UiTypography.body3m.textPrimary,
),
],
),
),
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Row(
@@ -151,12 +163,12 @@ class CertificateCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
document.name,
certificate.name,
style: UiTypography.body1m.textPrimary,
),
const SizedBox(height: 2),
Text(
document.description ?? '', // Optional description
certificate.description ?? '',
style: UiTypography.body3r.textSecondary,
),
],
@@ -170,11 +182,10 @@ class CertificateCard extends StatelessWidget {
],
),
const SizedBox(height: UiConstants.space4),
if (showComplete) _buildCompleteStatus(document.expiryDate),
if (isExpiring || isExpired) _buildExpiringStatus(context, document.expiryDate),
if (showComplete)
_buildCompleteStatus(certificate.expiryDate),
if (isExpiring || isExpired)
_buildExpiringStatus(context, certificate.expiryDate),
if (isNotStarted)
SizedBox(
width: double.infinity,
@@ -207,7 +218,6 @@ class CertificateCard extends StatelessWidget {
),
),
),
if (showComplete || isExpiring || isExpired) ...<Widget>[
const SizedBox(height: UiConstants.space3),
SizedBox(
@@ -281,7 +291,9 @@ class CertificateCard extends StatelessWidget {
),
if (expiryDate != null)
Text(
t.staff_certificates.card.exp(date: DateFormat('MMM d, yyyy').format(expiryDate)),
t.staff_certificates.card.exp(
date: DateFormat('MMM d, yyyy').format(expiryDate),
),
style: UiTypography.body3r.textSecondary,
),
],
@@ -308,9 +320,7 @@ class CertificateCard extends StatelessWidget {
const SizedBox(width: UiConstants.space2),
Text(
t.staff_certificates.card.expiring_soon,
style: UiTypography.body2m.copyWith(
color: UiColors.primary,
),
style: UiTypography.body2m.copyWith(color: UiColors.primary),
),
],
),
@@ -318,7 +328,9 @@ class CertificateCard extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
t.staff_certificates.card.exp(date: DateFormat('MMM d, yyyy').format(expiryDate)),
t.staff_certificates.card.exp(
date: DateFormat('MMM d, yyyy').format(expiryDate),
),
style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
@@ -330,10 +342,7 @@ class CertificateCard extends StatelessWidget {
children: <Widget>[
_buildIconButton(UiIcons.eye, onView),
const SizedBox(width: UiConstants.space2),
_buildSmallOutlineButton(
t.staff_certificates.card.renew,
onUpload,
),
_buildSmallOutlineButton(t.staff_certificates.card.renew, onUpload),
],
),
],
@@ -347,12 +356,9 @@ class CertificateCard extends StatelessWidget {
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: UiColors.transparent,
border: Border.all(
color: UiColors.transparent,
),
),
child: Center(
child: Icon(icon, size: 16, color: UiColors.textSecondary),
@@ -365,10 +371,10 @@ class CertificateCard extends StatelessWidget {
return OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
side: BorderSide(color: UiColors.primary.withValues(alpha: 0.4)), // Primary with opacity
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusFull,
),
side: BorderSide(
color: UiColors.primary.withValues(alpha: 0.4),
), // Primary with opacity
shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusFull),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
minimumSize: const Size(0, 32),
),
@@ -379,30 +385,19 @@ class CertificateCard extends StatelessWidget {
);
}
bool _isExpiring(DateTime? expiry) {
if (expiry == null) return false;
final int days = expiry.difference(DateTime.now()).inDays;
return days >= 0 && days <= 30; // Close to expiry but not expired
}
bool _isExpired(DateTime? expiry) {
if (expiry == null) return false;
return expiry.difference(DateTime.now()).inDays < 0;
}
int _daysUntilExpiry(DateTime? expiry) {
if (expiry == null) return 0;
return expiry.difference(DateTime.now()).inDays;
}
// Mock mapping for UI props based on ID
_CertificateUiProps _getUiProps(String id) {
switch (id) {
case 'background':
_CertificateUiProps _getUiProps(ComplianceType type) {
switch (type) {
case ComplianceType.backgroundCheck:
return _CertificateUiProps(UiIcons.fileCheck, UiColors.primary);
case 'food_handler':
case ComplianceType.foodHandler:
return _CertificateUiProps(UiIcons.utensils, UiColors.primary);
case 'rbs':
case ComplianceType.rbs:
return _CertificateUiProps(UiIcons.wine, UiColors.foreground);
default:
// Default generic icon

View File

@@ -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;

View File

@@ -1,18 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'data/repositories_impl/certificates_repository_impl.dart';
import 'domain/repositories/certificates_repository.dart';
import 'domain/usecases/get_certificates_usecase.dart';
import 'domain/usecases/delete_certificate_usecase.dart';
import 'domain/usecases/upsert_certificate_usecase.dart';
import 'domain/usecases/upload_certificate_usecase.dart';
import 'presentation/blocs/certificates/certificates_cubit.dart';
import 'presentation/blocs/certificate_upload/certificate_upload_cubit.dart';
import 'presentation/pages/certificate_upload_page.dart';
import 'presentation/pages/certificates_page.dart';
class StaffCertificatesModule extends Module {
@override
void binds(Injector i) {
i.addLazySingleton<CertificatesRepository>(CertificatesRepositoryImpl.new);
i.addLazySingleton(GetCertificatesUseCase.new);
i.addLazySingleton(CertificatesCubit.new);
i.addLazySingleton<GetCertificatesUseCase>(GetCertificatesUseCase.new);
i.addLazySingleton<DeleteCertificateUseCase>(DeleteCertificateUseCase.new);
i.addLazySingleton<UpsertCertificateUseCase>(UpsertCertificateUseCase.new);
i.addLazySingleton<UploadCertificateUseCase>(UploadCertificateUseCase.new);
i.addLazySingleton<CertificatesCubit>(CertificatesCubit.new);
i.add<CertificateUploadCubit>(CertificateUploadCubit.new);
}
@override
@@ -21,5 +32,16 @@ class StaffCertificatesModule extends Module {
StaffPaths.childRoute(StaffPaths.certificates, StaffPaths.certificates),
child: (_) => const CertificatesPage(),
);
r.child(
StaffPaths.childRoute(
StaffPaths.certificates,
StaffPaths.certificateUpload,
),
child: (BuildContext context) => CertificateUploadPage(
certificate: r.args.data is StaffCertificate
? r.args.data as StaffCertificate
: null,
),
);
}
}

View File

@@ -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