diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index 0cdc11e0..5456d1e6 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -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: { + 'document': document, + 'initialUrl': initialUrl, + }, + ); + } + /// Pushes the certificates management page. /// /// Manage professional certificates (e.g., food handling, CPR). diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index f3bb8428..89ec84a0 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -164,6 +164,9 @@ 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.). diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 740e0370..c077ea63 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1050,6 +1050,14 @@ "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" } }, "staff_certificates": { diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 9a991a74..1dd68c91 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1050,6 +1050,14 @@ "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" } }, "staff_certificates": { diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart index afbb94c5..08ab2e55 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/certificates/lib/src/data/repositories_impl/certificates_repository_impl.dart @@ -9,11 +9,10 @@ import '../../domain/repositories/certificates_repository.dart'; /// /// This class handles the communication with the backend via [DataConnectService]. /// It maps raw generated data types to clean [domain.StaffDocument] entities. -class CertificatesRepositoryImpl - implements CertificatesRepository { - +class CertificatesRepositoryImpl implements CertificatesRepository { /// Creates a [CertificatesRepositoryImpl]. CertificatesRepositoryImpl() : _service = DataConnectService.instance; + /// The Data Connect service instance. final DataConnectService _service; @@ -23,15 +22,20 @@ class CertificatesRepositoryImpl final String staffId = await _service.getStaffId(); // Execute the query via DataConnect generated SDK - final QueryResult result = - await _service.connector - .listStaffDocumentsByStaffId(staffId: staffId) - .execute(); + final QueryResult< + ListStaffDocumentsByStaffIdData, + ListStaffDocumentsByStaffIdVariables + > + result = await _service.connector + .listStaffDocumentsByStaffId(staffId: staffId) + .execute(); // Map the generated SDK types to pure Domain entities return result.data.staffDocuments - .map((ListStaffDocumentsByStaffIdStaffDocuments doc) => - _mapToDomain(doc)) + .map( + (ListStaffDocumentsByStaffIdStaffDocuments doc) => + _mapToDomain(doc), + ) .toList(); }); } @@ -67,9 +71,30 @@ class CertificatesRepositoryImpl case DocumentStatus.UPLOADED: return domain.DocumentStatus.pending; case DocumentStatus.EXPIRING: - // '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. 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 diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/IMPLEMENTATION_WORKFLOW.md b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/IMPLEMENTATION_WORKFLOW.md index 9734fcb8..9c6ea627 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/IMPLEMENTATION_WORKFLOW.md +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/IMPLEMENTATION_WORKFLOW.md @@ -1,68 +1,161 @@ # 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 + 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. -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. + +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 -- **`FilePickerService`**: Handles PDF/File selection from the device. -- **`CameraService` / `GalleryService`**: Handles image capturing (if applicable). -- **`FileUploadService`**: Uploads raw files to our secure cloud storage. -- **`SignedUrlService`**: Generates secure internal/public links for viewing. -- **`VerificationService`**: Orchestrates the automated (AI) or manual verification of the document. -- **`DataConnect` (Firebase)**: Persists the structured data and verification metadata. + +| 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 Steps +## 3. Implementation Status -### A. UI 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. +### ✅ Completed — Presentation Layer -### B. Domain Layer -1. **UseCase**: - - Manage the sequence of repository calls. - - Handle domain-specific validation (e.g., checking if the document is mandatory). +#### 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` -### C. Data Layer (The Repository Pattern) -The `Repository.uploadDocument` method should perform the following: -1. **Upload**: Call `FileUploadService` to get a `fileUri`. -2. **Link**: Call `SignedUrlService.createSignedUrl` to generate a `documentUrl`. -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. -5. **Persist**: Call Data Connect's `upsertStaffDocument` mutation with: - - `documentId` - - `documentUrl` - - `verificationId` - - `verificationStatus` (e.g., `PENDING` or `APPROVED`) +#### 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 +- 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) -- **`status`**: Track `loading`, `uploading`, `success`, and `failure`. -- **`errorMessage`**: Store localized error keys from `BlocErrorHandler`. -- **`verificationId`**: Store the ID returned from the server to allow later manual refreshes. +## 4. Pending — Data Layer (Real Repository Implementation) + +> The `DocumentsRepositoryImpl.uploadDocument` method is currently a **mock**. Replace it with the following sequence: + +### Step-by-step Repository Implementation + +```dart +@override +Future 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 -Ensure the `DocumentStatus` or `VerificationStatus` enum aligns with the backend definition: -- `PENDING`: Waiting for job to start. -- `PROCESSING`: AI is currently analyzing. -- `APPROVED`: Document is valid. -- `REJECTED`: Document is invalid (should check `rejectionReason`). -- `NEEDS_REVIEW`: AI is unsure, manual check required. +## 5. DocumentStatus Mapping + +The backend uses a richer enum than the domain layer. The mapping in `_mapDocumentStatus` should be finalized as follows: + +| Backend `DocumentStatus` | Domain `DocumentStatus` | Notes | +|--------------------------|-------------------------|-------| +| `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 diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart index 8b9cdcd4..385d59f6 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/data/repositories_impl/documents_repository_impl.dart @@ -6,9 +6,7 @@ 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() : _service = DataConnectService.instance; final DataConnectService _service; @@ -17,34 +15,53 @@ class DocumentsRepositoryImpl 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( - 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)), - ), - ]; - + /// MOCK IMPLEMENTATION + /// To be replaced with real data connect query when available + return [ + 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)), + ), + ]; }); } + @override + Future 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( ListStaffDocumentsByStaffIdStaffDocuments doc, ) { @@ -74,11 +91,30 @@ class DocumentsRepositoryImpl case DocumentStatus.UPLOADED: case DocumentStatus.EXPIRING: 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 return domain.DocumentStatus.pending; } } - - diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/repositories/documents_repository.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/repositories/documents_repository.dart index 26f6a7db..2ecd3faf 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/repositories/documents_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/repositories/documents_repository.dart @@ -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> getDocuments(); + + /// Uploads a document for the current staff member. + Future uploadDocument(String documentId, String filePath); } diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/upload_document_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/upload_document_usecase.dart new file mode 100644 index 00000000..13dfa2f3 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/domain/usecases/upload_document_usecase.dart @@ -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 { + UploadDocumentUseCase(this._repository); + final DocumentsRepository _repository; + + @override + Future 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; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart new file mode 100644 index 00000000..8cb036a6 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_cubit.dart @@ -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 { + 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 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(), + ), + ); + } + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart new file mode 100644 index 00000000..a737b1a1 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/blocs/document_upload/document_upload_state.dart @@ -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 get props => [ + status, + isAttested, + documentUrl, + updatedDocument, + errorMessage, + ]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart new file mode 100644 index 00000000..b5d7e806 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/document_upload_page.dart @@ -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 createState() => _DocumentUploadPageState(); +} + +class _DocumentUploadPageState extends State { + String? _selectedFilePath; + final FilePickerService _filePicker = Modular.get(); + + Future _pickFile() async { + final String? path = await _filePicker.pickFile( + allowedExtensions: ['pdf'], + ); + + if (path != null) { + setState(() { + _selectedFilePath = path; + }); + } + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext _) => Modular.get(), + child: BlocConsumer( + 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: [ + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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( + UiColors.primary, + ), + ), + ), + ) + else + UiButton.primary( + fullWidth: true, + onPressed: _selectedFilePath != null && state.isAttested + ? () => BlocProvider.of(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: [ + 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: [ + Checkbox( + value: state.isAttested, + onChanged: (bool? value) => BlocProvider.of( + context, + ).setAttested(value ?? false), + activeColor: UiColors.primary, + ), + Expanded( + child: Text( + t.staff_documents.upload.attestation, + style: UiTypography.body2r.textPrimary, + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart index 36aabbae..884aad75 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/presentation/pages/documents_page.dart @@ -80,8 +80,7 @@ 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), ), ), ], diff --git a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart index 8193497e..900d4750 100644 --- a/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/compliance/documents/lib/src/staff_documents_module.dart @@ -1,17 +1,24 @@ 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(DocumentsRepositoryImpl.new); i.addLazySingleton(GetDocumentsUseCase.new); - i.addLazySingleton(DocumentsCubit.new); + i.addLazySingleton(UploadDocumentUseCase.new); + + i.add(DocumentsCubit.new); + i.add(DocumentUploadCubit.new); } @override @@ -20,5 +27,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?, + ), + ); } }