feat: Version 3.3.4 - Nouvelle architecture pages, optimisations widgets Flutter et API
- Mise à jour VERSION vers 3.3.4 - Optimisations et révisions architecture API (deploy-api.sh, scripts de migration) - Ajout documentation Stripe Tap to Pay complète - Migration vers polices Inter Variable pour Flutter - Optimisations build Android et nettoyage fichiers temporaires - Amélioration système de déploiement avec gestion backups - Ajout scripts CRON et migrations base de données 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,426 +0,0 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'dart:math' as math;
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/presentation/widgets/sector_distribution_card.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
|
||||
/// Class pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class AdminDashboardHomePage extends StatefulWidget {
|
||||
const AdminDashboardHomePage({super.key});
|
||||
|
||||
@override
|
||||
State<AdminDashboardHomePage> createState() => _AdminDashboardHomePageState();
|
||||
}
|
||||
|
||||
class _AdminDashboardHomePageState extends State<AdminDashboardHomePage> {
|
||||
// Données pour le tableau de bord
|
||||
int totalPassages = 0;
|
||||
double totalAmounts = 0.0;
|
||||
List<Map<String, dynamic>> memberStats = [];
|
||||
bool isDataLoaded = false;
|
||||
bool isLoading = true;
|
||||
bool isFirstLoad = true; // Pour suivre le premier chargement
|
||||
|
||||
// Données pour les graphiques
|
||||
List<PaymentData> paymentData = [];
|
||||
Map<int, int> passagesByType = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDashboardData();
|
||||
}
|
||||
|
||||
/// Prépare les données pour le graphique de paiement
|
||||
void _preparePaymentData(List<dynamic> passages) {
|
||||
// Réinitialiser les données
|
||||
paymentData = [];
|
||||
|
||||
// Compter les montants par type de règlement
|
||||
Map<int, double> paymentAmounts = {};
|
||||
|
||||
// Initialiser les compteurs pour tous les types de règlement
|
||||
for (final typeId in AppKeys.typesReglements.keys) {
|
||||
paymentAmounts[typeId] = 0.0;
|
||||
}
|
||||
|
||||
// Calculer les montants par type de règlement
|
||||
for (final passage in passages) {
|
||||
if (passage.fkTypeReglement != null && passage.montant != null && passage.montant.isNotEmpty) {
|
||||
final typeId = passage.fkTypeReglement;
|
||||
final amount = double.tryParse(passage.montant) ?? 0.0;
|
||||
paymentAmounts[typeId] = (paymentAmounts[typeId] ?? 0.0) + amount;
|
||||
}
|
||||
}
|
||||
|
||||
// Créer les objets PaymentData
|
||||
paymentAmounts.forEach((typeId, amount) {
|
||||
if (amount > 0 && AppKeys.typesReglements.containsKey(typeId)) {
|
||||
final typeInfo = AppKeys.typesReglements[typeId]!;
|
||||
paymentData.add(PaymentData(
|
||||
typeId: typeId,
|
||||
amount: amount,
|
||||
title: typeInfo['titre'] as String,
|
||||
color: Color(typeInfo['couleur'] as int),
|
||||
icon: typeInfo['icon_data'] as IconData,
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadDashboardData() async {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
debugPrint('AdminDashboardHomePage: Chargement des données du tableau de bord...');
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
// Pas besoin de Provider.of car les instances sont déjà disponibles
|
||||
|
||||
// Récupérer l'opération en cours (les boxes sont déjà ouvertes par SplashPage)
|
||||
final currentOperation = userRepository.getCurrentOperation();
|
||||
debugPrint('AdminDashboardHomePage: Opération récupérée: ${currentOperation?.id ?? "null"}');
|
||||
|
||||
if (currentOperation != null) {
|
||||
// Charger les passages pour l'opération en cours
|
||||
debugPrint('AdminDashboardHomePage: Chargement des passages pour l\'opération ${currentOperation.id}...');
|
||||
final passages = passageRepository.getPassagesByOperation(currentOperation.id);
|
||||
debugPrint('AdminDashboardHomePage: ${passages.length} passages récupérés');
|
||||
|
||||
// Calculer le nombre total de passages
|
||||
totalPassages = passages.length;
|
||||
|
||||
// Calculer le montant total collecté
|
||||
totalAmounts = passages.fold(0.0, (sum, passage) => sum + (passage.montant.isNotEmpty ? double.tryParse(passage.montant) ?? 0.0 : 0.0));
|
||||
|
||||
// Préparer les données pour le graphique de paiement
|
||||
_preparePaymentData(passages);
|
||||
|
||||
// Compter les passages par type
|
||||
passagesByType = {};
|
||||
for (final passage in passages) {
|
||||
final typeId = passage.fkType;
|
||||
passagesByType[typeId] = (passagesByType[typeId] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Afficher les comptages par type pour le débogage
|
||||
debugPrint('AdminDashboardHomePage: Comptage des passages par type:');
|
||||
passagesByType.forEach((typeId, count) {
|
||||
final typeInfo = AppKeys.typesPassages[typeId];
|
||||
final typeName = typeInfo != null ? typeInfo['titre'] : 'Inconnu';
|
||||
debugPrint('AdminDashboardHomePage: Type $typeId ($typeName): $count passages');
|
||||
});
|
||||
|
||||
// Charger les statistiques par membre
|
||||
memberStats = [];
|
||||
final Map<int, int> memberCounts = {};
|
||||
|
||||
// Compter les passages par membre
|
||||
for (final passage in passages) {
|
||||
memberCounts[passage.fkUser] = (memberCounts[passage.fkUser] ?? 0) + 1;
|
||||
}
|
||||
|
||||
// Récupérer les informations des membres
|
||||
for (final entry in memberCounts.entries) {
|
||||
final user = userRepository.getUserById(entry.key);
|
||||
if (user != null) {
|
||||
memberStats.add({
|
||||
'name': '${user.firstName ?? ''} ${user.name ?? ''}'.trim(),
|
||||
'count': entry.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Trier les membres par nombre de passages (décroissant)
|
||||
memberStats.sort((a, b) => (b['count'] as int).compareTo(a['count'] as int));
|
||||
} else {
|
||||
debugPrint('AdminDashboardHomePage: Aucune opération en cours, impossible de charger les passages');
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isDataLoaded = true;
|
||||
isLoading = false;
|
||||
isFirstLoad = false; // Marquer que le premier chargement est terminé
|
||||
});
|
||||
}
|
||||
|
||||
// Vérifier si les données sont correctement chargées
|
||||
debugPrint(
|
||||
'AdminDashboardHomePage: Données chargées: isDataLoaded=$isDataLoaded, totalPassages=$totalPassages, passagesByType=${passagesByType.length} types');
|
||||
} catch (e) {
|
||||
debugPrint('AdminDashboardHomePage: Erreur lors du chargement des données: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint('Building AdminDashboardHomePage');
|
||||
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > 800;
|
||||
|
||||
// Récupérer l'opération en cours (les boîtes sont déjà ouvertes par SplashPage)
|
||||
final currentOperation = userRepository.getCurrentOperation();
|
||||
|
||||
// Titre dynamique avec l'ID et le nom de l'opération
|
||||
final String title = currentOperation != null ? 'Opération #${currentOperation.id} ${currentOperation.name}' : 'Opération';
|
||||
|
||||
return Stack(children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
// Contenu de la page
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingL),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
// Afficher un indicateur de chargement si les données ne sont pas encore chargées
|
||||
if (isLoading && !isDataLoaded)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
|
||||
// Afficher le contenu seulement si les données sont chargées ou en cours de mise à jour
|
||||
if (isDataLoaded || isLoading) ...[
|
||||
// LIGNE 1 : Graphiques de répartition (type de passage et mode de paiement)
|
||||
isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildPassageTypeCard(context),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
child: _buildPaymentTypeCard(context),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildPassageTypeCard(context),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildPaymentTypeCard(context),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// LIGNE 2 : Carte de répartition par secteur (pleine largeur)
|
||||
ValueListenableBuilder<Box<SectorModel>>(
|
||||
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
|
||||
builder: (context, Box<SectorModel> box, child) {
|
||||
final sectorCount = box.values.length;
|
||||
return SectorDistributionCard(
|
||||
key: ValueKey('sector_distribution_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
|
||||
title: '$sectorCount secteurs',
|
||||
height: 500, // Hauteur maximale pour afficher tous les secteurs
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// LIGNE 3 : Graphique d'activité
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: ActivityChart(
|
||||
key: ValueKey('activity_chart_${isFirstLoad ? 'initial' : 'refreshed'}_$isLoading'),
|
||||
height: 350,
|
||||
showAllPassages: true, // Tous les passages, pas seulement ceux de l'utilisateur courant
|
||||
title: 'Passages réalisés par jour (15 derniers jours)',
|
||||
daysToShow: 15,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Actions rapides - uniquement visible sur le web
|
||||
if (kIsWeb) ...[
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Actions sur cette opération',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Wrap(
|
||||
spacing: AppTheme.spacingM,
|
||||
runSpacing: AppTheme.spacingM,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
context,
|
||||
'Exporter les données',
|
||||
Icons.file_download_outlined,
|
||||
AppTheme.primaryColor,
|
||||
() {},
|
||||
),
|
||||
_buildActionButton(
|
||||
context,
|
||||
'Gérer les secteurs',
|
||||
Icons.map_outlined,
|
||||
AppTheme.accentColor,
|
||||
() {},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// Construit la carte de répartition par type de passage avec liste
|
||||
Widget _buildPassageTypeCard(BuildContext context) {
|
||||
return PassageSummaryCard(
|
||||
title: 'Passages',
|
||||
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 PaymentSummaryCard(
|
||||
title: 'Règlements',
|
||||
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,
|
||||
IconData icon,
|
||||
Color color,
|
||||
VoidCallback onPressed,
|
||||
) {
|
||||
return ElevatedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon),
|
||||
label: Text(label),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: color,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingL,
|
||||
vertical: AppTheme.spacingM,
|
||||
),
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
|
||||
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
// Import des pages admin
|
||||
import 'admin_dashboard_home_page.dart';
|
||||
import 'admin_statistics_page.dart';
|
||||
import 'admin_history_page.dart';
|
||||
import '../chat/chat_communication_page.dart';
|
||||
import 'admin_map_page.dart';
|
||||
import 'admin_amicale_page.dart';
|
||||
import 'admin_operations_page.dart';
|
||||
|
||||
/// Class pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class AdminDashboardPage extends StatefulWidget {
|
||||
const AdminDashboardPage({super.key});
|
||||
|
||||
@override
|
||||
State<AdminDashboardPage> createState() => _AdminDashboardPageState();
|
||||
}
|
||||
|
||||
class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBindingObserver {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
// Pages seront construites dynamiquement dans build()
|
||||
|
||||
// Référence à la boîte Hive pour les paramètres
|
||||
late Box _settingsBox;
|
||||
|
||||
// Listener pour les changements de paramètres
|
||||
late ValueListenable<Box<dynamic>> _settingsListenable;
|
||||
|
||||
// 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,
|
||||
pageType: _PageType.dashboardHome,
|
||||
),
|
||||
const _NavigationItem(
|
||||
label: 'Statistiques',
|
||||
icon: Icons.bar_chart_outlined,
|
||||
selectedIcon: Icons.bar_chart,
|
||||
pageType: _PageType.statistics,
|
||||
),
|
||||
const _NavigationItem(
|
||||
label: 'Historique',
|
||||
icon: Icons.history_outlined,
|
||||
selectedIcon: Icons.history,
|
||||
pageType: _PageType.history,
|
||||
),
|
||||
const _NavigationItem(
|
||||
label: 'Messages',
|
||||
icon: Icons.chat_outlined,
|
||||
selectedIcon: Icons.chat,
|
||||
pageType: _PageType.communication,
|
||||
),
|
||||
const _NavigationItem(
|
||||
label: 'Carte',
|
||||
icon: Icons.map_outlined,
|
||||
selectedIcon: Icons.map,
|
||||
pageType: _PageType.map,
|
||||
),
|
||||
];
|
||||
|
||||
// É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,
|
||||
pageType: _PageType.amicale,
|
||||
requiredRole: 2,
|
||||
),
|
||||
const _NavigationItem(
|
||||
label: 'Opérations',
|
||||
icon: Icons.calendar_today_outlined,
|
||||
selectedIcon: Icons.calendar_today,
|
||||
pageType: _PageType.operations,
|
||||
requiredRole: 2,
|
||||
),
|
||||
];
|
||||
|
||||
// Construire la page basée sur le type
|
||||
Widget _buildPage(_PageType pageType) {
|
||||
switch (pageType) {
|
||||
case _PageType.dashboardHome:
|
||||
return const AdminDashboardHomePage();
|
||||
case _PageType.statistics:
|
||||
return const AdminStatisticsPage();
|
||||
case _PageType.history:
|
||||
return const AdminHistoryPage();
|
||||
case _PageType.communication:
|
||||
return const ChatCommunicationPage();
|
||||
case _PageType.map:
|
||||
return const AdminMapPage();
|
||||
case _PageType.amicale:
|
||||
return AdminAmicalePage(
|
||||
userRepository: userRepository,
|
||||
amicaleRepository: amicaleRepository,
|
||||
membreRepository: membreRepository,
|
||||
passageRepository: passageRepository,
|
||||
operationRepository: operationRepository,
|
||||
);
|
||||
case _PageType.operations:
|
||||
return AdminOperationsPage(
|
||||
operationRepository: operationRepository,
|
||||
userRepository: userRepository,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Construire la liste des destinations de navigation en fonction du rôle
|
||||
List<NavigationDestination> _buildNavigationDestinations() {
|
||||
final destinations = <NavigationDestination>[];
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isMobile = size.width <= 900;
|
||||
|
||||
// Ajouter les éléments de base
|
||||
for (final item in _baseNavigationItems) {
|
||||
// Utiliser createBadgedNavigationDestination pour les messages
|
||||
if (item.label == 'Messages') {
|
||||
destinations.add(
|
||||
createBadgedNavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: Icon(item.selectedIcon),
|
||||
label: item.label,
|
||||
showBadge: true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
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) {
|
||||
// En mobile, exclure "Amicale & membres" et "Opérations"
|
||||
if (isMobile &&
|
||||
(item.label == 'Amicale & membres' || item.label == 'Opérations')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.requiredRole == null || item.requiredRole == 2) {
|
||||
// Utiliser createBadgedNavigationDestination pour les messages
|
||||
if (item.label == 'Messages') {
|
||||
destinations.add(
|
||||
createBadgedNavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: Icon(item.selectedIcon),
|
||||
label: item.label,
|
||||
showBadge: true,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
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();
|
||||
final size = MediaQuery.of(context).size;
|
||||
final isMobile = size.width <= 900;
|
||||
|
||||
// Ajouter les pages de base
|
||||
for (final item in _baseNavigationItems) {
|
||||
pages.add(_buildPage(item.pageType));
|
||||
}
|
||||
|
||||
// Ajouter les pages admin si l'utilisateur a le rôle requis
|
||||
if (currentUser?.role == 2) {
|
||||
for (final item in _adminNavigationItems) {
|
||||
// En mobile, exclure "Amicale & membres" et "Opérations"
|
||||
if (isMobile &&
|
||||
(item.label == 'Amicale & membres' || item.label == 'Opérations')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (item.requiredRole == null || item.requiredRole == 2) {
|
||||
pages.add(_buildPage(item.pageType));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
|
||||
try {
|
||||
debugPrint('Initialisation de AdminDashboardPage');
|
||||
|
||||
// Vérifier que userRepository est correctement initialisé
|
||||
debugPrint('userRepository est correctement initialisé');
|
||||
final currentUser = userRepository.getCurrentUser();
|
||||
if (currentUser == null) {
|
||||
debugPrint('ERREUR: Aucun utilisateur connecté dans AdminDashboardPage');
|
||||
} else {
|
||||
debugPrint('Utilisateur connecté: ${currentUser.username} (${currentUser.id})');
|
||||
}
|
||||
userRepository.addListener(_handleUserRepositoryChanges);
|
||||
|
||||
// Les pages seront construites dynamiquement dans build()
|
||||
|
||||
// Initialiser et charger les paramètres
|
||||
_initSettings().then((_) {
|
||||
// Écouter les changements de la boîte de paramètres après l'initialisation
|
||||
_settingsListenable = _settingsBox.listenable(keys: ['selectedPageIndex']);
|
||||
_settingsListenable.addListener(_onSettingsChanged);
|
||||
});
|
||||
|
||||
// Vérifier si des données sont en cours de chargement
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkLoadingState();
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('ERREUR CRITIQUE dans AdminDashboardPage.initState: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
userRepository.removeListener(_handleUserRepositoryChanges);
|
||||
_settingsListenable.removeListener(_onSettingsChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// Méthode pour gérer les changements d'état du UserRepository
|
||||
void _handleUserRepositoryChanges() {
|
||||
_checkLoadingState();
|
||||
}
|
||||
|
||||
// Méthode pour gérer les changements de paramètres
|
||||
void _onSettingsChanged() {
|
||||
final newIndex = _settingsBox.get('selectedPageIndex');
|
||||
if (newIndex != null && newIndex is int && newIndex != _selectedIndex) {
|
||||
setState(() {
|
||||
_selectedIndex = newIndex;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Méthode pour vérifier l'état de chargement (barre de progression désactivée)
|
||||
void _checkLoadingState() {
|
||||
// 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 {
|
||||
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
|
||||
} else {
|
||||
_settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
}
|
||||
|
||||
// Charger l'index de page sélectionné
|
||||
final savedIndex = _settingsBox.get('selectedPageIndex');
|
||||
|
||||
// Vérifier si l'index sauvegardé est valide
|
||||
if (savedIndex != null && savedIndex is int) {
|
||||
debugPrint('Index sauvegardé trouvé: $savedIndex');
|
||||
|
||||
// La validation de l'index sera faite dans build()
|
||||
setState(() {
|
||||
_selectedIndex = savedIndex;
|
||||
});
|
||||
debugPrint('Index sauvegardé utilisé: $_selectedIndex');
|
||||
} else {
|
||||
debugPrint(
|
||||
'Aucun index sauvegardé trouvé, utilisation de l\'index par défaut: 0',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des paramètres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Sauvegarder les paramètres utilisateur
|
||||
void _saveSettings() {
|
||||
try {
|
||||
// Sauvegarder l'index de page sélectionné
|
||||
_settingsBox.put('selectedPageIndex', _selectedIndex);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Construire les pages et destinations dynamiquement
|
||||
final pages = _buildPages();
|
||||
final destinations = _buildNavigationDestinations();
|
||||
|
||||
// Valider et ajuster l'index si nécessaire
|
||||
if (_selectedIndex >= pages.length) {
|
||||
_selectedIndex = 0;
|
||||
// Sauvegarder le nouvel index
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_saveSettings();
|
||||
});
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
// Contenu de la page
|
||||
DashboardLayout(
|
||||
title: 'Tableau de bord Administration',
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (index) {
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
_saveSettings(); // Sauvegarder l'index de page sélectionné
|
||||
});
|
||||
},
|
||||
destinations: destinations,
|
||||
isAdmin: true,
|
||||
body: pages[_selectedIndex],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Enum pour les types de pages
|
||||
enum _PageType {
|
||||
dashboardHome,
|
||||
statistics,
|
||||
history,
|
||||
communication,
|
||||
map,
|
||||
amicale,
|
||||
operations,
|
||||
}
|
||||
|
||||
// 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 _PageType pageType;
|
||||
final int? requiredRole; // null si accessible à tous les rôles
|
||||
|
||||
const _NavigationItem({
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.selectedIcon,
|
||||
required this.pageType,
|
||||
this.requiredRole,
|
||||
});
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geosector_app/presentation/widgets/environment_info_widget.dart';
|
||||
|
||||
/// Widget d'information de débogage pour l'administrateur
|
||||
/// À intégrer où nécessaire dans l'interface administrateur
|
||||
class AdminDebugInfoWidget extends StatelessWidget {
|
||||
const AdminDebugInfoWidget({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.all(16.0),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.bug_report, color: Colors.grey),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Informations de débogage',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('Environnement'),
|
||||
subtitle: const Text(
|
||||
'Afficher les informations sur l\'environnement actuel'),
|
||||
onTap: () => EnvironmentInfoWidget.show(context),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
tileColor: Colors.grey.withValues(alpha: 0.1),
|
||||
),
|
||||
// Autres options de débogage peuvent être ajoutées ici
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,946 +0,0 @@
|
||||
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/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.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/data/models/membre_model.dart';
|
||||
import 'package:geosector_app/core/data/models/user_model.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/sector_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/membre_repository.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
|
||||
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Class pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
// Enum pour gérer les types de tri
|
||||
enum PassageSortType {
|
||||
dateDesc, // Plus récent en premier (défaut)
|
||||
dateAsc, // Plus ancien en premier
|
||||
addressAsc, // Adresse A-Z
|
||||
addressDesc, // Adresse Z-A
|
||||
}
|
||||
|
||||
class AdminHistoryPage extends StatefulWidget {
|
||||
const AdminHistoryPage({super.key});
|
||||
|
||||
@override
|
||||
State<AdminHistoryPage> createState() => _AdminHistoryPageState();
|
||||
}
|
||||
|
||||
class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
||||
// État du tri actuel
|
||||
PassageSortType _currentSort = PassageSortType.dateDesc;
|
||||
|
||||
// Filtres présélectionnés depuis une autre page
|
||||
int? selectedSectorId;
|
||||
String selectedSector = 'Tous';
|
||||
String selectedType = 'Tous';
|
||||
|
||||
// Listes pour les filtres
|
||||
List<SectorModel> _sectors = [];
|
||||
List<MembreModel> _membres = [];
|
||||
|
||||
// Repositories
|
||||
late PassageRepository _passageRepository;
|
||||
late SectorRepository _sectorRepository;
|
||||
late UserRepository _userRepository;
|
||||
late MembreRepository _membreRepository;
|
||||
|
||||
// Passages originaux pour l'édition
|
||||
List<PassageModel> _originalPassages = [];
|
||||
|
||||
// État de chargement
|
||||
bool _isLoading = true;
|
||||
String _errorMessage = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialiser les filtres
|
||||
_initializeFilters();
|
||||
// Charger les filtres présélectionnés depuis Hive si disponibles
|
||||
_loadPreselectedFilters();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Récupérer les repositories une seule fois
|
||||
_loadRepositories();
|
||||
}
|
||||
|
||||
// Charger les repositories et les données
|
||||
void _loadRepositories() {
|
||||
try {
|
||||
// Utiliser les instances globales définies dans app.dart
|
||||
_passageRepository = passageRepository;
|
||||
_userRepository = userRepository;
|
||||
_sectorRepository = sectorRepository;
|
||||
_membreRepository = membreRepository;
|
||||
|
||||
// Charger les secteurs et les membres
|
||||
_loadSectorsAndMembres();
|
||||
|
||||
// Charger les passages
|
||||
_loadPassages();
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = 'Erreur lors du chargement des repositories: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Charger les secteurs et les membres
|
||||
void _loadSectorsAndMembres() {
|
||||
try {
|
||||
// Récupérer la liste des secteurs
|
||||
_sectors = _sectorRepository.getAllSectors();
|
||||
debugPrint('Nombre de secteurs récupérés: ${_sectors.length}');
|
||||
|
||||
// Récupérer la liste des membres
|
||||
_membres = _membreRepository.getAllMembres();
|
||||
debugPrint('Nombre de membres récupérés: ${_membres.length}');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des secteurs et membres: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Charger les passages
|
||||
void _loadPassages() {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
// Récupérer les passages
|
||||
final List<PassageModel> allPassages =
|
||||
_passageRepository.getAllPassages();
|
||||
|
||||
// Stocker les passages originaux pour l'édition
|
||||
_originalPassages = allPassages;
|
||||
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = 'Erreur lors du chargement des passages: $e';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialiser les filtres
|
||||
void _initializeFilters() {
|
||||
// Par défaut, on n'applique pas de filtre présélectionné
|
||||
selectedSectorId = null;
|
||||
selectedSector = 'Tous';
|
||||
selectedType = 'Tous';
|
||||
}
|
||||
|
||||
// Charger les filtres présélectionnés depuis Hive
|
||||
void _loadPreselectedFilters() {
|
||||
try {
|
||||
// Utiliser Hive directement sans async
|
||||
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
||||
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
||||
|
||||
// Charger le secteur présélectionné
|
||||
final int? preselectedSectorId =
|
||||
settingsBox.get('history_selectedSectorId');
|
||||
final String? preselectedSectorName =
|
||||
settingsBox.get('history_selectedSectorName');
|
||||
final int? preselectedTypeId =
|
||||
settingsBox.get('history_selectedTypeId');
|
||||
|
||||
if (preselectedSectorId != null && preselectedSectorName != null) {
|
||||
selectedSectorId = preselectedSectorId;
|
||||
selectedSector = preselectedSectorName;
|
||||
|
||||
debugPrint(
|
||||
'Secteur présélectionné: $preselectedSectorName (ID: $preselectedSectorId)');
|
||||
}
|
||||
|
||||
if (preselectedTypeId != null) {
|
||||
selectedType = preselectedTypeId.toString();
|
||||
debugPrint('Type de passage présélectionné: $preselectedTypeId');
|
||||
}
|
||||
|
||||
// Nettoyer les valeurs après utilisation pour ne pas les réutiliser la prochaine fois
|
||||
settingsBox.delete('history_selectedSectorId');
|
||||
settingsBox.delete('history_selectedSectorName');
|
||||
settingsBox.delete('history_selectedTypeId');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des filtres présélectionnés: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Afficher un widget de chargement ou d'erreur si nécessaire
|
||||
if (_isLoading) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child: const SizedBox(
|
||||
width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage.isNotEmpty) {
|
||||
return _buildErrorWidget(_errorMessage);
|
||||
}
|
||||
|
||||
// Retourner le widget principal avec les données chargées
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child:
|
||||
const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
// Contenu de la page
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Padding responsive : réduit sur mobile pour maximiser l'espace
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final horizontalPadding = screenWidth < 600 ? 8.0 : 16.0;
|
||||
final verticalPadding = 16.0;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
vertical: verticalPadding,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Widget de liste des passages avec ValueListenableBuilder
|
||||
Expanded(
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable:
|
||||
Hive.box<PassageModel>(AppKeys.passagesBoxName)
|
||||
.listenable(),
|
||||
builder:
|
||||
(context, Box<PassageModel> passagesBox, child) {
|
||||
// Reconvertir les passages à chaque changement
|
||||
final List<PassageModel> allPassages =
|
||||
passagesBox.values.toList();
|
||||
|
||||
// Convertir et formater les passages
|
||||
final formattedPassages = _formatPassagesForWidget(
|
||||
allPassages,
|
||||
_sectorRepository,
|
||||
_membreRepository);
|
||||
|
||||
// Récupérer les UserModel depuis les MembreModel
|
||||
final users = _membres.map((membre) {
|
||||
return userRepository.getUserById(membre.id);
|
||||
}).where((user) => user != null).toList();
|
||||
|
||||
return PassagesListWidget(
|
||||
// Données
|
||||
passages: formattedPassages,
|
||||
// Activation des filtres
|
||||
showFilters: true,
|
||||
showSearch: true,
|
||||
showTypeFilter: true,
|
||||
showPaymentFilter: true,
|
||||
showSectorFilter: true,
|
||||
showUserFilter: true,
|
||||
showPeriodFilter: true,
|
||||
// Données pour les filtres
|
||||
sectors: _sectors,
|
||||
members: users.cast<UserModel>(),
|
||||
// Bouton d'ajout
|
||||
showAddButton: true,
|
||||
onAddPassage: () async {
|
||||
// Ouvrir le dialogue de création de passage
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return PassageFormDialog(
|
||||
title: 'Nouveau passage',
|
||||
passageRepository: _passageRepository,
|
||||
userRepository: _userRepository,
|
||||
operationRepository: operationRepository,
|
||||
onSuccess: () {
|
||||
// Le widget se rafraîchira automatiquement via ValueListenableBuilder
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
sortingButtons: Row(
|
||||
children: [
|
||||
// Bouton tri par date avec icône calendrier
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.calendar_today,
|
||||
size: 20,
|
||||
color: _currentSort ==
|
||||
PassageSortType.dateDesc ||
|
||||
_currentSort ==
|
||||
PassageSortType.dateAsc
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
tooltip:
|
||||
_currentSort == PassageSortType.dateAsc
|
||||
? 'Tri par date (ancien en premier)'
|
||||
: 'Tri par date (récent en premier)',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_currentSort ==
|
||||
PassageSortType.dateDesc) {
|
||||
_currentSort = PassageSortType.dateAsc;
|
||||
} else {
|
||||
_currentSort = PassageSortType.dateDesc;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
// Indicateur de direction pour la date
|
||||
if (_currentSort == PassageSortType.dateDesc ||
|
||||
_currentSort == PassageSortType.dateAsc)
|
||||
Icon(
|
||||
_currentSort == PassageSortType.dateAsc
|
||||
? Icons.arrow_upward
|
||||
: Icons.arrow_downward,
|
||||
size: 14,
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
// Bouton tri par adresse avec icône maison
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.home,
|
||||
size: 20,
|
||||
color: _currentSort ==
|
||||
PassageSortType.addressDesc ||
|
||||
_currentSort ==
|
||||
PassageSortType.addressAsc
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withValues(alpha: 0.6),
|
||||
),
|
||||
tooltip:
|
||||
_currentSort == PassageSortType.addressAsc
|
||||
? 'Tri par adresse (A-Z)'
|
||||
: 'Tri par adresse (Z-A)',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
if (_currentSort ==
|
||||
PassageSortType.addressAsc) {
|
||||
_currentSort =
|
||||
PassageSortType.addressDesc;
|
||||
} else {
|
||||
_currentSort =
|
||||
PassageSortType.addressAsc;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
// Indicateur de direction pour l'adresse
|
||||
if (_currentSort ==
|
||||
PassageSortType.addressDesc ||
|
||||
_currentSort == PassageSortType.addressAsc)
|
||||
Icon(
|
||||
_currentSort == PassageSortType.addressAsc
|
||||
? Icons.arrow_upward
|
||||
: Icons.arrow_downward,
|
||||
size: 14,
|
||||
color:
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
// Actions
|
||||
showActions: true,
|
||||
// Le widget gère maintenant le flux conditionnel par défaut
|
||||
onPassageSelected: null,
|
||||
onReceiptView: (passage) {
|
||||
_showReceiptDialog(context, passage);
|
||||
},
|
||||
onDetailsView: (passage) {
|
||||
_showDetailsDialog(context, passage);
|
||||
},
|
||||
onPassageEdit: (passage) {
|
||||
// Action pour modifier le passage
|
||||
},
|
||||
onPassageDelete: (passage) {
|
||||
_showDeleteConfirmationDialog(passage);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Widget d'erreur pour afficher un message d'erreur
|
||||
Widget _buildErrorWidget(String message) {
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child:
|
||||
const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
color: Colors.red,
|
||||
size: 64,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Erreur',
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 24),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red[700],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: AppTheme.r(context, 16)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Recharger la page
|
||||
setState(() {});
|
||||
},
|
||||
child: const Text('Réessayer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Convertir les passages du modèle Hive vers le format attendu par le widget
|
||||
List<Map<String, dynamic>> _formatPassagesForWidget(
|
||||
List<PassageModel> passages,
|
||||
SectorRepository sectorRepository,
|
||||
MembreRepository membreRepository) {
|
||||
return passages.map((passage) {
|
||||
// Récupérer le secteur associé au passage (si fkSector n'est pas null)
|
||||
final SectorModel? sector = passage.fkSector != null
|
||||
? sectorRepository.getSectorById(passage.fkSector!)
|
||||
: null;
|
||||
|
||||
// Récupérer le membre associé au passage
|
||||
final MembreModel? membre =
|
||||
membreRepository.getMembreById(passage.fkUser);
|
||||
|
||||
// Construire l'adresse complète
|
||||
final String address =
|
||||
'${passage.numero} ${passage.rue}${passage.rueBis.isNotEmpty ? ' ${passage.rueBis}' : ''}, ${passage.ville}';
|
||||
|
||||
// Déterminer si le passage a une erreur d'envoi de reçu
|
||||
final bool hasError = passage.emailErreur.isNotEmpty;
|
||||
|
||||
// Récupérer l'ID de l'utilisateur courant pour déterminer la propriété
|
||||
final currentUserId = _userRepository.getCurrentUser()?.id;
|
||||
|
||||
return {
|
||||
'id': passage.id,
|
||||
if (passage.passedAt != null) 'date': passage.passedAt!,
|
||||
'address': address, // Adresse complète pour l'affichage
|
||||
// Champs séparés pour l'édition
|
||||
'numero': passage.numero,
|
||||
'rueBis': passage.rueBis,
|
||||
'rue': passage.rue,
|
||||
'ville': passage.ville,
|
||||
'residence': passage.residence,
|
||||
'appt': passage.appt,
|
||||
'niveau': passage.niveau,
|
||||
'fkHabitat': passage.fkHabitat,
|
||||
'fkSector': passage.fkSector,
|
||||
'sector': sector?.libelle ?? 'Secteur inconnu',
|
||||
'fkUser': passage.fkUser,
|
||||
'user': membre?.name ?? 'Membre inconnu',
|
||||
'type': passage.fkType,
|
||||
'amount': double.tryParse(passage.montant) ?? 0.0,
|
||||
'payment': passage.fkTypeReglement,
|
||||
'email': passage.email,
|
||||
'hasReceipt': passage.nomRecu.isNotEmpty,
|
||||
'hasError': hasError,
|
||||
'notes': passage.remarque,
|
||||
'name': passage.name,
|
||||
'phone': passage.phone,
|
||||
'montant': passage.montant,
|
||||
'remarque': passage.remarque,
|
||||
// Autres champs utiles
|
||||
'fkOperation': passage.fkOperation,
|
||||
'passedAt': passage.passedAt,
|
||||
'lastSyncedAt': passage.lastSyncedAt,
|
||||
'isActive': passage.isActive,
|
||||
'isSynced': passage.isSynced,
|
||||
'isOwnedByCurrentUser':
|
||||
passage.fkUser == currentUserId, // Ajout du champ pour le widget
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void _showReceiptDialog(BuildContext context, Map<String, dynamic> passage) {
|
||||
final int passageId = passage['id'] as int;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Reçu du passage #$passageId'),
|
||||
content: const SizedBox(
|
||||
width: 500,
|
||||
height: 600,
|
||||
child: Center(
|
||||
child: Text('Aperçu du reçu PDF'),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Action pour télécharger le reçu
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Télécharger'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode pour conserver l'ancienne _showDetailsDialog pour les autres usages
|
||||
void _showDetailsDialog(BuildContext context, Map<String, dynamic> passage) {
|
||||
final int passageId = passage['id'] as int;
|
||||
final DateTime date = passage['date'] as DateTime;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Détails du passage #$passageId'),
|
||||
content: SizedBox(
|
||||
width: 500,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildDetailRow('Date',
|
||||
'${date.day}/${date.month}/${date.year} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}'),
|
||||
_buildDetailRow('Adresse', passage['address'] as String),
|
||||
_buildDetailRow('Secteur', passage['sector'] as String),
|
||||
_buildDetailRow('Collecteur', passage['user'] as String),
|
||||
_buildDetailRow(
|
||||
'Type',
|
||||
AppKeys.typesPassages[passage['type']]?['titre'] ??
|
||||
'Inconnu'),
|
||||
_buildDetailRow('Montant', '${passage['amount']} €'),
|
||||
_buildDetailRow(
|
||||
'Mode de paiement',
|
||||
AppKeys.typesReglements[passage['payment']]?['titre'] ??
|
||||
'Inconnu'),
|
||||
_buildDetailRow('Email', passage['email'] as String),
|
||||
_buildDetailRow(
|
||||
'Reçu envoyé', passage['hasReceipt'] ? 'Oui' : 'Non'),
|
||||
_buildDetailRow(
|
||||
'Erreur d\'envoi', passage['hasError'] ? 'Oui' : 'Non'),
|
||||
_buildDetailRow(
|
||||
'Notes',
|
||||
(passage['notes'] as String).isEmpty
|
||||
? '-'
|
||||
: passage['notes'] as String),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Historique des actions',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildHistoryItem(
|
||||
date,
|
||||
passage['user'] as String,
|
||||
'Création du passage',
|
||||
),
|
||||
if (passage['hasReceipt'])
|
||||
_buildHistoryItem(
|
||||
date.add(const Duration(minutes: 5)),
|
||||
'Système',
|
||||
'Envoi du reçu par email',
|
||||
),
|
||||
if (passage['hasError'])
|
||||
_buildHistoryItem(
|
||||
date.add(const Duration(minutes: 6)),
|
||||
'Système',
|
||||
'Erreur lors de l\'envoi du reçu',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode extraite pour ouvrir le dialog de modification
|
||||
|
||||
Widget _buildDetailRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: Text(
|
||||
'$label :',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHistoryItem(DateTime date, String user, String action) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${date.day}/${date.month}/${date.year} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: AppTheme.r(context, 12)),
|
||||
),
|
||||
Text('$user - $action'),
|
||||
const Divider(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Afficher le dialog de confirmation de suppression
|
||||
void _showDeleteConfirmationDialog(Map<String, dynamic> passage) {
|
||||
final TextEditingController confirmController = TextEditingController();
|
||||
|
||||
// Récupérer l'ID du passage et trouver le PassageModel original
|
||||
final int passageId = passage['id'] as int;
|
||||
final PassageModel? passageModel =
|
||||
_originalPassages.where((p) => p.id == passageId).firstOrNull;
|
||||
|
||||
if (passageModel == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Impossible de trouver le passage'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final String streetNumber = passageModel.numero;
|
||||
final String fullAddress =
|
||||
'${passageModel.numero} ${passageModel.rueBis} ${passageModel.rue}'
|
||||
.trim();
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.warning, color: Colors.red, size: 28),
|
||||
SizedBox(width: 8),
|
||||
Text('Confirmation de suppression'),
|
||||
],
|
||||
),
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'ATTENTION : Cette action est irréversible !',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red,
|
||||
fontSize: AppTheme.r(context, 16),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Vous êtes sur le point de supprimer définitivement le passage :',
|
||||
style: TextStyle(color: Colors.grey[800]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[100],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey[300]!),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
fullAddress.isEmpty ? 'Adresse inconnue' : fullAddress,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: AppTheme.r(context, 14),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (passage['user'] != null)
|
||||
Text(
|
||||
'Collecteur: ${passage['user']}',
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 12),
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
if (passage['date'] != null)
|
||||
Text(
|
||||
'Date: ${_formatDate(passage['date'] as DateTime)}',
|
||||
style: TextStyle(
|
||||
fontSize: AppTheme.r(context, 12),
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Pour confirmer la suppression, veuillez saisir le numéro de rue de ce passage :',
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: confirmController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Numéro de rue',
|
||||
hintText: streetNumber.isNotEmpty
|
||||
? 'Ex: $streetNumber'
|
||||
: 'Saisir le numéro',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.home),
|
||||
),
|
||||
keyboardType: TextInputType.text,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
confirmController.dispose();
|
||||
Navigator.of(dialogContext).pop();
|
||||
},
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
// Vérifier que le numéro saisi correspond
|
||||
final enteredNumber = confirmController.text.trim();
|
||||
if (enteredNumber.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Veuillez saisir le numéro de rue'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (streetNumber.isNotEmpty &&
|
||||
enteredNumber.toUpperCase() != streetNumber.toUpperCase()) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Le numéro de rue ne correspond pas'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fermer le dialog
|
||||
confirmController.dispose();
|
||||
Navigator.of(dialogContext).pop();
|
||||
|
||||
// Effectuer la suppression
|
||||
await _deletePassage(passageModel);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.red,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: const Text('Supprimer définitivement'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Supprimer un passage
|
||||
Future<void> _deletePassage(PassageModel passage) async {
|
||||
try {
|
||||
// Appeler le repository pour supprimer via l'API
|
||||
final success = await _passageRepository.deletePassageViaApi(passage.id);
|
||||
|
||||
if (success && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Passage supprimé avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
|
||||
// Pas besoin de recharger, le ValueListenableBuilder
|
||||
// se rafraîchira automatiquement après la suppression dans Hive
|
||||
} else if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Erreur lors de la suppression du passage'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur suppression passage: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Formater une date
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,589 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/core/theme/app_theme.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
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/presentation/widgets/charts/charts.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// Class pour dessiner les petits points blancs sur le fond
|
||||
class DotsPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.5)
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final random = math.Random(42); // Seed fixe pour consistance
|
||||
final numberOfDots = (size.width * size.height) ~/ 1500;
|
||||
|
||||
for (int i = 0; i < numberOfDots; i++) {
|
||||
final x = random.nextDouble() * size.width;
|
||||
final y = random.nextDouble() * size.height;
|
||||
final radius = 1.0 + random.nextDouble() * 2.0;
|
||||
canvas.drawCircle(Offset(x, y), radius, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class AdminStatisticsPage extends StatefulWidget {
|
||||
const AdminStatisticsPage({super.key});
|
||||
|
||||
@override
|
||||
State<AdminStatisticsPage> createState() => _AdminStatisticsPageState();
|
||||
}
|
||||
|
||||
class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
|
||||
// Filtres
|
||||
String _selectedPeriod = 'Jour';
|
||||
String _selectedSector = 'Tous';
|
||||
String _selectedMember = 'Tous';
|
||||
int _daysToShow = 15;
|
||||
|
||||
// Liste des périodes
|
||||
final List<String> _periods = ['Jour', 'Semaine', 'Mois', 'Année'];
|
||||
|
||||
// Listes dynamiques pour les secteurs et membres
|
||||
List<String> _sectors = ['Tous'];
|
||||
List<String> _members = ['Tous'];
|
||||
|
||||
// Listes complètes (non filtrées) pour réinitialisation
|
||||
List<SectorModel> _allSectors = [];
|
||||
List<MembreModel> _allMembers = [];
|
||||
List<UserSectorModel> _userSectors = [];
|
||||
|
||||
// Map pour stocker les IDs correspondants
|
||||
final Map<String, int> _sectorIds = {};
|
||||
final Map<String, int> _memberIds = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadData();
|
||||
}
|
||||
|
||||
void _loadData() {
|
||||
// Charger les secteurs depuis Hive
|
||||
if (Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
|
||||
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
|
||||
_allSectors = sectorsBox.values.toList();
|
||||
}
|
||||
|
||||
// Charger les membres depuis Hive
|
||||
if (Hive.isBoxOpen(AppKeys.membresBoxName)) {
|
||||
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
|
||||
_allMembers = membresBox.values.toList();
|
||||
}
|
||||
|
||||
// Charger les associations user-sector depuis Hive
|
||||
if (Hive.isBoxOpen(AppKeys.userSectorBoxName)) {
|
||||
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
|
||||
_userSectors = userSectorBox.values.toList();
|
||||
}
|
||||
|
||||
// Initialiser les listes avec toutes les données
|
||||
_updateSectorsList();
|
||||
_updateMembersList();
|
||||
}
|
||||
|
||||
// Mettre à jour la liste des secteurs (filtrée ou complète)
|
||||
void _updateSectorsList({int? forMemberId}) {
|
||||
setState(() {
|
||||
_sectors = ['Tous'];
|
||||
_sectorIds.clear();
|
||||
|
||||
List<SectorModel> sectorsToShow = _allSectors;
|
||||
|
||||
// Si un membre est sélectionné, filtrer les secteurs
|
||||
if (forMemberId != null) {
|
||||
final memberSectorIds = _userSectors
|
||||
.where((us) => us.id == forMemberId)
|
||||
.map((us) => us.fkSector)
|
||||
.toSet();
|
||||
|
||||
sectorsToShow = _allSectors
|
||||
.where((sector) => memberSectorIds.contains(sector.id))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Ajouter les secteurs à la liste
|
||||
for (final sector in sectorsToShow) {
|
||||
_sectors.add(sector.libelle);
|
||||
_sectorIds[sector.libelle] = sector.id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Mettre à jour la liste des membres (filtrée ou complète)
|
||||
void _updateMembersList({int? forSectorId}) {
|
||||
setState(() {
|
||||
_members = ['Tous'];
|
||||
_memberIds.clear();
|
||||
|
||||
List<MembreModel> membersToShow = _allMembers;
|
||||
|
||||
// Si un secteur est sélectionné, filtrer les membres
|
||||
if (forSectorId != null) {
|
||||
final sectorMemberIds = _userSectors
|
||||
.where((us) => us.fkSector == forSectorId)
|
||||
.map((us) => us.id)
|
||||
.toSet();
|
||||
|
||||
membersToShow = _allMembers
|
||||
.where((member) => sectorMemberIds.contains(member.id))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Ajouter les membres à la liste
|
||||
for (final membre in membersToShow) {
|
||||
final fullName = '${membre.firstName} ${membre.name}'.trim();
|
||||
_members.add(fullName);
|
||||
_memberIds[fullName] = membre.id;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > 800;
|
||||
|
||||
// Utiliser un Builder simple avec listeners pour les boxes
|
||||
// On écoute les changements et on reconstruit le widget
|
||||
return Stack(
|
||||
children: [
|
||||
// Fond dégradé avec petits points blancs
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.white, Colors.red.shade300],
|
||||
),
|
||||
),
|
||||
child: CustomPaint(
|
||||
painter: DotsPainter(),
|
||||
child:
|
||||
const SizedBox(width: double.infinity, height: double.infinity),
|
||||
),
|
||||
),
|
||||
// Contenu de la page
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingL),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Filtres
|
||||
Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
color: Colors.white, // Fond opaque
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Filtres',
|
||||
style:
|
||||
Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
isDesktop
|
||||
? Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildPeriodDropdown()),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(child: _buildDaysDropdown()),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildSectorDropdown()),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(child: _buildMemberDropdown()),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildPeriodDropdown(),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildDaysDropdown(),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildSectorDropdown(),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildMemberDropdown(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphique d'activité principal
|
||||
Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
color: Colors.white, // Fond opaque
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Évolution des passages',
|
||||
style:
|
||||
Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
ActivityChart(
|
||||
height: 350,
|
||||
showAllPassages: _selectedMember == 'Tous', // Afficher tous les passages seulement si "Tous" est sélectionné
|
||||
title: '',
|
||||
daysToShow: _daysToShow,
|
||||
periodType: _selectedPeriod,
|
||||
userId: _selectedMember != 'Tous'
|
||||
? _getMemberIdFromName(_selectedMember)
|
||||
: null,
|
||||
// Note: Le filtre par secteur nécessite une modification du widget ActivityChart
|
||||
// Pour filtrer par secteur, il faudrait ajouter un paramètre sectorId au widget
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphiques de répartition
|
||||
isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildChartCard(
|
||||
'Répartition par type de passage',
|
||||
PassageSummaryCard(
|
||||
title: '',
|
||||
titleColor: AppTheme.primaryColor,
|
||||
titleIcon: Icons.pie_chart,
|
||||
height: 300,
|
||||
useValueListenable: true,
|
||||
showAllPassages: _selectedMember == 'Tous',
|
||||
excludePassageTypes: const [
|
||||
2
|
||||
], // Exclure "À finaliser"
|
||||
userId: _selectedMember != 'Tous'
|
||||
? _getMemberIdFromName(_selectedMember)
|
||||
: null,
|
||||
// Note: Le filtre par secteur nécessite une modification du widget PassageSummaryCard
|
||||
isDesktop:
|
||||
MediaQuery.of(context).size.width > 800,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
child: _buildChartCard(
|
||||
'Répartition par mode de paiement',
|
||||
PaymentPieChart(
|
||||
useValueListenable: true,
|
||||
showAllPassages: _selectedMember == 'Tous',
|
||||
userId: _selectedMember != 'Tous'
|
||||
? _getMemberIdFromName(_selectedMember)
|
||||
: null,
|
||||
size: 300,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildChartCard(
|
||||
'Répartition par type de passage',
|
||||
PassageSummaryCard(
|
||||
title: '',
|
||||
titleColor: AppTheme.primaryColor,
|
||||
titleIcon: Icons.pie_chart,
|
||||
height: 300,
|
||||
useValueListenable: true,
|
||||
showAllPassages: _selectedMember == 'Tous',
|
||||
excludePassageTypes: const [
|
||||
2
|
||||
], // Exclure "À finaliser"
|
||||
userId: _selectedMember != 'Tous'
|
||||
? _getMemberIdFromName(_selectedMember)
|
||||
: null,
|
||||
// Note: Le filtre par secteur nécessite une modification du widget PassageSummaryCard
|
||||
isDesktop: MediaQuery.of(context).size.width > 800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildChartCard(
|
||||
'Répartition par mode de paiement',
|
||||
PaymentPieChart(
|
||||
useValueListenable: true,
|
||||
showAllPassages: _selectedMember == 'Tous',
|
||||
userId: _selectedMember != 'Tous'
|
||||
? _getMemberIdFromName(_selectedMember)
|
||||
: null,
|
||||
size: 300,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown pour la période
|
||||
Widget _buildPeriodDropdown() {
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Période',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingM,
|
||||
vertical: AppTheme.spacingS,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedPeriod,
|
||||
isDense: true,
|
||||
isExpanded: true,
|
||||
items: _periods.map((String period) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: period,
|
||||
child: Text(period),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
_selectedPeriod = newValue;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown pour le nombre de jours
|
||||
Widget _buildDaysDropdown() {
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Nombre de jours',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingM,
|
||||
vertical: AppTheme.spacingS,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<int>(
|
||||
value: _daysToShow,
|
||||
isDense: true,
|
||||
isExpanded: true,
|
||||
items: [7, 15, 30, 60, 90, 180, 365].map((int days) {
|
||||
return DropdownMenuItem<int>(
|
||||
value: days,
|
||||
child: Text('$days jours'),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (int? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
_daysToShow = newValue;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown pour les secteurs
|
||||
Widget _buildSectorDropdown() {
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Secteur',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingM,
|
||||
vertical: AppTheme.spacingS,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedSector,
|
||||
isDense: true,
|
||||
isExpanded: true,
|
||||
items: _sectors.map((String sector) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: sector,
|
||||
child: Text(sector),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
_selectedSector = newValue;
|
||||
|
||||
// Si "Tous" est sélectionné, réinitialiser la liste des membres
|
||||
if (newValue == 'Tous') {
|
||||
_updateMembersList();
|
||||
// Garder le membre sélectionné s'il existe
|
||||
} else {
|
||||
// Sinon, filtrer les membres pour ce secteur
|
||||
final sectorId = _getSectorIdFromName(newValue);
|
||||
_updateMembersList(forSectorId: sectorId);
|
||||
|
||||
// Si le membre actuellement sélectionné n'est pas dans la liste filtrée
|
||||
if (_selectedMember == 'Tous' || !_members.contains(_selectedMember)) {
|
||||
// Auto-sélectionner le premier membre du secteur (après "Tous")
|
||||
// Puisque chaque secteur a au moins un membre, il y aura toujours un membre à sélectionner
|
||||
if (_members.length > 1) {
|
||||
_selectedMember = _members[1]; // Index 1 car 0 est "Tous"
|
||||
}
|
||||
}
|
||||
// Si le membre sélectionné est dans la liste, on le garde
|
||||
// Les graphiques afficheront ses données
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown pour les membres
|
||||
Widget _buildMemberDropdown() {
|
||||
return InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Membre',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.spacingM,
|
||||
vertical: AppTheme.spacingS,
|
||||
),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: _selectedMember,
|
||||
isDense: true,
|
||||
isExpanded: true,
|
||||
items: _members.map((String member) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: member,
|
||||
child: Text(member),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
_selectedMember = newValue;
|
||||
|
||||
// Si "Tous" est sélectionné, réinitialiser la liste des secteurs
|
||||
if (newValue == 'Tous') {
|
||||
_updateSectorsList();
|
||||
// On peut réinitialiser le secteur car "Tous" les membres = pas de filtre secteur pertinent
|
||||
_selectedSector = 'Tous';
|
||||
} else {
|
||||
// Sinon, filtrer les secteurs pour ce membre
|
||||
final memberId = _getMemberIdFromName(newValue);
|
||||
_updateSectorsList(forMemberId: memberId);
|
||||
|
||||
// Si le secteur actuellement sélectionné n'est plus dans la liste, réinitialiser
|
||||
if (_selectedSector != 'Tous' && !_sectors.contains(_selectedSector)) {
|
||||
_selectedSector = 'Tous';
|
||||
}
|
||||
// Si le secteur est toujours dans la liste, on le garde sélectionné
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Widget pour envelopper un graphique dans une carte
|
||||
Widget _buildChartCard(String title, Widget chart) {
|
||||
return Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
),
|
||||
color: Colors.white, // Fond opaque
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
chart,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Méthode utilitaire pour obtenir l'ID membre à partir de son nom
|
||||
int? _getMemberIdFromName(String name) {
|
||||
if (name == 'Tous') return null;
|
||||
return _memberIds[name];
|
||||
}
|
||||
|
||||
// Méthode utilitaire pour obtenir l'ID du secteur à partir de son nom
|
||||
int? _getSectorIdFromName(String name) {
|
||||
if (name == 'Tous') return null;
|
||||
return _sectorIds[name];
|
||||
}
|
||||
|
||||
// Méthode pour obtenir tous les IDs des membres d'un secteur
|
||||
|
||||
// Méthode pour déterminer quel userId utiliser pour les graphiques
|
||||
|
||||
// Méthode pour déterminer si on doit afficher tous les passages
|
||||
}
|
||||
Reference in New Issue
Block a user