Amélioration de la splash_page et du login

This commit is contained in:
d6soft
2025-06-04 16:51:40 +02:00
parent 8c9e9a21c4
commit bcfdbb2c8b
168 changed files with 153842 additions and 6183 deletions

View File

@@ -1,18 +1,17 @@
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';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.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}, ...]
/// Si useValueListenable est true, ce paramètre est ignoré
final List<Map<String, dynamic>>? passageData;
/// Type de période (Jour, Semaine, Mois, Année)
@@ -30,9 +29,6 @@ class ActivityChart extends StatefulWidget {
/// 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;
@@ -51,8 +47,8 @@ class ActivityChart extends StatefulWidget {
/// Si vrai, n'applique aucun filtrage par utilisateur (affiche tous les passages)
final bool showAllPassages;
/// Si vrai, force le rechargement des données
final bool forceRefresh;
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
final bool useValueListenable;
const ActivityChart({
super.key,
@@ -62,16 +58,14 @@ class ActivityChart extends StatefulWidget {
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,
this.forceRefresh = false,
}) : assert(loadFromHive || passageData != null,
'Soit loadFromHive doit être true, soit passageData doit être fourni');
this.useValueListenable = true,
});
@override
State<ActivityChart> createState() => _ActivityChartState();
@@ -96,16 +90,6 @@ 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;
@@ -117,9 +101,6 @@ class _ActivityChartState extends State<ActivityChart>
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,
@@ -128,83 +109,29 @@ class _ActivityChartState extends State<ActivityChart>
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);
}
@override
void didUpdateWidget(ActivityChart oldWidget) {
super.didUpdateWidget(oldWidget);
void _loadData() {
// Marquer comme chargé immédiatement pour éviter les appels multiples pendant le chargement
// Mais permettre un rechargement ultérieur si nécessaire
if (_dataLoaded && _hasData) return;
// 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.useValueListenable
? false
: oldWidget.passageData != widget.passageData;
final bool filteringChanged = oldWidget.userId != widget.userId ||
!listEquals(
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages ||
oldWidget.useValueListenable != widget.useValueListenable;
_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;
});
// Si des paramètres importants ont changé, relancer l'animation
if (periodChanged || dataSourceChanged || filteringChanged) {
_animationController.reset();
_animationController.forward();
}
}
@@ -215,47 +142,141 @@ class _ActivityChartState extends State<ActivityChart>
}
@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;
final bool refreshForced = widget.forceRefresh && !oldWidget.forceRefresh;
// Si des paramètres importants ont changé ou si forceRefresh est passé à true, recharger les données
if (periodChanged ||
dataSourceChanged ||
filteringChanged ||
refreshForced) {
_selectedDays = widget.daysToShow;
_dataLoaded = false; // Réinitialiser l'état pour forcer le rechargement
_loadData();
Widget build(BuildContext context) {
if (widget.useValueListenable) {
return _buildWithValueListenable();
} else {
return _buildWithStaticData();
}
}
// 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
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
Widget _buildWithValueListenable() {
return ValueListenableBuilder(
valueListenable:
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final chartData = _calculateActivityData(passagesBox);
return _buildChart(chartData);
},
);
}
/// Prépare les données pour le graphique
void _prepareChartData() {
/// Construction du widget avec des données statiques
Widget _buildWithStaticData() {
if (widget.passageData == null) {
return SizedBox(
height: widget.height,
child: const Center(
child: Text('Aucune donnée fournie'),
),
);
}
final chartData = _prepareChartDataFromPassageData(widget.passageData!);
return _buildChart(chartData);
}
/// Calcule les données d'activité depuis la Hive box
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox) {
try {
// Vérifier que les données sont disponibles
if (_passageData.isEmpty) {
_chartData = [];
return;
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
// Déterminer l'utilisateur cible selon les filtres
final int? targetUserId =
widget.showAllPassages ? null : (widget.userId ?? currentUser?.id);
// Calculer la date de début (nombre de jours en arrière)
final endDate = DateTime.now();
final startDate = endDate.subtract(Duration(days: widget.daysToShow - 1));
// Préparer les données par date
final Map<String, Map<int, int>> dataByDate = {};
// Initialiser toutes les dates de la période
for (int i = 0; i < widget.daysToShow; i++) {
final date = startDate.add(Duration(days: i));
final dateStr = DateFormat('yyyy-MM-dd').format(date);
dataByDate[dateStr] = {};
// Initialiser tous les types de passage possibles
for (final typeId in AppKeys.typesPassages.keys) {
if (!widget.excludePassageTypes.contains(typeId)) {
dataByDate[dateStr]![typeId] = 0;
}
}
}
// Parcourir les passages et les compter par date et type
for (final passage in passages) {
// Appliquer les filtres
bool shouldInclude = true;
// Filtrer par utilisateur si nécessaire
if (targetUserId != null && passage.fkUser != targetUserId) {
shouldInclude = false;
}
// Exclure certains types
if (widget.excludePassageTypes.contains(passage.fkType)) {
shouldInclude = false;
}
// Vérifier si le passage est dans la période
final passageDate = passage.passedAt;
if (passageDate.isBefore(startDate) || passageDate.isAfter(endDate)) {
shouldInclude = false;
}
if (shouldInclude) {
final dateStr = DateFormat('yyyy-MM-dd').format(passageDate);
if (dataByDate.containsKey(dateStr)) {
dataByDate[dateStr]![passage.fkType] =
(dataByDate[dateStr]![passage.fkType] ?? 0) + 1;
}
}
}
// Convertir en liste d'ActivityData
final List<ActivityData> chartData = [];
dataByDate.forEach((dateStr, passagesByType) {
final dateParts = dateStr.split('-');
if (dateParts.length == 3) {
try {
final date = DateTime(
int.parse(dateParts[0]),
int.parse(dateParts[1]),
int.parse(dateParts[2]),
);
chartData.add(ActivityData(
date: date,
dateStr: dateStr,
passagesByType: passagesByType,
));
} catch (e) {
debugPrint('Erreur de conversion de date: $dateStr');
}
}
});
// Trier par date
chartData.sort((a, b) => a.date.compareTo(b.date));
return chartData;
} catch (e) {
debugPrint('Erreur lors du calcul des données d\'activité: $e');
return [];
}
}
/// Prépare les données pour le graphique à partir des données de passage brutes (ancien système)
List<ActivityData> _prepareChartDataFromPassageData(
List<Map<String, dynamic>> passageData) {
try {
// Obtenir toutes les dates uniques
final Set<String> uniqueDatesSet = {};
for (final data in _passageData) {
for (final data in passageData) {
if (data.containsKey('date') && data['date'] != null) {
uniqueDatesSet.add(data['date'] as String);
}
@@ -266,7 +287,7 @@ class _ActivityChartState extends State<ActivityChart>
uniqueDates.sort();
// Créer les données pour chaque date
_chartData = [];
final List<ActivityData> chartData = [];
for (final dateStr in uniqueDates) {
final passagesByType = <int, int>{};
@@ -278,7 +299,7 @@ class _ActivityChartState extends State<ActivityChart>
}
// Remplir les données de passage
for (final data in _passageData) {
for (final data in passageData) {
if (data.containsKey('date') &&
data['date'] == dateStr &&
data.containsKey('type_passage') &&
@@ -301,37 +322,29 @@ class _ActivityChartState extends State<ActivityChart>
final date = DateTime(year, month, day);
// Ajouter les données à la liste
_chartData.add(ActivityData(
chartData.add(ActivityData(
date: date,
dateStr: dateStr,
passagesByType: passagesByType,
));
}
} catch (e) {
// Silencieux lors des erreurs de conversion de date pour éviter les logs excessifs
debugPrint('Erreur de conversion de date: $dateStr');
}
}
// Trier les données par date
_chartData.sort((a, b) => a.date.compareTo(b.date));
chartData.sort((a, b) => a.date.compareTo(b.date));
return chartData;
} catch (e) {
// Erreur silencieuse pour éviter les logs excessifs
_chartData = [];
debugPrint('Erreur lors de la préparation des données: $e');
return [];
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return SizedBox(
height: widget.height,
child: const Center(
child: CircularProgressIndicator(),
),
);
}
if (!_hasData || _chartData.isEmpty) {
/// Construit le graphique avec les données fournies
Widget _buildChart(List<ActivityData> chartData) {
if (chartData.isEmpty) {
return SizedBox(
height: widget.height,
child: const Center(
@@ -340,17 +353,12 @@ class _ActivityChartState extends State<ActivityChart>
);
}
// 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é)
// Titre
if (widget.title.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
@@ -362,13 +370,13 @@ class _ActivityChartState extends State<ActivityChart>
),
),
),
// Graphique (occupe maintenant plus d'espace)
// Graphique
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
child: SfCartesianChart(
plotAreaBorderWidth: 0,
legend: Legend(
legend: const Legend(
isVisible: true,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
@@ -378,30 +386,25 @@ class _ActivityChartState extends State<ActivityChart>
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
minimum: chartData.isNotEmpty ? chartData.first.date : null,
maximum: chartData.isNotEmpty ? chartData.last.date : null,
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(
primaryYAxis: const NumericAxis(
labelStyle: TextStyle(fontSize: 10),
axisLine: AxisLine(width: 0),
majorTickLines: MajorTickLines(size: 0),
majorGridLines: MajorGridLines(
width: 0.5,
color: Colors.grey,
dashArray: <double>[5, 5], // Motif de pointillés
dashArray: <double>[5, 5],
),
title: const AxisTitle(
title: AxisTitle(
text: 'Passages',
textStyle: TextStyle(fontSize: 10, color: Colors.grey),
),
),
series: _buildSeries(),
series: _buildSeries(chartData),
tooltipBehavior: TooltipBehavior(enable: true),
zoomPanBehavior: _zoomPanBehavior,
),
@@ -413,11 +416,12 @@ class _ActivityChartState extends State<ActivityChart>
}
/// Construit les séries de données pour le graphique
List<CartesianSeries<ActivityData, DateTime>> _buildSeries() {
List<CartesianSeries<ActivityData, DateTime>> _buildSeries(
List<ActivityData> chartData) {
final List<CartesianSeries<ActivityData, DateTime>> series = [];
// Vérifier que les données sont disponibles
if (_chartData.isEmpty) {
if (chartData.isEmpty) {
return series;
}
@@ -444,25 +448,19 @@ class _ActivityChartState extends State<ActivityChart>
// Calculer le total pour ce type pour déterminer s'il faut l'afficher
int totalForType = 0;
for (final data in _chartData) {
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) {
// Ajouter la série pour ce type si elle a des données
if (totalForType > 0) {
series.add(
StackedColumnSeries<ActivityData, DateTime>(
name: typeName,
dataSource: _chartData,
dataSource: chartData,
xValueMapper: (ActivityData data, _) => data.date,
yValueMapper: (ActivityData data, _) {
final value = data.passagesByType.containsKey(typeId)
? data.passagesByType[typeId]!
: 0;
return value;
return data.passagesByType[typeId] ?? 0;
},
color: typeColor,
width: widget.columnWidth,

View File

@@ -3,9 +3,10 @@ library geosector_charts;
export 'payment_data.dart';
export 'payment_pie_chart.dart';
export 'payment_utils.dart';
export 'payment_summary_card.dart';
export 'passage_data.dart';
export 'passage_utils.dart';
export 'passage_pie_chart.dart';
export 'passage_summary_card.dart';
export 'activity_chart.dart';
export 'combined_chart.dart';

View File

@@ -1,13 +1,10 @@
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:flutter/foundation.dart' show listEquals;
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';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
/// Modèle de données pour le graphique en camembert des passages
class PassageChartData {
@@ -38,7 +35,7 @@ class PassageChartData {
/// 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é
/// Si useValueListenable est true, ce paramètre est ignoré
final Map<int, int> passagesByType;
/// Taille du graphique
@@ -62,18 +59,21 @@ class PassagePieChart extends StatefulWidget {
/// Rayon central pour le format donut (en pourcentage)
final String innerRadius;
/// Charger les données depuis Hive
/// Charger les données depuis Hive (obsolète, utiliser useValueListenable)
final bool loadFromHive;
/// ID de l'utilisateur pour filtrer les passages (utilisé seulement si loadFromHive est true)
/// ID de l'utilisateur pour filtrer les passages
final int? userId;
/// Types de passages à exclure (utilisé seulement si loadFromHive est true)
/// Types de passages à exclure
final List<int> excludePassageTypes;
/// Afficher tous les passages sans filtrer par utilisateur (utilisé seulement si loadFromHive est true)
/// Afficher tous les passages sans filtrer par utilisateur
final bool showAllPassages;
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
final bool useValueListenable;
const PassagePieChart({
super.key,
this.passagesByType = const {},
@@ -88,6 +88,7 @@ class PassagePieChart extends StatefulWidget {
this.userId,
this.excludePassageTypes = const [2],
this.showAllPassages = false,
this.useValueListenable = true,
});
@override
@@ -98,20 +99,9 @@ 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,
@@ -119,235 +109,21 @@ class _PassagePieChartState extends State<PassagePieChart>
);
_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;
// Relancer l'animation si les paramètres importants ont changé
final bool shouldResetAnimation = oldWidget.userId != widget.userId ||
!listEquals(oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages ||
oldWidget.useValueListenable != widget.useValueListenable;
// 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é
if (shouldResetAnimation) {
_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 pendant le chargement
if (_isLoading) {
debugPrint('PassagePieChart: Déjà en cours de chargement, ignoré');
return;
}
// Si les données sont déjà chargées et non vides, ne pas recharger
if (_dataLoaded && _passagesByType.isNotEmpty) {
debugPrint('PassagePieChart: Données déjà chargées, ignoré');
return;
}
debugPrint('PassagePieChart: Début du chargement des données');
setState(() {
_isLoading = true;
});
// 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) {
debugPrint('PassagePieChart: Widget démonté, chargement annulé');
return;
}
try {
debugPrint('PassagePieChart: Création du service de données');
// Utiliser les instances globales définies dans app.dart
// Vérifier que les repositories sont disponibles
if (passageRepository == null) {
debugPrint('PassagePieChart: ERREUR - passageRepository est null');
if (mounted) {
setState(() {
_isLoading = false;
});
}
return;
}
if (userRepository == null) {
debugPrint('PassagePieChart: ERREUR - userRepository est null');
if (mounted) {
setState(() {
_isLoading = false;
});
}
return;
}
// Créer une instance du service de données
final passageDataService = PassageDataService(
passageRepository: passageRepository,
userRepository: userRepository,
);
debugPrint(
'PassagePieChart: Chargement des données avec excludePassageTypes=${widget.excludePassageTypes}, userId=${widget.userId}, showAllPassages=${widget.showAllPassages}');
// Utiliser le service pour charger les données
final data = passageDataService.loadPassageDataForPieChart(
excludePassageTypes: widget.excludePassageTypes,
userId: widget.userId,
showAllPassages: widget.showAllPassages,
);
debugPrint('PassagePieChart: Données chargées: $data');
// 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();
debugPrint('PassagePieChart: Données préparées pour le graphique');
}
} catch (e) {
// Gérer les erreurs et réinitialiser l'état pour permettre une future tentative
debugPrint(
'PassagePieChart: ERREUR lors du chargement des données: $e');
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) {
debugPrint('PassagePieChart: Utilisation des données en cache');
return _cachedChartData!;
}
debugPrint('PassagePieChart: Préparation des données pour le graphique');
debugPrint('PassagePieChart: Données brutes: $_passagesByType');
// Vérifier si les données sont vides
if (_passagesByType.isEmpty) {
debugPrint('PassagePieChart: Aucune donnée disponible');
return [];
}
// Vérifier si les données contiennent uniquement des passages de type 2
bool onlyType2 = true;
_passagesByType.forEach((typeId, count) {
if (typeId != 2 && count > 0) {
onlyType2 = false;
}
});
if (onlyType2) {
debugPrint(
'PassagePieChart: Les données contiennent uniquement des passages de type 2');
}
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)) {
// Vérifier si le type est exclu
bool isExcluded = widget.excludePassageTypes.contains(typeId);
if (isExcluded) {
debugPrint('PassagePieChart: Type $typeId exclu');
} else {
final typeInfo = AppKeys.typesPassages[typeId]!;
final typeName = typeInfo['titre'] as String;
debugPrint(
'PassagePieChart: Ajout du type $typeId ($typeName) avec $count passages');
chartData.add(PassageChartData(
typeId: typeId,
count: count,
title: typeName,
color: Color(typeInfo['couleur2'] as int),
icon: typeInfo['icon_data'] as IconData,
));
}
} else {
if (count <= 0) {
debugPrint('PassagePieChart: Type $typeId ignoré car count=$count');
} else if (!AppKeys.typesPassages.containsKey(typeId)) {
debugPrint(
'PassagePieChart: Type $typeId ignoré car non défini dans AppKeys.typesPassages');
}
}
});
debugPrint(
'PassagePieChart: ${chartData.length} types de passages ajoutés au graphique');
// Mettre en cache les données générées
_cachedChartData = chartData;
return chartData;
}
@override
@@ -358,19 +134,100 @@ class _PassagePieChartState extends State<PassagePieChart>
@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(),
),
);
if (widget.useValueListenable) {
return _buildWithValueListenable();
} else {
return _buildWithStaticData();
}
}
final chartData = _prepareChartData();
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
Widget _buildWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final chartData = _calculatePassageData(passagesBox);
return _buildChart(chartData);
},
);
}
/// Construction du widget avec des données statiques (ancien système)
Widget _buildWithStaticData() {
final chartData = _prepareChartDataFromMap(widget.passagesByType);
return _buildChart(chartData);
}
/// Calcule les données de passage depuis la Hive box
List<PassageChartData> _calculatePassageData(Box<PassageModel> passagesBox) {
try {
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
// Calculer les données selon les filtres
final Map<int, int> passagesByType = {};
// Initialiser tous les types de passage possibles
for (final typeId in AppKeys.typesPassages.keys) {
if (!widget.excludePassageTypes.contains(typeId)) {
passagesByType[typeId] = 0;
}
}
for (final passage in passages) {
// Appliquer les filtres
bool shouldInclude = true;
// Filtrer par utilisateur si nécessaire
if (!widget.showAllPassages && widget.userId != null) {
shouldInclude = passage.fkUser == widget.userId;
} else if (!widget.showAllPassages && currentUser != null) {
shouldInclude = passage.fkUser == currentUser.id;
}
// Exclure certains types
if (widget.excludePassageTypes.contains(passage.fkType)) {
shouldInclude = false;
}
if (shouldInclude) {
passagesByType[passage.fkType] =
(passagesByType[passage.fkType] ?? 0) + 1;
}
}
return _prepareChartDataFromMap(passagesByType);
} catch (e) {
debugPrint('Erreur lors du calcul des données de passage: $e');
return [];
}
}
/// Prépare les données pour le graphique en camembert à partir d'une Map
List<PassageChartData> _prepareChartDataFromMap(Map<int, int> passagesByType) {
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,
));
}
});
return chartData;
}
/// Construit le graphique avec les données fournies
Widget _buildChart(List<PassageChartData> chartData) {
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
return SizedBox(
@@ -448,8 +305,7 @@ class _PassagePieChartState extends State<PassagePieChart>
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
animationDuration: 0,
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
)
@@ -485,8 +341,7 @@ class _PassagePieChartState extends State<PassagePieChart>
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
animationDuration: 0,
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
),
@@ -499,14 +354,9 @@ class _PassagePieChartState extends State<PassagePieChart>
);
}
/// Crée les annotations d'icônes pour le graphique avec mise en cache
/// Crée les annotations d'icônes pour le graphique
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
@@ -541,9 +391,6 @@ class _PassagePieChartState extends State<PassagePieChart>
currentAngle += segmentAngle;
}
// Mettre en cache les annotations générées
_cachedAnnotations = annotations;
return annotations;
}
}

