Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user