feat: Version 3.3.5 - Optimisations pages, améliorations ergonomie et affichages dynamiques stats
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -140,12 +140,14 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
|
||||
// Sauvegarder aussi dans Hive pour la persistance
|
||||
_saveMemberFilter(widget.memberId!);
|
||||
} else if (!isAdmin) {
|
||||
// Pour un user standard, toujours filtrer sur son propre ID
|
||||
selectedMemberId = currentUserId;
|
||||
} else {
|
||||
// Admin sans memberId spécifique, charger les filtres depuis Hive
|
||||
// Pour tous les autres cas (admin et user), charger les filtres depuis Hive
|
||||
_loadPreselectedFilters();
|
||||
|
||||
// Pour un user standard, toujours filtrer sur son propre ID
|
||||
if (!isAdmin) {
|
||||
selectedMemberId = currentUserId;
|
||||
}
|
||||
}
|
||||
|
||||
_initializeNewFilters();
|
||||
@@ -385,7 +387,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
// Filtre Type de passage
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _selectedTypeFilter,
|
||||
value: _selectedTypeFilter,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@@ -418,7 +420,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
// Filtre Mode de règlement
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
initialValue: _selectedPaymentFilter,
|
||||
value: _selectedPaymentFilter,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@@ -473,7 +475,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
final sectors = sectorsBox.values.toList();
|
||||
|
||||
return DropdownButtonFormField<int?>(
|
||||
initialValue: _selectedSectorId,
|
||||
value: _selectedSectorId,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@@ -520,37 +522,30 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
const SizedBox(width: 12),
|
||||
if (isAdmin)
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<Box<UserModel>>(
|
||||
valueListenable: Hive.box<UserModel>(AppKeys.userBoxName).listenable(),
|
||||
builder: (context, usersBox, child) {
|
||||
final users = usersBox.values.where((user) => user.role == 1).toList();
|
||||
|
||||
return DropdownButtonFormField<int?>(
|
||||
initialValue: _selectedUserId,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
isDense: true,
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
child: Text('Membres'),
|
||||
),
|
||||
...users.map((UserModel user) {
|
||||
return DropdownMenuItem<int?>(
|
||||
value: user.id,
|
||||
child: Text('${user.firstName ?? ''} ${user.name ?? ''}'),
|
||||
);
|
||||
}),
|
||||
],
|
||||
onChanged: (int? newValue) {
|
||||
setState(() {
|
||||
_selectedUserId = newValue;
|
||||
});
|
||||
_notifyFiltersChanged();
|
||||
},
|
||||
);
|
||||
child: DropdownButtonFormField<int?>(
|
||||
value: _selectedUserId,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
isDense: true,
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<int?>(
|
||||
value: null,
|
||||
child: Text('Membres'),
|
||||
),
|
||||
..._users.map((UserModel user) {
|
||||
return DropdownMenuItem<int?>(
|
||||
value: user.id,
|
||||
child: Text('${user.firstName ?? ''} ${user.name ?? ''}'),
|
||||
);
|
||||
}),
|
||||
],
|
||||
onChanged: (int? newValue) {
|
||||
setState(() {
|
||||
_selectedUserId = newValue;
|
||||
});
|
||||
_notifyFiltersChanged();
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -896,6 +891,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
if (memberId != null && memberId is int) {
|
||||
setState(() {
|
||||
selectedMemberId = memberId;
|
||||
_selectedUserId = memberId; // Synchroniser avec le nouveau filtre
|
||||
});
|
||||
debugPrint('HistoryPage: Membre présélectionné chargé: $memberId');
|
||||
}
|
||||
@@ -906,6 +902,7 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
if (sectorId != null && sectorId is int) {
|
||||
setState(() {
|
||||
selectedSectorId = sectorId;
|
||||
_selectedSectorId = sectorId; // Synchroniser avec le nouveau filtre
|
||||
});
|
||||
debugPrint('HistoryPage: Secteur présélectionné chargé: $sectorId');
|
||||
}
|
||||
@@ -917,6 +914,10 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
selectedTypeId = typeId;
|
||||
final typeInfo = AppKeys.typesPassages[typeId];
|
||||
selectedType = typeInfo != null ? typeInfo['titre'] as String : 'Inconnu';
|
||||
// Synchroniser avec le nouveau filtre
|
||||
if (typeInfo != null) {
|
||||
_selectedTypeFilter = typeInfo['titre'] as String;
|
||||
}
|
||||
});
|
||||
debugPrint('HistoryPage: Type de passage présélectionné: $typeId');
|
||||
}
|
||||
@@ -926,6 +927,12 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
if (paymentTypeId != null && paymentTypeId is int) {
|
||||
setState(() {
|
||||
selectedPaymentTypeId = paymentTypeId;
|
||||
_selectedPaymentTypeId = paymentTypeId; // Synchroniser avec le nouveau filtre
|
||||
// Mettre à jour aussi le label du filtre
|
||||
final paymentInfo = AppKeys.typesReglements[paymentTypeId];
|
||||
if (paymentInfo != null) {
|
||||
_selectedPaymentFilter = paymentInfo['titre'] as String;
|
||||
}
|
||||
});
|
||||
debugPrint('HistoryPage: Type de règlement présélectionné: $paymentTypeId');
|
||||
}
|
||||
@@ -1592,8 +1599,11 @@ class _HistoryContentState extends State<HistoryContent> {
|
||||
orElse: () => PassageModel.fromJson(passageMap),
|
||||
);
|
||||
|
||||
// Vérifier les permissions : admin peut tout éditer, user seulement ses propres passages
|
||||
if (isAdmin || passage.fkUser == currentUserId) {
|
||||
// Vérifier les permissions :
|
||||
// - Admin peut tout éditer
|
||||
// - User peut éditer ses propres passages
|
||||
// - Type 2 (À finaliser) : éditable par tous les utilisateurs
|
||||
if (isAdmin || passage.fkUser == currentUserId || passage.fkType == 2) {
|
||||
_handlePassageEdit(passage);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
|
||||
@@ -93,13 +93,11 @@ class _HomeContentState extends State<HomeContent> {
|
||||
|
||||
// Tableau détaillé des membres - uniquement pour admin sur Web
|
||||
if (isAdmin && kIsWeb) ...[
|
||||
const MembersBoardPassages(
|
||||
height: 700,
|
||||
),
|
||||
const MembersBoardPassages(),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
],
|
||||
|
||||
// LIGNE 2 : Carte de répartition par secteur
|
||||
// LIGNE 2 : Carte de répartition par secteur (uniquement si > 1 secteur)
|
||||
// Le widget filtre automatiquement selon le rôle de l'utilisateur
|
||||
ValueListenableBuilder<Box<SectorModel>>(
|
||||
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
|
||||
@@ -113,9 +111,13 @@ class _HomeContentState extends State<HomeContent> {
|
||||
sectorCount = userSectors.length;
|
||||
}
|
||||
|
||||
// N'afficher que s'il y a plus d'un secteur
|
||||
if (sectorCount <= 1) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return SectorDistributionCard(
|
||||
title: '$sectorCount secteur${sectorCount > 1 ? 's' : ''}',
|
||||
height: 500,
|
||||
title: '$sectorCount secteurs',
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -132,10 +134,9 @@ class _HomeContentState extends State<HomeContent> {
|
||||
child: ActivityChart(
|
||||
height: 350,
|
||||
showAllPassages: isAdmin, // Admin voit tout, user voit tous les passages de ses secteurs
|
||||
title: isAdmin
|
||||
? 'Passages réalisés par jour (15 derniers jours)'
|
||||
: 'Passages de mes secteurs par jour (15 derniers jours)',
|
||||
daysToShow: 15,
|
||||
title: isAdmin ? 'Passages' : 'Mes passages',
|
||||
daysToShow: 7,
|
||||
showPeriodButtons: true,
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ 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/data/models/user_sector_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 {
|
||||
@@ -51,6 +53,9 @@ class ActivityChart extends StatefulWidget {
|
||||
/// 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,
|
||||
@@ -66,6 +71,7 @@ class ActivityChart extends StatefulWidget {
|
||||
this.columnSpacing = 0.2,
|
||||
this.showAllPassages = false,
|
||||
this.useValueListenable = true,
|
||||
this.showPeriodButtons = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -94,9 +100,14 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
// 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),
|
||||
@@ -157,7 +168,7 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final chartData = _calculateActivityData(passagesBox);
|
||||
final chartData = _calculateActivityData(passagesBox, _selectedDays);
|
||||
return _buildChart(chartData);
|
||||
},
|
||||
);
|
||||
@@ -179,7 +190,7 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
}
|
||||
|
||||
/// Calcule les données d'activité depuis la Hive box
|
||||
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox) {
|
||||
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox, int daysToShow) {
|
||||
try {
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
@@ -187,55 +198,63 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
// Pour les users : récupérer les secteurs assignés
|
||||
Set<int>? userSectorIds;
|
||||
if (!widget.showAllPassages && currentUser != null) {
|
||||
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
|
||||
userSectorIds = userSectorBox.values
|
||||
.where((us) => us.id == currentUser.id)
|
||||
.map((us) => us.fkSector)
|
||||
.toSet();
|
||||
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: widget.daysToShow - 1));
|
||||
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 < widget.daysToShow; i++) {
|
||||
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) {
|
||||
if (!widget.excludePassageTypes.contains(typeId)) {
|
||||
dataByDate[dateStr]![typeId] = 0;
|
||||
}
|
||||
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;
|
||||
String excludeReason = '';
|
||||
|
||||
// Filtrer par secteurs assignés si nécessaire (pour les users)
|
||||
if (userSectorIds != null && !userSectorIds.contains(passage.fkSector)) {
|
||||
shouldInclude = false;
|
||||
excludeReason = 'Secteur ${passage.fkSector} non assigné';
|
||||
}
|
||||
|
||||
// Exclure certains types
|
||||
if (widget.excludePassageTypes.contains(passage.fkType)) {
|
||||
// Exclure les passages de type 2 (À finaliser) avec nbPassages = 0
|
||||
if (shouldInclude && passage.fkType == 2 && passage.nbPassages == 0) {
|
||||
shouldInclude = false;
|
||||
excludeReason = 'Type 2 avec nbPassages=0';
|
||||
}
|
||||
|
||||
// Vérifier si le passage est dans la période
|
||||
final passageDate = passage.passedAt;
|
||||
if (passageDate == null ||
|
||||
if (shouldInclude && (passageDate == null ||
|
||||
passageDate.isBefore(startDate) ||
|
||||
passageDate.isAfter(endDate)) {
|
||||
passageDate.isAfter(endDate))) {
|
||||
shouldInclude = false;
|
||||
excludeReason = passageDate == null
|
||||
? 'Date null'
|
||||
: 'Hors période (${DateFormat('yyyy-MM-dd').format(passageDate)})';
|
||||
}
|
||||
|
||||
if (shouldInclude && passageDate != null) {
|
||||
@@ -243,10 +262,15 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
if (dataByDate.containsKey(dateStr)) {
|
||||
dataByDate[dateStr]![passage.fkType] =
|
||||
(dataByDate[dateStr]![passage.fkType] ?? 0) + 1;
|
||||
includedCount++;
|
||||
}
|
||||
} 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) {
|
||||
@@ -367,16 +391,32 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre
|
||||
// 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: Text(
|
||||
widget.title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
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
|
||||
@@ -434,10 +474,8 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
return series;
|
||||
}
|
||||
|
||||
// Obtenir tous les types de passage (sauf ceux exclus)
|
||||
final passageTypes = AppKeys.typesPassages.keys
|
||||
.where((typeId) => !widget.excludePassageTypes.contains(typeId))
|
||||
.toList();
|
||||
// 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) {
|
||||
@@ -481,6 +519,10 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -488,4 +530,86 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
|
||||
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);
|
||||
final passages = passageBox.values.toList();
|
||||
|
||||
// Calculer la date de début (nombre de jours en arrière)
|
||||
final endDate = DateTime.now();
|
||||
final startDate = endDate.subtract(Duration(days: _selectedDays - 1));
|
||||
|
||||
// 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.withValues(alpha: 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
library geosector_charts;
|
||||
|
||||
export 'payment_data.dart';
|
||||
export 'payment_pie_chart.dart';
|
||||
export 'payment_summary_card.dart';
|
||||
export 'passage_data.dart';
|
||||
export 'passage_utils.dart';
|
||||
export 'passage_pie_chart.dart';
|
||||
export 'passage_summary_card.dart';
|
||||
export 'activity_chart.dart';
|
||||
export 'combined_chart.dart';
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
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: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';
|
||||
|
||||
/// 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() {
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Filtrer les données pour exclure le type 5 si nécessaire
|
||||
Map<int, int> filteredData = Map.from(widget.passagesByType);
|
||||
if (!showLotType) {
|
||||
filteredData.remove(5);
|
||||
}
|
||||
|
||||
final chartData = _prepareChartDataFromMap(filteredData);
|
||||
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();
|
||||
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
bool showLotType = true;
|
||||
if (currentUser != null && currentUser.fkEntite != null) {
|
||||
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
||||
if (userAmicale != null) {
|
||||
showLotType = userAmicale.chkLotActif;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Exclure le type Lot (5) si chkLotActif = false
|
||||
if (typeId == 5 && !showLotType) {
|
||||
continue;
|
||||
}
|
||||
if (!widget.excludePassageTypes.contains(typeId)) {
|
||||
passagesByType[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 passages) {
|
||||
// Appliquer les filtres locaux uniquement
|
||||
bool shouldInclude = true;
|
||||
|
||||
// Filtrer par userId si spécifié (cas particulier pour compatibilité)
|
||||
if (widget.userId != null) {
|
||||
shouldInclude = passage.fkUser == widget.userId;
|
||||
}
|
||||
|
||||
// Exclure certains types
|
||||
if (widget.excludePassageTypes.contains(passage.fkType)) {
|
||||
shouldInclude = false;
|
||||
}
|
||||
|
||||
// Exclure le type Lot (5) si chkLotActif = false
|
||||
if (passage.fkType == 5 && !showLotType) {
|
||||
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 = [];
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.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 StatelessWidget {
|
||||
class PassageSummaryCard extends StatefulWidget {
|
||||
/// Titre de la carte
|
||||
final String title;
|
||||
|
||||
@@ -73,10 +93,51 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
this.backgroundIconSize = 180,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PassageSummaryCard> createState() => _PassageSummaryCardState();
|
||||
}
|
||||
|
||||
class _PassageSummaryCardState extends State<PassageSummaryCard>
|
||||
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 (useValueListenable) {
|
||||
if (widget.useValueListenable) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
@@ -93,11 +154,11 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
);
|
||||
} else {
|
||||
// Données statiques
|
||||
final totalPassages = passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
|
||||
final totalPassages = widget.passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
|
||||
return _buildCardContent(
|
||||
context,
|
||||
totalUserPassages: totalPassages,
|
||||
passagesCounts: passagesByType ?? {},
|
||||
passagesCounts: widget.passagesByType ?? {},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -116,20 +177,20 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
// Icône d'arrière-plan (optionnelle)
|
||||
if (backgroundIcon != null)
|
||||
if (widget.backgroundIcon != null)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
backgroundIcon,
|
||||
size: backgroundIconSize,
|
||||
color: (backgroundIconColor ?? AppTheme.primaryColor)
|
||||
.withValues(alpha: backgroundIconOpacity),
|
||||
widget.backgroundIcon,
|
||||
size: widget.backgroundIconSize,
|
||||
color: (widget.backgroundIconColor ?? AppTheme.primaryColor)
|
||||
.withValues(alpha: widget.backgroundIconOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Contenu principal
|
||||
Container(
|
||||
height: height,
|
||||
height: widget.height,
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -145,32 +206,19 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
children: [
|
||||
// Liste des passages à gauche
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
flex: widget.isDesktop ? 1 : 2,
|
||||
child: _buildPassagesList(context, passagesCounts),
|
||||
),
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
if (widget.isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
// Graphique en camembert à droite
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
flex: widget.isDesktop ? 1 : 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: PassagePieChart(
|
||||
useValueListenable: false, // Utilise les données calculées
|
||||
passagesByType: passagesCounts,
|
||||
excludePassageTypes: excludePassageTypes,
|
||||
showAllPassages: showAllPassages,
|
||||
userId: showAllPassages ? null : userId,
|
||||
size: double.infinity,
|
||||
labelSize: 12,
|
||||
showPercentage: true,
|
||||
showIcons: false,
|
||||
showLegend: false,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
),
|
||||
child: _buildPieChart(passagesCounts),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -189,17 +237,17 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
Widget _buildTitle(BuildContext context, int totalUserPassages) {
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
if (widget.titleIcon != null) ...[
|
||||
Icon(
|
||||
titleIcon,
|
||||
color: titleColor,
|
||||
widget.titleIcon,
|
||||
color: widget.titleColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 16),
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -207,19 +255,38 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(totalUserPassages) ??
|
||||
widget.customTotalDisplay?.call(totalUserPassages) ??
|
||||
totalUserPassages.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 20),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
color: widget.titleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction de la liste des passages
|
||||
/// 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<int, int> passagesCounts) {
|
||||
// Vérifier si le type Lot doit être affiché
|
||||
bool showLotType = true;
|
||||
@@ -249,37 +316,44 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -287,6 +361,95 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du graphique en camembert (avec clics)
|
||||
Widget _buildPieChart(Map<int, int> 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: <CircularSeries>[
|
||||
DoughnutSeries<PassageChartData, String>(
|
||||
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<int, int> _calculatePassagesCounts(Box<PassageModel> passagesBox) {
|
||||
final Map<int, int> counts = {};
|
||||
@@ -308,7 +471,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
continue;
|
||||
}
|
||||
// Exclure les types non désirés
|
||||
if (excludePassageTypes.contains(typeId)) {
|
||||
if (widget.excludePassageTypes.contains(typeId)) {
|
||||
continue;
|
||||
}
|
||||
counts[typeId] = 0;
|
||||
@@ -322,7 +485,7 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
continue;
|
||||
}
|
||||
// Exclure les types non désirés
|
||||
if (excludePassageTypes.contains(passage.fkType)) {
|
||||
if (widget.excludePassageTypes.contains(passage.fkType)) {
|
||||
continue;
|
||||
}
|
||||
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
|
||||
@@ -330,4 +493,42 @@ class PassageSummaryCard extends StatelessWidget {
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// 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 = [];
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
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;
|
||||
|
||||
/// Afficher tous les passages (admin) ou seulement ceux de l'utilisateur
|
||||
final bool showAllPassages;
|
||||
|
||||
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,
|
||||
this.showAllPassages = false,
|
||||
});
|
||||
|
||||
@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 ||
|
||||
widget.showAllPassages != oldWidget.showAllPassages) {
|
||||
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();
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
// Déterminer le filtre utilisateur : en mode user, on filtre par fkUser
|
||||
final int? filterUserId = widget.showAllPassages
|
||||
? null
|
||||
: (widget.userId ?? currentUser?.id);
|
||||
|
||||
for (final passage in passages) {
|
||||
// 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 {
|
||||
// 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: 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<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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.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 StatelessWidget {
|
||||
class PaymentSummaryCard extends StatefulWidget {
|
||||
/// Titre de la carte
|
||||
final String title;
|
||||
|
||||
@@ -70,10 +72,49 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
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 (useValueListenable) {
|
||||
if (widget.useValueListenable) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
@@ -90,11 +131,11 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
);
|
||||
} else {
|
||||
// Données statiques
|
||||
final totalAmount = paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
|
||||
final totalAmount = widget.paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
|
||||
return _buildCardContent(
|
||||
context,
|
||||
totalAmount: totalAmount,
|
||||
paymentAmounts: paymentsByType ?? {},
|
||||
paymentAmounts: widget.paymentsByType ?? {},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -113,20 +154,20 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
child: Stack(
|
||||
children: [
|
||||
// Icône d'arrière-plan (optionnelle)
|
||||
if (backgroundIcon != null)
|
||||
if (widget.backgroundIcon != null)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
backgroundIcon,
|
||||
size: backgroundIconSize,
|
||||
color: (backgroundIconColor ?? Colors.blue)
|
||||
.withValues(alpha: backgroundIconOpacity),
|
||||
widget.backgroundIcon,
|
||||
size: widget.backgroundIconSize,
|
||||
color: (widget.backgroundIconColor ?? Colors.blue)
|
||||
.withValues(alpha: widget.backgroundIconOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Contenu principal
|
||||
Container(
|
||||
height: height,
|
||||
height: widget.height,
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -142,35 +183,19 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
children: [
|
||||
// Liste des règlements à gauche
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
flex: widget.isDesktop ? 1 : 2,
|
||||
child: _buildPaymentsList(context, paymentAmounts),
|
||||
),
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
if (widget.isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
// Graphique en camembert à droite
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
flex: widget.isDesktop ? 1 : 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: PaymentPieChart(
|
||||
useValueListenable: false, // Utilise les données calculées
|
||||
payments: _convertMapToPaymentData(paymentAmounts),
|
||||
showAllPassages: showAllPayments,
|
||||
userId: showAllPayments ? null : userId,
|
||||
size: double.infinity,
|
||||
labelSize: 12,
|
||||
showPercentage: true,
|
||||
showIcons: false,
|
||||
showLegend: false,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
enable3DEffect: false,
|
||||
effect3DIntensity: 0.0,
|
||||
enableEnhancedExplode: false,
|
||||
useGradient: false,
|
||||
),
|
||||
child: _buildPieChart(paymentAmounts),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -189,17 +214,17 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
Widget _buildTitle(BuildContext context, double totalAmount) {
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
if (widget.titleIcon != null) ...[
|
||||
Icon(
|
||||
titleIcon,
|
||||
color: titleColor,
|
||||
widget.titleIcon,
|
||||
color: widget.titleColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
widget.title,
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 16),
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -207,19 +232,38 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(totalAmount) ??
|
||||
widget.customTotalDisplay?.call(totalAmount) ??
|
||||
'${totalAmount.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 20),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
color: widget.titleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction de la liste des règlements
|
||||
/// 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,
|
||||
@@ -233,37 +277,44 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
@@ -271,6 +322,95 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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 = {};
|
||||
@@ -282,7 +422,7 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
|
||||
// En mode user, filtrer uniquement les passages créés par l'utilisateur (fkUser)
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final int? filterUserId = showAllPayments ? null : currentUser?.id;
|
||||
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
|
||||
@@ -314,8 +454,8 @@ class PaymentSummaryCard extends StatelessWidget {
|
||||
return paymentAmounts;
|
||||
}
|
||||
|
||||
/// Convertit une Map<int, double> en List<PaymentData> pour les données statiques
|
||||
List<PaymentData> _convertMapToPaymentData(Map<int, double> paymentsMap) {
|
||||
/// 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) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map_cache/flutter_map_cache.dart';
|
||||
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
|
||||
import 'package:http_cache_hive_store/http_cache_hive_store.dart'; // Mise à jour v2.0.0 (06/10/2025)
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
|
||||
@@ -47,9 +47,6 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: widget.height ?? 700, // Hauteur max, sinon s'adapte au contenu
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
@@ -57,6 +54,7 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// En-tête de la card
|
||||
Container(
|
||||
@@ -88,8 +86,7 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
||||
),
|
||||
|
||||
// Corps avec le tableau
|
||||
Expanded(
|
||||
child: ValueListenableBuilder<Box<MembreModel>>(
|
||||
ValueListenableBuilder<Box<MembreModel>>(
|
||||
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
|
||||
builder: (context, membresBox, child) {
|
||||
final membres = membresBox.values.toList();
|
||||
@@ -118,28 +115,24 @@ class _MembersBoardPassagesState extends State<MembersBoardPassages> {
|
||||
..._buildRows(membres, currentOperation.id, theme),
|
||||
];
|
||||
|
||||
// Utilise seulement le scroll vertical, le tableau s'adapte à la largeur
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.vertical,
|
||||
child: SizedBox(
|
||||
width: double.infinity, // Prendre toute la largeur disponible
|
||||
child: DataTable(
|
||||
columnSpacing: 4, // Espacement minimal entre colonnes
|
||||
horizontalMargin: 4, // Marges horizontales minimales
|
||||
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
|
||||
dataRowMinHeight: 42,
|
||||
dataRowMaxHeight: 42,
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
theme.colorScheme.primary.withValues(alpha: 0.08),
|
||||
),
|
||||
columns: _buildColumns(theme),
|
||||
rows: allRows,
|
||||
// Afficher le tableau complet sans scroll interne
|
||||
return SizedBox(
|
||||
width: double.infinity, // Prendre toute la largeur disponible
|
||||
child: DataTable(
|
||||
columnSpacing: 4, // Espacement minimal entre colonnes
|
||||
horizontalMargin: 4, // Marges horizontales minimales
|
||||
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
|
||||
dataRowMinHeight: 42,
|
||||
dataRowMaxHeight: 42,
|
||||
headingRowColor: WidgetStateProperty.all(
|
||||
theme.colorScheme.primary.withValues(alpha: 0.08),
|
||||
),
|
||||
columns: _buildColumns(theme),
|
||||
rows: allRows,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -100,7 +100,6 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: widget.height,
|
||||
padding: widget.padding ?? const EdgeInsets.all(AppTheme.spacingM),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
@@ -109,6 +108,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Ligne du titre avec boutons de tri
|
||||
Row(
|
||||
@@ -135,9 +135,7 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Expanded(
|
||||
child: _buildAutoRefreshContent(),
|
||||
),
|
||||
_buildAutoRefreshContent(),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -183,6 +181,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
|
||||
// Liste des secteurs directement sans sous-titre
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: sectorStats.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sector = sectorStats[index];
|
||||
@@ -318,41 +318,41 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: isAdmin
|
||||
? InkWell(
|
||||
onTap: () {
|
||||
// Sauvegarder le secteur sélectionné et l'index de la page carte dans Hive
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.put('selectedSectorId', sectorId);
|
||||
settingsBox.put(
|
||||
'selectedPageIndex', 4); // Index de la page carte
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
|
||||
// Naviguer vers le dashboard admin qui chargera la page carte
|
||||
context.go('/admin');
|
||||
},
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 14),
|
||||
color: textColor,
|
||||
fontWeight:
|
||||
hasPassages ? FontWeight.w600 : FontWeight.w300,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: textColor.withValues(alpha: 0.5),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 14),
|
||||
color: textColor,
|
||||
fontWeight:
|
||||
hasPassages ? FontWeight.w600 : FontWeight.w300,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (isAdmin) {
|
||||
// Admin : naviguer vers la page carte
|
||||
settingsBox.put('selectedSectorId', sectorId);
|
||||
settingsBox.put('selectedPageIndex', 4); // Index de la page carte
|
||||
context.go('/admin');
|
||||
} else {
|
||||
// User : naviguer vers la page historique avec le secteur sélectionné
|
||||
settingsBox.delete('history_selectedTypeId');
|
||||
settingsBox.delete('history_selectedPaymentTypeId');
|
||||
settingsBox.delete('history_selectedMemberId');
|
||||
settingsBox.delete('history_startDate');
|
||||
settingsBox.delete('history_endDate');
|
||||
|
||||
settingsBox.put('history_selectedSectorId', sectorId);
|
||||
settingsBox.put('history_selectedSectorName', name);
|
||||
context.go('/user/history');
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 14),
|
||||
color: textColor,
|
||||
fontWeight:
|
||||
hasPassages ? FontWeight.w600 : FontWeight.w300,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: textColor.withValues(alpha: 0.5),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
hasPassages
|
||||
@@ -420,75 +420,49 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
? Color(typeInfo['couleur2'] as int)
|
||||
: Colors.grey;
|
||||
|
||||
// Vérifier si l'utilisateur est admin pour les clics (prend en compte le mode d'affichage)
|
||||
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
|
||||
return Expanded(
|
||||
flex: count,
|
||||
child: isAdmin
|
||||
? InkWell(
|
||||
onTap: () {
|
||||
// Sauvegarder les filtres dans Hive pour la page historique
|
||||
final settingsBox =
|
||||
Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.put(
|
||||
'history_selectedSectorId', sectorId);
|
||||
settingsBox.put(
|
||||
'history_selectedSectorName', sectorName);
|
||||
settingsBox.put('history_selectedTypeId', typeId);
|
||||
settingsBox.put('selectedPageIndex',
|
||||
2); // Index de la page historique
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Réinitialiser TOUS les filtres avant de sauvegarder les nouveaux
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.delete('history_selectedPaymentTypeId');
|
||||
settingsBox.delete('history_selectedMemberId');
|
||||
settingsBox.delete('history_startDate');
|
||||
settingsBox.delete('history_endDate');
|
||||
|
||||
// Naviguer vers le dashboard admin qui chargera la page historique
|
||||
context.go('/admin');
|
||||
},
|
||||
child: Container(
|
||||
color: color,
|
||||
child: Center(
|
||||
child: percentage >=
|
||||
5 // N'afficher le texte que si >= 5%
|
||||
? Text(
|
||||
'$count (${percentage.toInt()}%)',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: AppTheme.r(context, 10),
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(0.5, 0.5),
|
||||
blurRadius: 1.0,
|
||||
color: Colors.black45,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: color,
|
||||
child: Center(
|
||||
child: percentage >=
|
||||
5 // N'afficher le texte que si >= 5%
|
||||
? Text(
|
||||
'$count (${percentage.toInt()}%)',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: AppTheme.r(context, 10),
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(0.5, 0.5),
|
||||
blurRadius: 1.0,
|
||||
color: Colors.black45,
|
||||
),
|
||||
],
|
||||
// Sauvegarder uniquement le secteur et le type de passage sélectionnés
|
||||
settingsBox.put('history_selectedSectorId', sectorId);
|
||||
settingsBox.put('history_selectedSectorName', sectorName);
|
||||
settingsBox.put('history_selectedTypeId', typeId);
|
||||
|
||||
// Naviguer directement vers la page historique
|
||||
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
|
||||
context.go(isAdmin ? '/admin/history' : '/user/history');
|
||||
},
|
||||
child: Container(
|
||||
color: color,
|
||||
child: Center(
|
||||
child: percentage >= 5
|
||||
? Text(
|
||||
'$count (${percentage.toInt()}%)',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: AppTheme.r(context, 10),
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
offset: Offset(0.5, 0.5),
|
||||
blurRadius: 1.0,
|
||||
color: Colors.black45,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user