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,394 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import '../../../../core/utils/logger.dart';
import '../../data/models/compte_epargne_model.dart';
import '../../data/models/transaction_epargne_model.dart';
import '../../data/repositories/transaction_epargne_repository.dart'; // CompteEpargneRepository + TransactionEpargneRepository
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../widgets/depot_epargne_dialog.dart';
import '../widgets/retrait_epargne_dialog.dart';
import '../widgets/transfert_epargne_dialog.dart';
import '../widgets/historique_epargne_sheet.dart';
/// Page détail d'un compte épargne : solde, infos, actions (Dépôt, Retrait, Transfert), dernieres transactions.
class EpargneDetailPage extends StatefulWidget {
final CompteEpargneModel compte;
final List<CompteEpargneModel> tousLesComptes;
final VoidCallback? onDataChanged;
const EpargneDetailPage({
super.key,
required this.compte,
required this.tousLesComptes,
this.onDataChanged,
});
@override
State<EpargneDetailPage> createState() => _EpargneDetailPageState();
}
class _EpargneDetailPageState extends State<EpargneDetailPage> {
List<TransactionEpargneModel> _transactions = [];
bool _loadingTx = true;
String? _errorTx;
CompteEpargneModel? _compte; // rafraîchi après actions
@override
void initState() {
super.initState();
_compte = widget.compte;
_loadTransactions();
}
Future<void> _refreshCompte() async {
try {
final repo = GetIt.I<CompteEpargneRepository>();
if (_compte?.id != null) {
final c = await repo.getById(_compte!.id!);
if (c != null && mounted) setState(() => _compte = c);
}
} catch (e, st) {
AppLogger.error('EpargneDetailPage: _refreshCompte échoué', error: e, stackTrace: st);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Impossible de rafraîchir le compte')),
);
}
}
}
Future<void> _loadTransactions() async {
if (_compte?.id == null) {
setState(() {
_loadingTx = false;
_transactions = [];
});
return;
}
setState(() {
_loadingTx = true;
_errorTx = null;
});
try {
final repo = GetIt.I<TransactionEpargneRepository>();
final list = await repo.getByCompte(_compte!.id!);
if (!mounted) return;
setState(() {
_transactions = list.map((e) => TransactionEpargneModel.fromJson(e)).toList();
_loadingTx = false;
_errorTx = null;
});
} catch (e, st) {
AppLogger.error('EpargneDetailPage: _loadTransactions échoué', error: e, stackTrace: st);
if (!mounted) return;
setState(() {
_transactions = [];
_loadingTx = false;
_errorTx = e.toString().replaceFirst('Exception: ', '');
});
}
}
void _openDepot() {
if (_compte?.id == null) return;
showDialog<bool>(
context: context,
builder: (ctx) => DepotEpargneDialog(
compteId: _compte!.id!,
onSuccess: () {
_refreshCompte();
_loadTransactions();
widget.onDataChanged?.call();
},
),
).then((_) => _refreshCompte());
}
void _openRetrait() {
if (_compte?.id == null) return;
final soldeDispo = (_compte!.soldeActuel - _compte!.soldeBloque).clamp(0.0, double.infinity);
showDialog<bool>(
context: context,
builder: (ctx) => RetraitEpargneDialog(
compteId: _compte!.id!,
numeroCompte: _compte!.numeroCompte ?? _compte!.id!,
soldeDisponible: soldeDispo,
onSuccess: () {
_refreshCompte();
_loadTransactions();
widget.onDataChanged?.call();
},
),
).then((_) => _refreshCompte());
}
void _openTransfert() {
if (_compte?.id == null) return;
showDialog<bool>(
context: context,
builder: (ctx) => TransfertEpargneDialog(
compteSource: _compte!,
tousLesComptes: widget.tousLesComptes,
onSuccess: () {
_refreshCompte();
_loadTransactions();
widget.onDataChanged?.call();
},
),
).then((_) => _refreshCompte());
}
void _openHistorique() {
if (_compte?.id == null) return;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (ctx) => HistoriqueEpargneSheet(compte: _compte!),
);
}
String? _typeCompteLibelle(String? code) {
if (code == null) return null;
const map = {
'COURANT': 'Compte courant',
'EPARGNE_LIBRE': 'Épargne libre',
'EPARGNE_BLOQUEE': 'Épargne bloquée',
'DEPOT_A_TERME': 'Dépôt à terme',
'EPARGNE_PROJET': 'Épargne projet',
};
return map[code] ?? code;
}
@override
Widget build(BuildContext context) {
final c = _compte ?? widget.compte;
final soldeDispo = (c.soldeActuel - c.soldeBloque).clamp(0.0, double.infinity);
final actif = c.statut == 'ACTIF';
return Scaffold(
appBar: AppBar(
title: const Text('Détail du compte'),
backgroundColor: Colors.transparent,
elevation: 0,
foregroundColor: ColorTokens.onSurface,
actions: [
IconButton(
icon: const Icon(Icons.history),
onPressed: _transactions.isEmpty ? null : _openHistorique,
tooltip: 'Historique',
),
],
),
body: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFFfafaf9),
const Color(0xFFfafaf9).withOpacity(0.85),
],
),
),
child: SafeArea(
child: RefreshIndicator(
onRefresh: () async {
await _refreshCompte();
await _loadTransactions();
},
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(SpacingTokens.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Card(
elevation: 2,
shadowColor: ColorTokens.shadow,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(RadiusTokens.lg)),
child: Padding(
padding: const EdgeInsets.all(SpacingTokens.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
c.numeroCompte ?? c.id ?? '',
style: TypographyTokens.titleMedium?.copyWith(
color: ColorTokens.onSurfaceVariant,
letterSpacing: 0.5,
),
),
if (_typeCompteLibelle(c.typeCompte) != null)
Text(
_typeCompteLibelle(c.typeCompte)!,
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
),
const SizedBox(height: SpacingTokens.md),
Text(
'${c.soldeActuel.toStringAsFixed(0)} XOF',
style: TypographyTokens.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: ColorTokens.primary,
),
),
if (c.soldeBloque > 0)
Text(
'dont ${c.soldeBloque.toStringAsFixed(0)} XOF bloqué(s)',
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
),
Text(
'Disponible: ${soldeDispo.toStringAsFixed(0)} XOF',
style: TypographyTokens.labelMedium?.copyWith(color: ColorTokens.primary),
),
if (c.dateOuverture != null)
Padding(
padding: const EdgeInsets.only(top: SpacingTokens.sm),
child: Text(
'Ouvert le ${c.dateOuverture!.day.toString().padLeft(2, '0')}/${c.dateOuverture!.month.toString().padLeft(2, '0')}/${c.dateOuverture!.year}',
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
),
),
if (c.description != null && c.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: SpacingTokens.xs),
child: Text(
c.description!,
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
),
const SizedBox(height: SpacingTokens.lg),
if (actif) ...[
Row(
children: [
Expanded(
child: FilledButton.icon(
onPressed: _openDepot,
icon: const Icon(Icons.add_circle_outline, size: 20),
label: const Text('Dépôt'),
),
),
const SizedBox(width: SpacingTokens.sm),
Expanded(
child: FilledButton.tonalIcon(
onPressed: soldeDispo > 0 ? _openRetrait : null,
icon: const Icon(Icons.remove_circle_outline, size: 20),
label: const Text('Retrait'),
),
),
],
),
const SizedBox(height: SpacingTokens.sm),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: widget.tousLesComptes.length > 1 ? _openTransfert : null,
icon: const Icon(Icons.swap_horiz, size: 20),
label: const Text('Transfert vers un autre compte'),
),
),
const SizedBox(height: SpacingTokens.lg),
],
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Dernières opérations',
style: TypographyTokens.titleSmall,
),
TextButton(
onPressed: _openHistorique,
child: const Text('Voir tout'),
),
],
),
if (_loadingTx)
const Padding(
padding: EdgeInsets.all(SpacingTokens.xl),
child: Center(child: CircularProgressIndicator()),
)
else if (_errorTx != null)
Padding(
padding: const EdgeInsets.all(SpacingTokens.md),
child: Column(
children: [
Text(_errorTx!, style: TextStyle(color: ColorTokens.error)),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed: _loadTransactions,
child: const Text('Réessayer'),
),
],
),
)
else if (_transactions.isEmpty)
Padding(
padding: const EdgeInsets.all(SpacingTokens.lg),
child: Text(
'Aucune transaction',
style: TypographyTokens.bodySmall?.copyWith(color: ColorTokens.onSurfaceVariant),
textAlign: TextAlign.center,
),
)
else
Card(
child: Column(
children: _transactions.take(10).map((t) {
return 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: 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),
)
: null,
trailing: Text(
'${t.isCredit ? '+' : '-'}${t.montant.toStringAsFixed(0)} XOF',
style: TypographyTokens.titleSmall?.copyWith(
color: t.isCredit ? ColorTokens.success : ColorTokens.error,
fontWeight: FontWeight.w600,
),
),
);
}).toList(),
),
),
],
),
),
),
),
),
);
}
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;
}
}

