feat: Implement full certificate management with upload, upsert, delete, and new domain models for staff certificates and their statuses.
This commit is contained in:
@@ -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
|
||||
// ==========================================================================
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/// Represents the broad category of a compliance certificate.
|
||||
enum ComplianceType {
|
||||
backgroundCheck('BACKGROUND_CHECK'),
|
||||
foodHandler('FOOD_HANDLER'),
|
||||
rbs('RBS'),
|
||||
legal('LEGAL'),
|
||||
operational('OPERATIONAL'),
|
||||
safety('SAFETY'),
|
||||
training('TRAINING'),
|
||||
license('LICENSE'),
|
||||
other('OTHER');
|
||||
|
||||
const ComplianceType(this.value);
|
||||
|
||||
/// The string value expected by the backend.
|
||||
final String value;
|
||||
|
||||
/// Creates a [ComplianceType] from a string.
|
||||
static ComplianceType fromString(String value) {
|
||||
return ComplianceType.values.firstWhere(
|
||||
(ComplianceType e) => e.value == value,
|
||||
orElse: () => ComplianceType.other,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'compliance_type.dart';
|
||||
import 'staff_certificate_status.dart';
|
||||
import 'staff_certificate_validation_status.dart';
|
||||
|
||||
/// Represents a staff's compliance certificate record.
|
||||
class StaffCertificate extends Equatable {
|
||||
const StaffCertificate({
|
||||
required this.id,
|
||||
required this.staffId,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.expiryDate,
|
||||
required this.status,
|
||||
this.certificateUrl,
|
||||
this.icon,
|
||||
required this.certificationType,
|
||||
this.issuer,
|
||||
this.certificateNumber,
|
||||
this.validationStatus,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
/// The unique identifier of the certificate record.
|
||||
final String id;
|
||||
|
||||
/// The ID of the staff member.
|
||||
final String staffId;
|
||||
|
||||
/// The display name of the certificate.
|
||||
final String name;
|
||||
|
||||
/// A description or details about the certificate.
|
||||
final String? description;
|
||||
|
||||
/// The expiration date of the certificate.
|
||||
final DateTime? expiryDate;
|
||||
|
||||
/// The current state of the certificate.
|
||||
final StaffCertificateStatus status;
|
||||
|
||||
/// The URL of the stored certificate file/image.
|
||||
final String? certificateUrl;
|
||||
|
||||
/// An icon to display for this certificate type.
|
||||
final String? icon;
|
||||
|
||||
/// The category of compliance this certificate fits into.
|
||||
final ComplianceType certificationType;
|
||||
|
||||
/// The issuing body or authority.
|
||||
final String? issuer;
|
||||
|
||||
/// Document number or reference.
|
||||
final String? certificateNumber;
|
||||
|
||||
/// Recent validation/verification results.
|
||||
final StaffCertificateValidationStatus? validationStatus;
|
||||
|
||||
/// Creation timestamp.
|
||||
final DateTime? createdAt;
|
||||
|
||||
/// Last update timestamp.
|
||||
final DateTime? updatedAt;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
id,
|
||||
staffId,
|
||||
name,
|
||||
description,
|
||||
expiryDate,
|
||||
status,
|
||||
certificateUrl,
|
||||
icon,
|
||||
certificationType,
|
||||
issuer,
|
||||
certificateNumber,
|
||||
validationStatus,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
];
|
||||
|
||||
/// Creates a copy of this [StaffCertificate] with updated fields.
|
||||
StaffCertificate copyWith({
|
||||
String? id,
|
||||
String? staffId,
|
||||
String? name,
|
||||
String? description,
|
||||
DateTime? expiryDate,
|
||||
StaffCertificateStatus? status,
|
||||
String? certificateUrl,
|
||||
String? icon,
|
||||
ComplianceType? certificationType,
|
||||
String? issuer,
|
||||
String? certificateNumber,
|
||||
StaffCertificateValidationStatus? validationStatus,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return StaffCertificate(
|
||||
id: id ?? this.id,
|
||||
staffId: staffId ?? this.staffId,
|
||||
name: name ?? this.name,
|
||||
description: description ?? this.description,
|
||||
expiryDate: expiryDate ?? this.expiryDate,
|
||||
status: status ?? this.status,
|
||||
certificateUrl: certificateUrl ?? this.certificateUrl,
|
||||
icon: icon ?? this.icon,
|
||||
certificationType: certificationType ?? this.certificationType,
|
||||
issuer: issuer ?? this.issuer,
|
||||
certificateNumber: certificateNumber ?? this.certificateNumber,
|
||||
validationStatus: validationStatus ?? this.validationStatus,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/// Represents the current validity status of a staff certificate.
|
||||
enum StaffCertificateStatus {
|
||||
current('CURRENT'),
|
||||
expiringSoon('EXPIRING_SOON'),
|
||||
completed('COMPLETED'),
|
||||
pending('PENDING'),
|
||||
expired('EXPIRED'),
|
||||
expiring('EXPIRING'),
|
||||
notStarted('NOT_STARTED');
|
||||
|
||||
const StaffCertificateStatus(this.value);
|
||||
|
||||
/// The string value expected by the backend.
|
||||
final String value;
|
||||
|
||||
/// Creates a [StaffCertificateStatus] from a string.
|
||||
static StaffCertificateStatus fromString(String value) {
|
||||
return StaffCertificateStatus.values.firstWhere(
|
||||
(StaffCertificateStatus e) => e.value == value,
|
||||
orElse: () => StaffCertificateStatus.notStarted,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/// Represents the verification or review state for a staff certificate.
|
||||
enum StaffCertificateValidationStatus {
|
||||
approved('APPROVED'),
|
||||
pendingExpertReview('PENDING_EXPERT_REVIEW'),
|
||||
rejected('REJECTED'),
|
||||
aiVerified('AI_VERIFIED'),
|
||||
aiFlagged('AI_FLAGGED'),
|
||||
manualReviewNeeded('MANUAL_REVIEW_NEEDED');
|
||||
|
||||
const StaffCertificateValidationStatus(this.value);
|
||||
|
||||
/// The string value expected by the backend.
|
||||
final String value;
|
||||
|
||||
/// Creates a [StaffCertificateValidationStatus] from a string.
|
||||
static StaffCertificateValidationStatus fromString(String value) {
|
||||
return StaffCertificateValidationStatus.values.firstWhere(
|
||||
(StaffCertificateValidationStatus e) => e.value == value,
|
||||
orElse: () => StaffCertificateValidationStatus.manualReviewNeeded,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
# Certificate Upload & Verification Workflow
|
||||
|
||||
This document outlines the standardized workflow for handling certificate uploads, metadata capture, and persistence within the Krow mobile application. The certificates module follows the same layered architecture as the `documents` module (see `documents/IMPLEMENTATION_WORKFLOW.md`), with key differences to accommodate certificate-specific metadata (expiry date, issuer, certificate number, type).
|
||||
|
||||
## 1. Overview
|
||||
|
||||
The workflow follows a 4-step lifecycle:
|
||||
|
||||
1. **Form Entry**: The user fills in certificate metadata (name, type, issuer, certificate number, expiry date).
|
||||
2. **File Selection**: Picking a PDF, JPG, or PNG file locally via `FilePickerService`.
|
||||
3. **Attestation**: Requiring the user to confirm the certificate is genuine before submission.
|
||||
4. **Upload & Persistence**: Pushing the file to storage, initiating background verification, and saving the record to the database via Data Connect.
|
||||
|
||||
---
|
||||
|
||||
## 2. Technical Stack
|
||||
|
||||
| Service | Responsibility |
|
||||
|---------|----------------|
|
||||
| `FilePickerService` | PDF/image file selection from device |
|
||||
| `FileUploadService` | Uploads raw files to secure cloud storage |
|
||||
| `SignedUrlService` | Generates secure internal links for verification access |
|
||||
| `VerificationService` | Orchestrates AI or manual verification (category: `CERTIFICATE`) |
|
||||
| `DataConnect` (Firebase) | Persists structured data and verification metadata via `upsertStaffCertificate` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Key Difference vs. Documents Module
|
||||
|
||||
| Aspect | Documents | Certificates |
|
||||
|--------|-----------|--------------|
|
||||
| Metadata captured | None (just a file) | `name`, `type` (`ComplianceType`), `expiryDate`, `issuer`, `certificateNumber` |
|
||||
| Accepted file types | `pdf` only | `pdf`, `jpg`, `png` |
|
||||
| Verification category | `DOCUMENT` | `CERTIFICATE` |
|
||||
| Domain entity | `StaffDocument` | `StaffCertificate` |
|
||||
| Status enum | `DocumentStatus` | `StaffCertificateStatus` |
|
||||
| Validation status | `DocumentVerificationStatus` | `StaffCertificateValidationStatus` |
|
||||
| Repository | `DocumentsRepository` | `CertificatesRepository` |
|
||||
| Data Connect method | `upsertStaffDocument` | `upsertStaffCertificate` |
|
||||
| Navigator helper | `StaffNavigator.toDocumentUpload` | `StaffNavigator.toCertificateUpload` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Status
|
||||
|
||||
### ✅ Completed — Presentation Layer
|
||||
|
||||
#### Routing
|
||||
- `StaffPaths.certificateUpload` constant used in `StaffPaths.childRoute` within `StaffCertificatesModule`.
|
||||
- `StaffNavigator.toCertificateUpload({StaffCertificate? certificate})` type-safe navigation helper wires the route argument.
|
||||
|
||||
#### Domain Layer
|
||||
- `CertificatesRepository` interface defined with three methods:
|
||||
- `getCertificates()` — fetch the current staff member's certificates.
|
||||
- `uploadCertificate(...)` — upload file + trigger verification + persist record.
|
||||
- `upsertCertificate(...)` — metadata-only update (no file re-upload).
|
||||
- `deleteCertificate(...)` — remove a certificate by `ComplianceType`.
|
||||
- `UploadCertificateUseCase` wraps `uploadCertificate`; takes `UploadCertificateParams`:
|
||||
- `certificationType` (`ComplianceType`) — required
|
||||
- `name` (`String`) — required
|
||||
- `filePath` (`String`) — required
|
||||
- `expiryDate` (`DateTime?`) — optional
|
||||
- `issuer` (`String?`) — optional
|
||||
- `certificateNumber` (`String?`) — optional
|
||||
- `GetCertificatesUseCase` wraps `getCertificates()`.
|
||||
- `UpsertCertificateUseCase` wraps `upsertCertificate()` for metadata-only saves.
|
||||
- `DeleteCertificateUseCase` wraps `deleteCertificate()`.
|
||||
|
||||
#### State Management
|
||||
- `CertificateUploadStatus` enum: `initial | uploading | success | failure`
|
||||
- `CertificateUploadState` (Equatable): tracks `status`, `isAttested`, `updatedCertificate`, `errorMessage`
|
||||
- `CertificateUploadCubit`:
|
||||
- Guards upload behind `state.isAttested == true`.
|
||||
- On success: emits `success` with the returned `StaffCertificate`.
|
||||
- On failure: emits `failure` with the error message key via `BlocErrorHandler`.
|
||||
|
||||
#### UI — `CertificateUploadPage`
|
||||
|
||||
Accepts an optional `StaffCertificate? certificate` as a route argument. When provided, the form is pre-populated for editing; when `null`, the page is in "new certificate" mode.
|
||||
|
||||
**Form fields:**
|
||||
- **Certificate Name** (`TextEditingController _nameController`) — required.
|
||||
- **Certificate Type** (`ComplianceType? _selectedType`) — `DropdownButton`, defaults to `ComplianceType.other`.
|
||||
- **Issuer** (`TextEditingController _issuerController`) — optional.
|
||||
- **Certificate Number** (`TextEditingController _numberController`) — optional.
|
||||
- **Expiry Date** (`DateTime? _selectedExpiryDate`) — date picker; defaults to 1 year from today.
|
||||
|
||||
**File selection:**
|
||||
- `FilePickerService.pickFile(allowedExtensions: ['pdf', 'jpg', 'png'])`.
|
||||
- Selected file path stored in `String? _selectedFilePath`.
|
||||
|
||||
**Attestation & submission:**
|
||||
- Attestation checkbox must be checked before submitting (mirrors Documents pattern).
|
||||
- Submit button enabled only when: a file is selected AND attestation is checked.
|
||||
- Loading state: `CircularProgressIndicator` replaces the submit button while `CertificateUploadStatus.uploading`.
|
||||
- On `success`: shows `UiSnackbar` (success type) and calls `Modular.to.pop()`.
|
||||
- On `failure`: shows `UiSnackbar` (error type); stays on page for retry.
|
||||
|
||||
**App bar:**
|
||||
- `UiAppBar` with `certificate?.name ?? t.staff_certificates.upload_modal.title` as title.
|
||||
|
||||
#### UI Guidelines (Consistent with Documents)
|
||||
Follow the same patterns defined in `documents/IMPLEMENTATION_WORKFLOW.md §3`:
|
||||
1. **Header & Instructions**: `UiAppBar` title = certificate name (or modal title). Body instructions use `UiTypography.body1m.textPrimary`.
|
||||
2. **File Selection Card**:
|
||||
- Empty: neutral/primary bordered card inviting file pick.
|
||||
- Selected: `UiColors.bgPopup` card with `UiConstants.radiusLg` rounding, `UiColors.primary` border, truncated file name, and explicit "Replace" action.
|
||||
3. **Bottom Footer / Attestation**: Fixed to `bottomNavigationBar` inside `SafeArea` + `Padding`; submit state tightly coupled to both file presence and attestation.
|
||||
|
||||
#### Module Wiring — `StaffCertificatesModule`
|
||||
|
||||
Binds (all lazy singletons unless noted):
|
||||
| Binding | Scope |
|
||||
|---------|-------|
|
||||
| `CertificatesRepository` → `CertificatesRepositoryImpl` | Lazy singleton |
|
||||
| `GetCertificatesUseCase` | Lazy singleton |
|
||||
| `DeleteCertificateUseCase` | Lazy singleton |
|
||||
| `UpsertCertificateUseCase` | Lazy singleton |
|
||||
| `UploadCertificateUseCase` | Lazy singleton |
|
||||
| `CertificatesCubit` | Lazy singleton |
|
||||
| `CertificateUploadCubit` | **Per-use** (non-singleton via `i.add<>`) |
|
||||
|
||||
Routes:
|
||||
- `StaffPaths.childRoute(StaffPaths.certificates, StaffPaths.certificates)` → `CertificatesPage`
|
||||
- `StaffPaths.childRoute(StaffPaths.certificates, StaffPaths.certificateUpload)` → `CertificateUploadPage(certificate: r.args.data is StaffCertificate ? … : null)`
|
||||
|
||||
#### `CertificatesPage` Integration
|
||||
- `CertificateCard.onTap` navigates to `CertificateUploadPage` with the `StaffCertificate` as route data.
|
||||
- After returning from the upload page, `loadCertificates()` is awaited to refresh the list (fixes `unawaited_futures` lint).
|
||||
|
||||
#### Localization
|
||||
- Keys live under `staff_certificates.*` in `en.i18n.json` and `es.i18n.json`.
|
||||
- Relevant keys: `upload_modal.title`, `upload_modal.success_snackbar`, `upload_modal.name_label`, `upload_modal.replace`, `error_loading`.
|
||||
- Codegen: `dart run slang` generates `TranslationsStaffCertificatesEn` and its Spanish counterpart.
|
||||
|
||||
---
|
||||
|
||||
### ✅ Completed — Data Layer
|
||||
|
||||
#### Data Connect Integration
|
||||
- `StaffConnectorRepository` interface includes `getStaffCertificates()` and `upsertStaffCertificate()`.
|
||||
- `deleteStaffCertificate(certificationType:)` implemented for certificate removal.
|
||||
- SDK regenerated via: `make dataconnect-generate-sdk ENV=dev`.
|
||||
|
||||
#### Repository Implementation — `CertificatesRepositoryImpl`
|
||||
|
||||
Constructor injects `FileUploadService`, `SignedUrlService`, `VerificationService`; uses `DataConnectService.instance` for Data Connect calls.
|
||||
|
||||
**`uploadCertificate()` — 5-step orchestration:**
|
||||
|
||||
```
|
||||
1. FileUploadService.uploadFile(...)
|
||||
→ fileName: 'staff_cert_<type>_<timestamp>.pdf'
|
||||
→ visibility: FileVisibility.private
|
||||
Returns FileUploadResponse { fileUri }
|
||||
|
||||
2. SignedUrlService.createSignedUrl(fileUri: uploadRes.fileUri)
|
||||
→ Generates an internal signed link for verification access
|
||||
|
||||
3. VerificationService.createVerification(...)
|
||||
→ fileUri, type: certificationType.value
|
||||
→ category: 'CERTIFICATE' ← distinguishes from DOCUMENT
|
||||
→ subjectType: 'STAFF'
|
||||
→ subjectId: await _service.getStaffId()
|
||||
|
||||
4. StaffConnectorRepository.upsertStaffCertificate(...)
|
||||
→ certificationType, name, status: pending
|
||||
→ fileUrl: uploadRes.fileUri, expiry, issuer, certificateNumber
|
||||
→ validationStatus: pendingExpertReview
|
||||
|
||||
5. getCertificates() → find & return the matching StaffCertificate
|
||||
```
|
||||
|
||||
**`upsertCertificate()` — metadata-only update:**
|
||||
- Directly calls `upsertStaffCertificate(...)` without any file upload or verification step.
|
||||
- Used for editing certificate details after initial upload.
|
||||
|
||||
**`deleteCertificate()` — record removal:**
|
||||
- Calls `deleteStaffCertificate(certificationType:)`.
|
||||
|
||||
---
|
||||
|
||||
## 5. State Management Reference
|
||||
|
||||
```
|
||||
CertificateUploadStatus
|
||||
├── initial — page just opened / form being filled
|
||||
├── uploading — upload + verification in progress
|
||||
├── success — certificate saved; navigate back (pop)
|
||||
└── failure — error; stay on page; show snackbar
|
||||
```
|
||||
|
||||
**Cubit guards:**
|
||||
- Upload is blocked unless `state.isAttested == true`.
|
||||
- Submit button enabled only when both a file is selected AND attestation is checked.
|
||||
- Uses `BlocErrorHandler` mixin for consistent error emission.
|
||||
|
||||
---
|
||||
|
||||
## 6. StaffCertificateStatus Mapping Reference
|
||||
|
||||
The backend uses a richer enum than the domain layer. Standard mapping:
|
||||
|
||||
| Backend / Validation Status | Domain `StaffCertificateStatus` | Notes |
|
||||
|-----------------------------|----------------------------------|-------|
|
||||
| `VERIFIED` / `AUTO_PASS` / `APPROVED` | `verified` | Fully or AI-approved |
|
||||
| `UPLOADED` / `PENDING` / `PROCESSING` / `NEEDS_REVIEW` / `EXPIRING` | `pending` | Upload received; processing or awaiting renewal |
|
||||
| `AUTO_FAIL` / `REJECTED` / `ERROR` | `rejected` | AI or manual rejection / system error |
|
||||
| `MISSING` | `missing` | Not yet uploaded |
|
||||
|
||||
`StaffCertificateValidationStatus` preserves the full backend granularity for detailed UI feedback (e.g., showing "Pending Expert Review" vs. "Auto-Failed").
|
||||
|
||||
---
|
||||
|
||||
## 7. Future Considerations
|
||||
|
||||
1. **Expiry Notifications**: Certificates approaching expiry (domain status `pending` / backend `EXPIRING`) should surface a nudge in `CertificatesPage` or via push notification.
|
||||
2. **Re-verification on Edit**: When a certificate's file is replaced via `uploadCertificate`, a new verification job is triggered. Decide whether editing only metadata (via `upsertCertificate`) should also trigger re-verification.
|
||||
3. **Multiple Files per Certificate**: The current schema supports a single `fileUrl`. If multi-page certificates become a requirement, the Data Connect schema and `CertificatesRepository` interface will need extending.
|
||||
4. **Shared Compliance UI Components**: The file selection card and attestation footer patterns are duplicated between `DocumentUploadPage` and `CertificateUploadPage`. Consider extracting them into a shared `compliance_upload_widgets` package to reduce duplication.
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:firebase_data_connect/src/core/ref.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
@@ -6,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,31 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
abstract interface class CertificatesRepository {
|
||||
/// Fetches the list of compliance certificates for the current staff member.
|
||||
///
|
||||
/// Returns a list of [StaffDocument] entities.
|
||||
Future<List<StaffDocument>> getCertificates();
|
||||
/// Returns a list of [StaffCertificate] entities.
|
||||
Future<List<StaffCertificate>> getCertificates();
|
||||
|
||||
/// Uploads a certificate file and saves the record.
|
||||
Future<StaffCertificate> uploadCertificate({
|
||||
required ComplianceType certificationType,
|
||||
required String name,
|
||||
required String filePath,
|
||||
DateTime? expiryDate,
|
||||
String? issuer,
|
||||
String? certificateNumber,
|
||||
});
|
||||
|
||||
/// Deletes a staff certificate.
|
||||
Future<void> deleteCertificate({required ComplianceType certificationType});
|
||||
|
||||
/// Upserts a certificate record (metadata only).
|
||||
Future<void> upsertCertificate({
|
||||
required ComplianceType certificationType,
|
||||
required String name,
|
||||
required StaffCertificateStatus status,
|
||||
String? fileUrl,
|
||||
DateTime? expiry,
|
||||
String? issuer,
|
||||
String? certificateNumber,
|
||||
StaffCertificateValidationStatus? validationStatus,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/certificates_repository.dart';
|
||||
|
||||
/// Use case for deleting a staff compliance certificate.
|
||||
class DeleteCertificateUseCase extends UseCase<ComplianceType, void> {
|
||||
/// Creates a [DeleteCertificateUseCase].
|
||||
DeleteCertificateUseCase(this._repository);
|
||||
final CertificatesRepository _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(ComplianceType certificationType) {
|
||||
return _repository.deleteCertificate(certificationType: certificationType);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,7 @@ import '../repositories/certificates_repository.dart';
|
||||
///
|
||||
/// Delegates the data retrieval to the [CertificatesRepository].
|
||||
/// Follows the strict one-to-one mapping between action and use case.
|
||||
class GetCertificatesUseCase extends NoInputUseCase<List<StaffDocument>> {
|
||||
|
||||
class GetCertificatesUseCase extends NoInputUseCase<List<StaffCertificate>> {
|
||||
/// Creates a [GetCertificatesUseCase].
|
||||
///
|
||||
/// Requires a [CertificatesRepository] to access the certificates data source.
|
||||
@@ -15,7 +14,7 @@ class GetCertificatesUseCase extends NoInputUseCase<List<StaffDocument>> {
|
||||
final CertificatesRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<StaffDocument>> call() {
|
||||
Future<List<StaffCertificate>> call() {
|
||||
return _repository.getCertificates();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/certificates_repository.dart';
|
||||
|
||||
/// Use case for uploading a staff compliance certificate.
|
||||
class UploadCertificateUseCase
|
||||
extends UseCase<UploadCertificateParams, StaffCertificate> {
|
||||
/// Creates an [UploadCertificateUseCase].
|
||||
UploadCertificateUseCase(this._repository);
|
||||
final CertificatesRepository _repository;
|
||||
|
||||
@override
|
||||
Future<StaffCertificate> call(UploadCertificateParams params) {
|
||||
return _repository.uploadCertificate(
|
||||
certificationType: params.certificationType,
|
||||
name: params.name,
|
||||
filePath: params.filePath,
|
||||
expiryDate: params.expiryDate,
|
||||
issuer: params.issuer,
|
||||
certificateNumber: params.certificateNumber,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for [UploadCertificateUseCase].
|
||||
class UploadCertificateParams {
|
||||
/// Creates [UploadCertificateParams].
|
||||
UploadCertificateParams({
|
||||
required this.certificationType,
|
||||
required this.name,
|
||||
required this.filePath,
|
||||
this.expiryDate,
|
||||
this.issuer,
|
||||
this.certificateNumber,
|
||||
});
|
||||
|
||||
/// The type of certification.
|
||||
final ComplianceType certificationType;
|
||||
|
||||
/// The name of the certificate.
|
||||
final String name;
|
||||
|
||||
/// The local file path to upload.
|
||||
final String filePath;
|
||||
|
||||
/// The expiry date of the certificate.
|
||||
final DateTime? expiryDate;
|
||||
|
||||
/// The issuer of the certificate.
|
||||
final String? issuer;
|
||||
|
||||
/// The certificate number.
|
||||
final String? certificateNumber;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/certificates_repository.dart';
|
||||
|
||||
/// Use case for upserting a staff compliance certificate.
|
||||
class UpsertCertificateUseCase extends UseCase<UpsertCertificateParams, void> {
|
||||
/// Creates an [UpsertCertificateUseCase].
|
||||
UpsertCertificateUseCase(this._repository);
|
||||
final CertificatesRepository _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(UpsertCertificateParams params) {
|
||||
return _repository.upsertCertificate(
|
||||
certificationType: params.certificationType,
|
||||
name: params.name,
|
||||
status: params.status,
|
||||
fileUrl: params.fileUrl,
|
||||
expiry: params.expiry,
|
||||
issuer: params.issuer,
|
||||
certificateNumber: params.certificateNumber,
|
||||
validationStatus: params.validationStatus,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for [UpsertCertificateUseCase].
|
||||
class UpsertCertificateParams {
|
||||
/// Creates [UpsertCertificateParams].
|
||||
UpsertCertificateParams({
|
||||
required this.certificationType,
|
||||
required this.name,
|
||||
required this.status,
|
||||
this.fileUrl,
|
||||
this.expiry,
|
||||
this.issuer,
|
||||
this.certificateNumber,
|
||||
this.validationStatus,
|
||||
});
|
||||
|
||||
/// The type of certification.
|
||||
final ComplianceType certificationType;
|
||||
|
||||
/// The name of the certificate.
|
||||
final String name;
|
||||
|
||||
/// The status of the certificate.
|
||||
final StaffCertificateStatus status;
|
||||
|
||||
/// The URL of the certificate file.
|
||||
final String? fileUrl;
|
||||
|
||||
/// The expiry date of the certificate.
|
||||
final DateTime? expiry;
|
||||
|
||||
/// The issuer of the certificate.
|
||||
final String? issuer;
|
||||
|
||||
/// The certificate number.
|
||||
final String? certificateNumber;
|
||||
|
||||
/// The validation status of the certificate.
|
||||
final StaffCertificateValidationStatus? validationStatus;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../../domain/usecases/upload_certificate_usecase.dart';
|
||||
import 'certificate_upload_state.dart';
|
||||
|
||||
class CertificateUploadCubit extends Cubit<CertificateUploadState>
|
||||
with BlocErrorHandler<CertificateUploadState> {
|
||||
CertificateUploadCubit(this._uploadCertificateUseCase)
|
||||
: super(const CertificateUploadState());
|
||||
|
||||
final UploadCertificateUseCase _uploadCertificateUseCase;
|
||||
|
||||
void setAttested(bool value) {
|
||||
emit(state.copyWith(isAttested: value));
|
||||
}
|
||||
|
||||
Future<void> uploadCertificate(UploadCertificateParams params) async {
|
||||
if (!state.isAttested) return;
|
||||
|
||||
emit(state.copyWith(status: CertificateUploadStatus.uploading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final StaffCertificate certificate = await _uploadCertificateUseCase(
|
||||
params,
|
||||
);
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: CertificateUploadStatus.success,
|
||||
updatedCertificate: certificate,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: CertificateUploadStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
enum CertificateUploadStatus { initial, uploading, success, failure }
|
||||
|
||||
class CertificateUploadState extends Equatable {
|
||||
const CertificateUploadState({
|
||||
this.status = CertificateUploadStatus.initial,
|
||||
this.isAttested = false,
|
||||
this.updatedCertificate,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
final CertificateUploadStatus status;
|
||||
final bool isAttested;
|
||||
final StaffCertificate? updatedCertificate;
|
||||
final String? errorMessage;
|
||||
|
||||
CertificateUploadState copyWith({
|
||||
CertificateUploadStatus? status,
|
||||
bool? isAttested,
|
||||
StaffCertificate? updatedCertificate,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return CertificateUploadState(
|
||||
status: status ?? this.status,
|
||||
isAttested: isAttested ?? this.isAttested,
|
||||
updatedCertificate: updatedCertificate ?? this.updatedCertificate,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
isAttested,
|
||||
updatedCertificate,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
@@ -2,23 +2,27 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../../domain/usecases/get_certificates_usecase.dart';
|
||||
import '../../../domain/usecases/delete_certificate_usecase.dart';
|
||||
import 'certificates_state.dart';
|
||||
|
||||
class CertificatesCubit extends Cubit<CertificatesState>
|
||||
with BlocErrorHandler<CertificatesState> {
|
||||
|
||||
CertificatesCubit(this._getCertificatesUseCase)
|
||||
: super(const CertificatesState()) {
|
||||
CertificatesCubit(
|
||||
this._getCertificatesUseCase,
|
||||
this._deleteCertificateUseCase,
|
||||
) : super(const CertificatesState()) {
|
||||
loadCertificates();
|
||||
}
|
||||
|
||||
final GetCertificatesUseCase _getCertificatesUseCase;
|
||||
final DeleteCertificateUseCase _deleteCertificateUseCase;
|
||||
|
||||
Future<void> loadCertificates() async {
|
||||
emit(state.copyWith(status: CertificatesStatus.loading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
final List<StaffDocument> certificates =
|
||||
final List<StaffCertificate> certificates =
|
||||
await _getCertificatesUseCase();
|
||||
emit(
|
||||
state.copyWith(
|
||||
@@ -27,12 +31,25 @@ class CertificatesCubit extends Cubit<CertificatesState>
|
||||
),
|
||||
);
|
||||
},
|
||||
onError:
|
||||
(String errorKey) => state.copyWith(
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: CertificatesStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> deleteCertificate(ComplianceType type) async {
|
||||
emit(state.copyWith(status: CertificatesStatus.loading));
|
||||
await handleError(
|
||||
emit: emit,
|
||||
action: () async {
|
||||
await _deleteCertificateUseCase(type);
|
||||
await loadCertificates();
|
||||
},
|
||||
onError: (String errorKey) => state.copyWith(
|
||||
status: CertificatesStatus.failure,
|
||||
errorMessage: errorKey,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,19 +4,19 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
enum CertificatesStatus { initial, loading, success, failure }
|
||||
|
||||
class CertificatesState extends Equatable {
|
||||
|
||||
const CertificatesState({
|
||||
this.status = CertificatesStatus.initial,
|
||||
List<StaffDocument>? certificates,
|
||||
List<StaffCertificate>? certificates,
|
||||
this.errorMessage,
|
||||
}) : certificates = certificates ?? const <StaffDocument>[];
|
||||
}) : certificates = certificates ?? const <StaffCertificate>[];
|
||||
|
||||
final CertificatesStatus status;
|
||||
final List<StaffDocument> certificates;
|
||||
final List<StaffCertificate> certificates;
|
||||
final String? errorMessage;
|
||||
|
||||
CertificatesState copyWith({
|
||||
CertificatesStatus? status,
|
||||
List<StaffDocument>? certificates,
|
||||
List<StaffCertificate>? certificates,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return CertificatesState(
|
||||
@@ -31,7 +31,10 @@ class CertificatesState extends Equatable {
|
||||
|
||||
/// The number of verified certificates.
|
||||
int get completedCount => certificates
|
||||
.where((StaffDocument doc) => doc.status == DocumentStatus.verified)
|
||||
.where(
|
||||
(StaffCertificate cert) =>
|
||||
cert.validationStatus == StaffCertificateValidationStatus.approved,
|
||||
)
|
||||
.length;
|
||||
|
||||
/// The total number of certificates.
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../blocs/certificate_upload/certificate_upload_cubit.dart';
|
||||
import '../blocs/certificate_upload/certificate_upload_state.dart';
|
||||
import '../../domain/usecases/upload_certificate_usecase.dart';
|
||||
|
||||
/// Page for uploading a certificate with metadata (expiry, issuer, etc).
|
||||
class CertificateUploadPage extends StatefulWidget {
|
||||
const CertificateUploadPage({super.key, this.certificate});
|
||||
|
||||
/// The certificate being edited, or null for a new one.
|
||||
final StaffCertificate? certificate;
|
||||
|
||||
@override
|
||||
State<CertificateUploadPage> createState() => _CertificateUploadPageState();
|
||||
}
|
||||
|
||||
class _CertificateUploadPageState extends State<CertificateUploadPage> {
|
||||
String? _selectedFilePath;
|
||||
DateTime? _selectedExpiryDate;
|
||||
final TextEditingController _issuerController = TextEditingController();
|
||||
final TextEditingController _numberController = TextEditingController();
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
|
||||
ComplianceType? _selectedType;
|
||||
|
||||
final FilePickerService _filePicker = Modular.get<FilePickerService>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.certificate != null) {
|
||||
_selectedExpiryDate = widget.certificate!.expiryDate;
|
||||
_issuerController.text = widget.certificate!.issuer ?? '';
|
||||
_numberController.text = widget.certificate!.certificateNumber ?? '';
|
||||
_nameController.text = widget.certificate!.name;
|
||||
_selectedType = widget.certificate!.certificationType;
|
||||
} else {
|
||||
_selectedType = ComplianceType.other;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_issuerController.dispose();
|
||||
_numberController.dispose();
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickFile() async {
|
||||
final String? path = await _filePicker.pickFile(
|
||||
allowedExtensions: <String>['pdf', 'jpg', 'png'],
|
||||
);
|
||||
|
||||
if (path != null) {
|
||||
setState(() {
|
||||
_selectedFilePath = path;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _selectDate() async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate:
|
||||
_selectedExpiryDate ?? DateTime.now().add(const Duration(days: 365)),
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 3650)),
|
||||
);
|
||||
|
||||
if (picked != null) {
|
||||
setState(() {
|
||||
_selectedExpiryDate = picked;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<CertificateUploadCubit>(
|
||||
create: (BuildContext _) => Modular.get<CertificateUploadCubit>(),
|
||||
child: BlocConsumer<CertificateUploadCubit, CertificateUploadState>(
|
||||
listener: (BuildContext context, CertificateUploadState state) {
|
||||
if (state.status == CertificateUploadStatus.success) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: t.staff_certificates.upload_modal.success_snackbar,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
Modular.to.pop(); // Returns to certificates list
|
||||
} else if (state.status == CertificateUploadStatus.failure) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.errorMessage ?? t.staff_certificates.error_loading,
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, CertificateUploadState state) {
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title:
|
||||
widget.certificate?.name ??
|
||||
t.staff_certificates.upload_modal.title,
|
||||
onLeadingPressed: () => Modular.to.pop(),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
t
|
||||
.staff_documents
|
||||
.upload
|
||||
.instructions, // Reusing instructions logic
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
// Name Field
|
||||
Text(
|
||||
t.staff_certificates.upload_modal.name_label,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "e.g. Food Handler Permit",
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Expiry Date Field
|
||||
Text(
|
||||
t.staff_certificates.upload_modal.expiry_label,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
InkWell(
|
||||
onTap: _selectDate,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.calendar,
|
||||
size: 20,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Text(
|
||||
_selectedExpiryDate != null
|
||||
? DateFormat(
|
||||
'MMM dd, yyyy',
|
||||
).format(_selectedExpiryDate!)
|
||||
: t.staff_certificates.upload_modal.select_date,
|
||||
style: _selectedExpiryDate != null
|
||||
? UiTypography.body1m.textPrimary
|
||||
: UiTypography.body1m.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// Issuer Field
|
||||
Text(
|
||||
t.staff_certificates.upload_modal.issuer_label,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
TextField(
|
||||
controller: _issuerController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "e.g. Department of Health",
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
// File Selector
|
||||
Text(
|
||||
t.staff_certificates.upload_modal.upload_file,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_FileSelector(
|
||||
selectedFilePath: _selectedFilePath,
|
||||
onTap: _pickFile,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
// Attestation
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Checkbox(
|
||||
value: state.isAttested,
|
||||
onChanged: (bool? val) =>
|
||||
BlocProvider.of<CertificateUploadCubit>(
|
||||
context,
|
||||
).setAttested(val ?? false),
|
||||
activeColor: UiColors.primary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
t.staff_documents.upload.attestation,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed:
|
||||
(_selectedFilePath != null &&
|
||||
state.isAttested &&
|
||||
_nameController.text.isNotEmpty)
|
||||
? () =>
|
||||
BlocProvider.of<CertificateUploadCubit>(
|
||||
context,
|
||||
).uploadCertificate(
|
||||
UploadCertificateParams(
|
||||
certificationType: _selectedType!,
|
||||
name: _nameController.text,
|
||||
filePath: _selectedFilePath!,
|
||||
expiryDate: _selectedExpiryDate,
|
||||
issuer: _issuerController.text,
|
||||
certificateNumber: _numberController.text,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: UiColors.primary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: UiConstants.space4,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
),
|
||||
child: state.status == CertificateUploadStatus.uploading
|
||||
? const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
)
|
||||
: Text(
|
||||
t.staff_certificates.upload_modal.save,
|
||||
style: UiTypography.body1m.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FileSelector extends StatelessWidget {
|
||||
const _FileSelector({this.selectedFilePath, required this.onTap});
|
||||
|
||||
final String? selectedFilePath;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (selectedFilePath != null) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagActive,
|
||||
border: Border.all(color: UiColors.primary),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.file, color: UiColors.primary),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
selectedFilePath!.split('/').last,
|
||||
style: UiTypography.body1m.textPrimary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
t.staff_documents.upload.replace,
|
||||
style: UiTypography.body3m.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 120,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: UiColors.border, style: BorderStyle.solid),
|
||||
borderRadius: UiConstants.radiusLg,
|
||||
color: UiColors.background,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.uploadCloud, size: 32, color: UiColors.primary),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Text(
|
||||
t.staff_certificates.upload_modal.drag_drop,
|
||||
style: UiTypography.body2m,
|
||||
),
|
||||
Text(
|
||||
t.staff_certificates.upload_modal.supported_formats,
|
||||
style: UiTypography.body3r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
|
||||
@@ -9,7 +10,6 @@ import '../blocs/certificates/certificates_cubit.dart';
|
||||
import '../blocs/certificates/certificates_state.dart';
|
||||
import '../widgets/add_certificate_card.dart';
|
||||
import '../widgets/certificate_card.dart';
|
||||
import '../widgets/certificate_upload_modal.dart';
|
||||
import '../widgets/certificates_header.dart';
|
||||
|
||||
/// Page for viewing and managing staff certificates.
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Modal for uploading or editing a certificate expiry.
|
||||
class CertificateUploadModal extends StatelessWidget {
|
||||
|
||||
const CertificateUploadModal({
|
||||
super.key,
|
||||
this.document,
|
||||
this.certificate,
|
||||
required this.onSave,
|
||||
required this.onCancel,
|
||||
});
|
||||
/// The document being edited, or null for a new upload.
|
||||
// ignore: unused_field
|
||||
final dynamic
|
||||
document; // Using dynamic for now as we don't import domain here to avoid direct coupling if possible, but actually we should import domain.
|
||||
// Ideally, widgets should be dumb. Let's import domain.
|
||||
|
||||
/// The certificate being edited, or null for a new upload.
|
||||
final StaffCertificate? certificate;
|
||||
|
||||
final VoidCallback onSave;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'data/repositories_impl/certificates_repository_impl.dart';
|
||||
import 'domain/repositories/certificates_repository.dart';
|
||||
import 'domain/usecases/get_certificates_usecase.dart';
|
||||
import 'domain/usecases/delete_certificate_usecase.dart';
|
||||
import 'domain/usecases/upsert_certificate_usecase.dart';
|
||||
import 'domain/usecases/upload_certificate_usecase.dart';
|
||||
import 'presentation/blocs/certificates/certificates_cubit.dart';
|
||||
import 'presentation/blocs/certificate_upload/certificate_upload_cubit.dart';
|
||||
import 'presentation/pages/certificate_upload_page.dart';
|
||||
import 'presentation/pages/certificates_page.dart';
|
||||
|
||||
class StaffCertificatesModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
i.addLazySingleton<CertificatesRepository>(CertificatesRepositoryImpl.new);
|
||||
i.addLazySingleton(GetCertificatesUseCase.new);
|
||||
i.addLazySingleton(CertificatesCubit.new);
|
||||
i.addLazySingleton<GetCertificatesUseCase>(GetCertificatesUseCase.new);
|
||||
i.addLazySingleton<DeleteCertificateUseCase>(DeleteCertificateUseCase.new);
|
||||
i.addLazySingleton<UpsertCertificateUseCase>(UpsertCertificateUseCase.new);
|
||||
i.addLazySingleton<UploadCertificateUseCase>(UploadCertificateUseCase.new);
|
||||
i.addLazySingleton<CertificatesCubit>(CertificatesCubit.new);
|
||||
i.add<CertificateUploadCubit>(CertificateUploadCubit.new);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -21,5 +32,16 @@ class StaffCertificatesModule extends Module {
|
||||
StaffPaths.childRoute(StaffPaths.certificates, StaffPaths.certificates),
|
||||
child: (_) => const CertificatesPage(),
|
||||
);
|
||||
r.child(
|
||||
StaffPaths.childRoute(
|
||||
StaffPaths.certificates,
|
||||
StaffPaths.certificateUpload,
|
||||
),
|
||||
child: (BuildContext context) => CertificateUploadPage(
|
||||
certificate: r.args.data is StaffCertificate
|
||||
? r.args.data as StaffCertificate
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ dependencies:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^8.1.0
|
||||
equatable: ^2.0.5
|
||||
intl: ^0.20.0
|
||||
get_it: ^7.6.0
|
||||
flutter_modular: ^6.3.0
|
||||
|
||||
|
||||
Reference in New Issue
Block a user