From 775729b4c31edbfa16449f166a883159bf6feffe Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Mon, 16 Mar 2026 05:14:22 +0000 Subject: [PATCH] =?UTF-8?q?feat(mobile):=20upload=20pi=C3=A8ce=20justifica?= =?UTF-8?q?tive=20LCB-FT=20(T020=20-=20Spec=20001)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémentation upload documents pour transactions épargne ≥ seuil LCB-FT : Backend : - DocumentUploadService (@lazySingleton) : upload JPG/PNG/PDF max 5MB - Dio provider dans register_module.dart (timeouts 15s) Mobile : - 3 dialogs épargne modifiés (dépôt, retrait, transfert) - FilePicker + upload + validation seuil - UI états (idle, loading, success) - Validation : pièce requise si montant ≥ seuil Corrections : - AppLogger.error() : signature correcte (error: param nommé) - Assets : création répertoires mobile_money/ et virement/ Spec 001 : 27/27 tâches (100%) Co-Authored-By: Claude Sonnet 4.5 --- .../payment_methods/mobile_money/README.md | 6 + .../images/payment_methods/virement/README.md | 6 + lib/core/di/register_module.dart | 7 ++ .../services/document_upload_service.dart | 99 +++++++++++++++ .../widgets/depot_epargne_dialog.dart | 118 ++++++++++++++++++ .../widgets/retrait_epargne_dialog.dart | 104 +++++++++++++++ .../widgets/transfert_epargne_dialog.dart | 118 ++++++++++++++++++ 7 files changed, 458 insertions(+) create mode 100644 assets/images/payment_methods/mobile_money/README.md create mode 100644 assets/images/payment_methods/virement/README.md create mode 100644 lib/features/epargne/data/services/document_upload_service.dart diff --git a/assets/images/payment_methods/mobile_money/README.md b/assets/images/payment_methods/mobile_money/README.md new file mode 100644 index 0000000..a707db2 --- /dev/null +++ b/assets/images/payment_methods/mobile_money/README.md @@ -0,0 +1,6 @@ +# Mobile Money + +Répertoire pour les logos des moyens de paiement mobile money génériques. + +## Assets à ajouter +- logo.png ou logo.svg diff --git a/assets/images/payment_methods/virement/README.md b/assets/images/payment_methods/virement/README.md new file mode 100644 index 0000000..47ca3e9 --- /dev/null +++ b/assets/images/payment_methods/virement/README.md @@ -0,0 +1,6 @@ +# Virement Bancaire + +Répertoire pour les logos de virement bancaire. + +## Assets à ajouter +- logo.png ou logo.svg diff --git a/lib/core/di/register_module.dart b/lib/core/di/register_module.dart index 1fc8979..e0d929f 100644 --- a/lib/core/di/register_module.dart +++ b/lib/core/di/register_module.dart @@ -1,4 +1,5 @@ import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:injectable/injectable.dart'; @@ -17,6 +18,12 @@ abstract class RegisterModule { @lazySingleton http.Client get httpClient => http.Client(); + @lazySingleton + Dio get dio => Dio(BaseOptions( + connectTimeout: const Duration(seconds: 15), + receiveTimeout: const Duration(seconds: 15), + )); + @preResolve Future get sharedPreferences => SharedPreferences.getInstance(); } diff --git a/lib/features/epargne/data/services/document_upload_service.dart b/lib/features/epargne/data/services/document_upload_service.dart new file mode 100644 index 0000000..d8197ce --- /dev/null +++ b/lib/features/epargne/data/services/document_upload_service.dart @@ -0,0 +1,99 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:http_parser/http_parser.dart'; +import 'package:injectable/injectable.dart'; +import '../../../../core/config/environment.dart'; +import '../../../../core/utils/logger.dart'; + +/// Service pour uploader des documents (pièces justificatives LCB-FT) +@lazySingleton +class DocumentUploadService { + final Dio _dio; + + DocumentUploadService(this._dio); + + /// Upload un fichier vers le backend + /// Retourne l'ID du document créé + Future uploadDocument({ + required File file, + String? description, + String typeDocument = 'PIECE_JUSTIFICATIVE', + }) async { + try { + final fileName = file.path.split('/').last; + final fileExtension = fileName.split('.').last.toLowerCase(); + + // Déterminer le type MIME + String contentType; + if (fileExtension == 'pdf') { + contentType = 'application/pdf'; + } else if (fileExtension == 'jpg' || fileExtension == 'jpeg') { + contentType = 'image/jpeg'; + } else if (fileExtension == 'png') { + contentType = 'image/png'; + } else if (fileExtension == 'gif') { + contentType = 'image/gif'; + } else { + throw Exception('Type de fichier non supporté: .$fileExtension'); + } + + // Vérifier la taille (max 5 MB) + final fileSize = await file.length(); + const maxSize = 5 * 1024 * 1024; // 5 MB + if (fileSize > maxSize) { + throw Exception('Fichier trop volumineux. Taille max: 5 MB'); + } + + // Créer le FormData + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile( + file.path, + filename: fileName, + contentType: MediaType.parse(contentType), + ), + if (description != null && description.isNotEmpty) + 'description': description, + 'typeDocument': typeDocument, + }); + + AppLogger.debug('Upload de $fileName (${_formatBytes(fileSize)})'); + + final response = await _dio.post( + '${AppConfig.apiBaseUrl}/documents/upload', + data: formData, + options: Options( + headers: { + 'Content-Type': 'multipart/form-data', + }, + ), + ); + + if (response.statusCode == 201 && response.data != null) { + final documentId = response.data['id'] as String; + AppLogger.info('Document uploadé avec succès: $documentId'); + return documentId; + } else { + throw Exception('Erreur lors de l\'upload: ${response.statusCode}'); + } + } on DioException catch (e) { + AppLogger.error('Erreur Dio lors de l\'upload: ${e.message}', error: e); + if (e.response?.data != null && e.response!.data['error'] != null) { + throw Exception(e.response!.data['error']); + } + throw Exception('Erreur réseau lors de l\'upload'); + } catch (e) { + AppLogger.error('Erreur lors de l\'upload du document', error: e); + rethrow; + } + } + + String _formatBytes(int bytes) { + if (bytes < 1024) { + return '$bytes B'; + } else if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(2)} KB'; + } else { + return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB'; + } + } +} diff --git a/lib/features/epargne/presentation/widgets/depot_epargne_dialog.dart b/lib/features/epargne/presentation/widgets/depot_epargne_dialog.dart index c76ba87..4d2c169 100644 --- a/lib/features/epargne/presentation/widgets/depot_epargne_dialog.dart +++ b/lib/features/epargne/presentation/widgets/depot_epargne_dialog.dart @@ -1,12 +1,15 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:url_launcher/url_launcher.dart'; +import 'package:file_picker/file_picker.dart'; import '../../../../core/constants/lcb_ft_constants.dart'; import '../../../../core/data/repositories/parametres_lcb_ft_repository.dart'; import '../../../../core/utils/error_formatter.dart'; import '../../data/models/transaction_epargne_request.dart'; import '../../data/repositories/transaction_epargne_repository.dart'; +import '../../data/services/document_upload_service.dart'; /// Dialogue de dépôt sur un compte épargne. /// Deux modes : enregistrement manuel (LCB-FT) ou paiement via Wave (mobile money, même flux que cotisations). @@ -34,19 +37,26 @@ class _DepotEpargneDialogState extends State { final _wavePhoneController = TextEditingController(); bool _loading = false; bool _waveLoading = false; + bool _uploadingDocument = false; _DepotMode _mode = _DepotMode.manual; late TransactionEpargneRepository _repository; late ParametresLcbFtRepository _parametresRepository; + late DocumentUploadService _uploadService; /// Seuil LCB-FT récupéré depuis l'API (fallback à 500k XOF). double _seuilLcbFt = kSeuilOrigineFondsObligatoireXOF; bool _seuilLoaded = false; + /// Pièce justificative pour opérations au-dessus du seuil + File? _pieceJustificative; + String? _pieceJustificativeId; + @override void initState() { super.initState(); _repository = GetIt.I(); _parametresRepository = GetIt.I(); + _uploadService = GetIt.I(); _chargerSeuil(); } @@ -75,6 +85,57 @@ class _DepotEpargneDialogState extends State { super.dispose(); } + /// Choisir et uploader une pièce justificative (photo ou PDF) + Future _choisirPieceJustificative() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf'], + allowMultiple: false, + ); + + if (result == null || result.files.isEmpty) return; + + final file = File(result.files.single.path!); + setState(() { + _uploadingDocument = true; + _pieceJustificative = file; + }); + + // Upload du fichier + final documentId = await _uploadService.uploadDocument( + file: file, + description: 'Pièce justificative - Dépôt épargne', + typeDocument: 'PIECE_JUSTIFICATIVE', + ); + + if (!mounted) return; + setState(() { + _pieceJustificativeId = documentId; + _uploadingDocument = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('✓ Pièce justificative uploadée avec succès'), + backgroundColor: Colors.green, + ), + ); + } catch (e) { + if (!mounted) return; + setState(() { + _uploadingDocument = false; + _pieceJustificative = null; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur upload : ${e.toString()}'), + backgroundColor: Colors.red, + ), + ); + } + } + Future _submitWave() async { final montant = double.tryParse(_montantController.text.replaceAll(',', '.')); if (montant == null || montant <= 0) { @@ -143,6 +204,17 @@ class _DepotEpargneDialogState extends State { ); return; } + if (_origineFondsRequis && _pieceJustificativeId == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Une pièce justificative est requise pour les opérations ≥ ${_seuilLcbFt.toStringAsFixed(0)} XOF (LCB-FT).', + ), + duration: const Duration(seconds: 4), + ), + ); + return; + } setState(() => _loading = true); try { final request = TransactionEpargneRequest( @@ -151,6 +223,7 @@ class _DepotEpargneDialogState extends State { montant: montant, motif: _motifController.text.trim().isEmpty ? null : _motifController.text.trim(), origineFonds: _origineFondsController.text.trim().isEmpty ? null : _origineFondsController.text.trim(), + pieceJustificativeId: _pieceJustificativeId, ); await _repository.executer(request); if (!mounted) return; @@ -251,6 +324,51 @@ class _DepotEpargneDialogState extends State { ), ), ), + if (_origineFondsRequis) ...[ + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _uploadingDocument ? null : _choisirPieceJustificative, + icon: _uploadingDocument + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + _pieceJustificativeId != null ? Icons.check_circle : Icons.attach_file, + color: _pieceJustificativeId != null ? Colors.green : null, + ), + label: Text( + _pieceJustificativeId != null + ? 'Pièce justificative uploadée' + : 'Joindre une pièce justificative *', + ), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + side: _pieceJustificativeId != null + ? const BorderSide(color: Colors.green) + : null, + ), + ), + if (_pieceJustificative != null) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + _pieceJustificative!.path.split('/').last, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + 'Photo ou PDF (max 5 MB)', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ), + ], ], ], ), diff --git a/lib/features/epargne/presentation/widgets/retrait_epargne_dialog.dart b/lib/features/epargne/presentation/widgets/retrait_epargne_dialog.dart index eafbee8..78fddcf 100644 --- a/lib/features/epargne/presentation/widgets/retrait_epargne_dialog.dart +++ b/lib/features/epargne/presentation/widgets/retrait_epargne_dialog.dart @@ -1,11 +1,14 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:file_picker/file_picker.dart'; import '../../../../core/constants/lcb_ft_constants.dart'; import '../../../../core/data/repositories/parametres_lcb_ft_repository.dart'; import '../../../../core/utils/error_formatter.dart'; import '../../data/models/transaction_epargne_request.dart'; import '../../data/repositories/transaction_epargne_repository.dart'; +import '../../data/services/document_upload_service.dart'; import '../../../../shared/design_system/unionflow_design_system.dart'; /// Dialogue de retrait sur un compte épargne. @@ -34,18 +37,25 @@ class _RetraitEpargneDialogState extends State { final _motifController = TextEditingController(); final _origineFondsController = TextEditingController(); bool _loading = false; + bool _uploadingDocument = false; late TransactionEpargneRepository _repository; late ParametresLcbFtRepository _parametresRepository; + late DocumentUploadService _uploadService; /// Seuil LCB-FT récupéré depuis l'API (fallback à 500k XOF). double _seuilLcbFt = kSeuilOrigineFondsObligatoireXOF; bool _seuilLoaded = false; + /// Pièce justificative pour opérations au-dessus du seuil + File? _pieceJustificative; + String? _pieceJustificativeId; + @override void initState() { super.initState(); _repository = GetIt.I(); _parametresRepository = GetIt.I(); + _uploadService = GetIt.I(); _chargerSeuil(); } @@ -73,6 +83,47 @@ class _RetraitEpargneDialogState extends State { super.dispose(); } + /// Choisir et uploader une pièce justificative (photo ou PDF) + Future _choisirPieceJustificative() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf'], + allowMultiple: false, + ); + + if (result == null || result.files.isEmpty) return; + + final file = File(result.files.single.path!); + setState(() { + _uploadingDocument = true; + _pieceJustificative = file; + }); + + // Upload du fichier + final documentId = await _uploadService.uploadDocument( + file: file, + description: 'Pièce justificative - Retrait épargne', + typeDocument: 'PIECE_JUSTIFICATIVE', + ); + + if (!mounted) return; + setState(() { + _pieceJustificativeId = documentId; + _uploadingDocument = false; + }); + + _showSnack('✓ Pièce justificative uploadée avec succès', isError: false); + } catch (e) { + if (!mounted) return; + setState(() { + _uploadingDocument = false; + _pieceJustificative = null; + }); + _showSnack('Erreur upload : ${e.toString()}'); + } + } + Future _submit() async { if (!_formKey.currentState!.validate()) return; final montant = double.tryParse(_montantController.text.replaceAll(',', '.')); @@ -90,6 +141,13 @@ class _RetraitEpargneDialogState extends State { ); return; } + if (_origineFondsRequis && _pieceJustificativeId == null) { + _showSnack( + 'Une pièce justificative est requise pour les opérations ≥ ${_seuilLcbFt.toStringAsFixed(0)} XOF (LCB-FT).', + duration: 4, + ); + return; + } setState(() => _loading = true); try { final request = TransactionEpargneRequest( @@ -98,6 +156,7 @@ class _RetraitEpargneDialogState extends State { montant: montant, motif: _motifController.text.trim().isEmpty ? null : _motifController.text.trim(), origineFonds: _origineFondsController.text.trim().isEmpty ? null : _origineFondsController.text.trim(), + pieceJustificativeId: _pieceJustificativeId, ); await _repository.executer(request); if (!mounted) return; @@ -188,6 +247,51 @@ class _RetraitEpargneDialogState extends State { style: Theme.of(context).textTheme.bodySmall?.copyWith(color: ColorTokens.primary), ), ), + if (_origineFondsRequis) ...[ + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _uploadingDocument ? null : _choisirPieceJustificative, + icon: _uploadingDocument + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + _pieceJustificativeId != null ? Icons.check_circle : Icons.attach_file, + color: _pieceJustificativeId != null ? Colors.green : null, + ), + label: Text( + _pieceJustificativeId != null + ? 'Pièce justificative uploadée' + : 'Joindre une pièce justificative *', + ), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + side: _pieceJustificativeId != null + ? const BorderSide(color: Colors.green) + : null, + ), + ), + if (_pieceJustificative != null) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + _pieceJustificative!.path.split('/').last, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + 'Photo ou PDF (max 5 MB)', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ), + ], ], ), ), diff --git a/lib/features/epargne/presentation/widgets/transfert_epargne_dialog.dart b/lib/features/epargne/presentation/widgets/transfert_epargne_dialog.dart index 10ba13c..dbdbf1b 100644 --- a/lib/features/epargne/presentation/widgets/transfert_epargne_dialog.dart +++ b/lib/features/epargne/presentation/widgets/transfert_epargne_dialog.dart @@ -1,5 +1,7 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:file_picker/file_picker.dart'; import '../../../../core/constants/lcb_ft_constants.dart'; import '../../../../core/data/repositories/parametres_lcb_ft_repository.dart'; @@ -7,6 +9,7 @@ import '../../../../core/utils/error_formatter.dart'; import '../../data/models/compte_epargne_model.dart'; import '../../data/models/transaction_epargne_request.dart'; import '../../data/repositories/transaction_epargne_repository.dart'; +import '../../data/services/document_upload_service.dart'; import '../../../../shared/design_system/unionflow_design_system.dart'; /// Dialogue de transfert entre deux comptes épargne du membre. @@ -32,14 +35,20 @@ class _TransfertEpargneDialogState extends State { final _motifController = TextEditingController(); final _origineFondsController = TextEditingController(); bool _loading = false; + bool _uploadingDocument = false; String? _compteDestinationId; late TransactionEpargneRepository _repository; late ParametresLcbFtRepository _parametresRepository; + late DocumentUploadService _uploadService; /// Seuil LCB-FT récupéré depuis l'API (fallback à 500k XOF). double _seuilLcbFt = kSeuilOrigineFondsObligatoireXOF; bool _seuilLoaded = false; + /// Pièce justificative pour opérations au-dessus du seuil + File? _pieceJustificative; + String? _pieceJustificativeId; + List get _comptesDestination { if (widget.compteSource.id == null) return []; return widget.tousLesComptes @@ -57,6 +66,7 @@ class _TransfertEpargneDialogState extends State { super.initState(); _repository = GetIt.I(); _parametresRepository = GetIt.I(); + _uploadService = GetIt.I(); if (_comptesDestination.isNotEmpty) _compteDestinationId = _comptesDestination.first.id; _chargerSeuil(); } @@ -80,6 +90,57 @@ class _TransfertEpargneDialogState extends State { super.dispose(); } + /// Choisir et uploader une pièce justificative (photo ou PDF) + Future _choisirPieceJustificative() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf'], + allowMultiple: false, + ); + + if (result == null || result.files.isEmpty) return; + + final file = File(result.files.single.path!); + setState(() { + _uploadingDocument = true; + _pieceJustificative = file; + }); + + // Upload du fichier + final documentId = await _uploadService.uploadDocument( + file: file, + description: 'Pièce justificative - Transfert épargne', + typeDocument: 'PIECE_JUSTIFICATIVE', + ); + + if (!mounted) return; + setState(() { + _pieceJustificativeId = documentId; + _uploadingDocument = false; + }); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('✓ Pièce justificative uploadée avec succès'), + backgroundColor: ColorTokens.success, + ), + ); + } catch (e) { + if (!mounted) return; + setState(() { + _uploadingDocument = false; + _pieceJustificative = null; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur upload : ${e.toString()}'), + backgroundColor: ColorTokens.error, + ), + ); + } + } + Future _submit() async { if (!_formKey.currentState!.validate()) return; if (_compteDestinationId == null || _compteDestinationId!.isEmpty) { @@ -110,6 +171,17 @@ class _TransfertEpargneDialogState extends State { ); return; } + if (_origineFondsRequis && _pieceJustificativeId == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + 'Une pièce justificative est requise pour les opérations ≥ ${_seuilLcbFt.toStringAsFixed(0)} XOF (LCB-FT).', + ), + duration: const Duration(seconds: 4), + ), + ); + return; + } setState(() => _loading = true); try { final request = TransactionEpargneRequest( @@ -119,6 +191,7 @@ class _TransfertEpargneDialogState extends State { compteDestinationId: _compteDestinationId, motif: _motifController.text.trim().isEmpty ? null : _motifController.text.trim(), origineFonds: _origineFondsController.text.trim().isEmpty ? null : _origineFondsController.text.trim(), + pieceJustificativeId: _pieceJustificativeId, ); await _repository.transferer(request); if (!mounted) return; @@ -240,6 +313,51 @@ class _TransfertEpargneDialogState extends State { style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.primary), ), ), + if (_origineFondsRequis) ...[ + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _uploadingDocument ? null : _choisirPieceJustificative, + icon: _uploadingDocument + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + _pieceJustificativeId != null ? Icons.check_circle : Icons.attach_file, + color: _pieceJustificativeId != null ? Colors.green : null, + ), + label: Text( + _pieceJustificativeId != null + ? 'Pièce justificative uploadée' + : 'Joindre une pièce justificative *', + ), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + side: _pieceJustificativeId != null + ? const BorderSide(color: Colors.green) + : null, + ), + ), + if (_pieceJustificative != null) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + _pieceJustificative!.path.split('/').last, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + 'Photo ou PDF (max 5 MB)', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ), + ], ], ), ),