Initialisation du projet geosector complet (web + flutter)

This commit is contained in:
d6soft
2025-05-01 18:59:27 +02:00
commit b5aafc424b
244 changed files with 37296 additions and 0 deletions

View File

@@ -0,0 +1,476 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/foundation.dart' show listEquals;
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/services/passage_data_service.dart';
/// Widget de graphique d'activité affichant les passages
class ActivityChart extends StatefulWidget {
/// Liste des données de passage par date et type (si fournie directement)
/// Format attendu: [{"date": String, "type_passage": int, "nb": int}, ...]
final List<Map<String, dynamic>>? passageData;
/// Type de période (Jour, Semaine, Mois, Année)
final String periodType;
/// Hauteur du graphique
final double height;
/// Nombre de jours à afficher (par défaut 15)
final int daysToShow;
/// ID de l'utilisateur pour filtrer les passages (null = tous les utilisateurs)
final int? userId;
/// Types de passages à exclure (par défaut [2] = "À finaliser")
final List<int> excludePassageTypes;
/// Indique si les données doivent être chargées depuis la Hive box
final bool loadFromHive;
/// Callback appelé lorsque la période change
final Function(int days)? onPeriodChanged;
/// Titre du graphique
final String title;
/// Afficher les étiquettes de valeur
final bool showDataLabels;
/// Largeur des colonnes (en pourcentage)
final double columnWidth;
/// Espacement entre les colonnes (en pourcentage)
final double columnSpacing;
/// Si vrai, n'applique aucun filtrage par utilisateur (affiche tous les passages)
final bool showAllPassages;
const ActivityChart({
super.key,
this.passageData,
this.periodType = 'Jour',
this.height = 350,
this.daysToShow = 15,
this.userId,
this.excludePassageTypes = const [2],
this.loadFromHive = false,
this.onPeriodChanged,
this.title = 'Dernière activité enregistrée sur 15 jours',
this.showDataLabels = true,
this.columnWidth = 0.8,
this.columnSpacing = 0.2,
this.showAllPassages = false,
}) : assert(loadFromHive || passageData != null,
'Soit loadFromHive doit être true, soit passageData doit être fourni');
@override
State<ActivityChart> createState() => _ActivityChartState();
}
/// Classe pour stocker les données d'activité par date
class ActivityData {
final DateTime date;
final String dateStr;
final Map<int, int> passagesByType;
final int totalPassages;
ActivityData({
required this.date,
required this.dateStr,
required this.passagesByType,
}) : totalPassages =
passagesByType.values.fold(0, (sum, count) => sum + count);
}
class _ActivityChartState extends State<ActivityChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
// Données pour les graphiques
List<Map<String, dynamic>> _passageData = [];
List<ActivityData> _chartData = [];
bool _isLoading = true;
bool _hasData = false;
bool _dataLoaded = false;
// Période sélectionnée en jours
int _selectedDays = 15;
// Contrôleur de zoom pour le graphique
late ZoomPanBehavior _zoomPanBehavior;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
// Initialiser la période sélectionnée avec la valeur par défaut du widget
_selectedDays = widget.daysToShow;
// Initialiser le contrôleur de zoom
_zoomPanBehavior = ZoomPanBehavior(
enablePinching: true,
enableDoubleTapZooming: true,
enablePanning: true,
zoomMode: ZoomMode.x,
);
_loadData();
_animationController.forward();
}
/// Trouve la date du passage le plus récent
DateTime _getMostRecentDate() {
final allDates = [
..._passageData.map((data) => DateTime.parse(data['date'] as String)),
];
if (allDates.isEmpty) {
return DateTime.now();
}
return allDates.reduce((a, b) => a.isAfter(b) ? a : b);
}
void _loadData() {
// Si les données ont déjà été chargées, ne pas les recharger
if (_dataLoaded) return;
// Marquer comme chargé immédiatement pour éviter les appels multiples
_dataLoaded = true;
setState(() {
_isLoading = true;
});
if (widget.loadFromHive) {
// Charger les données depuis Hive
WidgetsBinding.instance.addPostFrameCallback((_) {
// Éviter de recharger si le widget a été démonté entre-temps
if (!mounted) return;
try {
// Utiliser les instances globales définies dans app.dart
// Créer une instance du service de données
final passageDataService = PassageDataService(
passageRepository: passageRepository,
userRepository: userRepository,
);
// Utiliser le service pour charger les données
_passageData = passageDataService.loadPassageData(
daysToShow: _selectedDays,
excludePassageTypes: widget.excludePassageTypes,
userId: widget.userId,
showAllPassages: widget.showAllPassages,
);
_prepareChartData();
// Mettre à jour l'état une seule fois après avoir préparé les données
if (mounted) {
setState(() {
_isLoading = false;
_hasData = _chartData.isNotEmpty;
});
}
} catch (e) {
// En cas d'erreur, réinitialiser l'état pour permettre une future tentative
if (mounted) {
setState(() {
_isLoading = false;
_hasData = false;
});
}
}
});
} else {
// Utiliser les données fournies directement
_passageData = widget.passageData ?? [];
_prepareChartData();
setState(() {
_isLoading = false;
_hasData = _chartData.isNotEmpty;
});
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
void didUpdateWidget(ActivityChart oldWidget) {
super.didUpdateWidget(oldWidget);
// Vérifier si les propriétés importantes ont changé
final bool periodChanged = oldWidget.periodType != widget.periodType ||
oldWidget.daysToShow != widget.daysToShow;
final bool dataSourceChanged = widget.loadFromHive
? false
: oldWidget.passageData != widget.passageData;
final bool filteringChanged = oldWidget.userId != widget.userId ||
!listEquals(
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages;
// Si des paramètres importants ont changé, recharger les données
if (periodChanged || dataSourceChanged || filteringChanged) {
_selectedDays = widget.daysToShow;
_dataLoaded = false; // Réinitialiser l'état pour forcer le rechargement
_loadData();
}
}
// La méthode _loadPassageDataFromHive a été intégrée directement dans _loadData
// pour éviter les appels multiples et les problèmes de cycle de vie
/// Prépare les données pour le graphique
void _prepareChartData() {
try {
// Vérifier que les données sont disponibles
if (_passageData.isEmpty) {
_chartData = [];
return;
}
// Obtenir toutes les dates uniques
final Set<String> uniqueDatesSet = {};
for (final data in _passageData) {
if (data.containsKey('date') && data['date'] != null) {
uniqueDatesSet.add(data['date'] as String);
}
}
// Trier les dates
final List<String> uniqueDates = uniqueDatesSet.toList();
uniqueDates.sort();
// Créer les données pour chaque date
_chartData = [];
for (final dateStr in uniqueDates) {
final passagesByType = <int, int>{};
// Initialiser tous les types de passage possibles
for (final typeId in AppKeys.typesPassages.keys) {
if (!widget.excludePassageTypes.contains(typeId)) {
passagesByType[typeId] = 0;
}
}
// Remplir les données de passage
for (final data in _passageData) {
if (data.containsKey('date') &&
data['date'] == dateStr &&
data.containsKey('type_passage') &&
data.containsKey('nb')) {
final typeId = data['type_passage'] as int;
if (!widget.excludePassageTypes.contains(typeId)) {
passagesByType[typeId] = data['nb'] as int;
}
}
}
try {
// Convertir la date en objet DateTime
final dateParts = dateStr.split('-');
if (dateParts.length == 3) {
final year = int.parse(dateParts[0]);
final month = int.parse(dateParts[1]);
final day = int.parse(dateParts[2]);
final date = DateTime(year, month, day);
// Ajouter les données à la liste
_chartData.add(ActivityData(
date: date,
dateStr: dateStr,
passagesByType: passagesByType,
));
}
} catch (e) {
// Silencieux lors des erreurs de conversion de date pour éviter les logs excessifs
}
}
// Trier les données par date
_chartData.sort((a, b) => a.date.compareTo(b.date));
} catch (e) {
// Erreur silencieuse pour éviter les logs excessifs
_chartData = [];
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return SizedBox(
height: widget.height,
child: const Center(
child: CircularProgressIndicator(),
),
);
}
if (!_hasData || _chartData.isEmpty) {
return SizedBox(
height: widget.height,
child: const Center(
child: Text('Aucune donnée disponible'),
),
);
}
// Préparer les données si nécessaire
if (_chartData.isEmpty) {
_prepareChartData();
}
return SizedBox(
height: widget.height,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre (conservé)
if (widget.title.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: 16.0, right: 16.0, top: 16.0, bottom: 8.0),
child: Text(
widget.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Graphique (occupe maintenant plus d'espace)
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
child: SfCartesianChart(
plotAreaBorderWidth: 0,
legend: Legend(
isVisible: true,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
),
primaryXAxis: DateTimeAxis(
dateFormat: DateFormat('dd/MM'),
intervalType: DateTimeIntervalType.days,
majorGridLines: const MajorGridLines(width: 0),
labelStyle: const TextStyle(fontSize: 10),
// Définir explicitement la plage de dates à afficher
minimum: _chartData.isNotEmpty ? _chartData.first.date : null,
maximum: _chartData.isNotEmpty ? _chartData.last.date : null,
// Assurer que tous les jours sont affichés
interval: 1,
axisLabelFormatter: (AxisLabelRenderDetails details) {
return ChartAxisLabel(details.text, details.textStyle);
},
),
primaryYAxis: NumericAxis(
labelStyle: const TextStyle(fontSize: 10),
axisLine: const AxisLine(width: 0),
majorTickLines: const MajorTickLines(size: 0),
majorGridLines: const MajorGridLines(
width: 0.5,
color: Colors.grey,
dashArray: <double>[5, 5], // Motif de pointillés
),
title: const AxisTitle(
text: 'Passages',
textStyle: TextStyle(fontSize: 10, color: Colors.grey),
),
),
series: _buildSeries(),
tooltipBehavior: TooltipBehavior(enable: true),
zoomPanBehavior: _zoomPanBehavior,
),
),
),
],
),
);
}
/// Construit les séries de données pour le graphique
List<CartesianSeries<ActivityData, DateTime>> _buildSeries() {
final List<CartesianSeries<ActivityData, DateTime>> series = [];
// Vérifier que les données sont disponibles
if (_chartData.isEmpty) {
return series;
}
// Obtenir tous les types de passage (sauf ceux exclus)
final passageTypes = AppKeys.typesPassages.keys
.where((typeId) => !widget.excludePassageTypes.contains(typeId))
.toList();
// Créer les séries pour les passages (colonnes empilées)
for (final typeId in passageTypes) {
// Vérifier que le type existe dans AppKeys
if (!AppKeys.typesPassages.containsKey(typeId)) {
continue;
}
final typeInfo = AppKeys.typesPassages[typeId]!;
// Vérifier que les clés nécessaires existent
if (!typeInfo.containsKey('couleur1') || !typeInfo.containsKey('titre')) {
continue;
}
final typeColor = Color(typeInfo['couleur1'] as int);
final typeName = typeInfo['titre'] as String;
// Calculer le total pour ce type pour déterminer s'il faut l'afficher
int totalForType = 0;
for (final data in _chartData) {
totalForType += data.passagesByType[typeId] ?? 0;
}
// On peut décider de ne pas afficher les types sans données
final addZeroValueTypes = false;
// Ajouter la série pour ce type
if (totalForType > 0 || addZeroValueTypes) {
series.add(
StackedColumnSeries<ActivityData, DateTime>(
name: typeName,
dataSource: _chartData,
xValueMapper: (ActivityData data, _) => data.date,
yValueMapper: (ActivityData data, _) {
final value = data.passagesByType.containsKey(typeId)
? data.passagesByType[typeId]!
: 0;
return value;
},
color: typeColor,
width: widget.columnWidth,
spacing: widget.columnSpacing,
dataLabelSettings: DataLabelSettings(
isVisible: widget.showDataLabels,
labelAlignment: ChartDataLabelAlignment.middle,
textStyle: const TextStyle(fontSize: 8, color: Colors.white),
),
markerSettings: const MarkerSettings(isVisible: false),
animationDuration: 1500,
),
);
}
}
return series;
}
}

