Initial commit: unionflow-mobile-apps

Application Flutter complète (sans build artifacts).

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 16:30:08 +00:00
commit d094d6db9c
1790 changed files with 507435 additions and 0 deletions

View File

@@ -0,0 +1,385 @@
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(),
),
),
);
}
}

View File

@@ -0,0 +1,45 @@
/// Wrapper BLoC pour la page des cotisations
library cotisations_page_wrapper;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.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/presentation/pages/contributions_page.dart';
import 'package:unionflow_mobile_apps/features/members/bloc/membres_bloc.dart';
final _getIt = GetIt.instance;
/// Wrapper qui fournit les BLoCs à la page des cotisations (et au dialogue de création)
class CotisationsPageWrapper extends StatelessWidget {
const CotisationsPageWrapper({super.key});
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<ContributionsBloc>(
create: (context) {
final bloc = _getIt<ContributionsBloc>();
bloc.add(const LoadContributions());
return bloc;
},
),
BlocProvider<MembresBloc>(
create: (context) => _getIt<MembresBloc>(),
),
],
child: const ContributionsPage(),
);
}
}
/// Alias pour la route /finances et références anglaises
class ContributionsPageWrapper extends StatelessWidget {
const ContributionsPageWrapper({super.key});
@override
Widget build(BuildContext context) => const CotisationsPageWrapper();
}

View File

