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:
pierre
2025-10-06 15:32:32 +02:00
parent b6584c83fa
commit 2b3d05c981
31 changed files with 1982 additions and 1442 deletions

View File

@@ -126,7 +126,7 @@ class AppKeys {
},
2: {
'titre': 'Chèque',
'couleur': 0xFFD8D5EC, // Violet clair (Figma)
'couleur': 0xFF7E57C2, // Violet foncé (Material Design Deep Purple 400)
'icon_data': Icons.account_balance_wallet_outlined,
},
3: {
@@ -186,9 +186,9 @@ class AppKeys {
6: {
'titres': 'Maisons vides',
'titre': 'Maison vide',
'couleur1': 0xFFB8B8B8, // Gris (Figma)
'couleur2': 0xFFB8B8B8, // Gris (Figma)
'couleur3': 0xFFB8B8B8, // Gris (Figma)
'couleur1': 0xFF757575, // Gris foncé (Material Design 600)
'couleur2': 0xFF757575, // Gris foncé (Material Design 600)
'couleur3': 0xFF757575, // Gris foncé (Material Design 600)
'icon_data': Icons.home_outlined,
},
};

View File

@@ -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(

View File

@@ -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,
),
),

View File

@@ -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,
),
),
),
);
}
}

View File

@@ -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';

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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) {

View File

@@ -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';

View File

@@ -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,
),
);
},
),
),
],
),
);

View File

@@ -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(),
),