View File

@@ -0,0 +1,444 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../../../authentication/data/models/user_role.dart';
import '../../../authentication/presentation/bloc/auth_bloc.dart';
import '../../data/models/compte_epargne_model.dart';
import '../../data/repositories/transaction_epargne_repository.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/core_card.dart';
import '../../../../shared/widgets/info_badge.dart';
import '../widgets/creer_compte_epargne_dialog.dart';
import '../widgets/depot_epargne_dialog.dart';
import '../widgets/retrait_epargne_dialog.dart';
import '../widgets/transfert_epargne_dialog.dart';
import '../widgets/historique_epargne_sheet.dart';
import 'epargne_detail_page.dart';
/// Page listant les comptes épargne — rendu bank-grade : récap, cartes avec actions (Dépôt, Retrait, Transfert, Détail, Historique).
class EpargnePage extends StatefulWidget {
const EpargnePage({super.key});
@override
State<EpargnePage> createState() => _EpargnePageState();
}
class _EpargnePageState extends State<EpargnePage> {
List<CompteEpargneModel> _comptes = [];
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_loadComptes();
}
Future<void> _loadComptes() async {
final authState = context.read<AuthBloc>().state;
if (authState is! AuthAuthenticated) {
if (!mounted) return;
setState(() {
_loading = false;
_error = 'Non connecté';
});
return;
}
if (!mounted) return;
setState(() {
_loading = true;
_error = null;
});
try {
final compteRepo = GetIt.I<CompteEpargneRepository>();
final list = await compteRepo.getMesComptes();
if (!mounted) return;
setState(() {
_comptes = list;
_loading = false;
_error = null;
});
} catch (e) {
if (!mounted) return;
setState(() {
_comptes = [];
_loading = false;
_error = 'Erreur: ${e.toString().replaceFirst('Exception: ', '')}';
});
}
}
void _openDepot(CompteEpargneModel compte) {
if (compte.id == null) return;
showDialog<bool>(
context: context,
builder: (ctx) => DepotEpargneDialog(
compteId: compte.id!,
onSuccess: _loadComptes,
),
).then((_) => _loadComptes());
}
void _openRetrait(CompteEpargneModel compte) {
if (compte.id == null) return;
final soldeDispo = (compte.soldeActuel - compte.soldeBloque).clamp(0.0, double.infinity);
showDialog<bool>(
context: context,
builder: (ctx) => RetraitEpargneDialog(
compteId: compte.id!,
numeroCompte: compte.numeroCompte ?? compte.id!,
soldeDisponible: soldeDispo,
onSuccess: _loadComptes,
),
).then((_) => _loadComptes());
}
void _openTransfert(CompteEpargneModel compte) {
if (compte.id == null) return;
showDialog<bool>(
context: context,
builder: (ctx) => TransfertEpargneDialog(
compteSource: compte,
tousLesComptes: _comptes,
onSuccess: _loadComptes,
),
).then((_) => _loadComptes());
}
void _openDetail(CompteEpargneModel compte) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (ctx) => EpargneDetailPage(
compte: compte,
tousLesComptes: _comptes,
onDataChanged: _loadComptes,
),
),
).then((_) => _loadComptes());
}
void _openHistorique(CompteEpargneModel compte) {
if (compte.id == null) return;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
builder: (ctx) => HistoriqueEpargneSheet(compte: compte),
);
}
String? _typeCompteLibelle(String? code) {
if (code == null) return null;
const map = {
'COURANT': 'Compte courant',
'EPARGNE_LIBRE': 'Épargne libre',
'EPARGNE_BLOQUEE': 'Épargne bloquée',
'DEPOT_A_TERME': 'Dépôt à terme',
'EPARGNE_PROJET': 'Épargne projet',
};
return map[code] ?? code;
}
bool _canCreateCompte(BuildContext context) {
final state = context.read<AuthBloc>().state;
if (state is! AuthAuthenticated) return false;
final role = state.effectiveRole;
return role == UserRole.superAdmin || role == UserRole.orgAdmin;
}
void _openCreerCompte() {
showDialog<bool>(
context: context,
builder: (ctx) => CreerCompteEpargneDialog(onCreated: _loadComptes),
).then((_) => _loadComptes());
}
Widget _buildRecapCard() {
double total = 0;
for (final c in _comptes) {
total += (c.soldeActuel - c.soldeBloque).clamp(0.0, double.infinity);
}
return CoreCard(
padding: const EdgeInsets.all(SpacingTokens.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'VUE D\'ENSEMBLE',
style: AppTypography.subtitleSmall.copyWith(fontWeight: FontWeight.bold),
),
const Icon(Icons.account_balance_wallet, color: AppColors.primaryGreen, size: 24),
],
),
const SizedBox(height: SpacingTokens.md),
Text(
'${total.toStringAsFixed(0)} XOF',
style: AppTypography.headerSmall.copyWith(fontSize: 24, color: AppColors.primaryGreen),
),
const SizedBox(height: SpacingTokens.xs),
Text(
'Solde disponible total • ${_comptes.length} compte${_comptes.length > 1 ? 's' : ''}',
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.textSecondaryLight),
),
],
),
);
}
Widget _buildCompteCard(CompteEpargneModel c) {
final typeLibelle = _typeCompteLibelle(c.typeCompte);
final dateStr = c.dateOuverture != null
? 'Ouvert le ${c.dateOuverture!.day.toString().padLeft(2, '0')}/${c.dateOuverture!.month.toString().padLeft(2, '0')}/${c.dateOuverture!.year}'
: null;
final soldeDispo = (c.soldeActuel - c.soldeBloque).clamp(0.0, double.infinity);
final actif = c.statut == 'ACTIF';
final canTransfert = _comptes.length > 1;
return CoreCard(
margin: const EdgeInsets.only(bottom: SpacingTokens.md),
padding: const EdgeInsets.all(SpacingTokens.md),
onTap: () => _openDetail(c),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
c.numeroCompte ?? 'Compte ${c.id ?? ""}',
style: AppTypography.actionText,
),
if (typeLibelle != null)
Text(
typeLibelle,
style: AppTypography.subtitleSmall.copyWith(color: AppColors.textSecondaryLight),
),
if (dateStr != null)
Text(
dateStr,
style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: AppColors.textSecondaryLight),
),
],
),
),
if (c.statut != null)
InfoBadge(
text: c.statut!,
backgroundColor: c.statut == 'ACTIF' ? AppColors.success : AppColors.textSecondaryLight,
),
],
),
const SizedBox(height: SpacingTokens.md),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('SOLDE ACTUEL', style: AppTypography.subtitleSmall.copyWith(fontSize: 8, fontWeight: FontWeight.bold)),
Text(
'${c.soldeActuel.toStringAsFixed(0)} XOF',
style: AppTypography.headerSmall.copyWith(fontSize: 14, color: AppColors.primaryGreen),
),
],
),
if (c.soldeBloque > 0)
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('BLOQUÉ', style: AppTypography.subtitleSmall.copyWith(fontSize: 8, fontWeight: FontWeight.bold)),
Text(
'${c.soldeBloque.toStringAsFixed(0)} XOF',
style: AppTypography.bodyTextSmall.copyWith(fontSize: 12, color: AppColors.error),
),
],
),
],
),
if (c.description != null && c.description!.isNotEmpty) ...[
const SizedBox(height: SpacingTokens.sm),
Text(
c.description!,
style: AppTypography.bodyTextSmall.copyWith(fontSize: 11, color: AppColors.textSecondaryLight),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
if (actif) ...[
const SizedBox(height: SpacingTokens.md),
const Divider(height: 1),
const SizedBox(height: SpacingTokens.sm),
Row(
children: [
Expanded(
child: FilledButton.tonal(
onPressed: () => _openDepot(c),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm),
backgroundColor: AppColors.primaryGreen.withOpacity(0.1),
foregroundColor: AppColors.primaryGreen,
),
child: const Text('Dépôt', style: TextStyle(fontSize: 12)),
),
),
const SizedBox(width: SpacingTokens.xs),
Expanded(
child: FilledButton.tonal(
onPressed: soldeDispo > 0 ? () => _openRetrait(c) : null,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm),
backgroundColor: AppColors.primaryGreen.withOpacity(0.1),
foregroundColor: AppColors.primaryGreen,
),
child: const Text('Retrait', style: TextStyle(fontSize: 12)),
),
),
if (canTransfert) ...[
const SizedBox(width: SpacingTokens.xs),
Expanded(
child: FilledButton.tonal(
onPressed: soldeDispo > 0 ? () => _openTransfert(c) : null,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm),
backgroundColor: AppColors.primaryGreen.withOpacity(0.1),
foregroundColor: AppColors.primaryGreen,
),
child: const Text('Transférer', style: TextStyle(fontSize: 12)),
),
),
],
],
),
const SizedBox(height: SpacingTokens.xs),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _openDetail(c),
icon: const Icon(Icons.info_outline, size: 16),
label: const Text('Détail', style: TextStyle(fontSize: 12)),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm),
foregroundColor: AppColors.textPrimaryLight,
),
),
),
const SizedBox(width: SpacingTokens.xs),
Expanded(
child: OutlinedButton.icon(
onPressed: () => _openHistorique(c),
icon: const Icon(Icons.history, size: 16),
label: const Text('Historique', style: TextStyle(fontSize: 12)),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm),
foregroundColor: AppColors.textPrimaryLight,
),
),
),
],
),
],
],
),
);
}
Widget _buildBodyContent() {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return Center(
child: Padding(
padding: const EdgeInsets.all(SpacingTokens.lg),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 48, color: AppColors.error),
const SizedBox(height: SpacingTokens.md),
Text(
_error!,
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.error),
textAlign: TextAlign.center,
),
const SizedBox(height: SpacingTokens.lg),
FilledButton(
onPressed: _loadComptes,
child: const Text('Réessayer'),
),
],
),
),
);
}
if (_comptes.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.xl),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.savings_outlined, size: 64, color: AppColors.textSecondaryLight),
const SizedBox(height: SpacingTokens.lg),
Text(
'Aucun compte épargne',
style: AppTypography.actionText.copyWith(color: AppColors.textSecondaryLight),
textAlign: TextAlign.center,
),
const SizedBox(height: SpacingTokens.sm),
Text(
'Votre organisation peut ouvrir un compte épargne pour vous. Contactez-la pour en bénéficier.',
style: AppTypography.bodyTextSmall.copyWith(color: AppColors.textSecondaryLight),
textAlign: TextAlign.center,
),
],
),
),
);
}
return RefreshIndicator(
onRefresh: _loadComptes,
child: ListView(
padding: const EdgeInsets.all(SpacingTokens.lg),
children: [
_buildRecapCard(),
const SizedBox(height: SpacingTokens.lg),
..._comptes.map(_buildCompteCard),
],
),
);
}
@override
Widget build(BuildContext context) {
final showFab = _canCreateCompte(context);
return Scaffold(
backgroundColor: AppColors.background,
appBar: const UFAppBar(
title: 'COMPTES ÉPARGNE',
backgroundColor: AppColors.surface,
foregroundColor: AppColors.textPrimaryLight,
),
body: _buildBodyContent(),
floatingActionButton: showFab
? FloatingActionButton(
onPressed: _openCreerCompte,
tooltip: 'Créer un compte épargne pour un membre',
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
child: const Icon(Icons.add),
)
: null,
);
}
}

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