View File

@@ -0,0 +1,11 @@
/// Bibliothèque de widgets de graphiques pour l'application GeoSector
library geosector_charts;
export 'payment_data.dart';
export 'payment_pie_chart.dart';
export 'payment_utils.dart';
export 'passage_data.dart';
export 'passage_utils.dart';
export 'passage_pie_chart.dart';
export 'activity_chart.dart';
export 'combined_chart.dart';

View File

@@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_data.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_utils.dart';
import 'package:intl/intl.dart';
/// Widget de graphique combiné pour afficher les passages et règlements
class CombinedChart extends StatelessWidget {
/// Liste des données de passage par type
final List<Map<String, dynamic>> passageData;
/// Liste des données de règlement par type
final List<Map<String, dynamic>> paymentData;
/// Type de période (Jour, Semaine, Mois, Année)
final String periodType;
/// Hauteur du graphique
final double height;
/// Largeur des barres
final double barWidth;
/// Rayon des points sur les lignes
final double dotRadius;
/// Épaisseur des lignes
final double lineWidth;
/// Montant maximum pour l'axe Y des règlements
final double? maxYAmount;
/// Nombre maximum pour l'axe Y des passages
final int? maxYCount;
const CombinedChart({
super.key,
required this.passageData,
required this.paymentData,
this.periodType = 'Jour',
this.height = 300,
this.barWidth = 16,
this.dotRadius = 4,
this.lineWidth = 3,
this.maxYAmount,
this.maxYCount,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Convertir les données brutes en modèles structurés
final passagesByType = PassageUtils.getPassageDataByType(passageData);
final paymentsByType = PassageUtils.getPaymentDataByType(paymentData);
// Extraire les dates uniques pour l'axe X
final List<DateTime> allDates = [];
for (final data in passageData) {
final DateTime date = data['date'] is DateTime
? data['date']
: DateTime.parse(data['date']);
if (!allDates.any((d) =>
d.year == date.year && d.month == date.month && d.day == date.day)) {
allDates.add(date);
}
}
// Trier les dates
allDates.sort((a, b) => a.compareTo(b));
// Calculer le maximum pour les axes Y
double maxAmount = 0;
for (final typeData in paymentsByType) {
for (final data in typeData) {
if (data.amount > maxAmount) {
maxAmount = data.amount;
}
}
}
int maxCount = 0;
for (final typeData in passagesByType) {
for (final data in typeData) {
if (data.count > maxCount) {
maxCount = data.count;
}
}
}
// Utiliser les maximums fournis ou calculés
final effectiveMaxYAmount = maxYAmount ?? (maxAmount * 1.2).ceilToDouble();
final effectiveMaxYCount = maxYCount ?? (maxCount * 1.2).ceil();
return SizedBox(
height: height,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: effectiveMaxYCount.toDouble(),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipPadding: const EdgeInsets.all(8),
tooltipMargin: 8,
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final date = allDates[group.x.toInt()];
final formattedDate = DateFormat('dd/MM').format(date);
// Calculer le total des passages pour cette date
int totalPassages = 0;
for (final typeData in passagesByType) {
for (final data in typeData) {
if (data.date.year == date.year &&
data.date.month == date.month &&
data.date.day == date.day) {
totalPassages += data.count;
}
}
}
return BarTooltipItem(
'$formattedDate: $totalPassages passages',
TextStyle(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
);
},
),
),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (value, meta) {
if (value >= 0 && value < allDates.length) {
final date = allDates[value.toInt()];
final formattedDate =
PassageUtils.formatDateForChart(date, periodType);
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
formattedDate,
style: TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 10,
),
),
);
}
return const SizedBox();
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
value.toInt().toString(),
style: TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 10,
),
),
);
},
reservedSize: 30,
),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
// Convertir la valeur de l'axe Y des passages à l'échelle des montants
final amountValue =
(value / effectiveMaxYCount) * effectiveMaxYAmount;
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
'${amountValue.toInt()}',
style: TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 10,
),
),
);
},
reservedSize: 40,
),
),
topTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: FlGridData(
show: true,
getDrawingHorizontalLine: (value) {
return FlLine(
color: theme.dividerColor.withOpacity(0.2),
strokeWidth: 1,
);
},
drawVerticalLine: false,
),
borderData: FlBorderData(show: false),
barGroups: _createBarGroups(allDates, passagesByType),
extraLinesData: ExtraLinesData(
horizontalLines: [],
verticalLines: [],
extraLinesOnTop: true,
),
),
swapAnimationDuration: const Duration(milliseconds: 250),
),
);
}
/// Créer les groupes de barres pour les passages
List<BarChartGroupData> _createBarGroups(
List<DateTime> allDates,
List<List<PassageData>> passagesByType,
) {
final List<BarChartGroupData> groups = [];
for (int i = 0; i < allDates.length; i++) {
final date = allDates[i];
// Calculer le total des passages pour cette date
int totalPassages = 0;
for (final typeData in passagesByType) {
for (final data in typeData) {
if (data.date.year == date.year &&
data.date.month == date.month &&
data.date.day == date.day) {
totalPassages += data.count;
}
}
}
// Créer un groupe de barres pour cette date
groups.add(
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: totalPassages.toDouble(),
color: Colors.blue.shade700,
width: barWidth,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
),
],
),
);
}
return groups;
}
}
/// Widget de légende pour le graphique combiné
class CombinedChartLegend extends StatelessWidget {
const CombinedChartLegend({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 16,
runSpacing: 8,
children: [
_buildLegendItem('Passages', Colors.blue.shade700, isBar: true),
_buildLegendItem('Espèces', const Color(0xFF4CAF50)),
_buildLegendItem('Chèques', const Color(0xFF2196F3)),
_buildLegendItem('CB', const Color(0xFFF44336)),
],
);
}
/// Créer un élément de légende
Widget _buildLegendItem(String label, Color color, {bool isBar = false}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
shape: isBar ? BoxShape.rectangle : BoxShape.circle,
borderRadius: isBar ? BorderRadius.circular(3) : null,
),
),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(fontSize: 12),
),
],
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
/// Modèle de données pour représenter un passage avec sa date, son type et son nombre
class PassageData {
/// Date du passage
final DateTime date;
/// Identifiant du type de passage (1: Effectué, 2: À finaliser, 3: Refusé, etc.)
final int typeId;
/// Nombre de passages
final int count;
/// Couleur associée au type de passage
final Color color;
/// Icône associée au type de passage (chemin vers le fichier d'icône)
final String iconPath;
/// Titre du type de passage
final String title;
const PassageData({
required this.date,
required this.typeId,
required this.count,
required this.color,
required this.iconPath,
required this.title,
});
/// Crée une instance de PassageData à partir d'une date au format ISO 8601
factory PassageData.fromIsoDate({
required String isoDate,
required int typeId,
required int count,
required Color color,
required String iconPath,
required String title,
}) {
return PassageData(
date: DateTime.parse(isoDate),
typeId: typeId,
count: count,
color: color,
iconPath: iconPath,
title: title,
);
}
}
/// Modèle de données pour représenter un règlement avec sa date, son type et son montant
class PaymentAmountData {
/// Date du règlement
final DateTime date;
/// Identifiant du type de règlement (1: Espèces, 2: Chèques, 3: CB)
final int typeId;
/// Montant du règlement
final double amount;
/// Couleur associée au type de règlement
final Color color;
/// Icône associée au type de règlement (chemin vers le fichier d'icône ou IconData)
final dynamic iconData;
/// Titre du type de règlement
final String title;
/// Crée une instance de PaymentAmountData à partir d'une date au format ISO 8601
factory PaymentAmountData.fromIsoDate({
required String isoDate,
required int typeId,
required double amount,
required Color color,
required dynamic iconData,
required String title,
}) {
return PaymentAmountData(
date: DateTime.parse(isoDate),
typeId: typeId,
amount: amount,
color: color,
iconData: iconData,
title: title,
);
}
const PaymentAmountData({
required this.date,
required this.typeId,
required this.amount,
required this.color,
required this.iconData,
required this.title,
});
}

