- Ajout système complet de gestion des secteurs avec contours géographiques - Import des contours départementaux depuis GeoJSON - API REST pour la gestion des secteurs (/api/sectors) - Service de géolocalisation pour déterminer les secteurs - Migration base de données avec tables x_departements_contours et sectors_adresses - Interface Flutter pour visualisation et gestion des secteurs - Ajout thème sombre dans l'application - Corrections diverses et optimisations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
401 lines
13 KiB
Dart
Executable File
401 lines
13 KiB
Dart
Executable File
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: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';
|
|
|
|
/// Modèle de données pour le graphique en camembert des passages
|
|
class PassageChartData {
|
|
/// Identifiant du type de passage
|
|
final int typeId;
|
|
|
|
/// Nombre de passages de ce type
|
|
final int count;
|
|
|
|
/// Titre du type de passage
|
|
final String title;
|
|
|
|
/// Couleur associée au type de passage
|
|
final Color color;
|
|
|
|
/// Icône associée au type de passage
|
|
final IconData icon;
|
|
|
|
PassageChartData({
|
|
required this.typeId,
|
|
required this.count,
|
|
required this.title,
|
|
required this.color,
|
|
required this.icon,
|
|
});
|
|
}
|
|
|
|
/// Widget de graphique en camembert pour représenter la répartition des passages par type
|
|
class PassagePieChart extends StatefulWidget {
|
|
/// Liste des données de passages par type sous forme de Map avec typeId et count
|
|
/// Si useValueListenable est true, ce paramètre est ignoré
|
|
final Map<int, int> passagesByType;
|
|
|
|
/// 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;
|
|
|
|
/// Charger les données depuis Hive (obsolète, utiliser useValueListenable)
|
|
final bool loadFromHive;
|
|
|
|
/// ID de l'utilisateur pour filtrer les passages
|
|
final int? userId;
|
|
|
|
/// Types de passages à exclure
|
|
final List<int> excludePassageTypes;
|
|
|
|
/// Afficher tous les passages sans filtrer par utilisateur
|
|
final bool showAllPassages;
|
|
|
|
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
|
|
final bool useValueListenable;
|
|
|
|
const PassagePieChart({
|
|
super.key,
|
|
this.passagesByType = const {},
|
|
this.size = 300,
|
|
this.labelSize = 12,
|
|
this.showPercentage = true,
|
|
this.showIcons = true,
|
|
this.showLegend = true,
|
|
this.isDonut = false,
|
|
this.innerRadius = '40%',
|
|
this.loadFromHive = false,
|
|
this.userId,
|
|
this.excludePassageTypes = const [2],
|
|
this.showAllPassages = false,
|
|
this.useValueListenable = true,
|
|
});
|
|
|
|
@override
|
|
State<PassagePieChart> createState() => _PassagePieChartState();
|
|
}
|
|
|
|
class _PassagePieChartState extends State<PassagePieChart>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Initialiser le contrôleur d'animation
|
|
_animationController = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 2000),
|
|
);
|
|
|
|
_animationController.forward();
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(PassagePieChart 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) {
|
|
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 chartData = _calculatePassageData(passagesBox);
|
|
return _buildChart(chartData);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Construction du widget avec des données statiques (ancien système)
|
|
Widget _buildWithStaticData() {
|
|
final chartData = _prepareChartDataFromMap(widget.passagesByType);
|
|
return _buildChart(chartData);
|
|
}
|
|
|
|
/// Calcule les données de passage depuis la Hive box
|
|
List<PassageChartData> _calculatePassageData(Box<PassageModel> passagesBox) {
|
|
try {
|
|
final passages = passagesBox.values.toList();
|
|
final currentUser = userRepository.getCurrentUser();
|
|
|
|
// Calculer les données selon les filtres
|
|
final Map<int, int> passagesByType = {};
|
|
|
|
// Initialiser tous les types de passage possibles
|
|
for (final typeId in AppKeys.typesPassages.keys) {
|
|
if (!widget.excludePassageTypes.contains(typeId)) {
|
|
passagesByType[typeId] = 0;
|
|
}
|
|
}
|
|
|
|
for (final passage in passages) {
|
|
// Appliquer les filtres
|
|
bool shouldInclude = true;
|
|
|
|
// Filtrer par utilisateur si nécessaire
|
|
if (!widget.showAllPassages && widget.userId != null) {
|
|
shouldInclude = passage.fkUser == widget.userId;
|
|
} else if (!widget.showAllPassages && currentUser != null) {
|
|
shouldInclude = passage.fkUser == currentUser.id;
|
|
}
|
|
|
|
// Exclure certains types
|
|
if (widget.excludePassageTypes.contains(passage.fkType)) {
|
|
shouldInclude = false;
|
|
}
|
|
|
|
if (shouldInclude) {
|
|
passagesByType[passage.fkType] =
|
|
(passagesByType[passage.fkType] ?? 0) + 1;
|
|
}
|
|
}
|
|
|
|
return _prepareChartDataFromMap(passagesByType);
|
|
} catch (e) {
|
|
debugPrint('Erreur lors du calcul des données de passage: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Prépare les données pour le graphique en camembert à partir d'une Map
|
|
List<PassageChartData> _prepareChartDataFromMap(
|
|
Map<int, int> passagesByType) {
|
|
final List<PassageChartData> chartData = [];
|
|
|
|
// Créer les données du graphique
|
|
passagesByType.forEach((typeId, count) {
|
|
// 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;
|
|
}
|
|
|
|
/// Construit le graphique avec les données fournies
|
|
Widget _buildChart(List<PassageChartData> chartData) {
|
|
// 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: 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 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<PassageChartData, String>(
|
|
dataSource: chartData,
|
|
xValueMapper: (PassageChartData data, _) => data.title,
|
|
yValueMapper: (PassageChartData data, _) => data.count,
|
|
pointColorMapper: (PassageChartData data, _) =>
|
|
data.color,
|
|
enableTooltip: true,
|
|
dataLabelMapper: (PassageChartData data, _) {
|
|
if (widget.showPercentage) {
|
|
// 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)}%';
|
|
} else {
|
|
return data.title;
|
|
}
|
|
},
|
|
dataLabelSettings: DataLabelSettings(
|
|
isVisible: true,
|
|
labelPosition: ChartDataLabelPosition.outside,
|
|
textStyle: TextStyle(fontSize: widget.labelSize),
|
|
connectorLineSettings: const ConnectorLineSettings(
|
|
type: ConnectorType.curve,
|
|
length: '15%',
|
|
),
|
|
),
|
|
innerRadius: widget.innerRadius,
|
|
explode: true,
|
|
explodeIndex: 0,
|
|
explodeOffset:
|
|
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
|
opacity: opacityAnimation.value,
|
|
animationDuration: 0,
|
|
startAngle: 270,
|
|
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
|
)
|
|
: PieSeries<PassageChartData, String>(
|
|
dataSource: chartData,
|
|
xValueMapper: (PassageChartData data, _) => data.title,
|
|
yValueMapper: (PassageChartData data, _) => data.count,
|
|
pointColorMapper: (PassageChartData data, _) =>
|
|
data.color,
|
|
enableTooltip: true,
|
|
dataLabelMapper: (PassageChartData data, _) {
|
|
if (widget.showPercentage) {
|
|
// 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)}%';
|
|
} 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,
|
|
explodeIndex: 0,
|
|
explodeOffset:
|
|
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
|
opacity: opacityAnimation.value,
|
|
animationDuration: 0,
|
|
startAngle: 270,
|
|
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
|
),
|
|
],
|
|
annotations:
|
|
widget.showIcons ? _buildIconAnnotations(chartData) : null,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Crée les annotations d'icônes pour le graphique
|
|
List<CircularChartAnnotation> _buildIconAnnotations(
|
|
List<PassageChartData> chartData) {
|
|
final List<CircularChartAnnotation> annotations = [];
|
|
|
|
// Calculer le total pour les pourcentages
|
|
int total = chartData.fold(0, (sum, item) => sum + item.count);
|
|
if (total == 0) return []; // Éviter la division par zéro
|
|
|
|
// Position angulaire actuelle (en radians)
|
|
double currentAngle = 0;
|
|
|
|
for (int i = 0; i < chartData.length; i++) {
|
|
final data = chartData[i];
|
|
final percentage = data.count / total;
|
|
|
|
// Calculer l'angle central de ce segment
|
|
final segmentAngle = percentage * 2 * 3.14159;
|
|
final midAngle = currentAngle + (segmentAngle / 2);
|
|
|
|
// Ajouter une annotation pour l'icône
|
|
annotations.add(
|
|
CircularChartAnnotation(
|
|
widget: Icon(
|
|
data.icon,
|
|
color: Colors.white,
|
|
size: 16,
|
|
),
|
|
radius: '50%',
|
|
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
|
|
),
|
|
);
|
|
|
|
// Mettre à jour l'angle actuel
|
|
currentAngle += segmentAngle;
|
|
}
|
|
|
|
return annotations;
|
|
}
|
|
}
|