- Configuration complète Stripe pour les 3 environnements (DEV/REC/PROD) * DEV: Clés TEST Pierre (mode test) * REC: Clés TEST Client (mode test) * PROD: Clés LIVE Client (mode live) - Ajout de la gestion des bases de données immeubles/bâtiments * Configuration buildings_database pour DEV/REC/PROD * Service BuildingService pour enrichissement des adresses - Optimisations pages et améliorations ergonomie - Mises à jour des dépendances Composer - Nettoyage des fichiers obsolètes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
535 lines
18 KiB
Dart
Executable File
535 lines
18 KiB
Dart
Executable File
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/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 StatefulWidget {
|
|
/// Titre de la carte
|
|
final String title;
|
|
|
|
/// Couleur de l'icône et du titre
|
|
final Color titleColor;
|
|
|
|
/// Icône à afficher dans le titre
|
|
final IconData? titleIcon;
|
|
|
|
/// Hauteur totale de la carte
|
|
final double? height;
|
|
|
|
/// Utiliser ValueListenableBuilder pour mise à jour automatique
|
|
final bool useValueListenable;
|
|
|
|
/// ID de l'utilisateur pour filtrer les passages (si null, tous les utilisateurs)
|
|
final int? userId;
|
|
|
|
/// Afficher tous les passages (admin) ou seulement ceux de l'utilisateur
|
|
final bool showAllPassages;
|
|
|
|
/// Types de passages à exclure du graphique
|
|
final List<int> excludePassageTypes;
|
|
|
|
/// Données statiques de passages par type (utilisé si useValueListenable = false)
|
|
final Map<int, int>? passagesByType;
|
|
|
|
/// Fonction de callback pour afficher la valeur totale personnalisée
|
|
final String Function(int totalPassages)? customTotalDisplay;
|
|
|
|
/// Afficher le graphique en mode desktop ou mobile
|
|
final bool isDesktop;
|
|
|
|
/// Icône d'arrière-plan (optionnelle)
|
|
final IconData? backgroundIcon;
|
|
|
|
/// Couleur de l'icône d'arrière-plan
|
|
final Color? backgroundIconColor;
|
|
|
|
/// Opacité de l'icône d'arrière-plan
|
|
final double backgroundIconOpacity;
|
|
|
|
/// Taille de l'icône d'arrière-plan
|
|
final double backgroundIconSize;
|
|
|
|
const PassageSummaryCard({
|
|
super.key,
|
|
required this.title,
|
|
this.titleColor = AppTheme.primaryColor,
|
|
this.titleIcon = Icons.route,
|
|
this.height,
|
|
this.useValueListenable = true,
|
|
this.userId,
|
|
this.showAllPassages = false,
|
|
this.excludePassageTypes = const [2], // Exclure "À finaliser" par défaut
|
|
this.passagesByType,
|
|
this.customTotalDisplay,
|
|
this.isDesktop = true,
|
|
this.backgroundIcon = Icons.route,
|
|
this.backgroundIconColor,
|
|
this.backgroundIconOpacity = 0.07,
|
|
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 (widget.useValueListenable) {
|
|
return ValueListenableBuilder(
|
|
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
|
builder: (context, Box<PassageModel> passagesBox, child) {
|
|
// Calculer les données une seule fois
|
|
final passagesCounts = _calculatePassagesCounts(passagesBox);
|
|
final totalUserPassages = passagesCounts.values.fold(0, (sum, count) => sum + count);
|
|
|
|
return _buildCardContent(
|
|
context,
|
|
totalUserPassages: totalUserPassages,
|
|
passagesCounts: passagesCounts,
|
|
);
|
|
},
|
|
);
|
|
} else {
|
|
// Données statiques
|
|
final totalPassages = widget.passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
|
|
return _buildCardContent(
|
|
context,
|
|
totalUserPassages: totalPassages,
|
|
passagesCounts: widget.passagesByType ?? {},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Construit le contenu de la card avec les données calculées
|
|
Widget _buildCardContent(
|
|
BuildContext context, {
|
|
required int totalUserPassages,
|
|
required Map<int, int> passagesCounts,
|
|
}) {
|
|
return Card(
|
|
elevation: 4,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
// Icône d'arrière-plan (optionnelle)
|
|
if (widget.backgroundIcon != null)
|
|
Positioned.fill(
|
|
child: Center(
|
|
child: Icon(
|
|
widget.backgroundIcon,
|
|
size: widget.backgroundIconSize,
|
|
color: (widget.backgroundIconColor ?? AppTheme.primaryColor)
|
|
.withOpacity(widget.backgroundIconOpacity),
|
|
),
|
|
),
|
|
),
|
|
// Contenu principal
|
|
Container(
|
|
height: widget.height,
|
|
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Titre avec comptage
|
|
_buildTitle(context, totalUserPassages),
|
|
const Divider(height: 24),
|
|
// Contenu principal
|
|
Expanded(
|
|
child: SizedBox(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Liste des passages à gauche
|
|
Expanded(
|
|
flex: widget.isDesktop ? 1 : 2,
|
|
child: _buildPassagesList(context, passagesCounts),
|
|
),
|
|
|
|
// Séparateur vertical
|
|
if (widget.isDesktop) const VerticalDivider(width: 24),
|
|
|
|
// Graphique en camembert à droite
|
|
Expanded(
|
|
flex: widget.isDesktop ? 1 : 2,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8.0),
|
|
child: _buildPieChart(passagesCounts),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Construction du titre
|
|
Widget _buildTitle(BuildContext context, int totalUserPassages) {
|
|
return Row(
|
|
children: [
|
|
if (widget.titleIcon != null) ...[
|
|
Icon(
|
|
widget.titleIcon,
|
|
color: widget.titleColor,
|
|
size: 24,
|
|
),
|
|
const SizedBox(width: 8),
|
|
],
|
|
Expanded(
|
|
child: Text(
|
|
widget.title,
|
|
style: TextStyle(
|
|
fontSize: AppTheme.r(context, 16),
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
widget.customTotalDisplay?.call(totalUserPassages) ??
|
|
totalUserPassages.toString(),
|
|
style: TextStyle(
|
|
fontSize: AppTheme.r(context, 20),
|
|
fontWeight: FontWeight.bold,
|
|
color: widget.titleColor,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 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;
|
|
final currentUser = userRepository.getCurrentUser();
|
|
if (currentUser != null && currentUser.fkEntite != null) {
|
|
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
|
if (userAmicale != null) {
|
|
showLotType = userAmicale.chkLotActif;
|
|
}
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
...AppKeys.typesPassages.entries.where((entry) {
|
|
// Exclure le type Lot (5) si chkLotActif = false
|
|
if (entry.key == 5 && !showLotType) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}).map((entry) {
|
|
final int typeId = entry.key;
|
|
final Map<String, dynamic> typeData = entry.value;
|
|
final int count = passagesCounts[typeId] ?? 0;
|
|
final Color color = Color(typeData['couleur2'] as int);
|
|
final IconData iconData = typeData['icon_data'] as IconData;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8.0),
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 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 = {};
|
|
|
|
// Vérifier si le type Lot doit être affiché
|
|
bool showLotType = true;
|
|
final currentUser = userRepository.getCurrentUser();
|
|
if (currentUser != null && currentUser.fkEntite != null) {
|
|
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
|
|
if (userAmicale != null) {
|
|
showLotType = userAmicale.chkLotActif;
|
|
}
|
|
}
|
|
|
|
// Initialiser tous les types
|
|
for (final typeId in AppKeys.typesPassages.keys) {
|
|
// Exclure le type Lot (5) si chkLotActif = false
|
|
if (typeId == 5 && !showLotType) {
|
|
continue;
|
|
}
|
|
// Exclure les types non désirés
|
|
if (widget.excludePassageTypes.contains(typeId)) {
|
|
continue;
|
|
}
|
|
counts[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 passagesBox.values) {
|
|
// Exclure le type Lot (5) si chkLotActif = false
|
|
if (passage.fkType == 5 && !showLotType) {
|
|
continue;
|
|
}
|
|
// Exclure les types non désirés
|
|
if (widget.excludePassageTypes.contains(passage.fkType)) {
|
|
continue;
|
|
}
|
|
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|