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.).
|
/// Manage professional certificates (e.g., food handling, CPR, etc.).
|
||||||
static const String certificates = '/worker-main/certificates/';
|
static const String certificates = '/worker-main/certificates/';
|
||||||
|
|
||||||
|
/// Certificate upload page.
|
||||||
|
static const String certificateUpload = '/worker-main/certificates/upload/';
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// FINANCIAL INFORMATION
|
// FINANCIAL INFORMATION
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class VerificationService extends BaseCoreService {
|
|||||||
required String subjectType,
|
required String subjectType,
|
||||||
required String subjectId,
|
required String subjectId,
|
||||||
required String fileUri,
|
required String fileUri,
|
||||||
|
String? category,
|
||||||
Map<String, dynamic>? rules,
|
Map<String, dynamic>? rules,
|
||||||
}) async {
|
}) async {
|
||||||
final ApiResponse res = await action(() async {
|
final ApiResponse res = await action(() async {
|
||||||
@@ -27,6 +28,7 @@ class VerificationService extends BaseCoreService {
|
|||||||
'subjectType': subjectType,
|
'subjectType': subjectType,
|
||||||
'subjectId': subjectId,
|
'subjectId': subjectId,
|
||||||
'fileUri': fileUri,
|
'fileUri': fileUri,
|
||||||
|
if (category != null) 'category': category,
|
||||||
if (rules != null) 'rules': rules,
|
if (rules != null) 'rules': rules,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1057,7 +1057,8 @@
|
|||||||
"select_pdf": "Select PDF File",
|
"select_pdf": "Select PDF File",
|
||||||
"attestation": "I certify that this document is genuine and valid.",
|
"attestation": "I certify that this document is genuine and valid.",
|
||||||
"success": "Document uploaded successfully",
|
"success": "Document uploaded successfully",
|
||||||
"error": "Failed to upload document"
|
"error": "Failed to upload document",
|
||||||
|
"replace": "Replace"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"staff_certificates": {
|
"staff_certificates": {
|
||||||
@@ -1086,13 +1087,16 @@
|
|||||||
},
|
},
|
||||||
"upload_modal": {
|
"upload_modal": {
|
||||||
"title": "Upload Certificate",
|
"title": "Upload Certificate",
|
||||||
|
"name_label": "Certificate Name",
|
||||||
|
"issuer_label": "Certificate Issuer",
|
||||||
"expiry_label": "Expiration Date (Optional)",
|
"expiry_label": "Expiration Date (Optional)",
|
||||||
"select_date": "Select date",
|
"select_date": "Select date",
|
||||||
"upload_file": "Upload File",
|
"upload_file": "Upload File",
|
||||||
"drag_drop": "Drag and drop or click to upload",
|
"drag_drop": "Drag and drop or click to upload",
|
||||||
"supported_formats": "PDF, JPG, PNG up to 10MB",
|
"supported_formats": "PDF, JPG, PNG up to 10MB",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"save": "Save Certificate"
|
"save": "Save Certificate",
|
||||||
|
"success_snackbar": "Certificate successfully uploaded and pending verification"
|
||||||
},
|
},
|
||||||
"delete_modal": {
|
"delete_modal": {
|
||||||
"title": "Remove Certificate?",
|
"title": "Remove Certificate?",
|
||||||
|
|||||||
@@ -524,4 +524,221 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
|||||||
return dc.DocumentStatus.EXPIRING;
|
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,
|
DocumentStatus? status,
|
||||||
String? verificationId,
|
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
|
// Profile
|
||||||
export 'src/entities/profile/staff_document.dart';
|
export 'src/entities/profile/staff_document.dart';
|
||||||
export 'src/entities/profile/document_verification_status.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_item.dart';
|
||||||
export 'src/entities/profile/attire_verification_status.dart';
|
export 'src/entities/profile/attire_verification_status.dart';
|
||||||
export 'src/entities/profile/relationship_type.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_core/core.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
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';
|
import '../../domain/repositories/certificates_repository.dart';
|
||||||
|
|
||||||
/// Implementation of [CertificatesRepository] using Data Connect.
|
/// Implementation of [CertificatesRepository] using Data Connect.
|
||||||
///
|
|
||||||
/// This class handles the communication with the backend via [DataConnectService].
|
|
||||||
/// It maps raw generated data types to clean [domain.StaffDocument] entities.
|
|
||||||
class CertificatesRepositoryImpl implements CertificatesRepository {
|
class CertificatesRepositoryImpl implements CertificatesRepository {
|
||||||
/// Creates a [CertificatesRepositoryImpl].
|
CertificatesRepositoryImpl({
|
||||||
CertificatesRepositoryImpl() : _service = DataConnectService.instance;
|
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 DataConnectService _service;
|
||||||
|
final FileUploadService _uploadService;
|
||||||
|
final SignedUrlService _signedUrlService;
|
||||||
|
final VerificationService _verificationService;
|
||||||
|
|
||||||
@override
|
@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 {
|
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();
|
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
|
// 4. Update/Create Certificate in Data Connect
|
||||||
final QueryResult<
|
await _service.getStaffRepository().upsertStaffCertificate(
|
||||||
ListStaffDocumentsByStaffIdData,
|
certificationType: certificationType,
|
||||||
ListStaffDocumentsByStaffIdVariables
|
name: name,
|
||||||
>
|
status: domain.StaffCertificateStatus.pending,
|
||||||
result = await _service.connector
|
fileUrl: uploadRes.fileUri,
|
||||||
.listStaffDocumentsByStaffId(staffId: staffId)
|
expiry: expiryDate,
|
||||||
.execute();
|
issuer: issuer,
|
||||||
|
certificateNumber: certificateNumber,
|
||||||
|
validationStatus:
|
||||||
|
domain.StaffCertificateValidationStatus.pendingExpertReview,
|
||||||
|
);
|
||||||
|
|
||||||
// Map the generated SDK types to pure Domain entities
|
// 5. Return updated list or the specific certificate
|
||||||
return result.data.staffDocuments
|
final List<domain.StaffCertificate> certificates =
|
||||||
.map(
|
await getCertificates();
|
||||||
(ListStaffDocumentsByStaffIdStaffDocuments doc) =>
|
return certificates.firstWhere(
|
||||||
_mapToDomain(doc),
|
(domain.StaffCertificate c) => c.certificationType == certificationType,
|
||||||
)
|
);
|
||||||
.toList();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps the Data Connect [ListStaffDocumentsByStaffIdStaffDocuments] to a domain [domain.StaffDocument].
|
@override
|
||||||
domain.StaffDocument _mapToDomain(
|
Future<void> upsertCertificate({
|
||||||
ListStaffDocumentsByStaffIdStaffDocuments doc,
|
required domain.ComplianceType certificationType,
|
||||||
) {
|
required String name,
|
||||||
return domain.StaffDocument(
|
required domain.StaffCertificateStatus status,
|
||||||
id: doc.id,
|
String? fileUrl,
|
||||||
staffId: doc.staffId,
|
DateTime? expiry,
|
||||||
documentId: doc.documentId,
|
String? issuer,
|
||||||
name: doc.document.name,
|
String? certificateNumber,
|
||||||
description: null, // Description not available in this query response
|
domain.StaffCertificateValidationStatus? validationStatus,
|
||||||
status: _mapStatus(doc.status),
|
}) async {
|
||||||
documentUrl: doc.documentUrl,
|
await _service.getStaffRepository().upsertStaffCertificate(
|
||||||
expiryDate: doc.expiryDate == null
|
certificationType: certificationType,
|
||||||
? null
|
name: name,
|
||||||
: DateTimeUtils.toDeviceTime(doc.expiryDate!.toDateTime()),
|
status: status,
|
||||||
|
fileUrl: fileUrl,
|
||||||
|
expiry: expiry,
|
||||||
|
issuer: issuer,
|
||||||
|
certificateNumber: certificateNumber,
|
||||||
|
validationStatus: validationStatus,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Maps the Data Connect [DocumentStatus] enum to the domain [domain.DocumentStatus].
|
@override
|
||||||
domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) {
|
Future<void> deleteCertificate({
|
||||||
if (status is Known<DocumentStatus>) {
|
required domain.ComplianceType certificationType,
|
||||||
switch (status.value) {
|
}) async {
|
||||||
case DocumentStatus.VERIFIED:
|
return _service.getStaffRepository().deleteStaffCertificate(
|
||||||
return domain.DocumentStatus.verified;
|
certificationType: certificationType,
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,31 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
abstract interface class CertificatesRepository {
|
abstract interface class CertificatesRepository {
|
||||||
/// Fetches the list of compliance certificates for the current staff member.
|
/// Fetches the list of compliance certificates for the current staff member.
|
||||||
///
|
///
|
||||||
/// Returns a list of [StaffDocument] entities.
|
/// Returns a list of [StaffCertificate] entities.
|
||||||
Future<List<StaffDocument>> getCertificates();
|
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].
|
/// Delegates the data retrieval to the [CertificatesRepository].
|
||||||
/// Follows the strict one-to-one mapping between action and use case.
|
/// 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].
|
/// Creates a [GetCertificatesUseCase].
|
||||||
///
|
///
|
||||||
/// Requires a [CertificatesRepository] to access the certificates data source.
|
/// Requires a [CertificatesRepository] to access the certificates data source.
|
||||||
@@ -15,7 +14,7 @@ class GetCertificatesUseCase extends NoInputUseCase<List<StaffDocument>> {
|
|||||||
final CertificatesRepository _repository;
|
final CertificatesRepository _repository;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<StaffDocument>> call() {
|
Future<List<StaffCertificate>> call() {
|
||||||
return _repository.getCertificates();
|
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_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../../../domain/usecases/get_certificates_usecase.dart';
|
import '../../../domain/usecases/get_certificates_usecase.dart';
|
||||||
|
import '../../../domain/usecases/delete_certificate_usecase.dart';
|
||||||
import 'certificates_state.dart';
|
import 'certificates_state.dart';
|
||||||
|
|
||||||
class CertificatesCubit extends Cubit<CertificatesState>
|
class CertificatesCubit extends Cubit<CertificatesState>
|
||||||
with BlocErrorHandler<CertificatesState> {
|
with BlocErrorHandler<CertificatesState> {
|
||||||
|
CertificatesCubit(
|
||||||
CertificatesCubit(this._getCertificatesUseCase)
|
this._getCertificatesUseCase,
|
||||||
: super(const CertificatesState()) {
|
this._deleteCertificateUseCase,
|
||||||
|
) : super(const CertificatesState()) {
|
||||||
loadCertificates();
|
loadCertificates();
|
||||||
}
|
}
|
||||||
|
|
||||||
final GetCertificatesUseCase _getCertificatesUseCase;
|
final GetCertificatesUseCase _getCertificatesUseCase;
|
||||||
|
final DeleteCertificateUseCase _deleteCertificateUseCase;
|
||||||
|
|
||||||
Future<void> loadCertificates() async {
|
Future<void> loadCertificates() async {
|
||||||
emit(state.copyWith(status: CertificatesStatus.loading));
|
emit(state.copyWith(status: CertificatesStatus.loading));
|
||||||
await handleError(
|
await handleError(
|
||||||
emit: emit,
|
emit: emit,
|
||||||
action: () async {
|
action: () async {
|
||||||
final List<StaffDocument> certificates =
|
final List<StaffCertificate> certificates =
|
||||||
await _getCertificatesUseCase();
|
await _getCertificatesUseCase();
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
@@ -27,12 +31,25 @@ class CertificatesCubit extends Cubit<CertificatesState>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
onError:
|
onError: (String errorKey) => state.copyWith(
|
||||||
(String errorKey) => state.copyWith(
|
status: CertificatesStatus.failure,
|
||||||
status: CertificatesStatus.failure,
|
errorMessage: errorKey,
|
||||||
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 }
|
enum CertificatesStatus { initial, loading, success, failure }
|
||||||
|
|
||||||
class CertificatesState extends Equatable {
|
class CertificatesState extends Equatable {
|
||||||
|
|
||||||
const CertificatesState({
|
const CertificatesState({
|
||||||
this.status = CertificatesStatus.initial,
|
this.status = CertificatesStatus.initial,
|
||||||
List<StaffDocument>? certificates,
|
List<StaffCertificate>? certificates,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
}) : certificates = certificates ?? const <StaffDocument>[];
|
}) : certificates = certificates ?? const <StaffCertificate>[];
|
||||||
|
|
||||||
final CertificatesStatus status;
|
final CertificatesStatus status;
|
||||||
final List<StaffDocument> certificates;
|
final List<StaffCertificate> certificates;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
|
||||||
CertificatesState copyWith({
|
CertificatesState copyWith({
|
||||||
CertificatesStatus? status,
|
CertificatesStatus? status,
|
||||||
List<StaffDocument>? certificates,
|
List<StaffCertificate>? certificates,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
}) {
|
}) {
|
||||||
return CertificatesState(
|
return CertificatesState(
|
||||||
@@ -31,7 +31,10 @@ class CertificatesState extends Equatable {
|
|||||||
|
|
||||||
/// The number of verified certificates.
|
/// The number of verified certificates.
|
||||||
int get completedCount => certificates
|
int get completedCount => certificates
|
||||||
.where((StaffDocument doc) => doc.status == DocumentStatus.verified)
|
.where(
|
||||||
|
(StaffCertificate cert) =>
|
||||||
|
cert.validationStatus == StaffCertificateValidationStatus.approved,
|
||||||
|
)
|
||||||
.length;
|
.length;
|
||||||
|
|
||||||
/// The total number of certificates.
|
/// 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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import 'package:core_localization/core_localization.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 '../blocs/certificates/certificates_state.dart';
|
||||||
import '../widgets/add_certificate_card.dart';
|
import '../widgets/add_certificate_card.dart';
|
||||||
import '../widgets/certificate_card.dart';
|
import '../widgets/certificate_card.dart';
|
||||||
import '../widgets/certificate_upload_modal.dart';
|
|
||||||
import '../widgets/certificates_header.dart';
|
import '../widgets/certificates_header.dart';
|
||||||
|
|
||||||
/// Page for viewing and managing staff certificates.
|
/// Page for viewing and managing staff certificates.
|
||||||
@@ -39,10 +39,10 @@ class CertificatesPage extends StatelessWidget {
|
|||||||
body: Center(
|
body: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
state.errorMessage != null
|
state.errorMessage != null
|
||||||
? translateErrorKey(state.errorMessage!)
|
? translateErrorKey(state.errorMessage!)
|
||||||
: t.staff_certificates.error_loading,
|
: t.staff_certificates.error_loading,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: UiTypography.body2r.textSecondary,
|
style: UiTypography.body2r.textSecondary,
|
||||||
),
|
),
|
||||||
@@ -51,7 +51,7 @@ class CertificatesPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<StaffDocument> documents = state.certificates;
|
final List<StaffCertificate> documents = state.certificates;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: UiColors.background, // Matches 0xFFF8FAFC
|
backgroundColor: UiColors.background, // Matches 0xFFF8FAFC
|
||||||
@@ -65,25 +65,32 @@ class CertificatesPage extends StatelessWidget {
|
|||||||
Transform.translate(
|
Transform.translate(
|
||||||
offset: const Offset(0, -48),
|
offset: const Offset(0, -48),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space5,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
...documents.map((StaffDocument doc) => CertificateCard(
|
...documents.map(
|
||||||
document: doc,
|
(StaffCertificate doc) => CertificateCard(
|
||||||
onUpload: () => _showUploadModal(context, doc),
|
certificate: doc,
|
||||||
onEditExpiry: () => _showEditExpiryDialog(context, doc),
|
onUpload: () => _navigateToUpload(context, doc),
|
||||||
onRemove: () => _showRemoveConfirmation(context, doc),
|
onEditExpiry: () =>
|
||||||
onView: () {
|
_showEditExpiryDialog(context, doc),
|
||||||
UiSnackbar.show(
|
onRemove: () =>
|
||||||
context,
|
_showRemoveConfirmation(context, doc),
|
||||||
message: t.staff_certificates.card.opened_snackbar,
|
onView: () {
|
||||||
type: UiSnackbarType.success,
|
UiSnackbar.show(
|
||||||
);
|
context,
|
||||||
},
|
message:
|
||||||
)),
|
t.staff_certificates.card.opened_snackbar,
|
||||||
|
type: UiSnackbarType.success,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
AddCertificateCard(
|
AddCertificateCard(
|
||||||
onTap: () => _showUploadModal(context, null),
|
onTap: () => _navigateToUpload(context, null),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space8),
|
const SizedBox(height: UiConstants.space8),
|
||||||
],
|
],
|
||||||
@@ -98,28 +105,29 @@ class CertificatesPage extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showUploadModal(BuildContext context, StaffDocument? document) {
|
Future<void> _navigateToUpload(
|
||||||
showModalBottomSheet<void>(
|
BuildContext context,
|
||||||
context: context,
|
StaffCertificate? certificate,
|
||||||
isScrollControlled: true,
|
) async {
|
||||||
backgroundColor: Colors.transparent,
|
await Modular.to.pushNamed(
|
||||||
builder: (BuildContext context) => CertificateUploadModal(
|
StaffPaths.certificateUpload,
|
||||||
document: document,
|
arguments: certificate,
|
||||||
onSave: () {
|
|
||||||
// TODO: Implement upload via Cubit
|
|
||||||
// Modular.get<CertificatesCubit>().uploadCertificate(...);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
onCancel: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
// Reload certificates after returning from the upload page
|
||||||
|
await Modular.get<CertificatesCubit>().loadCertificates();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showEditExpiryDialog(BuildContext context, StaffDocument document) {
|
void _showEditExpiryDialog(
|
||||||
_showUploadModal(context, document);
|
BuildContext context,
|
||||||
|
StaffCertificate certificate,
|
||||||
|
) {
|
||||||
|
_navigateToUpload(context, certificate);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showRemoveConfirmation(BuildContext context, StaffDocument document) {
|
void _showRemoveConfirmation(
|
||||||
|
BuildContext context,
|
||||||
|
StaffCertificate certificate,
|
||||||
|
) {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) => AlertDialog(
|
builder: (BuildContext context) => AlertDialog(
|
||||||
@@ -132,8 +140,9 @@ class CertificatesPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
// TODO: Implement delete via Cubit
|
Modular.get<CertificatesCubit>().deleteCertificate(
|
||||||
// Modular.get<CertificatesCubit>().deleteCertificate(document.id);
|
certificate.certificationType,
|
||||||
|
);
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
style: TextButton.styleFrom(foregroundColor: UiColors.destructive),
|
style: TextButton.styleFrom(foregroundColor: UiColors.destructive),
|
||||||
|
|||||||
@@ -5,16 +5,16 @@ import 'package:intl/intl.dart';
|
|||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
class CertificateCard extends StatelessWidget {
|
class CertificateCard extends StatelessWidget {
|
||||||
|
|
||||||
const CertificateCard({
|
const CertificateCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.document,
|
required this.certificate,
|
||||||
this.onUpload,
|
this.onUpload,
|
||||||
this.onEditExpiry,
|
this.onEditExpiry,
|
||||||
this.onRemove,
|
this.onRemove,
|
||||||
this.onView,
|
this.onView,
|
||||||
});
|
});
|
||||||
final StaffDocument document;
|
|
||||||
|
final StaffCertificate certificate;
|
||||||
final VoidCallback? onUpload;
|
final VoidCallback? onUpload;
|
||||||
final VoidCallback? onEditExpiry;
|
final VoidCallback? onEditExpiry;
|
||||||
final VoidCallback? onRemove;
|
final VoidCallback? onRemove;
|
||||||
@@ -22,21 +22,30 @@ class CertificateCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Determine UI state from document
|
// Determine UI state from certificate
|
||||||
final bool isComplete = document.status == DocumentStatus.verified;
|
final bool isComplete =
|
||||||
// Todo: Better logic for expring. Assuming if expiryDate is close.
|
certificate.validationStatus ==
|
||||||
// Prototype used 'EXPIRING' status. We map this logic:
|
StaffCertificateValidationStatus.approved;
|
||||||
final bool isExpiring = _isExpiring(document.expiryDate);
|
final bool isExpiring =
|
||||||
final bool isExpired = _isExpired(document.expiryDate);
|
certificate.status == StaffCertificateStatus.expiring ||
|
||||||
|
certificate.status == StaffCertificateStatus.expiringSoon;
|
||||||
|
final bool isExpired = certificate.status == StaffCertificateStatus.expired;
|
||||||
|
|
||||||
// Override isComplete if expiring or expired
|
// Override isComplete if expiring or expired
|
||||||
final bool showComplete = isComplete && !isExpired && !isExpiring;
|
final bool showComplete = isComplete && !isExpired && !isExpiring;
|
||||||
|
|
||||||
final bool isPending = document.status == DocumentStatus.pending;
|
final bool isPending =
|
||||||
final bool isNotStarted = document.status == DocumentStatus.missing || document.status == DocumentStatus.rejected;
|
certificate.validationStatus ==
|
||||||
|
StaffCertificateValidationStatus.pendingExpertReview;
|
||||||
|
final bool isNotStarted =
|
||||||
|
certificate.status == StaffCertificateStatus.notStarted ||
|
||||||
|
certificate.validationStatus ==
|
||||||
|
StaffCertificateValidationStatus.rejected;
|
||||||
|
|
||||||
// UI Properties helper
|
// UI Properties helper
|
||||||
final _CertificateUiProps uiProps = _getUiProps(document.documentId);
|
final _CertificateUiProps uiProps = _getUiProps(
|
||||||
|
certificate.certificationType,
|
||||||
|
);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: UiConstants.space4),
|
margin: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||||
@@ -64,12 +73,14 @@ class CertificateCard extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.accent.withValues(alpha: 0.2), // Yellow tint
|
color: UiColors.accent.withValues(alpha: 0.2), // Yellow tint
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(color: UiColors.accent.withValues(alpha: 0.4)),
|
bottom: BorderSide(
|
||||||
|
color: UiColors.accent.withValues(alpha: 0.4),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Icon(
|
const Icon(
|
||||||
UiIcons.warning,
|
UiIcons.warning,
|
||||||
size: 16,
|
size: 16,
|
||||||
color: UiColors.textPrimary,
|
color: UiColors.textPrimary,
|
||||||
@@ -78,13 +89,14 @@ class CertificateCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
isExpired
|
isExpired
|
||||||
? t.staff_certificates.card.expired
|
? 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,
|
style: UiTypography.body3m.textPrimary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -151,12 +163,12 @@ class CertificateCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
document.name,
|
certificate.name,
|
||||||
style: UiTypography.body1m.textPrimary,
|
style: UiTypography.body1m.textPrimary,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
document.description ?? '', // Optional description
|
certificate.description ?? '',
|
||||||
style: UiTypography.body3r.textSecondary,
|
style: UiTypography.body3r.textSecondary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -170,11 +182,10 @@ class CertificateCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
if (showComplete)
|
||||||
if (showComplete) _buildCompleteStatus(document.expiryDate),
|
_buildCompleteStatus(certificate.expiryDate),
|
||||||
|
if (isExpiring || isExpired)
|
||||||
if (isExpiring || isExpired) _buildExpiringStatus(context, document.expiryDate),
|
_buildExpiringStatus(context, certificate.expiryDate),
|
||||||
|
|
||||||
if (isNotStarted)
|
if (isNotStarted)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -207,7 +218,6 @@ class CertificateCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
if (showComplete || isExpiring || isExpired) ...<Widget>[
|
if (showComplete || isExpiring || isExpired) ...<Widget>[
|
||||||
const SizedBox(height: UiConstants.space3),
|
const SizedBox(height: UiConstants.space3),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
@@ -281,7 +291,9 @@ class CertificateCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (expiryDate != null)
|
if (expiryDate != null)
|
||||||
Text(
|
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,
|
style: UiTypography.body3r.textSecondary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -308,9 +320,7 @@ class CertificateCard extends StatelessWidget {
|
|||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
Text(
|
Text(
|
||||||
t.staff_certificates.card.expiring_soon,
|
t.staff_certificates.card.expiring_soon,
|
||||||
style: UiTypography.body2m.copyWith(
|
style: UiTypography.body2m.copyWith(color: UiColors.primary),
|
||||||
color: UiColors.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -318,7 +328,9 @@ class CertificateCard extends StatelessWidget {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4),
|
||||||
child: Text(
|
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(
|
style: UiTypography.body3r.copyWith(
|
||||||
color: UiColors.textSecondary,
|
color: UiColors.textSecondary,
|
||||||
),
|
),
|
||||||
@@ -330,10 +342,7 @@ class CertificateCard extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
_buildIconButton(UiIcons.eye, onView),
|
_buildIconButton(UiIcons.eye, onView),
|
||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
_buildSmallOutlineButton(
|
_buildSmallOutlineButton(t.staff_certificates.card.renew, onUpload),
|
||||||
t.staff_certificates.card.renew,
|
|
||||||
onUpload,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -347,12 +356,9 @@ class CertificateCard extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: UiColors.transparent,
|
color: UiColors.transparent,
|
||||||
border: Border.all(
|
|
||||||
color: UiColors.transparent,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(icon, size: 16, color: UiColors.textSecondary),
|
child: Icon(icon, size: 16, color: UiColors.textSecondary),
|
||||||
@@ -365,10 +371,10 @@ class CertificateCard extends StatelessWidget {
|
|||||||
return OutlinedButton(
|
return OutlinedButton(
|
||||||
onPressed: onTap,
|
onPressed: onTap,
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
side: BorderSide(color: UiColors.primary.withValues(alpha: 0.4)), // Primary with opacity
|
side: BorderSide(
|
||||||
shape: RoundedRectangleBorder(
|
color: UiColors.primary.withValues(alpha: 0.4),
|
||||||
borderRadius: UiConstants.radiusFull,
|
), // Primary with opacity
|
||||||
),
|
shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusFull),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
|
||||||
minimumSize: const Size(0, 32),
|
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) {
|
int _daysUntilExpiry(DateTime? expiry) {
|
||||||
if (expiry == null) return 0;
|
if (expiry == null) return 0;
|
||||||
return expiry.difference(DateTime.now()).inDays;
|
return expiry.difference(DateTime.now()).inDays;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock mapping for UI props based on ID
|
// Mock mapping for UI props based on ID
|
||||||
_CertificateUiProps _getUiProps(String id) {
|
_CertificateUiProps _getUiProps(ComplianceType type) {
|
||||||
switch (id) {
|
switch (type) {
|
||||||
case 'background':
|
case ComplianceType.backgroundCheck:
|
||||||
return _CertificateUiProps(UiIcons.fileCheck, UiColors.primary);
|
return _CertificateUiProps(UiIcons.fileCheck, UiColors.primary);
|
||||||
case 'food_handler':
|
case ComplianceType.foodHandler:
|
||||||
return _CertificateUiProps(UiIcons.utensils, UiColors.primary);
|
return _CertificateUiProps(UiIcons.utensils, UiColors.primary);
|
||||||
case 'rbs':
|
case ComplianceType.rbs:
|
||||||
return _CertificateUiProps(UiIcons.wine, UiColors.foreground);
|
return _CertificateUiProps(UiIcons.wine, UiColors.foreground);
|
||||||
default:
|
default:
|
||||||
// Default generic icon
|
// Default generic icon
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
/// Modal for uploading or editing a certificate expiry.
|
/// Modal for uploading or editing a certificate expiry.
|
||||||
class CertificateUploadModal extends StatelessWidget {
|
class CertificateUploadModal extends StatelessWidget {
|
||||||
|
|
||||||
const CertificateUploadModal({
|
const CertificateUploadModal({
|
||||||
super.key,
|
super.key,
|
||||||
this.document,
|
this.certificate,
|
||||||
required this.onSave,
|
required this.onSave,
|
||||||
required this.onCancel,
|
required this.onCancel,
|
||||||
});
|
});
|
||||||
/// The document being edited, or null for a new upload.
|
|
||||||
// ignore: unused_field
|
/// The certificate being edited, or null for a new upload.
|
||||||
final dynamic
|
final StaffCertificate? certificate;
|
||||||
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.
|
|
||||||
|
|
||||||
final VoidCallback onSave;
|
final VoidCallback onSave;
|
||||||
final VoidCallback onCancel;
|
final VoidCallback onCancel;
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
import 'data/repositories_impl/certificates_repository_impl.dart';
|
import 'data/repositories_impl/certificates_repository_impl.dart';
|
||||||
import 'domain/repositories/certificates_repository.dart';
|
import 'domain/repositories/certificates_repository.dart';
|
||||||
import 'domain/usecases/get_certificates_usecase.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/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';
|
import 'presentation/pages/certificates_page.dart';
|
||||||
|
|
||||||
class StaffCertificatesModule extends Module {
|
class StaffCertificatesModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
i.addLazySingleton<CertificatesRepository>(CertificatesRepositoryImpl.new);
|
i.addLazySingleton<CertificatesRepository>(CertificatesRepositoryImpl.new);
|
||||||
i.addLazySingleton(GetCertificatesUseCase.new);
|
i.addLazySingleton<GetCertificatesUseCase>(GetCertificatesUseCase.new);
|
||||||
i.addLazySingleton(CertificatesCubit.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
|
@override
|
||||||
@@ -21,5 +32,16 @@ class StaffCertificatesModule extends Module {
|
|||||||
StaffPaths.childRoute(StaffPaths.certificates, StaffPaths.certificates),
|
StaffPaths.childRoute(StaffPaths.certificates, StaffPaths.certificates),
|
||||||
child: (_) => const CertificatesPage(),
|
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
|
sdk: flutter
|
||||||
flutter_bloc: ^8.1.0
|
flutter_bloc: ^8.1.0
|
||||||
equatable: ^2.0.5
|
equatable: ^2.0.5
|
||||||
|
intl: ^0.20.0
|
||||||
get_it: ^7.6.0
|
get_it: ^7.6.0
|
||||||
flutter_modular: ^6.3.0
|
flutter_modular: ^6.3.0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user