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'; /// 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; 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, }); @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; @override void initState() { super.initState(); _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); 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) { try { 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> 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 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: [ // Titre 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 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 (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; } // 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, ), ); } } return series; } }