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,309 @@
/// Dialog de création d'un compte épargne pour un membre (admin / admin organisation).
/// Structure : 1) Choisir l'organisation 2) Choisir le membre de cette organisation 3) Type de compte + notes.
library creer_compte_epargne_dialog;
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../../members/data/models/membre_complete_model.dart';
import '../../../members/domain/repositories/membre_repository.dart';
import '../../../organizations/data/models/organization_model.dart';
import '../../../organizations/domain/repositories/organization_repository.dart';
import '../../../../shared/models/membre_search_criteria.dart';
import '../../data/repositories/transaction_epargne_repository.dart';
/// Types de compte alignés avec le backend TypeCompteEpargne.
const List<Map<String, String>> _typesCompte = [
{'code': 'COURANT', 'label': 'Compte courant'},
{'code': 'EPARGNE_LIBRE', 'label': 'Épargne libre'},
{'code': 'EPARGNE_BLOQUEE', 'label': 'Épargne bloquée (garantie crédit)'},
{'code': 'DEPOT_A_TERME', 'label': 'Dépôt à terme'},
{'code': 'EPARGNE_PROJET', 'label': 'Épargne projet'},
];
class CreerCompteEpargneDialog extends StatefulWidget {
final VoidCallback? onCreated;
const CreerCompteEpargneDialog({super.key, this.onCreated});
@override
State<CreerCompteEpargneDialog> createState() => _CreerCompteEpargneDialogState();
}
class _CreerCompteEpargneDialogState extends State<CreerCompteEpargneDialog> {
String? _organisationId;
MembreCompletModel? _selectedMembre;
String _typeCompte = 'EPARGNE_LIBRE';
final _notesController = TextEditingController();
bool _loading = false;
bool _loadingMembres = false;
bool _submitting = false;
String? _error;
List<OrganizationModel> _organisations = [];
List<MembreCompletModel> _membres = [];
@override
void initState() {
super.initState();
_loadOrganisations();
}
@override
void dispose() {
_notesController.dispose();
super.dispose();
}
Future<void> _loadOrganisations() async {
setState(() {
_loading = true;
_error = null;
_organisationId = null;
_selectedMembre = null;
_membres = [];
});
try {
final orgRepo = GetIt.instance<IOrganizationRepository>();
final orgs = await orgRepo.getOrganizations(page: 0, size: 100);
if (mounted) {
setState(() {
_organisations = orgs;
_loading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_loading = false;
_error = 'Erreur chargement organisations: $e';
});
}
}
}
Future<void> _loadMembresDeLOrganisation(String organisationId) async {
if (organisationId.isEmpty) {
setState(() {
_membres = [];
_selectedMembre = null;
});
return;
}
setState(() {
_loadingMembres = true;
_selectedMembre = null;
_membres = [];
});
try {
final membreRepo = GetIt.instance<IMembreRepository>();
final result = await membreRepo.searchMembres(
criteria: MembreSearchCriteria(
organisationIds: [organisationId],
includeInactifs: false,
),
page: 0,
size: 200,
);
if (mounted) {
setState(() {
_membres = result.membres;
_loadingMembres = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_loadingMembres = false;
_membres = [];
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Impossible de charger les membres: $e')),
);
}
}
}
Future<void> _submit() async {
if (_organisationId == null || _organisationId!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sélectionnez une organisation')),
);
return;
}
if (_selectedMembre == null || _selectedMembre!.id == null || _selectedMembre!.id!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Sélectionnez un membre')),
);
return;
}
setState(() => _submitting = true);
try {
final compteRepo = GetIt.I<CompteEpargneRepository>();
await compteRepo.creerCompte(
membreId: _selectedMembre!.id!,
organisationId: _organisationId!,
typeCompte: _typeCompte,
notesOuverture: _notesController.text.trim().isEmpty ? null : _notesController.text.trim(),
);
if (!mounted) return;
Navigator.of(context).pop(true);
widget.onCreated?.call();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Compte épargne créé')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur: $e')),
);
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Créer un compte épargne'),
content: SingleChildScrollView(
child: _loading
? const Padding(
padding: EdgeInsets.all(24),
child: Center(child: CircularProgressIndicator()),
)
: _error != null
? Padding(
padding: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)),
const SizedBox(height: 12),
TextButton(onPressed: _loadOrganisations, child: const Text('Réessayer')),
],
),
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 1. Organisation
DropdownButtonFormField<String>(
value: _organisationId,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Organisation *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.business),
),
items: _organisations
.map((o) => DropdownMenuItem(
value: o.id,
child: Text(o.nom ?? o.id ?? '', overflow: TextOverflow.ellipsis, maxLines: 1),
))
.toList(),
onChanged: _submitting
? null
: (v) {
setState(() {
_organisationId = v;
_selectedMembre = null;
});
if (v != null && v.isNotEmpty) _loadMembresDeLOrganisation(v);
},
),
const SizedBox(height: 16),
// 2. Membre de l'organisation — l'administrateur sélectionne le membre pour lequel créer le compte
if (_organisationId != null && _organisationId!.isNotEmpty) ...[
if (_loadingMembres)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Center(child: CircularProgressIndicator()),
)
else if (_membres.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'Aucun membre dans cette organisation. Le compte épargne ne peut être créé que pour un membre existant.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
)
else
DropdownButtonFormField<MembreCompletModel>(
value: _selectedMembre,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Membre *',
hintText: 'Choisir le membre pour lequel créer le compte',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person),
),
items: _membres
.map((m) => DropdownMenuItem(
value: m,
child: Text(
'${m.prenom} ${m.nom}${m.numeroMembre != null ? ' (${m.numeroMembre})' : ''}',
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
))
.toList(),
onChanged: _submitting ? null : (v) => setState(() => _selectedMembre = v),
),
const SizedBox(height: 16),
],
// 3. Type de compte
DropdownButtonFormField<String>(
value: _typeCompte,
isExpanded: true,
decoration: const InputDecoration(
labelText: 'Type de compte',
border: OutlineInputBorder(),
),
items: _typesCompte
.map((t) => DropdownMenuItem(
value: t['code'],
child: Text(t['label']!, overflow: TextOverflow.ellipsis, maxLines: 1),
))
.toList(),
onChanged: _submitting ? null : (v) => setState(() => _typeCompte = v ?? 'EPARGNE_LIBRE'),
),
const SizedBox(height: 16),
// 4. Notes
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes (optionnel)',
border: OutlineInputBorder(),
),
maxLines: 2,
enabled: !_submitting,
),
],
),
),
actions: [
TextButton(
onPressed: _submitting ? null : () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
FilledButton(
onPressed: (_loading ||
_submitting ||
_organisationId == null ||
_selectedMembre == null ||
_selectedMembre!.id == null)
? null
: _submit,
child: _submitting
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
: const Text('Créer'),
),
],
);
}
}

