Files
unionflow-mobile-apps/lib/features/epargne/presentation/widgets/depot_epargne_dialog.dart
dahoud 775729b4c3 feat(mobile): upload pièce justificative LCB-FT (T020 - Spec 001)
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 <noreply@anthropic.com>
2026-03-16 05:14:22 +00:00

400 lines
14 KiB
Dart

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).
class DepotEpargneDialog extends StatefulWidget {
final String compteId;
final VoidCallback? onSuccess;
const DepotEpargneDialog({
super.key,
required this.compteId,
this.onSuccess,
});
@override
State<DepotEpargneDialog> createState() => _DepotEpargneDialogState();
}
enum _DepotMode { manual, wave }
class _DepotEpargneDialogState extends State<DepotEpargneDialog> {
final _formKey = GlobalKey<FormState>();
final _montantController = TextEditingController();
final _motifController = TextEditingController();
final _origineFondsController = TextEditingController();
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<TransactionEpargneRepository>();
_parametresRepository = GetIt.I<ParametresLcbFtRepository>();
_uploadService = GetIt.I<DocumentUploadService>();
_chargerSeuil();
}
/// Charge le seuil LCB-FT depuis l'API au chargement du dialog.
Future<void> _chargerSeuil() async {
final seuil = await _parametresRepository.getSeuilJustification();
if (mounted) {
setState(() {
_seuilLcbFt = seuil.montantSeuil;
_seuilLoaded = true;
});
}
}
bool get _origineFondsRequis {
final m = double.tryParse(_montantController.text.replaceAll(',', '.'));
return m != null && m >= _seuilLcbFt;
}
@override
void dispose() {
_montantController.dispose();
_motifController.dispose();
_origineFondsController.dispose();
_wavePhoneController.dispose();
super.dispose();
}
/// Choisir et uploader une pièce justificative (photo ou PDF)
Future<void> _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<void> _submitWave() async {
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
if (montant == null || montant <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Indiquez un montant valide')),
);
return;
}
final phone = _wavePhoneController.text.replaceAll(RegExp(r'\D'), '');
if (phone.length < 9) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Indiquez votre numéro Wave (9 chiffres)')),
);
return;
}
setState(() => _waveLoading = true);
try {
final result = await _repository.initierDepotEpargneEnLigne(
compteId: widget.compteId,
montant: montant,
numeroTelephone: phone,
);
final url = result.waveLaunchUrl.isNotEmpty ? result.waveLaunchUrl : result.redirectUrl;
if (url.isEmpty) throw Exception('URL Wave non reçue');
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
await launchUrl(uri);
}
if (!mounted) return;
Navigator.of(context).pop(true);
widget.onSuccess?.call();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(result.message)),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(ErrorFormatter.format(e)),
duration: ErrorFormatter.isLcbFtError(e) ? const Duration(seconds: 6) : const Duration(seconds: 3),
),
);
} finally {
if (mounted) setState(() => _waveLoading = false);
}
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
if (montant == null || montant <= 0) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Montant invalide')),
);
return;
}
if (_origineFondsRequis && (_origineFondsController.text.trim().isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'L\'origine des fonds est obligatoire pour les opérations à partir de ${_seuilLcbFt.toStringAsFixed(0)} XOF (LCB-FT).',
),
),
);
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(
compteId: widget.compteId,
typeTransaction: 'DEPOT',
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;
Navigator.of(context).pop(true);
widget.onSuccess?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Dépôt enregistré')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(ErrorFormatter.format(e)),
duration: ErrorFormatter.isLcbFtError(e) ? const Duration(seconds: 6) : const Duration(seconds: 3),
),
);
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Dépôt sur compte épargne'),
content: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SegmentedButton<_DepotMode>(
segments: const [
ButtonSegment(value: _DepotMode.manual, label: Text('Manuel'), icon: Icon(Icons.edit_note)),
ButtonSegment(value: _DepotMode.wave, label: Text('Wave'), icon: Icon(Icons.phone_android)),
],
selected: {_mode},
onSelectionChanged: (s) => setState(() => _mode = s.first),
),
const SizedBox(height: 16),
TextFormField(
controller: _montantController,
decoration: const InputDecoration(
labelText: 'Montant (XOF)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: _mode == _DepotMode.manual
? (v) {
if (v == null || v.isEmpty) return 'Obligatoire';
final n = double.tryParse(v.replaceAll(',', '.'));
if (n == null || n <= 0) return 'Montant invalide';
return null;
}
: null,
onChanged: (_) => setState(() {}),
),
if (_mode == _DepotMode.wave) ...[
const SizedBox(height: 16),
TextFormField(
controller: _wavePhoneController,
decoration: const InputDecoration(
labelText: 'Numéro Wave (9 chiffres) *',
hintText: 'Ex: 771234567',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.phone,
maxLength: 12,
),
] else ...[
const SizedBox(height: 16),
TextFormField(
controller: _motifController,
decoration: const InputDecoration(
labelText: 'Motif (optionnel)',
border: OutlineInputBorder(),
),
maxLines: 2,
),
const SizedBox(height: 16),
TextFormField(
controller: _origineFondsController,
decoration: InputDecoration(
labelText: 'Origine des fonds (LCB-FT)',
hintText: _origineFondsRequis ? 'Obligatoire au-dessus du seuil' : 'Optionnel',
border: const OutlineInputBorder(),
),
onChanged: (_) => setState(() {}),
),
if (_origineFondsRequis)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Requis pour les opérations ≥ ${_seuilLcbFt.toStringAsFixed(0)} XOF',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.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,
),
),
),
],
],
],
),
),
),
actions: [
TextButton(
onPressed: (_loading || _waveLoading) ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
if (_mode == _DepotMode.wave)
FilledButton(
onPressed: _waveLoading ? null : _submitWave,
child: _waveLoading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Ouvrir Wave'),
)
else
FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Enregistrer'),
),
],
);
}
}