Merge pull request #556 from Oloodi/267-p0-comp-04-documents-screen

Completed some FE issues
This commit is contained in:
Achintha Isuru
2026-02-27 01:01:49 -05:00
committed by GitHub
51 changed files with 2613 additions and 337 deletions

View File

@@ -225,6 +225,21 @@ extension StaffNavigator on IModularNavigator {
pushNamed(StaffPaths.documents);
}
/// Pushes the document upload page.
///
/// Parameters:
/// * [document] - The document metadata to upload
/// * [initialUrl] - Optional initial document URL
void toDocumentUpload({required StaffDocument document, String? initialUrl}) {
navigate(
StaffPaths.documentUpload,
arguments: <String, dynamic>{
'document': document,
'initialUrl': initialUrl,
},
);
}
/// Pushes the certificates management page.
///
/// Manage professional certificates (e.g., food handling, CPR).

View File

@@ -164,11 +164,17 @@ class StaffPaths {
/// Store ID, work permits, and other required documentation.
static const String documents = '/worker-main/documents/';
/// Document upload page.
static const String documentUpload = '/worker-main/documents/upload/';
/// Certificates management - professional certifications.
///
/// Manage professional certificates (e.g., food handling, CPR, etc.).
static const String certificates = '/worker-main/certificates/';
/// Certificate upload page.
static const String certificateUpload = '/worker-main/certificates/upload/';
// ==========================================================================
// FINANCIAL INFORMATION
// ==========================================================================

View File

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

View File

@@ -1050,6 +1050,15 @@
"pending": "Pending",
"missing": "Missing",
"rejected": "Rejected"
},
"upload": {
"instructions": "Please select a valid PDF file to upload.",
"submit": "Submit Document",
"select_pdf": "Select PDF File",
"attestation": "I certify that this document is genuine and valid.",
"success": "Document uploaded successfully",
"error": "Failed to upload document",
"replace": "Replace"
}
},
"staff_certificates": {
@@ -1078,13 +1087,16 @@
},
"upload_modal": {
"title": "Upload Certificate",
"name_label": "Certificate Name",
"issuer_label": "Certificate Issuer",
"expiry_label": "Expiration Date (Optional)",
"select_date": "Select date",
"upload_file": "Upload File",
"drag_drop": "Drag and drop or click to upload",
"supported_formats": "PDF, JPG, PNG up to 10MB",
"cancel": "Cancel",
"save": "Save Certificate"
"save": "Save Certificate",
"success_snackbar": "Certificate successfully uploaded and pending verification"
},
"delete_modal": {
"title": "Remove Certificate?",

View File

@@ -1050,6 +1050,15 @@
"pending": "Pendiente",
"missing": "Faltante",
"rejected": "Rechazado"
},
"upload": {
"instructions": "Por favor selecciona un archivo PDF válido para subir.",
"submit": "Enviar Documento",
"select_pdf": "Seleccionar Archivo PDF",
"attestation": "Certifico que este documento es genuino y válido.",
"success": "Documento subido exitosamente",
"error": "Error al subir el documento",
"replace": "Reemplazar"
}
},
"staff_certificates": {
@@ -1078,13 +1087,16 @@
},
"upload_modal": {
"title": "Subir Certificado",
"name_label": "Nombre del Certificado",
"issuer_label": "Emisor del Certificado",
"expiry_label": "Fecha de Expiraci\u00f3n (Opcional)",
"select_date": "Seleccionar fecha",
"upload_file": "Subir Archivo",
"drag_drop": "Arrastra y suelta o haz clic para subir",
"supported_formats": "PDF, JPG, PNG hasta 10MB",
"cancel": "Cancelar",
"save": "Guardar Certificado"
"save": "Guardar Certificado",
"success_snackbar": "Certificado subido exitosamente y pendiente de verificaci\u00f3n"
},
"delete_modal": {
"title": "\u00bfEliminar Certificado?",

View File

@@ -1,4 +1,5 @@
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/repositories/staff_connector_repository.dart';
@@ -349,4 +350,395 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
throw Exception('Error signing out: ${e.toString()}');
}
}
@override
Future<List<domain.StaffDocument>> getStaffDocuments() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final List<QueryResult<Object, Object?>> results =
await Future.wait<QueryResult<Object, Object?>>(
<Future<QueryResult<Object, Object?>>>[
_service.connector.listDocuments().execute(),
_service.connector
.listStaffDocumentsByStaffId(staffId: staffId)
.execute(),
],
);
final QueryResult<dc.ListDocumentsData, void> documentsRes =
results[0] as QueryResult<dc.ListDocumentsData, void>;
final QueryResult<
dc.ListStaffDocumentsByStaffIdData,
dc.ListStaffDocumentsByStaffIdVariables
>
staffDocsRes =
results[1]
as QueryResult<
dc.ListStaffDocumentsByStaffIdData,
dc.ListStaffDocumentsByStaffIdVariables
>;
final List<dc.ListStaffDocumentsByStaffIdStaffDocuments> staffDocs =
staffDocsRes.data.staffDocuments;
return documentsRes.data.documents.map((dc.ListDocumentsDocuments doc) {
// Find if this staff member has already uploaded this document
final dc.ListStaffDocumentsByStaffIdStaffDocuments? currentDoc =
staffDocs
.where(
(dc.ListStaffDocumentsByStaffIdStaffDocuments d) =>
d.documentId == doc.id,
)
.firstOrNull;
return domain.StaffDocument(
id: currentDoc?.id ?? '',
staffId: staffId,
documentId: doc.id,
name: doc.name,
description: doc.description,
status: currentDoc != null
? _mapDocumentStatus(currentDoc.status)
: domain.DocumentStatus.missing,
documentUrl: currentDoc?.documentUrl,
expiryDate: currentDoc?.expiryDate == null
? null
: DateTimeUtils.toDeviceTime(
currentDoc!.expiryDate!.toDateTime(),
),
verificationId: currentDoc?.verificationId,
verificationStatus: currentDoc != null
? _mapFromDCDocumentVerificationStatus(currentDoc.status)
: null,
);
}).toList();
});
}
@override
Future<void> upsertStaffDocument({
required String documentId,
required String documentUrl,
domain.DocumentStatus? status,
String? verificationId,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
final domain.Staff staff = await getStaffProfile();
await _service.connector
.upsertStaffDocument(
staffId: staffId,
staffName: staff.name,
documentId: documentId,
status: _mapToDCDocumentStatus(status),
)
.documentUrl(documentUrl)
.verificationId(verificationId)
.execute();
});
}
domain.DocumentStatus _mapDocumentStatus(
dc.EnumValue<dc.DocumentStatus> status,
) {
if (status is dc.Unknown) {
return domain.DocumentStatus.pending;
}
final dc.DocumentStatus value =
(status as dc.Known<dc.DocumentStatus>).value;
switch (value) {
case dc.DocumentStatus.VERIFIED:
return domain.DocumentStatus.verified;
case dc.DocumentStatus.PENDING:
return domain.DocumentStatus.pending;
case dc.DocumentStatus.MISSING:
return domain.DocumentStatus.missing;
case dc.DocumentStatus.UPLOADED:
case dc.DocumentStatus.EXPIRING:
return domain.DocumentStatus.pending;
case dc.DocumentStatus.PROCESSING:
case dc.DocumentStatus.AUTO_PASS:
case dc.DocumentStatus.AUTO_FAIL:
case dc.DocumentStatus.NEEDS_REVIEW:
case dc.DocumentStatus.APPROVED:
case dc.DocumentStatus.REJECTED:
case dc.DocumentStatus.ERROR:
if (value == dc.DocumentStatus.AUTO_PASS ||
value == dc.DocumentStatus.APPROVED) {
return domain.DocumentStatus.verified;
}
if (value == dc.DocumentStatus.AUTO_FAIL ||
value == dc.DocumentStatus.REJECTED ||
value == dc.DocumentStatus.ERROR) {
return domain.DocumentStatus.rejected;
}
return domain.DocumentStatus.pending;
}
}
domain.DocumentVerificationStatus _mapFromDCDocumentVerificationStatus(
dc.EnumValue<dc.DocumentStatus> status,
) {
if (status is dc.Unknown) {
return domain.DocumentVerificationStatus.error;
}
final String name = (status as dc.Known<dc.DocumentStatus>).value.name;
switch (name) {
case 'PENDING':
return domain.DocumentVerificationStatus.pending;
case 'PROCESSING':
return domain.DocumentVerificationStatus.processing;
case 'AUTO_PASS':
return domain.DocumentVerificationStatus.autoPass;
case 'AUTO_FAIL':
return domain.DocumentVerificationStatus.autoFail;
case 'NEEDS_REVIEW':
return domain.DocumentVerificationStatus.needsReview;
case 'APPROVED':
return domain.DocumentVerificationStatus.approved;
case 'REJECTED':
return domain.DocumentVerificationStatus.rejected;
case 'VERIFIED':
return domain.DocumentVerificationStatus.approved;
case 'ERROR':
return domain.DocumentVerificationStatus.error;
default:
return domain.DocumentVerificationStatus.error;
}
}
dc.DocumentStatus _mapToDCDocumentStatus(domain.DocumentStatus? status) {
if (status == null) return dc.DocumentStatus.PENDING;
switch (status) {
case domain.DocumentStatus.verified:
return dc.DocumentStatus.VERIFIED;
case domain.DocumentStatus.pending:
return dc.DocumentStatus.PENDING;
case domain.DocumentStatus.missing:
return dc.DocumentStatus.MISSING;
case domain.DocumentStatus.rejected:
return dc.DocumentStatus.REJECTED;
case domain.DocumentStatus.expired:
return dc.DocumentStatus.EXPIRING;
}
}
@override
Future<List<domain.StaffCertificate>> getStaffCertificates() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
final QueryResult<
dc.ListCertificatesByStaffIdData,
dc.ListCertificatesByStaffIdVariables
>
response = await _service.connector
.listCertificatesByStaffId(staffId: staffId)
.execute();
return response.data.certificates.map((
dc.ListCertificatesByStaffIdCertificates cert,
) {
return domain.StaffCertificate(
id: cert.id,
staffId: cert.staffId,
name: cert.name,
description: cert.description,
expiryDate: _service.toDateTime(cert.expiry),
status: _mapToDomainCertificateStatus(cert.status),
certificateUrl: cert.fileUrl,
icon: cert.icon,
certificationType: _mapToDomainComplianceType(cert.certificationType),
issuer: cert.issuer,
certificateNumber: cert.certificateNumber,
validationStatus: _mapToDomainValidationStatus(cert.validationStatus),
createdAt: _service.toDateTime(cert.createdAt),
);
}).toList();
});
}
@override
Future<void> upsertStaffCertificate({
required domain.ComplianceType certificationType,
required String name,
required domain.StaffCertificateStatus status,
String? fileUrl,
DateTime? expiry,
String? issuer,
String? certificateNumber,
domain.StaffCertificateValidationStatus? validationStatus,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
await _service.connector
.upsertStaffCertificate(
staffId: staffId,
certificationType: _mapToDCComplianceType(certificationType),
name: name,
status: _mapToDCCertificateStatus(status),
)
.fileUrl(fileUrl)
.expiry(_service.tryToTimestamp(expiry))
.issuer(issuer)
.certificateNumber(certificateNumber)
.validationStatus(_mapToDCValidationStatus(validationStatus))
.execute();
});
}
@override
Future<void> deleteStaffCertificate({
required domain.ComplianceType certificationType,
}) async {
await _service.run(() async {
final String staffId = await _service.getStaffId();
await _service.connector
.deleteCertificate(
staffId: staffId,
certificationType: _mapToDCComplianceType(certificationType),
)
.execute();
});
}
domain.StaffCertificateStatus _mapToDomainCertificateStatus(
dc.EnumValue<dc.CertificateStatus> status,
) {
if (status is dc.Unknown) return domain.StaffCertificateStatus.notStarted;
final dc.CertificateStatus value =
(status as dc.Known<dc.CertificateStatus>).value;
switch (value) {
case dc.CertificateStatus.CURRENT:
return domain.StaffCertificateStatus.current;
case dc.CertificateStatus.EXPIRING_SOON:
return domain.StaffCertificateStatus.expiringSoon;
case dc.CertificateStatus.COMPLETED:
return domain.StaffCertificateStatus.completed;
case dc.CertificateStatus.PENDING:
return domain.StaffCertificateStatus.pending;
case dc.CertificateStatus.EXPIRED:
return domain.StaffCertificateStatus.expired;
case dc.CertificateStatus.EXPIRING:
return domain.StaffCertificateStatus.expiring;
case dc.CertificateStatus.NOT_STARTED:
return domain.StaffCertificateStatus.notStarted;
}
}
dc.CertificateStatus _mapToDCCertificateStatus(
domain.StaffCertificateStatus status,
) {
switch (status) {
case domain.StaffCertificateStatus.current:
return dc.CertificateStatus.CURRENT;
case domain.StaffCertificateStatus.expiringSoon:
return dc.CertificateStatus.EXPIRING_SOON;
case domain.StaffCertificateStatus.completed:
return dc.CertificateStatus.COMPLETED;
case domain.StaffCertificateStatus.pending:
return dc.CertificateStatus.PENDING;
case domain.StaffCertificateStatus.expired:
return dc.CertificateStatus.EXPIRED;
case domain.StaffCertificateStatus.expiring:
return dc.CertificateStatus.EXPIRING;
case domain.StaffCertificateStatus.notStarted:
return dc.CertificateStatus.NOT_STARTED;
}
}
domain.ComplianceType _mapToDomainComplianceType(
dc.EnumValue<dc.ComplianceType> type,
) {
if (type is dc.Unknown) return domain.ComplianceType.other;
final dc.ComplianceType value = (type as dc.Known<dc.ComplianceType>).value;
switch (value) {
case dc.ComplianceType.BACKGROUND_CHECK:
return domain.ComplianceType.backgroundCheck;
case dc.ComplianceType.FOOD_HANDLER:
return domain.ComplianceType.foodHandler;
case dc.ComplianceType.RBS:
return domain.ComplianceType.rbs;
case dc.ComplianceType.LEGAL:
return domain.ComplianceType.legal;
case dc.ComplianceType.OPERATIONAL:
return domain.ComplianceType.operational;
case dc.ComplianceType.SAFETY:
return domain.ComplianceType.safety;
case dc.ComplianceType.TRAINING:
return domain.ComplianceType.training;
case dc.ComplianceType.LICENSE:
return domain.ComplianceType.license;
case dc.ComplianceType.OTHER:
return domain.ComplianceType.other;
}
}
dc.ComplianceType _mapToDCComplianceType(domain.ComplianceType type) {
switch (type) {
case domain.ComplianceType.backgroundCheck:
return dc.ComplianceType.BACKGROUND_CHECK;
case domain.ComplianceType.foodHandler:
return dc.ComplianceType.FOOD_HANDLER;
case domain.ComplianceType.rbs:
return dc.ComplianceType.RBS;
case domain.ComplianceType.legal:
return dc.ComplianceType.LEGAL;
case domain.ComplianceType.operational:
return dc.ComplianceType.OPERATIONAL;
case domain.ComplianceType.safety:
return dc.ComplianceType.SAFETY;
case domain.ComplianceType.training:
return dc.ComplianceType.TRAINING;
case domain.ComplianceType.license:
return dc.ComplianceType.LICENSE;
case domain.ComplianceType.other:
return dc.ComplianceType.OTHER;
}
}
domain.StaffCertificateValidationStatus? _mapToDomainValidationStatus(
dc.EnumValue<dc.ValidationStatus>? status,
) {
if (status == null || status is dc.Unknown) return null;
final dc.ValidationStatus value =
(status as dc.Known<dc.ValidationStatus>).value;
switch (value) {
case dc.ValidationStatus.APPROVED:
return domain.StaffCertificateValidationStatus.approved;
case dc.ValidationStatus.PENDING_EXPERT_REVIEW:
return domain.StaffCertificateValidationStatus.pendingExpertReview;
case dc.ValidationStatus.REJECTED:
return domain.StaffCertificateValidationStatus.rejected;
case dc.ValidationStatus.AI_VERIFIED:
return domain.StaffCertificateValidationStatus.aiVerified;
case dc.ValidationStatus.AI_FLAGGED:
return domain.StaffCertificateValidationStatus.aiFlagged;
case dc.ValidationStatus.MANUAL_REVIEW_NEEDED:
return domain.StaffCertificateValidationStatus.manualReviewNeeded;
}
}
dc.ValidationStatus? _mapToDCValidationStatus(
domain.StaffCertificateValidationStatus? status,
) {
if (status == null) return null;
switch (status) {
case domain.StaffCertificateValidationStatus.approved:
return dc.ValidationStatus.APPROVED;
case domain.StaffCertificateValidationStatus.pendingExpertReview:
return dc.ValidationStatus.PENDING_EXPERT_REVIEW;
case domain.StaffCertificateValidationStatus.rejected:
return dc.ValidationStatus.REJECTED;
case domain.StaffCertificateValidationStatus.aiVerified:
return dc.ValidationStatus.AI_VERIFIED;
case domain.StaffCertificateValidationStatus.aiFlagged:
return dc.ValidationStatus.AI_FLAGGED;
case domain.StaffCertificateValidationStatus.manualReviewNeeded:
return dc.ValidationStatus.MANUAL_REVIEW_NEEDED;
}
}
}

