Files
geo/app/lib/presentation/widgets/charts/activity_chart.dart
pierre 2f5946a184 feat: Version 3.5.2 - Configuration Stripe et gestion des immeubles
- Configuration complète Stripe pour les 3 environnements (DEV/REC/PROD)
  * DEV: Clés TEST Pierre (mode test)
  * REC: Clés TEST Client (mode test)
  * PROD: Clés LIVE Client (mode live)
- Ajout de la gestion des bases de données immeubles/bâtiments
  * Configuration buildings_database pour DEV/REC/PROD
  * Service BuildingService pour enrichissement des adresses
- Optimisations pages et améliorations ergonomie
- Mises à jour des dépendances Composer
- Nettoyage des fichiers obsolètes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-09 18:26:27 +01:00

617 lines
21 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: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';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:go_router/go_router.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<Map<String, dynamic>>? 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<int> 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;
/// Afficher les boutons de sélection de période (7j, 14j, 21j)
final bool showPeriodButtons;
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,
this.showPeriodButtons = false,
});
@override
State<ActivityChart> createState() => _ActivityChartState();
}
/// Classe pour stocker les données d'activité par date
class ActivityData {
final DateTime date;
final String dateStr;
final Map<int, int> 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<ActivityChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
// Contrôleur de zoom pour le graphique
late ZoomPanBehavior _zoomPanBehavior;
// Période sélectionnée pour le filtre (7, 14 ou 21 jours)
late int _selectedDays;
@override
void initState() {
super.initState();
_selectedDays = widget.daysToShow;
_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<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final chartData = _calculateActivityData(passagesBox, _selectedDays);
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<ActivityData> _calculateActivityData(
Box<PassageModel> passagesBox, int daysToShow) {
try {
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
// Pour les users : récupérer les secteurs assignés
Set<int>? userSectorIds;
if (!widget.showAllPassages && currentUser != null) {
final userSectors = userRepository.getUserSectors();
userSectorIds = userSectors.map((sector) => sector.id).toSet();
debugPrint(
'ActivityChart: Mode USER - Secteurs assignés: $userSectorIds');
} else {
debugPrint('ActivityChart: Mode ADMIN - Tous les passages');
}
// Calculer la date de début (nombre de jours en arrière)
final endDate = DateTime.now();
final startDate = endDate.subtract(Duration(days: daysToShow - 1));
debugPrint(
'ActivityChart: Période du ${DateFormat('yyyy-MM-dd').format(startDate)} au ${DateFormat('yyyy-MM-dd').format(endDate)}');
debugPrint('ActivityChart: Nombre total de passages: ${passages.length}');
// Préparer les données par date
final Map<String, Map<int, int>> dataByDate = {};
// Initialiser toutes les dates de la période
for (int i = 0; i < 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) {
dataByDate[dateStr]![typeId] = 0;
}
}
// Parcourir les passages et les compter par date et type
int includedCount = 0;
for (final passage in passages) {
// Appliquer les filtres
bool shouldInclude = true;
// Filtrer par secteurs assignés si nécessaire (pour les users)
if (userSectorIds != null &&
!userSectorIds.contains(passage.fkSector)) {
shouldInclude = false;
}
// Exclure les passages de type 2 (À finaliser) avec nbPassages = 0
if (shouldInclude && passage.fkType == 2 && passage.nbPassages == 0) {
shouldInclude = false;
}
// Vérifier si le passage est dans la période
final passageDate = passage.passedAt;
if (shouldInclude &&
(passageDate == null ||
passageDate.isBefore(startDate) ||
passageDate.isAfter(endDate))) {
shouldInclude = false;
}
if (shouldInclude && passageDate != null) {
final dateStr = DateFormat('yyyy-MM-dd').format(passageDate);
if (dataByDate.containsKey(dateStr)) {
dataByDate[dateStr]![passage.fkType] =
(dataByDate[dateStr]![passage.fkType] ?? 0) + 1;
includedCount++;
}
}
// Debug désactivé pour éviter la pollution de la console avec les passages type 2 sans date
// else if (!shouldInclude && userSectorIds != null) {
// debugPrint(
// 'ActivityChart: Passage #${passage.id} exclu - $excludeReason (type=${passage.fkType}, secteur=${passage.fkSector}, date=${passageDate != null ? DateFormat('yyyy-MM-dd').format(passageDate) : 'null'})');
// }
}
debugPrint(
'ActivityChart: Passages inclus dans le graphique: $includedCount');
// Convertir en liste d'ActivityData
final List<ActivityData> 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<ActivityData> _prepareChartDataFromPassageData(
List<Map<String, dynamic>> passageData) {
try {
// Obtenir toutes les dates uniques
final Set<String> uniqueDatesSet = {};
for (final data in passageData) {
if (data.containsKey('date') && data['date'] != null) {
uniqueDatesSet.add(data['date'] as String);
}
}
// Trier les dates
final List<String> uniqueDates = uniqueDatesSet.toList();
uniqueDates.sort();
// Créer les données pour chaque date
final List<ActivityData> chartData = [];
for (final dateStr in uniqueDates) {
final passagesByType = <int, int>{};
// 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<ActivityData> 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: [
// En-tête avec titre et boutons de filtre
if (widget.title.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: 16.0, right: 16.0, top: 16.0, bottom: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (widget.showPeriodButtons)
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildPeriodButton(7),
const SizedBox(width: 4),
_buildPeriodButton(14),
const SizedBox(width: 4),
_buildPeriodButton(21),
],
),
],
),
),
// 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: <double>[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<CartesianSeries<ActivityData, DateTime>> _buildSeries(
List<ActivityData> chartData) {
final List<CartesianSeries<ActivityData, DateTime>> series = [];
// Vérifier que les données sont disponibles
if (chartData.isEmpty) {
return series;
}
// Obtenir tous les types de passage
final passageTypes = AppKeys.typesPassages.keys.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<ActivityData, DateTime>(
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,
// Ajouter le callback de clic uniquement depuis home_page
onPointTap: widget.showPeriodButtons
? (ChartPointDetails details) {
_handlePointTap(details, typeId);
}
: null,
),
);
}
}
return series;
}
/// Gère le clic sur un point du graphique
void _handlePointTap(ChartPointDetails details, int typeId) {
if (details.pointIndex == null || details.pointIndex! < 0) return;
// Récupérer les données du point cliqué
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
// Créer les données d'activité
final chartData = _calculateActivityData(passageBox, _selectedDays);
if (details.pointIndex! >= chartData.length) return;
final clickedData = chartData[details.pointIndex!];
final clickedDate = clickedData.date;
// Réinitialiser tous les filtres sauf celui sélectionné
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.delete('history_selectedPaymentTypeId');
settingsBox.delete('history_selectedSectorId');
settingsBox.delete('history_selectedSectorName');
settingsBox.delete('history_selectedMemberId');
// Sauvegarder le type de passage et les dates (début et fin de journée)
settingsBox.put('history_selectedTypeId', typeId);
// Date de début : début de la journée cliquée
final startDateTime =
DateTime(clickedDate.year, clickedDate.month, clickedDate.day, 0, 0, 0);
settingsBox.put('history_startDate', startDateTime.millisecondsSinceEpoch);
// Date de fin : fin de la journée cliquée
final endDateTime = DateTime(
clickedDate.year, clickedDate.month, clickedDate.day, 23, 59, 59);
settingsBox.put('history_endDate', endDateTime.millisecondsSinceEpoch);
// Naviguer vers la page historique
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
context.go(isAdmin ? '/admin/history' : '/user/history');
}
/// Construit un bouton de sélection de période
Widget _buildPeriodButton(int days) {
final isSelected = _selectedDays == days;
return InkWell(
onTap: () {
setState(() {
_selectedDays = days;
_animationController.reset();
_animationController.forward();
});
widget.onPeriodChanged?.call(days);
},
borderRadius: BorderRadius.circular(4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
width: 1,
),
),
child: Text(
'${days}j',
style: TextStyle(
fontSize: 12,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected ? Colors.white : Colors.grey.shade700,
),
),
),
);
}
}