Files
geo/app/lib/presentation/widgets/charts/payment_summary_card.dart

491 lines
16 KiB
Dart
Executable File

import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.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: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';
/// 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 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 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
State<PaymentSummaryCard> createState() => _PaymentSummaryCardState();
}
class _PaymentSummaryCardState extends State<PaymentSummaryCard>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
_animationController.forward();
}
@override
void didUpdateWidget(PaymentSummaryCard oldWidget) {
super.didUpdateWidget(oldWidget);
// Relancer l'animation si les paramètres importants ont changé
final bool shouldResetAnimation = oldWidget.userId != widget.userId ||
oldWidget.showAllPayments != widget.showAllPayments ||
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<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
// Calculer les données une seule fois
final paymentAmounts = _calculatePaymentAmounts(passagesBox);
final totalAmount = paymentAmounts.values.fold(0.0, (sum, amount) => sum + amount);
return _buildCardContent(
context,
totalAmount: totalAmount,
paymentAmounts: paymentAmounts,
);
},
);
} else {
// Données statiques
final totalAmount = widget.paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
return _buildCardContent(
context,
totalAmount: totalAmount,
paymentAmounts: widget.paymentsByType ?? {},
);
}
}
/// Construit le contenu de la card avec les données calculées
Widget _buildCardContent(
BuildContext context, {
required double totalAmount,
required Map<int, double> paymentAmounts,
}) {
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 ?? Colors.blue)
.withValues(alpha: 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, totalAmount),
const Divider(height: 24),
// Contenu principal
Expanded(
child: SizedBox(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Liste des règlements à gauche
Expanded(
flex: widget.isDesktop ? 1 : 2,
child: _buildPaymentsList(context, paymentAmounts),
),
// 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(paymentAmounts),
),
),
],
),
),
),
],
),
),
],
),
);
}
/// Construction du titre
Widget _buildTitle(BuildContext context, double totalAmount) {
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(totalAmount) ??
'${totalAmount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold,
color: widget.titleColor,
),
),
],
);
}
/// Gérer le clic sur un type de règlement
void _handlePaymentTypeClick(int typeId) {
// Réinitialiser TOUS les filtres avant de sauvegarder le nouveau
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.delete('history_selectedTypeId');
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 règlement sélectionné
settingsBox.put('history_selectedPaymentTypeId', 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 règlements (avec clics)
Widget _buildPaymentsList(BuildContext context, 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: InkWell(
onTap: () => _handlePaymentTypeClick(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['titre'] as String,
style: TextStyle(fontSize: AppTheme.r(context, 14)),
),
),
Text(
'${amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: AppTheme.r(context, 16),
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
),
),
);
}),
],
);
}
/// Construction du graphique en camembert (avec clics)
Widget _buildPieChart(Map<int, double> paymentAmounts) {
final chartData = _prepareChartDataFromMap(paymentAmounts);
// 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];
_handlePaymentTypeClick(selectedData.typeId);
}
},
series: <CircularSeries>[
DoughnutSeries<PaymentData, String>(
dataSource: chartData,
xValueMapper: (PaymentData data, _) => data.title,
yValueMapper: (PaymentData data, _) => data.amount,
pointColorMapper: (PaymentData data, _) => data.color,
enableTooltip: true,
selectionBehavior: SelectionBehavior(
enable: true,
selectedColor: null, // Garde la couleur d'origine
unselectedOpacity: 0.5,
),
dataLabelMapper: (PaymentData data, _) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(0.0, (sum, item) => sum + item.amount);
final percentage = (data.amount / total * 100);
return '${percentage.toStringAsFixed(1)}%';
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.inside,
textStyle: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
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 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;
}
// En mode user, filtrer uniquement les passages créés par l'utilisateur (fkUser)
final currentUser = userRepository.getCurrentUser();
final int? filterUserId = widget.showAllPayments ? null : currentUser?.id;
for (final passage in passagesBox.values) {
// En mode user, ne compter que les passages de l'utilisateur
if (filterUserId != null && passage.fkUser != filterUserId) {
continue;
}
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;
}
/// Prépare les données pour le graphique en camembert à partir d'une Map
List<PaymentData> _prepareChartDataFromMap(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;
}
}