import 'package:flutter/material.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/theme/app_theme.dart'; import 'package:geosector_app/core/services/current_user_service.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:geosector_app/core/data/models/passage_model.dart'; import 'package:geosector_app/app.dart'; import 'package:go_router/go_router.dart'; /// Modèle de données pour le graphique en camembert des passages class PassageChartData { final int typeId; final int count; final String title; final Color color; final IconData icon; PassageChartData({ required this.typeId, required this.count, required this.title, required this.color, required this.icon, }); } /// Widget commun pour afficher une carte de synthèse des passages /// avec liste des types à gauche et graphique en camembert à droite class PassageSummaryCard extends StatefulWidget { /// 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 excludePassageTypes; /// Données statiques de passages par type (utilisé si useValueListenable = false) final Map? 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 State createState() => _PassageSummaryCardState(); } class _PassageSummaryCardState extends State with SingleTickerProviderStateMixin { late AnimationController _animationController; @override void initState() { super.initState(); _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 2000), ); _animationController.forward(); } @override void didUpdateWidget(PassageSummaryCard oldWidget) { super.didUpdateWidget(oldWidget); // 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; if (shouldResetAnimation) { _animationController.reset(); _animationController.forward(); } } @override void dispose() { _animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { // Si useValueListenable, construire avec ValueListenableBuilder centralisé if (widget.useValueListenable) { return ValueListenableBuilder( valueListenable: Hive.box(AppKeys.passagesBoxName).listenable(), builder: (context, Box passagesBox, child) { // Calculer les données une seule fois final passagesCounts = _calculatePassagesCounts(passagesBox); final totalUserPassages = passagesCounts.values.fold(0, (sum, count) => sum + count); return _buildCardContent( context, totalUserPassages: totalUserPassages, passagesCounts: passagesCounts, ); }, ); } else { // Données statiques final totalPassages = widget.passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0; return _buildCardContent( context, totalUserPassages: totalPassages, passagesCounts: widget.passagesByType ?? {}, ); } } /// Construit le contenu de la card avec les données calculées Widget _buildCardContent( BuildContext context, { required int totalUserPassages, required Map passagesCounts, }) { return Card( elevation: 4, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), child: Stack( children: [ // Icône d'arrière-plan (optionnelle) if (widget.backgroundIcon != null) Positioned.fill( child: Center( child: Icon( widget.backgroundIcon, size: widget.backgroundIconSize, color: (widget.backgroundIconColor ?? AppTheme.primaryColor) .withOpacity(widget.backgroundIconOpacity), ), ), ), // Contenu principal Container( height: widget.height, padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Titre avec comptage _buildTitle(context, totalUserPassages), const Divider(height: 24), // Contenu principal Expanded( child: SizedBox( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Liste des passages à gauche Expanded( flex: widget.isDesktop ? 1 : 2, child: _buildPassagesList(context, passagesCounts), ), // Séparateur vertical if (widget.isDesktop) const VerticalDivider(width: 24), // Graphique en camembert à droite Expanded( flex: widget.isDesktop ? 1 : 2, child: Padding( padding: const EdgeInsets.all(8.0), child: _buildPieChart(passagesCounts), ), ), ], ), ), ), ], ), ), ], ), ); } /// Construction du titre Widget _buildTitle(BuildContext context, int totalUserPassages) { return Row( children: [ if (widget.titleIcon != null) ...[ Icon( widget.titleIcon, color: widget.titleColor, size: 24, ), const SizedBox(width: 8), ], Expanded( child: Text( widget.title, style: TextStyle( fontSize: AppTheme.r(context, 16), fontWeight: FontWeight.bold, ), ), ), Text( widget.customTotalDisplay?.call(totalUserPassages) ?? totalUserPassages.toString(), style: TextStyle( fontSize: AppTheme.r(context, 20), fontWeight: FontWeight.bold, color: widget.titleColor, ), ), ], ); } /// Gérer le clic sur un type de passage void _handlePassageTypeClick(int typeId) { // Réinitialiser TOUS les filtres avant de sauvegarder le nouveau final settingsBox = Hive.box(AppKeys.settingsBoxName); settingsBox.delete('history_selectedPaymentTypeId'); settingsBox.delete('history_selectedSectorId'); settingsBox.delete('history_selectedSectorName'); settingsBox.delete('history_selectedMemberId'); settingsBox.delete('history_startDate'); settingsBox.delete('history_endDate'); // Sauvegarder uniquement le type de passage sélectionné settingsBox.put('history_selectedTypeId', typeId); // Naviguer directement vers la page historique final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI; context.go(isAdmin ? '/admin/history' : '/user/history'); } /// Construction de la liste des passages (avec clics) Widget _buildPassagesList(BuildContext context, Map passagesCounts) { // Vérifier si le type Lot doit être affiché bool showLotType = true; final currentUser = userRepository.getCurrentUser(); if (currentUser != null && currentUser.fkEntite != null) { final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!); if (userAmicale != null) { showLotType = userAmicale.chkLotActif; } } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ...AppKeys.typesPassages.entries.where((entry) { // Exclure le type Lot (5) si chkLotActif = false if (entry.key == 5 && !showLotType) { return false; } return true; }).map((entry) { final int typeId = entry.key; final Map 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: InkWell( onTap: () => _handlePassageTypeClick(typeId), borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 4.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: TextStyle(fontSize: AppTheme.r(context, 14)), ), ), Text( count.toString(), style: TextStyle( fontSize: AppTheme.r(context, 16), fontWeight: FontWeight.bold, color: color, ), ), ], ), ), ), ); }), ], ); } /// Construction du graphique en camembert (avec clics) Widget _buildPieChart(Map passagesCounts) { final chartData = _prepareChartDataFromMap(passagesCounts); // Si aucune donnée, afficher un message if (chartData.isEmpty) { return const Center( child: Text('Aucune donnée disponible'), ); } // Créer des animations final progressAnimation = CurvedAnimation( parent: _animationController, curve: Curves.easeOutCubic, ); final explodeAnimation = CurvedAnimation( parent: _animationController, curve: const Interval(0.7, 1.0, curve: Curves.elasticOut), ); final opacityAnimation = CurvedAnimation( parent: _animationController, curve: const Interval(0.1, 0.5, curve: Curves.easeIn), ); return AnimatedBuilder( animation: _animationController, builder: (context, child) { return SfCircularChart( margin: EdgeInsets.zero, legend: Legend( isVisible: false, position: LegendPosition.bottom, overflowMode: LegendItemOverflowMode.wrap, textStyle: const TextStyle(fontSize: 12), ), tooltipBehavior: TooltipBehavior(enable: true), onSelectionChanged: (SelectionArgs args) { // Gérer le clic sur un segment du graphique final pointIndex = args.pointIndex; if (pointIndex < chartData.length) { final selectedData = chartData[pointIndex]; _handlePassageTypeClick(selectedData.typeId); } }, series: [ DoughnutSeries( dataSource: chartData, xValueMapper: (PassageChartData data, _) => data.title, yValueMapper: (PassageChartData data, _) => data.count, pointColorMapper: (PassageChartData data, _) => data.color, enableTooltip: true, selectionBehavior: SelectionBehavior( enable: true, selectedColor: null, // Garde la couleur d'origine unselectedOpacity: 0.5, ), dataLabelMapper: (PassageChartData data, _) { // Calculer le pourcentage avec une décimale final total = chartData.fold(0, (sum, item) => sum + item.count); final percentage = (data.count / total * 100); return '${percentage.toStringAsFixed(1)}%'; }, dataLabelSettings: DataLabelSettings( isVisible: true, labelPosition: ChartDataLabelPosition.outside, textStyle: const TextStyle(fontSize: 12), connectorLineSettings: const ConnectorLineSettings( type: ConnectorType.curve, length: '15%', ), ), innerRadius: '50%', explode: true, explodeIndex: 0, explodeOffset: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%', opacity: opacityAnimation.value, animationDuration: 0, startAngle: 270, endAngle: 270 + (360 * progressAnimation.value).toInt(), ), ], ); }, ); } /// Calcule les compteurs de passages par type Map _calculatePassagesCounts(Box passagesBox) { final Map counts = {}; // Vérifier si le type Lot doit être affiché bool showLotType = true; final currentUser = userRepository.getCurrentUser(); if (currentUser != null && currentUser.fkEntite != null) { final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!); if (userAmicale != null) { showLotType = userAmicale.chkLotActif; } } // Initialiser tous les types for (final typeId in AppKeys.typesPassages.keys) { // Exclure le type Lot (5) si chkLotActif = false if (typeId == 5 && !showLotType) { continue; } // Exclure les types non désirés if (widget.excludePassageTypes.contains(typeId)) { continue; } counts[typeId] = 0; } // L'API filtre déjà les passages côté serveur // On compte simplement tous les passages de la box for (final passage in passagesBox.values) { // Exclure le type Lot (5) si chkLotActif = false if (passage.fkType == 5 && !showLotType) { continue; } // Exclure les types non désirés if (widget.excludePassageTypes.contains(passage.fkType)) { continue; } counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1; } return counts; } /// Prépare les données pour le graphique en camembert à partir d'une Map List _prepareChartDataFromMap(Map passagesByType) { final List chartData = []; // Vérifier si le type Lot doit être affiché bool showLotType = true; final currentUser = CurrentUserService.instance.currentUser; if (currentUser != null && currentUser.fkEntite != null) { final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!); if (userAmicale != null) { showLotType = userAmicale.chkLotActif; } } // Créer les données du graphique passagesByType.forEach((typeId, count) { // Exclure le type Lot (5) si chkLotActif = false if (typeId == 5 && !showLotType) { return; // Skip ce type } // 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; } }