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>? 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 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; /// Si vrai, force le rechargement des données final bool forceRefresh; 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, this.forceRefresh = false, }) : assert(loadFromHive || passageData != null, 'Soit loadFromHive doit être true, soit passageData doit être fourni'); @override State createState() => _ActivityChartState(); } /// Classe pour stocker les données d'activité par date class ActivityData { final DateTime date; final String dateStr; final Map 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 with SingleTickerProviderStateMixin { late AnimationController _animationController; // Données pour les graphiques List> _passageData = []; List _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() { // 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; _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; 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(); } } // 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 uniqueDatesSet = {}; for (final data in _passageData) { if (data.containsKey('date') && data['date'] != null) { uniqueDatesSet.add(data['date'] as String); } } // Trier les dates final List uniqueDates = uniqueDatesSet.toList(); uniqueDates.sort(); // Créer les données pour chaque date _chartData = []; for (final dateStr in uniqueDates) { final passagesByType = {}; // 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: [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> _buildSeries() { final List> 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( 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; } }