View File

@@ -0,0 +1,459 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/foundation.dart' show listEquals, mapEquals;
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/services/passage_data_service.dart';
/// Modèle de données pour le graphique en camembert des passages
class PassageChartData {
/// Identifiant du type de passage
final int typeId;
/// Nombre de passages de ce type
final int count;
/// Titre du type de passage
final String title;
/// Couleur associée au type de passage
final Color color;
/// Icône associée au type de passage
final IconData icon;
PassageChartData({
required this.typeId,
required this.count,
required this.title,
required this.color,
required this.icon,
});
}
/// Widget de graphique en camembert pour représenter la répartition des passages par type
class PassagePieChart extends StatefulWidget {
/// Liste des données de passages par type sous forme de Map avec typeId et count
/// Si loadFromHive est true, ce paramètre est ignoré
final Map<int, int> passagesByType;
/// Taille du graphique
final double size;
/// Taille des étiquettes
final double labelSize;
/// Afficher les pourcentages
final bool showPercentage;
/// Afficher les icônes
final bool showIcons;
/// Afficher la légende
final bool showLegend;
/// Format donut (anneau)
final bool isDonut;
/// Rayon central pour le format donut (en pourcentage)
final String innerRadius;
/// Charger les données depuis Hive
final bool loadFromHive;
/// ID de l'utilisateur pour filtrer les passages (utilisé seulement si loadFromHive est true)
final int? userId;
/// Types de passages à exclure (utilisé seulement si loadFromHive est true)
final List<int> excludePassageTypes;
/// Afficher tous les passages sans filtrer par utilisateur (utilisé seulement si loadFromHive est true)
final bool showAllPassages;
const PassagePieChart({
super.key,
this.passagesByType = const {},
this.size = 300,
this.labelSize = 12,
this.showPercentage = true,
this.showIcons = true,
this.showLegend = true,
this.isDonut = false,
this.innerRadius = '40%',
this.loadFromHive = false,
this.userId,
this.excludePassageTypes = const [2],
this.showAllPassages = false,
});
@override
State<PassagePieChart> createState() => _PassagePieChartState();
}
class _PassagePieChartState extends State<PassagePieChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
/// Données de passages par type
late Map<int, int> _passagesByType;
/// Variables pour la mise en cache et l'optimisation
bool _dataLoaded = false;
bool _isLoading = false;
List<PassageChartData>? _cachedChartData;
List<CircularChartAnnotation>? _cachedAnnotations;
@override
void initState() {
super.initState();
_passagesByType = widget.passagesByType;
// Initialiser le contrôleur d'animation
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
_animationController.forward();
// Si nous n'utilisons pas Hive, préparer les données immédiatement
if (!widget.loadFromHive) {
_prepareChartData();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (widget.loadFromHive && !_dataLoaded && !_isLoading) {
_isLoading = true; // Prévenir les chargements multiples
_loadPassageDataFromHive(context);
}
}
@override
void didUpdateWidget(PassagePieChart oldWidget) {
super.didUpdateWidget(oldWidget);
// Vérifier si les propriétés importantes ont changé
final bool dataSourceChanged = widget.loadFromHive
? false
: !mapEquals(oldWidget.passagesByType, widget.passagesByType);
final bool filteringChanged = oldWidget.userId != widget.userId ||
!listEquals(
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages;
final bool visualChanged = oldWidget.size != widget.size ||
oldWidget.labelSize != widget.labelSize ||
oldWidget.showPercentage != widget.showPercentage ||
oldWidget.showIcons != widget.showIcons ||
oldWidget.showLegend != widget.showLegend ||
oldWidget.isDonut != widget.isDonut ||
oldWidget.innerRadius != widget.innerRadius;
// Si les paramètres de filtrage ou de source de données ont changé, recharger les données
if (dataSourceChanged || filteringChanged) {
_cachedChartData = null;
_cachedAnnotations = null;
// Relancer l'animation si les données ont changé
_animationController.reset();
_animationController.forward();
if (!widget.loadFromHive) {
_passagesByType = widget.passagesByType;
_prepareChartData();
} else if (!_isLoading) {
_dataLoaded = false;
_isLoading = true;
_loadPassageDataFromHive(context);
}
}
// Si seuls les paramètres visuels ont changé, recalculer les annotations sans recharger les données
else if (visualChanged) {
_cachedAnnotations = null;
}
}
/// Charge les données de passage depuis Hive en utilisant le service PassageDataService
void _loadPassageDataFromHive(BuildContext context) {
// Éviter les appels multiples
if (_isLoading && _dataLoaded) return;
// Charger les données dans un addPostFrameCallback pour éviter les problèmes de cycle de vie
WidgetsBinding.instance.addPostFrameCallback((_) {
// Vérifier si le widget est toujours monté
if (!mounted) return;
try {
// Utiliser les instances globales définies dans app.dart
// Créer une instance du service de données
final passageDataService = PassageDataService(
passageRepository: passageRepository,
userRepository: userRepository,
);
// Utiliser le service pour charger les données
final data = passageDataService.loadPassageDataForPieChart(
excludePassageTypes: widget.excludePassageTypes,
userId: widget.userId,
showAllPassages: widget.showAllPassages,
);
// Mettre à jour les données et les états
if (mounted) {
setState(() {
_passagesByType = data;
_dataLoaded = true;
_isLoading = false;
_cachedChartData =
null; // Forcer la régénération des données du graphique
_cachedAnnotations = null;
});
// Préparer les données du graphique
_prepareChartData();
}
} catch (e) {
// Gérer les erreurs et réinitialiser l'état pour permettre une future tentative
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
});
}
/// Prépare les données pour le graphique en camembert avec mise en cache
List<PassageChartData> _prepareChartData() {
// Utiliser les données en cache si disponibles
if (_cachedChartData != null) {
return _cachedChartData!;
}
final List<PassageChartData> chartData = [];
// Créer les données du graphique
_passagesByType.forEach((typeId, count) {
// Vérifier que le type existe et que le compteur est positif
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
final typeInfo = AppKeys.typesPassages[typeId]!;
chartData.add(PassageChartData(
typeId: typeId,
count: count,
title: typeInfo['titre'] as String,
color: Color(typeInfo['couleur2'] as int),
icon: typeInfo['icon_data'] as IconData,
));
}
});
// Mettre en cache les données générées
_cachedChartData = chartData;
return chartData;
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Si les données doivent être chargées depuis Hive mais ne sont pas encore prêtes
if (widget.loadFromHive && !_dataLoaded) {
return SizedBox(
width: widget.size,
height: widget.size,
child: const Center(
child: CircularProgressIndicator(),
),
);
}
final chartData = _prepareChartData();
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
return SizedBox(
width: widget.size,
height: widget.size,
child: const Center(
child: Text('Aucune donnée disponible'),
),
);
}
// Créer des animations pour différents aspects du graphique
final progressAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
);
final explodeAnimation = CurvedAnimation(
parent: _animationController,
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
);
final opacityAnimation = CurvedAnimation(
parent: _animationController,
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
);
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return SizedBox(
width: widget.size,
height: widget.size,
child: SfCircularChart(
margin: EdgeInsets.zero,
legend: Legend(
isVisible: widget.showLegend,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
textStyle: TextStyle(fontSize: widget.labelSize),
),
tooltipBehavior: TooltipBehavior(enable: true),
series: <CircularSeries>[
widget.isDonut
? DoughnutSeries<PassageChartData, String>(
dataSource: chartData,
xValueMapper: (PassageChartData data, _) => data.title,
yValueMapper: (PassageChartData data, _) => data.count,
pointColorMapper: (PassageChartData data, _) =>
data.color,
enableTooltip: true,
dataLabelMapper: (PassageChartData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0, (sum, item) => sum + item.count);
final percentage = (data.count / total * 100);
return '${percentage.toStringAsFixed(1)}%';
} else {
return data.title;
}
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
textStyle: TextStyle(fontSize: widget.labelSize),
connectorLineSettings: const ConnectorLineSettings(
type: ConnectorType.curve,
length: '15%',
),
),
innerRadius: widget.innerRadius,
explode: true,
explodeIndex: 0,
explodeOffset:
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
opacity: opacityAnimation.value,
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
)
: PieSeries<PassageChartData, String>(
dataSource: chartData,
xValueMapper: (PassageChartData data, _) => data.title,
yValueMapper: (PassageChartData data, _) => data.count,
pointColorMapper: (PassageChartData data, _) =>
data.color,
enableTooltip: true,
dataLabelMapper: (PassageChartData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0, (sum, item) => sum + item.count);
final percentage = (data.count / total * 100);
return '${percentage.toStringAsFixed(1)}%';
} else {
return data.title;
}
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
textStyle: TextStyle(fontSize: widget.labelSize),
connectorLineSettings: const ConnectorLineSettings(
type: ConnectorType.curve,
length: '15%',
),
),
explode: true,
explodeIndex: 0,
explodeOffset:
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
opacity: opacityAnimation.value,
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
),
],
annotations:
widget.showIcons ? _buildIconAnnotations(chartData) : null,
),
);
},
);
}
/// Crée les annotations d'icônes pour le graphique avec mise en cache
List<CircularChartAnnotation> _buildIconAnnotations(
List<PassageChartData> chartData) {
// Utiliser les annotations en cache si disponibles
if (_cachedAnnotations != null) {
return _cachedAnnotations!;
}
final List<CircularChartAnnotation> annotations = [];
// Calculer le total pour les pourcentages
int total = chartData.fold(0, (sum, item) => sum + item.count);
if (total == 0) return []; // Éviter la division par zéro
// Position angulaire actuelle (en radians)
double currentAngle = 0;
for (int i = 0; i < chartData.length; i++) {
final data = chartData[i];
final percentage = data.count / total;
// Calculer l'angle central de ce segment
final segmentAngle = percentage * 2 * 3.14159;
final midAngle = currentAngle + (segmentAngle / 2);
// Ajouter une annotation pour l'icône
annotations.add(
CircularChartAnnotation(
widget: Icon(
data.icon,
color: Colors.white,
size: 16,
),
radius: '50%',
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
),
);
// Mettre à jour l'angle actuel
currentAngle += segmentAngle;
}
// Mettre en cache les annotations générées
_cachedAnnotations = annotations;
return annotations;
}
}