View File

@@ -0,0 +1,353 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/app.dart';
/// Widget commun pour afficher une carte de synthèse des passages
/// avec liste des types à gauche et graphique en camembert à droite
class PassageSummaryCard extends StatelessWidget {
/// Titre de la carte
final String title;
/// Couleur de l'icône et du titre
final Color titleColor;
/// Icône à afficher dans le titre
final IconData? titleIcon;
/// Hauteur totale de la carte
final double? height;
/// Utiliser ValueListenableBuilder pour mise à jour automatique
final bool useValueListenable;
/// ID de l'utilisateur pour filtrer les passages (si null, tous les utilisateurs)
final int? userId;
/// Afficher tous les passages (admin) ou seulement ceux de l'utilisateur
final bool showAllPassages;
/// Types de passages à exclure du graphique
final List<int> excludePassageTypes;
/// Données statiques de passages par type (utilisé si useValueListenable = false)
final Map<int, int>? passagesByType;
/// Fonction de callback pour afficher la valeur totale personnalisée
final String Function(int totalPassages)? customTotalDisplay;
/// Afficher le graphique en mode desktop ou mobile
final bool isDesktop;
/// Icône d'arrière-plan (optionnelle)
final IconData? backgroundIcon;
/// Couleur de l'icône d'arrière-plan
final Color? backgroundIconColor;
/// Opacité de l'icône d'arrière-plan
final double backgroundIconOpacity;
/// Taille de l'icône d'arrière-plan
final double backgroundIconSize;
const PassageSummaryCard({
super.key,
required this.title,
this.titleColor = AppTheme.primaryColor,
this.titleIcon = Icons.route,
this.height,
this.useValueListenable = true,
this.userId,
this.showAllPassages = false,
this.excludePassageTypes = const [2], // Exclure "À finaliser" par défaut
this.passagesByType,
this.customTotalDisplay,
this.isDesktop = true,
this.backgroundIcon = Icons.route,
this.backgroundIconColor,
this.backgroundIconOpacity = 0.07,
this.backgroundIconSize = 180,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Stack(
children: [
// Icône d'arrière-plan (optionnelle)
if (backgroundIcon != null)
Positioned.fill(
child: Center(
child: Icon(
backgroundIcon,
size: backgroundIconSize,
color: (backgroundIconColor ?? AppTheme.primaryColor).withOpacity(backgroundIconOpacity),
),
),
),
// Contenu principal
Container(
height: height,
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre avec comptage
useValueListenable
? _buildTitleWithValueListenable()
: _buildTitleWithStaticData(),
const Divider(height: 24),
// Contenu principal
Expanded(
child: SizedBox(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Liste des passages à gauche
Expanded(
flex: isDesktop ? 1 : 2,
child: useValueListenable
? _buildPassagesListWithValueListenable()
: _buildPassagesListWithStaticData(),
),
// Séparateur vertical
if (isDesktop) const VerticalDivider(width: 24),
// Graphique en camembert à droite
Expanded(
flex: isDesktop ? 1 : 2,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PassagePieChart(
useValueListenable: useValueListenable,
passagesByType: passagesByType ?? {},
excludePassageTypes: excludePassageTypes,
userId: showAllPassages ? null : userId,
size: double.infinity,
labelSize: 12,
showPercentage: true,
showIcons: false,
showLegend: false,
isDonut: true,
innerRadius: '50%',
),
),
),
],
),
),
),
],
),
),
],
),
);
}
/// Construction du titre avec ValueListenableBuilder
Widget _buildTitleWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final totalUserPassages = _calculateUserPassagesCount(passagesBox);
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(totalUserPassages) ?? totalUserPassages.toString(),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
},
);
}
/// Construction du titre avec données statiques
Widget _buildTitleWithStaticData() {
final totalPassages = passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(totalPassages) ?? totalPassages.toString(),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
}
/// Construction de la liste des passages avec ValueListenableBuilder
Widget _buildPassagesListWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final passagesCounts = _calculatePassagesCounts(passagesBox);
return _buildPassagesList(passagesCounts);
},
);
}
/// Construction de la liste des passages avec données statiques
Widget _buildPassagesListWithStaticData() {
return _buildPassagesList(passagesByType ?? {});
}
/// Construction de la liste des passages
Widget _buildPassagesList(Map<int, int> passagesCounts) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...AppKeys.typesPassages.entries.map((entry) {
final int typeId = entry.key;
final Map<String, dynamic> typeData = entry.value;
final int count = passagesCounts[typeId] ?? 0;
final Color color = Color(typeData['couleur2'] as int);
final IconData iconData = typeData['icon_data'] as IconData;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
iconData,
color: Colors.white,
size: 16,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
typeData['titres'] as String,
style: const TextStyle(fontSize: 14),
),
),
Text(
count.toString(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}).toList(),
],
);
}
/// Calcule le nombre total de passages pour l'utilisateur
int _calculateUserPassagesCount(Box<PassageModel> passagesBox) {
if (showAllPassages) {
// Pour les administrateurs : tous les passages sauf ceux exclus
return passagesBox.values
.where((passage) => !excludePassageTypes.contains(passage.fkType))
.length;
} else {
// Pour les utilisateurs : seulement leurs passages
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId == null) return 0;
return passagesBox.values
.where((passage) =>
passage.fkUser == targetUserId &&
!excludePassageTypes.contains(passage.fkType))
.length;
}
}
/// Calcule les compteurs de passages par type
Map<int, int> _calculatePassagesCounts(Box<PassageModel> passagesBox) {
final Map<int, int> counts = {};
// Initialiser tous les types
for (final typeId in AppKeys.typesPassages.keys) {
counts[typeId] = 0;
}
if (showAllPassages) {
// Pour les administrateurs : compter tous les passages
for (final passage in passagesBox.values) {
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
}
} else {
// Pour les utilisateurs : compter seulement leurs passages
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId != null) {
for (final passage in passagesBox.values) {
if (passage.fkUser == targetUserId) {
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
}
}
}
}
return counts;
}
}

