Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
@@ -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: '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 = <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 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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user