- Epargne: badge LCB-FT (bouclier ambre) sur comptes avec fonds bloques + note recap - EpargneDetail: historique pagine (page/size), affichage soldeAvant/soldeApres/motif dans chaque transaction, bouton "Charger plus" - TransactionEpargneRepository: getByCompte accepte page et size, gere reponse paginee Spring (content[]) - MessagingDatasource: markMessageAsRead silencieuse (pas d'endpoint unitaire), getUnreadCount somme unreadCount des conversations - OrganizationDetail: _memberCount charge le vrai nombre depuis GET /membres/count, affiche la valeur reelle au lieu de nombreMembres (toujours 0)
481 lines
17 KiB
Dart
481 lines
17 KiB
Dart
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),
|
|
),
|
|
if (_comptes.any((c) => c.soldeBloque > 0)) ...[
|
|
const SizedBox(height: SpacingTokens.xs),
|
|
Row(
|
|
children: [
|
|
Icon(Icons.shield_outlined, size: 12, color: Colors.amber.shade700),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'Certains fonds sont sous surveillance LCB-FT',
|
|
style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: Colors.amber.shade700),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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.soldeBloque > 0)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: SpacingTokens.xs),
|
|
child: Tooltip(
|
|
message: 'Fonds bloqués — surveillance LCB-FT',
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: Colors.amber.withOpacity(0.15),
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: Colors.amber.shade700, width: 1),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.shield_outlined, size: 12, color: Colors.amber.shade700),
|
|
const SizedBox(width: 3),
|
|
Text('LCB-FT', style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: Colors.amber.shade700)),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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.sm),
|
|
children: [
|
|
_buildRecapCard(),
|
|
const SizedBox(height: SpacingTokens.sm),
|
|
..._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,
|
|
);
|
|
}
|
|
}
|