View File

@@ -1,11 +1,16 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/app.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
/// Si useValueListenable est true, ce paramètre est ignoré
final List<PaymentData> payments;
/// Taille du graphique
@@ -41,9 +46,15 @@ class PaymentPieChart extends StatefulWidget {
/// Utiliser un dégradé pour simuler l'effet 3D
final bool useGradient;
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
final bool useValueListenable;
/// ID de l'utilisateur pour filtrer les passages
final int? userId;
const PaymentPieChart({
super.key,
required this.payments,
this.payments = const [],
this.size = 300,
this.labelSize = 12,
this.showPercentage = true,
@@ -55,6 +66,8 @@ class PaymentPieChart extends StatefulWidget {
this.effect3DIntensity = 1.0,
this.enableEnhancedExplode = false,
this.useGradient = false,
this.useValueListenable = true,
this.userId,
});
@override
@@ -80,20 +93,24 @@ class _PaymentPieChartState extends State<PaymentPieChart>
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
// Relancer l'animation si les paramètres importants ont changé
bool shouldResetAnimation = false;
if (oldWidget.payments.length != widget.payments.length) {
if (widget.useValueListenable != oldWidget.useValueListenable ||
widget.userId != oldWidget.userId) {
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) {
} else if (!widget.useValueListenable) {
// Pour les données statiques, comparer les éléments
if (oldWidget.payments.length != widget.payments.length) {
shouldResetAnimation = true;
break;
} else {
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;
}
}
}
}
@@ -110,15 +127,115 @@ class _PaymentPieChartState extends State<PaymentPieChart>
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();
if (widget.useValueListenable) {
return _buildWithValueListenable();
} else {
return _buildWithStaticData();
}
}
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
Widget _buildWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final paymentData = _calculatePaymentData(passagesBox);
return _buildChart(paymentData);
},
);
}
/// Construction du widget avec des données statiques
Widget _buildWithStaticData() {
return _buildChart(widget.payments);
}
/// Calcule les données de règlement depuis la Hive box
List<PaymentData> _calculatePaymentData(Box<PassageModel> passagesBox) {
try {
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
final int? currentUserId = widget.userId ?? currentUser?.id;
// Initialiser les montants par type de règlement
final Map<int, double> paymentAmounts = {
0: 0.0, // Pas de règlement
1: 0.0, // Espèces
2: 0.0, // Chèques
3: 0.0, // CB
};
// Parcourir les passages et calculer les montants par type de règlement
for (final passage in passages) {
// Vérifier si le passage appartient à l'utilisateur actuel
if (currentUserId != null && passage.fkUser == currentUserId) {
final int typeReglement = passage.fkTypeReglement;
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
// Gérer les formats possibles (virgule ou point)
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
debugPrint('Erreur de conversion du montant: ${passage.montant}');
}
// Ne compter que les passages avec un montant > 0
if (montant > 0) {
// Ajouter au montant total par type de règlement
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] =
(paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
}
}
// Convertir le Map en List<PaymentData>
final List<PaymentData> paymentDataList = [];
paymentAmounts.forEach((typeReglement, montant) {
if (montant > 0) { // Ne retourner que les types avec un montant > 0
// Récupérer les informations depuis AppKeys.typesReglements
final reglementInfo = AppKeys.typesReglements[typeReglement];
if (reglementInfo != null) {
paymentDataList.add(PaymentData(
typeId: typeReglement,
title: reglementInfo['titre'] as String,
amount: montant,
color: Color(reglementInfo['couleur'] as int),
icon: reglementInfo['icon_data'] as IconData,
));
} else {
// Fallback pour les types non définis
paymentDataList.add(PaymentData(
typeId: typeReglement,
title: 'Type inconnu',
amount: montant,
color: Colors.grey,
icon: Icons.help_outline,
));
}
}
});
return paymentDataList;
} catch (e) {
debugPrint('Erreur lors du calcul des données de règlement: $e');
return [];
}
}
/// Construit le graphique avec les données fournies
Widget _buildChart(List<PaymentData> paymentData) {
final chartData = _prepareChartData(paymentData);
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
@@ -170,7 +287,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
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;
@@ -181,11 +297,9 @@ class _PaymentPieChartState extends State<PaymentPieChart>
}
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);
@@ -196,18 +310,14 @@ class _PaymentPieChartState extends State<PaymentPieChart>
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition
.inside, // Afficher les étiquettes à l'intérieur du donut
labelPosition: ChartDataLabelPosition.inside,
textStyle: TextStyle(
fontSize: widget.labelSize,
color: Colors
.white, // Texte blanc pour meilleure lisibilité
fontWeight: FontWeight
.bold, // Texte en gras pour meilleure lisibilité
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
innerRadius: widget.innerRadius,
// Effet d'explosion plus prononcé pour donner du relief avec animation
explode: true,
explodeAll: widget.enableEnhancedExplode,
explodeIndex: widget.enableEnhancedExplode ? null : 0,
@@ -216,13 +326,10 @@ class _PaymentPieChartState extends State<PaymentPieChart>
? '${(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
animationDuration: 0,
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
)
@@ -232,7 +339,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
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;
@@ -243,11 +349,9 @@ class _PaymentPieChartState extends State<PaymentPieChart>
}
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);
@@ -265,7 +369,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
length: '15%',
),
),
// Effet d'explosion plus prononcé pour donner du relief avec animation
explode: true,
explodeAll: widget.enableEnhancedExplode,
explodeIndex: widget.enableEnhancedExplode ? null : 0,
@@ -274,40 +377,35 @@ class _PaymentPieChartState extends State<PaymentPieChart>
? '${(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
animationDuration: 0,
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
),
);
},
);
}
/// Ce 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);
/// Ppare les données pour le graphique en camembert
List<PaymentData> _prepareChartData(List<PaymentData> payments) {
// Filtrer les règlements avec un montant > 0
return payments.where((payment) => payment.amount > 0).toList();
}
// Augmenter la luminosité pour simuler un éclairage
/// Crée une couleur avec effet 3D en ajustant les nuances
Color _create3DColor(Color baseColor, double intensity) {
final hslColor = HSLColor.fromColor(baseColor);
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);
@@ -321,24 +419,17 @@ class _PaymentPieChartState extends State<PaymentPieChart>
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);
@@ -356,10 +447,7 @@ class _PaymentPieChartState extends State<PaymentPieChart>
/// 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);
@@ -371,21 +459,15 @@ class _PaymentPieChartState extends State<PaymentPieChart>
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(
@@ -394,11 +476,10 @@ class _PaymentPieChartState extends State<PaymentPieChart>
size: 16,
),
radius: '50%',
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
angle: (midAngle * (180 / 3.14159)).toInt(),
),
);
// Mettre à jour l'angle actuel
currentAngle += segmentAngle;
}

