Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
394
lib/features/epargne/presentation/pages/epargne_detail_page.dart
Normal file
394
lib/features/epargne/presentation/pages/epargne_detail_page.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
444
lib/features/epargne/presentation/pages/epargne_page.dart
Normal file
444
lib/features/epargne/presentation/pages/epargne_page.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user