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 createState() => _MesStatistiquesCotisationsPageState(); } class _MesStatistiquesCotisationsPageState extends State { Map? _synthese; List? _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().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( 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().add(const LoadContributionsStats()); }, child: _buildBody(), ), ), ); } Widget _buildBody() { if (_error != null) { return Center( child: AppErrorWidget( message: _error!, onRetry: () { context.read().add(const LoadContributionsStats()); context.read().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( 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(0, (s, c) => s + (c.montantPaye ?? c.montant)); final du = _cotisations! .where((c) => c.statut != ContributionStatus.payee && c.statut != ContributionStatus.annulee) .fold(0, (s, c) => s + c.montant); if (paye + du <= 0) return const SizedBox.shrink(); final sections = []; 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: 'Dû', 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, 'Dû', _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 = {}; 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) { 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 l’axe 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 l’axe X : "Jan 25", "Avr 25" — peu de caractères. String _formatAxisPeriod(int month, int year) { final shortYear = year % 100; return '${_monthShort(month)} $shortYear'; } }