View File

@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_data.dart';
import 'package:intl/intl.dart';
/// Utilitaires pour les passages et règlements
class PassageUtils {
/// Convertit les données de passage brutes en liste de PassageData
///
/// [passageData] est une liste d'objets contenant date, type_passage et nb
static List<List<PassageData>> getPassageDataByType(
List<Map<String, dynamic>> passageData) {
// Créer un Map pour stocker les données par type de passage
final Map<int, List<PassageData>> passagesByType = {};
// Initialiser les listes pour chaque type de passage
for (final entry in AppKeys.typesPassages.entries) {
passagesByType[entry.key] = [];
}
// Grouper les passages par type
for (final data in passageData) {
final int typeId = data['type_passage'];
final int count = data['nb'];
if (AppKeys.typesPassages.containsKey(typeId)) {
final typeData = AppKeys.typesPassages[typeId]!;
final Color color = Color(typeData['couleur1'] as int);
final String iconPath = typeData['icone'] as String;
final String title = typeData['titre'] as String;
// Utiliser la méthode factory qui gère les dates au format ISO 8601
if (data['date'] is String) {
passagesByType[typeId]!.add(
PassageData.fromIsoDate(
isoDate: data['date'],
typeId: typeId,
count: count,
color: color,
iconPath: iconPath,
title: title,
),
);
} else {
// Fallback pour les objets DateTime (pour compatibilité)
final DateTime date = data['date'] as DateTime;
passagesByType[typeId]!.add(
PassageData(
date: date,
typeId: typeId,
count: count,
color: color,
iconPath: iconPath,
title: title,
),
);
}
}
}
// Convertir le Map en liste de listes
return passagesByType.values.toList();
}
/// Convertit les données de règlement brutes en liste de PaymentAmountData
///
/// [paymentData] est une liste d'objets contenant date, type_reglement et montant
static List<List<PaymentAmountData>> getPaymentDataByType(
List<Map<String, dynamic>> paymentData) {
// Créer un Map pour stocker les données par type de règlement
final Map<int, List<PaymentAmountData>> paymentsByType = {};
// Initialiser les listes pour chaque type de règlement (sauf 0 qui est "Pas de règlement")
for (final entry in AppKeys.typesReglements.entries) {
if (entry.key > 0) {
// Ignorer le type 0 (Pas de règlement)
paymentsByType[entry.key] = [];
}
}
// Grouper les règlements par type
for (final data in paymentData) {
final int typeId = data['type_reglement'];
final double amount = data['montant'] is double
? data['montant']
: double.parse(data['montant'].toString());
if (typeId > 0 && AppKeys.typesReglements.containsKey(typeId)) {
final typeData = AppKeys.typesReglements[typeId]!;
final Color color = Color(typeData['couleur'] as int);
final dynamic iconData = _getIconForPaymentType(typeId);
final String title = typeData['titre'] as String;
// Utiliser la méthode factory qui gère les dates au format ISO 8601
if (data['date'] is String) {
paymentsByType[typeId]!.add(
PaymentAmountData.fromIsoDate(
isoDate: data['date'],
typeId: typeId,
amount: amount,
color: color,
iconData: iconData,
title: title,
),
);
} else {
// Fallback pour les objets DateTime (pour compatibilité)
final DateTime date = data['date'] as DateTime;
paymentsByType[typeId]!.add(
PaymentAmountData(
date: date,
typeId: typeId,
amount: amount,
color: color,
iconData: iconData,
title: title,
),
);
}
}
}
// Convertir le Map en liste de listes
return paymentsByType.values.toList();
}
/// Génère des données de passage fictives pour les 14 derniers jours
static List<Map<String, dynamic>> generateMockPassageData() {
final List<Map<String, dynamic>> mockData = [];
final now = DateTime.now();
for (int i = 13; i >= 0; i--) {
final date = now.subtract(Duration(days: i));
// Ajouter des données pour chaque type de passage
for (int typeId = 1; typeId <= 6; typeId++) {
// Générer un nombre aléatoire de passages entre 0 et 5
final count = (typeId == 1 || typeId == 2)
? (1 + (date.day % 5)) // Plus de passages pour les types 1 et 2
: (date.day % 3); // Moins pour les autres types
if (count > 0) {
mockData.add({
'date': date,
'type_passage': typeId,
'nb': count,
});
}
}
}
return mockData;
}
/// Génère des données de règlement fictives pour les 14 derniers jours
static List<Map<String, dynamic>> generateMockPaymentData() {
final List<Map<String, dynamic>> mockData = [];
final now = DateTime.now();
for (int i = 13; i >= 0; i--) {
final date = now.subtract(Duration(days: i));
// Ajouter des données pour chaque type de règlement
for (int typeId = 1; typeId <= 3; typeId++) {
// Générer un montant aléatoire
final amount = (typeId * 100.0) + (date.day * 10.0);
mockData.add({
'date': date,
'type_reglement': typeId,
'montant': amount,
});
}
}
return mockData;
}
/// Obtenir l'icône correspondant au type de règlement
/// Retourne un IconData pour les règlements car ils n'ont pas de chemin d'icône défini dans AppKeys
static IconData _getIconForPaymentType(int typeId) {
switch (typeId) {
case 1: // Espèces
return Icons.payments;
case 2: // Chèque
return Icons.money;
case 3: // CB
return Icons.credit_card;
default:
return Icons.euro;
}
}
/// Formater une date pour l'affichage dans les graphiques
static String formatDateForChart(DateTime date, String periodType) {
switch (periodType.toLowerCase()) {
case 'jour':
return DateFormat('dd/MM').format(date);
case 'semaine':
// Calculer le numéro de la semaine dans l'année
final firstDayOfYear = DateTime(date.year, 1, 1);
final dayOfYear = date.difference(firstDayOfYear).inDays;
final weekNumber =
((dayOfYear + firstDayOfYear.weekday - 1) / 7).ceil();
return 'S$weekNumber';
case 'mois':
return DateFormat('MMM').format(date);
case 'année':
return DateFormat('yyyy').format(date);
default:
return DateFormat('dd/MM').format(date);
}
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
/// Modèle de données pour représenter un type de règlement avec son montant
class PaymentData {
/// Identifiant du type de règlement (1: Espèces, 2: Chèques, 3: CB)
final int typeId;
/// Montant du règlement
final double amount;
/// Couleur associée au type de règlement
final Color color;
/// Icône associée au type de règlement
final IconData icon;
/// Titre du type de règlement
final String title;
const PaymentData({
required this.typeId,
required this.amount,
required this.color,
required this.icon,
required this.title,
});
}

View File

@@ -0,0 +1,404 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'dart:math' as math;
/// Widget de graphique en camembert pour représenter la répartition des règlements
class PaymentPieChart extends StatefulWidget {
/// Liste des données de règlement à afficher dans le graphique
final List<PaymentData> payments;
/// Taille du graphique
final double size;
/// Taille des étiquettes
final double labelSize;
/// Afficher les pourcentages
final bool showPercentage;
/// Afficher les icônes
final bool showIcons;
/// Afficher la légende
final bool showLegend;
/// Format donut (anneau)
final bool isDonut;
/// Rayon central pour le format donut (en pourcentage)
final String innerRadius;
/// Activer l'effet 3D
final bool enable3DEffect;
/// Intensité de l'effet 3D (1.0 = normal, 2.0 = fort)
final double effect3DIntensity;
/// Activer l'effet d'explosion plus prononcé
final bool enableEnhancedExplode;
/// Utiliser un dégradé pour simuler l'effet 3D
final bool useGradient;
const PaymentPieChart({
super.key,
required this.payments,
this.size = 300,
this.labelSize = 12,
this.showPercentage = true,
this.showIcons = true,
this.showLegend = true,
this.isDonut = false,
this.innerRadius = '40%',
this.enable3DEffect = false,
this.effect3DIntensity = 1.0,
this.enableEnhancedExplode = false,
this.useGradient = false,
});
@override
State<PaymentPieChart> createState() => _PaymentPieChartState();
}
class _PaymentPieChartState extends State<PaymentPieChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
_animationController.forward();
}
@override
void didUpdateWidget(PaymentPieChart oldWidget) {
super.didUpdateWidget(oldWidget);
// Relancer l'animation si les données ont changé
// Utiliser une comparaison plus stricte pour éviter des animations inutiles
bool shouldResetAnimation = false;
if (oldWidget.payments.length != widget.payments.length) {
shouldResetAnimation = true;
} else {
// Comparer les éléments importants uniquement
for (int i = 0; i < oldWidget.payments.length; i++) {
if (i >= widget.payments.length) break;
if (oldWidget.payments[i].amount != widget.payments[i].amount ||
oldWidget.payments[i].title != widget.payments[i].title) {
shouldResetAnimation = true;
break;
}
}
}
if (shouldResetAnimation) {
_animationController.reset();
_animationController.forward();
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
/// Prépare les données pour le graphique en camembert
List<PaymentData> _prepareChartData() {
// Filtrer les règlements avec un montant > 0
return widget.payments.where((payment) => payment.amount > 0).toList();
}
@override
Widget build(BuildContext context) {
final chartData = _prepareChartData();
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
return SizedBox(
width: widget.size,
height: widget.size,
child: const Center(
child: Text('Aucune donnée disponible'),
),
);
}
// Créer des animations pour différents aspects du graphique
final progressAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
);
final explodeAnimation = CurvedAnimation(
parent: _animationController,
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
);
final opacityAnimation = CurvedAnimation(
parent: _animationController,
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
);
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return SizedBox(
width: widget.size,
height: widget.size,
child: SfCircularChart(
margin: EdgeInsets.zero,
legend: Legend(
isVisible: widget.showLegend,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
textStyle: TextStyle(fontSize: widget.labelSize),
),
tooltipBehavior: TooltipBehavior(enable: true),
series: <CircularSeries>[
widget.isDonut
? DoughnutSeries<PaymentData, String>(
dataSource: chartData,
xValueMapper: (PaymentData data, _) => data.title,
yValueMapper: (PaymentData data, _) => data.amount,
pointColorMapper: (PaymentData data, _) {
if (widget.enable3DEffect) {
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
final index = chartData.indexOf(data);
final angle =
(index / chartData.length) * 2 * math.pi;
return widget.useGradient
? _createEnhanced3DColor(data.color, angle)
: _create3DColor(
data.color, widget.effect3DIntensity);
}
return data.color;
},
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
enableTooltip: true,
dataLabelMapper: (PaymentData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0.0, (sum, item) => sum + item.amount);
final percentage = (data.amount / total * 100);
return '${percentage.toStringAsFixed(1)}%';
} else {
return data.title;
}
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
textStyle: TextStyle(fontSize: widget.labelSize),
connectorLineSettings: const ConnectorLineSettings(
type: ConnectorType.curve,
length: '15%',
),
),
innerRadius: widget.innerRadius,
// Effet d'explosion plus prononcé pour donner du relief avec animation
explode: true,
explodeAll: widget.enableEnhancedExplode,
explodeIndex: widget.enableEnhancedExplode ? null : 0,
explodeOffset: widget.enableEnhancedExplode
? widget.enable3DEffect
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
// Effet 3D via l'opacité et les couleurs avec animation
opacity: widget.enable3DEffect
? 0.95 * opacityAnimation.value
: opacityAnimation.value,
// Animation progressive du graphique
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
)
: PieSeries<PaymentData, String>(
dataSource: chartData,
xValueMapper: (PaymentData data, _) => data.title,
yValueMapper: (PaymentData data, _) => data.amount,
pointColorMapper: (PaymentData data, _) {
if (widget.enable3DEffect) {
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
final index = chartData.indexOf(data);
final angle =
(index / chartData.length) * 2 * math.pi;
return widget.useGradient
? _createEnhanced3DColor(data.color, angle)
: _create3DColor(
data.color, widget.effect3DIntensity);
}
return data.color;
},
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
enableTooltip: true,
dataLabelMapper: (PaymentData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0.0, (sum, item) => sum + item.amount);
final percentage = (data.amount / total * 100);
return '${percentage.toStringAsFixed(1)}%';
} else {
return data.title;
}
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
textStyle: TextStyle(fontSize: widget.labelSize),
connectorLineSettings: const ConnectorLineSettings(
type: ConnectorType.curve,
length: '15%',
),
),
// Effet d'explosion plus prononcé pour donner du relief avec animation
explode: true,
explodeAll: widget.enableEnhancedExplode,
explodeIndex: widget.enableEnhancedExplode ? null : 0,
explodeOffset: widget.enableEnhancedExplode
? widget.enable3DEffect
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
// Effet 3D via l'opacité et les couleurs avec animation
opacity: widget.enable3DEffect
? 0.95 * opacityAnimation.value
: opacityAnimation.value,
// Animation progressive du graphique
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
),
],
annotations:
widget.showIcons ? _buildIconAnnotations(chartData) : null,
// Paramètres pour améliorer l'effet 3D
palette: widget.enable3DEffect ? _create3DPalette(chartData) : null,
// Ajouter un effet de bordure pour renforcer l'effet 3D
borderWidth: widget.enable3DEffect ? 0.5 : 0,
// Note: La rotation n'est pas directement prise en charge dans cette version de Syncfusion
),
);
},
);
}
/// Crée une couleur avec effet 3D en ajoutant des nuances
Color _create3DColor(Color baseColor, double intensity) {
// Ajuster la luminosité et la saturation pour créer un effet 3D plus prononcé
final hslColor = HSLColor.fromColor(baseColor);
// Augmenter la luminosité pour simuler un éclairage
final adjustedLightness =
(hslColor.lightness + 0.15 * intensity).clamp(0.0, 1.0);
// Augmenter légèrement la saturation pour des couleurs plus vives
final adjustedSaturation =
(hslColor.saturation + 0.05 * intensity).clamp(0.0, 1.0);
return hslColor
.withLightness(adjustedLightness)
.withSaturation(adjustedSaturation)
.toColor();
}
/// Crée une palette de couleurs pour l'effet 3D
List<Color> _create3DPalette(List<PaymentData> chartData) {
List<Color> palette = [];
// Créer des variations de couleurs pour chaque segment
for (var i = 0; i < chartData.length; i++) {
var data = chartData[i];
// Calculer un angle pour chaque segment pour simuler un éclairage directionnel
final angle = (i / chartData.length) * 2 * math.pi;
// Créer un effet d'ombre et de lumière en fonction de l'angle
final hslColor = HSLColor.fromColor(data.color);
// Ajuster la luminosité en fonction de l'angle
final lightAdjustment = 0.15 * widget.effect3DIntensity * math.sin(angle);
final adjustedLightness = (hslColor.lightness -
0.1 * widget.effect3DIntensity +
lightAdjustment)
.clamp(0.0, 1.0);
// Ajuster la saturation pour plus de profondeur
final adjustedSaturation =
(hslColor.saturation + 0.1 * widget.effect3DIntensity)
.clamp(0.0, 1.0);
final enhancedColor = hslColor
.withLightness(adjustedLightness)
.withSaturation(adjustedSaturation)
.toColor();
palette.add(enhancedColor);
}
return palette;
}
/// Crée une couleur avec effet 3D plus avancé
Color _createEnhanced3DColor(Color baseColor, double angle) {
// Simuler un effet de lumière directionnel
final hslColor = HSLColor.fromColor(baseColor);
// Ajuster la luminosité en fonction de l'angle pour simuler un éclairage
final adjustedLightness = hslColor.lightness +
(0.2 * widget.effect3DIntensity * math.sin(angle)).clamp(-0.3, 0.3);
return hslColor.withLightness(adjustedLightness.clamp(0.0, 1.0)).toColor();
}
/// Crée les annotations d'icônes pour le graphique
List<CircularChartAnnotation> _buildIconAnnotations(
List<PaymentData> chartData) {
final List<CircularChartAnnotation> annotations = [];
// Calculer le total pour les pourcentages
double total = chartData.fold(0.0, (sum, item) => sum + item.amount);
// Position angulaire actuelle (en radians)
double currentAngle = 0;
for (int i = 0; i < chartData.length; i++) {
final data = chartData[i];
final percentage = data.amount / total;
// Calculer l'angle central de ce segment
final segmentAngle = percentage * 2 * 3.14159;
final midAngle = currentAngle + (segmentAngle / 2);
// Ajouter une annotation pour l'icône
annotations.add(
CircularChartAnnotation(
widget: Icon(
data.icon,
color: Colors.white,
size: 16,
),
radius: '50%',
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
),
);
// Mettre à jour l'angle actuel
currentAngle += segmentAngle;
}
return annotations;
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
/// Utilitaires pour les paiements et règlements
class PaymentUtils {
/// Convertit les données de règlement depuis les constantes AppKeys
///
/// [paymentAmounts] est une Map associant l'ID du type de règlement à son montant
static List<PaymentData> getPaymentDataFromAmounts(
Map<int, double> paymentAmounts) {
final List<PaymentData> paymentDataList = [];
// Parcourir tous les types de règlements définis dans AppKeys
AppKeys.typesReglements.forEach((typeId, typeData) {
// Vérifier si nous avons un montant pour ce type de règlement
final double amount = paymentAmounts[typeId] ?? 0.0;
// Créer un objet PaymentData pour ce type de règlement
final PaymentData paymentData = PaymentData(
typeId: typeId,
amount: amount,
color: Color(typeData['couleur'] as int),
icon: typeData['icon_data'] as IconData,
title: typeData['titre'] as String,
);
paymentDataList.add(paymentData);
});
return paymentDataList;
}
}