386 lines
14 KiB
Dart
386 lines
14 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:intl/intl.dart';
|
|
import '../../../../shared/design_system/unionflow_design_system.dart';
|
|
import '../../../../shared/design_system/tokens/app_typography.dart';
|
|
import '../../../../shared/widgets/info_badge.dart';
|
|
import '../../../../shared/widgets/loading_widget.dart';
|
|
import '../../../../shared/widgets/error_widget.dart';
|
|
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_bloc.dart';
|
|
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_event.dart';
|
|
import 'package:unionflow_mobile_apps/features/contributions/bloc/contributions_state.dart';
|
|
import 'package:unionflow_mobile_apps/features/contributions/data/models/contribution_model.dart';
|
|
import 'package:unionflow_mobile_apps/features/contributions/presentation/widgets/payment_dialog.dart';
|
|
import 'package:unionflow_mobile_apps/features/contributions/presentation/widgets/create_contribution_dialog.dart';
|
|
import 'package:unionflow_mobile_apps/features/contributions/presentation/pages/mes_statistiques_cotisations_page.dart';
|
|
|
|
/// Page de gestion des contributions - Version Design System
|
|
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', decimalDigits: 0);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 4, vsync: this);
|
|
}
|
|
|
|
@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 Scaffold(
|
|
backgroundColor: ColorTokens.background,
|
|
appBar: UFAppBar(
|
|
title: 'Cotisations',
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.bar_chart, size: 20),
|
|
onPressed: () => _showStats(),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.add_circle_outline, size: 20),
|
|
onPressed: () => _showCreateDialog(),
|
|
),
|
|
],
|
|
bottom: TabBar(
|
|
controller: _tabController,
|
|
onTap: (_) => _loadContributions(),
|
|
labelColor: ColorTokens.onPrimary,
|
|
unselectedLabelColor: ColorTokens.onPrimary.withOpacity(0.7),
|
|
indicatorColor: ColorTokens.onPrimary,
|
|
labelStyle: AppTypography.badgeText.copyWith(fontWeight: FontWeight.bold),
|
|
tabs: const [
|
|
Tab(text: 'Toutes'),
|
|
Tab(text: 'Payées'),
|
|
Tab(text: 'Dues'),
|
|
Tab(text: 'Retard'),
|
|
],
|
|
),
|
|
),
|
|
body: TabBarView(
|
|
controller: _tabController,
|
|
children: List.generate(4, (_) => _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) {
|
|
return _buildListOrEmpty(state.contributions);
|
|
}
|
|
|
|
// Au retour de "Mes Statistiques", la liste peut être conservée dans ContributionsStatsLoaded
|
|
if (state is ContributionsStatsLoaded) {
|
|
if (state.contributions != null) {
|
|
return _buildListOrEmpty(state.contributions!);
|
|
}
|
|
// Stats ouverts sans liste préalable : charger les contributions une fois
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (context.mounted) {
|
|
context.read<ContributionsBloc>().add(const LoadContributions());
|
|
}
|
|
});
|
|
return const Center(child: Text('Initialisation...'));
|
|
}
|
|
|
|
return const Center(child: Text('Initialisation...'));
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildListOrEmpty(List<ContributionModel> contributions) {
|
|
if (contributions.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.payment_outlined, size: 48, color: ColorTokens.onSurfaceVariant.withOpacity(0.5)),
|
|
const SizedBox(height: SpacingTokens.md),
|
|
Text('Aucune contribution', style: AppTypography.bodyTextSmall),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return Column(
|
|
children: [
|
|
_buildMiniStats(contributions),
|
|
Expanded(
|
|
child: RefreshIndicator(
|
|
onRefresh: () async => _loadContributions(),
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.all(SpacingTokens.md),
|
|
itemCount: contributions.length,
|
|
itemBuilder: (context, index) => _buildContributionCard(contributions[index]),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildMiniStats(List<ContributionModel> contributions) {
|
|
final totalDue = contributions.fold(0.0, (sum, c) => sum + c.montant);
|
|
final totalPaid = contributions.fold(0.0, (sum, c) => sum + (c.montantPaye ?? 0.0));
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: SpacingTokens.md, vertical: SpacingTokens.sm),
|
|
color: ColorTokens.surfaceVariant.withOpacity(0.3),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
children: [
|
|
_buildMetric('DU', _currencyFormat.format(totalDue), ColorTokens.secondary),
|
|
_buildMetric('PAYÉ', _currencyFormat.format(totalPaid), ColorTokens.success),
|
|
_buildMetric('RESTANT', _currencyFormat.format(totalDue - totalPaid), ColorTokens.error),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMetric(String label, String value, Color color) {
|
|
return Column(
|
|
children: [
|
|
Text(label, style: AppTypography.badgeText.copyWith(color: ColorTokens.onSurfaceVariant)),
|
|
Text(value, style: AppTypography.headerSmall.copyWith(color: color, fontWeight: FontWeight.bold)),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildContributionCard(ContributionModel contribution) {
|
|
return UFCard(
|
|
margin: const EdgeInsets.only(bottom: SpacingTokens.sm),
|
|
onTap: () => _showContributionDetails(contribution),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(contribution.membreNomComplet, style: AppTypography.headerSmall),
|
|
Text(contribution.libellePeriode, style: AppTypography.subtitleSmall),
|
|
],
|
|
),
|
|
),
|
|
_buildStatutBadge(contribution.statut, contribution.estEnRetard),
|
|
],
|
|
),
|
|
const SizedBox(height: SpacingTokens.md),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
_buildAmountValue('Montant', contribution.montant),
|
|
if (contribution.montantPaye != null && contribution.montantPaye! > 0)
|
|
_buildAmountValue('Payé', contribution.montantPaye!, color: ColorTokens.success),
|
|
_buildAmountValue('Échéance', contribution.dateEcheance, isDate: true),
|
|
],
|
|
),
|
|
if (contribution.statut == ContributionStatus.partielle) ...[
|
|
const SizedBox(height: SpacingTokens.sm),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(RadiusTokens.sm),
|
|
child: LinearProgressIndicator(
|
|
value: contribution.pourcentagePaye / 100,
|
|
backgroundColor: ColorTokens.surfaceVariant,
|
|
valueColor: const AlwaysStoppedAnimation<Color>(ColorTokens.primary),
|
|
minHeight: 4,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAmountValue(String label, dynamic value, {Color? color, bool isDate = false}) {
|
|
String displayValue = isDate
|
|
? DateFormat('dd/MM/yy').format(value as DateTime)
|
|
: _currencyFormat.format(value as double);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: AppTypography.badgeText.copyWith(color: ColorTokens.onSurfaceVariant)),
|
|
Text(displayValue, style: AppTypography.bodyTextSmall.copyWith(
|
|
color: color ?? ColorTokens.onSurface,
|
|
fontWeight: FontWeight.w600,
|
|
)),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildStatutBadge(ContributionStatus statut, bool enRetard) {
|
|
if (enRetard && statut != ContributionStatus.payee) {
|
|
return const InfoBadge(text: 'RETARD', backgroundColor: Color(0xFFFFEBEB), textColor: ColorTokens.error);
|
|
}
|
|
|
|
switch (statut) {
|
|
case ContributionStatus.payee:
|
|
return const InfoBadge(text: 'PAYÉE', backgroundColor: Color(0xFFE3F9E5), textColor: ColorTokens.success);
|
|
case ContributionStatus.nonPayee:
|
|
case ContributionStatus.enAttente:
|
|
return const InfoBadge(text: 'DUE', backgroundColor: Color(0xFFFFF4E5), textColor: ColorTokens.warning);
|
|
case ContributionStatus.partielle:
|
|
return const InfoBadge(text: 'PARTIELLE', backgroundColor: Color(0xFFE5F1FF), textColor: ColorTokens.info);
|
|
case ContributionStatus.annulee:
|
|
return InfoBadge.neutral('ANNULÉE');
|
|
default:
|
|
return InfoBadge.neutral(statut.name.toUpperCase());
|
|
}
|
|
}
|
|
|
|
void _showContributionDetails(ContributionModel contribution) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
backgroundColor: ColorTokens.surface,
|
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(RadiusTokens.lg))),
|
|
builder: (context) => Padding(
|
|
padding: const EdgeInsets.all(SpacingTokens.xl),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(contribution.membreNomComplet, style: AppTypography.headerSmall),
|
|
Text(contribution.libellePeriode, style: AppTypography.subtitleSmall),
|
|
const Divider(height: SpacingTokens.xl),
|
|
_buildDetailRow('Montant Total', _currencyFormat.format(contribution.montant)),
|
|
_buildDetailRow('Montant Payé', _currencyFormat.format(contribution.montantPaye ?? 0.0)),
|
|
_buildDetailRow('Reste à payer', _currencyFormat.format(contribution.montantRestant), isCritical: contribution.montantRestant > 0),
|
|
_buildDetailRow('Date d\'échéance', DateFormat('dd MMMM yyyy').format(contribution.dateEcheance)),
|
|
if (contribution.description != null) ...[
|
|
const SizedBox(height: SpacingTokens.md),
|
|
Text(contribution.description!, style: AppTypography.bodyTextSmall),
|
|
],
|
|
const SizedBox(height: SpacingTokens.xl),
|
|
Row(
|
|
children: [
|
|
if (contribution.statut != ContributionStatus.payee)
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: UFPrimaryButton(
|
|
label: 'Enregistrer Paiement',
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_showPaymentDialog(contribution);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
style: OutlinedButton.styleFrom(
|
|
side: const BorderSide(color: ColorTokens.outline),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(RadiusTokens.md)),
|
|
),
|
|
child: Text('Fermer', style: AppTypography.actionText.copyWith(color: ColorTokens.onSurface)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDetailRow(String label, String value, {bool isCritical = false}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: SpacingTokens.xs),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(label, style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
|
|
Text(value, style: AppTypography.bodyTextSmall.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: isCritical ? ColorTokens.error : ColorTokens.onSurface,
|
|
)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showPaymentDialog(ContributionModel contribution) {
|
|
final contributionsBloc = context.read<ContributionsBloc>();
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => BlocProvider.value(
|
|
value: contributionsBloc,
|
|
child: PaymentDialog(cotisation: contribution),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showCreateDialog() {
|
|
final contributionsBloc = context.read<ContributionsBloc>();
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => BlocProvider.value(
|
|
value: contributionsBloc,
|
|
child: const CreateContributionDialog(),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showStats() {
|
|
final contributionsBloc = context.read<ContributionsBloc>();
|
|
Navigator.of(context).push(
|
|
MaterialPageRoute<void>(
|
|
builder: (_) => BlocProvider.value(
|
|
value: contributionsBloc,
|
|
child: const MesStatistiquesCotisationsPage(),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|