513 lines
16 KiB
Dart
513 lines
16 KiB
Dart
/// Page de gestion des contributions
|
|
library contributions_page;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../../../../shared/widgets/error_widget.dart';
|
|
import '../../../../shared/widgets/loading_widget.dart';
|
|
import '../../bloc/contributions_bloc.dart';
|
|
import '../../bloc/contributions_event.dart';
|
|
import '../../bloc/contributions_state.dart';
|
|
import '../../data/models/contribution_model.dart';
|
|
import 'package:unionflow_mobile_apps/features/contributions/presentation/widgets/create_contribution_dialog.dart';
|
|
import '../widgets/payment_dialog.dart';
|
|
import '../../../members/bloc/membres_bloc.dart';
|
|
|
|
/// Page principale des contributions
|
|
class ContributionsPage extends StatefulWidget {
|
|
const ContributionsPage({super.key});
|
|
|
|
@override
|
|
State<ContributionsPage> createState() => _ContributionsPageState();
|
|
}
|
|
|
|
class _ContributionsPageState extends State<ContributionsPage>
|
|
with SingleTickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA');
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 4, vsync: this);
|
|
_loadContributions();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _loadContributions() {
|
|
final currentTab = _tabController.index;
|
|
switch (currentTab) {
|
|
case 0:
|
|
context.read<ContributionsBloc>().add(const LoadContributions());
|
|
break;
|
|
case 1:
|
|
context.read<ContributionsBloc>().add(const LoadContributionsPayees());
|
|
break;
|
|
case 2:
|
|
context.read<ContributionsBloc>().add(const LoadContributionsNonPayees());
|
|
break;
|
|
case 3:
|
|
context.read<ContributionsBloc>().add(const LoadContributionsEnRetard());
|
|
break;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocListener<ContributionsBloc, ContributionsState>(
|
|
listener: (context, state) {
|
|
// Gestion des erreurs avec SnackBar
|
|
if (state is ContributionsError) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(state.message),
|
|
backgroundColor: Colors.red,
|
|
duration: const Duration(seconds: 4),
|
|
action: SnackBarAction(
|
|
label: 'Réessayer',
|
|
textColor: Colors.white,
|
|
onPressed: _loadContributions,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Cotisations'),
|
|
bottom: TabBar(
|
|
controller: _tabController,
|
|
onTap: (_) => _loadContributions(),
|
|
tabs: const [
|
|
Tab(text: 'Toutes', icon: Icon(Icons.list)),
|
|
Tab(text: 'Payées', icon: Icon(Icons.check_circle)),
|
|
Tab(text: 'Non payées', icon: Icon(Icons.pending)),
|
|
Tab(text: 'En retard', icon: Icon(Icons.warning)),
|
|
],
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.bar_chart),
|
|
onPressed: () => _showStats(),
|
|
tooltip: 'Statistiques',
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.add),
|
|
onPressed: () => _showCreateDialog(),
|
|
tooltip: 'Nouvelle contribution',
|
|
),
|
|
],
|
|
),
|
|
body: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
_buildContributionsList(),
|
|
_buildContributionsList(),
|
|
_buildContributionsList(),
|
|
_buildContributionsList(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildContributionsList() {
|
|
return BlocBuilder<ContributionsBloc, ContributionsState>(
|
|
builder: (context, state) {
|
|
if (state is ContributionsLoading) {
|
|
return const Center(child: AppLoadingWidget());
|
|
}
|
|
|
|
if (state is ContributionsError) {
|
|
return Center(
|
|
child: AppErrorWidget(
|
|
message: state.message,
|
|
onRetry: _loadContributions,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (state is ContributionsLoaded) {
|
|
if (state.contributions.isEmpty) {
|
|
return const Center(
|
|
child: EmptyDataWidget(
|
|
message: 'Aucune contribution trouvée',
|
|
icon: Icons.payment,
|
|
),
|
|
);
|
|
}
|
|
|
|
return RefreshIndicator(
|
|
onRefresh: () async => _loadContributions(),
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.all(16),
|
|
itemCount: state.contributions.length,
|
|
itemBuilder: (context, index) {
|
|
final contribution = state.contributions[index];
|
|
return _buildContributionCard(contribution);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
return const Center(child: Text('Chargez les cotisations'));
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildContributionCard(ContributionModel contribution) {
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
child: InkWell(
|
|
onTap: () => _showContributionDetails(contribution),
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
contribution.membreNomComplet,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
contribution.libellePeriode,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_buildStatutChip(contribution.statut),
|
|
],
|
|
),
|
|
const Divider(height: 24),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Montant',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_currencyFormat.format(contribution.montant),
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (contribution.montantPaye != null)
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Payé',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_currencyFormat.format(contribution.montantPaye),
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.green,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
'Échéance',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
DateFormat('dd/MM/yyyy').format(contribution.dateEcheance),
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: contribution.estEnRetard ? Colors.red : null,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
if (contribution.statut == ContributionStatus.partielle)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 12),
|
|
child: LinearProgressIndicator(
|
|
value: contribution.pourcentagePaye / 100,
|
|
backgroundColor: Colors.grey[200],
|
|
valueColor: const AlwaysStoppedAnimation<Color>(Colors.blue),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatutChip(ContributionStatus statut) {
|
|
Color color;
|
|
String label;
|
|
IconData icon;
|
|
|
|
switch (statut) {
|
|
case ContributionStatus.payee:
|
|
color = Colors.green;
|
|
label = 'Payée';
|
|
icon = Icons.check_circle;
|
|
break;
|
|
case ContributionStatus.nonPayee:
|
|
color = Colors.orange;
|
|
label = 'Non payée';
|
|
icon = Icons.pending;
|
|
break;
|
|
case ContributionStatus.enRetard:
|
|
color = Colors.red;
|
|
label = 'En retard';
|
|
icon = Icons.warning;
|
|
break;
|
|
case ContributionStatus.partielle:
|
|
color = Colors.blue;
|
|
label = 'Partielle';
|
|
icon = Icons.hourglass_bottom;
|
|
break;
|
|
case ContributionStatus.annulee:
|
|
color = Colors.grey;
|
|
label = 'Annulée';
|
|
icon = Icons.cancel;
|
|
break;
|
|
}
|
|
|
|
return Chip(
|
|
avatar: Icon(icon, size: 16, color: Colors.white),
|
|
label: Text(label),
|
|
backgroundColor: color,
|
|
labelStyle: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showContributionDetails(ContributionModel contribution) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(contribution.membreNomComplet),
|
|
content: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildDetailRow('Période', contribution.libellePeriode),
|
|
_buildDetailRow('Montant', _currencyFormat.format(contribution.montant)),
|
|
if (contribution.montantPaye != null)
|
|
_buildDetailRow('Payé', _currencyFormat.format(contribution.montantPaye)),
|
|
_buildDetailRow('Restant', _currencyFormat.format(contribution.montantRestant)),
|
|
_buildDetailRow(
|
|
'Échéance',
|
|
DateFormat('dd/MM/yyyy').format(contribution.dateEcheance),
|
|
),
|
|
if (contribution.datePaiement != null)
|
|
_buildDetailRow(
|
|
'Date paiement',
|
|
DateFormat('dd/MM/yyyy').format(contribution.datePaiement!),
|
|
),
|
|
if (contribution.methodePaiement != null)
|
|
_buildDetailRow('Méthode', _getMethodePaiementLabel(contribution.methodePaiement!)),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
if (contribution.statut != ContributionStatus.payee)
|
|
TextButton.icon(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_showPaymentDialog(contribution);
|
|
},
|
|
icon: const Icon(Icons.payment),
|
|
label: const Text('Enregistrer paiement'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Fermer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDetailRow(String label, String value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
color: Colors.grey[600],
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
Text(
|
|
value,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _getMethodePaiementLabel(PaymentMethod methode) {
|
|
switch (methode) {
|
|
case PaymentMethod.especes:
|
|
return 'Espèces';
|
|
case PaymentMethod.cheque:
|
|
return 'Chèque';
|
|
case PaymentMethod.virement:
|
|
return 'Virement';
|
|
case PaymentMethod.carteBancaire:
|
|
return 'Carte bancaire';
|
|
case PaymentMethod.waveMoney:
|
|
return 'Wave Money';
|
|
case PaymentMethod.orangeMoney:
|
|
return 'Orange Money';
|
|
case PaymentMethod.freeMoney:
|
|
return 'Free Money';
|
|
case PaymentMethod.mobileMoney:
|
|
return 'Mobile Money';
|
|
case PaymentMethod.autre:
|
|
return 'Autre';
|
|
}
|
|
}
|
|
|
|
void _showPaymentDialog(ContributionModel contribution) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => BlocProvider.value(
|
|
value: context.read<ContributionsBloc>(),
|
|
child: PaymentDialog(cotisation: contribution),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showCreateDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => MultiBlocProvider(
|
|
providers: [
|
|
BlocProvider.value(value: context.read<ContributionsBloc>()),
|
|
BlocProvider.value(value: context.read<MembresBloc>()),
|
|
],
|
|
child: const CreateContributionDialog(),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showStats() {
|
|
context.read<ContributionsBloc>().add(const LoadContributionsStats());
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Statistiques'),
|
|
content: BlocBuilder<ContributionsBloc, ContributionsState>(
|
|
builder: (context, state) {
|
|
if (state is ContributionsStatsLoaded) {
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildStatRow('Total', state.stats['total'].toString()),
|
|
_buildStatRow('Payées', state.stats['payees'].toString()),
|
|
_buildStatRow('Non payées', state.stats['nonPayees'].toString()),
|
|
_buildStatRow('En retard', state.stats['enRetard'].toString()),
|
|
const Divider(),
|
|
_buildStatRow(
|
|
'Montant total',
|
|
_currencyFormat.format(state.stats['montantTotal']),
|
|
),
|
|
_buildStatRow(
|
|
'Montant payé',
|
|
_currencyFormat.format(state.stats['montantPaye']),
|
|
),
|
|
_buildStatRow(
|
|
'Taux recouvrement',
|
|
'${state.stats['tauxRecouvrement'].toStringAsFixed(1)}%',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return const AppLoadingWidget();
|
|
},
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Fermer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatRow(String label, String value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(label),
|
|
Text(
|
|
value,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|