View File

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

View File

@@ -77,6 +77,11 @@ export 'src/adapters/financial/bank_account/bank_account_adapter.dart';
// Profile
export 'src/entities/profile/staff_document.dart';
export 'src/entities/profile/document_verification_status.dart';
export 'src/entities/profile/staff_certificate.dart';
export 'src/entities/profile/compliance_type.dart';
export 'src/entities/profile/staff_certificate_status.dart';
export 'src/entities/profile/staff_certificate_validation_status.dart';
export 'src/entities/profile/attire_item.dart';
export 'src/entities/profile/attire_verification_status.dart';
export 'src/entities/profile/relationship_type.dart';

View File

@@ -0,0 +1,25 @@
/// Represents the broad category of a compliance certificate.
enum ComplianceType {
backgroundCheck('BACKGROUND_CHECK'),
foodHandler('FOOD_HANDLER'),
rbs('RBS'),
legal('LEGAL'),
operational('OPERATIONAL'),
safety('SAFETY'),
training('TRAINING'),
license('LICENSE'),
other('OTHER');
const ComplianceType(this.value);
/// The string value expected by the backend.
final String value;
/// Creates a [ComplianceType] from a string.
static ComplianceType fromString(String value) {
return ComplianceType.values.firstWhere(
(ComplianceType e) => e.value == value,
orElse: () => ComplianceType.other,
);
}
}

