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 570a1fa1f0
commit 21657a3820
31 changed files with 1982 additions and 1442 deletions

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