Initial commit: unionflow-mobile-apps

Application Flutter complète (sans build artifacts).

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 16:30:08 +00:00
commit d094d6db9c
1790 changed files with 507435 additions and 0 deletions

View File

@@ -0,0 +1,261 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.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/compte_epargne_model.dart';
import '../../data/models/transaction_epargne_request.dart';
import '../../data/repositories/transaction_epargne_repository.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
/// Dialogue de transfert entre deux comptes épargne du membre.
class TransfertEpargneDialog extends StatefulWidget {
final CompteEpargneModel compteSource;
final List<CompteEpargneModel> tousLesComptes;
final VoidCallback? onSuccess;
const TransfertEpargneDialog({
super.key,
required this.compteSource,
required this.tousLesComptes,
this.onSuccess,
});
@override
State<TransfertEpargneDialog> createState() => _TransfertEpargneDialogState();
}
class _TransfertEpargneDialogState extends State<TransfertEpargneDialog> {
final _formKey = GlobalKey<FormState>();
final _montantController = TextEditingController();
final _motifController = TextEditingController();
final _origineFondsController = TextEditingController();
bool _loading = false;
String? _compteDestinationId;
late TransactionEpargneRepository _repository;
late ParametresLcbFtRepository _parametresRepository;
/// Seuil LCB-FT récupéré depuis l'API (fallback à 500k XOF).
double _seuilLcbFt = kSeuilOrigineFondsObligatoireXOF;
bool _seuilLoaded = false;
List<CompteEpargneModel> get _comptesDestination {
if (widget.compteSource.id == null) return [];
return widget.tousLesComptes
.where((c) => c.id != null && c.id != widget.compteSource.id && c.statut == 'ACTIF')
.toList();
}
bool get _origineFondsRequis {
final m = double.tryParse(_montantController.text.replaceAll(',', '.'));
return m != null && m >= _seuilLcbFt;
}
@override
void initState() {
super.initState();
_repository = GetIt.I<TransactionEpargneRepository>();
_parametresRepository = GetIt.I<ParametresLcbFtRepository>();
if (_comptesDestination.isNotEmpty) _compteDestinationId = _comptesDestination.first.id;
_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;
});
}
}
@override
void dispose() {
_montantController.dispose();
_motifController.dispose();
_origineFondsController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (_compteDestinationId == null || _compteDestinationId!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sélectionnez un compte de destination')),
);
return;
}
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
if (montant == null || montant <= 0) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Montant invalide')));
return;
}
final soldeDispo = widget.compteSource.soldeActuel - widget.compteSource.soldeBloque;
if (montant > soldeDispo) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Solde disponible insuffisant (${soldeDispo.toStringAsFixed(0)} XOF)')),
);
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;
}
setState(() => _loading = true);
try {
final request = TransactionEpargneRequest(
compteId: widget.compteSource.id!,
typeTransaction: 'TRANSFERT_SORTANT',
montant: montant,
compteDestinationId: _compteDestinationId,
motif: _motifController.text.trim().isEmpty ? null : _motifController.text.trim(),
origineFonds: _origineFondsController.text.trim().isEmpty ? null : _origineFondsController.text.trim(),
);
await _repository.transferer(request);
if (!mounted) return;
Navigator.of(context).pop(true);
widget.onSuccess?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Transfert effectué'), backgroundColor: ColorTokens.success),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(ErrorFormatter.format(e)),
backgroundColor: ColorTokens.error,
duration: ErrorFormatter.isLcbFtError(e) ? const Duration(seconds: 6) : const Duration(seconds: 3),
),
);
} finally {
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final destinations = _comptesDestination;
if (destinations.isEmpty) {
return AlertDialog(
title: const Text('Transfert'),
content: const Text(
'Vous n\'avez pas d\'autre compte épargne actif pour effectuer un transfert.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
);
}
return AlertDialog(
title: const Text('Transfert entre comptes'),
content: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'De: ${widget.compteSource.numeroCompte ?? widget.compteSource.id}',
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
),
Text(
'Solde disponible: ${(widget.compteSource.soldeActuel - widget.compteSource.soldeBloque).toStringAsFixed(0)} XOF',
style: TypographyTokens.titleSmall,
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _compteDestinationId,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Compte de destination',
border: OutlineInputBorder(),
),
items: destinations
.map((c) => DropdownMenuItem(
value: c.id,
child: Text(
'${c.numeroCompte ?? c.id}${c.soldeActuel.toStringAsFixed(0)} XOF',
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
))
.toList(),
onChanged: (v) => setState(() => _compteDestinationId = v),
),
const SizedBox(height: 16),
TextFormField(
controller: _montantController,
decoration: const InputDecoration(
labelText: 'Montant (XOF)',
border: OutlineInputBorder(),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
validator: (v) {
if (v == null || v.isEmpty) return 'Obligatoire';
final n = double.tryParse(v.replaceAll(',', '.'));
if (n == null || n <= 0) return 'Montant invalide';
final solde = widget.compteSource.soldeActuel - widget.compteSource.soldeBloque;
if (n > solde) return 'Solde insuffisant';
return null;
},
onChanged: (_) => setState(() {}),
),
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),
child: Text(
'Requis pour les opérations ≥ ${_seuilLcbFt.toStringAsFixed(0)} XOF',
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.primary),
),
),
],
),
),
),
actions: [
TextButton(
onPressed: _loading ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: _loading ? null : _submit,
child: _loading
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Transférer'),
),
],
);
}
}