View File

@@ -0,0 +1,39 @@
/// Represents the verification status of a compliance document.
enum DocumentVerificationStatus {
/// Job is created and waiting to be processed.
pending('PENDING'),
/// Job is currently being processed by machine or human.
processing('PROCESSING'),
/// Machine verification passed automatically.
autoPass('AUTO_PASS'),
/// Machine verification failed automatically.
autoFail('AUTO_FAIL'),
/// Machine results are inconclusive and require human review.
needsReview('NEEDS_REVIEW'),
/// Human reviewer approved the verification.
approved('APPROVED'),
/// Human reviewer rejected the verification.
rejected('REJECTED'),
/// An error occurred during processing.
error('ERROR');
const DocumentVerificationStatus(this.value);
/// The string value expected by the Core API.
final String value;
/// Creates a [DocumentVerificationStatus] from a string.
static DocumentVerificationStatus fromString(String value) {
return DocumentVerificationStatus.values.firstWhere(
(DocumentVerificationStatus e) => e.value == value,
orElse: () => DocumentVerificationStatus.error,
);
}
}

View File

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

View File

@@ -0,0 +1,23 @@
/// Represents the current validity status of a staff certificate.
enum StaffCertificateStatus {
current('CURRENT'),
expiringSoon('EXPIRING_SOON'),
completed('COMPLETED'),
pending('PENDING'),
expired('EXPIRED'),
expiring('EXPIRING'),
notStarted('NOT_STARTED');
const StaffCertificateStatus(this.value);
/// The string value expected by the backend.
final String value;
/// Creates a [StaffCertificateStatus] from a string.
static StaffCertificateStatus fromString(String value) {
return StaffCertificateStatus.values.firstWhere(
(StaffCertificateStatus e) => e.value == value,
orElse: () => StaffCertificateStatus.notStarted,
);
}
}

View File

@@ -0,0 +1,22 @@
/// Represents the verification or review state for a staff certificate.
enum StaffCertificateValidationStatus {
approved('APPROVED'),
pendingExpertReview('PENDING_EXPERT_REVIEW'),
rejected('REJECTED'),
aiVerified('AI_VERIFIED'),
aiFlagged('AI_FLAGGED'),
manualReviewNeeded('MANUAL_REVIEW_NEEDED');
const StaffCertificateValidationStatus(this.value);
/// The string value expected by the backend.
final String value;
/// Creates a [StaffCertificateValidationStatus] from a string.
static StaffCertificateValidationStatus fromString(String value) {
return StaffCertificateValidationStatus.values.firstWhere(
(StaffCertificateValidationStatus e) => e.value == value,
orElse: () => StaffCertificateValidationStatus.manualReviewNeeded,
);
}
}

View File

