Files
unionflow-mobile-apps/lib/features/epargne/presentation/pages/epargne_detail_page.dart
dahoud 120434aba0 feat(features): refontes adhesions/admin/auth/backup/contributions/dashboard/epargne/events
- adhesions : bloc complet avec events/states/model, dialogs paiement/rejet
- admin : users bloc, user management list/detail pages
- authentication : bloc + keycloak auth service + webview
- backup : bloc complet, repository, models
- contributions : bloc + widgets + export
- dashboard : widgets connectés (activities, events, notifications, search)
  + charts + monitoring + shortcuts
- epargne : repository, transactions, dialogs
- events : bloc complet, pages (detail, connected, wrapper), models
2026-04-15 20:26:48 +00:00

426 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: UFAppBar(
title: 'Détail du compte',
moduleGradient: ModuleColors.epargneGradient,
actions: [
IconButton(
icon: const Icon(Icons.history),
onPressed: _transactions.isEmpty ? null : _openHistorique,
tooltip: 'Historique',
),
],
),
body: Container(
width: double.infinity,
decoration: const BoxDecoration(
color: AppColors.background,
),
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;
}
}