View File

@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:url_launcher/url_launcher.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';
/// 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;
_DepotMode _mode = _DepotMode.manual;
late TransactionEpargneRepository _repository;
late ParametresLcbFtRepository _parametresRepository;
/// Seuil LCB-FT récupéré depuis l'API (fallback à 500k XOF).
double _seuilLcbFt = kSeuilOrigineFondsObligatoireXOF;
bool _seuilLoaded = false;
@override
void initState() {
super.initState();
_repository = GetIt.I<TransactionEpargneRepository>();
_parametresRepository = GetIt.I<ParametresLcbFtRepository>();
_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();
}
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;
}
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(),
);
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,
),
),
),
],
],
),
),
),
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'),
),
],
);
}
}

View File

@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../data/models/compte_epargne_model.dart';
import '../../data/models/transaction_epargne_model.dart';
import '../../data/repositories/transaction_epargne_repository.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
/// Bottom sheet affichant l'historique complet des transactions d'un compte (charge et rafraîchit les données).
class HistoriqueEpargneSheet extends StatefulWidget {
final CompteEpargneModel compte;
const HistoriqueEpargneSheet({
super.key,
required this.compte,
});
@override
State<HistoriqueEpargneSheet> createState() => _HistoriqueEpargneSheetState();
}
class _HistoriqueEpargneSheetState extends State<HistoriqueEpargneSheet> {
List<TransactionEpargneModel> _transactions = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
if (widget.compte.id == null) {
setState(() {
_loading = false;
_transactions = [];
});
return;
}
setState(() {
_loading = true;
_error = null;
});
try {
final repo = GetIt.I<TransactionEpargneRepository>();
final list = await repo.getByCompte(widget.compte.id!);
if (!mounted) return;
setState(() {
_transactions = list.map((e) => TransactionEpargneModel.fromJson(e)).toList();
_loading = false;
_error = null;
});
} catch (e) {
if (!mounted) return;
setState(() {
_transactions = [];
_loading = false;
_error = e.toString().replaceFirst('Exception: ', '');
});
}
}
String _libelleType(String? type) {
if (type == null) return '';
const map = {
'DEPOT': 'Dépôt',
'RETRAIT': 'Retrait',
'TRANSFERT_ENTRANT': 'Virement reçu',
'TRANSFERT_SORTANT': 'Virement envoyé',
};
return map[type] ?? type;
}
@override
Widget build(BuildContext context) {
final compte = widget.compte;
return DraggableScrollableSheet(
initialChildSize: 0.6,
minChildSize: 0.3,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return Column(
children: [
Container(
margin: const EdgeInsets.only(top: 12, bottom: 8),
width: 40,
height: 4,
decoration: BoxDecoration(
color: ColorTokens.onSurfaceVariant.withOpacity(0.4),
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.lg),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Historique — ${compte.numeroCompte ?? compte.id}',
style: TypographyTokens.titleMedium,
),
IconButton(
icon: _loading ? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
) : const Icon(Icons.refresh),
onPressed: _loading ? null : _load,
tooltip: 'Actualiser',
),
],
),
),
const SizedBox(height: 8),
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Padding(
padding: const EdgeInsets.all(SpacingTokens.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_error!, style: TextStyle(color: ColorTokens.error), textAlign: TextAlign.center),
const SizedBox(height: SpacingTokens.md),
FilledButton.tonal(onPressed: _load, child: const Text('Réessayer')),
],
),
),
)
: _transactions.isEmpty
? Center(
child: Text(
'Aucune transaction',
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
),
)
: ListView.builder(
controller: scrollController,
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.lg, vertical: SpacingTokens.sm),
itemCount: _transactions.length,
itemBuilder: (context, index) {
final t = _transactions[index];
return Card(
margin: const EdgeInsets.only(bottom: SpacingTokens.sm),
child: ListTile(
leading: CircleAvatar(
backgroundColor: t.isCredit
? ColorTokens.success.withOpacity(0.2)
: ColorTokens.error.withOpacity(0.2),
child: Icon(
t.isCredit ? Icons.arrow_downward : Icons.arrow_upward,
color: t.isCredit ? ColorTokens.success : ColorTokens.error,
size: 20,
),
),
title: Text(
_libelleType(t.type),
style: TypographyTokens.bodyMedium,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (t.dateTransaction != null)
Text(
'${t.dateTransaction!.day.toString().padLeft(2, '0')}/${t.dateTransaction!.month.toString().padLeft(2, '0')}/${t.dateTransaction!.year} ${t.dateTransaction!.hour.toString().padLeft(2, '0')}:${t.dateTransaction!.minute.toString().padLeft(2, '0')}',
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
),
if (t.motif != null && t.motif!.isNotEmpty)
Text(
t.motif!,
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${t.isCredit ? '+' : '-'}${t.montant.toStringAsFixed(0)} XOF',
style: TypographyTokens.titleSmall?.copyWith(
color: t.isCredit ? ColorTokens.success : ColorTokens.error,
fontWeight: FontWeight.w600,
),
),
Text(
'Solde: ${t.soldeApres.toStringAsFixed(0)}',
style: TypographyTokens.labelSmall?.copyWith(color: ColorTokens.onSurfaceVariant),
),
],
),
),
);
},
),
),
],
);
},
);
}
}

