Amélioration de la splash_page et du login
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/presentation/widgets/chat/chat_sidebar.dart';
|
||||
import 'package:geosector_app/presentation/widgets/chat/chat_messages.dart';
|
||||
import 'package:geosector_app/presentation/widgets/chat/chat_input.dart';
|
||||
|
||||
@@ -3,18 +3,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'dart:math' as math;
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/activity_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
|
||||
import 'package:geosector_app/presentation/widgets/sector_distribution_card.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/operation_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
|
||||
/// Class pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@@ -281,7 +276,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
0.0,
|
||||
(sum, passage) =>
|
||||
sum +
|
||||
(passage.montant != null && passage.montant.isNotEmpty
|
||||
(passage.montant.isNotEmpty
|
||||
? double.tryParse(passage.montant) ?? 0.0
|
||||
: 0.0));
|
||||
|
||||
@@ -310,10 +305,8 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
|
||||
// Compter les passages par membre
|
||||
for (final passage in passages) {
|
||||
if (passage.fkUser != null) {
|
||||
memberCounts[passage.fkUser!] =
|
||||
(memberCounts[passage.fkUser!] ?? 0) + 1;
|
||||
}
|
||||
memberCounts[passage.fkUser] =
|
||||
(memberCounts[passage.fkUser] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Récupérer les informations des membres
|
||||
@@ -504,7 +497,6 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
key: ValueKey(
|
||||
'sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
|
||||
height: 200,
|
||||
forceRefresh: !isFirstLoad,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -531,7 +523,6 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
key: ValueKey(
|
||||
'sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
|
||||
height: 200,
|
||||
forceRefresh: !isFirstLoad,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -550,12 +541,10 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
key: ValueKey(
|
||||
'activity_chart_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
|
||||
height: 350,
|
||||
loadFromHive: true,
|
||||
showAllPassages:
|
||||
true, // Tous les passages, pas seulement ceux de l'utilisateur courant
|
||||
title: 'Passages réalisés par jour (15 derniers jours)',
|
||||
daysToShow: 15,
|
||||
forceRefresh: !isFirstLoad,
|
||||
),
|
||||
// Si vous avez besoin de passer l'ID de l'opération en cours, décommentez les lignes suivantes
|
||||
// child: ActivityChart(
|
||||
@@ -607,7 +596,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
const Text(
|
||||
'Actions sur cette opération',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -624,7 +613,7 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
context,
|
||||
'Exporter les données',
|
||||
Icons.file_download_outlined,
|
||||
AppTheme.buttonPrimaryColor,
|
||||
AppTheme.primaryColor,
|
||||
() {},
|
||||
),
|
||||
_buildActionButton(
|
||||
@@ -705,386 +694,54 @@ class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChartCard(
|
||||
BuildContext context,
|
||||
String title,
|
||||
Widget chart,
|
||||
) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
chart,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construit la carte de répartition par type de passage avec liste
|
||||
Widget _buildPassageTypeCard(BuildContext context) {
|
||||
return Container(
|
||||
height: 300, // Hauteur fixe de 300px
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par type de passage',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$totalPassages passages',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: AppTheme.spacingM),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Graphique à gauche
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
height: 180, // Taille réduite
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
// Vérifier si nous avons des données de passages
|
||||
if (passagesByType.isEmpty) {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Aucune donnée de passage disponible pour le graphique');
|
||||
return const Center(
|
||||
child: Text('Aucune donnée disponible'),
|
||||
);
|
||||
}
|
||||
|
||||
// Si nous avons des données, afficher le graphique
|
||||
// Mais d'abord, vérifier si tous les passages sont de type 2 (à finaliser)
|
||||
// qui est exclu par défaut dans PassagePieChart
|
||||
bool hasNonType2Passages = passagesByType.entries.any(
|
||||
(entry) => entry.key != 2 && entry.value > 0);
|
||||
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Données pour le graphique: $passagesByType');
|
||||
|
||||
// Créer un widget personnalisé pour afficher le graphique ou un message
|
||||
// selon le contenu des données
|
||||
if (passagesByType.isEmpty) {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Aucune donnée de passage disponible');
|
||||
return const Center(
|
||||
child: Text('Aucune donnée disponible'),
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier si nous avons des données pour au moins un type
|
||||
int totalPassages = 0;
|
||||
passagesByType
|
||||
.forEach((_, count) => totalPassages += count);
|
||||
|
||||
if (totalPassages == 0) {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Aucun passage trouvé');
|
||||
return const Center(
|
||||
child: Text('Aucun passage trouvé'),
|
||||
);
|
||||
}
|
||||
|
||||
// Vérifier si tous les passages sont de type 2 (à finaliser)
|
||||
if (!hasNonType2Passages) {
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Tous les passages sont de type 2 (à finaliser)');
|
||||
|
||||
// Créer un widget personnalisé pour afficher un message
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: Colors.orange,
|
||||
size: 40,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Uniquement des passages à finaliser',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${passagesByType[2] ?? 0} passages',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.orange,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Sinon, afficher le graphique avec les données
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Affichage du graphique avec ${passagesByType.length} types');
|
||||
return PassagePieChart(
|
||||
size: 180,
|
||||
passagesByType: passagesByType,
|
||||
loadFromHive: false,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
showIcons: false,
|
||||
showLegend: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des types à droite
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.end, // Alignement à droite
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
...AppKeys.typesPassages.entries.map((entry) {
|
||||
final int typeId = entry.key;
|
||||
final Map<String, dynamic> typeInfo = entry.value;
|
||||
final int count = passagesByType[typeId] ?? 0;
|
||||
final Color color =
|
||||
Color(typeInfo['couleur2'] as int);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment
|
||||
.end, // Alignement à droite
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$count ${typeInfo['titres']}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
textAlign: TextAlign
|
||||
.right, // Texte aligné à droite
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return PassageSummaryCard(
|
||||
title: 'Répartition par type de passage',
|
||||
titleColor: AppTheme.primaryColor,
|
||||
titleIcon: Icons.route,
|
||||
height: 300,
|
||||
useValueListenable: false, // Utiliser les données statiques
|
||||
showAllPassages: true,
|
||||
excludePassageTypes: const [2], // Exclure "À finaliser"
|
||||
passagesByType: passagesByType,
|
||||
customTotalDisplay: (total) => '$totalPassages passages',
|
||||
isDesktop: MediaQuery.of(context).size.width > 800,
|
||||
backgroundIcon: Icons.route,
|
||||
backgroundIconColor: AppTheme.primaryColor,
|
||||
backgroundIconOpacity: 0.07,
|
||||
backgroundIconSize: 180,
|
||||
);
|
||||
}
|
||||
|
||||
// Construit la carte de répartition par mode de paiement
|
||||
Widget _buildPaymentTypeCard(BuildContext context) {
|
||||
return Container(
|
||||
height: 300, // Hauteur fixe de 300px
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par mode de paiement',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${totalAmounts.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: AppTheme.buttonSuccessColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: AppTheme.spacingM),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Graphique à gauche
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
height: 180, // Taille réduite
|
||||
child: PaymentPieChart(
|
||||
size: 180,
|
||||
payments: paymentData,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
showIcons: false,
|
||||
showLegend: false,
|
||||
enable3DEffect:
|
||||
false, // Désactiver l'effet 3D pour conserver les couleurs originales
|
||||
effect3DIntensity: 0.0, // Pas d'intensité 3D
|
||||
enableEnhancedExplode: false, // Désactiver l'explosion
|
||||
useGradient:
|
||||
false, // Ne pas utiliser de dégradé pour conserver les couleurs originales
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des types de règlement à droite
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.end, // Alignement à droite
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
...[1, 2, 3].map((typeId) {
|
||||
// Uniquement les types 1, 2 et 3
|
||||
if (!AppKeys.typesReglements.containsKey(typeId)) {
|
||||
return const SizedBox
|
||||
.shrink(); // Ignorer si le type n'existe pas
|
||||
}
|
||||
|
||||
final Map<String, dynamic> typeInfo =
|
||||
AppKeys.typesReglements[typeId]!;
|
||||
|
||||
// Calculer le montant total pour ce type de règlement
|
||||
double amount = 0.0;
|
||||
for (final payment in paymentData) {
|
||||
if (payment.typeId == typeId) {
|
||||
amount = payment.amount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ne pas afficher si le montant est 0
|
||||
if (amount <= 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final Color color =
|
||||
Color(typeInfo['couleur'] as int);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment
|
||||
.end, // Alignement à droite
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${amount.toStringAsFixed(2)} € ${typeInfo['titre']}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
textAlign: TextAlign
|
||||
.right, // Texte aligné à droite
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return PaymentSummaryCard(
|
||||
title: 'Répartition par mode de paiement',
|
||||
titleColor: AppTheme.buttonSuccessColor,
|
||||
titleIcon: Icons.euro,
|
||||
height: 300,
|
||||
useValueListenable: false, // Utiliser les données statiques
|
||||
showAllPayments: true,
|
||||
paymentsByType: _convertPaymentDataToMap(paymentData),
|
||||
customTotalDisplay: (total) => '${totalAmounts.toStringAsFixed(2)} €',
|
||||
isDesktop: MediaQuery.of(context).size.width > 800,
|
||||
backgroundIcon: Icons.euro,
|
||||
backgroundIconColor: AppTheme.primaryColor,
|
||||
backgroundIconOpacity: 0.07,
|
||||
backgroundIconSize: 180,
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode helper pour convertir les PaymentData en Map
|
||||
Map<int, double> _convertPaymentDataToMap(List<PaymentData> paymentDataList) {
|
||||
final Map<int, double> result = {};
|
||||
for (final payment in paymentDataList) {
|
||||
result[payment.typeId] = payment.amount;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Widget _buildActionButton(
|
||||
BuildContext context,
|
||||
String label,
|
||||
|
||||
@@ -2,11 +2,7 @@ import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
import 'package:geosector_app/presentation/widgets/loading_progress_overlay.dart';
|
||||
import 'package:geosector_app/core/models/loading_state.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
// Import des pages admin
|
||||
@@ -54,14 +50,114 @@ class _AdminDashboardPageState extends State<AdminDashboardPage>
|
||||
// Liste des pages à afficher
|
||||
late final List<Widget> _pages;
|
||||
|
||||
// Index de la page Amicale et membres
|
||||
static const int entitePageIndex = 5;
|
||||
|
||||
// Référence à la boîte Hive pour les paramètres
|
||||
late Box _settingsBox;
|
||||
|
||||
// Overlay pour afficher la progression du chargement
|
||||
OverlayEntry? _progressOverlay;
|
||||
// Liste des éléments de navigation de base (toujours visibles)
|
||||
final List<_NavigationItem> _baseNavigationItems = [
|
||||
const _NavigationItem(
|
||||
label: 'Tableau de bord',
|
||||
icon: Icons.dashboard_outlined,
|
||||
selectedIcon: Icons.dashboard,
|
||||
page: AdminDashboardHomePage(),
|
||||
),
|
||||
const _NavigationItem(
|
||||
label: 'Statistiques',
|
||||
icon: Icons.bar_chart_outlined,
|
||||
selectedIcon: Icons.bar_chart,
|
||||
page: AdminStatisticsPage(),
|
||||
),
|
||||
const _NavigationItem(
|
||||
label: 'Historique',
|
||||
icon: Icons.history_outlined,
|
||||
selectedIcon: Icons.history,
|
||||
page: AdminHistoryPage(),
|
||||
),
|
||||
const _NavigationItem(
|
||||
label: 'Messages',
|
||||
icon: Icons.chat_outlined,
|
||||
selectedIcon: Icons.chat,
|
||||
page: AdminCommunicationPage(),
|
||||
),
|
||||
const _NavigationItem(
|
||||
label: 'Carte',
|
||||
icon: Icons.map_outlined,
|
||||
selectedIcon: Icons.map,
|
||||
page: AdminMapPage(),
|
||||
),
|
||||
];
|
||||
|
||||
// Éléments de navigation supplémentaires pour le rôle 2
|
||||
final List<_NavigationItem> _adminNavigationItems = [
|
||||
const _NavigationItem(
|
||||
label: 'Amicale & membres',
|
||||
icon: Icons.business_outlined,
|
||||
selectedIcon: Icons.business,
|
||||
page: AdminEntitePage(),
|
||||
requiredRole: 2,
|
||||
),
|
||||
const _NavigationItem(
|
||||
label: 'Opérations',
|
||||
icon: Icons.calendar_today_outlined,
|
||||
selectedIcon: Icons.calendar_today,
|
||||
page: Scaffold(body: Center(child: Text('Page Opérations'))),
|
||||
requiredRole: 2,
|
||||
),
|
||||
];
|
||||
|
||||
// Construire la liste des destinations de navigation en fonction du rôle
|
||||
List<NavigationDestination> _buildNavigationDestinations() {
|
||||
final destinations = <NavigationDestination>[];
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
|
||||
// Ajouter les éléments de base
|
||||
for (final item in _baseNavigationItems) {
|
||||
destinations.add(
|
||||
NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: Icon(item.selectedIcon),
|
||||
label: item.label,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Ajouter les éléments admin si l'utilisateur a le rôle requis
|
||||
if (currentUser?.role == 2) {
|
||||
for (final item in _adminNavigationItems) {
|
||||
if (item.requiredRole == null || item.requiredRole == 2) {
|
||||
destinations.add(
|
||||
NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: Icon(item.selectedIcon),
|
||||
label: item.label,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return destinations;
|
||||
}
|
||||
|
||||
// Construire la liste des pages en fonction du rôle
|
||||
List<Widget> _buildPages() {
|
||||
final pages = <Widget>[];
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
|
||||
// Ajouter les pages de base
|
||||
pages.addAll(_baseNavigationItems.map((item) => item.page));
|
||||
|
||||
// Ajouter les pages admin si l'utilisateur a le rôle requis
|
||||
if (currentUser?.role == 2) {
|
||||
for (final item in _adminNavigationItems) {
|
||||
if (item.requiredRole == null || item.requiredRole == 2) {
|
||||
pages.add(item.page);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -72,35 +168,19 @@ class _AdminDashboardPageState extends State<AdminDashboardPage>
|
||||
debugPrint('Initialisation de AdminDashboardPage');
|
||||
|
||||
// Vérifier que userRepository est correctement initialisé
|
||||
if (userRepository == null) {
|
||||
debugPrint('ERREUR: userRepository est null dans AdminDashboardPage');
|
||||
debugPrint('userRepository est correctement initialisé');
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
if (currentUser == null) {
|
||||
debugPrint(
|
||||
'ERREUR: Aucun utilisateur connecté dans AdminDashboardPage');
|
||||
} else {
|
||||
debugPrint('userRepository est correctement initialisé');
|
||||
|
||||
// Vérifier l'utilisateur courant
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
if (currentUser == null) {
|
||||
debugPrint(
|
||||
'ERREUR: Aucun utilisateur connecté dans AdminDashboardPage',
|
||||
);
|
||||
} else {
|
||||
debugPrint(
|
||||
'Utilisateur connecté: ${currentUser.username} (${currentUser.id})',
|
||||
);
|
||||
}
|
||||
|
||||
// Écouter les changements d'état du UserRepository
|
||||
userRepository.addListener(_handleUserRepositoryChanges);
|
||||
debugPrint(
|
||||
'Utilisateur connecté: ${currentUser.username} (${currentUser.id})');
|
||||
}
|
||||
userRepository.addListener(_handleUserRepositoryChanges);
|
||||
|
||||
_pages = [
|
||||
const AdminDashboardHomePage(),
|
||||
const AdminStatisticsPage(),
|
||||
const AdminHistoryPage(),
|
||||
const AdminCommunicationPage(),
|
||||
const AdminMapPage(),
|
||||
const AdminEntitePage(),
|
||||
];
|
||||
// Initialiser les pages et les destinations
|
||||
_pages = _buildPages();
|
||||
|
||||
// Initialiser et charger les paramètres
|
||||
_initSettings();
|
||||
@@ -117,10 +197,7 @@ class _AdminDashboardPageState extends State<AdminDashboardPage>
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
if (userRepository != null) {
|
||||
userRepository.removeListener(_handleUserRepositoryChanges);
|
||||
}
|
||||
_removeProgressOverlay();
|
||||
userRepository.removeListener(_handleUserRepositoryChanges);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -134,19 +211,6 @@ class _AdminDashboardPageState extends State<AdminDashboardPage>
|
||||
// La barre de progression est désactivée, ne rien faire
|
||||
}
|
||||
|
||||
// Méthodes pour gérer l'overlay de progression (désactivées)
|
||||
void _showProgressOverlay(LoadingState state) {
|
||||
// La barre de progression est désactivée, ne rien faire
|
||||
}
|
||||
|
||||
void _updateProgressOverlay(LoadingState state) {
|
||||
// La barre de progression est désactivée, ne rien faire
|
||||
}
|
||||
|
||||
void _removeProgressOverlay() {
|
||||
// La barre de progression est désactivée, ne rien faire
|
||||
}
|
||||
|
||||
// Initialiser la boîte de paramètres et charger les préférences
|
||||
Future<void> _initSettings() async {
|
||||
try {
|
||||
@@ -233,47 +297,21 @@ class _AdminDashboardPageState extends State<AdminDashboardPage>
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit la liste des destinations de navigation
|
||||
List<NavigationDestination> _buildNavigationDestinations() {
|
||||
// Destinations de base toujours présentes
|
||||
final List<NavigationDestination> destinations = [
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: 'Tableau de bord',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.bar_chart_outlined),
|
||||
selectedIcon: Icon(Icons.bar_chart),
|
||||
label: 'Statistiques',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.history_outlined),
|
||||
selectedIcon: Icon(Icons.history),
|
||||
label: 'Historique',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.chat_outlined),
|
||||
selectedIcon: Icon(Icons.chat),
|
||||
label: 'Messages',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.map_outlined),
|
||||
selectedIcon: Icon(Icons.map),
|
||||
label: 'Carte',
|
||||
),
|
||||
];
|
||||
|
||||
// Ajouter la destination "Amicale et membres"
|
||||
destinations.add(
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.business_outlined),
|
||||
selectedIcon: Icon(Icons.business),
|
||||
label: 'Amicale',
|
||||
),
|
||||
);
|
||||
|
||||
return destinations;
|
||||
}
|
||||
}
|
||||
|
||||
// Classe pour représenter une destination de navigation avec sa page associée
|
||||
class _NavigationItem {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final IconData selectedIcon;
|
||||
final Widget page;
|
||||
final int? requiredRole; // null si accessible à tous les rôles
|
||||
|
||||
const _NavigationItem({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.selectedIcon,
|
||||
required this.page,
|
||||
this.requiredRole,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/services/location_service.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import '../../shared/app_theme.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
|
||||
class AdminMapPage extends StatefulWidget {
|
||||
const AdminMapPage({Key? key}) : super(key: key);
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:geosector_app/presentation/widgets/charts/activity_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/combined_chart.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import '../../shared/app_theme.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Class pour dessiner les petits points blancs sur le fond
|
||||
@@ -186,7 +178,6 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
ActivityChart(
|
||||
height: 350,
|
||||
loadFromHive: true,
|
||||
showAllPassages: true,
|
||||
title: '',
|
||||
daysToShow: _daysToShow,
|
||||
@@ -210,13 +201,21 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
Expanded(
|
||||
child: _buildChartCard(
|
||||
'Répartition par type de passage',
|
||||
PassagePieChart(
|
||||
size: 300,
|
||||
loadFromHive: true,
|
||||
PassageSummaryCard(
|
||||
title: '',
|
||||
titleColor: AppTheme.primaryColor,
|
||||
titleIcon: Icons.pie_chart,
|
||||
height: 300,
|
||||
useValueListenable: true,
|
||||
showAllPassages: true,
|
||||
excludePassageTypes: const [
|
||||
2
|
||||
], // Exclure "À finaliser"
|
||||
userId: _selectedUser != 'Tous'
|
||||
? _getUserIdFromName(_selectedUser)
|
||||
: null,
|
||||
isDesktop:
|
||||
MediaQuery.of(context).size.width > 800,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -224,7 +223,7 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
Expanded(
|
||||
child: _buildChartCard(
|
||||
'Répartition par mode de paiement',
|
||||
PaymentPieChart(
|
||||
const PaymentPieChart(
|
||||
payments: [
|
||||
PaymentData(
|
||||
typeId: 1,
|
||||
@@ -258,19 +257,26 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
children: [
|
||||
_buildChartCard(
|
||||
'Répartition par type de passage',
|
||||
PassagePieChart(
|
||||
size: 300,
|
||||
loadFromHive: true,
|
||||
PassageSummaryCard(
|
||||
title: '',
|
||||
titleColor: AppTheme.primaryColor,
|
||||
titleIcon: Icons.pie_chart,
|
||||
height: 300,
|
||||
useValueListenable: true,
|
||||
showAllPassages: true,
|
||||
excludePassageTypes: const [
|
||||
2
|
||||
], // Exclure "À finaliser"
|
||||
userId: _selectedUser != 'Tous'
|
||||
? _getUserIdFromName(_selectedUser)
|
||||
: null,
|
||||
isDesktop: MediaQuery.of(context).size.width > 800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildChartCard(
|
||||
'Répartition par mode de paiement',
|
||||
PaymentPieChart(
|
||||
const PaymentPieChart(
|
||||
payments: [
|
||||
PaymentData(
|
||||
typeId: 1,
|
||||
@@ -357,7 +363,7 @@ class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
icon: const Icon(Icons.print),
|
||||
label: const Text('Imprimer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.buttonSecondaryColor,
|
||||
backgroundColor: AppTheme.secondaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
||||
import 'dart:js' as js;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:go_router/src/state.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:geosector_app/core/services/app_info_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_button.dart';
|
||||
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
|
||||
import 'package:geosector_app/core/services/location_service.dart';
|
||||
import 'package:geosector_app/core/services/connectivity_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
@@ -51,6 +52,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
final _passwordController = TextEditingController();
|
||||
final _usernameFocusNode = FocusNode();
|
||||
bool _obscurePassword = true;
|
||||
String _appVersion = '';
|
||||
|
||||
// Type de connexion (utilisateur ou administrateur)
|
||||
late String _loginType;
|
||||
@@ -63,6 +65,37 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// État de la connexion Internet
|
||||
bool _isConnected = false;
|
||||
|
||||
Future<void> _getAppVersion() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = packageInfo.version;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération de la version: $e');
|
||||
// Fallback sur la version du AppInfoService si elle existe
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = AppInfoService.fullVersion
|
||||
.split(' ')
|
||||
.last; // Extraire juste le numéro
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkConnectivity() async {
|
||||
await connectivityService.checkConnectivity();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isConnected = connectivityService.isConnected;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -163,6 +196,12 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}
|
||||
});
|
||||
|
||||
// Récupérer la version de l'application
|
||||
_getAppVersion();
|
||||
|
||||
// Vérification de connectivité au démarrage
|
||||
_checkConnectivity();
|
||||
|
||||
// Pré-remplir le champ username avec l'identifiant du dernier utilisateur connecté
|
||||
// seulement si le rôle correspond au type de login
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
@@ -538,6 +577,14 @@ class _LoginPageState extends State<LoginPage> {
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
AppInfoService.fullVersion,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Ajouter un texte de débogage uniquement en mode développement
|
||||
if (kDebugMode)
|
||||
Text(
|
||||
@@ -558,7 +605,38 @@ class _LoginPageState extends State<LoginPage> {
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Indicateur de connectivité
|
||||
ConnectivityIndicator(),
|
||||
const ConnectivityIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (!kIsWeb && !_isConnected)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color:
|
||||
theme.colorScheme.error.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.signal_wifi_off,
|
||||
color: theme.colorScheme.error, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text('Connexion Internet requise',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.error)),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Veuillez vous connecter à Internet (WiFi ou données mobiles) pour pouvoir vous connecter.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Formulaire de connexion
|
||||
@@ -689,7 +767,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () {
|
||||
// Naviguer vers la page de récupération de mot de passe
|
||||
_showForgotPasswordDialog(context);
|
||||
},
|
||||
child: Text(
|
||||
'Mot de passe oublié ?',
|
||||
@@ -863,10 +941,10 @@ class _LoginPageState extends State<LoginPage> {
|
||||
onPressed: () {
|
||||
context.go('/register');
|
||||
},
|
||||
child: Text(
|
||||
child: const Text(
|
||||
'Inscription Administrateur',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.tertiary,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -897,8 +975,255 @@ class _LoginPageState extends State<LoginPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Badge de version en bas à droite
|
||||
if (_appVersion.isNotEmpty)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'v$_appVersion',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary.withOpacity(0.8),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Affiche la boîte de dialogue pour la récupération de mot de passe
|
||||
void _showForgotPasswordDialog(BuildContext context) {
|
||||
final emailController = TextEditingController();
|
||||
final formKey = GlobalKey<FormState>();
|
||||
bool isLoading = false;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return StatefulBuilder(builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.lock_reset, color: Colors.blue),
|
||||
SizedBox(width: 10),
|
||||
Text('Récupération de mot de passe'),
|
||||
],
|
||||
),
|
||||
content: Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
'Veuillez entrer votre adresse email pour recevoir un nouveau mot de passe.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomTextField(
|
||||
controller: emailController,
|
||||
label: 'Email',
|
||||
hintText: 'Entrez votre email',
|
||||
prefixIcon: Icons.email_outlined,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Veuillez entrer votre email';
|
||||
}
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
|
||||
.hasMatch(value)) {
|
||||
return 'Veuillez entrer un email valide';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () async {
|
||||
if (formKey.currentState!.validate()) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Vérifier la connexion Internet
|
||||
await connectivityService.checkConnectivity();
|
||||
if (!connectivityService.isConnected) {
|
||||
throw Exception('Aucune connexion Internet');
|
||||
}
|
||||
|
||||
// Construire l'URL de l'API
|
||||
final baseUrl = Uri.base.origin;
|
||||
final apiUrl = '$baseUrl/api/lostpassword';
|
||||
|
||||
print('Envoi de la requête à: $apiUrl');
|
||||
print('Email: ${emailController.text.trim()}');
|
||||
|
||||
http.Response? response;
|
||||
|
||||
try {
|
||||
// Envoyer la requête à l'API
|
||||
response = await http.post(
|
||||
Uri.parse(apiUrl),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({
|
||||
'email': emailController.text.trim(),
|
||||
}),
|
||||
);
|
||||
|
||||
print('Réponse reçue: ${response.statusCode}');
|
||||
print('Corps de la réponse: ${response.body}');
|
||||
|
||||
// Si la réponse est 404, c'est peut-être un problème de route
|
||||
if (response.statusCode == 404) {
|
||||
// Essayer avec une URL alternative
|
||||
final alternativeUrl =
|
||||
'$baseUrl/api/index.php/lostpassword';
|
||||
print(
|
||||
'Tentative avec URL alternative: $alternativeUrl');
|
||||
|
||||
final alternativeResponse = await http.post(
|
||||
Uri.parse(alternativeUrl),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: json.encode({
|
||||
'email': emailController.text.trim(),
|
||||
}),
|
||||
);
|
||||
|
||||
print(
|
||||
'Réponse alternative reçue: ${alternativeResponse.statusCode}');
|
||||
print(
|
||||
'Corps de la réponse alternative: ${alternativeResponse.body}');
|
||||
|
||||
// Si la réponse alternative est un succès, utiliser cette réponse
|
||||
if (alternativeResponse.statusCode == 200) {
|
||||
response = alternativeResponse;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
print(
|
||||
'Erreur lors de l\'envoi de la requête: $e');
|
||||
throw Exception('Erreur de connexion: $e');
|
||||
}
|
||||
|
||||
// Traiter la réponse
|
||||
if (response != null &&
|
||||
response.statusCode == 200) {
|
||||
// Modifier le contenu de la boîte de dialogue pour afficher le message de succès
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
|
||||
// Remplacer le contenu de la boîte de dialogue par un message de succès
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
// Fermer automatiquement la boîte de dialogue après 2 secondes
|
||||
Future.delayed(Duration(seconds: 2), () {
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
});
|
||||
|
||||
return const AlertDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.green,
|
||||
size: 48,
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Vous recevrez un nouveau mot de passe par email',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Fermer la boîte de dialogue actuelle
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// Afficher un message d'erreur
|
||||
final responseData = json.decode(response.body);
|
||||
throw Exception(responseData['message'] ??
|
||||
'Erreur lors de la récupération du mot de passe');
|
||||
}
|
||||
} catch (e) {
|
||||
// Afficher un message d'erreur
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e
|
||||
.toString()
|
||||
.contains('Exception:')
|
||||
? e.toString().split('Exception: ')[1]
|
||||
: 'Erreur lors de la récupération du mot de passe'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
child: isLoading
|
||||
? SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: Text('Recevoir un nouveau mot de passe'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:geosector_app/core/services/app_info_service.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
@@ -15,10 +13,11 @@ import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/core/data/models/user_sector_model.dart';
|
||||
import 'package:geosector_app/chat/models/conversation_model.dart';
|
||||
import 'package:geosector_app/chat/models/message_model.dart';
|
||||
import 'package:geosector_app/core/services/hive_reset_state_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/clear_cache_dialog.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class SplashPage extends StatefulWidget {
|
||||
const SplashPage({super.key});
|
||||
@@ -58,12 +57,28 @@ class _SplashPageState extends State<SplashPage>
|
||||
String _statusMessage = "Initialisation...";
|
||||
double _progress = 0.0;
|
||||
bool _showButtons = false;
|
||||
String _appVersion = '';
|
||||
|
||||
final List<String> _initializationSteps = [
|
||||
"Initialisation des services...",
|
||||
"Vérification de l'authentification...",
|
||||
"Chargement des données..."
|
||||
];
|
||||
Future<void> _getAppVersion() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = packageInfo.version;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la récupération de la version: $e');
|
||||
// Fallback sur la version du AppInfoService si elle existe
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_appVersion = AppInfoService.fullVersion
|
||||
.split(' ')
|
||||
.last; // Extraire juste le numéro
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -89,33 +104,12 @@ class _SplashPageState extends State<SplashPage>
|
||||
// Démarrer l'animation immédiatement
|
||||
_animationController.forward();
|
||||
|
||||
// Vérifier si Hive a été réinitialisé
|
||||
_checkHiveReset();
|
||||
_getAppVersion();
|
||||
|
||||
// Simuler le processus d'initialisation
|
||||
_startInitialization();
|
||||
}
|
||||
|
||||
// Méthode pour vérifier si Hive a été réinitialisé et afficher le dialogue si nécessaire
|
||||
void _checkHiveReset() {
|
||||
// Vérifier si Hive a été réinitialisé et si le dialogue n'a pas encore été affiché
|
||||
if (hiveResetStateService.wasReset && !hiveResetStateService.dialogShown) {
|
||||
// Attendre que le widget soit complètement construit
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) {
|
||||
// Afficher le dialogue de nettoyage du cache
|
||||
ClearCacheDialog.show(
|
||||
context,
|
||||
onClose: () {
|
||||
// Marquer le dialogue comme ayant été affiché
|
||||
hiveResetStateService.markDialogAsShown();
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
@@ -123,11 +117,11 @@ class _SplashPageState extends State<SplashPage>
|
||||
}
|
||||
|
||||
void _startInitialization() async {
|
||||
// Étape 1: Initialisation des services
|
||||
// Étape 1: Initialisation des boîtes Hive (0% à 75%)
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = _initializationSteps[0];
|
||||
_progress = 0.2;
|
||||
_statusMessage = "Initialisation des données...";
|
||||
_progress = 0.0;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -135,20 +129,11 @@ class _SplashPageState extends State<SplashPage>
|
||||
await _initializeAllHiveBoxes();
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Étape 2: Vérification de l'authentification
|
||||
// Étape 2: Initialisation des services (75% à 100%)
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = _initializationSteps[1];
|
||||
_progress = 0.4;
|
||||
});
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
// Étape 3: Chargement des données
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = _initializationSteps[2];
|
||||
_progress = 1.0; // Directement à 100% après la 3ème étape
|
||||
_statusMessage = "Préparation de l'application...";
|
||||
_progress = 0.75;
|
||||
});
|
||||
}
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
@@ -157,15 +142,8 @@ class _SplashPageState extends State<SplashPage>
|
||||
setState(() {
|
||||
_isInitializing = false;
|
||||
_showButtons = true;
|
||||
_progress = 1.0; // S'assurer que la barre est à 100%
|
||||
});
|
||||
|
||||
// Attendre quelques secondes avant de rediriger automatiquement
|
||||
// si l'utilisateur est déjà connecté
|
||||
if (userRepository.isLoggedIn) {
|
||||
Timer(const Duration(seconds: 2), () {
|
||||
_redirectToAppropriateScreen();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,8 +180,8 @@ class _SplashPageState extends State<SplashPage>
|
||||
},
|
||||
];
|
||||
|
||||
// Calculer l'incrément de progression pour chaque boîte
|
||||
final progressIncrement = 0.2 / boxesToOpen.length;
|
||||
// Calculer l'incrément de progression pour chaque boîte (0.75 / nombre de boîtes)
|
||||
final progressIncrement = 0.75 / boxesToOpen.length;
|
||||
double currentProgress = 0.0;
|
||||
|
||||
// Ouvrir chaque boîte si elle n'est pas déjà ouverte
|
||||
@@ -212,12 +190,11 @@ class _SplashPageState extends State<SplashPage>
|
||||
final displayName = boxesToOpen[i]['display'] as String;
|
||||
|
||||
// Mettre à jour la barre de progression et le message
|
||||
currentProgress += progressIncrement;
|
||||
currentProgress = progressIncrement * (i + 1);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = displayName;
|
||||
_progress =
|
||||
0.2 * (currentProgress / 0.2); // Normaliser entre 0 et 0.2
|
||||
_progress = currentProgress;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -266,10 +243,27 @@ class _SplashPageState extends State<SplashPage>
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = 'Toutes les boîtes sont prêtes';
|
||||
_progress = 0.2;
|
||||
_progress = 0.8;
|
||||
});
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusMessage = "Préparation de l'application...";
|
||||
_progress = 0.9;
|
||||
});
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
}
|
||||
|
||||
// Finalisation
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isInitializing = false;
|
||||
_showButtons = true;
|
||||
_progress = 1.0;
|
||||
});
|
||||
}
|
||||
debugPrint('Toutes les boîtes Hive sont maintenant ouvertes');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation des boîtes Hive: $e');
|
||||
@@ -283,33 +277,9 @@ class _SplashPageState extends State<SplashPage>
|
||||
}
|
||||
}
|
||||
|
||||
void _redirectToAppropriateScreen() {
|
||||
if (!mounted) return;
|
||||
|
||||
// Utiliser l'instance globale de userRepository définie dans app.dart
|
||||
if (userRepository.isLoggedIn) {
|
||||
debugPrint('SplashPage: Redirection d\'utilisateur connecté');
|
||||
|
||||
// Récupérer directement le rôle utilisateur
|
||||
final roleValue = userRepository.getUserRole();
|
||||
debugPrint('SplashPage: Rôle utilisateur = $roleValue');
|
||||
|
||||
// Redirection simple basée sur le rôle
|
||||
if (roleValue > 1) {
|
||||
debugPrint('SplashPage: Redirection vers /admin (rôle $roleValue > 1)');
|
||||
context.go('/admin');
|
||||
} else {
|
||||
debugPrint('SplashPage: Redirection vers /user (rôle $roleValue = 1)');
|
||||
context.go('/user');
|
||||
}
|
||||
}
|
||||
// Ne redirige plus vers la landing page
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final size = MediaQuery.of(context).size;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
@@ -424,7 +394,8 @@ class _SplashPageState extends State<SplashPage>
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.go('/login', extra: {'type': 'user'});
|
||||
context.go(
|
||||
'/login/user'); // Utiliser la route spécifique
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
@@ -449,13 +420,14 @@ class _SplashPageState extends State<SplashPage>
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bouton Connexion Administrateur
|
||||
// Bouton Connexion Administrateur
|
||||
AnimatedOpacity(
|
||||
opacity: _showButtons ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.go('/login', extra: {'type': 'admin'});
|
||||
context.go(
|
||||
'/login/admin'); // Utiliser la route spécifique
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
@@ -479,18 +451,74 @@ class _SplashPageState extends State<SplashPage>
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(
|
||||
height: 32), // 2 espaces sous le bouton précédent
|
||||
|
||||
// Lien d'inscription
|
||||
// Bouton d'inscription
|
||||
AnimatedOpacity(
|
||||
opacity: _showButtons ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: TextButton(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
context.go('/register');
|
||||
},
|
||||
child: Text(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blue,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 40,
|
||||
vertical: 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
elevation: 2,
|
||||
),
|
||||
child: const Text(
|
||||
'Pas encore inscrit ?',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Lien vers le site web
|
||||
AnimatedOpacity(
|
||||
opacity: _showButtons ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: TextButton.icon(
|
||||
onPressed: () {
|
||||
// Déterminer l'URL du site web en fonction de l'environnement
|
||||
String webUrl = 'https://geosector.fr';
|
||||
|
||||
if (kIsWeb) {
|
||||
final host = Uri.base.host;
|
||||
if (host.startsWith('dapp.')) {
|
||||
webUrl = 'https://dev.geosector.fr';
|
||||
} else if (host.startsWith('rapp.')) {
|
||||
webUrl = 'https://rec.geosector.fr';
|
||||
} else if (host.startsWith('app.')) {
|
||||
webUrl = 'https://geosector.fr';
|
||||
}
|
||||
}
|
||||
|
||||
// Ouvrir l'URL dans une nouvelle fenêtre/onglet
|
||||
launchUrl(
|
||||
Uri.parse(webUrl),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.language,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
label: Text(
|
||||
'Site web Geosector',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
@@ -506,6 +534,38 @@ class _SplashPageState extends State<SplashPage>
|
||||
),
|
||||
),
|
||||
),
|
||||
// Badge de version en bas à droite
|
||||
if (_appVersion.isNotEmpty)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
right: 16,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showButtons ? 0.7 : 0.5,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'v$_appVersion',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passages/passage_form.dart';
|
||||
|
||||
// Import des pages utilisateur
|
||||
import 'user_dashboard_home_page.dart';
|
||||
@@ -94,73 +94,85 @@ class _UserDashboardPageState extends State<UserDashboardPage> {
|
||||
final bool shouldShowNoOperationMessage = isStandardUser && !hasOperation;
|
||||
final bool shouldShowNoSectorMessage = isStandardUser && !hasSectors;
|
||||
|
||||
// Définir les actions supplémentaires pour l'AppBar
|
||||
List<Widget>? additionalActions;
|
||||
if (shouldShowNoOperationMessage || shouldShowNoSectorMessage) {
|
||||
additionalActions = [
|
||||
// Bouton de déconnexion uniquement si l'utilisateur n'a pas d'opération
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.logout, color: Colors.white),
|
||||
label: const Text('Se déconnecter',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
onPressed: () async {
|
||||
// Utiliser directement userRepository pour la déconnexion
|
||||
await userRepository.logoutWithUI(context);
|
||||
// La redirection est gérée dans logoutWithUI
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
// Si l'utilisateur n'a pas d'opération ou de secteur, utiliser DashboardLayout avec un body spécial
|
||||
if (shouldShowNoOperationMessage) {
|
||||
return DashboardLayout(
|
||||
title: 'GEOSECTOR',
|
||||
selectedIndex: 0, // Index par défaut
|
||||
onDestinationSelected: (index) {
|
||||
// Ne rien faire car l'utilisateur ne peut pas naviguer
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.warning_outlined),
|
||||
selectedIcon: Icon(Icons.warning),
|
||||
label: 'Accès restreint',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16), // Espacement à droite
|
||||
];
|
||||
],
|
||||
showNewPassageButton: false,
|
||||
body: _buildNoOperationMessage(context),
|
||||
);
|
||||
}
|
||||
|
||||
return shouldShowNoOperationMessage
|
||||
? _buildNoOperationMessage(context)
|
||||
: (shouldShowNoSectorMessage
|
||||
? _buildNoSectorMessage(context)
|
||||
: DashboardLayout(
|
||||
title: 'GEOSECTOR',
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
_saveSettings(); // Sauvegarder l'index de page sélectionné
|
||||
});
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: 'Tableau de bord',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.bar_chart_outlined),
|
||||
selectedIcon: Icon(Icons.bar_chart),
|
||||
label: 'Stats',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.history_outlined),
|
||||
selectedIcon: Icon(Icons.history),
|
||||
label: 'Historique',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_outlined),
|
||||
selectedIcon: Icon(Icons.chat),
|
||||
label: 'Messages',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.map_outlined),
|
||||
selectedIcon: Icon(Icons.map),
|
||||
label: 'Carte',
|
||||
),
|
||||
],
|
||||
additionalActions: additionalActions,
|
||||
onNewPassagePressed: () => _showPassageForm(context),
|
||||
body: _pages[_selectedIndex],
|
||||
));
|
||||
if (shouldShowNoSectorMessage) {
|
||||
return DashboardLayout(
|
||||
title: 'GEOSECTOR',
|
||||
selectedIndex: 0, // Index par défaut
|
||||
onDestinationSelected: (index) {
|
||||
// Ne rien faire car l'utilisateur ne peut pas naviguer
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.warning_outlined),
|
||||
selectedIcon: Icon(Icons.warning),
|
||||
label: 'Accès restreint',
|
||||
),
|
||||
],
|
||||
showNewPassageButton: false,
|
||||
body: _buildNoSectorMessage(context),
|
||||
);
|
||||
}
|
||||
|
||||
// Utilisateur normal avec accès complet
|
||||
return DashboardLayout(
|
||||
title: 'GEOSECTOR',
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
_saveSettings(); // Sauvegarder l'index de page sélectionné
|
||||
});
|
||||
},
|
||||
destinations: const [
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.dashboard_outlined),
|
||||
selectedIcon: Icon(Icons.dashboard),
|
||||
label: 'Tableau de bord',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.bar_chart_outlined),
|
||||
selectedIcon: Icon(Icons.bar_chart),
|
||||
label: 'Stats',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.history_outlined),
|
||||
selectedIcon: Icon(Icons.history),
|
||||
label: 'Historique',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_outlined),
|
||||
selectedIcon: Icon(Icons.chat),
|
||||
label: 'Messages',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.map_outlined),
|
||||
selectedIcon: Icon(Icons.map),
|
||||
label: 'Carte',
|
||||
),
|
||||
],
|
||||
onNewPassagePressed: () => _showPassageForm(context),
|
||||
body: _pages[_selectedIndex],
|
||||
);
|
||||
}
|
||||
|
||||
// Message pour les utilisateurs sans opération assignée
|
||||
@@ -269,110 +281,90 @@ class _UserDashboardPageState extends State<UserDashboardPage> {
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(
|
||||
'Nouveau passage',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
builder: (context) => Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 600,
|
||||
maxHeight: 700,
|
||||
),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Adresse',
|
||||
prefixIcon: const Icon(Icons.location_on),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
// En-tête de la modale
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Nouveau passage',
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Type de passage',
|
||||
prefixIcon: const Icon(Icons.category),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 1,
|
||||
child: Text('Effectué'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 2,
|
||||
child: Text('À finaliser'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 3,
|
||||
child: Text('Refusé'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 4,
|
||||
child: Text('Don'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 5,
|
||||
child: Text('Lot'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 6,
|
||||
child: Text('Maison vide'),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Commentaire',
|
||||
prefixIcon: const Icon(Icons.comment),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Formulaire de passage
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: PassageForm(
|
||||
onSubmit: (formData) {
|
||||
// Traiter les données du formulaire
|
||||
_handlePassageSubmission(context, formData);
|
||||
},
|
||||
),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
'Annuler',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Enregistrer le passage
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Passage enregistré avec succès'),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
),
|
||||
child: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Traiter la soumission du formulaire de passage
|
||||
void _handlePassageSubmission(
|
||||
BuildContext context, Map<String, dynamic> formData) {
|
||||
// Fermer la modale
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// Ici vous pouvez traiter les données du formulaire
|
||||
// Par exemple, les envoyer au repository ou à un service
|
||||
|
||||
// Pour l'instant, afficher un message de succès
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Passage enregistré avec succès pour ${formData['adresse']}'),
|
||||
backgroundColor: Theme.of(context).colorScheme.primary,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
|
||||
// TODO: Intégrer avec votre logique métier
|
||||
// Exemple :
|
||||
// try {
|
||||
// await passageRepository.createPassage(formData);
|
||||
// // Rafraîchir les données si nécessaire
|
||||
// } catch (e) {
|
||||
// ScaffoldMessenger.of(context).showSnackBar(
|
||||
// SnackBar(
|
||||
// content: Text('Erreur lors de l\'enregistrement: $e'),
|
||||
// backgroundColor: Theme.of(context).colorScheme.error,
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
|
||||
class UserStatisticsPage extends StatefulWidget {
|
||||
const UserStatisticsPage({super.key});
|
||||
@@ -58,8 +55,9 @@ class _UserStatisticsPageState extends State<UserStatisticsPage> {
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Résumé par type de règlement
|
||||
_buildPaymentTypeSummary(theme, isDesktop),
|
||||
// Résumé par type de règlement
|
||||
_buildPaymentTypeSummary(theme, isDesktop),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -363,219 +361,34 @@ class _UserStatisticsPageState extends State<UserStatisticsPage> {
|
||||
|
||||
// Construction du résumé par type de passage
|
||||
Widget _buildPassageTypeSummary(ThemeData theme, bool isDesktop) {
|
||||
// Dans une implémentation réelle, ces données seraient filtrées par secteur
|
||||
// en fonction de _selectedSectorId
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par type de passage',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
// Graphique circulaire
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
child: PassagePieChart(
|
||||
passagesByType: {
|
||||
1: 60, // Effectués
|
||||
2: 15, // À finaliser
|
||||
3: 10, // Refusés
|
||||
4: 8, // Dons
|
||||
5: 5, // Lots
|
||||
6: 2, // Maisons vides
|
||||
},
|
||||
size: 140,
|
||||
labelSize: 12,
|
||||
showPercentage: true,
|
||||
showIcons: false, // Désactiver les icônes
|
||||
isDonut: true, // Activer le format donut
|
||||
innerRadius: '50%' // Rayon interne du donut
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Légende
|
||||
if (isDesktop)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildLegendItem(
|
||||
'Effectués', '60%', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem(
|
||||
'À finaliser', '15%', const Color(0xFFFF9800)),
|
||||
_buildLegendItem(
|
||||
'Refusés', '10%', const Color(0xFFF44336)),
|
||||
_buildLegendItem('Dons', '8%', const Color(0xFF03A9F4)),
|
||||
_buildLegendItem('Lots', '5%', const Color(0xFF0D47A1)),
|
||||
_buildLegendItem(
|
||||
'Maisons vides', '2%', const Color(0xFF9E9E9E)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!isDesktop)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
_buildLegendItem('Effectués', '60%', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem(
|
||||
'À finaliser', '15%', const Color(0xFFFF9800)),
|
||||
_buildLegendItem('Refusés', '10%', const Color(0xFFF44336)),
|
||||
_buildLegendItem('Dons', '8%', const Color(0xFF03A9F4)),
|
||||
_buildLegendItem('Lots', '5%', const Color(0xFF0D47A1)),
|
||||
_buildLegendItem(
|
||||
'Maisons vides', '2%', const Color(0xFF9E9E9E)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
return PassageSummaryCard(
|
||||
title: 'Répartition par type de passage',
|
||||
titleColor: theme.colorScheme.primary,
|
||||
titleIcon: Icons.pie_chart,
|
||||
height: 300,
|
||||
useValueListenable: true,
|
||||
userId: userRepository.getCurrentUser()?.id,
|
||||
showAllPassages: false,
|
||||
excludePassageTypes: const [2], // Exclure "À finaliser"
|
||||
isDesktop: isDesktop,
|
||||
);
|
||||
}
|
||||
|
||||
// Construction du résumé par type de règlement
|
||||
Widget _buildPaymentTypeSummary(ThemeData theme, bool isDesktop) {
|
||||
// Dans une implémentation réelle, ces données seraient filtrées par secteur
|
||||
// en fonction de _selectedSectorId
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par type de règlement',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
// Graphique circulaire
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
sectionsSpace: 2,
|
||||
centerSpaceRadius: 40,
|
||||
sections: [
|
||||
_buildPieChartSection(
|
||||
'Espèces', 30, const Color(0xFF4CAF50), 0),
|
||||
_buildPieChartSection(
|
||||
'Chèques', 45, const Color(0xFF2196F3), 1),
|
||||
_buildPieChartSection(
|
||||
'CB', 25, const Color(0xFFF44336), 2),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Légende
|
||||
if (isDesktop)
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildLegendItem(
|
||||
'Espèces', '30%', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem(
|
||||
'Chèques', '45%', const Color(0xFF2196F3)),
|
||||
_buildLegendItem('CB', '25%', const Color(0xFFF44336)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!isDesktop)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
_buildLegendItem('Espèces', '30%', const Color(0xFF4CAF50)),
|
||||
_buildLegendItem('Chèques', '45%', const Color(0xFF2196F3)),
|
||||
_buildLegendItem('CB', '25%', const Color(0xFFF44336)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction d'une section de graphique circulaire
|
||||
PieChartSectionData _buildPieChartSection(
|
||||
String title, double value, Color color, int index) {
|
||||
return PieChartSectionData(
|
||||
color: color,
|
||||
value: value,
|
||||
title: '$value%',
|
||||
radius: 60,
|
||||
titleStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construction d'un élément de légende
|
||||
Widget _buildLegendItem(String title, String value, Color color) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
Widget _buildPaymentTypeSummary(ThemeData theme, bool isDesktop) {
|
||||
return PaymentSummaryCard(
|
||||
title: 'Répartition par type de règlement',
|
||||
titleColor: AppTheme.accentColor,
|
||||
titleIcon: Icons.pie_chart,
|
||||
height: 300,
|
||||
useValueListenable: true,
|
||||
userId: userRepository.getCurrentUser()?.id,
|
||||
showAllPayments: false,
|
||||
isDesktop: isDesktop,
|
||||
backgroundIcon: Icons.euro_symbol,
|
||||
backgroundIconColor: Colors.blue,
|
||||
backgroundIconOpacity: 0.05,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,22 +1,20 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/region_repository.dart';
|
||||
import 'package:geosector_app/core/services/api_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/mapbox_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'custom_text_field.dart';
|
||||
|
||||
class EntiteForm extends StatefulWidget {
|
||||
class AmicaleForm extends StatefulWidget {
|
||||
final AmicaleModel? amicale;
|
||||
final Function(AmicaleModel)? onSubmit;
|
||||
final bool readOnly;
|
||||
|
||||
const EntiteForm({
|
||||
const AmicaleForm({
|
||||
Key? key,
|
||||
this.amicale,
|
||||
this.onSubmit,
|
||||
@@ -24,10 +22,10 @@ class EntiteForm extends StatefulWidget {
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EntiteForm> createState() => _EntiteFormState();
|
||||
State<AmicaleForm> createState() => _AmicaleFormState();
|
||||
}
|
||||
|
||||
class _EntiteFormState extends State<EntiteForm> {
|
||||
class _AmicaleFormState extends State<AmicaleForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
// Controllers
|
||||
@@ -52,9 +50,6 @@ class _EntiteFormState extends State<EntiteForm> {
|
||||
bool _chkActive = true;
|
||||
bool _chkStripe = false;
|
||||
|
||||
// Liste des régions (sera chargée depuis le store)
|
||||
List<Map<String, dynamic>> _regions = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -81,36 +76,6 @@ class _EntiteFormState extends State<EntiteForm> {
|
||||
_chkAcceptSms = amicale?.chkAcceptSms ?? false;
|
||||
_chkActive = amicale?.chkActive ?? true;
|
||||
_chkStripe = amicale?.chkStripe ?? false;
|
||||
|
||||
// Charger les régions depuis le repository
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadRegions();
|
||||
});
|
||||
}
|
||||
|
||||
void _loadRegions() {
|
||||
try {
|
||||
final regionRepository =
|
||||
Provider.of<RegionRepository>(context, listen: false);
|
||||
if (!regionRepository.isLoaded) {
|
||||
// Initialiser le repository si ce n'est pas déjà fait
|
||||
regionRepository.init().then((_) {
|
||||
setState(() {
|
||||
_regions = regionRepository.getRegionsForDropdown();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_regions = regionRepository.getRegionsForDropdown();
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des régions: $e');
|
||||
// Utiliser une liste vide en cas d'erreur
|
||||
setState(() {
|
||||
_regions = [];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -130,7 +95,7 @@ class _EntiteFormState extends State<EntiteForm> {
|
||||
}
|
||||
|
||||
// Appeler l'API pour mettre à jour l'entité
|
||||
Future<void> _updateEntite(AmicaleModel amicale) async {
|
||||
Future<void> _updateAmicale(AmicaleModel amicale) async {
|
||||
try {
|
||||
// Afficher un indicateur de chargement
|
||||
showDialog(
|
||||
@@ -175,7 +140,7 @@ class _EntiteFormState extends State<EntiteForm> {
|
||||
try {
|
||||
// Obtenir l'instance du service API
|
||||
final apiService = Provider.of<ApiService>(context, listen: false);
|
||||
|
||||
|
||||
// Appeler la méthode post du service API
|
||||
await apiService.post('/entite/update', data: data);
|
||||
|
||||
@@ -185,7 +150,7 @@ class _EntiteFormState extends State<EntiteForm> {
|
||||
// Afficher un message de succès
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Entité mise à jour avec succès'),
|
||||
content: Text('Amicale mise à jour avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
@@ -204,7 +169,8 @@ class _EntiteFormState extends State<EntiteForm> {
|
||||
// Afficher un message d'erreur
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la mise à jour de l\'entité: $error'),
|
||||
content:
|
||||
Text('Erreur lors de la mise à jour de l\'amicale: $error'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
@@ -277,8 +243,8 @@ class _EntiteFormState extends State<EntiteForm> {
|
||||
chkActive: _chkActive,
|
||||
);
|
||||
|
||||
// Appeler l'API pour mettre à jour l'entité
|
||||
_updateEntite(amicale);
|
||||
// Appeler l'API pour mettre à jour l'amicale
|
||||
_updateAmicale(amicale);
|
||||
|
||||
// Appeler la fonction onSubmit si elle existe (pour la compatibilité avec le code existant)
|
||||
if (widget.onSubmit != null) {
|
||||
@@ -391,8 +357,8 @@ class _EntiteFormState extends State<EntiteForm> {
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: const Icon(
|
||||
Icons.location_on,
|
||||
color: Color(0xFF20335E),
|
||||
Icons.fireplace_rounded,
|
||||
color: Color.fromARGB(255, 212, 34, 31),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
@@ -424,6 +390,72 @@ class _EntiteFormState extends State<EntiteForm> {
|
||||
);
|
||||
}
|
||||
|
||||
// Construire le dropdown pour la région
|
||||
Widget _buildRegionDropdown(bool restrictedFieldsReadOnly) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Afficher le libellé de la région en lecture seule
|
||||
if (_libRegion != null && _libRegion!.isNotEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).inputDecorationTheme.fillColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_libRegion!,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).inputDecorationTheme.fillColor,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'Aucune région définie',
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construire une option checkbox
|
||||
Widget _buildCheckboxOption({
|
||||
required String label,
|
||||
required bool value,
|
||||
required void Function(bool?)? onChanged,
|
||||
}) {
|
||||
return Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeColor: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Construire le formulaire principal
|
||||
Widget _buildMainForm(ThemeData theme, bool restrictedFieldsReadOnly) {
|
||||
return Column(
|
||||
@@ -887,4 +919,46 @@ class _EntiteFormState extends State<EntiteForm> {
|
||||
widget.readOnly || !canEditRestrictedFields;
|
||||
|
||||
// Calculer la largeur maximale du formulaire pour les écrans larges
|
||||
final screenWidth = MediaQuery.of(context
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final maxFormWidth = screenWidth > 800 ? 800.0 : screenWidth;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.readOnly ? 'Détails de l\'amicale' : 'Modifier l\'amicale'),
|
||||
backgroundColor: theme.appBarTheme.backgroundColor,
|
||||
foregroundColor: theme.appBarTheme.foregroundColor,
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
width: maxFormWidth,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header avec logo et minimap
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
// Section Logo
|
||||
_buildLogoSection(),
|
||||
// Section MiniMap
|
||||
_buildMiniMap(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Formulaire principal
|
||||
_buildMainForm(theme, restrictedFieldsReadOnly),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/repositories/region_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/amicale_row_widget.dart';
|
||||
import 'package:geosector_app/presentation/widgets/entite_form.dart';
|
||||
import 'package:geosector_app/presentation/widgets/amicale_form.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Widget de tableau pour afficher une liste d'amicales
|
||||
@@ -165,7 +165,7 @@ class AmicaleTableWidget extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Formulaire EntiteForm en mode lecture seule
|
||||
EntiteForm(
|
||||
AmicaleForm(
|
||||
amicale: amicale,
|
||||
readOnly: readOnly,
|
||||
onSubmit: (updatedAmicale) {
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/foundation.dart' show listEquals;
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/services/passage_data_service.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
|
||||
/// Widget de graphique d'activité affichant les passages
|
||||
class ActivityChart extends StatefulWidget {
|
||||
/// Liste des données de passage par date et type (si fournie directement)
|
||||
/// Format attendu: [{"date": String, "type_passage": int, "nb": int}, ...]
|
||||
/// Si useValueListenable est true, ce paramètre est ignoré
|
||||
final List<Map<String, dynamic>>? passageData;
|
||||
|
||||
/// Type de période (Jour, Semaine, Mois, Année)
|
||||
@@ -30,9 +29,6 @@ class ActivityChart extends StatefulWidget {
|
||||
/// Types de passages à exclure (par défaut [2] = "À finaliser")
|
||||
final List<int> excludePassageTypes;
|
||||
|
||||
/// Indique si les données doivent être chargées depuis la Hive box
|
||||
final bool loadFromHive;
|
||||
|
||||
/// Callback appelé lorsque la période change
|
||||
final Function(int days)? onPeriodChanged;
|
||||
|
||||
@@ -51,8 +47,8 @@ class ActivityChart extends StatefulWidget {
|
||||
/// Si vrai, n'applique aucun filtrage par utilisateur (affiche tous les passages)
|
||||
final bool showAllPassages;
|
||||
|
||||
/// Si vrai, force le rechargement des données
|
||||
final bool forceRefresh;
|
||||
/// Utiliser ValueListenableBuilder pour la mise à jour automatique
|
||||
final bool useValueListenable;
|
||||
|
||||
const ActivityChart({
|
||||
super.key,
|
||||
@@ -62,16 +58,14 @@ class ActivityChart extends StatefulWidget {
|
||||
this.daysToShow = 15,
|
||||
this.userId,
|
||||
this.excludePassageTypes = const [2],
|
||||
this.loadFromHive = false,
|
||||
this.onPeriodChanged,
|
||||
this.title = 'Dernière activité enregistrée sur 15 jours',
|
||||
this.showDataLabels = true,
|
||||
this.columnWidth = 0.8,
|
||||
this.columnSpacing = 0.2,
|
||||
this.showAllPassages = false,
|
||||
this.forceRefresh = false,
|
||||
}) : assert(loadFromHive || passageData != null,
|
||||
'Soit loadFromHive doit être true, soit passageData doit être fourni');
|
||||
this.useValueListenable = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ActivityChart> createState() => _ActivityChartState();
|
||||
@@ -96,16 +90,6 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
// Données pour les graphiques
|
||||
List<Map<String, dynamic>> _passageData = [];
|
||||
List<ActivityData> _chartData = [];
|
||||
bool _isLoading = true;
|
||||
bool _hasData = false;
|
||||
bool _dataLoaded = false;
|
||||
|
||||
// Période sélectionnée en jours
|
||||
int _selectedDays = 15;
|
||||
|
||||
// Contrôleur de zoom pour le graphique
|
||||
late ZoomPanBehavior _zoomPanBehavior;
|
||||
|
||||
@@ -117,9 +101,6 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
);
|
||||
|
||||
// Initialiser la période sélectionnée avec la valeur par défaut du widget
|
||||
_selectedDays = widget.daysToShow;
|
||||
|
||||
// Initialiser le contrôleur de zoom
|
||||
_zoomPanBehavior = ZoomPanBehavior(
|
||||
enablePinching: true,
|
||||
@@ -128,83 +109,29 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
zoomMode: ZoomMode.x,
|
||||
);
|
||||
|
||||
_loadData();
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
/// Trouve la date du passage le plus récent
|
||||
DateTime _getMostRecentDate() {
|
||||
final allDates = [
|
||||
..._passageData.map((data) => DateTime.parse(data['date'] as String)),
|
||||
];
|
||||
if (allDates.isEmpty) {
|
||||
return DateTime.now();
|
||||
}
|
||||
return allDates.reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
}
|
||||
@override
|
||||
void didUpdateWidget(ActivityChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
void _loadData() {
|
||||
// Marquer comme chargé immédiatement pour éviter les appels multiples pendant le chargement
|
||||
// Mais permettre un rechargement ultérieur si nécessaire
|
||||
if (_dataLoaded && _hasData) return;
|
||||
// Vérifier si les propriétés importantes ont changé
|
||||
final bool periodChanged = oldWidget.periodType != widget.periodType ||
|
||||
oldWidget.daysToShow != widget.daysToShow;
|
||||
final bool dataSourceChanged = widget.useValueListenable
|
||||
? false
|
||||
: oldWidget.passageData != widget.passageData;
|
||||
final bool filteringChanged = oldWidget.userId != widget.userId ||
|
||||
!listEquals(
|
||||
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
||||
oldWidget.showAllPassages != widget.showAllPassages ||
|
||||
oldWidget.useValueListenable != widget.useValueListenable;
|
||||
|
||||
_dataLoaded = true;
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
if (widget.loadFromHive) {
|
||||
// Charger les données depuis Hive
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Éviter de recharger si le widget a été démonté entre-temps
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
|
||||
// Créer une instance du service de données
|
||||
final passageDataService = PassageDataService(
|
||||
passageRepository: passageRepository,
|
||||
userRepository: userRepository,
|
||||
);
|
||||
|
||||
// Utiliser le service pour charger les données
|
||||
_passageData = passageDataService.loadPassageData(
|
||||
daysToShow: _selectedDays,
|
||||
excludePassageTypes: widget.excludePassageTypes,
|
||||
userId: widget.userId,
|
||||
showAllPassages: widget.showAllPassages,
|
||||
);
|
||||
|
||||
_prepareChartData();
|
||||
|
||||
// Mettre à jour l'état une seule fois après avoir préparé les données
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = _chartData.isNotEmpty;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// En cas d'erreur, réinitialiser l'état pour permettre une future tentative
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Utiliser les données fournies directement
|
||||
_passageData = widget.passageData ?? [];
|
||||
_prepareChartData();
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasData = _chartData.isNotEmpty;
|
||||
});
|
||||
// Si des paramètres importants ont changé, relancer l'animation
|
||||
if (periodChanged || dataSourceChanged || filteringChanged) {
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,47 +142,141 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ActivityChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Vérifier si les propriétés importantes ont changé
|
||||
final bool periodChanged = oldWidget.periodType != widget.periodType ||
|
||||
oldWidget.daysToShow != widget.daysToShow;
|
||||
final bool dataSourceChanged = widget.loadFromHive
|
||||
? false
|
||||
: oldWidget.passageData != widget.passageData;
|
||||
final bool filteringChanged = oldWidget.userId != widget.userId ||
|
||||
!listEquals(
|
||||
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
||||
oldWidget.showAllPassages != widget.showAllPassages;
|
||||
final bool refreshForced = widget.forceRefresh && !oldWidget.forceRefresh;
|
||||
|
||||
// Si des paramètres importants ont changé ou si forceRefresh est passé à true, recharger les données
|
||||
if (periodChanged ||
|
||||
dataSourceChanged ||
|
||||
filteringChanged ||
|
||||
refreshForced) {
|
||||
_selectedDays = widget.daysToShow;
|
||||
_dataLoaded = false; // Réinitialiser l'état pour forcer le rechargement
|
||||
_loadData();
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.useValueListenable) {
|
||||
return _buildWithValueListenable();
|
||||
} else {
|
||||
return _buildWithStaticData();
|
||||
}
|
||||
}
|
||||
|
||||
// La méthode _loadPassageDataFromHive a été intégrée directement dans _loadData
|
||||
// pour éviter les appels multiples et les problèmes de cycle de vie
|
||||
/// Construction du widget avec ValueListenableBuilder pour mise à jour automatique
|
||||
Widget _buildWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final chartData = _calculateActivityData(passagesBox);
|
||||
return _buildChart(chartData);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique
|
||||
void _prepareChartData() {
|
||||
/// Construction du widget avec des données statiques
|
||||
Widget _buildWithStaticData() {
|
||||
if (widget.passageData == null) {
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: const Center(
|
||||
child: Text('Aucune donnée fournie'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final chartData = _prepareChartDataFromPassageData(widget.passageData!);
|
||||
return _buildChart(chartData);
|
||||
}
|
||||
|
||||
/// Calcule les données d'activité depuis la Hive box
|
||||
List<ActivityData> _calculateActivityData(Box<PassageModel> passagesBox) {
|
||||
try {
|
||||
// Vérifier que les données sont disponibles
|
||||
if (_passageData.isEmpty) {
|
||||
_chartData = [];
|
||||
return;
|
||||
final passages = passagesBox.values.toList();
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
|
||||
// Déterminer l'utilisateur cible selon les filtres
|
||||
final int? targetUserId =
|
||||
widget.showAllPassages ? null : (widget.userId ?? currentUser?.id);
|
||||
|
||||
// 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));
|
||||
|
||||
// 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++) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parcourir les passages et les compter par date et type
|
||||
for (final passage in passages) {
|
||||
// Appliquer les filtres
|
||||
bool shouldInclude = true;
|
||||
|
||||
// Filtrer par utilisateur si nécessaire
|
||||
if (targetUserId != null && passage.fkUser != targetUserId) {
|
||||
shouldInclude = false;
|
||||
}
|
||||
|
||||
// Exclure certains types
|
||||
if (widget.excludePassageTypes.contains(passage.fkType)) {
|
||||
shouldInclude = false;
|
||||
}
|
||||
|
||||
// Vérifier si le passage est dans la période
|
||||
final passageDate = passage.passedAt;
|
||||
if (passageDate.isBefore(startDate) || passageDate.isAfter(endDate)) {
|
||||
shouldInclude = false;
|
||||
}
|
||||
|
||||
if (shouldInclude) {
|
||||
final dateStr = DateFormat('yyyy-MM-dd').format(passageDate);
|
||||
if (dataByDate.containsKey(dateStr)) {
|
||||
dataByDate[dateStr]![passage.fkType] =
|
||||
(dataByDate[dateStr]![passage.fkType] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convertir en liste d'ActivityData
|
||||
final List<ActivityData> chartData = [];
|
||||
dataByDate.forEach((dateStr, passagesByType) {
|
||||
final dateParts = dateStr.split('-');
|
||||
if (dateParts.length == 3) {
|
||||
try {
|
||||
final date = DateTime(
|
||||
int.parse(dateParts[0]),
|
||||
int.parse(dateParts[1]),
|
||||
int.parse(dateParts[2]),
|
||||
);
|
||||
|
||||
chartData.add(ActivityData(
|
||||
date: date,
|
||||
dateStr: dateStr,
|
||||
passagesByType: passagesByType,
|
||||
));
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de conversion de date: $dateStr');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Trier par date
|
||||
chartData.sort((a, b) => a.date.compareTo(b.date));
|
||||
return chartData;
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du calcul des données d\'activité: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique à partir des données de passage brutes (ancien système)
|
||||
List<ActivityData> _prepareChartDataFromPassageData(
|
||||
List<Map<String, dynamic>> passageData) {
|
||||
try {
|
||||
// Obtenir toutes les dates uniques
|
||||
final Set<String> uniqueDatesSet = {};
|
||||
for (final data in _passageData) {
|
||||
for (final data in passageData) {
|
||||
if (data.containsKey('date') && data['date'] != null) {
|
||||
uniqueDatesSet.add(data['date'] as String);
|
||||
}
|
||||
@@ -266,7 +287,7 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
uniqueDates.sort();
|
||||
|
||||
// Créer les données pour chaque date
|
||||
_chartData = [];
|
||||
final List<ActivityData> chartData = [];
|
||||
for (final dateStr in uniqueDates) {
|
||||
final passagesByType = <int, int>{};
|
||||
|
||||
@@ -278,7 +299,7 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
}
|
||||
|
||||
// Remplir les données de passage
|
||||
for (final data in _passageData) {
|
||||
for (final data in passageData) {
|
||||
if (data.containsKey('date') &&
|
||||
data['date'] == dateStr &&
|
||||
data.containsKey('type_passage') &&
|
||||
@@ -301,37 +322,29 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
final date = DateTime(year, month, day);
|
||||
|
||||
// Ajouter les données à la liste
|
||||
_chartData.add(ActivityData(
|
||||
chartData.add(ActivityData(
|
||||
date: date,
|
||||
dateStr: dateStr,
|
||||
passagesByType: passagesByType,
|
||||
));
|
||||
}
|
||||
} catch (e) {
|
||||
// Silencieux lors des erreurs de conversion de date pour éviter les logs excessifs
|
||||
debugPrint('Erreur de conversion de date: $dateStr');
|
||||
}
|
||||
}
|
||||
|
||||
// Trier les données par date
|
||||
_chartData.sort((a, b) => a.date.compareTo(b.date));
|
||||
chartData.sort((a, b) => a.date.compareTo(b.date));
|
||||
return chartData;
|
||||
} catch (e) {
|
||||
// Erreur silencieuse pour éviter les logs excessifs
|
||||
_chartData = [];
|
||||
debugPrint('Erreur lors de la préparation des données: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoading) {
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!_hasData || _chartData.isEmpty) {
|
||||
/// Construit le graphique avec les données fournies
|
||||
Widget _buildChart(List<ActivityData> chartData) {
|
||||
if (chartData.isEmpty) {
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: const Center(
|
||||
@@ -340,17 +353,12 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
);
|
||||
}
|
||||
|
||||
// Préparer les données si nécessaire
|
||||
if (_chartData.isEmpty) {
|
||||
_prepareChartData();
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
height: widget.height,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre (conservé)
|
||||
// Titre
|
||||
if (widget.title.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
@@ -362,13 +370,13 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
),
|
||||
),
|
||||
),
|
||||
// Graphique (occupe maintenant plus d'espace)
|
||||
// Graphique
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
|
||||
child: SfCartesianChart(
|
||||
plotAreaBorderWidth: 0,
|
||||
legend: Legend(
|
||||
legend: const Legend(
|
||||
isVisible: true,
|
||||
position: LegendPosition.bottom,
|
||||
overflowMode: LegendItemOverflowMode.wrap,
|
||||
@@ -378,30 +386,25 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
intervalType: DateTimeIntervalType.days,
|
||||
majorGridLines: const MajorGridLines(width: 0),
|
||||
labelStyle: const TextStyle(fontSize: 10),
|
||||
// Définir explicitement la plage de dates à afficher
|
||||
minimum: _chartData.isNotEmpty ? _chartData.first.date : null,
|
||||
maximum: _chartData.isNotEmpty ? _chartData.last.date : null,
|
||||
// Assurer que tous les jours sont affichés
|
||||
minimum: chartData.isNotEmpty ? chartData.first.date : null,
|
||||
maximum: chartData.isNotEmpty ? chartData.last.date : null,
|
||||
interval: 1,
|
||||
axisLabelFormatter: (AxisLabelRenderDetails details) {
|
||||
return ChartAxisLabel(details.text, details.textStyle);
|
||||
},
|
||||
),
|
||||
primaryYAxis: NumericAxis(
|
||||
labelStyle: const TextStyle(fontSize: 10),
|
||||
axisLine: const AxisLine(width: 0),
|
||||
majorTickLines: const MajorTickLines(size: 0),
|
||||
majorGridLines: const MajorGridLines(
|
||||
primaryYAxis: const NumericAxis(
|
||||
labelStyle: TextStyle(fontSize: 10),
|
||||
axisLine: AxisLine(width: 0),
|
||||
majorTickLines: MajorTickLines(size: 0),
|
||||
majorGridLines: MajorGridLines(
|
||||
width: 0.5,
|
||||
color: Colors.grey,
|
||||
dashArray: <double>[5, 5], // Motif de pointillés
|
||||
dashArray: <double>[5, 5],
|
||||
),
|
||||
title: const AxisTitle(
|
||||
title: AxisTitle(
|
||||
text: 'Passages',
|
||||
textStyle: TextStyle(fontSize: 10, color: Colors.grey),
|
||||
),
|
||||
),
|
||||
series: _buildSeries(),
|
||||
series: _buildSeries(chartData),
|
||||
tooltipBehavior: TooltipBehavior(enable: true),
|
||||
zoomPanBehavior: _zoomPanBehavior,
|
||||
),
|
||||
@@ -413,11 +416,12 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
}
|
||||
|
||||
/// Construit les séries de données pour le graphique
|
||||
List<CartesianSeries<ActivityData, DateTime>> _buildSeries() {
|
||||
List<CartesianSeries<ActivityData, DateTime>> _buildSeries(
|
||||
List<ActivityData> chartData) {
|
||||
final List<CartesianSeries<ActivityData, DateTime>> series = [];
|
||||
|
||||
// Vérifier que les données sont disponibles
|
||||
if (_chartData.isEmpty) {
|
||||
if (chartData.isEmpty) {
|
||||
return series;
|
||||
}
|
||||
|
||||
@@ -444,25 +448,19 @@ class _ActivityChartState extends State<ActivityChart>
|
||||
|
||||
// Calculer le total pour ce type pour déterminer s'il faut l'afficher
|
||||
int totalForType = 0;
|
||||
for (final data in _chartData) {
|
||||
for (final data in chartData) {
|
||||
totalForType += data.passagesByType[typeId] ?? 0;
|
||||
}
|
||||
|
||||
// On peut décider de ne pas afficher les types sans données
|
||||
final addZeroValueTypes = false;
|
||||
|
||||
// Ajouter la série pour ce type
|
||||
if (totalForType > 0 || addZeroValueTypes) {
|
||||
// Ajouter la série pour ce type si elle a des données
|
||||
if (totalForType > 0) {
|
||||
series.add(
|
||||
StackedColumnSeries<ActivityData, DateTime>(
|
||||
name: typeName,
|
||||
dataSource: _chartData,
|
||||
dataSource: chartData,
|
||||
xValueMapper: (ActivityData data, _) => data.date,
|
||||
yValueMapper: (ActivityData data, _) {
|
||||
final value = data.passagesByType.containsKey(typeId)
|
||||
? data.passagesByType[typeId]!
|
||||
: 0;
|
||||
return value;
|
||||
return data.passagesByType[typeId] ?? 0;
|
||||
},
|
||||
color: typeColor,
|
||||
width: widget.columnWidth,
|
||||
|
||||
@@ -3,9 +3,10 @@ library geosector_charts;
|
||||
|
||||
export 'payment_data.dart';
|
||||
export 'payment_pie_chart.dart';
|
||||
export 'payment_utils.dart';
|
||||
export 'payment_summary_card.dart';
|
||||
export 'passage_data.dart';
|
||||
export 'passage_utils.dart';
|
||||
export 'passage_pie_chart.dart';
|
||||
export 'passage_summary_card.dart';
|
||||
export 'activity_chart.dart';
|
||||
export 'combined_chart.dart';
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/foundation.dart' show listEquals, mapEquals;
|
||||
import 'package:intl/intl.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/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/services/passage_data_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 {
|
||||
@@ -38,7 +35,7 @@ class PassageChartData {
|
||||
/// 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 loadFromHive est true, ce paramètre est ignoré
|
||||
/// Si useValueListenable est true, ce paramètre est ignoré
|
||||
final Map<int, int> passagesByType;
|
||||
|
||||
/// Taille du graphique
|
||||
@@ -62,18 +59,21 @@ class PassagePieChart extends StatefulWidget {
|
||||
/// Rayon central pour le format donut (en pourcentage)
|
||||
final String innerRadius;
|
||||
|
||||
/// Charger les données depuis Hive
|
||||
/// Charger les données depuis Hive (obsolète, utiliser useValueListenable)
|
||||
final bool loadFromHive;
|
||||
|
||||
/// ID de l'utilisateur pour filtrer les passages (utilisé seulement si loadFromHive est true)
|
||||
/// ID de l'utilisateur pour filtrer les passages
|
||||
final int? userId;
|
||||
|
||||
/// Types de passages à exclure (utilisé seulement si loadFromHive est true)
|
||||
/// Types de passages à exclure
|
||||
final List<int> excludePassageTypes;
|
||||
|
||||
/// Afficher tous les passages sans filtrer par utilisateur (utilisé seulement si loadFromHive est true)
|
||||
/// 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 {},
|
||||
@@ -88,6 +88,7 @@ class PassagePieChart extends StatefulWidget {
|
||||
this.userId,
|
||||
this.excludePassageTypes = const [2],
|
||||
this.showAllPassages = false,
|
||||
this.useValueListenable = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -98,20 +99,9 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
|
||||
/// Données de passages par type
|
||||
late Map<int, int> _passagesByType;
|
||||
|
||||
/// Variables pour la mise en cache et l'optimisation
|
||||
bool _dataLoaded = false;
|
||||
bool _isLoading = false;
|
||||
List<PassageChartData>? _cachedChartData;
|
||||
List<CircularChartAnnotation>? _cachedAnnotations;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_passagesByType = widget.passagesByType;
|
||||
|
||||
// Initialiser le contrôleur d'animation
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
@@ -119,235 +109,21 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
);
|
||||
|
||||
_animationController.forward();
|
||||
|
||||
// Si nous n'utilisons pas Hive, préparer les données immédiatement
|
||||
if (!widget.loadFromHive) {
|
||||
_prepareChartData();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (widget.loadFromHive && !_dataLoaded && !_isLoading) {
|
||||
_isLoading = true; // Prévenir les chargements multiples
|
||||
_loadPassageDataFromHive(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PassagePieChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Vérifier si les propriétés importantes ont changé
|
||||
final bool dataSourceChanged = widget.loadFromHive
|
||||
? false
|
||||
: !mapEquals(oldWidget.passagesByType, widget.passagesByType);
|
||||
final bool filteringChanged = oldWidget.userId != widget.userId ||
|
||||
!listEquals(
|
||||
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
|
||||
oldWidget.showAllPassages != widget.showAllPassages;
|
||||
final bool visualChanged = oldWidget.size != widget.size ||
|
||||
oldWidget.labelSize != widget.labelSize ||
|
||||
oldWidget.showPercentage != widget.showPercentage ||
|
||||
oldWidget.showIcons != widget.showIcons ||
|
||||
oldWidget.showLegend != widget.showLegend ||
|
||||
oldWidget.isDonut != widget.isDonut ||
|
||||
oldWidget.innerRadius != widget.innerRadius;
|
||||
// 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;
|
||||
|
||||
// Si les paramètres de filtrage ou de source de données ont changé, recharger les données
|
||||
if (dataSourceChanged || filteringChanged) {
|
||||
_cachedChartData = null;
|
||||
_cachedAnnotations = null;
|
||||
|
||||
// Relancer l'animation si les données ont changé
|
||||
if (shouldResetAnimation) {
|
||||
_animationController.reset();
|
||||
_animationController.forward();
|
||||
|
||||
if (!widget.loadFromHive) {
|
||||
_passagesByType = widget.passagesByType;
|
||||
_prepareChartData();
|
||||
} else if (!_isLoading) {
|
||||
_dataLoaded = false;
|
||||
_isLoading = true;
|
||||
_loadPassageDataFromHive(context);
|
||||
}
|
||||
}
|
||||
// Si seuls les paramètres visuels ont changé, recalculer les annotations sans recharger les données
|
||||
else if (visualChanged) {
|
||||
_cachedAnnotations = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge les données de passage depuis Hive en utilisant le service PassageDataService
|
||||
void _loadPassageDataFromHive(BuildContext context) {
|
||||
// Éviter les appels multiples pendant le chargement
|
||||
if (_isLoading) {
|
||||
debugPrint('PassagePieChart: Déjà en cours de chargement, ignoré');
|
||||
return;
|
||||
}
|
||||
|
||||
// Si les données sont déjà chargées et non vides, ne pas recharger
|
||||
if (_dataLoaded && _passagesByType.isNotEmpty) {
|
||||
debugPrint('PassagePieChart: Données déjà chargées, ignoré');
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('PassagePieChart: Début du chargement des données');
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// Charger les données dans un addPostFrameCallback pour éviter les problèmes de cycle de vie
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
// Vérifier si le widget est toujours monté
|
||||
if (!mounted) {
|
||||
debugPrint('PassagePieChart: Widget démonté, chargement annulé');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('PassagePieChart: Création du service de données');
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
|
||||
// Vérifier que les repositories sont disponibles
|
||||
if (passageRepository == null) {
|
||||
debugPrint('PassagePieChart: ERREUR - passageRepository est null');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (userRepository == null) {
|
||||
debugPrint('PassagePieChart: ERREUR - userRepository est null');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Créer une instance du service de données
|
||||
final passageDataService = PassageDataService(
|
||||
passageRepository: passageRepository,
|
||||
userRepository: userRepository,
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
'PassagePieChart: Chargement des données avec excludePassageTypes=${widget.excludePassageTypes}, userId=${widget.userId}, showAllPassages=${widget.showAllPassages}');
|
||||
|
||||
// Utiliser le service pour charger les données
|
||||
final data = passageDataService.loadPassageDataForPieChart(
|
||||
excludePassageTypes: widget.excludePassageTypes,
|
||||
userId: widget.userId,
|
||||
showAllPassages: widget.showAllPassages,
|
||||
);
|
||||
|
||||
debugPrint('PassagePieChart: Données chargées: $data');
|
||||
|
||||
// Mettre à jour les données et les états
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_passagesByType = data;
|
||||
_dataLoaded = true;
|
||||
_isLoading = false;
|
||||
_cachedChartData =
|
||||
null; // Forcer la régénération des données du graphique
|
||||
_cachedAnnotations = null;
|
||||
});
|
||||
|
||||
// Préparer les données du graphique
|
||||
_prepareChartData();
|
||||
debugPrint('PassagePieChart: Données préparées pour le graphique');
|
||||
}
|
||||
} catch (e) {
|
||||
// Gérer les erreurs et réinitialiser l'état pour permettre une future tentative
|
||||
debugPrint(
|
||||
'PassagePieChart: ERREUR lors du chargement des données: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique en camembert avec mise en cache
|
||||
List<PassageChartData> _prepareChartData() {
|
||||
// Utiliser les données en cache si disponibles
|
||||
if (_cachedChartData != null) {
|
||||
debugPrint('PassagePieChart: Utilisation des données en cache');
|
||||
return _cachedChartData!;
|
||||
}
|
||||
|
||||
debugPrint('PassagePieChart: Préparation des données pour le graphique');
|
||||
debugPrint('PassagePieChart: Données brutes: $_passagesByType');
|
||||
|
||||
// Vérifier si les données sont vides
|
||||
if (_passagesByType.isEmpty) {
|
||||
debugPrint('PassagePieChart: Aucune donnée disponible');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Vérifier si les données contiennent uniquement des passages de type 2
|
||||
bool onlyType2 = true;
|
||||
_passagesByType.forEach((typeId, count) {
|
||||
if (typeId != 2 && count > 0) {
|
||||
onlyType2 = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (onlyType2) {
|
||||
debugPrint(
|
||||
'PassagePieChart: Les données contiennent uniquement des passages de type 2');
|
||||
}
|
||||
|
||||
final List<PassageChartData> chartData = [];
|
||||
|
||||
// Créer les données du graphique
|
||||
_passagesByType.forEach((typeId, count) {
|
||||
// Vérifier que le type existe et que le compteur est positif
|
||||
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
|
||||
// Vérifier si le type est exclu
|
||||
bool isExcluded = widget.excludePassageTypes.contains(typeId);
|
||||
if (isExcluded) {
|
||||
debugPrint('PassagePieChart: Type $typeId exclu');
|
||||
} else {
|
||||
final typeInfo = AppKeys.typesPassages[typeId]!;
|
||||
final typeName = typeInfo['titre'] as String;
|
||||
debugPrint(
|
||||
'PassagePieChart: Ajout du type $typeId ($typeName) avec $count passages');
|
||||
|
||||
chartData.add(PassageChartData(
|
||||
typeId: typeId,
|
||||
count: count,
|
||||
title: typeName,
|
||||
color: Color(typeInfo['couleur2'] as int),
|
||||
icon: typeInfo['icon_data'] as IconData,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
if (count <= 0) {
|
||||
debugPrint('PassagePieChart: Type $typeId ignoré car count=$count');
|
||||
} else if (!AppKeys.typesPassages.containsKey(typeId)) {
|
||||
debugPrint(
|
||||
'PassagePieChart: Type $typeId ignoré car non défini dans AppKeys.typesPassages');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
debugPrint(
|
||||
'PassagePieChart: ${chartData.length} types de passages ajoutés au graphique');
|
||||
|
||||
// Mettre en cache les données générées
|
||||
_cachedChartData = chartData;
|
||||
|
||||
return chartData;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -358,19 +134,100 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Si les données doivent être chargées depuis Hive mais ne sont pas encore prêtes
|
||||
if (widget.loadFromHive && !_dataLoaded) {
|
||||
return SizedBox(
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
if (widget.useValueListenable) {
|
||||
return _buildWithValueListenable();
|
||||
} else {
|
||||
return _buildWithStaticData();
|
||||
}
|
||||
}
|
||||
|
||||
final chartData = _prepareChartData();
|
||||
/// 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() {
|
||||
final chartData = _prepareChartDataFromMap(widget.passagesByType);
|
||||
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();
|
||||
|
||||
// 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) {
|
||||
if (!widget.excludePassageTypes.contains(typeId)) {
|
||||
passagesByType[typeId] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
for (final passage in passages) {
|
||||
// Appliquer les filtres
|
||||
bool shouldInclude = true;
|
||||
|
||||
// Filtrer par utilisateur si nécessaire
|
||||
if (!widget.showAllPassages && widget.userId != null) {
|
||||
shouldInclude = passage.fkUser == widget.userId;
|
||||
} else if (!widget.showAllPassages && currentUser != null) {
|
||||
shouldInclude = passage.fkUser == currentUser.id;
|
||||
}
|
||||
|
||||
// Exclure certains types
|
||||
if (widget.excludePassageTypes.contains(passage.fkType)) {
|
||||
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 = [];
|
||||
|
||||
// Créer les données du graphique
|
||||
passagesByType.forEach((typeId, count) {
|
||||
// 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(
|
||||
@@ -448,8 +305,7 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
explodeOffset:
|
||||
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
opacity: opacityAnimation.value,
|
||||
animationDuration:
|
||||
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
|
||||
animationDuration: 0,
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
)
|
||||
@@ -485,8 +341,7 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
explodeOffset:
|
||||
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
opacity: opacityAnimation.value,
|
||||
animationDuration:
|
||||
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
|
||||
animationDuration: 0,
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
),
|
||||
@@ -499,14 +354,9 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
);
|
||||
}
|
||||
|
||||
/// Crée les annotations d'icônes pour le graphique avec mise en cache
|
||||
/// Crée les annotations d'icônes pour le graphique
|
||||
List<CircularChartAnnotation> _buildIconAnnotations(
|
||||
List<PassageChartData> chartData) {
|
||||
// Utiliser les annotations en cache si disponibles
|
||||
if (_cachedAnnotations != null) {
|
||||
return _cachedAnnotations!;
|
||||
}
|
||||
|
||||
final List<CircularChartAnnotation> annotations = [];
|
||||
|
||||
// Calculer le total pour les pourcentages
|
||||
@@ -541,9 +391,6 @@ class _PassagePieChartState extends State<PassagePieChart>
|
||||
currentAngle += segmentAngle;
|
||||
}
|
||||
|
||||
// Mettre en cache les annotations générées
|
||||
_cachedAnnotations = annotations;
|
||||
|
||||
return annotations;
|
||||
}
|
||||
}
|
||||
|
||||
353
app/lib/presentation/widgets/charts/passage_summary_card.dart
Normal file
353
app/lib/presentation/widgets/charts/passage_summary_card.dart
Normal file
@@ -0,0 +1,353 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
|
||||
/// 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 {
|
||||
/// 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
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Icône d'arrière-plan (optionnelle)
|
||||
if (backgroundIcon != null)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
backgroundIcon,
|
||||
size: backgroundIconSize,
|
||||
color: (backgroundIconColor ?? AppTheme.primaryColor).withOpacity(backgroundIconOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Contenu principal
|
||||
Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre avec comptage
|
||||
useValueListenable
|
||||
? _buildTitleWithValueListenable()
|
||||
: _buildTitleWithStaticData(),
|
||||
const Divider(height: 24),
|
||||
// Contenu principal
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Liste des passages à gauche
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: useValueListenable
|
||||
? _buildPassagesListWithValueListenable()
|
||||
: _buildPassagesListWithStaticData(),
|
||||
),
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
// Graphique en camembert à droite
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: PassagePieChart(
|
||||
useValueListenable: useValueListenable,
|
||||
passagesByType: passagesByType ?? {},
|
||||
excludePassageTypes: excludePassageTypes,
|
||||
userId: showAllPassages ? null : userId,
|
||||
size: double.infinity,
|
||||
labelSize: 12,
|
||||
showPercentage: true,
|
||||
showIcons: false,
|
||||
showLegend: false,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du titre avec ValueListenableBuilder
|
||||
Widget _buildTitleWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final totalUserPassages = _calculateUserPassagesCount(passagesBox);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
Icon(
|
||||
titleIcon,
|
||||
color: titleColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(totalUserPassages) ?? totalUserPassages.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du titre avec données statiques
|
||||
Widget _buildTitleWithStaticData() {
|
||||
final totalPassages = passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
Icon(
|
||||
titleIcon,
|
||||
color: titleColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(totalPassages) ?? totalPassages.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction de la liste des passages avec ValueListenableBuilder
|
||||
Widget _buildPassagesListWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final passagesCounts = _calculatePassagesCounts(passagesBox);
|
||||
|
||||
return _buildPassagesList(passagesCounts);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction de la liste des passages avec données statiques
|
||||
Widget _buildPassagesListWithStaticData() {
|
||||
return _buildPassagesList(passagesByType ?? {});
|
||||
}
|
||||
|
||||
/// Construction de la liste des passages
|
||||
Widget _buildPassagesList(Map<int, int> passagesCounts) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...AppKeys.typesPassages.entries.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: 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: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
count.toString(),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Calcule le nombre total de passages pour l'utilisateur
|
||||
int _calculateUserPassagesCount(Box<PassageModel> passagesBox) {
|
||||
if (showAllPassages) {
|
||||
// Pour les administrateurs : tous les passages sauf ceux exclus
|
||||
return passagesBox.values
|
||||
.where((passage) => !excludePassageTypes.contains(passage.fkType))
|
||||
.length;
|
||||
} else {
|
||||
// Pour les utilisateurs : seulement leurs passages
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
if (targetUserId == null) return 0;
|
||||
|
||||
return passagesBox.values
|
||||
.where((passage) =>
|
||||
passage.fkUser == targetUserId &&
|
||||
!excludePassageTypes.contains(passage.fkType))
|
||||
.length;
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcule les compteurs de passages par type
|
||||
Map<int, int> _calculatePassagesCounts(Box<PassageModel> passagesBox) {
|
||||
final Map<int, int> counts = {};
|
||||
|
||||
// Initialiser tous les types
|
||||
for (final typeId in AppKeys.typesPassages.keys) {
|
||||
counts[typeId] = 0;
|
||||
}
|
||||
|
||||
if (showAllPassages) {
|
||||
// Pour les administrateurs : compter tous les passages
|
||||
for (final passage in passagesBox.values) {
|
||||
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
|
||||
}
|
||||
} else {
|
||||
// Pour les utilisateurs : compter seulement leurs passages
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
if (targetUserId != null) {
|
||||
for (final passage in passagesBox.values) {
|
||||
if (passage.fkUser == targetUserId) {
|
||||
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
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
|
||||
@@ -41,9 +46,15 @@ class PaymentPieChart extends StatefulWidget {
|
||||
/// 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;
|
||||
|
||||
const PaymentPieChart({
|
||||
super.key,
|
||||
required this.payments,
|
||||
this.payments = const [],
|
||||
this.size = 300,
|
||||
this.labelSize = 12,
|
||||
this.showPercentage = true,
|
||||
@@ -55,6 +66,8 @@ class PaymentPieChart extends StatefulWidget {
|
||||
this.effect3DIntensity = 1.0,
|
||||
this.enableEnhancedExplode = false,
|
||||
this.useGradient = false,
|
||||
this.useValueListenable = true,
|
||||
this.userId,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -80,20 +93,24 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
void didUpdateWidget(PaymentPieChart oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Relancer l'animation si les données ont changé
|
||||
// Utiliser une comparaison plus stricte pour éviter des animations inutiles
|
||||
// Relancer l'animation si les paramètres importants ont changé
|
||||
bool shouldResetAnimation = false;
|
||||
|
||||
if (oldWidget.payments.length != widget.payments.length) {
|
||||
if (widget.useValueListenable != oldWidget.useValueListenable ||
|
||||
widget.userId != oldWidget.userId) {
|
||||
shouldResetAnimation = true;
|
||||
} else {
|
||||
// Comparer les éléments importants uniquement
|
||||
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) {
|
||||
} else if (!widget.useValueListenable) {
|
||||
// Pour les données statiques, comparer les éléments
|
||||
if (oldWidget.payments.length != widget.payments.length) {
|
||||
shouldResetAnimation = true;
|
||||
break;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,15 +127,115 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique en camembert
|
||||
List<PaymentData> _prepareChartData() {
|
||||
// Filtrer les règlements avec un montant > 0
|
||||
return widget.payments.where((payment) => payment.amount > 0).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chartData = _prepareChartData();
|
||||
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();
|
||||
final int? currentUserId = widget.userId ?? currentUser?.id;
|
||||
|
||||
// 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
|
||||
};
|
||||
|
||||
// Parcourir les passages et calculer les montants par type de règlement
|
||||
for (final passage in passages) {
|
||||
// Vérifier si le passage appartient à l'utilisateur actuel
|
||||
if (currentUserId != null && passage.fkUser == currentUserId) {
|
||||
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) {
|
||||
@@ -170,7 +287,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
yValueMapper: (PaymentData data, _) => data.amount,
|
||||
pointColorMapper: (PaymentData data, _) {
|
||||
if (widget.enable3DEffect) {
|
||||
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
|
||||
final index = chartData.indexOf(data);
|
||||
final angle =
|
||||
(index / chartData.length) * 2 * math.pi;
|
||||
@@ -181,11 +297,9 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
}
|
||||
return data.color;
|
||||
},
|
||||
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PaymentData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(
|
||||
0.0, (sum, item) => sum + item.amount);
|
||||
final percentage = (data.amount / total * 100);
|
||||
@@ -196,18 +310,14 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
},
|
||||
dataLabelSettings: DataLabelSettings(
|
||||
isVisible: true,
|
||||
labelPosition: ChartDataLabelPosition
|
||||
.inside, // Afficher les étiquettes à l'intérieur du donut
|
||||
labelPosition: ChartDataLabelPosition.inside,
|
||||
textStyle: TextStyle(
|
||||
fontSize: widget.labelSize,
|
||||
color: Colors
|
||||
.white, // Texte blanc pour meilleure lisibilité
|
||||
fontWeight: FontWeight
|
||||
.bold, // Texte en gras pour meilleure lisibilité
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
innerRadius: widget.innerRadius,
|
||||
// Effet d'explosion plus prononcé pour donner du relief avec animation
|
||||
explode: true,
|
||||
explodeAll: widget.enableEnhancedExplode,
|
||||
explodeIndex: widget.enableEnhancedExplode ? null : 0,
|
||||
@@ -216,13 +326,10 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
// Effet 3D via l'opacité et les couleurs avec animation
|
||||
opacity: widget.enable3DEffect
|
||||
? 0.95 * opacityAnimation.value
|
||||
: opacityAnimation.value,
|
||||
// Animation progressive du graphique
|
||||
animationDuration:
|
||||
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
|
||||
animationDuration: 0,
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
)
|
||||
@@ -232,7 +339,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
yValueMapper: (PaymentData data, _) => data.amount,
|
||||
pointColorMapper: (PaymentData data, _) {
|
||||
if (widget.enable3DEffect) {
|
||||
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
|
||||
final index = chartData.indexOf(data);
|
||||
final angle =
|
||||
(index / chartData.length) * 2 * math.pi;
|
||||
@@ -243,11 +349,9 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
}
|
||||
return data.color;
|
||||
},
|
||||
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
|
||||
enableTooltip: true,
|
||||
dataLabelMapper: (PaymentData data, _) {
|
||||
if (widget.showPercentage) {
|
||||
// Calculer le pourcentage avec une décimale
|
||||
final total = chartData.fold(
|
||||
0.0, (sum, item) => sum + item.amount);
|
||||
final percentage = (data.amount / total * 100);
|
||||
@@ -265,7 +369,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
length: '15%',
|
||||
),
|
||||
),
|
||||
// Effet d'explosion plus prononcé pour donner du relief avec animation
|
||||
explode: true,
|
||||
explodeAll: widget.enableEnhancedExplode,
|
||||
explodeIndex: widget.enableEnhancedExplode ? null : 0,
|
||||
@@ -274,40 +377,35 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
|
||||
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
|
||||
// Effet 3D via l'opacité et les couleurs avec animation
|
||||
opacity: widget.enable3DEffect
|
||||
? 0.95 * opacityAnimation.value
|
||||
: opacityAnimation.value,
|
||||
// Animation progressive du graphique
|
||||
animationDuration:
|
||||
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
|
||||
animationDuration: 0,
|
||||
startAngle: 270,
|
||||
endAngle: 270 + (360 * progressAnimation.value).toInt(),
|
||||
),
|
||||
],
|
||||
annotations:
|
||||
widget.showIcons ? _buildIconAnnotations(chartData) : null,
|
||||
// Paramètres pour améliorer l'effet 3D
|
||||
palette: widget.enable3DEffect ? _create3DPalette(chartData) : null,
|
||||
// Ajouter un effet de bordure pour renforcer l'effet 3D
|
||||
borderWidth: widget.enable3DEffect ? 0.5 : 0,
|
||||
// Note: La rotation n'est pas directement prise en charge dans cette version de Syncfusion
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Crée une couleur avec effet 3D en ajoutant des nuances
|
||||
Color _create3DColor(Color baseColor, double intensity) {
|
||||
// Ajuster la luminosité et la saturation pour créer un effet 3D plus prononcé
|
||||
final hslColor = HSLColor.fromColor(baseColor);
|
||||
/// 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();
|
||||
}
|
||||
|
||||
// Augmenter la luminosité pour simuler un éclairage
|
||||
/// 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);
|
||||
|
||||
// Augmenter légèrement la saturation pour des couleurs plus vives
|
||||
final adjustedSaturation =
|
||||
(hslColor.saturation + 0.05 * intensity).clamp(0.0, 1.0);
|
||||
|
||||
@@ -321,24 +419,17 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
List<Color> _create3DPalette(List<PaymentData> chartData) {
|
||||
List<Color> palette = [];
|
||||
|
||||
// Créer des variations de couleurs pour chaque segment
|
||||
for (var i = 0; i < chartData.length; i++) {
|
||||
var data = chartData[i];
|
||||
|
||||
// Calculer un angle pour chaque segment pour simuler un éclairage directionnel
|
||||
final angle = (i / chartData.length) * 2 * math.pi;
|
||||
|
||||
// Créer un effet d'ombre et de lumière en fonction de l'angle
|
||||
final hslColor = HSLColor.fromColor(data.color);
|
||||
|
||||
// Ajuster la luminosité en fonction de l'angle
|
||||
final lightAdjustment = 0.15 * widget.effect3DIntensity * math.sin(angle);
|
||||
final adjustedLightness = (hslColor.lightness -
|
||||
0.1 * widget.effect3DIntensity +
|
||||
lightAdjustment)
|
||||
.clamp(0.0, 1.0);
|
||||
|
||||
// Ajuster la saturation pour plus de profondeur
|
||||
final adjustedSaturation =
|
||||
(hslColor.saturation + 0.1 * widget.effect3DIntensity)
|
||||
.clamp(0.0, 1.0);
|
||||
@@ -356,10 +447,7 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
|
||||
/// Crée une couleur avec effet 3D plus avancé
|
||||
Color _createEnhanced3DColor(Color baseColor, double angle) {
|
||||
// Simuler un effet de lumière directionnel
|
||||
final hslColor = HSLColor.fromColor(baseColor);
|
||||
|
||||
// Ajuster la luminosité en fonction de l'angle pour simuler un éclairage
|
||||
final adjustedLightness = hslColor.lightness +
|
||||
(0.2 * widget.effect3DIntensity * math.sin(angle)).clamp(-0.3, 0.3);
|
||||
|
||||
@@ -371,21 +459,15 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
List<PaymentData> chartData) {
|
||||
final List<CircularChartAnnotation> annotations = [];
|
||||
|
||||
// Calculer le total pour les pourcentages
|
||||
double total = chartData.fold(0.0, (sum, item) => sum + item.amount);
|
||||
|
||||
// Position angulaire actuelle (en radians)
|
||||
double currentAngle = 0;
|
||||
|
||||
for (int i = 0; i < chartData.length; i++) {
|
||||
final data = chartData[i];
|
||||
final percentage = data.amount / 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(
|
||||
@@ -394,11 +476,10 @@ class _PaymentPieChartState extends State<PaymentPieChart>
|
||||
size: 16,
|
||||
),
|
||||
radius: '50%',
|
||||
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
|
||||
angle: (midAngle * (180 / 3.14159)).toInt(),
|
||||
),
|
||||
);
|
||||
|
||||
// Mettre à jour l'angle actuel
|
||||
currentAngle += segmentAngle;
|
||||
}
|
||||
|
||||
|
||||
463
app/lib/presentation/widgets/charts/payment_summary_card.dart
Normal file
463
app/lib/presentation/widgets/charts/payment_summary_card.dart
Normal file
@@ -0,0 +1,463 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.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:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/app.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 {
|
||||
/// 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 règlements (admin) ou seulement ceux de l'utilisateur
|
||||
final bool showAllPayments;
|
||||
|
||||
/// Données statiques de règlements par type (utilisé si useValueListenable = false)
|
||||
final Map<int, double>? paymentsByType;
|
||||
|
||||
/// Fonction de callback pour afficher la valeur totale personnalisée
|
||||
final String Function(double totalAmount)? 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 PaymentSummaryCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.titleColor = AppTheme.accentColor,
|
||||
this.titleIcon = Icons.payments,
|
||||
this.height,
|
||||
this.useValueListenable = true,
|
||||
this.userId,
|
||||
this.showAllPayments = false,
|
||||
this.paymentsByType,
|
||||
this.customTotalDisplay,
|
||||
this.isDesktop = true,
|
||||
this.backgroundIcon = Icons.euro_symbol,
|
||||
this.backgroundIconColor,
|
||||
this.backgroundIconOpacity = 0.07,
|
||||
this.backgroundIconSize = 180,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Icône d'arrière-plan (optionnelle)
|
||||
if (backgroundIcon != null)
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Icon(
|
||||
backgroundIcon,
|
||||
size: backgroundIconSize,
|
||||
color: (backgroundIconColor ?? Colors.blue).withOpacity(backgroundIconOpacity),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Contenu principal
|
||||
Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre avec comptage
|
||||
useValueListenable
|
||||
? _buildTitleWithValueListenable()
|
||||
: _buildTitleWithStaticData(),
|
||||
const Divider(height: 24),
|
||||
// Contenu principal
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Liste des règlements à gauche
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: useValueListenable
|
||||
? _buildPaymentsListWithValueListenable()
|
||||
: _buildPaymentsListWithStaticData(),
|
||||
),
|
||||
|
||||
// Séparateur vertical
|
||||
if (isDesktop) const VerticalDivider(width: 24),
|
||||
|
||||
// Graphique en camembert à droite
|
||||
Expanded(
|
||||
flex: isDesktop ? 1 : 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: PaymentPieChart(
|
||||
useValueListenable: useValueListenable,
|
||||
payments: useValueListenable ? [] : _convertMapToPaymentData(paymentsByType ?? {}),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du titre avec ValueListenableBuilder
|
||||
Widget _buildTitleWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final paymentStats = _calculatePaymentStats(passagesBox);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
Icon(
|
||||
titleIcon,
|
||||
color: titleColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(paymentStats['totalAmount']) ??
|
||||
'${paymentStats['totalAmount'].toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction du titre avec données statiques
|
||||
Widget _buildTitleWithStaticData() {
|
||||
final totalAmount = paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
if (titleIcon != null) ...[
|
||||
Icon(
|
||||
titleIcon,
|
||||
color: titleColor,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
customTotalDisplay?.call(totalAmount) ?? '${totalAmount.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction de la liste des règlements avec ValueListenableBuilder
|
||||
Widget _buildPaymentsListWithValueListenable() {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
final paymentAmounts = _calculatePaymentAmounts(passagesBox);
|
||||
|
||||
return _buildPaymentsList(paymentAmounts);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction de la liste des règlements avec données statiques
|
||||
Widget _buildPaymentsListWithStaticData() {
|
||||
return _buildPaymentsList(paymentsByType ?? {});
|
||||
}
|
||||
|
||||
/// Construction de la liste des règlements
|
||||
Widget _buildPaymentsList(Map<int, double> paymentAmounts) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...AppKeys.typesReglements.entries.map((entry) {
|
||||
final int typeId = entry.key;
|
||||
final Map<String, dynamic> typeData = entry.value;
|
||||
final double amount = paymentAmounts[typeId] ?? 0.0;
|
||||
final Color color = Color(typeData['couleur'] as int);
|
||||
final IconData iconData = typeData['icon_data'] as IconData;
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
typeData['titre'] as String,
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${amount.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Calcule les statistiques de règlement
|
||||
Map<String, dynamic> _calculatePaymentStats(Box<PassageModel> passagesBox) {
|
||||
if (showAllPayments) {
|
||||
// Pour les administrateurs : tous les règlements
|
||||
int passagesWithPaymentCount = 0;
|
||||
double totalAmount = 0.0;
|
||||
|
||||
for (final passage in passagesBox.values) {
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de conversion
|
||||
}
|
||||
|
||||
if (montant > 0) {
|
||||
passagesWithPaymentCount++;
|
||||
totalAmount += montant;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'passagesCount': passagesWithPaymentCount,
|
||||
'totalAmount': totalAmount,
|
||||
};
|
||||
} else {
|
||||
// Pour les utilisateurs : seulement leurs règlements
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
if (targetUserId == null) {
|
||||
return {'passagesCount': 0, 'totalAmount': 0.0};
|
||||
}
|
||||
|
||||
int passagesWithPaymentCount = 0;
|
||||
double totalAmount = 0.0;
|
||||
|
||||
for (final passage in passagesBox.values) {
|
||||
if (passage.fkUser == targetUserId) {
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de conversion
|
||||
}
|
||||
|
||||
if (montant > 0) {
|
||||
passagesWithPaymentCount++;
|
||||
totalAmount += montant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
'passagesCount': passagesWithPaymentCount,
|
||||
'totalAmount': totalAmount,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Calcule les montants par type de règlement
|
||||
Map<int, double> _calculatePaymentAmounts(Box<PassageModel> passagesBox) {
|
||||
final Map<int, double> paymentAmounts = {};
|
||||
|
||||
// Initialiser tous les types
|
||||
for (final typeId in AppKeys.typesReglements.keys) {
|
||||
paymentAmounts[typeId] = 0.0;
|
||||
}
|
||||
|
||||
if (showAllPayments) {
|
||||
// Pour les administrateurs : compter tous les règlements
|
||||
for (final passage in passagesBox.values) {
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de conversion
|
||||
}
|
||||
|
||||
if (montant > 0) {
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] = (paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Pour les utilisateurs : compter seulement leurs règlements
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final targetUserId = userId ?? currentUser?.id;
|
||||
|
||||
if (targetUserId != null) {
|
||||
for (final passage in passagesBox.values) {
|
||||
if (passage.fkUser == targetUserId) {
|
||||
final int typeReglement = passage.fkTypeReglement;
|
||||
|
||||
// Convertir la chaîne de montant en double
|
||||
double montant = 0.0;
|
||||
try {
|
||||
String montantStr = passage.montant.replaceAll(',', '.');
|
||||
montant = double.tryParse(montantStr) ?? 0.0;
|
||||
} catch (e) {
|
||||
// Ignorer les erreurs de conversion
|
||||
}
|
||||
|
||||
if (montant > 0) {
|
||||
if (paymentAmounts.containsKey(typeReglement)) {
|
||||
paymentAmounts[typeReglement] = (paymentAmounts[typeReglement] ?? 0.0) + montant;
|
||||
} else {
|
||||
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paymentAmounts;
|
||||
}
|
||||
|
||||
/// Convertit une Map<int, double> en List<PaymentData> pour les données statiques
|
||||
List<PaymentData> _convertMapToPaymentData(Map<int, double> paymentsMap) {
|
||||
final List<PaymentData> paymentDataList = [];
|
||||
|
||||
paymentsMap.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;
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
|
||||
|
||||
/// Utilitaires pour les paiements et règlements
|
||||
class PaymentUtils {
|
||||
/// Convertit les données de règlement depuis les constantes AppKeys
|
||||
///
|
||||
/// [paymentAmounts] est une Map associant l'ID du type de règlement à son montant
|
||||
static List<PaymentData> getPaymentDataFromAmounts(
|
||||
Map<int, double> paymentAmounts) {
|
||||
final List<PaymentData> paymentDataList = [];
|
||||
|
||||
// Parcourir tous les types de règlements définis dans AppKeys
|
||||
AppKeys.typesReglements.forEach((typeId, typeData) {
|
||||
// Vérifier si nous avons un montant pour ce type de règlement
|
||||
final double amount = paymentAmounts[typeId] ?? 0.0;
|
||||
|
||||
// Créer un objet PaymentData pour ce type de règlement
|
||||
final PaymentData paymentData = PaymentData(
|
||||
typeId: typeId,
|
||||
amount: amount,
|
||||
color: Color(typeData['couleur'] as int),
|
||||
icon: typeData['icon_data'] as IconData,
|
||||
title: typeData['titre'] as String,
|
||||
);
|
||||
|
||||
paymentDataList.add(paymentData);
|
||||
});
|
||||
|
||||
return paymentDataList;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
|
||||
/// Widget pour la zone de saisie des messages
|
||||
class ChatInput extends StatefulWidget {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
|
||||
/// Widget pour afficher les messages d'une conversation
|
||||
class ChatMessages extends StatelessWidget {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
|
||||
/// Widget pour afficher la barre latérale des contacts
|
||||
class ChatSidebar extends StatelessWidget {
|
||||
|
||||
@@ -22,6 +22,7 @@ class CustomTextField extends StatelessWidget {
|
||||
final Color? fillColor;
|
||||
final String? helperText;
|
||||
final Function(String)? onFieldSubmitted;
|
||||
final bool isRequired;
|
||||
|
||||
const CustomTextField({
|
||||
super.key,
|
||||
@@ -45,6 +46,7 @@ class CustomTextField extends StatelessWidget {
|
||||
this.fillColor,
|
||||
this.helperText,
|
||||
this.onFieldSubmitted,
|
||||
this.isRequired = false,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -65,93 +67,113 @@ class CustomTextField extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// Ajouter un Container avec une ombre pour créer un effet d'élévation
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
validator: validator,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
minLines: minLines,
|
||||
readOnly: readOnly,
|
||||
onTap: onTap,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
autofocus: autofocus,
|
||||
focusNode: focusNode,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground.withOpacity(0.5),
|
||||
),
|
||||
errorText: errorText,
|
||||
helperText: helperText,
|
||||
helperStyle: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
prefixIcon: prefixIcon != null
|
||||
? Icon(prefixIcon, color: theme.colorScheme.primary)
|
||||
: null,
|
||||
suffixIcon: suffixIcon,
|
||||
// Couleur de fond différente selon l'état (lecture seule ou éditable)
|
||||
fillColor: fillColor ??
|
||||
(readOnly
|
||||
? const Color(0xFFF8F9FA) // Gris plus clair pour readOnly
|
||||
: const Color(
|
||||
0xFFECEFF1)), // Gris plus foncé pour éditable
|
||||
filled: true,
|
||||
// Ajouter une élévation avec une petite ombre
|
||||
border: OutlineInputBorder(
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.05),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
// Ajouter une ombre pour créer un effet d'élévation
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
gapPadding: 0,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2,
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
obscureText: obscureText,
|
||||
keyboardType: keyboardType,
|
||||
validator: validator,
|
||||
inputFormatters: inputFormatters,
|
||||
maxLines: maxLines,
|
||||
minLines: minLines,
|
||||
readOnly: readOnly,
|
||||
onTap: onTap,
|
||||
onChanged: onChanged,
|
||||
onFieldSubmitted: onFieldSubmitted,
|
||||
autofocus: autofocus,
|
||||
focusNode: focusNode,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
hintStyle: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: theme.colorScheme.onBackground.withOpacity(0.5),
|
||||
),
|
||||
errorText: errorText,
|
||||
helperText: helperText,
|
||||
helperStyle: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onBackground.withOpacity(0.6),
|
||||
),
|
||||
prefixIcon: prefixIcon != null
|
||||
? Icon(prefixIcon, color: theme.colorScheme.primary)
|
||||
: null,
|
||||
suffixIcon: suffixIcon,
|
||||
// Couleur de fond différente selon l'état (lecture seule ou éditable)
|
||||
fillColor: fillColor ??
|
||||
(readOnly
|
||||
? const Color(
|
||||
0xFFF8F9FA) // Gris plus clair pour readOnly
|
||||
: const Color(
|
||||
0xFFECEFF1)), // Gris plus foncé pour éditable
|
||||
filled: true,
|
||||
// Ajouter une élévation avec une petite ombre
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
// Ajouter une ombre pour créer un effet d'élévation
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
gapPadding: 0,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: theme.colorScheme.error,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Point rouge en haut à droite pour indiquer que le champ est obligatoire
|
||||
if (isRequired)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
margin: const EdgeInsets.only(top: 8, right: 8),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart';
|
||||
import 'package:geosector_app/core/services/app_info_service.dart';
|
||||
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
|
||||
import 'package:geosector_app/presentation/widgets/profile_dialog.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@@ -12,9 +13,6 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
/// Le titre de la page actuelle (optionnel)
|
||||
final String? pageTitle;
|
||||
|
||||
/// Actions supplémentaires à afficher dans l'AppBar
|
||||
final List<Widget>? additionalActions;
|
||||
|
||||
/// Indique si le bouton "Nouveau passage" doit être affiché
|
||||
final bool showNewPassageButton;
|
||||
|
||||
@@ -31,7 +29,6 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
Key? key,
|
||||
required this.title,
|
||||
this.pageTitle,
|
||||
this.additionalActions,
|
||||
this.showNewPassageButton = true,
|
||||
this.onNewPassagePressed,
|
||||
this.isAdmin = false,
|
||||
@@ -56,10 +53,15 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
Widget _buildLogo() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Image.asset(
|
||||
'assets/images/logo-geosector-1024.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/images/logo-geosector-1024.png',
|
||||
width: 40,
|
||||
height: 40,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -71,33 +73,38 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
|
||||
// Ajouter l'indicateur de connectivité
|
||||
actions.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
|
||||
child: const ConnectivityIndicator(
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
|
||||
child: ConnectivityIndicator(
|
||||
showErrorMessage: false,
|
||||
showConnectionType: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Ajouter les actions supplémentaires si elles existent
|
||||
if (additionalActions != null && additionalActions!.isNotEmpty) {
|
||||
actions.addAll(additionalActions!);
|
||||
} else if (showNewPassageButton) {
|
||||
// Ajouter le bouton "Nouveau passage" en haut à droite
|
||||
actions.add(
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add_location_alt, color: Colors.white),
|
||||
label: const Text('Nouveau passage',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
onPressed: onNewPassagePressed,
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.secondary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
// Ajouter la version de l'application
|
||||
actions.add(
|
||||
Text(
|
||||
AppInfoService.fullVersion,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white70,
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
actions.add(
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add_location_alt, color: Colors.white),
|
||||
label: const Text('Nouveau passage',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
onPressed: onNewPassagePressed,
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.secondary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Ajouter le bouton "Mon compte"
|
||||
actions.add(
|
||||
@@ -128,25 +135,34 @@ class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
tooltip: 'Déconnexion',
|
||||
onPressed: onLogoutPressed ??
|
||||
() {
|
||||
// Si aucun callback n'est fourni, utiliser le userRepository global
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Déconnexion'),
|
||||
content:
|
||||
const Text('Voulez-vous vraiment vous déconnecter ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
// Utiliser directement userRepository pour la déconnexion
|
||||
// qui gère à la fois le nettoyage des données et la redirection
|
||||
await userRepository.logoutWithUI(context);
|
||||
// La redirection est gérée dans logoutWithUI
|
||||
// Fermer la dialog d'abord
|
||||
Navigator.of(dialogContext).pop();
|
||||
|
||||
// Utiliser le context original de l'AppBar pour la navigation
|
||||
final success = await userRepository.logout(context);
|
||||
|
||||
// Vérification supplémentaire et navigation forcée si nécessaire
|
||||
if (success && context.mounted) {
|
||||
// Attendre un court instant pour que les changements d'état se propagent
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 100));
|
||||
|
||||
// Navigation forcée vers la page d'accueil
|
||||
context.go('/');
|
||||
}
|
||||
},
|
||||
child: const Text('Déconnexion'),
|
||||
),
|
||||
|
||||
@@ -85,7 +85,6 @@ class DashboardLayout extends StatelessWidget {
|
||||
appBar: DashboardAppBar(
|
||||
title: title,
|
||||
pageTitle: destinations[selectedIndex].label,
|
||||
additionalActions: additionalActions,
|
||||
showNewPassageButton: showNewPassageButton,
|
||||
onNewPassagePressed: onNewPassagePressed,
|
||||
isAdmin: isAdmin,
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
# Documentation des Widgets Amicale
|
||||
|
||||
Cette documentation explique comment utiliser les widgets `AmicaleRowWidget` et `AmicaleTableWidget` pour afficher et gérer les données des amicales dans l'application.
|
||||
|
||||
## AmicaleRowWidget
|
||||
|
||||
Le widget `AmicaleRowWidget` représente une ligne dans un tableau d'amicales. Il affiche les informations d'une amicale avec les colonnes suivantes :
|
||||
|
||||
- ID
|
||||
- Nom
|
||||
- Code Postal
|
||||
- Région
|
||||
- Actions (boutons selon les droits de l'utilisateur)
|
||||
|
||||
### Propriétés
|
||||
|
||||
| Propriété | Type | Description |
|
||||
| ------------- | --------------- | ---------------------------------------------------------------------------------- |
|
||||
| `amicale` | `AmicaleModel` | **Obligatoire**. L'objet amicale à afficher. |
|
||||
| `onEdit` | `VoidCallback?` | Fonction appelée lorsque l'utilisateur clique sur le bouton d'édition. |
|
||||
| `onDelete` | `VoidCallback?` | Fonction appelée lorsque l'utilisateur clique sur le bouton de suppression. |
|
||||
| `isAlternate` | `bool` | Indique si la ligne doit avoir une couleur de fond alternée. Par défaut à `false`. |
|
||||
|
||||
### Gestion des droits d'accès
|
||||
|
||||
Le widget gère automatiquement l'affichage des boutons d'action en fonction du rôle de l'utilisateur :
|
||||
|
||||
- Le bouton d'édition (crayon) est visible pour tous les utilisateurs avec un rôle > 1
|
||||
- Le bouton de suppression (corbeille) est visible uniquement pour les utilisateurs avec un rôle > 2
|
||||
|
||||
### Exemple d'utilisation
|
||||
|
||||
```dart
|
||||
AmicaleRowWidget(
|
||||
amicale: amicale,
|
||||
isAlternate: index % 2 == 1, // Alterner les couleurs
|
||||
onEdit: () {
|
||||
// Code pour gérer l'édition
|
||||
},
|
||||
onDelete: () {
|
||||
// Code pour gérer la suppression
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## AmicaleTableWidget
|
||||
|
||||
Le widget `AmicaleTableWidget` affiche un tableau complet d'amicales avec un en-tête et des lignes. Il utilise le widget `AmicaleRowWidget` pour afficher chaque ligne.
|
||||
|
||||
### Propriétés
|
||||
|
||||
| Propriété | Type | Description |
|
||||
| -------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `amicales` | `List<AmicaleModel>` | **Obligatoire**. La liste des amicales à afficher. |
|
||||
| `onEdit` | `Function(AmicaleModel)?` | Fonction appelée lorsque l'utilisateur clique sur le bouton d'édition d'une amicale. |
|
||||
| `onDelete` | `Function(AmicaleModel)?` | Fonction appelée lorsque l'utilisateur clique sur le bouton de suppression d'une amicale. |
|
||||
| `isLoading` | `bool` | Indique si les données sont en cours de chargement. Affiche un indicateur de chargement si `true`. Par défaut à `false`. |
|
||||
| `emptyMessage` | `String?` | Message à afficher lorsque la liste des amicales est vide. |
|
||||
|
||||
### États du tableau
|
||||
|
||||
Le widget gère automatiquement différents états :
|
||||
|
||||
1. **Chargement** : Affiche un indicateur de chargement circulaire lorsque `isLoading` est `true`.
|
||||
2. **Liste vide** : Affiche un message lorsque la liste des amicales est vide.
|
||||
3. **Affichage normal** : Affiche la liste des amicales avec des lignes alternées.
|
||||
|
||||
### Exemple d'utilisation
|
||||
|
||||
```dart
|
||||
AmicaleTableWidget(
|
||||
amicales: _amicales,
|
||||
isLoading: _isLoading,
|
||||
onEdit: (amicale) {
|
||||
// Code pour gérer l'édition de l'amicale
|
||||
},
|
||||
onDelete: (amicale) {
|
||||
// Code pour gérer la suppression de l'amicale
|
||||
},
|
||||
emptyMessage: 'Aucune amicale trouvée. Veuillez en créer une nouvelle.',
|
||||
)
|
||||
```
|
||||
|
||||
## Intégration avec AmicaleRepository
|
||||
|
||||
Pour utiliser ces widgets avec le repository des amicales, vous devez :
|
||||
|
||||
1. Récupérer les amicales depuis le repository :
|
||||
|
||||
```dart
|
||||
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
|
||||
final amicales = amicaleRepository.getAllAmicales();
|
||||
```
|
||||
|
||||
2. Gérer les actions d'édition et de suppression :
|
||||
|
||||
```dart
|
||||
void _handleEdit(AmicaleModel amicale) {
|
||||
// Naviguer vers la page d'édition ou afficher une boîte de dialogue
|
||||
}
|
||||
|
||||
Future<void> _handleDelete(AmicaleModel amicale) async {
|
||||
// Afficher une confirmation puis supprimer
|
||||
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
|
||||
await amicaleRepository.deleteAmicale(amicale.id);
|
||||
|
||||
// Recharger la liste
|
||||
setState(() {
|
||||
_amicales = amicaleRepository.getAllAmicales();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Exemple complet
|
||||
|
||||
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/amicale_table_example.dart`.
|
||||
|
||||
## Personnalisation
|
||||
|
||||
### Styles
|
||||
|
||||
Les widgets utilisent les styles du thème de l'application pour la cohérence visuelle. Vous pouvez personnaliser l'apparence en modifiant le thème ou en surchargeant les styles dans votre implémentation.
|
||||
|
||||
### Colonnes et flexibilité
|
||||
|
||||
Les colonnes du tableau ont des valeurs de flex prédéfinies pour une mise en page optimale :
|
||||
|
||||
- ID : flex 1
|
||||
- Nom : flex 4
|
||||
- Code Postal : flex 2
|
||||
- Région : flex 3
|
||||
- Actions : flex 2
|
||||
|
||||
Vous pouvez ajuster ces valeurs en modifiant le code source si nécessaire.
|
||||
|
||||
## Bonnes pratiques
|
||||
|
||||
1. **Gestion des erreurs** : Ajoutez toujours une gestion des erreurs lors de l'interaction avec le repository.
|
||||
2. **Confirmation des actions** : Demandez toujours une confirmation avant de supprimer une amicale.
|
||||
3. **Actualisation des données** : Prévoyez un moyen de rafraîchir les données (bouton ou pull-to-refresh).
|
||||
4. **Pagination** : Pour les grandes listes, envisagez d'implémenter une pagination.
|
||||
@@ -1,204 +0,0 @@
|
||||
# Documentation du Widget EntiteForm
|
||||
|
||||
Cette documentation décrit le widget `EntiteForm` créé pour la création et la modification des entités (amicales) dans l'application GeoSector.
|
||||
|
||||
## Description
|
||||
|
||||
Le widget `EntiteForm` est un formulaire complet permettant de créer ou modifier une entité (amicale). Il gère l'affichage de tous les champs nécessaires, la validation des données et les restrictions d'accès basées sur le rôle de l'utilisateur.
|
||||
|
||||
## Propriétés
|
||||
|
||||
- `amicale` (AmicaleModel?, optionnel) : Le modèle d'amicale à modifier. Si null, le formulaire sera en mode création.
|
||||
- `onSubmit` (Function(AmicaleModel)?, optionnel) : Callback appelé lorsque le formulaire est soumis avec succès.
|
||||
- `readOnly` (bool, défaut: false) : Si true, tous les champs du formulaire seront en lecture seule.
|
||||
|
||||
## Champs du formulaire
|
||||
|
||||
Le formulaire inclut les champs suivants :
|
||||
|
||||
### Informations générales
|
||||
|
||||
- **Nom** : Nom de l'amicale (obligatoire)
|
||||
|
||||
### Adresse
|
||||
|
||||
- **Adresse ligne 1** : Première ligne d'adresse
|
||||
- **Adresse ligne 2** : Seconde ligne d'adresse (optionnelle)
|
||||
- **Code Postal** : Code postal (validation pour 5 chiffres)
|
||||
- **Ville** : Nom de la ville
|
||||
- **Région** : Sélection de la région via un dropdown
|
||||
|
||||
### Contact
|
||||
|
||||
- **Téléphone fixe** : Numéro de téléphone fixe (validation pour 10 chiffres)
|
||||
- **Téléphone mobile** : Numéro de téléphone mobile (validation pour 10 chiffres)
|
||||
- **Email** : Adresse email (obligatoire, avec validation de format)
|
||||
|
||||
### Informations avancées (visibles uniquement pour les administrateurs ou si déjà remplies)
|
||||
|
||||
- **GPS Latitude** : Coordonnée GPS latitude
|
||||
- **GPS Longitude** : Coordonnée GPS longitude
|
||||
- **Stripe ID** : Identifiant Stripe pour les paiements
|
||||
|
||||
### Options
|
||||
|
||||
- **Mode démo** : Indique si l'amicale est en mode démo
|
||||
- **Copie des mails reçus** : Indique si l'amicale reçoit une copie des emails
|
||||
- **Accepte les SMS** : Indique si l'amicale accepte les SMS
|
||||
- **Actif** : Indique si l'amicale est active
|
||||
|
||||
## Restrictions d'accès
|
||||
|
||||
Certains champs sont soumis à des restrictions d'accès basées sur le rôle de l'utilisateur :
|
||||
|
||||
- Les champs suivants sont en lecture seule pour les utilisateurs avec un rôle ≤ 2 :
|
||||
- fkRegion/libRegion
|
||||
- gpsLat
|
||||
- gpsLng
|
||||
- stripeId
|
||||
- chkDemo
|
||||
- chkActive
|
||||
|
||||
## Exemple d'utilisation
|
||||
|
||||
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/entite_form_example.dart`.
|
||||
|
||||
### Utilisation simple
|
||||
|
||||
```dart
|
||||
// Création d'une nouvelle amicale
|
||||
EntiteForm(
|
||||
onSubmit: (amicale) {
|
||||
// Gérer la soumission
|
||||
print('Nouvelle amicale: ${amicale.name}');
|
||||
},
|
||||
)
|
||||
|
||||
// Modification d'une amicale existante
|
||||
EntiteForm(
|
||||
amicale: amicaleExistante,
|
||||
onSubmit: (amicale) {
|
||||
// Gérer la soumission
|
||||
print('Amicale modifiée: ${amicale.name}');
|
||||
},
|
||||
)
|
||||
|
||||
// Affichage en lecture seule
|
||||
EntiteForm(
|
||||
amicale: amicaleExistante,
|
||||
readOnly: true,
|
||||
)
|
||||
```
|
||||
|
||||
### Utilisation avec gestion d'état
|
||||
|
||||
```dart
|
||||
class _MyWidgetState extends State<MyWidget> {
|
||||
AmicaleModel? _amicale;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAmicale();
|
||||
}
|
||||
|
||||
Future<void> _loadAmicale() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
if (widget.amicaleId != null) {
|
||||
// Récupérer l'amicale depuis le repository
|
||||
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
|
||||
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
|
||||
|
||||
setState(() {
|
||||
_amicale = amicale;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
// Création d'une nouvelle amicale
|
||||
setState(() {
|
||||
_amicale = null;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement de l\'amicale: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmit(AmicaleModel amicale) async {
|
||||
try {
|
||||
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
|
||||
|
||||
// Sauvegarder l'amicale
|
||||
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
|
||||
|
||||
// Afficher un message de confirmation
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la sauvegarde: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: EntiteForm(
|
||||
amicale: _amicale,
|
||||
onSubmit: _handleSubmit,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Intégration avec le système de rôles
|
||||
|
||||
Le widget utilise le `UserRepository` pour déterminer le rôle de l'utilisateur actuel et appliquer les restrictions d'accès en conséquence. Assurez-vous que le `UserRepository` est disponible dans l'arbre des widgets via un `Provider`.
|
||||
|
||||
```dart
|
||||
// Dans le widget parent
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider<UserRepository>(
|
||||
create: (context) => userRepository,
|
||||
),
|
||||
Provider<AmicaleRepository>(
|
||||
create: (context) => amicaleRepository,
|
||||
),
|
||||
],
|
||||
child: MyWidget(),
|
||||
);
|
||||
```
|
||||
|
||||
## Personnalisation
|
||||
|
||||
Le widget utilise le thème de l'application pour le style. Vous pouvez personnaliser l'apparence en modifiant le thème ou en étendant le widget pour créer votre propre version personnalisée.
|
||||
|
||||
## Validation des données
|
||||
|
||||
Le formulaire inclut une validation pour les champs suivants :
|
||||
|
||||
- **Nom** : Ne peut pas être vide
|
||||
- **Code Postal** : Doit contenir 5 chiffres s'il est rempli
|
||||
- **Téléphone fixe** : Doit contenir 10 chiffres s'il est rempli
|
||||
- **Téléphone mobile** : Doit contenir 10 chiffres s'il est rempli
|
||||
- **Email** : Ne peut pas être vide et doit contenir un '@' et un '.'
|
||||
@@ -1,160 +0,0 @@
|
||||
# Documentation du Widget EntiteForm avec RegionRepository
|
||||
|
||||
Cette documentation explique comment utiliser le widget `EntiteForm` avec le `RegionRepository` pour afficher et gérer les régions dans le formulaire d'entité.
|
||||
|
||||
## Intégration du RegionRepository
|
||||
|
||||
Le widget `EntiteForm` est conçu pour fonctionner avec le `RegionRepository` afin de récupérer la liste des régions disponibles pour le champ de sélection de région. Voici comment l'intégrer :
|
||||
|
||||
### 1. Initialisation du RegionRepository
|
||||
|
||||
Le `RegionRepository` doit être initialisé et fourni au widget `EntiteForm` via un `Provider`. Voici un exemple d'initialisation :
|
||||
|
||||
```dart
|
||||
final regionRepository = RegionRepository();
|
||||
await regionRepository.init();
|
||||
```
|
||||
|
||||
### 2. Fournir le RegionRepository via Provider
|
||||
|
||||
Pour que le widget `EntiteForm` puisse accéder au `RegionRepository`, vous devez le fournir via un `Provider` :
|
||||
|
||||
```dart
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<RegionRepository>.value(value: regionRepository),
|
||||
// Autres providers si nécessaire
|
||||
],
|
||||
child: EntiteForm(
|
||||
amicale: amicale,
|
||||
onSubmit: handleSubmit,
|
||||
readOnly: false,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Mise à jour des régions depuis l'API
|
||||
|
||||
Lorsque l'API renvoie les données des régions dans la réponse de login, vous devez les mettre à jour dans le `RegionRepository` :
|
||||
|
||||
```dart
|
||||
// Dans le service qui gère la connexion
|
||||
void handleLoginResponse(Map<String, dynamic> response) {
|
||||
// Autres traitements...
|
||||
|
||||
// Mise à jour des régions si présentes dans la réponse
|
||||
if (response.containsKey('regions') && response['regions'] is List) {
|
||||
final regionRepository = Provider.of<RegionRepository>(context, listen: false);
|
||||
regionRepository.updateRegionsFromApi(response['regions']);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Fonctionnement avec les restrictions d'accès
|
||||
|
||||
Le widget `EntiteForm` gère automatiquement les restrictions d'accès basées sur le rôle de l'utilisateur :
|
||||
|
||||
- Pour les utilisateurs avec un rôle ≤ 2, le champ de sélection de région est en lecture seule
|
||||
- Pour les utilisateurs avec un rôle > 2, le champ de sélection de région est modifiable
|
||||
|
||||
## Filtrage des régions selon le code postal
|
||||
|
||||
Le `RegionRepository` offre une méthode `getRegionByPostalCode` qui permet de filtrer les régions en fonction du code postal :
|
||||
|
||||
```dart
|
||||
// Récupérer la région correspondant au code postal
|
||||
final codePostal = '75001';
|
||||
final region = regionRepository.getRegionByPostalCode(codePostal);
|
||||
if (region != null) {
|
||||
// Utiliser la région trouvée
|
||||
print('Région trouvée : ${region.libelle}');
|
||||
}
|
||||
```
|
||||
|
||||
Cette fonctionnalité est particulièrement utile pour pré-remplir le champ de région lorsque l'utilisateur entre un code postal.
|
||||
|
||||
## Exemple complet d'utilisation
|
||||
|
||||
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/entite_form_with_regions_example.dart`.
|
||||
|
||||
### Exemple simplifié
|
||||
|
||||
```dart
|
||||
class MyWidget extends StatefulWidget {
|
||||
@override
|
||||
State<MyWidget> createState() => _MyWidgetState();
|
||||
}
|
||||
|
||||
class _MyWidgetState extends State<MyWidget> {
|
||||
late RegionRepository _regionRepository;
|
||||
AmicaleModel? _amicale;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_regionRepository = RegionRepository();
|
||||
_initData();
|
||||
}
|
||||
|
||||
Future<void> _initData() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Initialiser le repository des régions
|
||||
await _regionRepository.init();
|
||||
|
||||
// Charger l'amicale si nécessaire
|
||||
// ...
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmit(AmicaleModel amicale) {
|
||||
// Traiter la soumission du formulaire
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<RegionRepository>.value(value: _regionRepository),
|
||||
],
|
||||
child: Scaffold(
|
||||
appBar: AppBar(title: Text('Formulaire d\'entité')),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: EntiteForm(
|
||||
amicale: _amicale,
|
||||
onSubmit: _handleSubmit,
|
||||
readOnly: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Mise à jour du code postal et de la région
|
||||
|
||||
Pour mettre à jour automatiquement la région lorsque l'utilisateur change le code postal, vous pouvez étendre le widget `EntiteForm` ou créer un wrapper qui écoute les changements du champ de code postal et met à jour la région en conséquence.
|
||||
|
||||
## Remarques importantes
|
||||
|
||||
1. Assurez-vous que le `RegionRepository` est initialisé avant d'afficher le formulaire.
|
||||
2. Le widget `EntiteForm` s'adapte automatiquement au rôle de l'utilisateur pour les restrictions d'accès.
|
||||
3. Les régions sont filtrées en fonction du code postal de l'amicale pour les utilisateurs avec un rôle ≤ 2.
|
||||
4. Pour les utilisateurs avec un rôle > 2, toutes les régions sont disponibles dans le dropdown.
|
||||
@@ -1,207 +0,0 @@
|
||||
# Documentation des Widgets Membre
|
||||
|
||||
Cette documentation décrit les widgets créés pour afficher et gérer les données des membres dans l'application GeoSector.
|
||||
|
||||
## Widgets disponibles
|
||||
|
||||
### 1. MembreRowWidget
|
||||
|
||||
Widget qui représente une ligne individuelle dans un tableau de membres. Il affiche les informations d'un membre et des boutons d'action pour l'édition et la suppression.
|
||||
|
||||
#### Propriétés
|
||||
|
||||
- `membre` (MembreModel, requis) : Le modèle de membre à afficher
|
||||
- `onEdit` (Function()?, optionnel) : Callback appelé lorsque le bouton d'édition est pressé
|
||||
- `onDelete` (Function()?, optionnel) : Callback appelé lorsque le bouton de suppression est pressé
|
||||
|
||||
#### Colonnes affichées
|
||||
|
||||
- ID : Identifiant unique du membre
|
||||
- Prénom (firstName) : Prénom du membre
|
||||
- Nom (name) : Nom de famille du membre
|
||||
- Secteur (sectName) : Nom du secteur auquel le membre est associé
|
||||
- Rôle (fkRole) : Rôle du membre (affiché sous forme de texte : User, Admin, Super)
|
||||
- Actions : Boutons d'édition et de suppression
|
||||
|
||||
### 2. MembreTableWidget
|
||||
|
||||
Widget qui affiche un tableau complet de membres avec en-tête et lignes. Il utilise le widget `MembreRowWidget` pour afficher chaque ligne.
|
||||
|
||||
#### Propriétés
|
||||
|
||||
- `membres` (List<MembreModel>, requis) : La liste des membres à afficher
|
||||
- `onEdit` (Function(MembreModel)?, optionnel) : Callback appelé lorsque le bouton d'édition est pressé pour un membre
|
||||
- `onDelete` (Function(MembreModel)?, optionnel) : Callback appelé lorsque le bouton de suppression est pressé pour un membre
|
||||
- `showHeader` (bool, défaut: true) : Indique si l'en-tête du tableau doit être affiché
|
||||
- `height` (double?, optionnel) : Hauteur du tableau (null pour prendre toute la hauteur disponible)
|
||||
- `padding` (EdgeInsetsGeometry?, optionnel) : Padding du tableau
|
||||
|
||||
## Exemple d'utilisation
|
||||
|
||||
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/membre_table_example.dart`.
|
||||
|
||||
### Utilisation simple
|
||||
|
||||
```dart
|
||||
// S'assurer que la boîte Hive est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
|
||||
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
|
||||
}
|
||||
|
||||
// Récupérer les membres depuis la boîte Hive
|
||||
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
final membres = membresBox.values.toList();
|
||||
|
||||
// Afficher le tableau
|
||||
return MembreTableWidget(
|
||||
membres: membres,
|
||||
onEdit: (membre) {
|
||||
// Gérer l'édition
|
||||
},
|
||||
onDelete: (membre) {
|
||||
// Gérer la suppression
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
### Utilisation avec gestion d'état
|
||||
|
||||
```dart
|
||||
class _MyWidgetState extends State<MyWidget> {
|
||||
List<MembreModel> _membres = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadMembres();
|
||||
}
|
||||
|
||||
Future<void> _loadMembres() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// S'assurer que la boîte Hive est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
|
||||
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
|
||||
}
|
||||
|
||||
// Récupérer les membres depuis la boîte Hive
|
||||
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
final membres = membresBox.values.toList();
|
||||
|
||||
setState(() {
|
||||
_membres = membres;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des membres: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: MembreTableWidget(
|
||||
membres: _membres,
|
||||
onEdit: _handleEdit,
|
||||
onDelete: _handleDelete,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Gestion des événements
|
||||
|
||||
### Édition d'un membre
|
||||
|
||||
```dart
|
||||
void _handleEdit(MembreModel membre) {
|
||||
// Exemple de gestion de l'événement d'édition
|
||||
debugPrint('Édition du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
|
||||
|
||||
// Ici, vous pourriez ouvrir une boîte de dialogue ou naviguer vers une page d'édition
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Édition de membre'),
|
||||
content: Text('Vous avez demandé à éditer le membre ${membre.firstName} ${membre.name}'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Suppression d'un membre
|
||||
|
||||
```dart
|
||||
void _handleDelete(MembreModel membre) {
|
||||
// Exemple de gestion de l'événement de suppression
|
||||
debugPrint('Suppression du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
|
||||
|
||||
// Demander confirmation avant de supprimer
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmation de suppression'),
|
||||
content: Text('Êtes-vous sûr de vouloir supprimer le membre ${membre.firstName} ${membre.name} ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
// Fermer la boîte de dialogue
|
||||
Navigator.of(context).pop();
|
||||
|
||||
try {
|
||||
// Supprimer le membre de la boîte Hive
|
||||
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
await membresBox.delete(membre.id);
|
||||
|
||||
// Mettre à jour l'état
|
||||
setState(() {
|
||||
_membres = _membres.where((m) => m.id != membre.id).toList();
|
||||
});
|
||||
|
||||
// Afficher un message de confirmation
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Membre ${membre.firstName} ${membre.name} supprimé'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la suppression du membre: $e');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la suppression: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Supprimer'),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Personnalisation
|
||||
|
||||
Les widgets utilisent le thème de l'application pour le style. Vous pouvez personnaliser l'apparence en modifiant le thème ou en étendant les widgets pour créer vos propres versions personnalisées.
|
||||
@@ -1,234 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/repositories/amicale_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/amicale_table_widget.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Exemple d'utilisation du widget AmicaleTableWidget
|
||||
///
|
||||
/// Ce widget montre comment intégrer le tableau d'amicales dans une page
|
||||
/// et comment gérer les actions d'édition et de suppression.
|
||||
class AmicaleTableExample extends StatefulWidget {
|
||||
const AmicaleTableExample({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AmicaleTableExample> createState() => _AmicaleTableExampleState();
|
||||
}
|
||||
|
||||
class _AmicaleTableExampleState extends State<AmicaleTableExample> {
|
||||
bool _isLoading = true;
|
||||
List<AmicaleModel> _amicales = [];
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAmicales();
|
||||
}
|
||||
|
||||
Future<void> _loadAmicales() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
// Récupérer les amicales depuis le repository
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
final amicales = amicaleRepository.getAllAmicales();
|
||||
|
||||
setState(() {
|
||||
_amicales = amicales;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = 'Erreur lors du chargement des amicales: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEdit(AmicaleModel amicale) {
|
||||
// Afficher une boîte de dialogue de confirmation
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Modifier l\'amicale'),
|
||||
content: Text('Voulez-vous modifier l\'amicale ${amicale.name} ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
// Naviguer vers la page de modification
|
||||
// Navigator.of(context).push(
|
||||
// MaterialPageRoute(
|
||||
// builder: (context) => EditAmicalePage(amicale: amicale),
|
||||
// ),
|
||||
// );
|
||||
},
|
||||
child: const Text('Modifier'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDelete(AmicaleModel amicale) {
|
||||
// Afficher une boîte de dialogue de confirmation
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Supprimer l\'amicale'),
|
||||
content: Text(
|
||||
'Êtes-vous sûr de vouloir supprimer l\'amicale ${amicale.name} ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_deleteAmicale(amicale);
|
||||
},
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteAmicale(AmicaleModel amicale) async {
|
||||
try {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
// Supprimer l'amicale via le repository
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
await amicaleRepository.deleteAmicale(amicale.id);
|
||||
|
||||
// Recharger la liste
|
||||
await _loadAmicales();
|
||||
|
||||
// Afficher un message de succès
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Amicale ${amicale.name} supprimée avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = 'Erreur lors de la suppression: $e';
|
||||
});
|
||||
|
||||
// Afficher un message d'erreur
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur: $_errorMessage'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Liste des amicales'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadAmicales,
|
||||
tooltip: 'Actualiser',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre et description
|
||||
Text(
|
||||
'Gestion des amicales',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Consultez, modifiez ou supprimez les amicales selon vos droits d\'accès.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Message d'erreur si présent
|
||||
if (_errorMessage != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.red.withOpacity(0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.red),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Tableau des amicales
|
||||
Expanded(
|
||||
child: AmicaleTableWidget(
|
||||
amicales: _amicales,
|
||||
isLoading: _isLoading,
|
||||
onDelete: _handleDelete,
|
||||
emptyMessage:
|
||||
'Aucune amicale trouvée. Veuillez en créer une nouvelle.',
|
||||
readOnly: false, // Permettre la modification dans la modale
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () {
|
||||
// Naviguer vers la page de création
|
||||
// Navigator.of(context).push(
|
||||
// MaterialPageRoute(
|
||||
// builder: (context) => CreateAmicalePage(),
|
||||
// ),
|
||||
// );
|
||||
},
|
||||
tooltip: 'Ajouter une amicale',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/repositories/amicale_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/entite_form.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Exemple d'utilisation du widget EntiteForm
|
||||
///
|
||||
/// Ce widget montre comment intégrer le formulaire d'entité dans une page
|
||||
/// et comment gérer les événements de soumission.
|
||||
class EntiteFormExample extends StatefulWidget {
|
||||
final int? amicaleId;
|
||||
final bool readOnly;
|
||||
|
||||
const EntiteFormExample({
|
||||
Key? key,
|
||||
this.amicaleId,
|
||||
this.readOnly = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EntiteFormExample> createState() => _EntiteFormExampleState();
|
||||
}
|
||||
|
||||
class _EntiteFormExampleState extends State<EntiteFormExample> {
|
||||
AmicaleModel? _amicale;
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadAmicale();
|
||||
}
|
||||
|
||||
Future<void> _loadAmicale() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
if (widget.amicaleId != null) {
|
||||
// Récupérer l'amicale depuis le repository
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
|
||||
|
||||
setState(() {
|
||||
_amicale = amicale;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
// Création d'une nouvelle amicale
|
||||
setState(() {
|
||||
_amicale = null;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement de l\'amicale: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmit(AmicaleModel amicale) async {
|
||||
try {
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
|
||||
// Sauvegarder l'amicale
|
||||
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
|
||||
|
||||
// Afficher un message de confirmation
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
|
||||
// Retourner à la page précédente
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la sauvegarde: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userRepository = Provider.of<UserRepository>(context, listen: false);
|
||||
final userRole = userRepository.getUserRole();
|
||||
final bool canCreate = userRole >
|
||||
1; // Seuls les utilisateurs avec rôle > 1 peuvent créer/modifier
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.amicaleId != null
|
||||
? (widget.readOnly
|
||||
? 'Détails de l\'amicale'
|
||||
: 'Modifier l\'amicale')
|
||||
: 'Nouvelle amicale'),
|
||||
actions: [
|
||||
if (!widget.readOnly && _amicale != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () => _showDeleteConfirmation(context),
|
||||
tooltip: 'Supprimer',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: !canCreate && _amicale == null
|
||||
? const Center(
|
||||
child: Text(
|
||||
'Vous n\'avez pas les droits pour créer une amicale'),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: EntiteForm(
|
||||
amicale: _amicale,
|
||||
onSubmit: widget.readOnly ? null : _handleSubmit,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showDeleteConfirmation(BuildContext context) {
|
||||
if (_amicale == null) return;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmation de suppression'),
|
||||
content: Text(
|
||||
'Êtes-vous sûr de vouloir supprimer l\'amicale ${_amicale!.name} ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
|
||||
try {
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
await amicaleRepository.deleteAmicale(_amicale!.id);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Amicale ${_amicale!.name} supprimée'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
|
||||
// Retourner à la page précédente
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la suppression de l\'amicale: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la suppression: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Supprimer'),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/data/models/amicale_model.dart';
|
||||
import 'package:geosector_app/core/repositories/amicale_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/region_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/entite_form.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// Exemple d'utilisation du widget EntiteForm avec le RegionRepository
|
||||
///
|
||||
/// Ce widget montre comment intégrer le formulaire d'entité dans une page
|
||||
/// et comment utiliser le RegionRepository pour charger les régions.
|
||||
class EntiteFormWithRegionsExample extends StatefulWidget {
|
||||
final int? amicaleId;
|
||||
final bool readOnly;
|
||||
|
||||
const EntiteFormWithRegionsExample({
|
||||
Key? key,
|
||||
this.amicaleId,
|
||||
this.readOnly = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<EntiteFormWithRegionsExample> createState() =>
|
||||
_EntiteFormWithRegionsExampleState();
|
||||
}
|
||||
|
||||
class _EntiteFormWithRegionsExampleState
|
||||
extends State<EntiteFormWithRegionsExample> {
|
||||
AmicaleModel? _amicale;
|
||||
bool _isLoading = true;
|
||||
late RegionRepository _regionRepository;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_regionRepository = RegionRepository();
|
||||
_initRepositories();
|
||||
}
|
||||
|
||||
Future<void> _initRepositories() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Initialiser le repository des régions
|
||||
await _regionRepository.init();
|
||||
|
||||
// Charger l'amicale si un ID est fourni
|
||||
if (widget.amicaleId != null) {
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
|
||||
|
||||
setState(() {
|
||||
_amicale = amicale;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleSubmit(AmicaleModel amicale) async {
|
||||
try {
|
||||
final amicaleRepository =
|
||||
Provider.of<AmicaleRepository>(context, listen: false);
|
||||
|
||||
// Sauvegarder l'amicale
|
||||
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
|
||||
|
||||
// Afficher un message de confirmation
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
|
||||
// Retourner à la page précédente
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la sauvegarde: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
// Fournir le RegionRepository pour qu'il soit accessible par le widget EntiteForm
|
||||
ChangeNotifierProvider<RegionRepository>.value(
|
||||
value: _regionRepository),
|
||||
],
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.amicaleId != null
|
||||
? (widget.readOnly
|
||||
? 'Détails de l\'amicale'
|
||||
: 'Modifier l\'amicale')
|
||||
: 'Nouvelle amicale'),
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: EntiteForm(
|
||||
amicale: _amicale,
|
||||
onSubmit: widget.readOnly ? null : _handleSubmit,
|
||||
readOnly: widget.readOnly,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/presentation/widgets/membre_table_widget.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
/// Exemple d'utilisation du widget MembreTableWidget
|
||||
///
|
||||
/// Ce widget montre comment intégrer le tableau de membres dans une page
|
||||
/// et comment gérer les événements d'édition et de suppression.
|
||||
class MembreTableExample extends StatefulWidget {
|
||||
const MembreTableExample({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<MembreTableExample> createState() => _MembreTableExampleState();
|
||||
}
|
||||
|
||||
class _MembreTableExampleState extends State<MembreTableExample> {
|
||||
List<MembreModel> _membres = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadMembres();
|
||||
}
|
||||
|
||||
Future<void> _loadMembres() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// S'assurer que la boîte Hive est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
|
||||
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
|
||||
}
|
||||
|
||||
// Récupérer les membres depuis la boîte Hive
|
||||
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
final membres = membresBox.values.toList();
|
||||
|
||||
setState(() {
|
||||
_membres = membres;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des membres: $e');
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleEdit(MembreModel membre) {
|
||||
// Exemple de gestion de l'événement d'édition
|
||||
debugPrint(
|
||||
'Édition du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
|
||||
|
||||
// Ici, vous pourriez ouvrir une boîte de dialogue ou naviguer vers une page d'édition
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Édition de membre'),
|
||||
content: Text(
|
||||
'Vous avez demandé à éditer le membre ${membre.firstName} ${membre.name}'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleDelete(MembreModel membre) {
|
||||
// Exemple de gestion de l'événement de suppression
|
||||
debugPrint(
|
||||
'Suppression du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
|
||||
|
||||
// Demander confirmation avant de supprimer
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmation de suppression'),
|
||||
content: Text(
|
||||
'Êtes-vous sûr de vouloir supprimer le membre ${membre.firstName} ${membre.name} ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
// Fermer la boîte de dialogue
|
||||
Navigator.of(context).pop();
|
||||
|
||||
try {
|
||||
// Supprimer le membre de la boîte Hive
|
||||
final membresBox =
|
||||
Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
await membresBox.delete(membre.id);
|
||||
|
||||
// Mettre à jour l'état
|
||||
setState(() {
|
||||
_membres = _membres.where((m) => m.id != membre.id).toList();
|
||||
});
|
||||
|
||||
// Afficher un message de confirmation
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Membre ${membre.firstName} ${membre.name} supprimé'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la suppression du membre: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors de la suppression: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Supprimer'),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Tableau des Membres'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadMembres,
|
||||
tooltip: 'Rafraîchir',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: MembreTableWidget(
|
||||
membres: _membres,
|
||||
onEdit: _handleEdit,
|
||||
onDelete: _handleDelete,
|
||||
height:
|
||||
null, // Utiliser null pour que le widget prenne toute la hauteur disponible
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'custom_text_field.dart';
|
||||
import '../custom_text_field.dart';
|
||||
|
||||
class PassageForm extends StatefulWidget {
|
||||
final Function(Map<String, dynamic>)? onSubmit;
|
||||
@@ -1,12 +1,9 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/help_dialog.dart';
|
||||
import 'package:geosector_app/presentation/widgets/profile_dialog.dart';
|
||||
|
||||
/// Widget qui fournit une navigation responsive pour l'application.
|
||||
/// Affiche une sidebar en mode desktop et une bottomBar en mode mobile.
|
||||
@@ -125,7 +122,6 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
appBar: widget.showAppBar
|
||||
? AppBar(
|
||||
title: Text(widget.title),
|
||||
actions: _buildAppBarActions(context),
|
||||
)
|
||||
: null,
|
||||
body: isDesktop ? _buildDesktopLayout() : _buildMobileLayout(),
|
||||
@@ -157,38 +153,6 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Construction des actions de l'AppBar
|
||||
List<Widget> _buildAppBarActions(BuildContext context) {
|
||||
List<Widget> actions = [];
|
||||
|
||||
// Ajouter les actions supplémentaires si elles existent
|
||||
if (widget.additionalActions != null &&
|
||||
widget.additionalActions!.isNotEmpty) {
|
||||
actions.addAll(widget.additionalActions!);
|
||||
} else if (widget.showNewPassageButton && widget.selectedIndex == 0) {
|
||||
// Ajouter le bouton "Nouveau passage" en haut à droite pour la page d'accueil
|
||||
actions.add(
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.add_location_alt, color: Colors.white),
|
||||
label: const Text('Nouveau passage',
|
||||
style: TextStyle(color: Colors.white)),
|
||||
onPressed: widget.onNewPassagePressed ??
|
||||
() {
|
||||
// Fonction par défaut si onNewPassagePressed n'est pas fourni
|
||||
_showPassageForm(context);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.secondary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
),
|
||||
),
|
||||
);
|
||||
actions.add(const SizedBox(width: 16)); // Espacement à droite
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
/// Construction de la barre de navigation inférieure pour mobile
|
||||
Widget _buildBottomNavigationBar() {
|
||||
final theme = Theme.of(context);
|
||||
@@ -350,103 +314,15 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
const Spacer(),
|
||||
const Divider(),
|
||||
|
||||
// Éléments du bas de la sidebar
|
||||
// Éléments du bas de la sidebar (widgets personnalisés)
|
||||
if (widget.sidebarBottomItems != null && !_isSidebarMinimized)
|
||||
...widget.sidebarBottomItems!,
|
||||
|
||||
// Éléments par défaut du bas de la sidebar
|
||||
if (!_isSidebarMinimized)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
'Paramètres',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
_SettingsItem(
|
||||
icon: Icons.person,
|
||||
title: 'Mon compte',
|
||||
subtitle: null,
|
||||
isSidebarMinimized: _isSidebarMinimized,
|
||||
onTap: () {
|
||||
// Afficher la boîte de dialogue de profil avec l'utilisateur actuel
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
final user = userRepository.currentUser;
|
||||
if (user != null) {
|
||||
// Passer l'objet utilisateur complet
|
||||
ProfileDialog.show(context, user);
|
||||
} else {
|
||||
// Afficher un message d'erreur si l'utilisateur n'est pas trouvé
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Erreur: Utilisateur non trouvé'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
// Option "Amicale & membres" - uniquement pour les administrateurs avec le rôle 2 et en version web
|
||||
if (widget.isAdmin &&
|
||||
userRepository.currentUser?.role == 2 &&
|
||||
MediaQuery.of(context).size.width > 900)
|
||||
_SettingsItem(
|
||||
icon: Icons.people,
|
||||
title: 'Amicale & membres',
|
||||
isSidebarMinimized: _isSidebarMinimized,
|
||||
onTap: () {
|
||||
// Navigation vers le tableau de bord admin avec sélection de l'onglet "Amicale et membres"
|
||||
context.go('/admin');
|
||||
|
||||
// Sélectionner l'onglet "Amicale et membres" (index 5)
|
||||
// Nous devons sauvegarder cet index dans les paramètres pour que le tableau de bord
|
||||
// puisse le récupérer et sélectionner le bon onglet
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
settingsBox.put('adminSelectedPageIndex', 5);
|
||||
|
||||
// Notifier l'utilisateur que la page est en cours de chargement
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content:
|
||||
Text('Chargement de la page Amicale & membres...'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
// Attendre un court instant pour permettre à la navigation de se terminer
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
// Forcer la sélection de l'onglet Amicale & membres
|
||||
if (widget.isAdmin && widget.selectedIndex != 5) {
|
||||
widget.onDestinationSelected(5);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
// Option "Opérations" - uniquement pour les administrateurs et en version web
|
||||
// Options administrateur - uniquement pour les administrateurs et en version web
|
||||
if (widget.isAdmin && MediaQuery.of(context).size.width > 900)
|
||||
_SettingsItem(
|
||||
icon: Icons.calendar_today,
|
||||
title: 'Opérations',
|
||||
isSidebarMinimized: _isSidebarMinimized,
|
||||
onTap: () {
|
||||
// Navigation vers le tableau de bord admin
|
||||
context.go('/admin');
|
||||
...[],
|
||||
|
||||
// Note: Pas de page spécifique pour le moment, juste un placeholder
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Fonctionnalité à venir'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Option "Aide" - toujours visible en bas
|
||||
_SettingsItem(
|
||||
icon: Icons.help_outline,
|
||||
title: 'Aide',
|
||||
@@ -456,6 +332,8 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
HelpDialog.show(context, widget.title);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -538,119 +416,6 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche le formulaire de passage
|
||||
void _showPassageForm(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(
|
||||
'Nouveau passage',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Adresse',
|
||||
prefixIcon: const Icon(Icons.location_on),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<int>(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Type de passage',
|
||||
prefixIcon: const Icon(Icons.category),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 1,
|
||||
child: Text('Effectué'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 2,
|
||||
child: Text('À finaliser'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 3,
|
||||
child: Text('Refusé'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 4,
|
||||
child: Text('Don'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 5,
|
||||
child: Text('Lot'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 6,
|
||||
child: Text('Maison vide'),
|
||||
),
|
||||
],
|
||||
onChanged: (value) {},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Commentaire',
|
||||
prefixIcon: const Icon(Icons.comment),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text(
|
||||
'Annuler',
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Enregistrer le passage
|
||||
Navigator.of(context).pop();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('Passage enregistré avec succès'),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
),
|
||||
);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: theme.colorScheme.onPrimary,
|
||||
),
|
||||
child: const Text('Enregistrer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget pour les éléments de paramètres
|
||||
|
||||
@@ -3,113 +3,25 @@ import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
|
||||
class SectorDistributionCard extends StatefulWidget {
|
||||
class SectorDistributionCard extends StatelessWidget {
|
||||
final String title;
|
||||
final double? height;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final bool forceRefresh;
|
||||
|
||||
const SectorDistributionCard({
|
||||
Key? key,
|
||||
this.title = 'Répartition par secteur',
|
||||
this.height,
|
||||
this.padding,
|
||||
this.forceRefresh = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<SectorDistributionCard> createState() => _SectorDistributionCardState();
|
||||
}
|
||||
|
||||
class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
List<Map<String, dynamic>> sectorStats = [];
|
||||
bool isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSectorData();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(SectorDistributionCard oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Recharger les données si forceRefresh est passé à true
|
||||
if (widget.forceRefresh && !oldWidget.forceRefresh) {
|
||||
_loadSectorData();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSectorData() async {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// S'assurer que les boîtes Hive sont ouvertes
|
||||
if (!Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
|
||||
await Hive.openBox<SectorModel>(AppKeys.sectorsBoxName);
|
||||
}
|
||||
|
||||
if (!Hive.isBoxOpen(AppKeys.passagesBoxName)) {
|
||||
await Hive.openBox<PassageModel>(AppKeys.passagesBoxName);
|
||||
}
|
||||
|
||||
// Récupérer tous les secteurs
|
||||
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
|
||||
final List<SectorModel> sectors = sectorsBox.values.toList();
|
||||
|
||||
// Récupérer tous les passages
|
||||
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
|
||||
final List<PassageModel> passages = passagesBox.values.toList();
|
||||
|
||||
// Compter les passages par secteur (en excluant ceux où fkType==2)
|
||||
final Map<int, int> sectorCounts = {};
|
||||
|
||||
for (final passage in passages) {
|
||||
// Exclure les passages où fkType==2
|
||||
if (passage.fkSector != null && passage.fkType != 2) {
|
||||
sectorCounts[passage.fkSector!] =
|
||||
(sectorCounts[passage.fkSector!] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Préparer les données pour l'affichage
|
||||
List<Map<String, dynamic>> stats = [];
|
||||
for (final sector in sectors) {
|
||||
final count = sectorCounts[sector.id] ?? 0;
|
||||
if (count > 0) {
|
||||
stats.add({
|
||||
'name': sector.libelle,
|
||||
'count': count,
|
||||
'color': sector.color.isEmpty
|
||||
? 0xFF4B77BE
|
||||
: int.tryParse(sector.color.replaceAll('#', '0xFF')) ??
|
||||
0xFF4B77BE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
sectorStats = stats;
|
||||
isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des données de secteur: $e');
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: widget.height,
|
||||
padding: widget.padding ?? const EdgeInsets.all(AppTheme.spacingM),
|
||||
height: height,
|
||||
padding: padding ?? const EdgeInsets.all(AppTheme.spacingM),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
@@ -118,65 +30,117 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
widget.title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
if (isLoading)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 20),
|
||||
onPressed: _loadSectorData,
|
||||
tooltip: 'Rafraîchir',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(),
|
||||
),
|
||||
],
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Expanded(
|
||||
child: isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: sectorStats.isEmpty
|
||||
? const Center(
|
||||
child: Text('Aucune donnée de secteur disponible'))
|
||||
: ListView.builder(
|
||||
itemCount: sectorStats.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sector = sectorStats[index];
|
||||
return _buildSectorItem(
|
||||
context,
|
||||
sector['name'],
|
||||
sector['count'],
|
||||
Color(sector['color']),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _buildAutoRefreshContent(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAutoRefreshContent() {
|
||||
// Écouter les changements des deux boîtes
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
|
||||
builder: (context, Box<SectorModel> sectorsBox, child) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
|
||||
builder: (context, Box<PassageModel> passagesBox, child) {
|
||||
return _buildContent(sectorsBox, passagesBox);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(Box<SectorModel> sectorsBox, Box<PassageModel> passagesBox) {
|
||||
try {
|
||||
// Calculer les statistiques
|
||||
final sectorStats = _calculateSectorStats(sectorsBox, passagesBox);
|
||||
|
||||
if (sectorStats.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('Aucune donnée de secteur disponible'),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
itemCount: sectorStats.length,
|
||||
itemBuilder: (context, index) {
|
||||
final sector = sectorStats[index];
|
||||
return _buildSectorItem(
|
||||
sector['name'],
|
||||
sector['count'],
|
||||
Color(sector['color']),
|
||||
sectorStats,
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du calcul des statistiques: $e');
|
||||
return Center(
|
||||
child: Text('Erreur: ${e.toString()}'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _calculateSectorStats(
|
||||
Box<SectorModel> sectorsBox,
|
||||
Box<PassageModel> passagesBox,
|
||||
) {
|
||||
// Récupérer tous les secteurs et passages
|
||||
final List<SectorModel> sectors = sectorsBox.values.toList();
|
||||
final List<PassageModel> passages = passagesBox.values.toList();
|
||||
|
||||
// Compter les passages par secteur (en excluant ceux où fkType==2)
|
||||
final Map<int, int> sectorCounts = {};
|
||||
|
||||
for (final passage in passages) {
|
||||
// Exclure les passages où fkType==2
|
||||
if (passage.fkSector != null && passage.fkType != 2) {
|
||||
sectorCounts[passage.fkSector!] =
|
||||
(sectorCounts[passage.fkSector!] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Préparer les données pour l'affichage
|
||||
List<Map<String, dynamic>> stats = [];
|
||||
for (final sector in sectors) {
|
||||
final count = sectorCounts[sector.id] ?? 0;
|
||||
if (count > 0) {
|
||||
stats.add({
|
||||
'name': sector.libelle,
|
||||
'count': count,
|
||||
'color': sector.color.isEmpty
|
||||
? 0xFF4B77BE
|
||||
: int.tryParse(sector.color.replaceAll('#', '0xFF')) ??
|
||||
0xFF4B77BE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Trier par nombre de passages (décroissant)
|
||||
stats.sort((a, b) => (b['count'] as int).compareTo(a['count'] as int));
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
Widget _buildSectorItem(
|
||||
BuildContext context,
|
||||
String name,
|
||||
int count,
|
||||
Color color,
|
||||
List<Map<String, dynamic>> allStats,
|
||||
) {
|
||||
final totalCount =
|
||||
sectorStats.fold(0, (sum, item) => sum + (item['count'] as int));
|
||||
allStats.fold(0, (sum, item) => sum + (item['count'] as int));
|
||||
final percentage = totalCount > 0 ? (count / totalCount) * 100 : 0;
|
||||
|
||||
return Padding(
|
||||
@@ -215,4 +179,4 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user