feat: implement document upload functionality with dedicated UI, state management, and routing.
This commit is contained in:
@@ -225,6 +225,21 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
pushNamed(StaffPaths.documents);
|
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.
|
/// Pushes the certificates management page.
|
||||||
///
|
///
|
||||||
/// Manage professional certificates (e.g., food handling, CPR).
|
/// Manage professional certificates (e.g., food handling, CPR).
|
||||||
|
|||||||
@@ -164,6 +164,9 @@ class StaffPaths {
|
|||||||
/// Store ID, work permits, and other required documentation.
|
/// Store ID, work permits, and other required documentation.
|
||||||
static const String documents = '/worker-main/documents/';
|
static const String documents = '/worker-main/documents/';
|
||||||
|
|
||||||
|
/// Document upload page.
|
||||||
|
static const String documentUpload = '/worker-main/documents/upload/';
|
||||||
|
|
||||||
/// Certificates management - professional certifications.
|
/// Certificates management - professional certifications.
|
||||||
///
|
///
|
||||||
/// Manage professional certificates (e.g., food handling, CPR, etc.).
|
/// Manage professional certificates (e.g., food handling, CPR, etc.).
|
||||||
|
|||||||
@@ -1050,6 +1050,14 @@
|
|||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"missing": "Missing",
|
"missing": "Missing",
|
||||||
"rejected": "Rejected"
|
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"staff_certificates": {
|
"staff_certificates": {
|
||||||
|
|||||||
@@ -1050,6 +1050,14 @@
|
|||||||
"pending": "Pendiente",
|
"pending": "Pendiente",
|
||||||
"missing": "Faltante",
|
"missing": "Faltante",
|
||||||
"rejected": "Rechazado"
|
"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"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"staff_certificates": {
|
"staff_certificates": {
|
||||||
|
|||||||
@@ -9,11 +9,10 @@ import '../../domain/repositories/certificates_repository.dart';
|
|||||||
///
|
///
|
||||||
/// This class handles the communication with the backend via [DataConnectService].
|
/// This class handles the communication with the backend via [DataConnectService].
|
||||||
/// It maps raw generated data types to clean [domain.StaffDocument] entities.
|
/// It maps raw generated data types to clean [domain.StaffDocument] entities.
|
||||||
class CertificatesRepositoryImpl
|
class CertificatesRepositoryImpl implements CertificatesRepository {
|
||||||
implements CertificatesRepository {
|
|
||||||
|
|
||||||
/// Creates a [CertificatesRepositoryImpl].
|
/// Creates a [CertificatesRepositoryImpl].
|
||||||
CertificatesRepositoryImpl() : _service = DataConnectService.instance;
|
CertificatesRepositoryImpl() : _service = DataConnectService.instance;
|
||||||
|
|
||||||
/// The Data Connect service instance.
|
/// The Data Connect service instance.
|
||||||
final DataConnectService _service;
|
final DataConnectService _service;
|
||||||
|
|
||||||
@@ -23,15 +22,20 @@ class CertificatesRepositoryImpl
|
|||||||
final String staffId = await _service.getStaffId();
|
final String staffId = await _service.getStaffId();
|
||||||
|
|
||||||
// Execute the query via DataConnect generated SDK
|
// Execute the query via DataConnect generated SDK
|
||||||
final QueryResult<ListStaffDocumentsByStaffIdData, ListStaffDocumentsByStaffIdVariables> result =
|
final QueryResult<
|
||||||
await _service.connector
|
ListStaffDocumentsByStaffIdData,
|
||||||
|
ListStaffDocumentsByStaffIdVariables
|
||||||
|
>
|
||||||
|
result = await _service.connector
|
||||||
.listStaffDocumentsByStaffId(staffId: staffId)
|
.listStaffDocumentsByStaffId(staffId: staffId)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
// Map the generated SDK types to pure Domain entities
|
// Map the generated SDK types to pure Domain entities
|
||||||
return result.data.staffDocuments
|
return result.data.staffDocuments
|
||||||
.map((ListStaffDocumentsByStaffIdStaffDocuments doc) =>
|
.map(
|
||||||
_mapToDomain(doc))
|
(ListStaffDocumentsByStaffIdStaffDocuments doc) =>
|
||||||
|
_mapToDomain(doc),
|
||||||
|
)
|
||||||
.toList();
|
.toList();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -70,6 +74,27 @@ class CertificatesRepositoryImpl
|
|||||||
// 'EXPIRING' in backend is treated as 'verified' in domain,
|
// 'EXPIRING' in backend is treated as 'verified' in domain,
|
||||||
// as the document is strictly valid until the expiry date.
|
// as the document is strictly valid until the expiry date.
|
||||||
return domain.DocumentStatus.verified;
|
return domain.DocumentStatus.verified;
|
||||||
|
case DocumentStatus.PROCESSING:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
case DocumentStatus.AUTO_PASS:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
case DocumentStatus.AUTO_FAIL:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
case DocumentStatus.NEEDS_REVIEW:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
case DocumentStatus.APPROVED:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
case DocumentStatus.REJECTED:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
case DocumentStatus.ERROR:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback for unknown status
|
// Fallback for unknown status
|
||||||
|
|||||||
@@ -1,68 +1,161 @@
|
|||||||
# Document Upload & Verification Workflow
|
# 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 should be used as a reference for the `certificates` module.
|
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
|
## 1. Overview
|
||||||
|
|
||||||
The workflow follows a 4-step lifecycle:
|
The workflow follows a 4-step lifecycle:
|
||||||
1. **Selection**: Picking or capturing a file (PDF/Image) locally.
|
|
||||||
2. **Preview**: Allowing the user to review the document/photo before submission.
|
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.
|
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.
|
4. **Persistence**: Saving the record with its verification status to the database via Data Connect.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. Technical Stack
|
## 2. Technical Stack
|
||||||
- **`FilePickerService`**: Handles PDF/File selection from the device.
|
|
||||||
- **`CameraService` / `GalleryService`**: Handles image capturing (if applicable).
|
| Service | Responsibility |
|
||||||
- **`FileUploadService`**: Uploads raw files to our secure cloud storage.
|
|---------|----------------|
|
||||||
- **`SignedUrlService`**: Generates secure internal/public links for viewing.
|
| `FilePickerService` | PDF/file selection from device |
|
||||||
- **`VerificationService`**: Orchestrates the automated (AI) or manual verification of the document.
|
| `FileUploadService` | Uploads raw files to secure cloud storage |
|
||||||
- **`DataConnect` (Firebase)**: Persists the structured data and verification metadata.
|
| `SignedUrlService` | Generates secure internal/public links for viewing |
|
||||||
|
| `VerificationService` | Orchestrates AI or manual verification |
|
||||||
|
| `DataConnect` (Firebase) | Persists structured data and verification metadata |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Implementation Steps
|
## 3. Implementation Status
|
||||||
|
|
||||||
### A. UI Layer
|
### ✅ Completed — Presentation Layer
|
||||||
1. **Selection**:
|
|
||||||
- Use `FilePickerService.pickFile(allowedExtensions: ['pdf'])`.
|
|
||||||
- Store the `localPath` in the widget state.
|
|
||||||
2. **Stateless Preview**:
|
|
||||||
- Provide an inline previewer (e.g., `PdfPreviewWidget`) so the user can verify the file contents.
|
|
||||||
3. **Submission**:
|
|
||||||
- Trigger a Cubit/Bloc event which calls the `UploadDocumentUseCase`.
|
|
||||||
- Show a global or inline progress indicator during the "Upload -> Verify -> Save" sequence.
|
|
||||||
|
|
||||||
### B. Domain Layer
|
#### Routing
|
||||||
1. **UseCase**:
|
- `StaffPaths.documentUpload` constant added to `core/lib/src/routing/staff/route_paths.dart`
|
||||||
- Manage the sequence of repository calls.
|
- `StaffNavigator.toDocumentUpload({required StaffDocument document})` type-safe navigation helper added to `core/lib/src/routing/staff/navigator.dart`
|
||||||
- Handle domain-specific validation (e.g., checking if the document is mandatory).
|
|
||||||
|
|
||||||
### C. Data Layer (The Repository Pattern)
|
#### Domain Layer
|
||||||
The `Repository.uploadDocument` method should perform the following:
|
- `DocumentsRepository.uploadDocument(String documentId, String filePath)` method added to the repository interface.
|
||||||
1. **Upload**: Call `FileUploadService` to get a `fileUri`.
|
- `UploadDocumentUseCase` created at `domain/usecases/upload_document_usecase.dart`, wrapping the repository call.
|
||||||
2. **Link**: Call `SignedUrlService.createSignedUrl` to generate a `documentUrl`.
|
- `UploadDocumentArguments` value object holds `documentId` and `filePath`.
|
||||||
3. **Verify**: Call `VerificationService.createVerification` with the `fileUri` and relevant rules (e.g., `documentType`).
|
|
||||||
4. **Poll (Optional but Recommended)**: Poll the `VerificationService` for status updates for ~10 seconds to provide immediate feedback to the user.
|
#### State Management
|
||||||
5. **Persist**: Call Data Connect's `upsertStaffDocument` mutation with:
|
- `DocumentUploadStatus` enum: `initial | uploading | success | failure`
|
||||||
- `documentId`
|
- `DocumentUploadState` (Equatable): tracks `status`, `isAttested`, `updatedDocument`, `errorMessage`
|
||||||
- `documentUrl`
|
- `DocumentUploadCubit`: guards upload behind attestation check; emits success/failure; typed result as `StaffDocument`
|
||||||
- `verificationId`
|
|
||||||
- `verificationStatus` (e.g., `PENDING` or `APPROVED`)
|
#### UI — `DocumentUploadPage`
|
||||||
|
- Accepts `StaffDocument document` and optional `String? initialUrl` as route arguments
|
||||||
|
- PDF file picker via `FilePickerService.pickFile(allowedExtensions: ['pdf'])`
|
||||||
|
- File selector card: shows file name when selected or a prompt icon when empty
|
||||||
|
- 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.pop(updatedDocument)` to return data to caller
|
||||||
|
- On failure: shows `UiSnackbar` with error message; stays on page for retry
|
||||||
|
|
||||||
|
#### 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
|
||||||
|
- `_mapDocumentStatus` in `DocumentsRepositoryImpl` now handles all backend enum variants:
|
||||||
|
- `VERIFIED` → `verified`
|
||||||
|
- `UPLOADED`, `PENDING`, `EXPIRING` → `pending` (treated as in-progress)
|
||||||
|
- `PROCESSING`, `AUTO_PASS`, `AUTO_FAIL`, `NEEDS_REVIEW`, `APPROVED`, `REJECTED`, `ERROR` → presence acknowledged with `UnimplementedError` (TODO)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. State Management (Cubit/Bloc)
|
## 4. Pending — Data Layer (Real Repository Implementation)
|
||||||
- **`status`**: Track `loading`, `uploading`, `success`, and `failure`.
|
|
||||||
- **`errorMessage`**: Store localized error keys from `BlocErrorHandler`.
|
> The `DocumentsRepositoryImpl.uploadDocument` method is currently a **mock**. Replace it with the following sequence:
|
||||||
- **`verificationId`**: Store the ID returned from the server to allow later manual refreshes.
|
|
||||||
|
### Step-by-step Repository Implementation
|
||||||
|
|
||||||
|
```dart
|
||||||
|
@override
|
||||||
|
Future<domain.StaffDocument> uploadDocument(
|
||||||
|
String documentId,
|
||||||
|
String filePath,
|
||||||
|
) async {
|
||||||
|
return _service.execute(() async {
|
||||||
|
// 1. Upload file to cloud storage
|
||||||
|
final String fileUri = await _fileUploadService.upload(filePath);
|
||||||
|
|
||||||
|
// 2. Generate a signed URL for viewing
|
||||||
|
final String documentUrl =
|
||||||
|
await _signedUrlService.createSignedUrl(fileUri);
|
||||||
|
|
||||||
|
// 3. Initiate verification job
|
||||||
|
final String verificationId = await _verificationService
|
||||||
|
.createVerification(fileUri: fileUri, documentType: documentId);
|
||||||
|
|
||||||
|
// 4. (Optional) Poll for immediate feedback (~10s)
|
||||||
|
// final VerificationStatus status =
|
||||||
|
// await _verificationService.poll(verificationId, maxSeconds: 10);
|
||||||
|
|
||||||
|
// 5. Persist to Data Connect
|
||||||
|
final String staffId = await _service.getStaffId();
|
||||||
|
await _service.connector.upsertStaffDocument(
|
||||||
|
staffId: staffId,
|
||||||
|
documentId: documentId,
|
||||||
|
documentUrl: documentUrl,
|
||||||
|
verificationId: verificationId,
|
||||||
|
status: DocumentStatus.UPLOADED,
|
||||||
|
);
|
||||||
|
|
||||||
|
return domain.StaffDocument(
|
||||||
|
id: documentId,
|
||||||
|
staffId: staffId,
|
||||||
|
documentId: documentId,
|
||||||
|
name: '', // populated from local cache or refetch
|
||||||
|
status: domain.DocumentStatus.pending,
|
||||||
|
documentUrl: documentUrl,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Metadata Mapping
|
## 5. DocumentStatus Mapping
|
||||||
Ensure the `DocumentStatus` or `VerificationStatus` enum aligns with the backend definition:
|
|
||||||
- `PENDING`: Waiting for job to start.
|
The backend uses a richer enum than the domain layer. The mapping in `_mapDocumentStatus` should be finalized as follows:
|
||||||
- `PROCESSING`: AI is currently analyzing.
|
|
||||||
- `APPROVED`: Document is valid.
|
| Backend `DocumentStatus` | Domain `DocumentStatus` | Notes |
|
||||||
- `REJECTED`: Document is invalid (should check `rejectionReason`).
|
|--------------------------|-------------------------|-------|
|
||||||
- `NEEDS_REVIEW`: AI is unsure, manual check required.
|
| `VERIFIED` | `verified` | Fully approved |
|
||||||
|
| `UPLOADED` | `pending` | File received, not yet processed |
|
||||||
|
| `PENDING` | `pending` | Queued for verification |
|
||||||
|
| `PROCESSING` | `pending` | AI analysis in progress |
|
||||||
|
| `AUTO_PASS` | `verified` | AI approved |
|
||||||
|
| `APPROVED` | `verified` | Manually approved |
|
||||||
|
| `NEEDS_REVIEW` | `pending` | AI unsure, human review needed |
|
||||||
|
| `AUTO_FAIL` | `rejected` | AI rejected |
|
||||||
|
| `REJECTED` | `rejected` | Manually rejected; check `rejectionReason` |
|
||||||
|
| `ERROR` | `rejected` | System error during verification |
|
||||||
|
| `EXPIRING` | `verified` | Valid but approaching expiry |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ import 'package:krow_domain/krow_domain.dart' as domain;
|
|||||||
import '../../domain/repositories/documents_repository.dart';
|
import '../../domain/repositories/documents_repository.dart';
|
||||||
|
|
||||||
/// Implementation of [DocumentsRepository] using Data Connect.
|
/// Implementation of [DocumentsRepository] using Data Connect.
|
||||||
class DocumentsRepositoryImpl
|
class DocumentsRepositoryImpl implements DocumentsRepository {
|
||||||
implements DocumentsRepository {
|
|
||||||
|
|
||||||
DocumentsRepositoryImpl() : _service = DataConnectService.instance;
|
DocumentsRepositoryImpl() : _service = DataConnectService.instance;
|
||||||
final DataConnectService _service;
|
final DataConnectService _service;
|
||||||
|
|
||||||
@@ -35,16 +33,35 @@ class DocumentsRepositoryImpl
|
|||||||
staffId: staffId,
|
staffId: staffId,
|
||||||
documentId: 'd2',
|
documentId: 'd2',
|
||||||
name: 'Health and Safety Training',
|
name: 'Health and Safety Training',
|
||||||
description: 'Certificate of completion for health and safety training',
|
description:
|
||||||
|
'Certificate of completion for health and safety training',
|
||||||
status: domain.DocumentStatus.pending,
|
status: domain.DocumentStatus.pending,
|
||||||
documentUrl: 'https://example.com/documents/health_safety.pdf',
|
documentUrl: 'https://example.com/documents/health_safety.pdf',
|
||||||
expiryDate: DateTime.now().add(const Duration(days: 180)),
|
expiryDate: DateTime.now().add(const Duration(days: 180)),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<domain.StaffDocument> uploadDocument(
|
||||||
|
String documentId,
|
||||||
|
String filePath,
|
||||||
|
) async {
|
||||||
|
// Mock upload delay
|
||||||
|
await Future.delayed(const Duration(seconds: 2));
|
||||||
|
|
||||||
|
final String staffId = await _service.getStaffId();
|
||||||
|
return domain.StaffDocument(
|
||||||
|
id: 'mock_uploaded_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
staffId: staffId,
|
||||||
|
documentId: documentId,
|
||||||
|
name: 'Uploaded Document',
|
||||||
|
status: domain.DocumentStatus.pending,
|
||||||
|
documentUrl: 'https://example.com/mock.pdf',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
domain.StaffDocument _mapToDomain(
|
domain.StaffDocument _mapToDomain(
|
||||||
ListStaffDocumentsByStaffIdStaffDocuments doc,
|
ListStaffDocumentsByStaffIdStaffDocuments doc,
|
||||||
) {
|
) {
|
||||||
@@ -74,11 +91,30 @@ class DocumentsRepositoryImpl
|
|||||||
case DocumentStatus.UPLOADED:
|
case DocumentStatus.UPLOADED:
|
||||||
case DocumentStatus.EXPIRING:
|
case DocumentStatus.EXPIRING:
|
||||||
return domain.DocumentStatus.pending;
|
return domain.DocumentStatus.pending;
|
||||||
|
case DocumentStatus.PROCESSING:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
case DocumentStatus.AUTO_PASS:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
case DocumentStatus.AUTO_FAIL:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
case DocumentStatus.NEEDS_REVIEW:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
case DocumentStatus.APPROVED:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
case DocumentStatus.REJECTED:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
|
case DocumentStatus.ERROR:
|
||||||
|
// TODO: Handle this case.
|
||||||
|
throw UnimplementedError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Default to pending for Unknown or unhandled cases
|
// Default to pending for Unknown or unhandled cases
|
||||||
return domain.DocumentStatus.pending;
|
return domain.DocumentStatus.pending;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,7 @@ import 'package:krow_domain/krow_domain.dart';
|
|||||||
abstract interface class DocumentsRepository {
|
abstract interface class DocumentsRepository {
|
||||||
/// Fetches the list of compliance documents for the current staff member.
|
/// Fetches the list of compliance documents for the current staff member.
|
||||||
Future<List<StaffDocument>> getDocuments();
|
Future<List<StaffDocument>> getDocuments();
|
||||||
|
|
||||||
|
/// Uploads a document for the current staff member.
|
||||||
|
Future<StaffDocument> uploadDocument(String documentId, String filePath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
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/document_upload/document_upload_cubit.dart';
|
||||||
|
import '../blocs/document_upload/document_upload_state.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.pop(state.updatedDocument);
|
||||||
|
} 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,
|
||||||
|
onLeadingPressed: () => Modular.to.toDocuments(),
|
||||||
|
),
|
||||||
|
body: Padding(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
t.staff_documents.upload.instructions,
|
||||||
|
style: UiTypography.body1m.textPrimary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
if (widget.document.description != null)
|
||||||
|
Text(
|
||||||
|
widget.document.description!,
|
||||||
|
style: UiTypography.body2r.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
_buildFileSelector(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
_buildAttestationCheckbox(context, state),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
if (state.status == DocumentUploadStatus.uploading)
|
||||||
|
const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(UiConstants.space4),
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
UiColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
UiButton.primary(
|
||||||
|
fullWidth: true,
|
||||||
|
onPressed: _selectedFilePath != null && state.isAttested
|
||||||
|
? () => BlocProvider.of<DocumentUploadCubit>(context)
|
||||||
|
.uploadDocument(
|
||||||
|
widget.document.id,
|
||||||
|
_selectedFilePath!,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
text: t.staff_documents.upload.submit,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFileSelector() {
|
||||||
|
return InkWell(
|
||||||
|
onTap: _pickFile,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
child: Container(
|
||||||
|
height: 180,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.bgPopup,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(
|
||||||
|
color: _selectedFilePath != null
|
||||||
|
? UiColors.primary
|
||||||
|
: UiColors.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: <Widget>[
|
||||||
|
Icon(
|
||||||
|
_selectedFilePath != null ? UiIcons.file : UiIcons.upload,
|
||||||
|
size: 48,
|
||||||
|
color: _selectedFilePath != null
|
||||||
|
? UiColors.primary
|
||||||
|
: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
Text(
|
||||||
|
_selectedFilePath != null
|
||||||
|
? _selectedFilePath!.split('/').last
|
||||||
|
: t.staff_documents.upload.select_pdf,
|
||||||
|
style: UiTypography.body2m.copyWith(
|
||||||
|
color: _selectedFilePath != null
|
||||||
|
? UiColors.primary
|
||||||
|
: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAttestationCheckbox(
|
||||||
|
BuildContext context,
|
||||||
|
DocumentUploadState state,
|
||||||
|
) {
|
||||||
|
return Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Checkbox(
|
||||||
|
value: state.isAttested,
|
||||||
|
onChanged: (bool? value) => BlocProvider.of<DocumentUploadCubit>(
|
||||||
|
context,
|
||||||
|
).setAttested(value ?? false),
|
||||||
|
activeColor: UiColors.primary,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
t.staff_documents.upload.attestation,
|
||||||
|
style: UiTypography.body2r.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,8 +80,7 @@ class DocumentsPage extends StatelessWidget {
|
|||||||
...state.documents.map(
|
...state.documents.map(
|
||||||
(StaffDocument doc) => DocumentCard(
|
(StaffDocument doc) => DocumentCard(
|
||||||
document: doc,
|
document: doc,
|
||||||
onTap: () =>
|
onTap: () => Modular.to.toDocumentUpload(document: doc),
|
||||||
Modular.to.pushNamed('./details', arguments: doc.id),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import 'data/repositories_impl/documents_repository_impl.dart';
|
import 'data/repositories_impl/documents_repository_impl.dart';
|
||||||
import 'domain/repositories/documents_repository.dart';
|
import 'domain/repositories/documents_repository.dart';
|
||||||
import 'domain/usecases/get_documents_usecase.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/documents/documents_cubit.dart';
|
||||||
|
import 'presentation/blocs/document_upload/document_upload_cubit.dart';
|
||||||
import 'presentation/pages/documents_page.dart';
|
import 'presentation/pages/documents_page.dart';
|
||||||
|
import 'presentation/pages/document_upload_page.dart';
|
||||||
|
|
||||||
class StaffDocumentsModule extends Module {
|
class StaffDocumentsModule extends Module {
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
i.addLazySingleton<DocumentsRepository>(DocumentsRepositoryImpl.new);
|
i.addLazySingleton<DocumentsRepository>(DocumentsRepositoryImpl.new);
|
||||||
i.addLazySingleton(GetDocumentsUseCase.new);
|
i.addLazySingleton(GetDocumentsUseCase.new);
|
||||||
i.addLazySingleton(DocumentsCubit.new);
|
i.addLazySingleton(UploadDocumentUseCase.new);
|
||||||
|
|
||||||
|
i.add(DocumentsCubit.new);
|
||||||
|
i.add(DocumentUploadCubit.new);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -20,5 +27,12 @@ class StaffDocumentsModule extends Module {
|
|||||||
StaffPaths.childRoute(StaffPaths.documents, StaffPaths.documents),
|
StaffPaths.childRoute(StaffPaths.documents, StaffPaths.documents),
|
||||||
child: (_) => const DocumentsPage(),
|
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?,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user