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>
400 lines
14 KiB
Dart
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|