- 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)
428 lines
17 KiB
Dart
428 lines
17 KiB
Dart
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/tokens/app_colors.dart';
|
|
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;
|
|
bool _loadingMore = false;
|
|
bool _hasMore = true;
|
|
int _page = 0;
|
|
static const int _pageSize = 20;
|
|
String? _errorTx;
|
|
CompteEpargneModel? _compte; // rafraîchi après actions
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_compte = widget.compte;
|
|
_loadTransactions(reset: true);
|
|
}
|
|
|
|
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({bool reset = false}) async {
|
|
if (_compte?.id == null) {
|
|
setState(() { _loadingTx = false; _transactions = []; });
|
|
return;
|
|
}
|
|
if (reset) {
|
|
setState(() { _loadingTx = true; _errorTx = null; _page = 0; _hasMore = true; });
|
|
} else {
|
|
if (_loadingMore || !_hasMore) return;
|
|
setState(() => _loadingMore = true);
|
|
}
|
|
try {
|
|
final repo = GetIt.I<TransactionEpargneRepository>();
|
|
final list = await repo.getByCompte(_compte!.id!, page: _page, size: _pageSize);
|
|
if (!mounted) return;
|
|
final parsed = list.map((e) => TransactionEpargneModel.fromJson(e)).toList();
|
|
setState(() {
|
|
if (reset) {
|
|
_transactions = parsed;
|
|
} else {
|
|
_transactions = [..._transactions, ...parsed];
|
|
}
|
|
_hasMore = parsed.length == _pageSize;
|
|
_page = _page + 1;
|
|
_loadingTx = false;
|
|
_loadingMore = false;
|
|
_errorTx = null;
|
|
});
|
|
} catch (e, st) {
|
|
AppLogger.error('EpargneDetailPage: _loadTransactions échoué', error: e, stackTrace: st);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
if (reset) _transactions = [];
|
|
_loadingTx = false;
|
|
_loadingMore = 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(reset: true);
|
|
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(reset: true);
|
|
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(reset: true);
|
|
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: const BoxDecoration(
|
|
color: AppColors.lightBackground,
|
|
),
|
|
child: SafeArea(
|
|
child: RefreshIndicator(
|
|
onRefresh: () async {
|
|
await _refreshCompte();
|
|
await _loadTransactions(reset: true);
|
|
},
|
|
child: SingleChildScrollView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.all(SpacingTokens.sm),
|
|
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.map((t) {
|
|
final dateStr = t.dateTransaction != null
|
|
? '${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')}'
|
|
: null;
|
|
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: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (dateStr != null)
|
|
Text(dateStr, style: TypographyTokens.bodySmall.copyWith(color: ColorTokens.onSurfaceVariant)),
|
|
Text(
|
|
'Avant: ${t.soldeAvant.toStringAsFixed(0)} → Après: ${t.soldeApres.toStringAsFixed(0)} XOF',
|
|
style: TypographyTokens.bodySmall.copyWith(color: ColorTokens.onSurfaceVariant),
|
|
),
|
|
if (t.motif != null && t.motif!.isNotEmpty)
|
|
Text(
|
|
t.motif!,
|
|
style: TypographyTokens.bodySmall.copyWith(
|
|
color: ColorTokens.onSurfaceVariant,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
trailing: Text(
|
|
'${t.isCredit ? '+' : '-'}${t.montant.toStringAsFixed(0)} XOF',
|
|
style: TypographyTokens.titleSmall.copyWith(
|
|
color: t.isCredit ? ColorTokens.success : ColorTokens.error,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
isThreeLine: t.motif != null && t.motif!.isNotEmpty,
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
if (_hasMore)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.sm),
|
|
child: _loadingMore
|
|
? const Center(child: CircularProgressIndicator())
|
|
: TextButton(
|
|
onPressed: () => _loadTransactions(),
|
|
child: const Text('Charger plus'),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|