@@ -0,0 +1,564 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/utils/logger.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/design_system/tokens/unionflow_colors.dart';
import '../../../../shared/widgets/loading_widget.dart';
import '../../../../shared/widgets/error_widget.dart';
import '../../bloc/contributions_bloc.dart';
import '../../bloc/contributions_event.dart';
import '../../bloc/contributions_state.dart';
import '../../data/models/contribution_model.dart';
/// Page dédiée « Mes statistiques cotisations » : KPIs, graphiques et synthèse.
/// Données réelles via GET /api/cotisations/mes-cotisations/synthese + liste des cotisations.
class MesStatistiquesCotisationsPage extends StatefulWidget {
const MesStatistiquesCotisationsPage({super.key});
@override
State<MesStatistiquesCotisationsPage> createState() => _MesStatistiquesCotisationsPageState();
}
class _MesStatistiquesCotisationsPageState extends State<MesStatistiquesCotisationsPage> {
Map<String, dynamic>? _synthese;
List<ContributionModel>? _cotisations;
String? _error;
final _currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: 'FCFA', decimalDigits: 0);
@override
void initState() {
super.initState();
// Charge uniquement la synthèse ; la liste est conservée dans l'état pour ne pas perdre l'onglet Toutes au retour.
context.read<ContributionsBloc>().add(const LoadContributionsStats());
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.background,
appBar: UFAppBar(
title: 'Mes statistiques cotisations',
backgroundColor: ColorTokens.surface,
foregroundColor: ColorTokens.onSurface,
),
body: BlocListener<ContributionsBloc, ContributionsState>(
listener: (context, state) {
if (state is ContributionsStatsLoaded) {
setState(() {
_synthese = state.stats;
_cotisations = state.contributions;
_error = null;
});
}
if (state is ContributionsLoaded) {
setState(() {
_cotisations = state.contributions;
_error = null;
});
}
if (state is ContributionsError) {
setState(() => _error = state.message);
}
},
child: RefreshIndicator(
onRefresh: () async {
context.read<ContributionsBloc>().add(const LoadContributionsStats());
},
child: _buildBody(),
),
),
);
}
Widget _buildBody() {
if (_error != null) {
return Center(
child: AppErrorWidget(
message: _error!,
onRetry: () {
context.read<ContributionsBloc>().add(const LoadContributionsStats());
context.read<ContributionsBloc>().add(const LoadContributions());
},
),
);
}
if (_synthese == null && _cotisations == null) {
return const Center(child: AppLoadingWidget());
}
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
const SizedBox(height: 24),
_buildKpiCards(),
const SizedBox(height: 20),
_buildTauxSection(),
const SizedBox(height: 20),
if (_cotisations != null && _cotisations!.isNotEmpty) _buildRepartitionChart(),
if (_cotisations != null && _cotisations!.isNotEmpty) const SizedBox(height: 20),
if (_cotisations != null && _cotisations!.isNotEmpty) _buildEvolutionSection(),
const SizedBox(height: 20),
_buildProchainesEcheances(),
const SizedBox(height: 32),
],
),
);
}
Widget _buildHeader() {
final annee = _synthese?['anneeEnCours'] is int
? _synthese!['anneeEnCours'] as int
: DateTime.now().year;
return Column(
children: [
Text(
'Synthèse $annee',
style: AppTypography.headerSmall.copyWith(fontSize: 20),
),
const SizedBox(height: 4),
Text(
'Votre situation cotisations',
style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant),
),
],
);
}
Widget _buildKpiCards() {
final montantDu = _toDouble(_synthese?['montantDu']);
final totalPayeAnnee = _toDouble(_synthese?['totalPayeAnnee']);
final enAttente = _synthese?['cotisationsEnAttente'] is int
? _synthese!['cotisationsEnAttente'] as int
: ((_synthese?['cotisationsEnAttente'] as num?)?.toInt() ?? 0);
final prochaineStr = _synthese?['prochaineEcheance']?.toString();
return Column(
children: [
Row(
children: [
Expanded(
child: _kpiCard(
'Montant dû',
_currencyFormat.format(montantDu),
icon: Icons.pending_actions_outlined,
color: montantDu > 0 ? UnionFlowColors.terracotta : UnionFlowColors.success,
),
),
const SizedBox(width: 12),
Expanded(
child: _kpiCard(
'Payé cette année',
_currencyFormat.format(totalPayeAnnee),
icon: Icons.check_circle_outline,
color: UnionFlowColors.unionGreen,
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _kpiCard(
'En attente',
'$enAttente',
icon: Icons.schedule,
color: enAttente > 0 ? UnionFlowColors.gold : UnionFlowColors.success,
),
),
const SizedBox(width: 12),
Expanded(
child: _kpiCard(
'Prochaine échéance',
prochaineStr != null && prochaineStr.isNotEmpty && prochaineStr != 'null'
? _formatDate(prochaineStr)
: '',
icon: Icons.event,
color: UnionFlowColors.indigo,
),
),
],
),
],
);
}
Widget _kpiCard(String label, String value, {required IconData icon, required Color color}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Text(
value,
style: AppTypography.headerSmall.copyWith(color: color, fontSize: 15),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildTauxSection() {
final montantDu = _toDouble(_synthese?['montantDu']);
final totalPayeAnnee = _toDouble(_synthese?['totalPayeAnnee']);
final total = montantDu + totalPayeAnnee;
final taux = total > 0 ? (totalPayeAnnee / total * 100) : 0.0;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Taux de paiement',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: (taux / 100).clamp(0.0, 1.0),
minHeight: 12,
backgroundColor: ColorTokens.onSurfaceVariant.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(
taux >= 75 ? UnionFlowColors.success : (taux >= 50 ? UnionFlowColors.gold : UnionFlowColors.terracotta),
),
),
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('0 %', style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(
'${taux.toStringAsFixed(0)} %',
style: AppTypography.headerSmall.copyWith(color: UnionFlowColors.unionGreen, fontWeight: FontWeight.w700),
),
Text('100 %', style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
],
),
],
),
);
}
Widget _buildRepartitionChart() {
final paye = _cotisations!
.where((c) => c.statut == ContributionStatus.payee)
.fold<double>(0, (s, c) => s + (c.montantPaye ?? c.montant));
final du = _cotisations!
.where((c) => c.statut != ContributionStatus.payee && c.statut != ContributionStatus.annulee)
.fold<double>(0, (s, c) => s + c.montant);
if (paye + du <= 0) return const SizedBox.shrink();
final sections = <PieChartSectionData>[];
if (paye > 0) {
sections.add(PieChartSectionData(
color: UnionFlowColors.unionGreen,
value: paye,
title: 'Payé',
radius: 60,
titleStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white),
));
}
if (du > 0) {
sections.add(PieChartSectionData(
color: UnionFlowColors.terracotta,
value: du,
title: '',
radius: 60,
titleStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: Colors.white),
));
}
if (sections.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Répartition Payé / Dû',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SizedBox(
height: 200,
child: PieChart(
PieChartData(
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: sections,
),
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_legendItem(UnionFlowColors.unionGreen, 'Payé', _currencyFormat.format(paye)),
_legendItem(UnionFlowColors.terracotta, '', _currencyFormat.format(du)),
],
),
],
),
);
}
Widget _legendItem(Color color, String label, String value) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 12, height: 12, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppTypography.bodyTextSmall.copyWith(color: ColorTokens.onSurfaceVariant)),
Text(value, style: AppTypography.bodyTextSmall.copyWith(fontWeight: FontWeight.w600)),
],
),
],
);
}
Widget _buildEvolutionSection() {
final payees = _cotisations!.where((c) => c.statut == ContributionStatus.payee).toList();
if (payees.isEmpty) return const SizedBox.shrink();
final byMonth = <int, double>{};
for (final c in payees) {
final d = c.datePaiement ?? c.dateEcheance;
final month = d.month + d.year * 12;
byMonth[month] = (byMonth[month] ?? 0) + (c.montantPaye ?? c.montant);
}
final entries = byMonth.entries.toList()..sort((a, b) => a.key.compareTo(b.key));
if (entries.isEmpty) return const SizedBox.shrink();
final dataMaxY = entries.map((e) => e.value).reduce((a, b) => a > b ? a : b);
final yMax = (dataMaxY * 1.1 + 1).clamp(1.0, double.infinity);
final yInterval = yMax / 4;
final spots = entries.asMap().entries.map((e) => FlSpot(e.key.toDouble(), e.value.value)).toList();
final n = spots.length;
final xInterval = n <= 5 ? 1.0 : (n - 1) / 4;
final xIntervalSafe = xInterval < 1 ? 1.0 : xInterval;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Paiements par période',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: LineChart(
LineChartData(
gridData: FlGridData(show: true, drawVerticalLine: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 44,
interval: yInterval,
getTitlesWidget: (v, _) => Text(_formatAxisAmount(v), style: const TextStyle(fontSize: 10)),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
interval: xIntervalSafe,
getTitlesWidget: (v, _) {
final i = v.round();
if (i >= 0 && i < entries.length) {
final k = entries[i].key;
final m = k % 12 == 0 ? 12 : k % 12;
final y = k % 12 == 0 ? (k ~/ 12) - 1 : (k ~/ 12);
return Text(_formatAxisPeriod(m, y), style: const TextStyle(fontSize: 10));
}
return const SizedBox.shrink();
},
),
),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
borderData: FlBorderData(show: true, border: Border(bottom: BorderSide(color: ColorTokens.outline), left: BorderSide(color: ColorTokens.outline))),
minX: 0,
maxX: (spots.length - 1).toDouble(),
minY: 0,
maxY: yMax,
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: UnionFlowColors.unionGreen,
barWidth: 2,
isStrokeCapRound: true,
dotData: const FlDotData(show: true),
belowBarData: BarAreaData(show: true, color: UnionFlowColors.unionGreen.withOpacity(0.15)),
),
],
),
),
),
],
),
);
}
Widget _buildProchainesEcheances() {
final list = _cotisations ?? [];
final aRegler = list.where((c) => c.statut != ContributionStatus.payee && c.statut != ContributionStatus.annulee).toList();
aRegler.sort((a, b) => a.dateEcheance.compareTo(b.dateEcheance));
final top = aRegler.take(5).toList();
if (top.isEmpty) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: ColorTokens.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: ColorTokens.outline),
boxShadow: [BoxShadow(color: ColorTokens.shadow.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Prochaines échéances à régler',
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w700,
color: ColorTokens.onSurfaceVariant,
),
),
const SizedBox(height: 12),
...top.map((c) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDate(c.dateEcheance.toIso8601String()),
style: AppTypography.bodyTextSmall,
),
Text(
_currencyFormat.format(c.montant),
style: AppTypography.bodyTextSmall.copyWith(
fontWeight: FontWeight.w600,
color: UnionFlowColors.terracotta,
),
),
],
),
)),
],
),
);
}
double _toDouble(dynamic v) {
if (v == null) return 0;
if (v is num) return v.toDouble();
if (v is String) return double.tryParse(v) ?? 0;
return 0;
}
String _formatDate(String isoOrRaw) {
try {
final dt = DateTime.tryParse(isoOrRaw);
if (dt != null) {
const months = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sep', 'Oct', 'Nov', 'Déc'];
return '${dt.day} ${months[dt.month - 1]} ${dt.year}';
}
} catch (e, st) {
AppLogger.warning('MesStatistiquesCotisations: format date invalide', tag: isoOrRaw);
}
return isoOrRaw;
}
String _formatShortAmount(double v) {
if (v >= 1000) return '${(v / 1000).toStringAsFixed(0)}k';
return v.toStringAsFixed(0);
}
/// Format court pour laxe Y : 0, 25 k, 50 k, 1 M — peu de libellés, lisibles.
String _formatAxisAmount(double v) {
if (v >= 1000000) return '${(v / 1000000).toStringAsFixed(1)} M';
if (v >= 1000) return '${(v / 1000).toStringAsFixed(0)} k';
if (v < 1) return '0';
return v.toStringAsFixed(0);
}
String _monthShort(int m) {
const t = ['Jan', 'Fév', 'Mar', 'Avr', 'Mai', 'Juin', 'Juil', 'Août', 'Sep', 'Oct', 'Nov', 'Déc'];
return m >= 1 && m <= 12 ? t[m - 1] : '';
}
/// Libellé court pour laxe X : "Jan 25", "Avr 25" — peu de caractères.
String _formatAxisPeriod(int month, int year) {
final shortYear = year % 100;
return '${_monthShort(month)} $shortYear';
}
}