View File

@@ -0,0 +1,209 @@
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/transaction_epargne_request.dart';
import '../../data/repositories/transaction_epargne_repository.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
/// Dialogue de retrait sur un compte épargne.
/// LCB-FT : origine des fonds obligatoire au-dessus du seuil.
class RetraitEpargneDialog extends StatefulWidget {
final String compteId;
final String numeroCompte;
final double soldeDisponible;
final VoidCallback? onSuccess;
const RetraitEpargneDialog({
super.key,
required this.compteId,
required this.numeroCompte,
required this.soldeDisponible,
this.onSuccess,
});
@override
State<RetraitEpargneDialog> createState() => _RetraitEpargneDialogState();
}
class _RetraitEpargneDialogState extends State<RetraitEpargneDialog> {
final _formKey = GlobalKey<FormState>();
final _montantController = TextEditingController();
final _motifController = TextEditingController();
final _origineFondsController = TextEditingController();
bool _loading = false;
late TransactionEpargneRepository _repository;
late ParametresLcbFtRepository _parametresRepository;
/// Seuil LCB-FT récupéré depuis l'API (fallback à 500k XOF).
double _seuilLcbFt = kSeuilOrigineFondsObligatoireXOF;
bool _seuilLoaded = false;
@override
void initState() {
super.initState();
_repository = GetIt.I<TransactionEpargneRepository>();
_parametresRepository = GetIt.I<ParametresLcbFtRepository>();
_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();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final montant = double.tryParse(_montantController.text.replaceAll(',', '.'));
if (montant == null || montant <= 0) {
_showSnack('Montant invalide');
return;
}
if (montant > widget.soldeDisponible) {
_showSnack('Solde disponible insuffisant (${widget.soldeDisponible.toStringAsFixed(0)} XOF)');
return;
}
if (_origineFondsRequis && _origineFondsController.text.trim().isEmpty) {
_showSnack(
'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.compteId,
typeTransaction: 'RETRAIT',
montant: montant,
motif: _motifController.text.trim().isEmpty ? null : _motifController.text.trim(),
origineFonds: _origineFondsController.text.trim().isEmpty ? null : _origineFondsController.text.trim(),
);
await _repository.executer(request);
if (!mounted) return;
Navigator.of(context).pop(true);
widget.onSuccess?.call();
_showSnack('Retrait enregistré', isError: false);
} catch (e) {
if (!mounted) return;
_showSnack(
ErrorFormatter.format(e),
duration: ErrorFormatter.isLcbFtError(e) ? 6 : 3,
);
} finally {
if (mounted) setState(() => _loading = false);
}
}
void _showSnack(String msg, {bool isError = true, int duration = 3}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(msg),
backgroundColor: isError ? ColorTokens.error : ColorTokens.success,
duration: Duration(seconds: duration),
),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Retrait'),
content: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.numeroCompte,
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
),
Text(
'Solde disponible: ${widget.soldeDisponible.toStringAsFixed(0)} XOF',
style: TypographyTokens.titleSmall,
),
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';
if (n > widget.soldeDisponible) 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: Theme.of(context).textTheme.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('Valider le retrait'),
),
],
);
}
}

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'),
),
],
);
}
}