View File

@@ -0,0 +1,463 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/app.dart';
/// Widget commun pour afficher une carte de synthèse des règlements
/// avec liste des types à gauche et graphique en camembert à droite
class PaymentSummaryCard extends StatelessWidget {
/// Titre de la carte
final String title;
/// Couleur de l'icône et du titre
final Color titleColor;
/// Icône à afficher dans le titre
final IconData? titleIcon;
/// Hauteur totale de la carte
final double? height;
/// Utiliser ValueListenableBuilder pour mise à jour automatique
final bool useValueListenable;
/// ID de l'utilisateur pour filtrer les passages (si null, tous les utilisateurs)
final int? userId;
/// Afficher tous les règlements (admin) ou seulement ceux de l'utilisateur
final bool showAllPayments;
/// Données statiques de règlements par type (utilisé si useValueListenable = false)
final Map<int, double>? paymentsByType;
/// Fonction de callback pour afficher la valeur totale personnalisée
final String Function(double totalAmount)? customTotalDisplay;
/// Afficher le graphique en mode desktop ou mobile
final bool isDesktop;
/// Icône d'arrière-plan (optionnelle)
final IconData? backgroundIcon;
/// Couleur de l'icône d'arrière-plan
final Color? backgroundIconColor;
/// Opacité de l'icône d'arrière-plan
final double backgroundIconOpacity;
/// Taille de l'icône d'arrière-plan
final double backgroundIconSize;
const PaymentSummaryCard({
super.key,
required this.title,
this.titleColor = AppTheme.accentColor,
this.titleIcon = Icons.payments,
this.height,
this.useValueListenable = true,
this.userId,
this.showAllPayments = false,
this.paymentsByType,
this.customTotalDisplay,
this.isDesktop = true,
this.backgroundIcon = Icons.euro_symbol,
this.backgroundIconColor,
this.backgroundIconOpacity = 0.07,
this.backgroundIconSize = 180,
});
@override
Widget build(BuildContext context) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Stack(
children: [
// Icône d'arrière-plan (optionnelle)
if (backgroundIcon != null)
Positioned.fill(
child: Center(
child: Icon(
backgroundIcon,
size: backgroundIconSize,
color: (backgroundIconColor ?? Colors.blue).withOpacity(backgroundIconOpacity),
),
),
),
// Contenu principal
Container(
height: height,
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre avec comptage
useValueListenable
? _buildTitleWithValueListenable()
: _buildTitleWithStaticData(),
const Divider(height: 24),
// Contenu principal
Expanded(
child: SizedBox(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Liste des règlements à gauche
Expanded(
flex: isDesktop ? 1 : 2,
child: useValueListenable
? _buildPaymentsListWithValueListenable()
: _buildPaymentsListWithStaticData(),
),
// Séparateur vertical
if (isDesktop) const VerticalDivider(width: 24),
// Graphique en camembert à droite
Expanded(
flex: isDesktop ? 1 : 2,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PaymentPieChart(
useValueListenable: useValueListenable,
payments: useValueListenable ? [] : _convertMapToPaymentData(paymentsByType ?? {}),
userId: showAllPayments ? null : userId,
size: double.infinity,
labelSize: 12,
showPercentage: true,
showIcons: false,
showLegend: false,
isDonut: true,
innerRadius: '50%',
enable3DEffect: false,
effect3DIntensity: 0.0,
enableEnhancedExplode: false,
useGradient: false,
),
),
),
],
),
),
),
],
),
),
],
),
);
}
/// Construction du titre avec ValueListenableBuilder
Widget _buildTitleWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final paymentStats = _calculatePaymentStats(passagesBox);
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(paymentStats['totalAmount']) ??
'${paymentStats['totalAmount'].toStringAsFixed(2)}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
},
);
}
/// Construction du titre avec données statiques
Widget _buildTitleWithStaticData() {
final totalAmount = paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
return Row(
children: [
if (titleIcon != null) ...[
Icon(
titleIcon,
color: titleColor,
size: 24,
),
const SizedBox(width: 8),
],
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(totalAmount) ?? '${totalAmount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
}
/// Construction de la liste des règlements avec ValueListenableBuilder
Widget _buildPaymentsListWithValueListenable() {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final paymentAmounts = _calculatePaymentAmounts(passagesBox);
return _buildPaymentsList(paymentAmounts);
},
);
}
/// Construction de la liste des règlements avec données statiques
Widget _buildPaymentsListWithStaticData() {
return _buildPaymentsList(paymentsByType ?? {});
}
/// Construction de la liste des règlements
Widget _buildPaymentsList(Map<int, double> paymentAmounts) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...AppKeys.typesReglements.entries.map((entry) {
final int typeId = entry.key;
final Map<String, dynamic> typeData = entry.value;
final double amount = paymentAmounts[typeId] ?? 0.0;
final Color color = Color(typeData['couleur'] as int);
final IconData iconData = typeData['icon_data'] as IconData;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
iconData,
color: Colors.white,
size: 16,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
typeData['titre'] as String,
style: const TextStyle(fontSize: 14),
),
),
Text(
'${amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}).toList(),
],
);
}
/// Calcule les statistiques de règlement
Map<String, dynamic> _calculatePaymentStats(Box<PassageModel> passagesBox) {
if (showAllPayments) {
// Pour les administrateurs : tous les règlements
int passagesWithPaymentCount = 0;
double totalAmount = 0.0;
for (final passage in passagesBox.values) {
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
passagesWithPaymentCount++;
totalAmount += montant;
}
}
return {
'passagesCount': passagesWithPaymentCount,
'totalAmount': totalAmount,
};
} else {
// Pour les utilisateurs : seulement leurs règlements
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId == null) {
return {'passagesCount': 0, 'totalAmount': 0.0};
}
int passagesWithPaymentCount = 0;
double totalAmount = 0.0;
for (final passage in passagesBox.values) {
if (passage.fkUser == targetUserId) {
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
passagesWithPaymentCount++;
totalAmount += montant;
}
}
}
return {
'passagesCount': passagesWithPaymentCount,
'totalAmount': totalAmount,
};
}
}
/// Calcule les montants par type de règlement
Map<int, double> _calculatePaymentAmounts(Box<PassageModel> passagesBox) {
final Map<int, double> paymentAmounts = {};
// Initialiser tous les types
for (final typeId in AppKeys.typesReglements.keys) {
paymentAmounts[typeId] = 0.0;
}
if (showAllPayments) {
// Pour les administrateurs : compter tous les règlements
for (final passage in passagesBox.values) {
final int typeReglement = passage.fkTypeReglement;
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] = (paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
}
} else {
// Pour les utilisateurs : compter seulement leurs règlements
final currentUser = userRepository.getCurrentUser();
final targetUserId = userId ?? currentUser?.id;
if (targetUserId != null) {
for (final passage in passagesBox.values) {
if (passage.fkUser == targetUserId) {
final int typeReglement = passage.fkTypeReglement;
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
// Ignorer les erreurs de conversion
}
if (montant > 0) {
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] = (paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
}
}
}
}
return paymentAmounts;
}
/// Convertit une Map<int, double> en List<PaymentData> pour les données statiques
List<PaymentData> _convertMapToPaymentData(Map<int, double> paymentsMap) {
final List<PaymentData> paymentDataList = [];
paymentsMap.forEach((typeReglement, montant) {
if (montant > 0) { // Ne retourner que les types avec un montant > 0
// Récupérer les informations depuis AppKeys.typesReglements
final reglementInfo = AppKeys.typesReglements[typeReglement];
if (reglementInfo != null) {
paymentDataList.add(PaymentData(
typeId: typeReglement,
title: reglementInfo['titre'] as String,
amount: montant,
color: Color(reglementInfo['couleur'] as int),
icon: reglementInfo['icon_data'] as IconData,
));
} else {
// Fallback pour les types non définis
paymentDataList.add(PaymentData(
typeId: typeReglement,
title: 'Type inconnu',
amount: montant,
color: Colors.grey,
icon: Icons.help_outline,
));
}
}
});
return paymentDataList;
}
}

View File

@@ -1,33 +0,0 @@
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;
}
}