import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales import 'package:flutter/material.dart'; 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:hive_flutter/hive_flutter.dart'; import 'package:geosector_app/core/data/models/passage_model.dart'; import 'package:geosector_app/core/services/current_user_service.dart'; import 'package:go_router/go_router.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>? 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; /// 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; /// Utiliser ValueListenableBuilder pour la mise à jour automatique final bool useValueListenable; /// Afficher les boutons de sélection de période (7j, 14j, 21j) final bool showPeriodButtons; const ActivityChart({ super.key, this.passageData, this.periodType = 'Jour', this.height = 350, this.daysToShow = 15, this.userId, this.excludePassageTypes = const [2], 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.useValueListenable = true, this.showPeriodButtons = false, }); @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; // Contrôleur de zoom pour le graphique late ZoomPanBehavior _zoomPanBehavior; // Période sélectionnée pour le filtre (7, 14 ou 21 jours) late int _selectedDays; @override void initState() { super.initState(); _selectedDays = widget.daysToShow; _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1500), ); // Initialiser le contrôleur de zoom _zoomPanBehavior = ZoomPanBehavior( enablePinching: true, enableDoubleTapZooming: true, enablePanning: true, zoomMode: ZoomMode.x, ); _animationController.forward(); } @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.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; // Si des paramètres importants ont changé, relancer l'animation if (periodChanged || dataSourceChanged || filteringChanged) { _animationController.reset(); _animationController.forward(); } } @override void dispose() { _animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { if (widget.useValueListenable) { return _buildWithValueListenable(); } else { return _buildWithStaticData(); } } /// Construction du widget avec ValueListenableBuilder pour mise à jour automatique Widget _buildWithValueListenable() { return ValueListenableBuilder( valueListenable: Hive.box(AppKeys.passagesBoxName).listenable(), builder: (context, Box passagesBox, child) { final chartData = _calculateActivityData(passagesBox, _selectedDays); return _buildChart(chartData); }, ); } /// 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 _calculateActivityData( Box passagesBox, int daysToShow) { try { final passages = passagesBox.values.toList(); final currentUser = userRepository.getCurrentUser(); // Pour les users : récupérer les secteurs assignés Set? userSectorIds; if (!widget.showAllPassages && currentUser != null) { final userSectors = userRepository.getUserSectors(); userSectorIds = userSectors.map((sector) => sector.id).toSet(); debugPrint( 'ActivityChart: Mode USER - Secteurs assignés: $userSectorIds'); } else { debugPrint('ActivityChart: Mode ADMIN - Tous les passages'); } // Calculer la date de début (nombre de jours en arrière) final endDate = DateTime.now(); final startDate = endDate.subtract(Duration(days: daysToShow - 1)); debugPrint( 'ActivityChart: Période du ${DateFormat('yyyy-MM-dd').format(startDate)} au ${DateFormat('yyyy-MM-dd').format(endDate)}'); debugPrint('ActivityChart: Nombre total de passages: ${passages.length}'); // Préparer les données par date final Map> dataByDate = {}; // Initialiser toutes les dates de la période for (int i = 0; i < 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) { dataByDate[dateStr]![typeId] = 0; } } // Parcourir les passages et les compter par date et type int includedCount = 0; for (final passage in passages) { // Appliquer les filtres bool shouldInclude = true; // Filtrer par secteurs assignés si nécessaire (pour les users) if (userSectorIds != null && !userSectorIds.contains(passage.fkSector)) { shouldInclude = false; } // Exclure les passages de type 2 (À finaliser) avec nbPassages = 0 if (shouldInclude && passage.fkType == 2 && passage.nbPassages == 0) { shouldInclude = false; } // Vérifier si le passage est dans la période final passageDate = passage.passedAt; if (shouldInclude && (passageDate == null || passageDate.isBefore(startDate) || passageDate.isAfter(endDate))) { shouldInclude = false; } if (shouldInclude && passageDate != null) { final dateStr = DateFormat('yyyy-MM-dd').format(passageDate); if (dataByDate.containsKey(dateStr)) { dataByDate[dateStr]![passage.fkType] = (dataByDate[dateStr]![passage.fkType] ?? 0) + 1; includedCount++; } } // Debug désactivé pour éviter la pollution de la console avec les passages type 2 sans date // else if (!shouldInclude && userSectorIds != null) { // debugPrint( // 'ActivityChart: Passage #${passage.id} exclu - $excludeReason (type=${passage.fkType}, secteur=${passage.fkSector}, date=${passageDate != null ? DateFormat('yyyy-MM-dd').format(passageDate) : 'null'})'); // } } debugPrint( 'ActivityChart: Passages inclus dans le graphique: $includedCount'); // Convertir en liste d'ActivityData final List 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 _prepareChartDataFromPassageData( List> passageData) { try { // 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 final List 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) { debugPrint('Erreur de conversion de date: $dateStr'); } } // Trier les données par date chartData.sort((a, b) => a.date.compareTo(b.date)); return chartData; } catch (e) { debugPrint('Erreur lors de la préparation des données: $e'); return []; } } /// Construit le graphique avec les données fournies Widget _buildChart(List chartData) { if (chartData.isEmpty) { return SizedBox( height: widget.height, child: const Center( child: Text('Aucune donnée disponible'), ), ); } return SizedBox( height: widget.height, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // En-tête avec titre et boutons de filtre if (widget.title.isNotEmpty) Padding( padding: const EdgeInsets.only( left: 16.0, right: 16.0, top: 16.0, bottom: 8.0), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( widget.title, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), if (widget.showPeriodButtons) Row( mainAxisSize: MainAxisSize.min, children: [ _buildPeriodButton(7), const SizedBox(width: 4), _buildPeriodButton(14), const SizedBox(width: 4), _buildPeriodButton(21), ], ), ], ), ), // Graphique Expanded( child: Padding( padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0), child: SfCartesianChart( plotAreaBorderWidth: 0, legend: const 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), minimum: chartData.isNotEmpty ? chartData.first.date : null, maximum: chartData.isNotEmpty ? chartData.last.date : null, interval: 1, ), primaryYAxis: const NumericAxis( labelStyle: TextStyle(fontSize: 10), axisLine: AxisLine(width: 0), majorTickLines: MajorTickLines(size: 0), majorGridLines: MajorGridLines( width: 0.5, color: Colors.grey, dashArray: [5, 5], ), title: AxisTitle( text: 'Passages', textStyle: TextStyle(fontSize: 10, color: Colors.grey), ), ), series: _buildSeries(chartData), tooltipBehavior: TooltipBehavior(enable: true), zoomPanBehavior: _zoomPanBehavior, ), ), ), ], ), ); } /// Construit les séries de données pour le graphique List> _buildSeries( List chartData) { final List> series = []; // Vérifier que les données sont disponibles if (chartData.isEmpty) { return series; } // Obtenir tous les types de passage final passageTypes = AppKeys.typesPassages.keys.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; } // Ajouter la série pour ce type si elle a des données if (totalForType > 0) { series.add( StackedColumnSeries( name: typeName, dataSource: chartData, xValueMapper: (ActivityData data, _) => data.date, yValueMapper: (ActivityData data, _) { return data.passagesByType[typeId] ?? 0; }, 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, // Ajouter le callback de clic uniquement depuis home_page onPointTap: widget.showPeriodButtons ? (ChartPointDetails details) { _handlePointTap(details, typeId); } : null, ), ); } } return series; } /// Gère le clic sur un point du graphique void _handlePointTap(ChartPointDetails details, int typeId) { if (details.pointIndex == null || details.pointIndex! < 0) return; // Récupérer les données du point cliqué final passageBox = Hive.box(AppKeys.passagesBoxName); // Créer les données d'activité final chartData = _calculateActivityData(passageBox, _selectedDays); if (details.pointIndex! >= chartData.length) return; final clickedData = chartData[details.pointIndex!]; final clickedDate = clickedData.date; // Réinitialiser tous les filtres sauf celui sélectionné final settingsBox = Hive.box(AppKeys.settingsBoxName); settingsBox.delete('history_selectedPaymentTypeId'); settingsBox.delete('history_selectedSectorId'); settingsBox.delete('history_selectedSectorName'); settingsBox.delete('history_selectedMemberId'); // Sauvegarder le type de passage et les dates (début et fin de journée) settingsBox.put('history_selectedTypeId', typeId); // Date de début : début de la journée cliquée final startDateTime = DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 0, 0, 0); settingsBox.put('history_startDate', startDateTime.millisecondsSinceEpoch); // Date de fin : fin de la journée cliquée final endDateTime = DateTime( clickedDate.year, clickedDate.month, clickedDate.day, 23, 59, 59); settingsBox.put('history_endDate', endDateTime.millisecondsSinceEpoch); // Naviguer vers la page historique final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI; context.go(isAdmin ? '/admin/history' : '/user/history'); } /// Construit un bouton de sélection de période Widget _buildPeriodButton(int days) { final isSelected = _selectedDays == days; return InkWell( onTap: () { setState(() { _selectedDays = days; _animationController.reset(); _animationController.forward(); }); widget.onPeriodChanged?.call(days); }, borderRadius: BorderRadius.circular(4), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: isSelected ? Theme.of(context).colorScheme.primary : Colors.grey.withOpacity(0.1), borderRadius: BorderRadius.circular(4), border: Border.all( color: isSelected ? Theme.of(context).colorScheme.primary : Colors.grey.shade400, width: 1, ), ), child: Text( '${days}j', style: TextStyle( fontSize: 12, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, color: isSelected ? Colors.white : Colors.grey.shade700, ), ), ), ); } }