@@ -1,17 +1,12 @@
import 'package:equatable/equatable.dart';
import 'document_verification_status.dart';
/// Status of a compliance document.
enum DocumentStatus {
verified,
pending,
missing,
rejected,
expired
}
enum DocumentStatus { verified, pending, missing, rejected, expired }
/// Represents a staff compliance document.
class StaffDocument extends Equatable {
const StaffDocument({
required this.id,
required this.staffId,
@@ -21,7 +16,10 @@ class StaffDocument extends Equatable {
required this.status,
this.documentUrl,
this.expiryDate,
this.verificationId,
this.verificationStatus,
});
/// The unique identifier of the staff document record.
final String id;
@@ -46,6 +44,12 @@ class StaffDocument extends Equatable {
/// The expiry date of the document.
final DateTime? expiryDate;
/// The ID of the verification record.
final String? verificationId;
/// The detailed verification status.
final DocumentVerificationStatus? verificationStatus;
@override
List<Object?> get props => <Object?>[
id,
@@ -56,5 +60,7 @@ class StaffDocument extends Equatable {
status,
documentUrl,
expiryDate,
verificationId,
verificationStatus,
];
}

View File

@@ -19,7 +19,7 @@ class ProfileLevelBadge extends StatelessWidget {
switch (status) {
case StaffStatus.active:
case StaffStatus.verified:
return 'Krower I';
return 'KROWER I';
case StaffStatus.pending:
case StaffStatus.completedProfile:
return 'Pending';

View File

@@ -56,6 +56,11 @@ class OnboardingSection extends StatelessWidget {
label: i18n.menu_items.attire,
onTap: () => Modular.to.toAttire(),
),
ProfileMenuItem(
icon: UiIcons.file,
label: i18n.menu_items.documents,
onTap: () => Modular.to.toDocuments(),
),
],
),
],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
@@ -9,7 +10,6 @@ import '../blocs/certificates/certificates_cubit.dart';
import '../blocs/certificates/certificates_state.dart';
import '../widgets/add_certificate_card.dart';
import '../widgets/certificate_card.dart';
import '../widgets/certificate_upload_modal.dart';
import '../widgets/certificates_header.dart';
/// Page for viewing and managing staff certificates.
@@ -51,7 +51,7 @@ class CertificatesPage extends StatelessWidget {
);
}
final List<StaffDocument> documents = state.certificates;
final List<StaffCertificate> documents = state.certificates;
return Scaffold(
backgroundColor: UiColors.background, // Matches 0xFFF8FAFC
@@ -65,25 +65,32 @@ class CertificatesPage extends StatelessWidget {
Transform.translate(
offset: const Offset(0, -48),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
child: Column(
children: <Widget>[
...documents.map((StaffDocument doc) => CertificateCard(
document: doc,
onUpload: () => _showUploadModal(context, doc),
onEditExpiry: () => _showEditExpiryDialog(context, doc),
onRemove: () => _showRemoveConfirmation(context, doc),
...documents.map(
(StaffCertificate doc) => CertificateCard(
certificate: doc,
onUpload: () => _navigateToUpload(context, doc),
onEditExpiry: () =>
_showEditExpiryDialog(context, doc),
onRemove: () =>
_showRemoveConfirmation(context, doc),
onView: () {
UiSnackbar.show(
context,
message: t.staff_certificates.card.opened_snackbar,
message:
t.staff_certificates.card.opened_snackbar,
type: UiSnackbarType.success,
);
},
)),
),
),
const SizedBox(height: UiConstants.space4),
AddCertificateCard(
onTap: () => _showUploadModal(context, null),
onTap: () => _navigateToUpload(context, null),
),
const SizedBox(height: UiConstants.space8),
],
@@ -98,28 +105,29 @@ class CertificatesPage extends StatelessWidget {
);
}
void _showUploadModal(BuildContext context, StaffDocument? document) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (BuildContext context) => CertificateUploadModal(
document: document,
onSave: () {
// TODO: Implement upload via Cubit
// Modular.get<CertificatesCubit>().uploadCertificate(...);
Navigator.pop(context);
},
onCancel: () => Navigator.pop(context),
),
Future<void> _navigateToUpload(
BuildContext context,
StaffCertificate? certificate,
) async {
await Modular.to.pushNamed(
StaffPaths.certificateUpload,
arguments: certificate,
);
// Reload certificates after returning from the upload page
await Modular.get<CertificatesCubit>().loadCertificates();
}
void _showEditExpiryDialog(BuildContext context, StaffDocument document) {
_showUploadModal(context, document);
void _showEditExpiryDialog(
BuildContext context,
StaffCertificate certificate,
) {
_navigateToUpload(context, certificate);
}
void _showRemoveConfirmation(BuildContext context, StaffDocument document) {
void _showRemoveConfirmation(
BuildContext context,
StaffCertificate certificate,
) {
showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
@@ -132,8 +140,9 @@ class CertificatesPage extends StatelessWidget {
),
TextButton(
onPressed: () {
// TODO: Implement delete via Cubit
// Modular.get<CertificatesCubit>().deleteCertificate(document.id);
Modular.get<CertificatesCubit>().deleteCertificate(
certificate.certificationType,
);
Navigator.pop(context);
},
style: TextButton.styleFrom(foregroundColor: UiColors.destructive),

View File

@@ -5,16 +5,16 @@ import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
class CertificateCard extends StatelessWidget {
const CertificateCard({
super.key,
required this.document,
required this.certificate,
this.onUpload,
this.onEditExpiry,
this.onRemove,
this.onView,
});
final StaffDocument document;
final StaffCertificate certificate;
final VoidCallback? onUpload;
final VoidCallback? onEditExpiry;
final VoidCallback? onRemove;
@@ -22,21 +22,30 @@ class CertificateCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Determine UI state from document
final bool isComplete = document.status == DocumentStatus.verified;
// Todo: Better logic for expring. Assuming if expiryDate is close.
// Prototype used 'EXPIRING' status. We map this logic:
final bool isExpiring = _isExpiring(document.expiryDate);
final bool isExpired = _isExpired(document.expiryDate);
// Determine UI state from certificate
final bool isComplete =
certificate.validationStatus ==
StaffCertificateValidationStatus.approved;
final bool isExpiring =
certificate.status == StaffCertificateStatus.expiring ||
certificate.status == StaffCertificateStatus.expiringSoon;
final bool isExpired = certificate.status == StaffCertificateStatus.expired;
// Override isComplete if expiring or expired
final bool showComplete = isComplete && !isExpired && !isExpiring;
final bool isPending = document.status == DocumentStatus.pending;
final bool isNotStarted = document.status == DocumentStatus.missing || document.status == DocumentStatus.rejected;
final bool isPending =
certificate.validationStatus ==
StaffCertificateValidationStatus.pendingExpertReview;
final bool isNotStarted =
certificate.status == StaffCertificateStatus.notStarted ||
certificate.validationStatus ==
StaffCertificateValidationStatus.rejected;
// UI Properties helper
final _CertificateUiProps uiProps = _getUiProps(document.documentId);
final _CertificateUiProps uiProps = _getUiProps(
certificate.certificationType,
);
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space4),
@@ -64,7 +73,9 @@ class CertificateCard extends StatelessWidget {
decoration: BoxDecoration(
color: UiColors.accent.withValues(alpha: 0.2), // Yellow tint
border: Border(
bottom: BorderSide(color: UiColors.accent.withValues(alpha: 0.4)),
bottom: BorderSide(
color: UiColors.accent.withValues(alpha: 0.4),
),
),
),
child: Row(
@@ -78,13 +89,14 @@ class CertificateCard extends StatelessWidget {
Text(
isExpired
? t.staff_certificates.card.expired
: t.staff_certificates.card.expires_in_days(days: _daysUntilExpiry(document.expiryDate)),
: t.staff_certificates.card.expires_in_days(
days: _daysUntilExpiry(certificate.expiryDate),
),
style: UiTypography.body3m.textPrimary,
),
],
),
),
Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: Row(
@@ -151,12 +163,12 @@ class CertificateCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
document.name,
certificate.name,
style: UiTypography.body1m.textPrimary,
),
const SizedBox(height: 2),
Text(
document.description ?? '', // Optional description
certificate.description ?? '',
style: UiTypography.body3r.textSecondary,
),
],
@@ -170,11 +182,10 @@ class CertificateCard extends StatelessWidget {
],
),
const SizedBox(height: UiConstants.space4),
if (showComplete) _buildCompleteStatus(document.expiryDate),
if (isExpiring || isExpired) _buildExpiringStatus(context, document.expiryDate),
if (showComplete)
_buildCompleteStatus(certificate.expiryDate),
if (isExpiring || isExpired)
_buildExpiringStatus(context, certificate.expiryDate),
if (isNotStarted)
SizedBox(
width: double.infinity,
@@ -207,7 +218,6 @@ class CertificateCard extends StatelessWidget {
),
),
),
if (showComplete || isExpiring || isExpired) ...<Widget>[
const SizedBox(height: UiConstants.space3),
SizedBox(
@@ -281,7 +291,9 @@ class CertificateCard extends StatelessWidget {
),
if (expiryDate != null)
Text(
t.staff_certificates.card.exp(date: DateFormat('MMM d, yyyy').format(expiryDate)),
t.staff_certificates.card.exp(
date: DateFormat('MMM d, yyyy').format(expiryDate),
),
style: UiTypography.body3r.textSecondary,
),
],
@@ -308,9 +320,7 @@ class CertificateCard extends StatelessWidget {
const SizedBox(width: UiConstants.space2),
Text(
t.staff_certificates.card.expiring_soon,
style: UiTypography.body2m.copyWith(
color: UiColors.primary,
),
style: UiTypography.body2m.copyWith(color: UiColors.primary),
),
],
),
@@ -318,7 +328,9 @@ class CertificateCard extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
t.staff_certificates.card.exp(date: DateFormat('MMM d, yyyy').format(expiryDate)),
t.staff_certificates.card.exp(
date: DateFormat('MMM d, yyyy').format(expiryDate),
),
style: UiTypography.body3r.copyWith(
color: UiColors.textSecondary,
),
@@ -330,10 +342,7 @@ class CertificateCard extends StatelessWidget {
children: <Widget>[
_buildIconButton(UiIcons.eye, onView),
const SizedBox(width: UiConstants.space2),
_buildSmallOutlineButton(
t.staff_certificates.card.renew,
onUpload,
),
_buildSmallOutlineButton(t.staff_certificates.card.renew, onUpload),
],
),
],
@@ -347,12 +356,9 @@ class CertificateCard extends StatelessWidget {
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: UiColors.transparent,
border: Border.all(
color: UiColors.transparent,
),
),
child: Center(
child: Icon(icon, size: 16, color: UiColors.textSecondary),
@@ -365,10 +371,10 @@ class CertificateCard extends StatelessWidget {
return OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
side: BorderSide(color: UiColors.primary.withValues(alpha: 0.4)), // Primary with opacity
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusFull,
),
side: BorderSide(
color: UiColors.primary.withValues(alpha: 0.4),
), // Primary with opacity
shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusFull),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 0),
minimumSize: const Size(0, 32),
),
@@ -379,30 +385,19 @@ class CertificateCard extends StatelessWidget {
);
}
bool _isExpiring(DateTime? expiry) {
if (expiry == null) return false;
final int days = expiry.difference(DateTime.now()).inDays;
return days >= 0 && days <= 30; // Close to expiry but not expired
}
bool _isExpired(DateTime? expiry) {
if (expiry == null) return false;
return expiry.difference(DateTime.now()).inDays < 0;
}
int _daysUntilExpiry(DateTime? expiry) {
if (expiry == null) return 0;
return expiry.difference(DateTime.now()).inDays;
}
// Mock mapping for UI props based on ID
_CertificateUiProps _getUiProps(String id) {
switch (id) {
case 'background':
_CertificateUiProps _getUiProps(ComplianceType type) {
switch (type) {
case ComplianceType.backgroundCheck:
return _CertificateUiProps(UiIcons.fileCheck, UiColors.primary);
case 'food_handler':
case ComplianceType.foodHandler:
return _CertificateUiProps(UiIcons.utensils, UiColors.primary);
case 'rbs':
case ComplianceType.rbs:
return _CertificateUiProps(UiIcons.wine, UiColors.foreground);
default:
// Default generic icon

View File

@@ -1,21 +1,19 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// Modal for uploading or editing a certificate expiry.
class CertificateUploadModal extends StatelessWidget {
const CertificateUploadModal({
super.key,
this.document,
this.certificate,
required this.onSave,
required this.onCancel,
});
/// The document being edited, or null for a new upload.
// ignore: unused_field
final dynamic
document; // Using dynamic for now as we don't import domain here to avoid direct coupling if possible, but actually we should import domain.
// Ideally, widgets should be dumb. Let's import domain.
/// The certificate being edited, or null for a new upload.
final StaffCertificate? certificate;
final VoidCallback onSave;
final VoidCallback onCancel;

View File

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

View File

@@ -12,6 +12,7 @@ dependencies:
sdk: flutter
flutter_bloc: ^8.1.0
equatable: ^2.0.5
intl: ^0.20.0
get_it: ^7.6.0
flutter_modular: ^6.3.0

View File

@@ -0,0 +1,165 @@
# Document Upload & Verification Workflow
This document outlines the standardized workflow for handling file uploads, verification, and persistence within the Krow mobile application. This pattern is based on the `attire` module and is used as the reference for the `documents` and `certificates` modules.
## 1. Overview
The workflow follows a 4-step lifecycle:
1. **Selection**: Picking a PDF file locally via `FilePickerService`.
2. **Attestation**: Requiring the user to confirm the document is genuine before submission.
3. **Upload & Verification**: Pushing the file to storage and initiating a background verification job.
4. **Persistence**: Saving the record with its verification status to the database via Data Connect.
---
## 2. Technical Stack
| Service | Responsibility |
|---------|----------------|
| `FilePickerService` | PDF/file selection from device |
| `FileUploadService` | Uploads raw files to secure cloud storage |
| `SignedUrlService` | Generates secure internal/public links for viewing |
| `VerificationService` | Orchestrates AI or manual verification |
| `DataConnect` (Firebase) | Persists structured data and verification metadata |
---
## 3. Implementation Status
### ✅ Completed — Presentation Layer
#### Routing
- `StaffPaths.documentUpload` constant added to `core/lib/src/routing/staff/route_paths.dart`
- `StaffNavigator.toDocumentUpload({required StaffDocument document})` type-safe navigation helper added to `core/lib/src/routing/staff/navigator.dart`
#### Domain Layer
- `DocumentsRepository.uploadDocument(String documentId, String filePath)` method added to the repository interface.
- `UploadDocumentUseCase` created at `domain/usecases/upload_document_usecase.dart`, wrapping the repository call.
- `UploadDocumentArguments` value object holds `documentId` and `filePath`.
#### State Management
- `DocumentUploadStatus` enum: `initial | uploading | success | failure`
- `DocumentUploadState` (Equatable): tracks `status`, `isAttested`, `updatedDocument`, `errorMessage`
- `DocumentUploadCubit`: guards upload behind attestation check; emits success/failure; typed result as `StaffDocument`
#### UI — `DocumentUploadPage`
- Accepts `StaffDocument document` and optional `String? initialUrl` as route arguments
- Refactored into specialized widgets for maintainability:
- `DocumentFileSelector`: Handles the file picking logic and "empty state" UI.
- `DocumentSelectedCard`: Displays the selected file with "Replace" action.
- `DocumentAttestationCheckbox`: Isolated checkbox logic for legal confirmation.
- `DocumentUploadFooter`: Sticky bottom bar containing the checkbox and submission button.
- PDF file picker via `FilePickerService.pickFile(allowedExtensions: ['pdf'])`
- Attestation checkbox must be checked before the submit button is enabled
- Loading state: shows `CircularProgressIndicator` while uploading (replaces button — mirrors attire pattern)
- On success: shows `UiSnackbar` and calls `Modular.to.toDocuments()` to return to the list
- On failure: shows `UiSnackbar` with error message; stays on page for retry
#### UI Guidelines (For Documents & Certificates)
To ensure a consistent experience across all compliance uploads (documents, certificates), adhere to the following UI patterns:
1. **Header & Instructions:** Use `UiAppBar` with the item name as the title and description as the subtitle. Provide clear instructions at the top of the body (`UiTypography.body1m.textPrimary`).
2. **File Selection Card:**
- When empty: Show a neutral/primary bordered card inviting the user to pick a file.
- When selected: Show an elegant card with `UiColors.bgPopup`, rounded corners (`UiConstants.radiusLg`), bordered by `UiColors.primary`.
- The selected card must contain an identifying icon, the truncated file name, and an explicit inline action (e.g., "Replace" or "Upload") using `UiTypography.body3m.textSecondary`.
- **Do not** embed native PDF viewers or link out to external readers.
3. **Bottom Footer / Attestation:**
- Fix the attestation checkbox and the submit button to the bottom using `bottomNavigationBar` wrapped in a `SafeArea` and `Padding`.
- The submit button state must be tightly coupled to both the file presence and the attestation state.
#### Module Wiring — `StaffDocumentsModule`
- `UploadDocumentUseCase` bound as lazy singleton
- `DocumentUploadCubit` bound (non-singleton, per-use)
- Upload route registered: `StaffPaths.documentUpload``DocumentUploadPage`
- Route arguments extracted: `data['document']` as `StaffDocument`, `data['initialUrl']` as `String?`
#### `DocumentsPage` Integration
- `DocumentCard.onTap` now calls `Modular.to.toDocumentUpload(document: doc)` instead of the old placeholder `pushNamed('./details')`
#### Localization
- Added `staff_documents.upload.*` keys to `en.i18n.json` and `es.i18n.json`
- Strings: `instructions`, `submit`, `select_pdf`, `attestation`, `success`, `error`
- Codegen (`dart run slang`) produces `TranslationsStaffDocumentsUploadEn` and its Spanish counterpart
#### DocumentStatus Mapping
- `DocumentStatus` mapping is centralized in `StaffConnectorRepositoryImpl`, collapsing complex backend states into domain levels:
- `VERIFIED`, `AUTO_PASS`, `APPROVED``verified`
- `UPLOADED`, `PENDING`, `PROCESSING`, `NEEDS_REVIEW`, `EXPIRING``pending`
- `AUTO_FAIL`, `REJECTED`, `ERROR``rejected`
- `MISSING``missing`
- A new `DocumentVerificationStatus` enum captures the full granularity of the backend state for detailed UI feedback (e.g., showing "Auto Fail" vs "Rejected").
---
### ✅ Completed — Data Layer
#### Data Connect Integration
- `StaffConnectorRepository` interface updated with `getStaffDocuments()` and `upsertStaffDocument()`.
- `upsertStaffDocument` mutation in `backend/dataconnect/connector/staffDocument/mutations.gql` updated to accept `verificationId`.
- `getStaffDocumentByKey` and `listStaffDocumentsByStaffId` queries updated to include `verificationId`.
- SDK regenerated: `make dataconnect-generate-sdk ENV=dev`.
#### Repository Implementation — `StaffConnectorRepositoryImpl`
- **`getStaffDocuments()`**:
- Uses `Future.wait` to simultaneously fetch the full list of available `Document` types and the current staff's `StaffDocument` records.
- Maps the master list of `Document` entities, joining them with any existing `StaffDocument` entry.
- This ensures the UI always shows all required documents, even if they haven't been uploaded yet (status: `missing`).
- Populates `verificationId` and `verificationStatus` for presentation layer mapping.
- **`upsertStaffDocument()`**:
- Handles the upsert (create or update) of a staff document record.
- Explicitly passes `documentUrl` and `verificationId`, ensuring metadata is persisted alongside the file reference.
- **Status Mapping**:
- `_mapDocumentStatus`: Collapses backend statuses into domain `verified | pending | rejected | missing`.
- `_mapFromDCDocumentVerificationStatus`: Preserves the full granularity of the backend status for the UI/presentation layer.
#### Feature Repository — `DocumentsRepositoryImpl`
- Fixed to ensure all cross-cutting services (`FileUploadService`, `VerificationService`) are properly orchestrated.
- **Verification Integration**: When a document is uploaded, a verification job is triggered, and its `verificationId` is saved to Data Connect immediately. This allows the UI to show a "Processing" state while the background job runs.
---
## 5. DocumentStatus Mapping Reference
The backend uses a richer enum than the domain layer. The mapping is standardized as follows:
| Backend `DocumentStatus` | Domain `DocumentStatus` | Notes |
|--------------------------|-------------------------|-------|
| `VERIFIED` | `verified` | Fully approved |
| `AUTO_PASS` | `verified` | AI approved |
| `APPROVED` | `verified` | Manually approved |
| `UPLOADED` | `pending` | File received, not yet processed |
| `PENDING` | `pending` | Queued for verification |
| `PROCESSING` | `pending` | AI analysis in progress |
| `NEEDS_REVIEW` | `pending` | AI unsure, human review needed |
| `EXPIRING` | `pending` | Approaching expiry (treated as pending for renewal) |
| `MISSING` | `missing` | Document not yet uploaded |
| `AUTO_FAIL` | `rejected` | AI rejected |
| `REJECTED` | `rejected` | Manually rejected |
| `ERROR` | `rejected` | System error during verification |
---
## 6. State Management Reference
```
DocumentUploadStatus
├── initial — page just opened
├── uploading — upload + verification in progress
├── success — document saved; navigate back with result
└── failure — error; stay on page; show snackbar
```
**Cubit guards:**
- Upload is blocked unless `state.isAttested == true`
- Button is only enabled when both a file is selected AND attestation is checked
---
## 7. Future Considerations: Certificates
The implementation of the **Certificates** module should follow this exact pattern with a few key differences:
1. **Repository**: Use `StaffConnectorRepository.getStaffCertificates()` and `upsertStaffCertificate()`.
2. **Metadata**: Certificates often require an `expiryDate` and `issueingBody` which should be captured during the upload step if not already present in the schema.
3. **Verification**: If using the same `VerificationService`, ensure the `category` is set to `CERTIFICATE` instead of `DOCUMENT` to trigger appropriate verification logic.
4. **UI**: Mirror the `DocumentUploadPage` design but update the instructions and translation keys to reference certificates.

View File

@@ -6,79 +6,91 @@ import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/repositories/documents_repository.dart';
/// Implementation of [DocumentsRepository] using Data Connect.
class DocumentsRepositoryImpl
implements DocumentsRepository {
class DocumentsRepositoryImpl implements DocumentsRepository {
DocumentsRepositoryImpl({
required FileUploadService uploadService,
required SignedUrlService signedUrlService,
required VerificationService verificationService,
}) : _service = DataConnectService.instance,
_uploadService = uploadService,
_signedUrlService = signedUrlService,
_verificationService = verificationService;
DocumentsRepositoryImpl() : _service = DataConnectService.instance;
final DataConnectService _service;
final FileUploadService _uploadService;
final SignedUrlService _signedUrlService;
final VerificationService _verificationService;
@override
Future<List<domain.StaffDocument>> getDocuments() async {
return _service.run(() async {
final String staffId = await _service.getStaffId();
/// MOCK IMPLEMENTATION
/// To be replaced with real data connect query when available
return <domain.StaffDocument>[
domain.StaffDocument(
id: 'doc1',
staffId: staffId,
documentId: 'd1',
name: 'Work Permit',
description: 'Valid work permit document',
status: domain.DocumentStatus.verified,
documentUrl: 'https://example.com/documents/work_permit.pdf',
expiryDate: DateTime.now().add(const Duration(days: 365)),
),
domain.StaffDocument(
id: 'doc2',
staffId: staffId,
documentId: 'd2',
name: 'Health and Safety Training',
description: 'Certificate of completion for health and safety training',
status: domain.DocumentStatus.pending,
documentUrl: 'https://example.com/documents/health_safety.pdf',
expiryDate: DateTime.now().add(const Duration(days: 180)),
),
];
});
return _service.getStaffRepository().getStaffDocuments();
}
domain.StaffDocument _mapToDomain(
ListStaffDocumentsByStaffIdStaffDocuments doc,
) {
return domain.StaffDocument(
id: doc.id,
staffId: doc.staffId,
documentId: doc.documentId,
name: doc.document.name,
description: null, // Description not available in data source
status: _mapStatus(doc.status),
documentUrl: doc.documentUrl,
expiryDate: doc.expiryDate == null
? null
: DateTimeUtils.toDeviceTime(doc.expiryDate!.toDateTime()),
@override
Future<domain.StaffDocument> uploadDocument(
String documentId,
String filePath,
) async {
return _service.run(() async {
// 1. Upload the file to cloud storage
final FileUploadResponse uploadRes = await _uploadService.uploadFile(
filePath: filePath,
fileName: 'staff_document_${DateTime.now().millisecondsSinceEpoch}.pdf',
visibility: domain.FileVisibility.private,
);
// 2. Generate a signed URL for verification service to access the file
final SignedUrlResponse signedUrlRes = await _signedUrlService
.createSignedUrl(fileUri: uploadRes.fileUri);
// 3. Initiate verification
final String staffId = await _service.getStaffId();
final VerificationResponse verificationRes = await _verificationService
.createVerification(
fileUri: uploadRes.fileUri,
type: documentId, // Assuming documentId aligns with type
subjectType: 'STAFF',
subjectId: staffId,
);
// 4. Update/Create StaffDocument in Data Connect
await _service.getStaffRepository().upsertStaffDocument(
documentId: documentId,
documentUrl: uploadRes.fileUri,
status: domain.DocumentStatus.pending,
verificationId: verificationRes.verificationId,
);
// 5. Return the updated document state
final List<domain.StaffDocument> documents = await getDocuments();
return documents.firstWhere(
(domain.StaffDocument d) => d.documentId == documentId,
);
});
}
domain.DocumentStatus _mapStatus(EnumValue<DocumentStatus> status) {
if (status is Known<DocumentStatus>) {
switch (status.value) {
case DocumentStatus.VERIFIED:
case DocumentStatus.AUTO_PASS:
case DocumentStatus.APPROVED:
return domain.DocumentStatus.verified;
case DocumentStatus.PENDING:
case DocumentStatus.UPLOADED:
case DocumentStatus.PROCESSING:
case DocumentStatus.NEEDS_REVIEW:
case DocumentStatus.EXPIRING:
return domain.DocumentStatus.pending;
case DocumentStatus.MISSING:
return domain.DocumentStatus.missing;
case DocumentStatus.UPLOADED:
case DocumentStatus.EXPIRING:
return domain.DocumentStatus.pending;
case DocumentStatus.AUTO_FAIL:
case DocumentStatus.REJECTED:
case DocumentStatus.ERROR:
return domain.DocumentStatus.rejected;
}
}
// Default to pending for Unknown or unhandled cases
return domain.DocumentStatus.pending;
}
}

View File

@@ -6,4 +6,7 @@ import 'package:krow_domain/krow_domain.dart';
abstract interface class DocumentsRepository {
/// Fetches the list of compliance documents for the current staff member.
Future<List<StaffDocument>> getDocuments();
/// Uploads a document for the current staff member.
Future<StaffDocument> uploadDocument(String documentId, String filePath);
}

View File

@@ -0,0 +1,24 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/documents_repository.dart';
class UploadDocumentUseCase
extends UseCase<UploadDocumentArguments, StaffDocument> {
UploadDocumentUseCase(this._repository);
final DocumentsRepository _repository;
@override
Future<StaffDocument> call(UploadDocumentArguments arguments) {
return _repository.uploadDocument(arguments.documentId, arguments.filePath);
}
}
class UploadDocumentArguments {
const UploadDocumentArguments({
required this.documentId,
required this.filePath,
});
final String documentId;
final String filePath;
}

View File

@@ -0,0 +1,50 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:staff_documents/src/domain/usecases/upload_document_usecase.dart';
import 'document_upload_state.dart';
/// Manages the lifecycle of a document upload operation.
///
/// Handles attestation validation, file submission, and reports
/// success/failure back to the UI through [DocumentUploadState].
class DocumentUploadCubit extends Cubit<DocumentUploadState> {
DocumentUploadCubit(this._uploadDocumentUseCase)
: super(const DocumentUploadState());
final UploadDocumentUseCase _uploadDocumentUseCase;
/// Updates the user's attestation status.
void setAttested(bool value) {
emit(state.copyWith(isAttested: value));
}
/// Uploads the selected document if the user has attested.
///
/// Requires [state.isAttested] to be true before proceeding.
Future<void> uploadDocument(String documentId, String filePath) async {
if (!state.isAttested) return;
emit(state.copyWith(status: DocumentUploadStatus.uploading));
try {
final StaffDocument updatedDoc = await _uploadDocumentUseCase(
UploadDocumentArguments(documentId: documentId, filePath: filePath),
);
emit(
state.copyWith(
status: DocumentUploadStatus.success,
updatedDocument: updatedDoc,
),
);
} catch (e) {
emit(
state.copyWith(
status: DocumentUploadStatus.failure,
errorMessage: e.toString(),
),
);
}
}
}

View File

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

View File

@@ -0,0 +1,132 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:core_localization/core_localization.dart';
import '../blocs/document_upload/document_upload_cubit.dart';
import '../blocs/document_upload/document_upload_state.dart';
import '../widgets/document_upload/document_attestation_checkbox.dart';
import '../widgets/document_upload/document_file_selector.dart';
import '../widgets/document_upload/document_upload_footer.dart';
/// Allows staff to select and submit a single PDF document for verification.
///
/// Mirrors the pattern used in [AttireCapturePage] for a consistent upload flow:
/// file selection → attestation → submit → poll for result.
class DocumentUploadPage extends StatefulWidget {
const DocumentUploadPage({
super.key,
required this.document,
this.initialUrl,
});
/// The staff document descriptor for the item being uploaded.
final StaffDocument document;
/// Optional URL of an already-uploaded document.
final String? initialUrl;
@override
State<DocumentUploadPage> createState() => _DocumentUploadPageState();
}
class _DocumentUploadPageState extends State<DocumentUploadPage> {
String? _selectedFilePath;
final FilePickerService _filePicker = Modular.get<FilePickerService>();
Future<void> _pickFile() async {
final String? path = await _filePicker.pickFile(
allowedExtensions: <String>['pdf'],
);
if (path != null) {
setState(() {
_selectedFilePath = path;
});
}
}
@override
Widget build(BuildContext context) {
return BlocProvider<DocumentUploadCubit>(
create: (BuildContext _) => Modular.get<DocumentUploadCubit>(),
child: BlocConsumer<DocumentUploadCubit, DocumentUploadState>(
listener: (BuildContext context, DocumentUploadState state) {
if (state.status == DocumentUploadStatus.success) {
UiSnackbar.show(
context,
message: t.staff_documents.upload.success,
type: UiSnackbarType.success,
);
Modular.to.toDocuments();
} else if (state.status == DocumentUploadStatus.failure) {
UiSnackbar.show(
context,
message: state.errorMessage ?? t.staff_documents.upload.error,
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, DocumentUploadState state) {
return Scaffold(
appBar: UiAppBar(
title: widget.document.name,
subtitle: widget.document.description,
onLeadingPressed: () => Modular.to.toDocuments(),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
t.staff_documents.upload.instructions,
style: UiTypography.body1m.textPrimary,
),
const SizedBox(height: UiConstants.space6),
DocumentFileSelector(
selectedFilePath: _selectedFilePath,
onTap: _pickFile,
),
],
),
),
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
vertical: UiConstants.space4,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
DocumentAttestationCheckbox(
isAttested: state.isAttested,
onChanged: (bool value) =>
BlocProvider.of<DocumentUploadCubit>(
context,
).setAttested(value),
),
const SizedBox(height: UiConstants.space4),
DocumentUploadFooter(
isUploading:
state.status == DocumentUploadStatus.uploading,
canSubmit: _selectedFilePath != null && state.isAttested,
onSubmit: () => BlocProvider.of<DocumentUploadCubit>(
context,
).uploadDocument(widget.document.id, _selectedFilePath!),
),
],
),
),
),
);
},
),
);
}
}

View File

@@ -1,10 +1,10 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
// ignore: depend_on_referenced_packages
import 'package:core_localization/core_localization.dart';
import '../blocs/documents/documents_cubit.dart';
import '../blocs/documents/documents_state.dart';
@@ -14,35 +14,18 @@ import '../widgets/documents_progress_card.dart';
class DocumentsPage extends StatelessWidget {
const DocumentsPage({super.key});
@override
Widget build(BuildContext context) {
final DocumentsCubit cubit = Modular.get<DocumentsCubit>();
if (cubit.state.status == DocumentsStatus.initial) {
cubit.loadDocuments();
}
return Scaffold(
appBar: AppBar(
elevation: 0,
leading: IconButton(
icon: const Icon(UiIcons.arrowLeft, color: UiColors.iconSecondary),
onPressed: () => Modular.to.pop(),
appBar: UiAppBar(
title: t.staff_documents.title,
showBackButton: true,
onLeadingPressed: () => Modular.to.toProfile(),
),
title: Text(
t.staff_documents.title,
style: UiTypography.headline3m.copyWith(
color: UiColors.textPrimary,
),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1.0),
child: Container(color: UiColors.border, height: 1.0),
),
),
body: BlocBuilder<DocumentsCubit, DocumentsState>(
bloc: cubit,
body: BlocProvider<DocumentsCubit>(
create: (BuildContext context) =>
Modular.get<DocumentsCubit>()..loadDocuments(),
child: BlocBuilder<DocumentsCubit, DocumentsState>(
builder: (BuildContext context, DocumentsState state) {
if (state.status == DocumentsStatus.loading) {
return const Center(
@@ -59,10 +42,14 @@ class DocumentsPage extends StatelessWidget {
state.errorMessage != null
? (state.errorMessage!.contains('errors.')
? translateErrorKey(state.errorMessage!)
: t.staff_documents.list.error(message: state.errorMessage!))
: t.staff_documents.list.error(
message: state.errorMessage!,
))
: t.staff_documents.list.error(message: 'Unknown'),
textAlign: TextAlign.center,
style: UiTypography.body1m.copyWith(color: UiColors.textSecondary),
style: UiTypography.body1m.copyWith(
color: UiColors.textSecondary,
),
),
),
);
@@ -71,7 +58,9 @@ class DocumentsPage extends StatelessWidget {
return Center(
child: Text(
t.staff_documents.list.empty,
style: UiTypography.body1m.copyWith(color: UiColors.textSecondary),
style: UiTypography.body1m.copyWith(
color: UiColors.textSecondary,
),
),
);
}
@@ -91,13 +80,14 @@ class DocumentsPage extends StatelessWidget {
...state.documents.map(
(StaffDocument doc) => DocumentCard(
document: doc,
onTap: () => Modular.to.pushNamed('./details', arguments: doc.id),
onTap: () => Modular.to.toDocumentUpload(document: doc),
),
),
],
);
},
),
),
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
// ignore: depend_on_referenced_packages
import 'package:core_localization/core_localization.dart';
/// A labeled checkbox confirming the document is genuine before submission.
///
/// Renders an attestation statement alongside a checkbox. The [onChanged]
/// callback is fired whenever the user toggles the checkbox.
class DocumentAttestationCheckbox extends StatelessWidget {
const DocumentAttestationCheckbox({
super.key,
required this.isAttested,
required this.onChanged,
});
/// Whether the user has currently checked the attestation box.
final bool isAttested;
/// Called with the new value when the checkbox is toggled.
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return Row(
children: <Widget>[
Checkbox(
value: isAttested,
onChanged: (bool? value) => onChanged(value ?? false),
activeColor: UiColors.primary,
),
Expanded(
child: Text(
t.staff_documents.upload.attestation,
style: UiTypography.body2r.textPrimary,
),
),
],
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
// ignore: depend_on_referenced_packages
import 'package:core_localization/core_localization.dart';
import 'document_selected_card.dart';
/// Displays a tappable card that prompts the user to pick a PDF file.
///
/// Shows the selected file name when a file has been chosen, or an
/// upload icon with a prompt when no file is selected yet.
class DocumentFileSelector extends StatelessWidget {
const DocumentFileSelector({
super.key,
required this.onTap,
this.selectedFilePath,
});
/// Called when the user taps the selector to pick a file.
final VoidCallback onTap;
/// The local path of the currently selected file, or null if none chosen.
final String? selectedFilePath;
bool get _hasFile => selectedFilePath != null;
@override
Widget build(BuildContext context) {
if (_hasFile) {
return InkWell(
onTap: onTap,
borderRadius: UiConstants.radiusLg,
child: DocumentSelectedCard(selectedFilePath: selectedFilePath!),
);
}
return InkWell(
onTap: onTap,
borderRadius: UiConstants.radiusLg,
child: Container(
height: 180,
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Icon(
UiIcons.upload,
size: 48,
color: UiColors.textSecondary,
),
const SizedBox(height: UiConstants.space2),
Text(
t.staff_documents.upload.select_pdf,
style: UiTypography.body2m.textError,
textAlign: TextAlign.center,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,46 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A card that displays the selected document file name and an icon.
class DocumentSelectedCard extends StatelessWidget {
const DocumentSelectedCard({super.key, required this.selectedFilePath});
/// The local path of the currently selected file.
final String selectedFilePath;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.primary),
),
child: Row(
children: <Widget>[
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(UiConstants.space2),
),
child: const Center(
child: Icon(UiIcons.file, color: UiColors.primary, size: 20),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(
selectedFilePath.split('/').last,
style: UiTypography.body1m.textPrimary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,47 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
// ignore: depend_on_referenced_packages
import 'package:core_localization/core_localization.dart';
/// Renders the bottom action area of the document upload page.
///
/// Shows a [CircularProgressIndicator] while [isUploading] is true,
/// otherwise shows a primary submit button. The button is only enabled
/// when both a file has been selected and the user has attested.
class DocumentUploadFooter extends StatelessWidget {
const DocumentUploadFooter({
super.key,
required this.isUploading,
required this.canSubmit,
required this.onSubmit,
});
/// Whether the upload is currently in progress.
final bool isUploading;
/// Whether all preconditions (file selected + attested) have been met.
final bool canSubmit;
/// Called when the user taps the submit button.
final VoidCallback onSubmit;
@override
Widget build(BuildContext context) {
if (isUploading) {
return const Center(
child: Padding(
padding: EdgeInsets.all(UiConstants.space4),
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(UiColors.primary),
),
),
);
}
return UiButton.primary(
fullWidth: true,
onPressed: canSubmit ? onSubmit : null,
text: t.staff_documents.upload.submit,
);
}
}

View File

@@ -1,17 +1,30 @@
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'data/repositories_impl/documents_repository_impl.dart';
import 'domain/repositories/documents_repository.dart';
import 'domain/usecases/get_documents_usecase.dart';
import 'domain/usecases/upload_document_usecase.dart';
import 'presentation/blocs/documents/documents_cubit.dart';
import 'presentation/blocs/document_upload/document_upload_cubit.dart';
import 'presentation/pages/documents_page.dart';
import 'presentation/pages/document_upload_page.dart';
class StaffDocumentsModule extends Module {
@override
void binds(Injector i) {
i.addLazySingleton<DocumentsRepository>(DocumentsRepositoryImpl.new);
i.addLazySingleton<DocumentsRepository>(
() => DocumentsRepositoryImpl(
uploadService: i.get<FileUploadService>(),
signedUrlService: i.get<SignedUrlService>(),
verificationService: i.get<VerificationService>(),
),
);
i.addLazySingleton(GetDocumentsUseCase.new);
i.addLazySingleton(DocumentsCubit.new);
i.addLazySingleton(UploadDocumentUseCase.new);
i.add(DocumentsCubit.new);
i.add(DocumentUploadCubit.new);
}
@override
@@ -20,5 +33,12 @@ class StaffDocumentsModule extends Module {
StaffPaths.childRoute(StaffPaths.documents, StaffPaths.documents),
child: (_) => const DocumentsPage(),
);
r.child(
StaffPaths.childRoute(StaffPaths.documents, StaffPaths.documentUpload),
child: (_) => DocumentUploadPage(
document: r.args.data['document'] as StaffDocument,
initialUrl: r.args.data['initialUrl'] as String?,
),
);
}
}

View File

@@ -17,7 +17,6 @@ dependencies:
equatable: ^2.0.5
firebase_auth: ^6.1.4
firebase_data_connect: ^0.2.2+2
# Architecture Packages
design_system:
path: ../../../../../design_system

View File

@@ -58,6 +58,7 @@ mutation upsertStaffDocument(
$status: DocumentStatus!
$documentUrl: String
$expiryDate: Timestamp
$verificationId: String
) @auth(level: USER) {
staffDocument_upsert(
data: {
@@ -67,6 +68,7 @@ mutation upsertStaffDocument(
status: $status
documentUrl: $documentUrl
expiryDate: $expiryDate
verificationId: $verificationId
}
)
}

View File

@@ -11,6 +11,7 @@ query getStaffDocumentByKey(
status
documentUrl
expiryDate
verificationId
createdAt
updatedAt
document {
@@ -39,6 +40,7 @@ query listStaffDocumentsByStaffId(
status
documentUrl
expiryDate
verificationId
createdAt
updatedAt
document {

View File

@@ -14,6 +14,7 @@ type Document @table(name: "documents") {
name: String!
description: String
documentType: DocumentType!
isMandatory: Boolean @default(expr: "false")
createdAt: Timestamp @default(expr: "request.time")
updatedAt: Timestamp @default(expr: "request.time")
createdBy: String

View File

@@ -1,6 +1,13 @@
enum DocumentStatus {
UPLOADED
PENDING
PROCESSING
AUTO_PASS
AUTO_FAIL
NEEDS_REVIEW
APPROVED
REJECTED
ERROR
UPLOADED
EXPIRING
MISSING
VERIFIED
@@ -12,9 +19,16 @@ type StaffDocument @table(name: "staff_documents", key: ["staffId", "documentId"
staffName: String!
documentId: UUID!
document: Document! @ref(fields: "documentId", references: "id")
status: DocumentStatus!
status: DocumentStatus! @default(expr: "'PENDING'")
documentUrl: String
expiryDate: Timestamp
# Verification Metadata (Align with Attire flow)
verificationId: String
verifiedAt: Timestamp
rejectionReason: String
createdAt: Timestamp @default(expr: "request.time")
updatedAt: Timestamp @default(expr: "request.time")
createdBy: String