Files
dahoud ba779a7a40 feat(ui): dark mode adaptatif sur 15 pages/widgets restants
Pattern AppColors pair (isDark ternaries) appliqué sur :
- login_page : SnackBar error color Color(0xFFDC2626) → AppColors.error
  (gradient brand intentionnel non modifié)
- help_support : barre de recherche + ExpansionTile + chevrons → scheme adaptatif
- system_settings : état 'Accès réservé' + unselectedLabelColor TabBar
- epargne : date/description/boutons OutlinedButton foregroundColor adaptatifs
- conversation_tile, connected_recent_activities, connected_upcoming_events
- dashboard_notifications_widget
- budgets_list_page, pending_approvals_page, approve/reject_dialog
- create_organization_page, edit_organization_page, about_page

Les couleurs sémantiques (error, success, warning, primary) restent inchangées.
Les blancs/gradients intentionnels (AppBars brand, logos payment) préservés.
2026-04-15 20:14:59 +00:00

489 lines
18 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.primary, size: 24),
],
),
const SizedBox(height: SpacingTokens.md),
Text(
'${total.toStringAsFixed(0)} XOF',
style: AppTypography.headerSmall.copyWith(fontSize: 24, color: AppColors.primary),
),
const SizedBox(height: SpacingTokens.xs),
Text(
'Solde disponible total • ${_comptes.length} compte${_comptes.length > 1 ? 's' : ''}',
style: AppTypography.bodyTextSmall.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
),
if (_comptes.any((c) => c.soldeBloque > 0)) ...[
const SizedBox(height: SpacingTokens.xs),
Row(
children: [
Icon(Icons.shield_outlined, size: 12, color: AppColors.warning),
const SizedBox(width: 4),
Text(
'Certains fonds sont sous surveillance LCB-FT',
style: AppTypography.bodyTextSmall.copyWith(fontSize: 10, color: AppColors.warning),
),
],
),
],
],
),
);
}
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: Theme.of(context).colorScheme.onSurfaceVariant),
),
if (dateStr != null)
Text(
dateStr,
style: AppTypography.bodyTextSmall.copyWith(
fontSize: 10,
color: Theme.of(context).brightness == Brightness.dark
? AppColors.textSecondaryDark
: AppColors.textSecondary,
),
),
],
),
),
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: AppColors.warningContainer,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: AppColors.warning, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.shield_outlined, size: 12, color: AppColors.warning),
const SizedBox(width: 3),
Text('LCB-FT', style: TextStyle(fontSize: 9, fontWeight: FontWeight.bold, color: AppColors.warning)),
],
),
),
),
),
if (c.statut != null)
InfoBadge(
text: c.statut!,
backgroundColor: c.statut == 'ACTIF' ? AppColors.success : AppColors.textSecondary,
),
],
),
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.primary),
),
],
),
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.textSecondary),
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.primary.withOpacity(0.1),
foregroundColor: AppColors.primary,
),
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.primary.withOpacity(0.1),
foregroundColor: AppColors.primary,
),
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.primary.withOpacity(0.1),
foregroundColor: AppColors.primary,
),
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: Theme.of(context).brightness == Brightness.dark
? AppColors.textPrimaryDark
: AppColors.textPrimary,
),
),
),
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: Theme.of(context).brightness == Brightness.dark
? AppColors.textPrimaryDark
: AppColors.textPrimary,
),
),
),
],
),
],
],
),
);
}
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: [
Icon(Icons.savings_outlined, size: 64, color: Theme.of(context).colorScheme.onSurfaceVariant),
const SizedBox(height: SpacingTokens.lg),
Text(
'Aucun compte épargne',
style: AppTypography.actionText.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
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: Theme.of(context).colorScheme.onSurfaceVariant),
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: Theme.of(context).scaffoldBackgroundColor,
appBar: UFAppBar(
title: 'Comptes Épargne',
moduleGradient: ModuleColors.epargneGradient,
),
body: _buildBodyContent(),
floatingActionButton: showFab
? FloatingActionButton(
onPressed: _openCreerCompte,
tooltip: 'Créer un compte épargne pour un membre',
backgroundColor: AppColors.primary,
foregroundColor: AppColors.onPrimary,
child: const Icon(Icons.add),
)
: null,
);
}
}