489 lines
17 KiB
Dart
489 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:syncfusion_flutter_charts/charts.dart';
|
|
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
|
|
import 'package:hive_flutter/hive_flutter.dart';
|
|
import 'package:geosector_app/core/data/models/passage_model.dart';
|
|
import 'package:geosector_app/core/constants/app_keys.dart';
|
|
import 'package:geosector_app/app.dart';
|
|
import 'dart:math' as math;
|
|
|
|
/// Widget de graphique en camembert pour représenter la répartition des règlements
|
|
class PaymentPieChart extends StatefulWidget {
|
|
/// Liste des données de règlement à afficher dans le graphique
|
|
/// Si useValueListenable est true, ce paramètre est ignoré
|
|
final List<PaymentData> payments;
|
|
|
|
/// Taille du graphique
|
|
final double size;
|
|
|
|
/// Taille des étiquettes
|
|
final double labelSize;
|
|
|
|
/// Afficher les pourcentages
|
|
final bool showPercentage;
|
|
|
|
/// Afficher les icônes
|
|
final bool showIcons;
|
|
|
|
/// Afficher la légende
|
|
final bool showLegend;
|
|
|
|
/// Format donut (anneau)
|
|
final bool isDonut;
|
|
|
|
/// Rayon central pour le format donut (en pourcentage)
|
|
final String innerRadius;
|
|
|
|
/// Activer l'effet 3D
|
|
final bool enable3DEffect;
|
|
|
|
/// Intensité de l'effet 3D (1.0 = normal, 2.0 = fort)
|
|
final double effect3DIntensity;
|
|
|
|
/// Activer l'effet d'explosion plus prononcé
|
|
final bool enableEnhancedExplode;
|
|
|
|
/// Utiliser un dégradé pour simuler l'effet 3D
|
|
final bool useGradient;
|
|
|
|
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
|
|
final bool useValueListenable;
|
|
|
|
/// ID de l'utilisateur pour filtrer les passages
|
|
final int? userId;
|
|
|
|
const PaymentPieChart({
|
|
super.key,
|
|
this.payments = const [],
|
|
this.size = 300,
|
|
this.labelSize = 12,
|
|
this.showPercentage = true,
|
|
this.showIcons = true,
|
|
this.showLegend = true,
|
|
this.isDonut = false,
|
|
this.innerRadius = '40%',
|
|
this.enable3DEffect = false,
|
|
this.effect3DIntensity = 1.0,
|
|
this.enableEnhancedExplode = false,
|
|
this.useGradient = false,
|
|
this.useValueListenable = true,
|
|
this.userId,
|
|
});
|
|
|
|
@override
|
|
State<PaymentPieChart> createState() => _PaymentPieChartState();
|
|
}
|
|
|
|
class _PaymentPieChartState extends State<PaymentPieChart>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_animationController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 2000),
|
|
);
|
|
|
|
_animationController.forward();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(PaymentPieChart oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
|
|
// Relancer l'animation si les paramètres importants ont changé
|
|
bool shouldResetAnimation = false;
|
|
|
|
if (widget.useValueListenable != oldWidget.useValueListenable ||
|
|
widget.userId != oldWidget.userId) {
|
|
shouldResetAnimation = true;
|
|
} else if (!widget.useValueListenable) {
|
|
// Pour les données statiques, comparer les éléments
|
|
if (oldWidget.payments.length != widget.payments.length) {
|
|
shouldResetAnimation = true;
|
|
} else {
|
|
for (int i = 0; i < oldWidget.payments.length; i++) {
|
|
if (i >= widget.payments.length) break;
|
|
if (oldWidget.payments[i].amount != widget.payments[i].amount ||
|
|
oldWidget.payments[i].title != widget.payments[i].title) {
|
|
shouldResetAnimation = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (shouldResetAnimation) {
|
|
_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<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
|
builder: (context, Box<PassageModel> passagesBox, child) {
|
|
final paymentData = _calculatePaymentData(passagesBox);
|
|
return _buildChart(paymentData);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Construction du widget avec des données statiques
|
|
Widget _buildWithStaticData() {
|
|
return _buildChart(widget.payments);
|
|
}
|
|
|
|
/// Calcule les données de règlement depuis la Hive box
|
|
List<PaymentData> _calculatePaymentData(Box<PassageModel> passagesBox) {
|
|
try {
|
|
final passages = passagesBox.values.toList();
|
|
final currentUser = userRepository.getCurrentUser();
|
|
final int? currentUserId = widget.userId ?? currentUser?.id;
|
|
|
|
// Initialiser les montants par type de règlement
|
|
final Map<int, double> paymentAmounts = {
|
|
0: 0.0, // Pas de règlement
|
|
1: 0.0, // Espèces
|
|
2: 0.0, // Chèques
|
|
3: 0.0, // CB
|
|
};
|
|
|
|
// Parcourir les passages et calculer les montants par type de règlement
|
|
for (final passage in passages) {
|
|
// Vérifier si le passage appartient à l'utilisateur actuel
|
|
if (currentUserId != null && passage.fkUser == currentUserId) {
|
|
final int typeReglement = passage.fkTypeReglement;
|
|
|
|
// Convertir la chaîne de montant en double
|
|
double montant = 0.0;
|
|
try {
|
|
// Gérer les formats possibles (virgule ou point)
|
|
String montantStr = passage.montant.replaceAll(',', '.');
|
|
montant = double.tryParse(montantStr) ?? 0.0;
|
|
} catch (e) {
|
|
debugPrint('Erreur de conversion du montant: ${passage.montant}');
|
|
}
|
|
|
|
// Ne compter que les passages avec un montant > 0
|
|
if (montant > 0) {
|
|
// Ajouter au montant total par type de règlement
|
|
if (paymentAmounts.containsKey(typeReglement)) {
|
|
paymentAmounts[typeReglement] =
|
|
(paymentAmounts[typeReglement] ?? 0.0) + montant;
|
|
} else {
|
|
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut
|
|
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convertir le Map en List<PaymentData>
|
|
final List<PaymentData> paymentDataList = [];
|
|
|
|
paymentAmounts.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;
|
|
} catch (e) {
|
|
debugPrint('Erreur lors du calcul des données de règlement: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Construit le graphique avec les données fournies
|
|
Widget _buildChart(List<PaymentData> paymentData) {
|
|
final chartData = _prepareChartData(paymentData);
|
|
|
|
// Si aucune donnée, afficher un message
|
|
if (chartData.isEmpty) {
|
|
return SizedBox(
|
|
width: widget.size,
|
|
height: widget.size,
|
|
child: const Center(
|
|
child: Text('Aucune donnée disponible'),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Créer des animations pour différents aspects du graphique
|
|
final progressAnimation = CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: Curves.easeOutCubic,
|
|
);
|
|
|
|
final explodeAnimation = CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
|
|
);
|
|
|
|
final opacityAnimation = CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
|
|
);
|
|
|
|
return AnimatedBuilder(
|
|
animation: _animationController,
|
|
builder: (context, child) {
|
|
return SizedBox(
|
|
width: widget.size,
|
|
height: widget.size,
|
|
child: SfCircularChart(
|
|
margin: EdgeInsets.zero,
|
|
legend: Legend(
|
|
isVisible: widget.showLegend,
|
|
position: LegendPosition.bottom,
|
|
overflowMode: LegendItemOverflowMode.wrap,
|
|
textStyle: TextStyle(fontSize: widget.labelSize),
|
|
),
|
|
tooltipBehavior: TooltipBehavior(enable: true),
|
|
series: <CircularSeries>[
|
|
widget.isDonut
|
|
? DoughnutSeries<PaymentData, String>(
|
|
dataSource: chartData,
|
|
xValueMapper: (PaymentData data, _) => data.title,
|
|
yValueMapper: (PaymentData data, _) => data.amount,
|
|
pointColorMapper: (PaymentData data, _) {
|
|
if (widget.enable3DEffect) {
|
|
final index = chartData.indexOf(data);
|
|
final angle =
|
|
(index / chartData.length) * 2 * math.pi;
|
|
return widget.useGradient
|
|
? _createEnhanced3DColor(data.color, angle)
|
|
: _create3DColor(
|
|
data.color, widget.effect3DIntensity);
|
|
}
|
|
return data.color;
|
|
},
|
|
enableTooltip: true,
|
|
dataLabelMapper: (PaymentData data, _) {
|
|
if (widget.showPercentage) {
|
|
final total = chartData.fold(
|
|
0.0, (sum, item) => sum + item.amount);
|
|
final percentage = (data.amount / total * 100);
|
|
return '${percentage.toStringAsFixed(1)}%';
|
|
} else {
|
|
return data.title;
|
|
}
|
|
},
|
|
dataLabelSettings: DataLabelSettings(
|
|
isVisible: true,
|
|
labelPosition: ChartDataLabelPosition.inside,
|
|
textStyle: TextStyle(
|
|
fontSize: widget.labelSize,
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
innerRadius: widget.innerRadius,
|
|
explode: true,
|
|
explodeAll: widget.enableEnhancedExplode,
|
|
explodeIndex: widget.enableEnhancedExplode ? null : 0,
|
|
explodeOffset: widget.enableEnhancedExplode
|
|
? widget.enable3DEffect
|
|
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
|
|
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
|
|
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
|
opacity: widget.enable3DEffect
|
|
? 0.95 * opacityAnimation.value
|
|
: opacityAnimation.value,
|
|
animationDuration: 0,
|
|
startAngle: 270,
|
|
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
|
)
|
|
: PieSeries<PaymentData, String>(
|
|
dataSource: chartData,
|
|
xValueMapper: (PaymentData data, _) => data.title,
|
|
yValueMapper: (PaymentData data, _) => data.amount,
|
|
pointColorMapper: (PaymentData data, _) {
|
|
if (widget.enable3DEffect) {
|
|
final index = chartData.indexOf(data);
|
|
final angle =
|
|
(index / chartData.length) * 2 * math.pi;
|
|
return widget.useGradient
|
|
? _createEnhanced3DColor(data.color, angle)
|
|
: _create3DColor(
|
|
data.color, widget.effect3DIntensity);
|
|
}
|
|
return data.color;
|
|
},
|
|
enableTooltip: true,
|
|
dataLabelMapper: (PaymentData data, _) {
|
|
if (widget.showPercentage) {
|
|
final total = chartData.fold(
|
|
0.0, (sum, item) => sum + item.amount);
|
|
final percentage = (data.amount / total * 100);
|
|
return '${percentage.toStringAsFixed(1)}%';
|
|
} else {
|
|
return data.title;
|
|
}
|
|
},
|
|
dataLabelSettings: DataLabelSettings(
|
|
isVisible: true,
|
|
labelPosition: ChartDataLabelPosition.outside,
|
|
textStyle: TextStyle(fontSize: widget.labelSize),
|
|
connectorLineSettings: const ConnectorLineSettings(
|
|
type: ConnectorType.curve,
|
|
length: '15%',
|
|
),
|
|
),
|
|
explode: true,
|
|
explodeAll: widget.enableEnhancedExplode,
|
|
explodeIndex: widget.enableEnhancedExplode ? null : 0,
|
|
explodeOffset: widget.enableEnhancedExplode
|
|
? widget.enable3DEffect
|
|
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
|
|
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
|
|
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
|
opacity: widget.enable3DEffect
|
|
? 0.95 * opacityAnimation.value
|
|
: opacityAnimation.value,
|
|
animationDuration: 0,
|
|
startAngle: 270,
|
|
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
|
),
|
|
],
|
|
annotations:
|
|
widget.showIcons ? _buildIconAnnotations(chartData) : null,
|
|
palette: widget.enable3DEffect ? _create3DPalette(chartData) : null,
|
|
borderWidth: widget.enable3DEffect ? 0.5 : 0,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Prépare les données pour le graphique en camembert
|
|
List<PaymentData> _prepareChartData(List<PaymentData> payments) {
|
|
// Filtrer les règlements avec un montant > 0
|
|
return payments.where((payment) => payment.amount > 0).toList();
|
|
}
|
|
|
|
/// Crée une couleur avec effet 3D en ajustant les nuances
|
|
Color _create3DColor(Color baseColor, double intensity) {
|
|
final hslColor = HSLColor.fromColor(baseColor);
|
|
final adjustedLightness =
|
|
(hslColor.lightness + 0.15 * intensity).clamp(0.0, 1.0);
|
|
final adjustedSaturation =
|
|
(hslColor.saturation + 0.05 * intensity).clamp(0.0, 1.0);
|
|
|
|
return hslColor
|
|
.withLightness(adjustedLightness)
|
|
.withSaturation(adjustedSaturation)
|
|
.toColor();
|
|
}
|
|
|
|
/// Crée une palette de couleurs pour l'effet 3D
|
|
List<Color> _create3DPalette(List<PaymentData> chartData) {
|
|
List<Color> palette = [];
|
|
|
|
for (var i = 0; i < chartData.length; i++) {
|
|
var data = chartData[i];
|
|
final angle = (i / chartData.length) * 2 * math.pi;
|
|
final hslColor = HSLColor.fromColor(data.color);
|
|
|
|
final lightAdjustment = 0.15 * widget.effect3DIntensity * math.sin(angle);
|
|
final adjustedLightness = (hslColor.lightness -
|
|
0.1 * widget.effect3DIntensity +
|
|
lightAdjustment)
|
|
.clamp(0.0, 1.0);
|
|
|
|
final adjustedSaturation =
|
|
(hslColor.saturation + 0.1 * widget.effect3DIntensity)
|
|
.clamp(0.0, 1.0);
|
|
|
|
final enhancedColor = hslColor
|
|
.withLightness(adjustedLightness)
|
|
.withSaturation(adjustedSaturation)
|
|
.toColor();
|
|
|
|
palette.add(enhancedColor);
|
|
}
|
|
|
|
return palette;
|
|
}
|
|
|
|
/// Crée une couleur avec effet 3D plus avancé
|
|
Color _createEnhanced3DColor(Color baseColor, double angle) {
|
|
final hslColor = HSLColor.fromColor(baseColor);
|
|
final adjustedLightness = hslColor.lightness +
|
|
(0.2 * widget.effect3DIntensity * math.sin(angle)).clamp(-0.3, 0.3);
|
|
|
|
return hslColor.withLightness(adjustedLightness.clamp(0.0, 1.0)).toColor();
|
|
}
|
|
|
|
/// Crée les annotations d'icônes pour le graphique
|
|
List<CircularChartAnnotation> _buildIconAnnotations(
|
|
List<PaymentData> chartData) {
|
|
final List<CircularChartAnnotation> annotations = [];
|
|
|
|
double total = chartData.fold(0.0, (sum, item) => sum + item.amount);
|
|
double currentAngle = 0;
|
|
|
|
for (int i = 0; i < chartData.length; i++) {
|
|
final data = chartData[i];
|
|
final percentage = data.amount / total;
|
|
final segmentAngle = percentage * 2 * 3.14159;
|
|
final midAngle = currentAngle + (segmentAngle / 2);
|
|
|
|
annotations.add(
|
|
CircularChartAnnotation(
|
|
widget: Icon(
|
|
data.icon,
|
|
color: Colors.white,
|
|
size: 16,
|
|
),
|
|
radius: '50%',
|
|
angle: (midAngle * (180 / 3.14159)).toInt(),
|
|
),
|
|
);
|
|
|
|
currentAngle += segmentAngle;
|
|
}
|
|
|
|
return annotations;
|
|
}
|
|
}
|