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:
pierre
2025-10-05 20:11:15 +02:00
parent 2786252307
commit 570a1fa1f0
212 changed files with 24275 additions and 11321 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'dart:convert';
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode, debugPrint;
import 'package:geosector_app/core/services/js_stub.dart'
if (dart.library.js) 'dart:js' as js;
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/services/app_info_service.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/custom_button.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
@@ -163,7 +164,7 @@ class _LoginPageState extends State<LoginPage> {
// Vérification du type de connexion (seulement si Hive est initialisé)
if (widget.loginType == null) {
// Si aucun type n'est spécifié, naviguer vers la splash page
print(
debugPrint(
'LoginPage: Aucun type de connexion spécifié, navigation vers splash page');
WidgetsBinding.instance.addPostFrameCallback((_) {
GoRouter.of(context).go('/');
@@ -171,7 +172,7 @@ class _LoginPageState extends State<LoginPage> {
_loginType = '';
} else {
_loginType = widget.loginType!;
print('LoginPage: Type de connexion utilisé: $_loginType');
debugPrint('LoginPage: Type de connexion utilisé: $_loginType');
}
// En mode web, essayer de détecter le paramètre dans l'URL directement
@@ -222,17 +223,17 @@ class _LoginPageState extends State<LoginPage> {
result.toLowerCase() == 'user') {
setState(() {
_loginType = 'user';
print(
debugPrint(
'LoginPage: Type détecté depuis sessionStorage: $_loginType');
});
}
} catch (e) {
print('LoginPage: Erreur lors de l\'accès au sessionStorage: $e');
debugPrint('LoginPage: Erreur lors de l\'accès au sessionStorage: $e');
}
});
}
} catch (e) {
print('Erreur lors de la récupération des paramètres d\'URL: $e');
debugPrint('Erreur lors de la récupération des paramètres d\'URL: $e');
}
}
@@ -327,7 +328,7 @@ class _LoginPageState extends State<LoginPage> {
@override
Widget build(BuildContext context) {
print('DEBUG BUILD: Reconstruction de LoginPage avec type: $_loginType');
debugPrint('DEBUG BUILD: Reconstruction de LoginPage avec type: $_loginType');
// Utiliser l'instance globale de userRepository
final theme = Theme.of(context);
@@ -565,13 +566,13 @@ class _LoginPageState extends State<LoginPage> {
_formKey.currentState!.validate()) {
// Vérifier que le type de connexion est spécifié
if (_loginType.isEmpty) {
print(
debugPrint(
'Login: Type non spécifié, redirection vers la page de démarrage');
context.go('/');
return;
}
print(
debugPrint(
'Login: Tentative avec type: $_loginType');
final success =
@@ -615,19 +616,37 @@ class _LoginPageState extends State<LoginPage> {
debugPrint(
'Role de l\'utilisateur: $roleValue');
// Redirection simple basée sur le rôle
if (roleValue > 1) {
debugPrint(
'Redirection vers /admin (rôle > 1)');
if (context.mounted) {
context.go('/admin');
}
} else {
debugPrint(
'Redirection vers /user (rôle = 1)');
// Définir le mode d'affichage selon le type de connexion
if (_loginType == 'user') {
// Connexion en mode user : toujours mode user
await CurrentUserService.instance.setDisplayMode('user');
debugPrint('Mode d\'affichage défini: user');
if (context.mounted) {
context.go('/user');
}
} else {
// Connexion en mode admin
if (roleValue >= 2) {
await CurrentUserService.instance.setDisplayMode('admin');
debugPrint('Mode d\'affichage défini: admin');
if (context.mounted) {
context.go('/admin');
}
} else {
// Un user (rôle 1) ne peut pas se connecter en mode admin
debugPrint('Erreur: User (rôle 1) tentant de se connecter en mode admin');
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Accès administrateur non autorisé pour ce compte.'),
backgroundColor: Colors.red,
),
);
}
return;
}
}
} else if (context.mounted) {
ScaffoldMessenger.of(context)
@@ -716,7 +735,7 @@ class _LoginPageState extends State<LoginPage> {
// Vérifier que le type de connexion est spécifié
if (_loginType.isEmpty) {
print(
debugPrint(
'Login: Type non spécifié, redirection vers la page de démarrage');
if (context.mounted) {
context.go('/');
@@ -724,7 +743,7 @@ class _LoginPageState extends State<LoginPage> {
return;
}
print(
debugPrint(
'Login: Tentative avec type: $_loginType');
// Utiliser le nouveau spinner moderne pour la connexion
@@ -773,19 +792,37 @@ class _LoginPageState extends State<LoginPage> {
debugPrint(
'Role de l\'utilisateur: $roleValue');
// Redirection simple basée sur le rôle
if (roleValue > 1) {
debugPrint(
'Redirection vers /admin (rôle > 1)');
if (context.mounted) {
context.go('/admin');
}
} else {
debugPrint(
'Redirection vers /user (rôle = 1)');
// Définir le mode d'affichage selon le type de connexion
if (_loginType == 'user') {
// Connexion en mode user : toujours mode user
await CurrentUserService.instance.setDisplayMode('user');
debugPrint('Mode d\'affichage défini: user');
if (context.mounted) {
context.go('/user');
}
} else {
// Connexion en mode admin
if (roleValue >= 2) {
await CurrentUserService.instance.setDisplayMode('admin');
debugPrint('Mode d\'affichage défini: admin');
if (context.mounted) {
context.go('/admin');
}
} else {
// Un user (rôle 1) ne peut pas se connecter en mode admin
debugPrint('Erreur: User (rôle 1) tentant de se connecter en mode admin');
if (context.mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Accès administrateur non autorisé pour ce compte.'),
backgroundColor: Colors.red,
),
);
}
return;
}
}
} else if (context.mounted) {
ScaffoldMessenger.of(context)
@@ -998,8 +1035,8 @@ class _LoginPageState extends State<LoginPage> {
final baseUrl = Uri.base.origin;
final apiUrl = '$baseUrl/api/lostpassword';
print('Envoi de la requête à: $apiUrl');
print('Email: ${emailController.text.trim()}');
debugPrint('Envoi de la requête à: $apiUrl');
debugPrint('Email: ${emailController.text.trim()}');
http.Response? response;
@@ -1013,15 +1050,15 @@ class _LoginPageState extends State<LoginPage> {
}),
);
print('Réponse reçue: ${response.statusCode}');
print('Corps de la réponse: ${response.body}');
debugPrint('Réponse reçue: ${response.statusCode}');
debugPrint('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(
debugPrint(
'Tentative avec URL alternative: $alternativeUrl');
final alternativeResponse = await http.post(
@@ -1032,9 +1069,9 @@ class _LoginPageState extends State<LoginPage> {
}),
);
print(
debugPrint(
'Réponse alternative reçue: ${alternativeResponse.statusCode}');
print(
debugPrint(
'Corps de la réponse alternative: ${alternativeResponse.body}');
// Si la réponse alternative est un succès, utiliser cette réponse
@@ -1043,7 +1080,7 @@ class _LoginPageState extends State<LoginPage> {
}
}
} catch (e) {
print(
debugPrint(
'Erreur lors de l\'envoi de la requête: $e');
throw Exception('Erreur de connexion: $e');
}

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/foundation.dart' show kIsWeb, debugPrint;
import 'package:go_router/go_router.dart';
import 'dart:math' as math;
import 'dart:convert';
@@ -256,7 +256,7 @@ class _RegisterPageState extends State<RegisterPage> {
});
}
} catch (e) {
print('Erreur lors de la récupération des villes: $e');
debugPrint('Erreur lors de la récupération des villes: $e');
setState(() {
_cities = [];
_isLoadingCities = false;

View File

@@ -10,9 +10,14 @@ 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';
import 'package:shared_preferences/shared_preferences.dart';
// Import conditionnel pour le web
import 'package:universal_html/html.dart' as html;
// Import des repositories pour reset du cache
import 'package:geosector_app/app.dart' show passageRepository, sectorRepository, membreRepository;
// Import des services pour la gestion de session F5
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/services/data_loading_service.dart';
class SplashPage extends StatefulWidget {
/// Action à effectuer après l'initialisation (login ou register)
@@ -130,18 +135,29 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
});
}
// Étape 2: Sauvegarder les données de pending_requests
debugPrint('💾 Sauvegarde des requêtes en attente...');
// Étape 2: Sauvegarder les données critiques (pending_requests + app_version)
debugPrint('💾 Sauvegarde des données critiques...');
List<dynamic>? pendingRequests;
String? savedAppVersion;
try {
// Sauvegarder pending_requests
if (Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) {
final pendingBox = Hive.box(AppKeys.pendingRequestsBoxName);
pendingRequests = pendingBox.values.toList();
debugPrint('📊 ${pendingRequests.length} requêtes en attente sauvegardées');
await pendingBox.close();
}
// Sauvegarder app_version pour éviter de perdre l'info de version
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
savedAppVersion = settingsBox.get('app_version') as String?;
if (savedAppVersion != null) {
debugPrint('📦 Version sauvegardée: $savedAppVersion');
}
}
} catch (e) {
debugPrint('⚠️ Erreur lors de la sauvegarde des requêtes: $e');
debugPrint('⚠️ Erreur lors de la sauvegarde: $e');
}
if (mounted) {
@@ -194,7 +210,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
await Future.delayed(const Duration(milliseconds: 500));
await Hive.initFlutter();
// Étape 6: Restaurer les requêtes en attente
// Étape 6: Restaurer les données critiques
if (pendingRequests != null && pendingRequests.isNotEmpty) {
debugPrint('♻️ Restauration des requêtes en attente...');
final pendingBox = await Hive.openBox(AppKeys.pendingRequestsBoxName);
@@ -204,6 +220,14 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
debugPrint('${pendingRequests.length} requêtes restaurées');
}
// Restaurer app_version pour maintenir la détection de changement de version
if (savedAppVersion != null) {
debugPrint('♻️ Restauration de la version...');
final settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
await settingsBox.put('app_version', savedAppVersion);
debugPrint('✅ Version restaurée: $savedAppVersion');
}
if (mounted) {
setState(() {
_statusMessage = "Nettoyage terminé !";
@@ -211,13 +235,6 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
});
}
// Étape 7: Sauvegarder la nouvelle version
if (!manual && kIsWeb) {
final prefs = await SharedPreferences.getInstance();
await prefs.setString('app_version', _appVersion);
debugPrint('💾 Version $_appVersion sauvegardée');
}
debugPrint('🎉 === NETTOYAGE TERMINÉ AVEC SUCCÈS === 🎉');
// Petit délai pour voir le message de succès
@@ -250,6 +267,206 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
}
}
/// Réinitialise le cache de tous les repositories après nettoyage complet
void _resetAllRepositoriesCache() {
try {
debugPrint('🔄 === RESET DU CACHE DES REPOSITORIES === 🔄');
// Reset du cache des 3 repositories qui utilisent le pattern de cache
passageRepository.resetCache();
sectorRepository.resetCache();
membreRepository.resetCache();
debugPrint('✅ Cache de tous les repositories réinitialisé');
} catch (e) {
debugPrint('⚠️ Erreur lors du reset des caches: $e');
// Ne pas faire échouer le processus si le reset échoue
}
}
/// Détecte et gère le refresh (F5) avec session existante
/// Retourne true si une session a été restaurée, false sinon
Future<bool> _handleSessionRefreshIfNeeded() async {
if (!kIsWeb) {
debugPrint('📱 Plateforme mobile - pas de gestion F5');
return false;
}
try {
debugPrint('🔍 Vérification d\'une session existante (F5)...');
// Charger l'utilisateur depuis Hive
await CurrentUserService.instance.loadFromHive();
final isLoggedIn = CurrentUserService.instance.isLoggedIn;
final displayMode = CurrentUserService.instance.displayMode;
final sessionId = CurrentUserService.instance.sessionId;
if (!isLoggedIn || sessionId == null) {
debugPrint(' Aucune session active - affichage normal de la splash');
return false;
}
debugPrint('🔄 Session active détectée - mode: $displayMode');
debugPrint('🔄 Rechargement des données depuis l\'API...');
if (mounted) {
setState(() {
_statusMessage = "Restauration de votre session...";
_progress = 0.85;
});
}
// Configurer ApiService avec le sessionId existant
ApiService.instance.setSessionId(sessionId);
// Appeler le nouvel endpoint API pour restaurer la session
final response = await ApiService.instance.get(
'/api/user/session',
queryParameters: {'mode': displayMode},
);
// Gestion des codes de retour HTTP
final statusCode = response.statusCode ?? 0;
final data = response.data as Map<String, dynamic>?;
switch (statusCode) {
case 200:
// Succès - traiter les données
if (data == null || data['success'] != true) {
debugPrint('❌ Format de réponse invalide (200 mais pas success=true)');
await CurrentUserService.instance.clearUser();
return false;
}
debugPrint('✅ Données reçues de l\'API, traitement...');
if (mounted) {
setState(() {
_statusMessage = "Chargement de vos données...";
_progress = 0.90;
});
}
// Traiter les données avec DataLoadingService
final apiData = data['data'] as Map<String, dynamic>?;
if (apiData == null) {
debugPrint('❌ Données manquantes dans la réponse');
await CurrentUserService.instance.clearUser();
return false;
}
await DataLoadingService.instance.processLoginData(apiData);
debugPrint('✅ Session restaurée avec succès');
break;
case 400:
// Paramètre mode invalide - erreur technique
debugPrint('❌ Paramètre mode invalide: $displayMode');
await CurrentUserService.instance.clearUser();
if (mounted) {
setState(() {
_statusMessage = "Erreur technique - veuillez vous reconnecter";
});
}
return false;
case 401:
// Session invalide ou expirée
debugPrint('⚠️ Session invalide ou expirée');
await CurrentUserService.instance.clearUser();
if (mounted) {
setState(() {
_statusMessage = "Session expirée - veuillez vous reconnecter";
});
}
return false;
case 403:
// Accès interdit (membre → admin) ou entité inactive
final message = data?['message'] ?? 'Accès interdit';
debugPrint('🚫 Accès interdit: $message');
await CurrentUserService.instance.clearUser();
if (mounted) {
setState(() {
_statusMessage = "Accès interdit - veuillez vous reconnecter";
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 5),
),
);
}
return false;
case 500:
// Erreur serveur
final message = data?['message'] ?? 'Erreur serveur';
debugPrint('❌ Erreur serveur: $message');
if (mounted) {
setState(() {
_statusMessage = "Erreur serveur - veuillez réessayer";
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur serveur: $message'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
}
// Ne pas effacer la session en cas d'erreur serveur
return false;
default:
// Code de retour inattendu
debugPrint('❌ Code HTTP inattendu: $statusCode');
await CurrentUserService.instance.clearUser();
return false;
}
if (mounted) {
setState(() {
_statusMessage = "Session restaurée !";
_progress = 0.95;
});
}
// Petit délai pour voir le message
await Future.delayed(const Duration(milliseconds: 500));
// Rediriger vers la bonne interface selon le mode
if (!mounted) return true;
if (displayMode == 'admin') {
debugPrint('🔀 Redirection vers interface admin');
context.go('/admin/home');
} else {
debugPrint('🔀 Redirection vers interface user');
context.go('/user/field-mode');
}
return true;
} catch (e) {
debugPrint('❌ Erreur lors de la restauration de session: $e');
// En cas d'erreur, effacer la session invalide
await CurrentUserService.instance.clearUser();
if (mounted) {
setState(() {
_statusMessage = "Erreur de restauration - veuillez vous reconnecter";
_progress = 0.0;
});
}
return false;
}
}
/// Vérifie si une nouvelle version est disponible et nettoie si nécessaire
Future<void> _checkVersionAndCleanIfNeeded() async {
if (!kIsWeb) {
@@ -258,9 +475,14 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
}
try {
final prefs = await SharedPreferences.getInstance();
final lastVersion = prefs.getString('app_version') ?? '';
String lastVersion = '';
// Lire la version depuis Hive settings
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
lastVersion = settingsBox.get('app_version', defaultValue: '') as String;
}
debugPrint('🔍 Vérification de version:');
debugPrint(' Version stockée: $lastVersion');
debugPrint(' Version actuelle: $_appVersion');
@@ -269,7 +491,7 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
if (lastVersion.isNotEmpty && lastVersion != _appVersion) {
debugPrint('🆕 NOUVELLE VERSION DÉTECTÉE !');
debugPrint(' Migration de $lastVersion vers $_appVersion');
if (mounted) {
setState(() {
_statusMessage = "Nouvelle version détectée, mise à jour...";
@@ -278,10 +500,17 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
// Effectuer le nettoyage automatique
await _performSelectiveCleanup(manual: false);
// Reset du cache des repositories après nettoyage
_resetAllRepositoriesCache();
} else if (lastVersion.isEmpty) {
// Première installation
debugPrint('🎉 Première installation détectée');
await prefs.setString('app_version', _appVersion);
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
final settingsBox = Hive.box(AppKeys.settingsBoxName);
await settingsBox.put('app_version', _appVersion);
debugPrint('💾 Version initiale sauvegardée dans Hive: $_appVersion');
}
} else {
debugPrint('✅ Même version - pas de nettoyage nécessaire');
}
@@ -325,9 +554,6 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
try {
debugPrint('🚀 Début de l\'initialisation complète de l\'application...');
// Étape 0: Vérifier et nettoyer si nouvelle version (Web uniquement)
await _checkVersionAndCleanIfNeeded();
// Étape 1: Vérification des permissions GPS (obligatoire) - 0 à 10%
if (!kIsWeb) {
if (mounted) {
@@ -402,7 +628,20 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
// Étape 3: Ouverture des Box - 60 à 80%
await HiveService.instance.ensureBoxesAreOpen();
// NOUVEAU : Vérifier et nettoyer si nouvelle version (Web uniquement)
// Maintenant que les boxes sont ouvertes, on peut vérifier la version dans Hive
await _checkVersionAndCleanIfNeeded();
// NOUVEAU : Détecter et gérer le F5 (refresh de page web avec session existante)
final sessionRestored = await _handleSessionRefreshIfNeeded();
if (sessionRestored) {
// Session restaurée avec succès, on arrête ici
// L'utilisateur a été redirigé vers son interface
debugPrint('✅ Session restaurée via F5 - fin de l\'initialisation');
return;
}
// Gérer la box pending_requests séparément pour préserver les données
try {
debugPrint('📦 Gestion de la box pending_requests...');
@@ -907,62 +1146,66 @@ class _SplashPageState extends State<SplashPage> with SingleTickerProviderStateM
const SizedBox(height: 8),
// Bouton de nettoyage du cache (en noir)
AnimatedOpacity(
opacity: _showButtons ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: TextButton.icon(
onPressed: _isCleaningCache ? null : () async {
// Confirmation avant nettoyage
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Nettoyer le cache ?'),
content: const Text(
'Cette action va :\n'
'• Supprimer toutes les données locales\n'
'Préserver les requêtes en attente\n'
'Forcer le rechargement de l\'application\n\n'
'Continuer ?'
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
// Bouton de nettoyage du cache (Web uniquement)
if (kIsWeb)
AnimatedOpacity(
opacity: _showButtons ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: TextButton.icon(
onPressed: _isCleaningCache ? null : () async {
// Confirmation avant nettoyage
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Nettoyer le cache ?'),
content: const Text(
'Cette action va :\n'
'Supprimer toutes les données locales\n'
'Préserver les requêtes en attente\n'
'• Forcer le rechargement de l\'application\n\n'
'Continuer ?'
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Annuler'),
),
child: const Text('Nettoyer'),
),
],
),
);
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
),
child: const Text('Nettoyer'),
),
],
),
);
if (confirm == true) {
debugPrint('👤 Utilisateur a demandé un nettoyage manuel');
await _performSelectiveCleanup(manual: true);
// Après le nettoyage, relancer l'initialisation
_startInitialization();
}
},
icon: Icon(
Icons.cleaning_services,
size: 18,
color: _isCleaningCache ? Colors.grey : Colors.black87,
),
label: Text(
_isCleaningCache ? 'Nettoyage...' : 'Nettoyer le cache',
style: TextStyle(
if (confirm == true) {
debugPrint('👤 Utilisateur a demandé un nettoyage manuel');
await _performSelectiveCleanup(manual: true);
// Reset du cache des repositories après nettoyage
_resetAllRepositoriesCache();
// Après le nettoyage, relancer l'initialisation
_startInitialization();
}
},
icon: Icon(
Icons.cleaning_services,
size: 18,
color: _isCleaningCache ? Colors.grey : Colors.black87,
fontWeight: FontWeight.w500,
),
label: Text(
_isCleaningCache ? 'Nettoyage...' : 'Nettoyer le cache',
style: TextStyle(
color: _isCleaningCache ? Colors.grey : Colors.black87,
fontWeight: FontWeight.w500,
),
),
),
),
),
],
const Spacer(flex: 1),

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/presentation/admin/admin_amicale_page.dart';
import 'package:geosector_app/app.dart';
/// Page de l'amicale unifiée utilisant AppScaffold
/// Accessible uniquement aux administrateurs (rôle 2)
class AmicalePage extends StatelessWidget {
const AmicalePage({super.key});
@override
Widget build(BuildContext context) {
// Vérifier le rôle pour l'accès
final currentUser = userRepository.getCurrentUser();
final userRole = currentUser?.role ?? 1;
// Vérifier que l'utilisateur a le rôle 2 (admin amicale)
if (userRole < 2) {
// Rediriger ou afficher un message d'erreur
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacementNamed('/user/dashboard');
});
return const SizedBox.shrink();
}
return AppScaffold(
key: const ValueKey('amicale_scaffold_admin'),
selectedIndex: 4, // Amicale est l'index 4
pageTitle: 'Amicale & membres',
body: AdminAmicalePage(
userRepository: userRepository,
amicaleRepository: amicaleRepository,
membreRepository: membreRepository,
passageRepository: passageRepository,
operationRepository: operationRepository,
),
);
}
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/presentation/user/user_field_mode_page.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
/// Page de mode terrain unifiée utilisant AppScaffold (users seulement)
class FieldModePage extends StatelessWidget {
const FieldModePage({super.key});
@override
Widget build(BuildContext context) {
// Déterminer le mode d'affichage (prend en compte le mode choisi à la connexion)
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
// Rediriger les admins vers le dashboard
if (isAdmin) {
// Les admins ne devraient pas avoir accès à cette page
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacementNamed('/admin');
});
return const SizedBox.shrink();
}
return AppScaffold(
key: const ValueKey('field_mode_scaffold_user'),
selectedIndex: 4, // Field mode est l'index 4 pour les users (après Dashboard, Historique, Messages, Carte)
pageTitle: 'Mode Terrain',
showBackground: false, // Pas de fond inutile, le mode terrain a son propre fond
body: const UserFieldModePage(), // Réutiliser la page existante
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
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 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/presentation/widgets/sector_distribution_card.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
import 'package:geosector_app/presentation/widgets/members_board_passages.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
/// Widget de contenu du tableau de bord unifié (sans scaffold)
class HomeContent extends StatefulWidget {
const HomeContent({super.key});
@override
State<HomeContent> createState() => _HomeContentState();
}
class _HomeContentState extends State<HomeContent> {
// Détection du rôle
late final bool isAdmin;
late final int currentUserId;
@override
void initState() {
super.initState();
// Déterminer le rôle de l'utilisateur et le mode d'affichage
final currentUser = userRepository.getCurrentUser();
isAdmin = CurrentUserService.instance.shouldShowAdminUI;
currentUserId = currentUser?.id ?? 0;
}
@override
Widget build(BuildContext context) {
debugPrint('Building HomeContent (isAdmin: $isAdmin)');
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
// Récupérer l'opération en cours
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';
// Retourner seulement le contenu (sans scaffold)
return SingleChildScrollView(
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? AppTheme.spacingL : AppTheme.spacingS,
vertical: 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),
// 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),
// Tableau détaillé des membres - uniquement pour admin sur Web
if (isAdmin && kIsWeb) ...[
const MembersBoardPassages(
height: 700,
),
const SizedBox(height: AppTheme.spacingL),
],
// LIGNE 2 : Carte de répartition par secteur
// Le widget filtre automatiquement selon le rôle de l'utilisateur
ValueListenableBuilder<Box<SectorModel>>(
valueListenable: Hive.box<SectorModel>(AppKeys.sectorsBoxName).listenable(),
builder: (context, Box<SectorModel> box, child) {
// Filtrer les secteurs pour les users
int sectorCount;
if (isAdmin) {
sectorCount = box.values.length;
} else {
final userSectors = userRepository.getUserSectors();
sectorCount = userSectors.length;
}
return SectorDistributionCard(
title: '$sectorCount secteur${sectorCount > 1 ? 's' : ''}',
height: 500,
);
},
),
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(
height: 350,
showAllPassages: isAdmin, // Admin voit tout, user voit tous les passages de ses secteurs
title: isAdmin
? 'Passages réalisés par jour (15 derniers jours)'
: 'Passages de mes secteurs par jour (15 derniers jours)',
daysToShow: 15,
),
),
const SizedBox(height: AppTheme.spacingL),
// Actions rapides - uniquement pour admin sur le web
if (isAdmin && 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
Widget _buildPassageTypeCard(BuildContext context) {
return PassageSummaryCard(
title: isAdmin ? 'Passages' : 'Passages de mes secteurs',
titleColor: AppTheme.primaryColor,
titleIcon: Icons.route,
height: 300,
useValueListenable: true,
showAllPassages: isAdmin, // Admin voit tout, user voit tous les passages de ses secteurs
userId: null, // Pas de filtre par userId, on filtre par secteurs assignés
excludePassageTypes: const [], // Afficher tous les types de passages
customTotalDisplay: (total) => '$total passage${total > 1 ? 's' : ''}',
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: isAdmin ? 'Règlements' : 'Mes règlements',
titleColor: AppTheme.buttonSuccessColor,
titleIcon: Icons.euro,
height: 300,
useValueListenable: true,
showAllPayments: isAdmin, // Admin voit tout, user voit uniquement ses règlements (fkUser)
userId: null, // Le filtre fkUser est géré automatiquement dans PaymentSummaryCard
customTotalDisplay: (total) => '${total.toStringAsFixed(2)}',
isDesktop: MediaQuery.of(context).size.width > 800,
backgroundIcon: Icons.euro,
backgroundIconColor: AppTheme.primaryColor,
backgroundIconOpacity: 0.07,
backgroundIconSize: 180,
);
}
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),
),
),
);
}
}
/// Page autonome du tableau de bord unifié utilisant AppScaffold
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
// Utiliser le mode d'affichage pour déterminer l'UI
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
return AppScaffold(
key: ValueKey('home_scaffold_${isAdmin ? 'admin' : 'user'}'),
selectedIndex: 0, // Dashboard/Home est toujours l'index 0
pageTitle: 'Tableau de bord',
body: const HomeContent(),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/presentation/chat/chat_communication_page.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
/// Page de messages unifiée utilisant AppScaffold
class MessagesPage extends StatelessWidget {
const MessagesPage({super.key});
@override
Widget build(BuildContext context) {
// Utiliser le mode d'affichage pour déterminer l'UI
final isAdmin = CurrentUserService.instance.shouldShowAdminUI;
return AppScaffold(
key: ValueKey('messages_scaffold_${isAdmin ? 'admin' : 'user'}'),
selectedIndex: 3, // Messages est l'index 3
pageTitle: 'Messages',
showBackground: false, // Pas de fond inutile, le chat a son propre fond
body: const ChatCommunicationPage(), // Réutiliser la page de chat existante
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/app_scaffold.dart';
import 'package:geosector_app/presentation/admin/admin_operations_page.dart';
import 'package:geosector_app/app.dart';
/// Page des opérations unifiée utilisant AppScaffold
/// Accessible uniquement aux administrateurs (rôle 2)
class OperationsPage extends StatelessWidget {
const OperationsPage({super.key});
@override
Widget build(BuildContext context) {
// Vérifier le rôle pour l'accès
final currentUser = userRepository.getCurrentUser();
final userRole = currentUser?.role ?? 1;
// Vérifier que l'utilisateur a le rôle 2 (admin amicale)
if (userRole < 2) {
// Rediriger ou afficher un message d'erreur
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacementNamed('/user/dashboard');
});
return const SizedBox.shrink();
}
return AppScaffold(
key: const ValueKey('operations_scaffold_admin'),
selectedIndex: 5, // Opérations est l'index 5
pageTitle: 'Opérations',
body: AdminOperationsPage(
operationRepository: operationRepository,
userRepository: userRepository,
),
);
}
}

View File

@@ -1,266 +0,0 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.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/passages/passages_list_widget.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
class UserDashboardHomePage extends StatefulWidget {
const UserDashboardHomePage({super.key});
@override
State<UserDashboardHomePage> createState() => _UserDashboardHomePageState();
}
class _UserDashboardHomePageState extends State<UserDashboardHomePage> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
final isMobile = size.width < 600;
final double horizontalPadding = isMobile ? 8.0 : 16.0;
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: SingleChildScrollView(
padding: EdgeInsets.all(horizontalPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Builder(builder: (context) {
// Récupérer l'opération actuelle
final operation = userRepository.getCurrentOperation();
if (operation != null) {
return Text(
operation.name,
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
);
} else {
return Text(
'Tableau de bord',
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
);
}
}),
const SizedBox(height: 24),
// Synthèse des passages
_buildSummaryCards(isDesktop),
const SizedBox(height: 24),
// Graphique des passages
_buildPassagesChart(context, theme),
const SizedBox(height: 24),
// Derniers passages
_buildRecentPassages(context, theme),
],
),
),
),
);
}
// Construction des cartes de synthèse
Widget _buildSummaryCards(bool isDesktop) {
return Column(
children: [
_buildCombinedPassagesCard(context, isDesktop),
const SizedBox(height: 16),
_buildCombinedPaymentsCard(isDesktop),
],
);
}
// Construction d'une carte combinée pour les règlements (liste + graphique)
Widget _buildCombinedPaymentsCard(bool isDesktop) {
return PaymentSummaryCard(
title: 'Règlements',
titleColor: AppTheme.accentColor,
titleIcon: Icons.euro,
height: 300,
useValueListenable: true,
userId: userRepository.getCurrentUser()?.id,
showAllPayments: false,
isDesktop: isDesktop,
backgroundIcon: Icons.euro_symbol,
backgroundIconColor: Colors.blue,
backgroundIconOpacity: 0.07,
backgroundIconSize: 180,
customTotalDisplay: (totalAmount) {
return '${totalAmount.toStringAsFixed(2)}';
},
);
}
// Construction d'une carte combinée pour les passages (liste + graphique)
Widget _buildCombinedPassagesCard(BuildContext context, bool isDesktop) {
return PassageSummaryCard(
title: 'Passages',
titleColor: AppTheme.primaryColor,
titleIcon: Icons.route,
height: 300,
useValueListenable: true,
userId: userRepository.getCurrentUser()?.id,
showAllPassages: false,
excludePassageTypes: const [2], // Exclure "À finaliser"
isDesktop: isDesktop,
);
}
// Construction du graphique des passages
Widget _buildPassagesChart(BuildContext context, ThemeData theme) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 350,
child: ActivityChart(
useValueListenable: true, // Utiliser le système réactif
excludePassageTypes: const [
2
], // Exclure les passages "À finaliser"
daysToShow: 15,
periodType: 'Jour',
height: 350,
userId: userRepository.getCurrentUser()?.id,
title: 'Dernière activité enregistrée sur 15 jours',
),
),
],
),
),
);
}
// Construction de la liste des derniers passages
Widget _buildRecentPassages(BuildContext context, ThemeData theme) {
// Utilisation directe du widget PassagesListWidget
return ValueListenableBuilder(
valueListenable:
Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
final recentPassages = _getRecentPassages(passagesBox);
// Debug : afficher le nombre de passages récupérés
debugPrint(
'UserDashboardHomePage: ${recentPassages.length} passages récents récupérés');
if (recentPassages.isEmpty) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: const Padding(
padding: EdgeInsets.all(32.0),
child: Center(
child: Text(
'Aucun passage récent',
style: TextStyle(
color: Colors.grey,
fontSize: 14,
),
),
),
),
);
}
// Utiliser PassagesListWidget sans hauteur fixe - laisse le widget gérer sa propre taille
return PassagesListWidget(
passages: recentPassages,
showFilters: false,
showSearch: false,
showActions: true,
maxPassages: 20,
showAddButton: false,
sortBy: 'date',
);
},
);
}
/// Récupère les passages récents pour la liste
List<Map<String, dynamic>> _getRecentPassages(Box<PassageModel> passagesBox) {
final currentUserId = userRepository.getCurrentUser()?.id;
// Filtrer les passages :
// - Avoir une date passedAt
// - Exclure le type 2 ("À finaliser")
// - Appartenir à l'utilisateur courant
final allPassages = passagesBox.values.where((p) {
if (p.passedAt == null) return false;
if (p.fkType == 2) return false; // Exclure les passages "À finaliser"
if (currentUserId != null && p.fkUser != currentUserId) {
return false; // Filtrer par utilisateur
}
return true;
}).toList();
// Trier par date décroissante
allPassages.sort((a, b) => b.passedAt!.compareTo(a.passedAt!));
// Limiter aux 20 passages les plus récents
final recentPassagesModels = allPassages.take(20).toList();
// Convertir les modèles de passage au format attendu par le widget PassagesListWidget
return recentPassagesModels.map((passage) {
// Construire l'adresse complète à partir des champs disponibles
final String address =
'${passage.numero} ${passage.rue}${passage.rueBis.isNotEmpty ? ' ${passage.rueBis}' : ''}, ${passage.ville}';
// Convertir le montant en double
double amount = 0.0;
try {
if (passage.montant.isNotEmpty) {
// Gérer les formats possibles (virgule ou point)
String montantStr = passage.montant.replaceAll(',', '.');
amount = double.tryParse(montantStr) ?? 0.0;
}
} catch (e) {
debugPrint('Erreur de conversion du montant: ${passage.montant}');
amount = 0.0;
}
return {
'id': passage.id, // Garder l'ID comme int, pas besoin de toString()
'address': address,
'amount': amount,
'date': passage.passedAt ?? DateTime.now(),
'type': passage.fkType,
'payment': passage.fkTypeReglement,
'name': passage.name,
'notes': passage.remarque,
'hasReceipt': passage.nomRecu.isNotEmpty,
'hasError': passage.emailErreur.isNotEmpty,
'fkUser': passage.fkUser,
'isOwnedByCurrentUser': passage.fkUser ==
userRepository
.getCurrentUser()
?.id, // Ajout du champ pour le widget
};
}).toList();
}
}

View File

@@ -1,281 +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/presentation/widgets/dashboard_layout.dart';
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
// Import des pages utilisateur
import 'user_dashboard_home_page.dart';
import 'user_statistics_page.dart';
import 'user_history_page.dart';
import '../chat/chat_communication_page.dart';
import 'user_map_page.dart';
import 'user_field_mode_page.dart';
class UserDashboardPage extends StatefulWidget {
const UserDashboardPage({super.key});
@override
State<UserDashboardPage> createState() => _UserDashboardPageState();
}
class _UserDashboardPageState extends State<UserDashboardPage> {
int _selectedIndex = 0;
// Liste des pages à afficher
late final List<Widget> _pages;
// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
@override
void initState() {
super.initState();
_pages = [
const UserDashboardHomePage(),
const UserStatisticsPage(),
const UserHistoryPage(),
const ChatCommunicationPage(),
const UserMapPage(),
const UserFieldModePage(),
];
// Initialiser et charger les paramètres
_initSettings();
}
// 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');
if (savedIndex != null &&
savedIndex is int &&
savedIndex >= 0 &&
savedIndex < _pages.length) {
setState(() {
_selectedIndex = savedIndex;
});
}
} 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) {
// Utiliser l'instance globale définie dans app.dart
final hasOperation = userRepository.getCurrentOperation() != null;
final hasSectors = userRepository.getUserSectors().isNotEmpty;
final isStandardUser = userRepository.currentUser != null &&
userRepository.currentUser!.role ==
'1'; // Rôle 1 = utilisateur standard
// Si l'utilisateur est standard et n'a pas d'opération assignée ou n'a pas de secteur, afficher un message spécial
final bool shouldShowNoOperationMessage = isStandardUser && !hasOperation;
final bool shouldShowNoSectorMessage = isStandardUser && !hasSectors;
// 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',
),
],
body: _buildNoOperationMessage(context),
);
}
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',
),
],
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',
),
const NavigationDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: 'Stats',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
createBadgedNavigationDestination(
icon: const Icon(Icons.chat_outlined),
selectedIcon: const Icon(Icons.chat),
label: 'Messages',
showBadge: true,
),
const NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
const NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: 'Terrain',
),
],
body: _pages[_selectedIndex],
);
}
// Message pour les utilisateurs sans opération assignée
Widget _buildNoOperationMessage(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Container(
padding: const EdgeInsets.all(24),
constraints: const BoxConstraints(maxWidth: 500),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_amber_rounded,
size: 80,
color: theme.colorScheme.error,
),
const SizedBox(height: 24),
Text(
'Aucune opération assignée',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Vous n\'avez pas encore été affecté à une opération. Veuillez contacter votre administrateur pour obtenir un accès.',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
],
),
),
);
}
// Message pour les utilisateurs sans secteur assigné
Widget _buildNoSectorMessage(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Container(
padding: const EdgeInsets.all(24),
constraints: const BoxConstraints(maxWidth: 500),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.map_outlined,
size: 80,
color: theme.colorScheme.error,
),
const SizedBox(height: 24),
Text(
'Aucun secteur assigné',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Vous n\'êtes affecté sur aucun secteur. Contactez votre administrateur pour qu\'il vous en affecte au moins un.',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
],
),
),
);
}
// Affiche le formulaire de passage
}

View File

@@ -86,9 +86,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
// Demander la permission et obtenir la position
final position = await Geolocator.getCurrentPosition(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
desiredAccuracy: LocationAccuracy.high,
);
setState(() {
@@ -232,12 +230,9 @@ class _UserFieldModePageState extends State<UserFieldModePage>
_qualityUpdateTimer =
Timer.periodic(const Duration(seconds: 5), (timer) async {
// Vérifier la connexion réseau
final connectivityResults = await Connectivity().checkConnectivity();
final connectivityResult = await Connectivity().checkConnectivity();
setState(() {
// Prendre le premier résultat de la liste
_connectivityResult = connectivityResults.isNotEmpty
? connectivityResults.first
: ConnectivityResult.none;
_connectivityResult = connectivityResult;
});
// Vérifier si le GPS est activé
@@ -274,7 +269,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
if (_currentPosition == null) return;
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final allPassages = passagesBox.values.where((p) => p.fkType == 2).toList();
final allPassages = passagesBox.values.toList(); // Tous les types de passages
// Calculer les distances et trier
final passagesWithDistance = allPassages.map((passage) {
@@ -295,8 +290,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
setState(() {
_nearbyPassages = passagesWithDistance
.take(50) // Limiter à 50 passages
.where((entry) => entry.value <= 2000) // Max 2km
.where((entry) => entry.value <= 500) // Max 500m
.map((entry) => entry.key)
.toList();
});
@@ -339,7 +333,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
void _startCompass() {
_magnetometerSubscription =
magnetometerEventStream().listen((MagnetometerEvent event) {
magnetometerEvents.listen((MagnetometerEvent event) {
setState(() {
// Calculer l'orientation à partir du magnétomètre
_heading = math.atan2(event.y, event.x) * (180 / math.pi);
@@ -375,6 +369,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
amicaleRepository: amicaleRepository,
onSuccess: () {
// Rafraîchir les passages après modification
_updateNearbyPassages();
@@ -985,22 +980,43 @@ class _UserFieldModePageState extends State<UserFieldModePage>
);
}
// Assombrir une couleur pour les bordures
Color _darkenColor(Color color, [double amount = 0.3]) {
assert(amount >= 0 && amount <= 1);
final hsl = HSLColor.fromColor(color);
final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0));
return hslDark.toColor();
}
List<Marker> _buildPassageMarkers() {
if (_currentPosition == null) return [];
return _nearbyPassages.map((passage) {
// Déterminer la couleur selon nbPassages
Color fillColor;
if (passage.nbPassages == 0) {
fillColor = const Color(0xFFFFFFFF); // couleur1: Blanc
} else if (passage.nbPassages == 1) {
fillColor = const Color(0xFFF7A278); // couleur2: Orange
} else {
fillColor = const Color(0xFFE65100); // couleur3: Orange foncé
// Déterminer la couleur selon le type de passage
Color fillColor = Colors.grey; // Couleur par défaut
if (AppKeys.typesPassages.containsKey(passage.fkType)) {
final typeInfo = AppKeys.typesPassages[passage.fkType]!;
if (passage.fkType == 2) {
// Type 2 (À finaliser) : adapter la couleur selon nbPassages
if (passage.nbPassages == 0) {
fillColor = Color(typeInfo['couleur1'] as int);
} else if (passage.nbPassages == 1) {
fillColor = Color(typeInfo['couleur2'] as int);
} else {
fillColor = Color(typeInfo['couleur3'] as int);
}
} else {
// Autres types : utiliser couleur2 par défaut
fillColor = Color(typeInfo['couleur2'] as int);
}
}
// Bordure toujours orange (couleur2)
const borderColor = Color(0xFFF7A278);
// Bordure : version assombrie de la couleur de remplissage
final borderColor = _darkenColor(fillColor, 0.3);
// Convertir les coordonnées GPS string en double
final double lat = double.tryParse(passage.gpsLat) ?? 0;
@@ -1029,8 +1045,10 @@ class _UserFieldModePageState extends State<UserFieldModePage>
child: Text(
'${passage.numero}${(passage.rueBis.isNotEmpty) ? passage.rueBis.substring(0, 1).toLowerCase() : ''}',
style: TextStyle(
color:
fillColor == Colors.white ? Colors.black : Colors.white,
// Texte noir sur fond clair, blanc sur fond foncé
color: fillColor.computeLuminance() > 0.5
? Colors.black
: Colors.white,
fontWeight: FontWeight.bold,
fontSize: AppTheme.r(context, 12),
),
@@ -1120,14 +1138,18 @@ class _UserFieldModePageState extends State<UserFieldModePage>
color: Colors.white,
child: PassagesListWidget(
passages: filteredPassages,
showFilters: false, // Pas de filtres, juste la liste
showSearch: false, // La recherche est déjà dans l'interface
showActions: true,
sortBy: 'distance', // Tri par distance pour le mode terrain
excludePassageTypes: const [], // Afficher tous les types (notamment le type 2)
showAddButton: true, // Activer le bouton de création
// Le widget gère maintenant le flux conditionnel par défaut
onPassageSelected: null,
onPassageEdit: (passage) {
// Retrouver le PassageModel original pour l'édition
final passageId = passage['id'] as int;
final originalPassage = _nearbyPassages.firstWhere(
(p) => p.id == passageId,
orElse: () => _nearbyPassages.first,
);
_openPassageForm(originalPassage);
},
onAddPassage: () async {
// Ouvrir le dialogue de création de passage
await showDialog(
@@ -1139,6 +1161,7 @@ class _UserFieldModePageState extends State<UserFieldModePage>
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
amicaleRepository: amicaleRepository,
onSuccess: () {
// Le widget se rafraîchira automatiquement via ValueListenableBuilder
},

View File

@@ -1,844 +0,0 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
import 'package:geosector_app/core/constants/app_keys.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/repositories/sector_repository.dart';
class UserHistoryPage extends StatefulWidget {
const UserHistoryPage({super.key});
@override
State<UserHistoryPage> createState() => _UserHistoryPageState();
}
// 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 _UserHistoryPageState extends State<UserHistoryPage> {
// Liste qui contiendra les passages convertis
List<Map<String, dynamic>> _convertedPassages = [];
// Variables pour indiquer l'état de chargement
bool _isLoading = true;
String _errorMessage = '';
// Statistiques pour l'affichage
int _totalSectors = 0;
int _sharedMembersCount = 0;
// État du tri actuel
PassageSortType _currentSort = PassageSortType.dateDesc;
// État des filtres (uniquement pour synchronisation)
int? selectedSectorId;
String selectedPeriod = 'Toutes';
DateTimeRange? selectedDateRange;
// Repository pour les secteurs
late SectorRepository _sectorRepository;
// Liste des secteurs disponibles pour l'utilisateur
List<SectorModel> _userSectors = [];
// Box des settings pour sauvegarder les préférences
late Box _settingsBox;
@override
void initState() {
super.initState();
// Initialiser le repository
_sectorRepository = sectorRepository;
// Initialiser les settings et charger les données
_initSettingsAndLoad();
}
// Initialiser les settings et charger les préférences
Future<void> _initSettingsAndLoad() async {
try {
// Ouvrir la box des settings
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
} else {
_settingsBox = Hive.box(AppKeys.settingsBoxName);
}
// Charger les préférences présélectionnées
_loadPreselectedFilters();
// Charger les secteurs de l'utilisateur
_loadUserSectors();
// Charger les passages
await _loadPassages();
} catch (e) {
debugPrint('Erreur lors de l\'initialisation: $e');
setState(() {
_isLoading = false;
_errorMessage = 'Erreur lors de l\'initialisation: $e';
});
}
}
// Charger les secteurs de l'utilisateur
void _loadUserSectors() {
try {
// Récupérer l'ID de l'utilisateur courant
final currentUserId = userRepository.getCurrentUser()?.id;
if (currentUserId != null) {
// Récupérer tous les secteurs
final allSectors = _sectorRepository.getAllSectors();
// Filtrer les secteurs où l'utilisateur a des passages
final userSectorIds = <int>{};
final allPassages = passageRepository.passages;
for (var passage in allPassages) {
if (passage.fkUser == currentUserId && passage.fkSector != null) {
userSectorIds.add(passage.fkSector!);
}
}
// Récupérer les secteurs correspondants
_userSectors = allSectors.where((sector) => userSectorIds.contains(sector.id)).toList();
debugPrint('Nombre de secteurs pour l\'utilisateur: ${_userSectors.length}');
}
} catch (e) {
debugPrint('Erreur lors du chargement des secteurs utilisateur: $e');
}
}
// Charger les filtres présélectionnés depuis Hive
void _loadPreselectedFilters() {
try {
// Charger le secteur présélectionné
final int? preselectedSectorId = _settingsBox.get('history_selectedSectorId');
final String? preselectedPeriod = _settingsBox.get('history_selectedPeriod');
if (preselectedSectorId != null) {
selectedSectorId = preselectedSectorId;
debugPrint('Secteur présélectionné: ID $preselectedSectorId');
}
if (preselectedPeriod != null) {
selectedPeriod = preselectedPeriod;
_updatePeriodFilter(preselectedPeriod);
debugPrint('Période présélectionnée: $preselectedPeriod');
}
// Nettoyer les valeurs après utilisation
_settingsBox.delete('history_selectedSectorId');
_settingsBox.delete('history_selectedSectorName');
_settingsBox.delete('history_selectedTypeId');
_settingsBox.delete('history_selectedPeriod');
_settingsBox.delete('history_selectedPaymentId');
} catch (e) {
debugPrint('Erreur lors du chargement des filtres présélectionnés: $e');
}
}
// Sauvegarder les préférences de filtres
void _saveFilterPreferences() {
try {
if (selectedSectorId != null) {
_settingsBox.put('history_selectedSectorId', selectedSectorId);
}
if (selectedPeriod != 'Toutes') {
_settingsBox.put('history_selectedPeriod', selectedPeriod);
}
} catch (e) {
debugPrint('Erreur lors de la sauvegarde des préférences: $e');
}
}
// Mettre à jour le filtre par secteur
void _updateSectorFilter(String sectorName, int? sectorId) {
setState(() {
selectedSectorId = sectorId;
});
_saveFilterPreferences();
}
// Mettre à jour le filtre par période
void _updatePeriodFilter(String period) {
setState(() {
selectedPeriod = period;
// Mettre à jour la plage de dates en fonction de la période
final DateTime now = DateTime.now();
switch (period) {
case 'Derniers 15 jours':
selectedDateRange = DateTimeRange(
start: now.subtract(const Duration(days: 15)),
end: now,
);
break;
case 'Dernière semaine':
selectedDateRange = DateTimeRange(
start: now.subtract(const Duration(days: 7)),
end: now,
);
break;
case 'Dernier mois':
selectedDateRange = DateTimeRange(
start: DateTime(now.year, now.month - 1, now.day),
end: now,
);
break;
case 'Tous':
selectedDateRange = null;
break;
}
});
_saveFilterPreferences();
}
// Méthode pour charger les passages depuis le repository
Future<void> _loadPassages() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
// Utiliser l'instance globale définie dans app.dart
final List<PassageModel> allPassages = passageRepository.passages;
debugPrint('Nombre total de passages dans la box: ${allPassages.length}');
// Filtrer les passages de l'utilisateur courant
final currentUserId = userRepository.getCurrentUser()?.id;
List<PassageModel> filtered = allPassages.where((p) => p.fkUser == currentUserId).toList();
debugPrint('Nombre de passages de l\'utilisateur: ${filtered.length}');
// Afficher la distribution des types de passages pour le débogage
final Map<int, int> typeCount = {};
for (var passage in filtered) {
typeCount[passage.fkType] = (typeCount[passage.fkType] ?? 0) + 1;
}
typeCount.forEach((type, count) {
debugPrint('Type de passage $type: $count passages');
});
// Calculer le nombre de secteurs uniques
final Set<int> uniqueSectors = {};
for (var passage in filtered) {
if (passage.fkSector != null && passage.fkSector! > 0) {
uniqueSectors.add(passage.fkSector!);
}
}
// Compter les membres partagés (autres membres dans la même amicale)
int sharedMembers = 0;
try {
final allMembers = membreRepository.membres;
// Compter les membres autres que l'utilisateur courant
sharedMembers = allMembers.where((membre) => membre.id != currentUserId).length;
debugPrint('Nombre de membres partagés: $sharedMembers');
} catch (e) {
debugPrint('Erreur lors du comptage des membres: $e');
}
// Convertir les modèles en Maps pour l'affichage
List<Map<String, dynamic>> passagesMap = [];
for (var passage in filtered) {
try {
final Map<String, dynamic> passageMap = _convertPassageModelToMap(passage);
passagesMap.add(passageMap);
} catch (e) {
debugPrint('Erreur lors de la conversion du passage en map: $e');
}
}
debugPrint('Nombre de passages après conversion: ${passagesMap.length}');
// Trier par date (plus récent en premier)
passagesMap = _sortPassages(passagesMap);
setState(() {
_convertedPassages = passagesMap;
_totalSectors = uniqueSectors.length;
_sharedMembersCount = sharedMembers;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Erreur lors du chargement des passages: $e';
_isLoading = false;
});
debugPrint(_errorMessage);
}
}
// Filtrer les passages selon les critères sélectionnés
List<Map<String, dynamic>> _getFilteredPassages(List<Map<String, dynamic>> passages) {
return passages.where((passage) {
// Filtrer par secteur
if (selectedSectorId != null && passage['fkSector'] != selectedSectorId) {
return false;
}
// Filtrer par période/date
if (selectedDateRange != null && passage['date'] is DateTime) {
final DateTime passageDate = passage['date'] as DateTime;
if (passageDate.isBefore(selectedDateRange!.start) ||
passageDate.isAfter(selectedDateRange!.end)) {
return false;
}
}
return true;
}).toList();
}
// Convertir un modèle de passage en Map pour l'affichage
Map<String, dynamic> _convertPassageModelToMap(PassageModel passage) {
try {
// Construire l'adresse complète
String address = _buildFullAddress(passage);
// Convertir le montant en double
double amount = 0.0;
if (passage.montant.isNotEmpty) {
amount = double.tryParse(passage.montant) ?? 0.0;
}
// Récupérer la date
DateTime date = passage.passedAt ?? DateTime.now();
// Récupérer le type
int type = passage.fkType;
if (!AppKeys.typesPassages.containsKey(type)) {
type = 1; // Type 1 par défaut (Effectué)
}
// Récupérer le type de règlement
int payment = passage.fkTypeReglement;
if (!AppKeys.typesReglements.containsKey(payment)) {
payment = 0; // Type de règlement inconnu
}
// Vérifier si un reçu est disponible
bool hasReceipt = amount > 0 && type == 1 && passage.nomRecu.isNotEmpty;
// Vérifier s'il y a une erreur
bool hasError = passage.emailErreur.isNotEmpty;
// Récupérer le secteur
SectorModel? sector;
if (passage.fkSector != null) {
sector = _sectorRepository.getSectorById(passage.fkSector!);
}
return {
'id': passage.id,
'address': address,
'amount': amount,
'date': date,
'type': type,
'payment': payment,
'name': passage.name,
'notes': passage.remarque,
'hasReceipt': hasReceipt,
'hasError': hasError,
'fkUser': passage.fkUser,
'fkSector': passage.fkSector,
'sector': sector?.libelle ?? 'Secteur inconnu',
'isOwnedByCurrentUser': passage.fkUser == userRepository.getCurrentUser()?.id,
// Composants de l'adresse pour le tri
'rue': passage.rue,
'numero': passage.numero,
'rueBis': passage.rueBis,
};
} catch (e) {
debugPrint('Erreur lors de la conversion du passage: $e');
// Retourner un objet valide par défaut
final currentUserId = userRepository.getCurrentUser()?.id;
return {
'id': 0,
'address': 'Adresse non disponible',
'amount': 0.0,
'date': DateTime.now(),
'type': 1,
'payment': 1,
'name': 'Nom non disponible',
'notes': '',
'hasReceipt': false,
'hasError': true,
'fkUser': currentUserId,
'fkSector': null,
'sector': 'Secteur inconnu',
'rue': '',
'numero': '',
'rueBis': '',
};
}
}
// Méthode pour trier les passages selon le type de tri sélectionné
List<Map<String, dynamic>> _sortPassages(List<Map<String, dynamic>> passages) {
final sortedPassages = List<Map<String, dynamic>>.from(passages);
switch (_currentSort) {
case PassageSortType.dateDesc:
sortedPassages.sort((a, b) {
try {
return (b['date'] as DateTime).compareTo(a['date'] as DateTime);
} catch (e) {
return 0;
}
});
break;
case PassageSortType.dateAsc:
sortedPassages.sort((a, b) {
try {
return (a['date'] as DateTime).compareTo(b['date'] as DateTime);
} catch (e) {
return 0;
}
});
break;
case PassageSortType.addressAsc:
sortedPassages.sort((a, b) {
try {
// Tri intelligent par rue, numéro, rueBis
final String rueA = a['rue'] ?? '';
final String rueB = b['rue'] ?? '';
final String numeroA = a['numero'] ?? '';
final String numeroB = b['numero'] ?? '';
final String rueBisA = a['rueBis'] ?? '';
final String rueBisB = b['rueBis'] ?? '';
// D'abord comparer les rues
int rueCompare = rueA.toLowerCase().compareTo(rueB.toLowerCase());
if (rueCompare != 0) return rueCompare;
// Si les rues sont identiques, comparer les numéros (numériquement)
int numA = int.tryParse(numeroA) ?? 0;
int numB = int.tryParse(numeroB) ?? 0;
int numCompare = numA.compareTo(numB);
if (numCompare != 0) return numCompare;
// Si les numéros sont identiques, comparer les rueBis
return rueBisA.toLowerCase().compareTo(rueBisB.toLowerCase());
} catch (e) {
return 0;
}
});
break;
case PassageSortType.addressDesc:
sortedPassages.sort((a, b) {
try {
// Tri intelligent inversé
final String rueA = a['rue'] ?? '';
final String rueB = b['rue'] ?? '';
final String numeroA = a['numero'] ?? '';
final String numeroB = b['numero'] ?? '';
final String rueBisA = a['rueBis'] ?? '';
final String rueBisB = b['rueBis'] ?? '';
// D'abord comparer les rues (inversé)
int rueCompare = rueB.toLowerCase().compareTo(rueA.toLowerCase());
if (rueCompare != 0) return rueCompare;
// Si les rues sont identiques, comparer les numéros (inversé)
int numA = int.tryParse(numeroA) ?? 0;
int numB = int.tryParse(numeroB) ?? 0;
int numCompare = numB.compareTo(numA);
if (numCompare != 0) return numCompare;
// Si les numéros sont identiques, comparer les rueBis (inversé)
return rueBisB.toLowerCase().compareTo(rueBisA.toLowerCase());
} catch (e) {
return 0;
}
});
break;
}
return sortedPassages;
}
// Construire l'adresse complète à partir des composants
String _buildFullAddress(PassageModel passage) {
final List<String> addressParts = [];
// Numéro et rue
if (passage.numero.isNotEmpty) {
addressParts.add('${passage.numero} ${passage.rue}');
} else {
addressParts.add(passage.rue);
}
// Complément rue bis
if (passage.rueBis.isNotEmpty) {
addressParts.add(passage.rueBis);
}
// Résidence/Bâtiment
if (passage.residence.isNotEmpty) {
addressParts.add(passage.residence);
}
// Appartement
if (passage.appt.isNotEmpty) {
addressParts.add('Appt ${passage.appt}');
}
// Niveau
if (passage.niveau.isNotEmpty) {
addressParts.add('Niveau ${passage.niveau}');
}
// Ville
if (passage.ville.isNotEmpty) {
addressParts.add(passage.ville);
}
return addressParts.join(', ');
}
// Méthode pour afficher les détails d'un passage
void _showPassageDetails(Map<String, dynamic> passage) {
// Récupérer les informations du type de passage et du type de règlement
final typePassage = AppKeys.typesPassages[passage['type']] as Map<String, dynamic>;
final typeReglement = AppKeys.typesReglements[passage['payment']] as Map<String, dynamic>;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Détails du passage'),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildDetailRow('Adresse', passage['address']),
_buildDetailRow('Nom', passage['name']),
_buildDetailRow('Date',
'${passage['date'].day}/${passage['date'].month}/${passage['date'].year}'),
_buildDetailRow('Type', typePassage['titre']),
_buildDetailRow('Règlement', typeReglement['titre']),
_buildDetailRow('Montant', '${passage['amount']}'),
if (passage['sector'] != null)
_buildDetailRow('Secteur', passage['sector']),
if (passage['notes'] != null && passage['notes'].toString().isNotEmpty)
_buildDetailRow('Notes', passage['notes']),
if (passage['hasReceipt'] == true)
_buildDetailRow('Reçu', 'Disponible'),
if (passage['hasError'] == true)
_buildDetailRow('Erreur', 'Détectée', isError: true),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
if (passage['hasReceipt'] == true)
TextButton(
onPressed: () {
Navigator.of(context).pop();
_showReceipt(passage);
},
child: const Text('Voir le reçu'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_editPassage(passage);
},
child: const Text('Modifier'),
),
],
),
);
}
// Méthode pour éditer un passage
void _editPassage(Map<String, dynamic> passage) {
debugPrint('Édition du passage ${passage['id']}');
}
// Méthode pour afficher un reçu
void _showReceipt(Map<String, dynamic> passage) {
debugPrint('Affichage du reçu pour le passage ${passage['id']}');
}
// Helper pour construire une ligne de détails
Widget _buildDetailRow(String label, String value, {bool isError = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text('$label:',
style: const TextStyle(fontWeight: FontWeight.bold))),
Expanded(
child: Text(
value,
style: isError ? const TextStyle(color: Colors.red) : null,
),
),
],
),
);
}
// Les filtres sont maintenant gérés directement dans le PassagesListWidget
// Méthodes de filtre retirées car maintenant gérées dans le widget
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Les filtres sont maintenant intégrés dans le PassagesListWidget
// Affichage du chargement ou des erreurs
if (_isLoading)
const Expanded(
child: Center(
child: CircularProgressIndicator(),
),
)
else if (_errorMessage.isNotEmpty)
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline,
size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(
'Erreur de chargement',
style: TextStyle(
fontSize: AppTheme.r(context, 22),
color: Colors.red),
),
const SizedBox(height: 8),
Text(_errorMessage),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadPassages,
child: const Text('Réessayer'),
),
],
),
),
)
// Utilisation du widget PassagesListWidget pour afficher la liste des passages
else
Expanded(
child: Container(
color: Colors.transparent,
child: Column(
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 currentUserId = userRepository.getCurrentUser()?.id;
final List<PassageModel> allPassages = passagesBox.values
.where((p) => p.fkUser == currentUserId)
.toList();
// Appliquer le même filtrage et conversion
List<Map<String, dynamic>> passagesMap = [];
for (var passage in allPassages) {
try {
final Map<String, dynamic> passageMap = _convertPassageModelToMap(passage);
passagesMap.add(passageMap);
} catch (e) {
debugPrint('Erreur lors de la conversion du passage en map: $e');
}
}
// Appliquer le tri sélectionné
passagesMap = _sortPassages(passagesMap);
return PassagesListWidget(
// Données
passages: passagesMap,
// Activation des filtres
showFilters: true,
showSearch: true,
showTypeFilter: true,
showPaymentFilter: true,
showSectorFilter: true,
showUserFilter: false, // Pas de filtre membre pour la page user
showPeriodFilter: true,
// Données pour les filtres
sectors: _userSectors,
members: null, // Pas de filtre membre pour la page user
// Valeurs initiales
initialSectorId: selectedSectorId,
initialPeriod: selectedPeriod,
dateRange: selectedDateRange,
// Filtre par utilisateur courant
filterByUserId: currentUserId,
// Bouton d'ajout
showAddButton: true,
onAddPassage: () async {
// Ouvrir le dialogue de création de passage
await showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
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.colorScheme.primary
: theme.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.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.colorScheme.primary
: theme.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.colorScheme.primary,
),
],
),
// Actions
showActions: true,
key: const ValueKey('user_passages_list'),
// Callback pour synchroniser les filtres
onFiltersChanged: (filters) {
setState(() {
selectedSectorId = filters['sectorId'];
selectedPeriod = filters['period'] ?? 'Toutes';
selectedDateRange = filters['dateRange'];
});
},
onDetailsView: (passage) {
debugPrint('Affichage des détails: ${passage['id']}');
_showPassageDetails(passage);
},
onPassageEdit: (passage) {
debugPrint('Modification du passage: ${passage['id']}');
_editPassage(passage);
},
onReceiptView: (passage) {
debugPrint('Affichage du reçu pour le passage: ${passage['id']}');
_showReceipt(passage);
},
onPassageDelete: (passage) {
// Pas besoin de recharger, le ValueListenableBuilder
// se rafraîchira automatiquement après la suppression
},
);
},
),
),
],
),
),
),
],
),
),
);
}
@override
void dispose() {
super.dispose();
}
}

View File

@@ -1,938 +0,0 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/services/location_service.dart';
import 'package:geosector_app/presentation/widgets/mapbox_map.dart';
import '../../core/constants/app_keys.dart';
import '../../core/data/models/sector_model.dart';
import '../../core/data/models/passage_model.dart';
import '../../presentation/widgets/passage_map_dialog.dart';
// Extension pour ajouter ln2 (logarithme népérien de 2) comme constante
extension MathConstants on math.Random {
static const double ln2 = 0.6931471805599453; // ln(2)
}
class UserMapPage extends StatefulWidget {
const UserMapPage({super.key});
@override
State<UserMapPage> createState() => _UserMapPageState();
}
class _UserMapPageState extends State<UserMapPage> {
// Contrôleur de carte
final MapController _mapController = MapController();
// Position actuelle et zoom
LatLng _currentPosition =
const LatLng(48.117266, -1.6777926); // Position initiale sur Rennes
double _currentZoom = 12.0; // Zoom initial
// Données des secteurs et passages
final List<Map<String, dynamic>> _sectors = [];
final List<Map<String, dynamic>> _passages = [];
// Items pour la combobox de secteurs
List<DropdownMenuItem<int?>> _sectorItems = [];
// Filtres pour les types de passages
bool _showEffectues = true;
bool _showAFinaliser = true;
bool _showRefuses = true;
bool _showDons = true;
bool _showLots = true;
bool _showMaisonsVides = true;
// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
// Vérifier si la combobox de secteurs doit être affichée
bool get _shouldShowSectorCombobox => _sectors.length > 1;
int? _selectedSectorId;
@override
void initState() {
super.initState();
_initSettings().then((_) {
_loadSectors();
_loadPassages();
});
}
// Initialiser la boîte de paramètres et charger les préférences
Future<void> _initSettings() async {
// 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 les filtres sauvegardés
_showEffectues = _settingsBox.get('showEffectues', defaultValue: true);
_showAFinaliser = _settingsBox.get('showAFinaliser', defaultValue: true);
_showRefuses = _settingsBox.get('showRefuses', defaultValue: true);
_showDons = _settingsBox.get('showDons', defaultValue: true);
_showLots = _settingsBox.get('showLots', defaultValue: true);
_showMaisonsVides =
_settingsBox.get('showMaisonsVides', defaultValue: true);
// Charger le secteur sélectionné
_selectedSectorId = _settingsBox.get('selectedSectorId');
// Charger la position et le zoom
final double? savedLat = _settingsBox.get('mapLat');
final double? savedLng = _settingsBox.get('mapLng');
final double? savedZoom = _settingsBox.get('mapZoom');
if (savedLat != null && savedLng != null) {
_currentPosition = LatLng(savedLat, savedLng);
}
if (savedZoom != null) {
_currentZoom = savedZoom;
}
}
// Obtenir la position actuelle de l'utilisateur
Future<void> _getUserLocation() async {
try {
// Afficher un indicateur de chargement
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Recherche de votre position...'),
duration: Duration(seconds: 2),
),
);
// Obtenir la position actuelle via le service de géolocalisation
final position = await LocationService.getCurrentPosition();
if (position != null) {
// Mettre à jour la position sur la carte
_updateMapPosition(position, zoom: 17);
// Sauvegarder la nouvelle position
_settingsBox.put('mapLat', position.latitude);
_settingsBox.put('mapLng', position.longitude);
// Informer l'utilisateur
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Position actualisée'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
);
}
} else {
// Informer l'utilisateur en cas d'échec
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Impossible d\'obtenir votre position. Vérifiez vos paramètres de localisation.'),
backgroundColor: Colors.red,
),
);
}
}
} catch (e) {
// Gérer les erreurs
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
// Sauvegarder les paramètres utilisateur
void _saveSettings() {
// Sauvegarder les filtres
_settingsBox.put('showEffectues', _showEffectues);
_settingsBox.put('showAFinaliser', _showAFinaliser);
_settingsBox.put('showRefuses', _showRefuses);
_settingsBox.put('showDons', _showDons);
_settingsBox.put('showLots', _showLots);
_settingsBox.put('showMaisonsVides', _showMaisonsVides);
// Sauvegarder le secteur sélectionné
if (_selectedSectorId != null) {
_settingsBox.put('selectedSectorId', _selectedSectorId);
}
// Sauvegarder la position et le zoom actuels
_settingsBox.put('mapLat', _currentPosition.latitude);
_settingsBox.put('mapLng', _currentPosition.longitude);
_settingsBox.put('mapZoom', _currentZoom);
}
// Charger les secteurs depuis la boîte Hive
void _loadSectors() {
try {
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
final sectors = sectorsBox.values.toList();
setState(() {
_sectors.clear();
for (final sector in sectors) {
final List<List<double>> coordinates = sector.getCoordinates();
final List<LatLng> points =
coordinates.map((coord) => LatLng(coord[0], coord[1])).toList();
if (points.isNotEmpty) {
_sectors.add({
'id': sector.id,
'name': sector.libelle,
'color': _hexToColor(sector.color),
'points': points,
});
}
}
// Mettre à jour les items de la combobox de secteurs
_updateSectorItems();
// Si un secteur était sélectionné précédemment, le centrer
if (_selectedSectorId != null &&
_sectors.any((s) => s['id'] == _selectedSectorId)) {
_centerMapOnSpecificSector(_selectedSectorId!);
}
// Sinon, centrer la carte sur tous les secteurs
else if (_sectors.isNotEmpty) {
_centerMapOnSectors();
}
});
} catch (e) {
debugPrint('Erreur lors du chargement des secteurs: $e');
}
}
// Mettre à jour les items de la combobox de secteurs
void _updateSectorItems() {
// Créer l'item "Tous les secteurs"
final List<DropdownMenuItem<int?>> items = [
const DropdownMenuItem<int?>(
value: null,
child: Text('Tous les secteurs'),
),
];
// Ajouter tous les secteurs
for (final sector in _sectors) {
items.add(
DropdownMenuItem<int?>(
value: sector['id'] as int,
child: Text(sector['name'] as String),
),
);
}
setState(() {
_sectorItems = items;
});
}
// Charger les passages depuis la boîte Hive
void _loadPassages() {
try {
// Récupérer la boîte des passages
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
// Créer une nouvelle liste temporaire
final List<Map<String, dynamic>> newPassages = [];
// Parcourir tous les passages dans la boîte
for (var i = 0; i < passagesBox.length; i++) {
final passage = passagesBox.getAt(i);
if (passage != null) {
// Vérifier si les coordonnées GPS sont valides
final lat = double.tryParse(passage.gpsLat);
final lng = double.tryParse(passage.gpsLng);
// Filtrer par secteur si un secteur est sélectionné
if (_selectedSectorId != null &&
passage.fkSector != _selectedSectorId) {
continue;
}
if (lat != null && lng != null) {
// Obtenir la couleur du type de passage
Color passageColor = Colors.grey; // Couleur par défaut
// Vérifier si le type de passage existe dans AppKeys.typesPassages
if (AppKeys.typesPassages.containsKey(passage.fkType)) {
// Utiliser la couleur1 du type de passage
final colorValue =
AppKeys.typesPassages[passage.fkType]!['couleur1'] as int;
passageColor = Color(colorValue);
// Ajouter le passage à la liste temporaire avec filtrage
if (_shouldShowPassage(passage.fkType)) {
newPassages.add({
'id': passage.id,
'position': LatLng(lat, lng),
'type': passage.fkType,
'color': passageColor,
'model': passage, // Ajouter le modèle complet
});
}
}
}
}
}
// Mettre à jour la liste des passages dans l'état
setState(() {
_passages.clear();
_passages.addAll(newPassages);
});
// Sauvegarder les paramètres après chargement des passages
_saveSettings();
} catch (e) {
debugPrint('Erreur lors du chargement des passages: $e');
}
}
// Vérifier si un passage doit être affiché en fonction de son type
bool _shouldShowPassage(int type) {
switch (type) {
case 1: // Effectué
return _showEffectues;
case 2: // À finaliser
return _showAFinaliser;
case 3: // Refusé
return _showRefuses;
case 4: // Don
return _showDons;
case 5: // Lot
return _showLots;
case 6: // Maison vide
return _showMaisonsVides;
default:
return true;
}
}
// Convertir une couleur hexadécimale en Color
Color _hexToColor(String hexColor) {
// Supprimer le # si présent
final String colorStr =
hexColor.startsWith('#') ? hexColor.substring(1) : hexColor;
// Ajouter FF pour l'opacité si nécessaire (6 caractères -> 8 caractères)
final String fullColorStr = colorStr.length == 6 ? 'FF$colorStr' : colorStr;
// Convertir en entier et créer la couleur
return Color(int.parse(fullColorStr, radix: 16));
}
// Centrer la carte sur tous les secteurs
void _centerMapOnSectors() {
if (_sectors.isEmpty) return;
// Trouver les limites de tous les secteurs
double minLat = 90.0;
double maxLat = -90.0;
double minLng = 180.0;
double maxLng = -180.0;
for (final sector in _sectors) {
final points = sector['points'] as List<LatLng>;
for (final point in points) {
minLat = point.latitude < minLat ? point.latitude : minLat;
maxLat = point.latitude > maxLat ? point.latitude : maxLat;
minLng = point.longitude < minLng ? point.longitude : minLng;
maxLng = point.longitude > maxLng ? point.longitude : maxLng;
}
}
// Ajouter un padding aux limites pour s'assurer que tous les secteurs sont entièrement visibles
// avec une marge autour (5% de la taille totale)
final latPadding = (maxLat - minLat) * 0.05;
final lngPadding = (maxLng - minLng) * 0.05;
minLat -= latPadding;
maxLat += latPadding;
minLng -= lngPadding;
maxLng += lngPadding;
// Calculer le centre
final centerLat = (minLat + maxLat) / 2;
final centerLng = (minLng + maxLng) / 2;
// Calculer le zoom approprié en tenant compte des dimensions de l'écran
final mapWidth = MediaQuery.of(context).size.width;
final mapHeight = MediaQuery.of(context).size.height *
0.7; // Estimation de la hauteur de la carte
final zoom = _calculateOptimalZoom(
minLat, maxLat, minLng, maxLng, mapWidth, mapHeight);
// Centrer la carte sur ces limites avec animation
_mapController.move(LatLng(centerLat, centerLng), zoom);
// Mettre à jour l'état pour refléter la nouvelle position
setState(() {
_currentPosition = LatLng(centerLat, centerLng);
_currentZoom = zoom;
});
debugPrint('Carte centrée sur tous les secteurs avec zoom: $zoom');
}
// Centrer la carte sur un secteur spécifique
void _centerMapOnSpecificSector(int sectorId) {
final sectorIndex = _sectors.indexWhere((s) => s['id'] == sectorId);
if (sectorIndex == -1) return;
// Mettre à jour le secteur sélectionné
_selectedSectorId = sectorId;
final sector = _sectors[sectorIndex];
final points = sector['points'] as List<LatLng>;
final sectorName = sector['name'] as String;
debugPrint(
'Centrage sur le secteur: $sectorName (ID: $sectorId) avec ${points.length} points');
if (points.isEmpty) {
debugPrint('Aucun point dans ce secteur!');
return;
}
// Trouver les limites du secteur
double minLat = 90.0;
double maxLat = -90.0;
double minLng = 180.0;
double maxLng = -180.0;
for (final point in points) {
minLat = point.latitude < minLat ? point.latitude : minLat;
maxLat = point.latitude > maxLat ? point.latitude : maxLat;
minLng = point.longitude < minLng ? point.longitude : minLng;
maxLng = point.longitude > maxLng ? point.longitude : maxLng;
}
debugPrint(
'Limites du secteur: minLat=$minLat, maxLat=$maxLat, minLng=$minLng, maxLng=$maxLng');
// Vérifier si les coordonnées sont valides
if (minLat >= maxLat || minLng >= maxLng) {
debugPrint('Coordonnées invalides pour le secteur $sectorName');
return;
}
// Calculer la taille du secteur
final latSpan = maxLat - minLat;
final lngSpan = maxLng - minLng;
debugPrint('Taille du secteur: latSpan=$latSpan, lngSpan=$lngSpan');
// Ajouter un padding minimal aux limites pour s'assurer que le secteur est bien visible
// mais prend le maximum de place sur la carte
final double latPadding, lngPadding;
if (latSpan < 0.01 || lngSpan < 0.01) {
// Pour les très petits secteurs, utiliser un padding très réduit
latPadding = 0.0003;
lngPadding = 0.0003;
} else if (latSpan < 0.05 || lngSpan < 0.05) {
// Pour les petits secteurs, padding réduit
latPadding = 0.0005;
lngPadding = 0.0005;
} else {
// Pour les secteurs plus grands, utiliser un pourcentage minimal
latPadding = latSpan * 0.03; // 3% au lieu de 10%
lngPadding = lngSpan * 0.03;
}
minLat -= latPadding;
maxLat += latPadding;
minLng -= lngPadding;
maxLng += lngPadding;
debugPrint(
'Limites avec padding: minLat=$minLat, maxLat=$maxLat, minLng=$minLng, maxLng=$maxLng');
// Calculer le centre
final centerLat = (minLat + maxLat) / 2;
final centerLng = (minLng + maxLng) / 2;
// Déterminer le zoom approprié en fonction de la taille du secteur
double zoom;
// Pour les très petits secteurs (comme des quartiers), utiliser un zoom fixe élevé
if (latSpan < 0.01 && lngSpan < 0.01) {
zoom = 16.0; // Zoom élevé pour les petits quartiers
} else if (latSpan < 0.02 && lngSpan < 0.02) {
zoom = 15.0; // Zoom élevé pour les petits quartiers
} else if (latSpan < 0.05 && lngSpan < 0.05) {
zoom =
13.0; // Zoom pour les secteurs de taille moyenne (quelques quartiers)
} else if (latSpan < 0.1 && lngSpan < 0.1) {
zoom = 12.0; // Zoom pour les grands secteurs (ville)
} else {
// Pour les secteurs plus grands, calculer le zoom
final mapWidth = MediaQuery.of(context).size.width;
final mapHeight = MediaQuery.of(context).size.height * 0.7;
zoom = _calculateOptimalZoom(
minLat, maxLat, minLng, maxLng, mapWidth, mapHeight);
}
debugPrint('Zoom calculé pour le secteur $sectorName: $zoom');
// Centrer la carte sur le secteur avec animation
_mapController.move(LatLng(centerLat, centerLng), zoom);
// Mettre à jour l'état pour refléter la nouvelle position
setState(() {
_currentPosition = LatLng(centerLat, centerLng);
_currentZoom = zoom;
});
}
// Calculer le zoom optimal pour afficher une zone géographique dans la fenêtre de la carte
double _calculateOptimalZoom(double minLat, double maxLat, double minLng,
double maxLng, double mapWidth, double mapHeight) {
// Méthode simplifiée et plus fiable pour calculer le zoom
// Vérifier si les coordonnées sont valides
if (minLat >= maxLat || minLng >= maxLng) {
debugPrint('Coordonnées invalides pour le calcul du zoom');
return 12.0; // Valeur par défaut raisonnable
}
// Calculer la taille en degrés
final latSpan = maxLat - minLat;
final lngSpan = maxLng - minLng;
debugPrint(
'_calculateOptimalZoom - Taille: latSpan=$latSpan, lngSpan=$lngSpan');
// Ajouter un facteur de sécurité pour éviter les divisions par zéro
if (latSpan < 0.0000001 || lngSpan < 0.0000001) {
return 15.0; // Zoom élevé pour un point très précis
}
// Formule simplifiée pour le calcul du zoom
// Basée sur l'expérience et adaptée pour les petites zones
double zoom;
if (latSpan < 0.005 || lngSpan < 0.005) {
// Très petite zone (quartier)
zoom = 16.0;
} else if (latSpan < 0.01 || lngSpan < 0.01) {
// Petite zone (quartier)
zoom = 15.0;
} else if (latSpan < 0.02 || lngSpan < 0.02) {
// Petite zone (plusieurs quartiers)
zoom = 14.0;
} else if (latSpan < 0.05 || lngSpan < 0.05) {
// Zone moyenne (ville)
zoom = 13.0;
} else if (latSpan < 0.2 || lngSpan < 0.2) {
// Grande zone (agglomération)
zoom = 11.0;
} else if (latSpan < 0.5 || lngSpan < 0.5) {
// Très grande zone (département)
zoom = 9.0;
} else if (latSpan < 2.0 || lngSpan < 2.0) {
// Région
zoom = 7.0;
} else if (latSpan < 5.0 || lngSpan < 5.0) {
// Pays
zoom = 5.0;
} else {
// Continent ou plus
zoom = 3.0;
}
debugPrint('Zoom calculé: $zoom pour zone: lat $latSpan, lng $lngSpan');
return zoom;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Carte
Expanded(
child: Stack(
children: [
// Carte principale utilisant le widget commun MapboxMap
MapboxMap(
initialPosition: _currentPosition,
initialZoom: _currentZoom,
mapController: _mapController,
// Utiliser OpenStreetMap sur mobile, Mapbox sur web
useOpenStreetMap: !kIsWeb,
markers: _buildPassageMarkers(),
polygons: _buildSectorPolygons(),
showControls: false, // Désactiver les contrôles par défaut pour éviter la duplication
onMapEvent: (event) {
if (event is MapEventMove) {
// Mettre à jour la position et le zoom actuels
setState(() {
_currentPosition = event.camera.center;
_currentZoom = event.camera.zoom;
});
}
},
),
// Combobox de sélection de secteurs (si plus d'un secteur)
if (_shouldShowSectorCombobox)
Positioned(
left: 16.0,
top: 16.0,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
width:
220, // Largeur fixe pour accommoder les noms longs
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.location_on,
size: 18, color: Colors.blue),
const SizedBox(width: 8),
Expanded(
child: DropdownButton<int?>(
value: _selectedSectorId,
hint: const Text('Tous les secteurs'),
isExpanded: true,
underline:
Container(), // Supprimer la ligne sous le dropdown
icon: const Icon(Icons.arrow_drop_down,
color: Colors.blue),
items: _sectorItems,
onChanged: (int? sectorId) {
setState(() {
_selectedSectorId = sectorId;
});
if (sectorId != null) {
_centerMapOnSpecificSector(sectorId);
} else {
// Si "Tous les secteurs" est sélectionné
_centerMapOnSectors();
// Recharger tous les passages sans filtrage par secteur
_loadPassages();
}
},
),
),
],
),
),
),
),
// Contrôles de zoom et localisation en bas à droite
Positioned(
bottom: 16.0,
right: 16.0,
child: Column(
children: [
// Bouton zoom +
_buildMapButton(
icon: Icons.add,
onPressed: () {
final newZoom = _currentZoom + 1;
_mapController.move(_currentPosition, newZoom);
setState(() {
_currentZoom = newZoom;
});
_saveSettings();
},
),
const SizedBox(height: 8),
// Bouton zoom -
_buildMapButton(
icon: Icons.remove,
onPressed: () {
final newZoom = _currentZoom - 1;
_mapController.move(_currentPosition, newZoom);
setState(() {
_currentZoom = newZoom;
});
_saveSettings();
},
),
const SizedBox(height: 8),
// Bouton de localisation
_buildMapButton(
icon: Icons.my_location,
onPressed: () {
_getUserLocation();
},
),
],
),
),
// Filtres de type de passage en bas à gauche
Positioned(
bottom: 16.0,
left: 16.0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Filtre Effectués (type 1)
_buildFilterDot(
color: Color(AppKeys.typesPassages[1]?['couleur2'] as int),
selected: _showEffectues,
onTap: () {
setState(() {
_showEffectues = !_showEffectues;
_loadPassages();
_saveSettings();
});
},
),
const SizedBox(width: 6),
// Filtre À finaliser (type 2)
_buildFilterDot(
color: Color(AppKeys.typesPassages[2]?['couleur2'] as int),
selected: _showAFinaliser,
onTap: () {
setState(() {
_showAFinaliser = !_showAFinaliser;
_loadPassages();
_saveSettings();
});
},
),
const SizedBox(width: 6),
// Filtre Refusés (type 3)
_buildFilterDot(
color: Color(AppKeys.typesPassages[3]?['couleur2'] as int),
selected: _showRefuses,
onTap: () {
setState(() {
_showRefuses = !_showRefuses;
_loadPassages();
_saveSettings();
});
},
),
const SizedBox(width: 6),
// Filtre Dons (type 4)
_buildFilterDot(
color: Color(AppKeys.typesPassages[4]?['couleur2'] as int),
selected: _showDons,
onTap: () {
setState(() {
_showDons = !_showDons;
_loadPassages();
_saveSettings();
});
},
),
const SizedBox(width: 6),
// Filtre Lots (type 5)
_buildFilterDot(
color: Color(AppKeys.typesPassages[5]?['couleur2'] as int),
selected: _showLots,
onTap: () {
setState(() {
_showLots = !_showLots;
_loadPassages();
_saveSettings();
});
},
),
const SizedBox(width: 6),
// Filtre Maisons vides (type 6)
_buildFilterDot(
color: Color(AppKeys.typesPassages[6]?['couleur2'] as int),
selected: _showMaisonsVides,
onTap: () {
setState(() {
_showMaisonsVides = !_showMaisonsVides;
_loadPassages();
_saveSettings();
});
},
),
],
),
),
),
],
),
),
],
),
),
);
}
// Construire une pastille de filtre pour la carte
Widget _buildFilterDot({
required Color color,
required bool selected,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: selected ? color : color.withValues(alpha: 0.3),
shape: BoxShape.circle,
border: Border.all(
color: selected ? Colors.white : Colors.white.withValues(alpha: 0.5),
width: 1.5,
),
),
),
);
}
// Construction d'un bouton de carte personnalisé
Widget _buildMapButton({
required IconData icon,
required VoidCallback onPressed,
}) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: IconButton(
icon: Icon(icon, size: 20),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
color: Colors.blue,
),
);
}
// Construire les marqueurs pour les passages
List<Marker> _buildPassageMarkers() {
return _passages.map((passage) {
final PassageModel passageModel = passage['model'] as PassageModel;
final bool hasNoSector = passageModel.fkSector == null;
// Si le passage n'a pas de secteur, on met une bordure rouge épaisse
final Color borderColor = hasNoSector ? Colors.red : Colors.white;
final double borderWidth = hasNoSector ? 3.0 : 1.0;
return Marker(
point: passage['position'] as LatLng,
width: hasNoSector ? 18.0 : 14.0, // Plus grand si orphelin
height: hasNoSector ? 18.0 : 14.0,
child: GestureDetector(
onTap: () {
_showPassageInfo(passage);
},
child: Container(
decoration: BoxDecoration(
color: passage['color'] as Color,
shape: BoxShape.circle,
border: Border.all(
color: borderColor,
width: borderWidth,
),
),
),
),
);
}).toList();
}
// Construire les polygones pour les secteurs
List<Polygon> _buildSectorPolygons() {
return _sectors.map((sector) {
return Polygon(
points: sector['points'] as List<LatLng>,
color: (sector['color'] as Color).withValues(alpha: 0.3),
borderColor: (sector['color'] as Color).withValues(alpha: 1.0),
borderStrokeWidth: 2.0,
);
}).toList();
}
// Méthode pour mettre à jour la position sur la carte
void _updateMapPosition(LatLng position, {double? zoom}) {
_mapController.move(
position,
zoom ?? _mapController.camera.zoom,
);
// Mettre à jour les variables d'état
setState(() {
_currentPosition = position;
if (zoom != null) {
_currentZoom = zoom;
}
});
// Sauvegarder les paramètres après mise à jour de la position
_saveSettings();
}
// Afficher les informations d'un passage lorsqu'on clique dessus
void _showPassageInfo(Map<String, dynamic> passage) {
final PassageModel passageModel = passage['model'] as PassageModel;
showDialog(
context: context,
builder: (context) => PassageMapDialog(
passage: passageModel,
isAdmin: false, // L'utilisateur n'est pas admin
onDeleted: () {
// Recharger les passages après suppression
_loadPassages();
},
),
);
}
}

View File

@@ -1,383 +0,0 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
class UserStatisticsPage extends StatefulWidget {
const UserStatisticsPage({super.key});
@override
State<UserStatisticsPage> createState() => _UserStatisticsPageState();
}
class _UserStatisticsPageState extends State<UserStatisticsPage> {
// Période sélectionnée
String _selectedPeriod = 'Semaine';
// Secteur sélectionné (0 = tous les secteurs)
int _selectedSectorId = 0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Filtres
_buildFilters(theme, isDesktop),
const SizedBox(height: 24),
// Graphiques
_buildCharts(theme),
const SizedBox(height: 24),
// Résumé par type de passage
_buildPassageTypeSummary(theme, isDesktop),
const SizedBox(height: 24),
// Résumé par type de règlement
_buildPaymentTypeSummary(theme, isDesktop),
],
),
),
),
);
}
// Construction des filtres
Widget _buildFilters(ThemeData theme, bool isDesktop) {
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(
'Filtres',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
// Sélection de la période
_buildFilterSection(
'Période',
['Jour', 'Semaine', 'Mois', 'Année'],
_selectedPeriod,
(value) {
setState(() {
_selectedPeriod = value;
});
},
theme,
),
// Sélection du secteur (si l'utilisateur a plusieurs secteurs)
_buildSectorSelector(context, theme),
// Bouton d'application des filtres
ElevatedButton.icon(
onPressed: () {
// Actualiser les statistiques avec les filtres sélectionnés
setState(() {
// Dans une implémentation réelle, on chargerait ici les données
// filtrées par période et secteur
});
},
icon: const Icon(Icons.filter_list),
label: const Text('Appliquer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
],
),
],
),
),
);
}
// Construction du sélecteur de secteur
Widget _buildSectorSelector(BuildContext context, ThemeData theme) {
// Utiliser l'instance globale définie dans app.dart
// Récupérer les secteurs de l'utilisateur
final sectors = userRepository.getUserSectors();
// Si l'utilisateur n'a qu'un seul secteur, ne pas afficher le sélecteur
if (sectors.length <= 1) {
return const SizedBox.shrink();
}
// Créer la liste des options avec "Tous" comme première option
final List<DropdownMenuItem<int>> items = [
const DropdownMenuItem<int>(
value: 0,
child: Text('Tous les secteurs'),
),
];
// Ajouter les secteurs de l'utilisateur
for (final sector in sectors) {
items.add(
DropdownMenuItem<int>(
value: sector.id,
child: Text(sector.libelle),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Secteur',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxWidth: 250),
child: DropdownButton<int>(
value: _selectedSectorId,
isExpanded: true,
items: items,
onChanged: (value) {
if (value != null) {
setState(() {
_selectedSectorId = value;
});
}
},
hint: const Text('Sélectionner un secteur'),
),
),
],
);
}
// Construction d'une section de filtre
Widget _buildFilterSection(
String title,
List<String> options,
String selectedValue,
Function(String) onChanged,
ThemeData theme,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: options.map((option) {
return ButtonSegment<String>(
value: option,
label: Text(option),
);
}).toList(),
selected: {selectedValue},
onSelectionChanged: (Set<String> selection) {
onChanged(selection.first);
},
style: ButtonStyle(
backgroundColor: WidgetStateProperty.resolveWith<Color>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return AppTheme.secondaryColor;
}
return theme.colorScheme.surface;
},
),
foregroundColor: WidgetStateProperty.resolveWith<Color>(
(Set<WidgetState> states) {
if (states.contains(WidgetState.selected)) {
return Colors.white;
}
return theme.colorScheme.onSurface;
},
),
),
),
],
);
}
// Construction des graphiques
Widget _buildCharts(ThemeData theme) {
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(
'Passages et règlements par $_selectedPeriod',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
SizedBox(
height: 300,
child: _buildActivityChart(theme),
),
],
),
),
);
}
// Construction du graphique d'activité
Widget _buildActivityChart(ThemeData theme) {
// Générer des données fictives pour les passages
final now = DateTime.now();
final List<Map<String, dynamic>> passageData = [];
// Récupérer le secteur sélectionné (si applicable)
final String sectorLabel = _selectedSectorId == 0
? 'Tous les secteurs'
: userRepository.getSectorById(_selectedSectorId)?.libelle ??
'Secteur inconnu';
// Déterminer la plage de dates en fonction de la période sélectionnée
DateTime startDate;
int daysToGenerate;
switch (_selectedPeriod) {
case 'Jour':
startDate = DateTime(now.year, now.month, now.day);
daysToGenerate = 1;
break;
case 'Semaine':
// Début de la semaine (lundi)
final weekday = now.weekday;
startDate = now.subtract(Duration(days: weekday - 1));
daysToGenerate = 7;
break;
case 'Mois':
// Début du mois
startDate = DateTime(now.year, now.month, 1);
// Calculer le nombre de jours dans le mois
final lastDayOfMonth = DateTime(now.year, now.month + 1, 0).day;
daysToGenerate = lastDayOfMonth;
break;
case 'Année':
// Début de l'année
startDate = DateTime(now.year, 1, 1);
daysToGenerate = 365;
break;
default:
startDate = DateTime(now.year, now.month, now.day);
daysToGenerate = 7;
}
// Générer des données pour la période sélectionnée
for (int i = 0; i < daysToGenerate; i++) {
final date = startDate.add(Duration(days: i));
// Générer des données pour chaque type de passage
for (int typeId = 1; typeId <= 6; typeId++) {
// Générer un nombre de passages basé sur le jour et le type
final count = (typeId == 1 || typeId == 2)
? (2 + (date.day % 6)) // Plus de passages pour les types 1 et 2
: (date.day % 4); // Moins pour les autres types
if (count > 0) {
passageData.add({
'date': date.toIso8601String(),
'type_passage': typeId,
'nb': count,
});
}
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Afficher le secteur sélectionné si ce n'est pas "Tous"
if (_selectedSectorId != 0)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
'Secteur: $sectorLabel',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
ActivityChart(
passageData: passageData,
periodType: _selectedPeriod,
height: 300,
),
],
);
}
// Construction du résumé par type de passage
Widget _buildPassageTypeSummary(ThemeData theme, bool isDesktop) {
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) {
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,
);
}
}

View File

@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
import 'package:geosector_app/app.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;
}
/// Scaffold partagé pour toutes les pages d'administration
/// Fournit le fond dégradé et la navigation commune
class AdminScaffold extends StatelessWidget {
/// Le contenu de la page
final Widget body;
/// L'index de navigation sélectionné
final int selectedIndex;
/// Le titre de la page
final String pageTitle;
/// Callback optionnel pour gérer la navigation personnalisée
final Function(int)? onDestinationSelected;
const AdminScaffold({
super.key,
required this.body,
required this.selectedIndex,
required this.pageTitle,
this.onDestinationSelected,
});
@override
Widget build(BuildContext context) {
final currentUser = userRepository.getCurrentUser();
final size = MediaQuery.of(context).size;
final isMobile = size.width <= 900;
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 avec navigation
DashboardLayout(
key: ValueKey('dashboard_layout_$selectedIndex'),
title: 'Tableau de bord Administration',
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected ?? (index) {
// Navigation par défaut si pas de callback personnalisé
AdminNavigationHelper.navigateToIndex(context, index);
},
destinations: AdminNavigationHelper.getDestinations(
currentUser: currentUser,
isMobile: isMobile,
),
isAdmin: true,
body: body,
),
],
);
}
}
/// Helper pour centraliser la logique de navigation admin
class AdminNavigationHelper {
/// Obtenir la liste des destinations de navigation selon le rôle et le device
static List<NavigationDestination> getDestinations({
required dynamic currentUser,
required bool isMobile,
}) {
final destinations = <NavigationDestination>[
// Pages de base toujours visibles
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',
),
createBadgedNavigationDestination(
icon: const Icon(Icons.chat_outlined),
selectedIcon: const Icon(Icons.chat),
label: 'Messages',
showBadge: true,
),
const NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
];
// Ajouter les pages admin (role 2) seulement sur desktop
if (currentUser?.role == 2 && !isMobile) {
destinations.addAll([
const NavigationDestination(
icon: Icon(Icons.business_outlined),
selectedIcon: Icon(Icons.business),
label: 'Amicale & membres',
),
const NavigationDestination(
icon: Icon(Icons.calendar_today_outlined),
selectedIcon: Icon(Icons.calendar_today),
label: 'Opérations',
),
]);
}
return destinations;
}
/// Naviguer vers une page selon l'index
static void navigateToIndex(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/admin');
break;
case 1:
context.go('/admin/statistics');
break;
case 2:
context.go('/admin/history');
break;
case 3:
context.go('/admin/messages');
break;
case 4:
context.go('/admin/map');
break;
case 5:
context.go('/admin/amicale');
break;
case 6:
context.go('/admin/operations');
break;
default:
context.go('/admin');
}
}
/// Obtenir l'index selon la route actuelle
static int getIndexFromRoute(String route) {
if (route.contains('/statistics')) return 1;
if (route.contains('/history')) return 2;
if (route.contains('/messages')) return 3;
if (route.contains('/map')) return 4;
if (route.contains('/amicale')) return 5;
if (route.contains('/operations')) return 6;
return 0; // Dashboard par défaut
}
/// Obtenir le nom de la page selon l'index
static String getPageNameFromIndex(int index) {
switch (index) {
case 0: return 'dashboard';
case 1: return 'statistics';
case 2: return 'history';
case 3: return 'messages';
case 4: return 'map';
case 5: return 'amicale';
case 6: return 'operations';
default: return 'dashboard';
}
}
}

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:typed_data';
import 'dart:convert';
import 'package:flutter_map/flutter_map.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
@@ -62,7 +61,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
bool _chkMdpManuel = false;
bool _chkUsernameManuel = false;
bool _chkUserDeletePass = false;
bool _chkLotActif = false;
// Pour l'upload du logo
final ImagePicker _picker = ImagePicker();
XFile? _selectedImage;
@@ -100,7 +100,8 @@ class _AmicaleFormState extends State<AmicaleForm> {
_chkMdpManuel = amicale?.chkMdpManuel ?? false;
_chkUsernameManuel = amicale?.chkUsernameManuel ?? false;
_chkUserDeletePass = amicale?.chkUserDeletePass ?? false;
_chkLotActif = amicale?.chkLotActif ?? false;
// Note : Le logo sera chargé dynamiquement depuis l'API
// Initialiser le service Stripe si API disponible
@@ -314,6 +315,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
'chk_mdp_manuel': amicale.chkMdpManuel ? 1 : 0,
'chk_username_manuel': amicale.chkUsernameManuel ? 1 : 0,
'chk_user_delete_pass': amicale.chkUserDeletePass ? 1 : 0,
'chk_lot_actif': amicale.chkLotActif ? 1 : 0,
};
// Ajouter les champs réservés aux administrateurs si l'utilisateur est admin
@@ -564,6 +566,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
chkUserDeletePass: _chkUserDeletePass,
chkLotActif: _chkLotActif,
) ??
AmicaleModel(
id: 0, // Sera remplacé par l'API
@@ -588,6 +591,7 @@ class _AmicaleFormState extends State<AmicaleForm> {
chkMdpManuel: _chkMdpManuel,
chkUsernameManuel: _chkUsernameManuel,
chkUserDeletePass: _chkUserDeletePass,
chkLotActif: _chkLotActif,
);
debugPrint('🔧 AmicaleModel créé: ${amicale.name}');
@@ -1392,6 +1396,20 @@ class _AmicaleFormState extends State<AmicaleForm> {
});
},
),
const SizedBox(height: 8),
// Checkbox pour activer le mode Lot
_buildCheckboxOption(
label: "Activer le mode Lot (distributions groupées)",
value: _chkLotActif,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkLotActif = value!;
});
},
),
const SizedBox(height: 25),
// Boutons Fermer et Enregistrer
@@ -1461,12 +1479,13 @@ class _AmicaleFormState extends State<AmicaleForm> {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Note : Utilise le rôle RÉEL pour les permissions d'édition (pas le mode d'affichage)
final userRole = widget.userRepository.getUserRole();
// Déterminer si l'utilisateur peut modifier les champs restreints
// Déterminer si l'utilisateur peut modifier les champs restreints (super admin uniquement)
final bool canEditRestrictedFields = userRole > 2;
// Pour Stripe, les admins d'amicale (rôle 2) peuvent aussi configurer
// Pour Stripe, les admins d'amicale (rôle 2) et super admins peuvent configurer
final bool canEditStripe = userRole >= 2;
// Lecture seule pour les champs restreints si l'utilisateur n'a pas les droits

View File

@@ -0,0 +1,416 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/services/current_user_service.dart';
import 'package:geosector_app/app.dart';
import 'dart:math' as math;
/// Classe 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;
}
/// Scaffold unifié pour toutes les pages (admin et user)
/// Adapte automatiquement son apparence selon le rôle de l'utilisateur
class AppScaffold extends StatelessWidget {
/// Le contenu de la page
final Widget body;
/// L'index de navigation sélectionné
final int selectedIndex;
/// Le titre de la page
final String pageTitle;
/// Callback optionnel pour gérer la navigation personnalisée
final Function(int)? onDestinationSelected;
/// Forcer le mode admin (optionnel, sinon détecte automatiquement)
final bool? forceAdmin;
/// Afficher ou non le fond dégradé avec points (économise des ressources si désactivé)
final bool showBackground;
const AppScaffold({
super.key,
required this.body,
required this.selectedIndex,
required this.pageTitle,
this.onDestinationSelected,
this.forceAdmin,
this.showBackground = true,
});
@override
Widget build(BuildContext context) {
final currentUser = userRepository.getCurrentUser();
final size = MediaQuery.of(context).size;
final isMobile = size.width <= 900;
// Déterminer si l'utilisateur est admin (prend en compte le mode d'affichage)
final userRole = currentUser?.role ?? 1;
final isAdmin = forceAdmin ?? CurrentUserService.instance.shouldShowAdminUI;
debugPrint('🎨 AppScaffold: isAdmin=$isAdmin, displayMode=${CurrentUserService.instance.displayMode}, userRole=$userRole');
// Pour les utilisateurs standards, vérifier les conditions d'accès
if (!isAdmin) {
final hasOperation = userRepository.getCurrentOperation() != null;
final hasSectors = userRepository.getUserSectors().isNotEmpty;
// Si pas d'opération, afficher le message approprié
if (!hasOperation) {
return _buildRestrictedAccess(
context: context,
icon: Icons.warning_outlined,
title: 'Aucune opération assignée',
message: 'Vous n\'avez pas encore été affecté à une opération. '
'Veuillez contacter votre administrateur pour obtenir un accès.',
isAdmin: false,
);
}
// Si pas de secteur, afficher le message approprié
if (!hasSectors) {
return _buildRestrictedAccess(
context: context,
icon: Icons.map_outlined,
title: 'Aucun secteur assigné',
message: 'Vous n\'êtes affecté sur aucun secteur. '
'Contactez votre administrateur pour qu\'il vous en affecte au moins un.',
isAdmin: false,
);
}
}
// Couleurs de fond selon le rôle
final gradientColors = isAdmin
? [Colors.white, Colors.red.shade300] // Admin: dégradé rouge
: [Colors.white, Colors.green.shade300]; // User: dégradé vert
// Titre avec suffixe selon le rôle
final dashboardTitle = isAdmin
? 'Tableau de bord Administration'
: 'GEOSECTOR';
return Stack(
children: [
// Fond dégradé avec petits points blancs (optionnel)
if (showBackground)
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: gradientColors,
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page avec navigation
DashboardLayout(
key: ValueKey('dashboard_layout_${isAdmin ? 'admin' : 'user'}_$selectedIndex'),
title: dashboardTitle,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected ?? (index) {
NavigationHelper.navigateToIndex(context, index, isAdmin);
},
destinations: NavigationHelper.getDestinations(
isAdmin: isAdmin,
isMobile: isMobile,
),
isAdmin: isAdmin,
body: body,
),
],
);
}
/// Construit l'écran d'accès restreint
Widget _buildRestrictedAccess({
required BuildContext context,
required IconData icon,
required String title,
required String message,
required bool isAdmin,
}) {
final theme = Theme.of(context);
// Utiliser le même fond que pour un utilisateur normal (vert)
final gradientColors = isAdmin
? [Colors.white, Colors.red.shade300]
: [Colors.white, Colors.green.shade300];
return Stack(
children: [
// Fond dégradé (optionnel)
if (showBackground)
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: gradientColors,
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox(width: double.infinity, height: double.infinity),
),
),
// Message d'accès restreint
DashboardLayout(
title: 'GEOSECTOR',
selectedIndex: 0,
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',
),
],
isAdmin: false,
body: Center(
child: Container(
padding: const EdgeInsets.all(24),
constraints: const BoxConstraints(maxWidth: 500),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 80,
color: theme.colorScheme.error,
),
const SizedBox(height: 24),
Text(
title,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
message,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
],
),
),
),
),
],
);
}
}
/// Helper centralisé pour la navigation
class NavigationHelper {
/// Obtenir la liste des destinations selon le mode d'affichage et le device
static List<NavigationDestination> getDestinations({
required bool isAdmin,
required bool isMobile,
}) {
final destinations = <NavigationDestination>[];
// Pages communes à tous les rôles
destinations.addAll([
const NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
const NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
createBadgedNavigationDestination(
icon: const Icon(Icons.chat_outlined),
selectedIcon: const Icon(Icons.chat),
label: 'Messages',
showBadge: true,
),
]);
// Pages spécifiques aux utilisateurs standards
if (!isAdmin) {
destinations.add(
const NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: 'Terrain',
),
);
}
// Pages spécifiques aux admins (seulement sur desktop)
if (isAdmin && !isMobile) {
destinations.addAll([
const NavigationDestination(
icon: Icon(Icons.business_outlined),
selectedIcon: Icon(Icons.business),
label: 'Amicale & membres',
),
const NavigationDestination(
icon: Icon(Icons.calendar_today_outlined),
selectedIcon: Icon(Icons.calendar_today),
label: 'Opérations',
),
]);
}
return destinations;
}
/// Naviguer vers une page selon l'index et le rôle
static void navigateToIndex(BuildContext context, int index, bool isAdmin) {
if (isAdmin) {
_navigateAdminIndex(context, index);
} else {
_navigateUserIndex(context, index);
}
}
/// Navigation pour les admins
static void _navigateAdminIndex(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/admin');
break;
case 1:
context.go('/admin/history');
break;
case 2:
context.go('/admin/map');
break;
case 3:
context.go('/admin/messages');
break;
case 4:
context.go('/admin/amicale');
break;
case 5:
context.go('/admin/operations');
break;
default:
context.go('/admin');
}
}
/// Navigation pour les utilisateurs standards
static void _navigateUserIndex(BuildContext context, int index) {
switch (index) {
case 0:
context.go('/user/dashboard');
break;
case 1:
context.go('/user/history');
break;
case 2:
context.go('/user/map');
break;
case 3:
context.go('/user/messages');
break;
case 4:
context.go('/user/field-mode');
break;
default:
context.go('/user/dashboard');
}
}
/// Obtenir l'index selon la route actuelle et le rôle
static int getIndexFromRoute(String route, bool isAdmin) {
// Enlever les paramètres de query si présents
final cleanRoute = route.split('?').first;
if (isAdmin) {
if (cleanRoute.contains('/admin/history')) return 1;
if (cleanRoute.contains('/admin/map')) return 2;
if (cleanRoute.contains('/admin/messages')) return 3;
if (cleanRoute.contains('/admin/amicale')) return 4;
if (cleanRoute.contains('/admin/operations')) return 5;
return 0; // Dashboard par défaut
} else {
if (cleanRoute.contains('/user/history')) return 1;
if (cleanRoute.contains('/user/map')) return 2;
if (cleanRoute.contains('/user/messages')) return 3;
if (cleanRoute.contains('/user/field-mode')) return 4;
return 0; // Dashboard par défaut
}
}
/// Obtenir le nom de la page selon l'index et le rôle
static String getPageNameFromIndex(int index, bool isAdmin) {
if (isAdmin) {
switch (index) {
case 0: return 'dashboard';
case 1: return 'history';
case 2: return 'map';
case 3: return 'messages';
case 4: return 'amicale';
case 5: return 'operations';
default: return 'dashboard';
}
} else {
switch (index) {
case 0: return 'dashboard';
case 1: return 'history';
case 2: return 'map';
case 3: return 'messages';
case 4: return 'field-mode';
default: return 'dashboard';
}
}
}
}

View File

@@ -6,6 +6,7 @@ import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/user_sector_model.dart';
/// Widget de graphique d'activité affichant les passages
class ActivityChart extends StatefulWidget {
@@ -183,9 +184,15 @@ class _ActivityChartState extends State<ActivityChart>
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);
// Pour les users : récupérer les secteurs assignés
Set<int>? userSectorIds;
if (!widget.showAllPassages && currentUser != null) {
final userSectorBox = Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
userSectorIds = userSectorBox.values
.where((us) => us.id == currentUser.id)
.map((us) => us.fkSector)
.toSet();
}
// Calculer la date de début (nombre de jours en arrière)
final endDate = DateTime.now();
@@ -213,8 +220,8 @@ class _ActivityChartState extends State<ActivityChart>
// Appliquer les filtres
bool shouldInclude = true;
// Filtrer par utilisateur si nécessaire
if (targetUserId != null && passage.fkUser != targetUserId) {
// Filtrer par secteurs assignés si nécessaire (pour les users)
if (userSectorIds != null && !userSectorIds.contains(passage.fkSector)) {
shouldInclude = false;
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show listEquals;
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
@@ -157,7 +158,23 @@ class _PassagePieChartState extends State<PassagePieChart>
/// Construction du widget avec des données statiques (ancien système)
Widget _buildWithStaticData() {
final chartData = _prepareChartDataFromMap(widget.passagesByType);
// Vérifier si le type Lot doit être affiché
bool showLotType = true;
final currentUser = CurrentUserService.instance.currentUser;
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
showLotType = userAmicale.chkLotActif;
}
}
// Filtrer les données pour exclure le type 5 si nécessaire
Map<int, int> filteredData = Map.from(widget.passagesByType);
if (!showLotType) {
filteredData.remove(5);
}
final chartData = _prepareChartDataFromMap(filteredData);
return _buildChart(chartData);
}
@@ -167,25 +184,38 @@ class _PassagePieChartState extends State<PassagePieChart>
final passages = passagesBox.values.toList();
final currentUser = userRepository.getCurrentUser();
// Vérifier si le type Lot doit être affiché
bool showLotType = true;
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
showLotType = userAmicale.chkLotActif;
}
}
// Calculer les données selon les filtres
final Map<int, int> passagesByType = {};
// Initialiser tous les types de passage possibles
for (final typeId in AppKeys.typesPassages.keys) {
// Exclure le type Lot (5) si chkLotActif = false
if (typeId == 5 && !showLotType) {
continue;
}
if (!widget.excludePassageTypes.contains(typeId)) {
passagesByType[typeId] = 0;
}
}
// L'API filtre déjà les passages côté serveur
// On compte simplement tous les passages de la box
for (final passage in passages) {
// Appliquer les filtres
// Appliquer les filtres locaux uniquement
bool shouldInclude = true;
// Filtrer par utilisateur si nécessaire
if (!widget.showAllPassages && widget.userId != null) {
// Filtrer par userId si spécifié (cas particulier pour compatibilité)
if (widget.userId != null) {
shouldInclude = passage.fkUser == widget.userId;
} else if (!widget.showAllPassages && currentUser != null) {
shouldInclude = passage.fkUser == currentUser.id;
}
// Exclure certains types
@@ -193,6 +223,11 @@ class _PassagePieChartState extends State<PassagePieChart>
shouldInclude = false;
}
// Exclure le type Lot (5) si chkLotActif = false
if (passage.fkType == 5 && !showLotType) {
shouldInclude = false;
}
if (shouldInclude) {
passagesByType[passage.fkType] =
(passagesByType[passage.fkType] ?? 0) + 1;
@@ -211,8 +246,23 @@ class _PassagePieChartState extends State<PassagePieChart>
Map<int, int> passagesByType) {
final List<PassageChartData> chartData = [];
// Vérifier si le type Lot doit être affiché
bool showLotType = true;
final currentUser = CurrentUserService.instance.currentUser;
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
showLotType = userAmicale.chkLotActif;
}
}
// Créer les données du graphique
passagesByType.forEach((typeId, count) {
// Exclure le type Lot (5) si chkLotActif = false
if (typeId == 5 && !showLotType) {
return; // Skip ce type
}
// Vérifier que le type existe et que le compteur est positif
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
final typeInfo = AppKeys.typesPassages[typeId]!;

View File

@@ -75,6 +75,39 @@ class PassageSummaryCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
if (useValueListenable) {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
// Calculer les données une seule fois
final passagesCounts = _calculatePassagesCounts(passagesBox);
final totalUserPassages = passagesCounts.values.fold(0, (sum, count) => sum + count);
return _buildCardContent(
context,
totalUserPassages: totalUserPassages,
passagesCounts: passagesCounts,
);
},
);
} else {
// Données statiques
final totalPassages = passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
return _buildCardContent(
context,
totalUserPassages: totalPassages,
passagesCounts: passagesByType ?? {},
);
}
}
/// Construit le contenu de la card avec les données calculées
Widget _buildCardContent(
BuildContext context, {
required int totalUserPassages,
required Map<int, int> passagesCounts,
}) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
@@ -102,9 +135,7 @@ class PassageSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre avec comptage
useValueListenable
? _buildTitleWithValueListenable()
: _buildTitleWithStaticData(context),
_buildTitle(context, totalUserPassages),
const Divider(height: 24),
// Contenu principal
Expanded(
@@ -115,9 +146,7 @@ class PassageSummaryCard extends StatelessWidget {
// Liste des passages à gauche
Expanded(
flex: isDesktop ? 1 : 2,
child: useValueListenable
? _buildPassagesListWithValueListenable()
: _buildPassagesListWithStaticData(context),
child: _buildPassagesList(context, passagesCounts),
),
// Séparateur vertical
@@ -129,9 +158,10 @@ class PassageSummaryCard extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PassagePieChart(
useValueListenable: useValueListenable,
passagesByType: passagesByType ?? {},
useValueListenable: false, // Utilise les données calculées
passagesByType: passagesCounts,
excludePassageTypes: excludePassageTypes,
showAllPassages: showAllPassages,
userId: showAllPassages ? null : userId,
size: double.infinity,
labelSize: 12,
@@ -155,53 +185,8 @@ class PassageSummaryCard extends StatelessWidget {
);
}
/// 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: TextStyle(
fontSize: AppTheme.r(context, 16),
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(totalUserPassages) ??
totalUserPassages.toString(),
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
},
);
}
/// Construction du titre avec données statiques
Widget _buildTitleWithStaticData(BuildContext context) {
final totalPassages =
passagesByType?.values.fold(0, (sum, count) => sum + count) ?? 0;
/// Construction du titre
Widget _buildTitle(BuildContext context, int totalUserPassages) {
return Row(
children: [
if (titleIcon != null) ...[
@@ -222,7 +207,8 @@ class PassageSummaryCard extends StatelessWidget {
),
),
Text(
customTotalDisplay?.call(totalPassages) ?? totalPassages.toString(),
customTotalDisplay?.call(totalUserPassages) ??
totalUserPassages.toString(),
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold,
@@ -233,30 +219,28 @@ class PassageSummaryCard extends StatelessWidget {
);
}
/// 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(context, passagesCounts);
},
);
}
/// Construction de la liste des passages avec données statiques
Widget _buildPassagesListWithStaticData(BuildContext context) {
return _buildPassagesList(context, passagesByType ?? {});
}
/// Construction de la liste des passages
Widget _buildPassagesList(BuildContext context, Map<int, int> passagesCounts) {
// Vérifier si le type Lot doit être affiché
bool showLotType = true;
final currentUser = userRepository.getCurrentUser();
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
showLotType = userAmicale.chkLotActif;
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...AppKeys.typesPassages.entries.map((entry) {
...AppKeys.typesPassages.entries.where((entry) {
// Exclure le type Lot (5) si chkLotActif = false
if (entry.key == 5 && !showLotType) {
return false;
}
return true;
}).map((entry) {
final int typeId = entry.key;
final Map<String, dynamic> typeData = entry.value;
final int count = passagesCounts[typeId] ?? 0;
@@ -303,54 +287,45 @@ class PassageSummaryCard extends StatelessWidget {
);
}
/// 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 = {};
// Vérifier si le type Lot doit être affiché
bool showLotType = true;
final currentUser = userRepository.getCurrentUser();
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
showLotType = userAmicale.chkLotActif;
}
}
// Initialiser tous les types
for (final typeId in AppKeys.typesPassages.keys) {
// Exclure le type Lot (5) si chkLotActif = false
if (typeId == 5 && !showLotType) {
continue;
}
// Exclure les types non désirés
if (excludePassageTypes.contains(typeId)) {
continue;
}
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;
// L'API filtre déjà les passages côté serveur
// On compte simplement tous les passages de la box
for (final passage in passagesBox.values) {
// Exclure le type Lot (5) si chkLotActif = false
if (passage.fkType == 5 && !showLotType) {
continue;
}
} 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;
}
}
// Exclure les types non désirés
if (excludePassageTypes.contains(passage.fkType)) {
continue;
}
counts[passage.fkType] = (counts[passage.fkType] ?? 0) + 1;
}
return counts;

View File

@@ -163,11 +163,6 @@ class _PaymentPieChartState extends State<PaymentPieChart>
try {
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);
// Initialiser les montants par type de règlement
final Map<int, double> paymentAmounts = {
@@ -177,37 +172,38 @@ class _PaymentPieChartState extends State<PaymentPieChart>
3: 0.0, // CB
};
// Parcourir les passages et calculer les montants par type de règlement
// Déterminer le filtre utilisateur : en mode user, on filtre par fkUser
final int? filterUserId = widget.showAllPassages
? null
: (widget.userId ?? currentUser?.id);
for (final passage in passages) {
// Appliquer le filtre utilisateur si nécessaire
bool shouldInclude = true;
if (targetUserId != null && passage.fkUser != targetUserId) {
shouldInclude = false;
// En mode user, ne compter que les passages de l'utilisateur
if (filterUserId != null && passage.fkUser != filterUserId) {
continue;
}
if (shouldInclude) {
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}');
}
final int typeReglement = passage.fkTypeReglement;
// 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 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;
}
}
}

View File

@@ -72,6 +72,39 @@ class PaymentSummaryCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
// Si useValueListenable, construire avec ValueListenableBuilder centralisé
if (useValueListenable) {
return ValueListenableBuilder(
valueListenable: Hive.box<PassageModel>(AppKeys.passagesBoxName).listenable(),
builder: (context, Box<PassageModel> passagesBox, child) {
// Calculer les données une seule fois
final paymentAmounts = _calculatePaymentAmounts(passagesBox);
final totalAmount = paymentAmounts.values.fold(0.0, (sum, amount) => sum + amount);
return _buildCardContent(
context,
totalAmount: totalAmount,
paymentAmounts: paymentAmounts,
);
},
);
} else {
// Données statiques
final totalAmount = paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
return _buildCardContent(
context,
totalAmount: totalAmount,
paymentAmounts: paymentsByType ?? {},
);
}
}
/// Construit le contenu de la card avec les données calculées
Widget _buildCardContent(
BuildContext context, {
required double totalAmount,
required Map<int, double> paymentAmounts,
}) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
@@ -99,9 +132,7 @@ class PaymentSummaryCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre avec comptage
useValueListenable
? _buildTitleWithValueListenable()
: _buildTitleWithStaticData(context),
_buildTitle(context, totalAmount),
const Divider(height: 24),
// Contenu principal
Expanded(
@@ -112,9 +143,7 @@ class PaymentSummaryCard extends StatelessWidget {
// Liste des règlements à gauche
Expanded(
flex: isDesktop ? 1 : 2,
child: useValueListenable
? _buildPaymentsListWithValueListenable()
: _buildPaymentsListWithStaticData(context),
child: _buildPaymentsList(context, paymentAmounts),
),
// Séparateur vertical
@@ -126,11 +155,9 @@ class PaymentSummaryCard extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PaymentPieChart(
useValueListenable: useValueListenable,
payments: useValueListenable
? []
: _convertMapToPaymentData(
paymentsByType ?? {}),
useValueListenable: false, // Utilise les données calculées
payments: _convertMapToPaymentData(paymentAmounts),
showAllPassages: showAllPayments,
userId: showAllPayments ? null : userId,
size: double.infinity,
labelSize: 12,
@@ -158,53 +185,8 @@ class PaymentSummaryCard extends StatelessWidget {
);
}
/// 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: TextStyle(
fontSize: AppTheme.r(context, 16),
fontWeight: FontWeight.bold,
),
),
),
Text(
customTotalDisplay?.call(paymentStats['totalAmount']) ??
'${paymentStats['totalAmount'].toStringAsFixed(2)}',
style: TextStyle(
fontSize: AppTheme.r(context, 20),
fontWeight: FontWeight.bold,
color: titleColor,
),
),
],
);
},
);
}
/// Construction du titre avec données statiques
Widget _buildTitleWithStaticData(BuildContext context) {
final totalAmount =
paymentsByType?.values.fold(0.0, (sum, amount) => sum + amount) ?? 0.0;
/// Construction du titre
Widget _buildTitle(BuildContext context, double totalAmount) {
return Row(
children: [
if (titleIcon != null) ...[
@@ -237,24 +219,6 @@ class PaymentSummaryCard extends StatelessWidget {
);
}
/// 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(context, paymentAmounts);
},
);
}
/// Construction de la liste des règlements avec données statiques
Widget _buildPaymentsListWithStaticData(BuildContext context) {
return _buildPaymentsList(context, paymentsByType ?? {});
}
/// Construction de la liste des règlements
Widget _buildPaymentsList(BuildContext context, Map<int, double> paymentAmounts) {
return Column(
@@ -307,70 +271,6 @@ class PaymentSummaryCard extends StatelessWidget {
);
}
/// 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 = {};
@@ -380,57 +280,33 @@ class PaymentSummaryCard extends StatelessWidget {
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;
// En mode user, filtrer uniquement les passages créés par l'utilisateur (fkUser)
final currentUser = userRepository.getCurrentUser();
final int? filterUserId = showAllPayments ? null : currentUser?.id;
// 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;
}
}
for (final passage in passagesBox.values) {
// En mode user, ne compter que les passages de l'utilisateur
if (filterUserId != null && passage.fkUser != filterUserId) {
continue;
}
} 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;
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
}
// 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;
}
}
}
if (montant > 0) {
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] =
(paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
}
}
}

View File

@@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/dashboard_app_bar.dart';
import 'package:geosector_app/presentation/widgets/responsive_navigation.dart';
import 'package:geosector_app/app.dart'; // Pour accéder à userRepository
import 'package:geosector_app/core/theme/app_theme.dart'; // Pour les couleurs du thème
import 'dart:math' as math;
/// Layout commun pour les tableaux de bord utilisateur et administrateur
/// Combine DashboardAppBar et ResponsiveNavigation
@@ -74,60 +72,33 @@ class DashboardLayout extends StatelessWidget {
);
}
// Déterminer le rôle de l'utilisateur
final currentUser = userRepository.getCurrentUser();
final userRole = currentUser?.role ?? 1;
// Définir les couleurs du gradient selon le rôle
final gradientColors = userRole > 1
? [Colors.white, Colors.red.shade300] // Admin : fond rouge
: [
Colors.white,
AppTheme.accentColor.withValues(alpha: 0.3)
]; // User : fond vert
return Stack(
children: [
// Fond dégradé avec points
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: gradientColors,
),
),
child: CustomPaint(
painter: DotsPainter(),
child: const SizedBox.expand(),
),
),
// Scaffold avec fond transparent
Scaffold(
backgroundColor: Colors.transparent,
appBar: DashboardAppBar(
title: title,
pageTitle: destinations[selectedIndex].label,
isAdmin: isAdmin,
onLogoutPressed: onLogoutPressed,
),
body: ResponsiveNavigation(
title:
title, // Même si le titre n'est pas affiché dans la navigation, il est utilisé pour la cohérence
body: body,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
destinations: destinations,
// Ne pas afficher le bouton "Nouveau passage" dans la navigation
showNewPassageButton: false,
onNewPassagePressed: null,
sidebarBottomItems: sidebarBottomItems,
isAdmin: isAdmin,
// Ne pas afficher l'AppBar dans la navigation car nous utilisons DashboardAppBar
showAppBar: false,
),
),
],
// Scaffold avec fond transparent (le fond est géré par AppScaffold)
return Scaffold(
key: ValueKey('dashboard_scaffold_$selectedIndex'),
backgroundColor: Colors.transparent,
appBar: DashboardAppBar(
key: ValueKey('dashboard_appbar_$selectedIndex'),
title: title,
pageTitle: destinations[selectedIndex].label,
isAdmin: isAdmin,
onLogoutPressed: onLogoutPressed,
),
body: ResponsiveNavigation(
key: ValueKey('responsive_nav_$selectedIndex'),
title:
title, // Même si le titre n'est pas affiché dans la navigation, il est utilisé pour la cohérence
body: body,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
destinations: destinations,
// Ne pas afficher le bouton "Nouveau passage" dans la navigation
showNewPassageButton: false,
onNewPassagePressed: null,
sidebarBottomItems: sidebarBottomItems,
isAdmin: isAdmin,
// Ne pas afficher l'AppBar dans la navigation car nous utilisons DashboardAppBar
showAppBar: false,
),
);
} catch (e) {
debugPrint('ERREUR CRITIQUE dans DashboardLayout.build: $e');
@@ -166,26 +137,3 @@ class DashboardLayout extends StatelessWidget {
}
}
}
/// CustomPainter 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;
}

View File

@@ -1,9 +1,8 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_cache/flutter_map_cache.dart';
import 'package:http_cache_file_store/http_cache_file_store.dart';
import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart';
import 'package:path_provider/path_provider.dart';
import 'package:latlong2/latlong.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
@@ -79,8 +78,8 @@ class _MapboxMapState extends State<MapboxMap> {
// ignore: unused_field
double _currentZoom = 13.0;
/// Provider de cache pour les tuiles
CachedTileProvider? _tileProvider;
/// Provider de tuiles (peut être NetworkTileProvider ou CachedTileProvider)
TileProvider? _tileProvider;
/// Indique si le cache est initialisé
bool _cacheInitialized = false;
@@ -96,18 +95,31 @@ class _MapboxMapState extends State<MapboxMap> {
/// Initialise le cache des tuiles
Future<void> _initializeCache() async {
try {
if (kIsWeb) {
// Pas de cache sur Web (non supporté)
setState(() {
_cacheInitialized = true;
});
return;
}
final dir = await getTemporaryDirectory();
// Utiliser un nom de cache différent selon le provider
final cacheName = widget.useOpenStreetMap ? 'OSMTileCache' : 'MapboxTileCache';
final cacheStore = FileCacheStore('${dir.path}${Platform.pathSeparator}$cacheName');
_tileProvider = CachedTileProvider(
store: cacheStore,
// Configuration du cache
// maxStale permet de servir des tuiles expirées jusqu'à 30 jours
maxStale: const Duration(days: 30),
final cacheDir = '${dir.path}/map_tiles_cache';
// Initialiser le HiveCacheStore
final cacheStore = HiveCacheStore(
cacheDir,
hiveBoxName: 'mapTilesCache',
);
// Initialiser le CachedTileProvider
_tileProvider = CachedTileProvider(
maxStale: const Duration(days: 30),
store: cacheStore,
);
debugPrint('MapboxMap: Cache initialisé dans $cacheDir');
if (mounted) {
setState(() {
_cacheInitialized = true;
@@ -238,6 +250,8 @@ class _MapboxMapState extends State<MapboxMap> {
options: MapOptions(
initialCenter: widget.initialPosition,
initialZoom: widget.initialZoom,
minZoom: 7.0, // Zoom minimum pour éviter que les tuiles ne se chargent pas
maxZoom: 20.0, // Zoom maximum
interactionOptions: InteractionOptions(
enableMultiFingerGestureRace: true,
flags: widget.disableDrag
@@ -265,22 +279,21 @@ class _MapboxMapState extends State<MapboxMap> {
userAgentPackageName: 'app.geosector.fr',
maxNativeZoom: 19,
maxZoom: 20,
minZoom: 1,
// Retirer tileSize pour utiliser la valeur par défaut
// Les additionalOptions ne sont pas nécessaires car le token est dans l'URL
// Utilise le cache si disponible sur web, NetworkTileProvider sur mobile
tileProvider: _cacheInitialized && _tileProvider != null
minZoom: 7,
// Utiliser le cache sur mobile, NetworkTileProvider sur Web
tileProvider: !kIsWeb && _cacheInitialized && _tileProvider != null
? _tileProvider!
: NetworkTileProvider(
headers: {
'User-Agent': 'geosector_app/3.1.3',
'User-Agent': 'geosector_app/3.3.1',
'Accept': '*/*',
},
),
errorTileCallback: (tile, error, stackTrace) {
debugPrint('MapboxMap: Erreur de chargement de tuile: $error');
debugPrint('MapboxMap: Coordonnées de la tuile: ${tile.coordinates}');
debugPrint('MapboxMap: Stack trace: $stackTrace');
// Réduire les logs d'erreur pour ne pas polluer la console
if (!error.toString().contains('abortTrigger')) {
debugPrint('MapboxMap: Erreur de chargement de tuile: $error');
}
},
),

View File

@@ -0,0 +1,899 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:go_router/go_router.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/membre_model.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/repositories/operation_repository.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/app.dart';
/// Widget affichant un tableau détaillé des membres avec leurs statistiques de passages
/// Uniquement visible sur plateforme Web
class MembersBoardPassages extends StatefulWidget {
final String title;
final double? height;
const MembersBoardPassages({
super.key,
this.title = 'Détails par membre',
this.height,
});
@override
State<MembersBoardPassages> createState() => _MembersBoardPassagesState();
}
class _MembersBoardPassagesState extends State<MembersBoardPassages> {
// Repository pour récupérer l'opération courante uniquement
final OperationRepository _operationRepository = operationRepository;
// Vérifier si le type Lot doit être affiché
bool _shouldShowLotType() {
final currentUser = CurrentUserService.instance.currentUser;
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
return userAmicale.chkLotActif;
}
}
return true; // Par défaut, on affiche
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
constraints: BoxConstraints(
maxHeight: widget.height ?? 700, // Hauteur max, sinon s'adapte au contenu
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
boxShadow: AppTheme.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête de la card
Container(
padding: const EdgeInsets.all(AppTheme.spacingM),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.05),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(AppTheme.borderRadiusMedium),
topRight: Radius.circular(AppTheme.borderRadiusMedium),
),
),
child: Row(
children: [
Icon(
Icons.people_outline,
color: theme.colorScheme.primary,
size: 24,
),
const SizedBox(width: AppTheme.spacingS),
Text(
widget.title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
],
),
),
// Corps avec le tableau
Expanded(
child: ValueListenableBuilder<Box<MembreModel>>(
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
builder: (context, membresBox, child) {
final membres = membresBox.values.toList();
// Récupérer l'opération courante
final currentOperation = _operationRepository.getCurrentOperation();
if (currentOperation == null) {
return const Center(
child: Padding(
padding: EdgeInsets.all(AppTheme.spacingL),
child: Text('Aucune opération en cours'),
),
);
}
// Trier les membres par nom
membres.sort((a, b) {
final nameA = '${a.firstName ?? ''} ${a.name ?? ''}'.trim();
final nameB = '${b.firstName ?? ''} ${b.name ?? ''}'.trim();
return nameA.compareTo(nameB);
});
// Construire les lignes : TOTAL en première position + détails membres
final allRows = [
_buildTotalRow(membres, currentOperation.id, theme),
..._buildRows(membres, currentOperation.id, theme),
];
// Utilise seulement le scroll vertical, le tableau s'adapte à la largeur
return SingleChildScrollView(
scrollDirection: Axis.vertical,
child: SizedBox(
width: double.infinity, // Prendre toute la largeur disponible
child: DataTable(
columnSpacing: 4, // Espacement minimal entre colonnes
horizontalMargin: 4, // Marges horizontales minimales
headingRowHeight: 42, // Hauteur de l'en-tête optimisée
dataRowMinHeight: 42,
dataRowMaxHeight: 42,
headingRowColor: WidgetStateProperty.all(
theme.colorScheme.primary.withValues(alpha: 0.08),
),
columns: _buildColumns(theme),
rows: allRows,
),
),
);
},
),
),
],
),
);
}
/// Construit les colonnes du tableau
List<DataColumn> _buildColumns(ThemeData theme) {
// Utilise le thème pour une meilleure lisibilité
final headerStyle = theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
) ?? const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
);
final showLotType = _shouldShowLotType();
final columns = [
// Nom
DataColumn(
label: Expanded(
child: Text('Nom', style: headerStyle),
),
),
// Total
DataColumn(
label: Expanded(
child: Center(
child: Text('Total', style: headerStyle),
),
),
numeric: true,
),
// Effectués
DataColumn(
label: Expanded(
child: Container(
color: Colors.green.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text('Effectués', style: headerStyle),
),
),
numeric: true,
),
// Montant moyen
DataColumn(
label: Expanded(
child: Center(
child: Text('Moy./passage', style: headerStyle),
),
),
numeric: true,
),
// À finaliser
DataColumn(
label: Expanded(
child: Container(
color: Colors.orange.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text('À finaliser', style: headerStyle),
),
),
numeric: true,
),
// Refusés
DataColumn(
label: Expanded(
child: Container(
color: Colors.red.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text('Refusés', style: headerStyle),
),
),
numeric: true,
),
// Dons
DataColumn(
label: Expanded(
child: Container(
color: Colors.lightBlue.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text('Dons', style: headerStyle),
),
),
numeric: true,
),
// Lots - affiché seulement si chkLotActif = true
if (showLotType)
DataColumn(
label: Expanded(
child: Container(
color: Colors.blue.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text('Lots', style: headerStyle),
),
),
numeric: true,
),
// Vides
DataColumn(
label: Expanded(
child: Container(
color: Colors.grey.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text('Vides', style: headerStyle),
),
),
numeric: true,
),
// Taux d'avancement
DataColumn(
label: Expanded(
child: Center(
child: Text('Avancement', style: headerStyle),
),
),
),
// Secteurs
DataColumn(
label: Expanded(
child: Center(
child: Text('Secteurs', style: headerStyle),
),
),
numeric: true,
),
];
return columns;
}
/// Construit la ligne de totaux
DataRow _buildTotalRow(List<MembreModel> membres, int operationId, ThemeData theme) {
final showLotType = _shouldShowLotType();
// Récupérer directement depuis les boxes Hive (déjà ouvertes)
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final allPassages = passageBox.values.where((p) => p.fkOperation == operationId).toList();
// Calculer les totaux globaux
int totalCount = allPassages.length;
int effectueCount = 0;
double effectueMontant = 0.0;
int aFinaliserCount = 0;
int refuseCount = 0;
int donCount = 0;
int lotsCount = 0;
double lotsMontant = 0.0;
int videCount = 0;
for (final passage in allPassages) {
switch (passage.fkType) {
case 1: // Effectué
effectueCount++;
if (passage.montant.isNotEmpty) {
effectueMontant += double.tryParse(passage.montant) ?? 0.0;
}
break;
case 2: // À finaliser
aFinaliserCount++;
break;
case 3: // Refusé
refuseCount++;
break;
case 4: // Don
donCount++;
break;
case 5: // Lots
if (showLotType) {
lotsCount++;
if (passage.montant.isNotEmpty) {
lotsMontant += double.tryParse(passage.montant) ?? 0.0;
}
}
break;
case 6: // Vide
videCount++;
break;
}
}
// Calculer le montant moyen global
double montantMoyen = effectueCount > 0 ? effectueMontant / effectueCount : 0.0;
// Compter les secteurs uniques
final Set<int> uniqueSectorIds = {};
for (final passage in allPassages) {
if (passage.fkSector != null) {
uniqueSectorIds.add(passage.fkSector!);
}
}
final sectorCount = uniqueSectorIds.length;
// Calculer le taux d'avancement global
double tauxAvancement = 0.0;
if (sectorCount > 0 && membres.isNotEmpty) {
tauxAvancement = effectueCount / (sectorCount * membres.length);
if (tauxAvancement > 1) tauxAvancement = 1.0;
}
return DataRow(
color: WidgetStateProperty.all(theme.colorScheme.primary.withValues(alpha: 0.15)),
cells: [
// Nom
DataCell(
Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
'TOTAL',
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
fontSize: 16,
) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
),
),
// Total
DataCell(
Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
totalCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
// Effectués
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.green.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
effectueCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'(${effectueMontant.toStringAsFixed(2)}€)',
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
),
],
),
),
),
// Montant moyen
DataCell(
Center(
child: Text(
montantMoyen > 0 ? '${montantMoyen.toStringAsFixed(2)}' : '-',
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
// À finaliser
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.orange.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text(
aFinaliserCount.toString(),
style: (theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14))
.copyWith(fontWeight: FontWeight.bold, fontStyle: FontStyle.italic),
),
),
),
// Refusés
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.red.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text(
refuseCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
// Dons
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.lightBlue.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text(
donCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
// Lots - affiché seulement si chkLotActif = true
if (showLotType)
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.blue.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
lotsCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'(${lotsMontant.toStringAsFixed(2)}€)',
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
),
],
),
),
),
// Vides
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.grey.withValues(alpha: 0.2),
alignment: Alignment.center,
child: Text(
videCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
// Taux d'avancement
DataCell(
SizedBox(
width: 100,
child: Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: tauxAvancement,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(
Colors.blue.shade600,
),
),
),
const SizedBox(width: 8),
Text(
'${(tauxAvancement * 100).toInt()}%',
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
],
),
),
),
// Secteurs
DataCell(
Center(
child: Text(
sectorCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
],
);
}
/// Construit les lignes du tableau
List<DataRow> _buildRows(List<MembreModel> membres, int operationId, ThemeData theme) {
final List<DataRow> rows = [];
final showLotType = _shouldShowLotType();
// Récupérer directement depuis les boxes Hive (déjà ouvertes)
final passageBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final allPassages = passageBox.values.where((p) => p.fkOperation == operationId).toList();
// Récupérer tous les secteurs directement depuis la box
final sectorBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
final allSectors = sectorBox.values.toList();
for (int index = 0; index < membres.length; index++) {
final membre = membres[index];
final isEvenRow = index % 2 == 0;
// Récupérer les passages du membre
final memberPassages = allPassages.where((p) => p.fkUser == membre.id).toList();
// Calculer les statistiques par type
int totalCount = memberPassages.length;
int effectueCount = 0;
double effectueMontant = 0.0;
int aFinaliserCount = 0;
int refuseCount = 0;
int donCount = 0;
int lotsCount = 0;
double lotsMontant = 0.0;
int videCount = 0;
for (final passage in memberPassages) {
switch (passage.fkType) {
case 1: // Effectué
effectueCount++;
if (passage.montant.isNotEmpty) {
effectueMontant += double.tryParse(passage.montant) ?? 0.0;
}
break;
case 2: // À finaliser
aFinaliserCount++;
break;
case 3: // Refusé
refuseCount++;
break;
case 4: // Don
donCount++;
break;
case 5: // Lots
if (showLotType) { // Compter seulement si Lots est activé
lotsCount++;
if (passage.montant.isNotEmpty) {
lotsMontant += double.tryParse(passage.montant) ?? 0.0;
}
}
break;
case 6: // Vide
videCount++;
break;
}
}
// Calculer le montant moyen
double montantMoyen = effectueCount > 0 ? effectueMontant / effectueCount : 0.0;
// Récupérer les secteurs uniques du membre via ses passages
final Set<int> memberSectorIds = {};
for (final passage in memberPassages) {
if (passage.fkSector != null) {
memberSectorIds.add(passage.fkSector!);
}
}
final sectorCount = memberSectorIds.length;
final memberSectors = allSectors.where((s) => memberSectorIds.contains(s.id)).toList();
// Calculer le taux d'avancement (passages effectués / secteurs attribués)
double tauxAvancement = 0.0;
bool hasWarning = false;
if (sectorCount > 0) {
// On considère que chaque secteur devrait avoir au moins un passage effectué
tauxAvancement = effectueCount / sectorCount;
if (tauxAvancement > 1) tauxAvancement = 1.0; // Limiter à 100%
hasWarning = tauxAvancement < 0.5; // Avertissement si moins de 50%
} else {
hasWarning = true; // Avertissement si aucun secteur attribué
}
rows.add(
DataRow(
color: WidgetStateProperty.all(
isEvenRow ? Colors.white : Colors.grey.shade50,
),
cells: [
// Nom - Cliquable pour naviguer vers l'historique avec le membre sélectionné
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
debugPrint('MembersBoardPassages: Clic sur membre ${membre.id}');
// Naviguer directement vers la page history avec memberId
debugPrint('MembersBoardPassages: Navigation vers /admin/history?memberId=${membre.id}');
if (mounted) {
context.go('/admin/history?memberId=${membre.id}');
}
},
child: Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
_buildMemberDisplayName(membre),
style: theme.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w600) ??
const TextStyle(fontWeight: FontWeight.w600, fontSize: 16),
),
),
),
),
),
// Total - Cliquable pour naviguer vers l'historique avec le membre sélectionné
DataCell(
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
debugPrint('MembersBoardPassages: Clic sur membre ${membre.id}');
// Naviguer directement vers la page history avec memberId
debugPrint('MembersBoardPassages: Navigation vers /admin/history?memberId=${membre.id}');
if (mounted) {
context.go('/admin/history?memberId=${membre.id}');
}
},
child: Container(
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
totalCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
),
),
),
// Effectués
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.green.withValues(alpha: 0.1),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
effectueCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'(${effectueMontant.toStringAsFixed(2)}€)',
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
),
],
),
),
),
// Montant moyen
DataCell(Center(child: Text(
montantMoyen > 0 ? '${montantMoyen.toStringAsFixed(2)}' : '-',
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
))),
// À finaliser
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.orange.withValues(alpha: 0.1),
alignment: Alignment.center,
child: Text(
aFinaliserCount.toString(),
style: (theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14))
.copyWith(fontStyle: FontStyle.italic),
),
),
),
// Refusés
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.red.withValues(alpha: 0.1),
alignment: Alignment.center,
child: Text(
refuseCount.toString(),
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
),
),
),
// Dons
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.lightBlue.withValues(alpha: 0.1),
alignment: Alignment.center,
child: Text(
donCount.toString(),
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
),
),
),
// Lots - affiché seulement si chkLotActif = true
if (showLotType)
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.blue.withValues(alpha: 0.1),
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
lotsCount.toString(),
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
Text(
'(${lotsMontant.toStringAsFixed(2)}€)',
style: theme.textTheme.bodySmall ?? const TextStyle(fontSize: 12),
),
],
),
),
),
// Vides
DataCell(
Container(
width: double.infinity,
height: double.infinity,
color: Colors.grey.withValues(alpha: 0.1),
alignment: Alignment.center,
child: Text(
videCount.toString(),
style: theme.textTheme.bodyMedium ?? const TextStyle(fontSize: 14),
),
),
),
// Taux d'avancement
DataCell(
SizedBox(
width: 100,
child: Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: tauxAvancement,
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(
hasWarning ? Colors.red.shade400 : Colors.green.shade400,
),
),
),
const SizedBox(width: 8),
if (hasWarning)
Icon(
Icons.warning,
color: Colors.red.shade400,
size: 16,
)
else
Text(
'${(tauxAvancement * 100).toInt()}%',
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.bold) ??
const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
],
),
),
),
// Secteurs
DataCell(
Row(
children: [
if (sectorCount == 0)
Icon(
Icons.warning,
color: Colors.red.shade400,
size: 16,
),
const SizedBox(width: 4),
Text(
sectorCount.toString(),
style: TextStyle(
fontSize: theme.textTheme.bodyMedium?.fontSize ?? 14,
fontWeight: sectorCount > 0 ? FontWeight.bold : FontWeight.normal,
color: sectorCount > 0 ? Colors.green.shade700 : Colors.red.shade700,
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.map_outlined, size: 16),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
_showMemberSectorsDialog(context, membre, memberSectors.toList());
},
),
],
),
),
],
),
);
}
return rows;
}
/// Construit le nom d'affichage d'un membre avec son sectName si disponible
String _buildMemberDisplayName(MembreModel membre) {
String displayName = '${membre.firstName ?? ''} ${membre.name ?? ''}'.trim();
// Ajouter le sectName entre parenthèses s'il existe
if (membre.sectName != null && membre.sectName!.isNotEmpty) {
displayName += ' (${membre.sectName})';
}
return displayName;
}
/// Affiche un dialogue avec les secteurs du membre
void _showMemberSectorsDialog(BuildContext context, MembreModel membre, List<SectorModel> memberSectors) {
final theme = Theme.of(context);
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('Secteurs de ${membre.firstName} ${membre.name}'),
content: SizedBox(
width: 400,
child: memberSectors.isEmpty
? const Text('Aucun secteur attribué')
: ListView.builder(
shrinkWrap: true,
itemCount: memberSectors.length,
itemBuilder: (context, index) {
final sector = memberSectors[index];
return ListTile(
leading: Icon(
Icons.map,
color: theme.colorScheme.primary,
),
title: Text(sector.libelle),
subtitle: Text('Secteur #${sector.id}'),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
);
},
);
}
}

View File

@@ -1,10 +1,17 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/repositories/operation_repository.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/core/services/stripe_tap_to_pay_service.dart';
import 'package:geosector_app/core/services/device_info_service.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
@@ -17,6 +24,7 @@ class PassageFormDialog extends StatefulWidget {
final PassageRepository passageRepository;
final UserRepository userRepository;
final OperationRepository operationRepository;
final AmicaleRepository amicaleRepository;
final VoidCallback? onSuccess;
const PassageFormDialog({
@@ -27,6 +35,7 @@ class PassageFormDialog extends StatefulWidget {
required this.passageRepository,
required this.userRepository,
required this.operationRepository,
required this.amicaleRepository,
this.onSuccess,
});
@@ -63,6 +72,12 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
int _fkTypeReglement = 4; // Par défaut Non renseigné
DateTime _passedAt = DateTime.now(); // Date et heure de passage
// Variable pour Tap to Pay
String? _stripePaymentIntentId;
// Boîte Hive pour mémoriser la dernière adresse
late Box _settingsBox;
// Helpers de validation
String? _validateNumero(String? value) {
if (value == null || value.trim().isEmpty) {
@@ -93,9 +108,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
String? _validateNomOccupant(String? value) {
if (_selectedPassageType == 1) {
// Le nom est obligatoire uniquement si un email est renseigné
final emailValue = _emailController.text.trim();
if (emailValue.isNotEmpty) {
if (value == null || value.trim().isEmpty) {
return 'Le nom est obligatoire pour les passages effectués';
return 'Le nom est obligatoire si un email est renseigné';
}
if (value.trim().length < 2) {
return 'Le nom doit contenir au moins 2 caractères';
@@ -138,6 +155,9 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
try {
debugPrint('=== DEBUT PassageFormDialog.initState ===');
// Accéder à la settingsBox (déjà ouverte dans l'app)
_settingsBox = Hive.box(AppKeys.settingsBoxName);
// Initialize controllers with passage data if available
final passage = widget.passage;
debugPrint('Passage reçu: ${passage != null}');
@@ -166,10 +186,10 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
debugPrint('Initialisation des controllers...');
// S'assurer que toutes les valeurs null deviennent des chaînes vides
final String numero = passage?.numero.toString() ?? '';
final String rueBis = passage?.rueBis.toString() ?? '';
final String rue = passage?.rue.toString() ?? '';
final String ville = passage?.ville.toString() ?? '';
String numero = passage?.numero.toString() ?? '';
String rueBis = passage?.rueBis.toString() ?? '';
String rue = passage?.rue.toString() ?? '';
String ville = passage?.ville.toString() ?? '';
final String name = passage?.name.toString() ?? '';
final String email = passage?.email.toString() ?? '';
final String phone = passage?.phone.toString() ?? '';
@@ -179,11 +199,26 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
(montantRaw == '0.00' || montantRaw == '0' || montantRaw == '0.0')
? ''
: montantRaw;
final String appt = passage?.appt.toString() ?? '';
final String niveau = passage?.niveau.toString() ?? '';
final String residence = passage?.residence.toString() ?? '';
String appt = passage?.appt.toString() ?? '';
String niveau = passage?.niveau.toString() ?? '';
String residence = passage?.residence.toString() ?? '';
final String remarque = passage?.remarque.toString() ?? '';
// Si nouveau passage, charger les valeurs mémorisées de la dernière adresse
if (passage == null) {
debugPrint('Nouveau passage: chargement des valeurs mémorisées...');
numero = _settingsBox.get('lastPassageNumero', defaultValue: '') as String;
rueBis = _settingsBox.get('lastPassageRueBis', defaultValue: '') as String;
rue = _settingsBox.get('lastPassageRue', defaultValue: '') as String;
ville = _settingsBox.get('lastPassageVille', defaultValue: '') as String;
residence = _settingsBox.get('lastPassageResidence', defaultValue: '') as String;
_fkHabitat = _settingsBox.get('lastPassageFkHabitat', defaultValue: 1) as int;
appt = _settingsBox.get('lastPassageAppt', defaultValue: '') as String;
niveau = _settingsBox.get('lastPassageNiveau', defaultValue: '') as String;
debugPrint('Valeurs chargées: numero="$numero", rue="$rue", ville="$ville"');
}
// Initialiser la date de passage
_passedAt = passage?.passedAt ?? DateTime.now();
final String dateFormatted =
@@ -220,6 +255,16 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
_dateController = TextEditingController(text: dateFormatted);
_timeController = TextEditingController(text: timeFormatted);
// Ajouter un listener sur le champ email pour mettre à jour la validation du nom
_emailController.addListener(() {
// Force la revalidation du formulaire quand l'email change
if (mounted) {
setState(() {
// Cela va déclencher un rebuild et mettre à jour l'indicateur isRequired
});
}
});
debugPrint('=== FIN PassageFormDialog.initState ===');
} catch (e, stackTrace) {
debugPrint('=== ERREUR PassageFormDialog.initState ===');
@@ -284,6 +329,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
return;
}
// Toujours sauvegarder le passage en premier
await _savePassage();
}
Future<void> _savePassage() async {
setState(() {
_isSubmitting = true;
});
@@ -314,6 +364,23 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
finalTypeReglement = 4;
}
// Déterminer la valeur de nbPassages selon le type de passage
final int finalNbPassages;
if (widget.passage != null) {
// Modification d'un passage existant
if (_selectedPassageType == 2) {
// Type 2 (À finaliser) : toujours incrémenter
finalNbPassages = widget.passage!.nbPassages + 1;
} else {
// Autres types : mettre à 1 si actuellement 0, sinon conserver
final currentNbPassages = widget.passage!.nbPassages;
finalNbPassages = currentNbPassages == 0 ? 1 : currentNbPassages;
}
} else {
// Nouveau passage : toujours 1
finalNbPassages = 1;
}
final passageData = widget.passage?.copyWith(
fkType: _selectedPassageType!,
numero: _numeroController.text.trim(),
@@ -330,7 +397,9 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
residence: _residenceController.text.trim(),
remarque: _remarqueController.text.trim(),
fkTypeReglement: finalTypeReglement,
nbPassages: finalNbPassages,
passedAt: _passedAt,
stripePaymentId: _stripePaymentIntentId,
lastSyncedAt: DateTime.now(),
) ??
PassageModel(
@@ -356,43 +425,127 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
montant: finalMontant,
fkTypeReglement: finalTypeReglement,
emailErreur: '',
nbPassages: 1,
nbPassages: finalNbPassages,
name: _nameController.text.trim(),
email: _emailController.text.trim(),
phone: _phoneController.text.trim(),
stripePaymentId: _stripePaymentIntentId,
lastSyncedAt: DateTime.now(),
isActive: true,
isSynced: false,
);
final success = widget.passage == null
? await widget.passageRepository.createPassage(passageData)
: await widget.passageRepository.updatePassage(passageData);
// Sauvegarder le passage d'abord
PassageModel? savedPassage;
if (widget.passage == null) {
// Création d'un nouveau passage
savedPassage = await widget.passageRepository.createPassageWithReturn(passageData);
} else {
// Mise à jour d'un passage existant
final success = await widget.passageRepository.updatePassage(passageData);
if (success) {
savedPassage = passageData;
}
}
if (success && mounted) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
ApiException.showSuccess(
context,
widget.passage == null
? "Nouveau passage créé avec succès"
: "Passage modifié avec succès",
);
if (savedPassage == null) {
throw Exception(widget.passage == null
? "Échec de la création du passage"
: "Échec de la mise à jour du passage");
}
// Mémoriser l'adresse pour la prochaine création de passage
await _saveLastPassageAddress();
// Vérifier si paiement CB nécessaire APRÈS la sauvegarde
if (finalTypeReglement == 3 &&
(_selectedPassageType == 1 || _selectedPassageType == 5)) {
final montant = double.tryParse(finalMontant.replaceAll(',', '.')) ?? 0;
if (montant > 0 && mounted) {
// Vérifier si le device supporte Tap to Pay
if (DeviceInfoService.instance.canUseTapToPay()) {
// Lancer le flow Tap to Pay avec l'ID du passage sauvegardé
final paymentSuccess = await _attemptTapToPayWithPassage(savedPassage, montant);
if (!paymentSuccess) {
// Si le paiement échoue, on pourrait marquer le passage comme "À finaliser"
// ou le supprimer selon la logique métier
debugPrint('⚠️ Paiement échoué pour le passage ${savedPassage.id}');
// Optionnel : mettre à jour le passage en type "À finaliser" (7)
}
} else {
// Le device ne supporte pas Tap to Pay (Web ou device non compatible)
if (mounted) {
// Déterminer le message d'avertissement approprié
String warningMessage;
if (kIsWeb) {
warningMessage = "Passage enregistré avec succès.\n\n Note : Le paiement sans contact n'est pas disponible sur navigateur web. Utilisez l'application mobile native pour cette fonctionnalité.";
} else {
// Vérifier pourquoi le device n'est pas compatible
final deviceInfo = DeviceInfoService.instance.getStoredDeviceInfo();
final nfcCapable = deviceInfo['device_nfc_capable'] == true;
final stripeCertified = deviceInfo['device_stripe_certified'] == true;
final batteryLevel = deviceInfo['battery_level'] as int?;
final platform = deviceInfo['platform'];
if (!nfcCapable) {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre appareil n'a pas de NFC activé ou disponible pour les paiements sans contact.";
} else if (!stripeCertified) {
if (platform == 'iOS') {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre iPhone n'est pas compatible. Tap to Pay nécessite un iPhone XS ou plus récent avec iOS 16.4+.";
} else {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre appareil Android n'est pas certifié par Stripe pour les paiements sans contact en France.";
}
} else if (batteryLevel != null && batteryLevel < 10) {
warningMessage = "Passage enregistré avec succès.\n\n Note : Batterie trop faible ($batteryLevel%). Minimum 10% requis pour les paiements sans contact.";
} else {
warningMessage = "Passage enregistré avec succès.\n\n Note : Votre appareil ne peut pas utiliser le paiement sans contact actuellement.";
}
}
});
// Fermer le dialog et afficher le message de succès avec avertissement
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
// Afficher un SnackBar orange pour l'avertissement
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(warningMessage),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 5),
),
);
}
});
}
});
}
}
});
} else if (mounted) {
ApiException.showError(
context,
Exception(widget.passage == null
? "Échec de la création du passage"
: "Échec de la mise à jour du passage"),
);
}
} else {
// Pas de paiement CB, fermer le dialog avec succès
if (mounted) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
ApiException.showSuccess(
context,
widget.passage == null
? "Nouveau passage créé avec succès"
: "Passage modifié avec succès",
);
}
});
}
});
}
}
} catch (e) {
if (mounted) {
@@ -407,9 +560,47 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
}
/// Mémoriser l'adresse du passage pour la prochaine création
Future<void> _saveLastPassageAddress() async {
try {
await _settingsBox.put('lastPassageNumero', _numeroController.text.trim());
await _settingsBox.put('lastPassageRueBis', _rueBisController.text.trim());
await _settingsBox.put('lastPassageRue', _rueController.text.trim());
await _settingsBox.put('lastPassageVille', _villeController.text.trim());
await _settingsBox.put('lastPassageResidence', _residenceController.text.trim());
await _settingsBox.put('lastPassageFkHabitat', _fkHabitat);
await _settingsBox.put('lastPassageAppt', _apptController.text.trim());
await _settingsBox.put('lastPassageNiveau', _niveauController.text.trim());
debugPrint('✅ Adresse mémorisée pour la prochaine création de passage');
} catch (e) {
debugPrint('⚠️ Erreur lors de la mémorisation de l\'adresse: $e');
}
}
Widget _buildPassageTypeSelection() {
final theme = Theme.of(context);
// Récupérer l'amicale de l'utilisateur pour vérifier chkLotActif
final currentUser = CurrentUserService.instance.currentUser;
bool showLotType = true; // Par défaut, on affiche le type Lot
if (currentUser != null && currentUser.fkEntite != null) {
final userAmicale = widget.amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (userAmicale != null) {
// Si chkLotActif = false (0), on ne doit pas afficher le type Lot (5)
showLotType = userAmicale.chkLotActif;
debugPrint('Amicale ${userAmicale.name}: chkLotActif = $showLotType');
}
}
// Filtrer les types de passages en fonction de chkLotActif
final filteredTypes = Map<int, Map<String, dynamic>>.from(AppKeys.typesPassages);
if (!showLotType) {
filteredTypes.remove(5); // Retirer le type "Lot" si chkLotActif = 0
debugPrint('Type Lot (5) masqué car chkLotActif = false');
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -431,11 +622,11 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: AppKeys.typesPassages.length,
itemCount: filteredTypes.length,
itemBuilder: (context, index) {
try {
final typeId = AppKeys.typesPassages.keys.elementAt(index);
final typeData = AppKeys.typesPassages[typeId];
final typeId = filteredTypes.keys.elementAt(index);
final typeData = filteredTypes[typeId];
if (typeData == null) {
debugPrint('ERREUR: typeData null pour typeId: $typeId');
@@ -523,35 +714,62 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
title: 'Date et Heure de passage',
icon: Icons.schedule,
children: [
Row(
children: [
Expanded(
child: CustomTextField(
controller: _dateController,
label: "Date",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "DD/MM/YYYY",
suffixIcon: const Icon(Icons.calendar_today),
onTap: widget.readOnly ? null : _selectDate,
// Layout responsive : 1 ligne desktop, 2 lignes mobile
_isMobile(context)
? Column(
children: [
CustomTextField(
controller: _dateController,
label: "Date",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "DD/MM/YYYY",
suffixIcon: const Icon(Icons.calendar_today),
onTap: widget.readOnly ? null : _selectDate,
),
const SizedBox(height: 16),
CustomTextField(
controller: _timeController,
label: "Heure",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "HH:MM",
suffixIcon: const Icon(Icons.access_time),
onTap: widget.readOnly ? null : _selectTime,
),
],
)
: Row(
children: [
Expanded(
child: CustomTextField(
controller: _dateController,
label: "Date",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "DD/MM/YYYY",
suffixIcon: const Icon(Icons.calendar_today),
onTap: widget.readOnly ? null : _selectDate,
),
),
const SizedBox(width: 12),
Expanded(
child: CustomTextField(
controller: _timeController,
label: "Heure",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "HH:MM",
suffixIcon: const Icon(Icons.access_time),
onTap: widget.readOnly ? null : _selectTime,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: CustomTextField(
controller: _timeController,
label: "Heure",
isRequired: true,
readOnly: true,
showLabel: false,
hintText: "HH:MM",
suffixIcon: const Icon(Icons.access_time),
onTap: widget.readOnly ? null : _selectTime,
),
),
],
),
],
),
const SizedBox(height: 24),
@@ -619,11 +837,12 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
Row(
children: [
Expanded(
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
child: RadioListTile<int>(
title: const Text('Maison'),
value: 1,
groupValue: _fkHabitat,
onChanged: widget.readOnly
groupValue: _fkHabitat, // ignore: deprecated_member_use
onChanged: widget.readOnly // ignore: deprecated_member_use
? null
: (value) {
setState(() {
@@ -637,8 +856,8 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
child: RadioListTile<int>(
title: const Text('Appart'),
value: 2,
groupValue: _fkHabitat,
onChanged: widget.readOnly
groupValue: _fkHabitat, // ignore: deprecated_member_use
onChanged: widget.readOnly // ignore: deprecated_member_use
? null
: (value) {
setState(() {
@@ -705,41 +924,63 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
children: [
CustomTextField(
controller: _nameController,
label: _selectedPassageType == 1
? "Nom de l'occupant"
: "Nom de l'occupant",
isRequired: _selectedPassageType == 1,
label: "Nom de l'occupant",
isRequired: _emailController.text.trim().isNotEmpty,
showLabel: false,
readOnly: widget.readOnly,
validator: _validateNomOccupant,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: CustomTextField(
controller: _emailController,
label: "Email",
showLabel: false,
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
validator: _validateEmail,
prefixIcon: Icons.email,
// Layout responsive : 1 ligne desktop, 2 lignes mobile
_isMobile(context)
? Column(
children: [
CustomTextField(
controller: _emailController,
label: "Email",
showLabel: false,
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
validator: _validateEmail,
prefixIcon: Icons.email,
),
const SizedBox(height: 16),
CustomTextField(
controller: _phoneController,
label: "Téléphone",
showLabel: false,
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
prefixIcon: Icons.phone,
),
],
)
: Row(
children: [
Expanded(
child: CustomTextField(
controller: _emailController,
label: "Email",
showLabel: false,
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
validator: _validateEmail,
prefixIcon: Icons.email,
),
),
const SizedBox(width: 12),
Expanded(
child: CustomTextField(
controller: _phoneController,
label: "Téléphone",
showLabel: false,
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
prefixIcon: Icons.phone,
),
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: CustomTextField(
controller: _phoneController,
label: "Téléphone",
showLabel: false,
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
prefixIcon: Icons.phone,
),
),
],
),
],
),
const SizedBox(height: 24),
@@ -1140,6 +1381,65 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
);
}
/// Tente d'effectuer un paiement Tap to Pay avec un passage déjà sauvegardé
Future<bool> _attemptTapToPayWithPassage(PassageModel passage, double montant) async {
try {
// Afficher le dialog de paiement avec l'ID réel du passage
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (context) => _TapToPayFlowDialog(
amount: montant,
passageId: passage.id, // ID réel du passage sauvegardé
onSuccess: (paymentIntentId) {
// Mettre à jour le passage avec le stripe_payment_id
final updatedPassage = passage.copyWith(
stripePaymentId: paymentIntentId,
);
// Envoyer la mise à jour à l'API (sera fait de manière asynchrone)
widget.passageRepository.updatePassage(updatedPassage).then((_) {
debugPrint('✅ Passage mis à jour avec stripe_payment_id: $paymentIntentId');
}).catchError((error) {
debugPrint('❌ Erreur mise à jour passage: $error');
});
setState(() {
_stripePaymentIntentId = paymentIntentId;
});
},
),
);
// Si paiement réussi, afficher le message de succès et fermer
if (result == true && mounted) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
widget.onSuccess?.call();
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
ApiException.showSuccess(
context,
"Paiement effectué avec succès",
);
}
});
}
});
return true;
}
return false;
} catch (e) {
debugPrint('Erreur Tap to Pay: $e');
if (mounted) {
ApiException.showError(context, e);
}
return false;
}
}
@override
Widget build(BuildContext context) {
try {
@@ -1228,3 +1528,340 @@ class _PassageFormDialogState extends State<PassageFormDialog> {
}
}
}
/// Dialog pour gérer le flow de paiement Tap to Pay
class _TapToPayFlowDialog extends StatefulWidget {
final double amount;
final int passageId;
final void Function(String paymentIntentId)? onSuccess;
const _TapToPayFlowDialog({
required this.amount,
required this.passageId,
this.onSuccess,
});
@override
State<_TapToPayFlowDialog> createState() => _TapToPayFlowDialogState();
}
class _TapToPayFlowDialogState extends State<_TapToPayFlowDialog> {
String _currentState = 'confirming';
String? _paymentIntentId;
String? _errorMessage;
StreamSubscription<TapToPayStatus>? _statusSubscription;
@override
void initState() {
super.initState();
_listenToPaymentStatus();
}
@override
void dispose() {
_statusSubscription?.cancel();
super.dispose();
}
void _listenToPaymentStatus() {
_statusSubscription = StripeTapToPayService.instance.paymentStatusStream.listen(
(status) {
if (!mounted) return;
setState(() {
switch (status.type) {
case TapToPayStatusType.ready:
_currentState = 'ready';
break;
case TapToPayStatusType.awaitingTap:
_currentState = 'awaiting_tap';
break;
case TapToPayStatusType.processing:
_currentState = 'processing';
break;
case TapToPayStatusType.confirming:
_currentState = 'confirming';
break;
case TapToPayStatusType.success:
_currentState = 'success';
_paymentIntentId = status.paymentIntentId;
_handleSuccess();
break;
case TapToPayStatusType.error:
_currentState = 'error';
_errorMessage = status.message;
break;
case TapToPayStatusType.cancelled:
Navigator.pop(context, false);
break;
}
});
},
);
}
void _handleSuccess() {
if (_paymentIntentId != null) {
widget.onSuccess?.call(_paymentIntentId!);
// Attendre un peu pour montrer le succès
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
Navigator.pop(context, true);
}
});
}
}
Future<void> _startPayment() async {
setState(() {
_currentState = 'initializing';
_errorMessage = null;
});
try {
// Initialiser le service si nécessaire
if (!StripeTapToPayService.instance.isInitialized) {
final initialized = await StripeTapToPayService.instance.initialize();
if (!initialized) {
throw Exception('Impossible d\'initialiser Tap to Pay');
}
}
// Vérifier que le service est prêt
if (!StripeTapToPayService.instance.isReadyForPayments()) {
throw Exception('L\'appareil n\'est pas prêt pour les paiements');
}
// Créer le PaymentIntent avec l'ID du passage dans les metadata
final paymentIntent = await StripeTapToPayService.instance.createPaymentIntent(
amountInCents: (widget.amount * 100).round(),
description: 'Calendrier pompiers${widget.passageId > 0 ? " - Passage #${widget.passageId}" : ""}',
metadata: {
'type': 'tap_to_pay',
'passage_id': widget.passageId.toString(),
'amicale_id': CurrentAmicaleService.instance.amicaleId.toString(),
'member_id': CurrentUserService.instance.userId.toString(),
},
);
if (paymentIntent == null) {
throw Exception('Impossible de créer le paiement');
}
_paymentIntentId = paymentIntent.paymentIntentId;
// Collecter le paiement
final collected = await StripeTapToPayService.instance.collectPayment(paymentIntent);
if (!collected) {
throw Exception('Échec de la collecte du paiement');
}
// Confirmer le paiement
final confirmed = await StripeTapToPayService.instance.confirmPayment(paymentIntent);
if (!confirmed) {
throw Exception('Échec de la confirmation du paiement');
}
} catch (e) {
setState(() {
_currentState = 'error';
_errorMessage = e.toString();
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
Widget content;
List<Widget> actions = [];
switch (_currentState) {
case 'confirming':
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.contactless, size: 64, color: theme.colorScheme.primary),
const SizedBox(height: 24),
Text(
'Paiement par carte sans contact',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 16),
Text(
'Montant: ${widget.amount.toStringAsFixed(2)}',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 24),
const Text(
'Le client va payer par carte bancaire sans contact.\n'
'Son téléphone ou sa carte sera présenté(e) sur cet appareil.',
textAlign: TextAlign.center,
),
],
);
actions = [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: _startPayment,
icon: const Icon(Icons.payment),
label: const Text('Lancer le paiement'),
),
];
break;
case 'initializing':
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 24),
Text(
'Initialisation du terminal...',
style: theme.textTheme.titleMedium,
),
],
);
break;
case 'awaiting_tap':
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.tap_and_play,
size: 80,
color: theme.colorScheme.primary,
),
const SizedBox(height: 24),
Text(
'Présentez la carte',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 16),
const LinearProgressIndicator(),
const SizedBox(height: 16),
Text(
'Montant: ${widget.amount.toStringAsFixed(2)}',
style: theme.textTheme.titleMedium,
),
],
);
actions = [
TextButton(
onPressed: () {
if (_paymentIntentId != null) {
StripeTapToPayService.instance.cancelPayment(_paymentIntentId!);
}
Navigator.pop(context, false);
},
child: const Text('Annuler'),
),
];
break;
case 'processing':
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 24),
Text(
'Traitement du paiement...',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text('Ne pas retirer la carte'),
],
);
break;
case 'success':
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.check_circle,
size: 80,
color: Colors.green,
),
const SizedBox(height: 24),
Text(
'Paiement réussi !',
style: theme.textTheme.headlineSmall?.copyWith(
color: Colors.green,
),
),
const SizedBox(height: 16),
Text(
'${widget.amount.toStringAsFixed(2)}€ payé par carte',
style: theme.textTheme.titleMedium,
),
],
);
break;
case 'error':
content = Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error_outline,
size: 80,
color: Colors.red,
),
const SizedBox(height: 24),
Text(
'Échec du paiement',
style: theme.textTheme.headlineSmall?.copyWith(
color: Colors.red,
),
),
const SizedBox(height: 16),
Text(
_errorMessage ?? 'Une erreur est survenue',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium,
),
],
);
actions = [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: _startPayment,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
];
break;
default:
content = const Center(child: CircularProgressIndicator());
}
return AlertDialog(
title: Row(
children: [
Icon(Icons.contactless, color: theme.colorScheme.primary),
const SizedBox(width: 8),
const Text('Paiement sans contact'),
],
),
content: Container(
constraints: const BoxConstraints(maxWidth: 400),
child: content,
),
actions: actions.isEmpty ? null : actions,
);
}
}

View File

@@ -3,6 +3,7 @@ import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
import 'package:geosector_app/app.dart';
class PassageMapDialog extends StatelessWidget {
@@ -24,78 +25,14 @@ class PassageMapDialog extends StatelessWidget {
// Récupérer le type de passage
final String typePassage =
AppKeys.typesPassages[type]?['titre'] ?? 'Inconnu';
// Utiliser couleur2 pour le badge (couleur1 peut être blanche pour type 2)
final Color typeColor =
Color(AppKeys.typesPassages[type]?['couleur1'] ?? 0xFF9E9E9E);
Color(AppKeys.typesPassages[type]?['couleur2'] ?? 0xFF9E9E9E);
// Construire l'adresse complète
final String adresse =
'${passage.numero} ${passage.rueBis} ${passage.rue}'.trim();
// Informations sur l'étage, l'appartement et la résidence (si habitat = 2)
String? etageInfo;
String? apptInfo;
String? residenceInfo;
if (passage.fkHabitat == 2) {
if (passage.niveau.isNotEmpty) {
etageInfo = 'Étage ${passage.niveau}';
}
if (passage.appt.isNotEmpty) {
apptInfo = 'Appt. ${passage.appt}';
}
if (passage.residence.isNotEmpty) {
residenceInfo = passage.residence;
}
}
// Formater la date (uniquement si le type n'est pas 2 et si la date existe)
String? dateInfo;
if (type != 2 && passage.passedAt != null) {
final date = passage.passedAt!;
dateInfo =
'${_formatDate(date)} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}';
}
// Récupérer le nom du passage (si le type n'est pas 6 - Maison vide)
String? nomInfo;
if (type != 6 && passage.name.isNotEmpty) {
nomInfo = passage.name;
}
// Récupérer les informations de règlement si le type est 1 (Effectué) ou 5 (Lot)
Widget? reglementInfo;
if ((type == 1 || type == 5) && passage.fkTypeReglement > 0) {
final int typeReglementId = passage.fkTypeReglement;
final String montant = passage.montant;
// Récupérer les informations du type de règlement
if (AppKeys.typesReglements.containsKey(typeReglementId)) {
final Map<String, dynamic> typeReglement =
AppKeys.typesReglements[typeReglementId]!;
final String titre = typeReglement['titre'] as String;
final Color couleur = Color(typeReglement['couleur'] as int);
final IconData iconData = typeReglement['icon_data'] as IconData;
reglementInfo = Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
color: couleur.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: couleur.withValues(alpha: 0.3)),
),
child: Row(
children: [
Icon(iconData, color: couleur, size: 20),
const SizedBox(width: 8),
Text('$titre: $montant',
style:
TextStyle(color: couleur, fontWeight: FontWeight.bold)),
],
),
);
}
}
// Vérifier si l'utilisateur peut supprimer (admin ou user avec permission)
bool canDelete = isAdmin;
if (!isAdmin) {
@@ -122,93 +59,39 @@ class PassageMapDialog extends StatelessWidget {
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Passage #${passage.id}',
style: const TextStyle(fontSize: 18),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: typeColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
typePassage,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: typeColor,
),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Afficher en premier si le passage n'est pas affecté à un secteur
if (passage.fkSector == null) ...[
Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
border: Border.all(color: Colors.red, width: 1),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
const Icon(Icons.warning, color: Colors.red, size: 20),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Ce passage n\'est plus affecté à un secteur',
style: TextStyle(
color: Colors.red, fontWeight: FontWeight.bold),
),
),
],
),
),
],
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Adresse
_buildInfoRow(Icons.location_on, 'Adresse',
adresse.isEmpty ? 'Non renseignée' : adresse),
// Adresse
_buildInfoRow(Icons.location_on, 'Adresse',
adresse.isEmpty ? 'Non renseignée' : adresse),
// Résidence
if (residenceInfo != null)
_buildInfoRow(Icons.apartment, 'Résidence', residenceInfo),
// Étage et appartement
if (etageInfo != null || apptInfo != null)
_buildInfoRow(Icons.stairs, 'Localisation',
[etageInfo, apptInfo].where((e) => e != null).join(' - ')),
// Date
if (dateInfo != null)
_buildInfoRow(Icons.calendar_today, 'Date', dateInfo),
// Nom
if (nomInfo != null) _buildInfoRow(Icons.person, 'Nom', nomInfo),
// Ville
if (passage.ville.isNotEmpty)
_buildInfoRow(Icons.location_city, 'Ville', passage.ville),
// Remarque
if (passage.remarque.isNotEmpty)
_buildInfoRow(Icons.note, 'Remarque', passage.remarque),
// Règlement
if (reglementInfo != null) reglementInfo,
],
),
// Ville
if (passage.ville.isNotEmpty)
_buildInfoRow(Icons.location_city, 'Ville', passage.ville),
],
),
actions: [
// Bouton de modification
TextButton.icon(
onPressed: () {
Navigator.of(context).pop();
_showEditDialog(context);
},
icon: const Icon(Icons.edit, size: 20),
label: const Text('Modifier'),
style: TextButton.styleFrom(
foregroundColor: Colors.blue,
),
),
// Bouton de suppression si autorisé
if (canDelete)
TextButton.icon(
@@ -259,9 +142,25 @@ class PassageMapDialog extends StatelessWidget {
);
}
// Formater une date
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
// Afficher le dialog de modification
void _showEditDialog(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return PassageFormDialog(
passage: passage,
title: 'Modifier le passage',
passageRepository: passageRepository,
userRepository: userRepository,
operationRepository: operationRepository,
amicaleRepository: amicaleRepository,
onSuccess: () {
// Appeler le callback si fourni pour rafraîchir l'affichage
onDeleted?.call();
},
);
},
);
}
// Afficher le dialog de confirmation de suppression

View File

@@ -336,10 +336,11 @@ class _PassageFormState extends State<PassageForm> {
return Row(
children: [
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
Radio<String>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
groupValue: groupValue, // ignore: deprecated_member_use
onChanged: onChanged, // ignore: deprecated_member_use
activeColor: const Color(0xFF20335E),
),
Text(

File diff suppressed because it is too large Load Diff

View File

@@ -138,6 +138,7 @@ class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
child: Container(
color: Colors
.transparent, // Fond transparent pour voir l'AdminBackground
alignment: Alignment.topCenter, // Aligner le contenu en haut
child: widget.body,
),
),

View File

@@ -305,8 +305,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
final bool hasPassages = count > 0;
final textColor = hasPassages ? Colors.black87 : Colors.grey;
// Vérifier si l'utilisateur est admin
final bool isAdmin = CurrentUserService.instance.canAccessAdmin;
// Vérifier si l'utilisateur est admin (prend en compte le mode d'affichage)
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
return Padding(
padding: const EdgeInsets.only(bottom: AppTheme.spacingM),
@@ -420,8 +420,8 @@ class _SectorDistributionCardState extends State<SectorDistributionCard> {
? Color(typeInfo['couleur2'] as int)
: Colors.grey;
// Vérifier si l'utilisateur est admin pour les clics
final bool isAdmin = CurrentUserService.instance.canAccessAdmin;
// Vérifier si l'utilisateur est admin pour les clics (prend en compte le mode d'affichage)
final bool isAdmin = CurrentUserService.instance.shouldShowAdminUI;
return Expanded(
flex: count,

View File

@@ -5,6 +5,7 @@ import 'dart:math';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'custom_text_field.dart';
class UserForm extends StatefulWidget {
@@ -50,10 +51,13 @@ class _UserFormState extends State<UserForm> {
int _fkTitre = 1; // 1 = M., 2 = Mme
DateTime? _dateNaissance;
DateTime? _dateEmbauche;
// Pour la génération automatique d'username
bool _isGeneratingUsername = false;
final Random _random = Random();
// Pour détecter la modification du username
String? _initialUsername;
// Pour afficher/masquer le mot de passe
bool _obscurePassword = true;
@@ -72,6 +76,9 @@ class _UserFormState extends State<UserForm> {
_mobileController = TextEditingController(text: user?.mobile ?? '');
_emailController = TextEditingController(text: user?.email ?? '');
// Stocker le username initial pour détecter les modifications
_initialUsername = user?.username;
_dateNaissance = user?.dateNaissance;
_dateEmbauche = user?.dateEmbauche;
@@ -373,80 +380,6 @@ class _UserFormState extends State<UserForm> {
return null;
}
// Générer un mot de passe selon les normes NIST (phrases de passe recommandées)
String _generatePassword() {
// Listes de mots pour créer des phrases de passe mémorables
final sujets = [
'Mon chat', 'Le chien', 'Ma voiture', 'Mon vélo', 'La maison',
'Mon jardin', 'Le soleil', 'La lune', 'Mon café', 'Le train',
'Ma pizza', 'Le gâteau', 'Mon livre', 'La musique', 'Mon film'
];
final noms = [
'Félix', 'Max', 'Luna', 'Bella', 'Charlie', 'Rocky', 'Maya',
'Oscar', 'Ruby', 'Leo', 'Emma', 'Jack', 'Sophie', 'Milo', 'Zoé'
];
final verbes = [
'aime', 'mange', 'court', 'saute', 'danse', 'chante', 'joue',
'dort', 'rêve', 'vole', 'nage', 'lit', 'écrit', 'peint', 'cuisine'
];
final complements = [
'dans le jardin', 'sous la pluie', 'avec joie', 'très vite', 'tout le temps',
'en été', 'le matin', 'la nuit', 'au soleil', 'dans la neige',
'sur la plage', 'à Paris', 'en vacances', 'avec passion', 'doucement'
];
// Choisir un type de phrase aléatoirement
final typePhrase = _random.nextInt(3);
String phrase;
switch (typePhrase) {
case 0:
// Type: Sujet + nom propre + verbe + complément
final sujet = sujets[_random.nextInt(sujets.length)];
final nom = noms[_random.nextInt(noms.length)];
final verbe = verbes[_random.nextInt(verbes.length)];
final complement = complements[_random.nextInt(complements.length)];
phrase = '$sujet $nom $verbe $complement';
break;
case 1:
// Type: Nom propre + a + nombre + ans + point d'exclamation
final nom = noms[_random.nextInt(noms.length)];
final age = 1 + _random.nextInt(20);
phrase = '$nom a $age ans!';
break;
default:
// Type: Sujet + verbe + nombre + complément
final sujet = sujets[_random.nextInt(sujets.length)];
final verbe = verbes[_random.nextInt(verbes.length)];
final nombre = 1 + _random.nextInt(100);
final complement = complements[_random.nextInt(complements.length)];
phrase = '$sujet $verbe $nombre fois $complement';
}
// Ajouter éventuellement un caractère spécial à la fin
if (_random.nextBool()) {
final speciaux = ['!', '?', '.', '...', '', '', '', ''];
phrase += speciaux[_random.nextInt(speciaux.length)];
}
// S'assurer que la phrase fait au moins 8 caractères (elle le sera presque toujours)
if (phrase.length < 8) {
phrase += ' ${1000 + _random.nextInt(9000)}';
}
// Tronquer si trop long (max 64 caractères selon NIST)
if (phrase.length > 64) {
phrase = phrase.substring(0, 64);
}
return phrase;
}
// Méthode publique pour récupérer le mot de passe si défini
String? getPassword() {
@@ -489,6 +422,93 @@ class _UserFormState extends State<UserForm> {
return null;
}
// Méthode asynchrone pour valider et récupérer l'utilisateur avec vérification du username
Future<UserModel?> validateAndGetUserAsync(BuildContext context) async {
if (!_formKey.currentState!.validate()) {
return null;
}
// Déterminer si on doit afficher le champ username selon les règles
final bool shouldShowUsernameField = widget.isAdmin && widget.amicale?.chkUsernameManuel == true;
// Déterminer si le username est éditable
final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit;
// Vérifier si le username a été modifié (seulement en mode édition)
final currentUsername = _usernameController.text;
final bool isUsernameModified = widget.user?.id != 0 && // Mode édition
_initialUsername != null &&
_initialUsername != currentUsername &&
canEditUsername;
// Si le username a été modifié, vérifier sa disponibilité
if (isUsernameModified) {
try {
final result = await _checkUsernameAvailability(currentUsername);
if (result['available'] != true) {
// Afficher l'erreur
if (context.mounted) {
ApiException.showError(
context,
Exception(result['message'] ?? 'Ce nom d\'utilisateur est déjà utilisé')
);
// Si des suggestions sont disponibles, les afficher
if (result['suggestions'] != null && (result['suggestions'] as List).isNotEmpty) {
final suggestions = (result['suggestions'] as List).take(3).join(', ');
if (context.mounted) {
ApiException.showError(
context,
Exception('Suggestions disponibles : $suggestions')
);
}
}
}
return null; // Bloquer la soumission
}
} catch (e) {
// En cas d'erreur réseau ou autre
if (context.mounted) {
ApiException.showError(
context,
Exception('Impossible de vérifier la disponibilité du nom d\'utilisateur')
);
}
return null;
}
}
// Si tout est OK, retourner l'utilisateur
return widget.user?.copyWith(
username: _usernameController.text, // NIST: ne pas faire de trim sur username
firstName: _firstNameController.text.trim(),
name: _nameController.text.trim(),
sectName: _sectNameController.text.trim(),
phone: _phoneController.text.trim(),
mobile: _mobileController.text.trim(),
email: _emailController.text.trim(),
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
) ??
UserModel(
id: 0,
username: _usernameController.text, // NIST: ne pas faire de trim sur username
firstName: _firstNameController.text.trim(),
name: _nameController.text.trim(),
sectName: _sectNameController.text.trim(),
phone: _phoneController.text.trim(),
mobile: _mobileController.text.trim(),
email: _emailController.text.trim(),
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
role: 1,
createdAt: DateTime.now(),
lastSyncedAt: DateTime.now(),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
@@ -496,8 +516,8 @@ class _UserFormState extends State<UserForm> {
// Déterminer si on doit afficher le champ username selon les règles
final bool shouldShowUsernameField = widget.isAdmin && widget.amicale?.chkUsernameManuel == true;
// Déterminer si le username est éditable (seulement en création, jamais en modification)
final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit && widget.user?.id == 0;
// Déterminer si le username est éditable
final bool canEditUsername = shouldShowUsernameField && widget.allowUsernameEdit;
// Déterminer si on doit afficher le champ mot de passe
final bool shouldShowPasswordField = widget.isAdmin && widget.amicale?.chkMdpManuel == true;
@@ -512,13 +532,12 @@ class _UserFormState extends State<UserForm> {
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
isRequired: true,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer l'adresse email";
}
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
// Email optionnel - valider seulement si une valeur est saisie
if (value != null && value.isNotEmpty) {
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
}
}
return null;
},
@@ -731,7 +750,7 @@ class _UserFormState extends State<UserForm> {
readOnly: !canEditUsername,
prefixIcon: Icons.account_circle,
isRequired: canEditUsername,
suffixIcon: (widget.user?.id == 0 && canEditUsername)
suffixIcon: canEditUsername
? _isGeneratingUsername
? SizedBox(
width: 20,
@@ -749,9 +768,9 @@ class _UserFormState extends State<UserForm> {
tooltip: "Générer un nom d'utilisateur",
)
: null,
helperText: canEditUsername
? "8 à 64 caractères. Tous les caractères sont acceptés, y compris les espaces et accents."
: null,
helperText: canEditUsername
? "Identifiant de connexion. 8 à 64 caractères. Tous les caractères sont acceptés."
: "Identifiant de connexion",
helperMaxLines: 2,
validator: canEditUsername
? (value) {
@@ -782,35 +801,14 @@ class _UserFormState extends State<UserForm> {
obscureText: _obscurePassword,
readOnly: widget.readOnly,
prefixIcon: Icons.lock,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton pour afficher/masquer le mot de passe
IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
// Bouton pour générer un mot de passe (seulement si éditable)
if (!widget.readOnly)
IconButton(
icon: Icon(Icons.auto_awesome),
onPressed: () {
final newPassword = _generatePassword();
setState(() {
_passwordController.text = newPassword;
_obscurePassword = false; // Afficher le mot de passe généré
});
// Revalider le formulaire
_formKey.currentState?.validate();
},
tooltip: "Générer un mot de passe sécurisé",
),
],
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
@@ -833,7 +831,7 @@ class _UserFormState extends State<UserForm> {
readOnly: !canEditUsername,
prefixIcon: Icons.account_circle,
isRequired: canEditUsername,
suffixIcon: (widget.user?.id == 0 && canEditUsername)
suffixIcon: canEditUsername
? _isGeneratingUsername
? SizedBox(
width: 20,
@@ -851,9 +849,9 @@ class _UserFormState extends State<UserForm> {
tooltip: "Générer un nom d'utilisateur",
)
: null,
helperText: canEditUsername
? "8 à 64 caractères. Tous les caractères sont acceptés, y compris les espaces et accents."
: null,
helperText: canEditUsername
? "Identifiant de connexion. 8 à 64 caractères. Tous les caractères sont acceptés."
: "Identifiant de connexion",
helperMaxLines: 2,
validator: canEditUsername
? (value) {
@@ -882,35 +880,14 @@ class _UserFormState extends State<UserForm> {
obscureText: _obscurePassword,
readOnly: widget.readOnly,
prefixIcon: Icons.lock,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Bouton pour afficher/masquer le mot de passe
IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
// Bouton pour générer un mot de passe (seulement si éditable)
if (!widget.readOnly)
IconButton(
icon: Icon(Icons.auto_awesome),
onPressed: () {
final newPassword = _generatePassword();
setState(() {
_passwordController.text = newPassword;
_obscurePassword = false; // Afficher le mot de passe généré
});
// Revalider le formulaire
_formKey.currentState?.validate();
},
tooltip: "Générer un mot de passe sécurisé",
),
],
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
tooltip: _obscurePassword ? "Afficher le mot de passe" : "Masquer le mot de passe",
),
helperText: widget.user?.id != 0
? "Laissez vide pour conserver le mot de passe actuel"
@@ -996,10 +973,11 @@ class _UserFormState extends State<UserForm> {
return Row(
children: [
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
Radio<int>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
groupValue: groupValue, // ignore: deprecated_member_use
onChanged: onChanged, // ignore: deprecated_member_use
activeColor: const Color(0xFF20335E),
),
Text(

View File

@@ -58,8 +58,8 @@ class _UserFormDialogState extends State<UserFormDialog> {
}
void _handleSubmit() async {
// Utiliser la méthode validateAndGetUser du UserForm
final userData = _userFormKey.currentState?.validateAndGetUser();
// Utiliser la méthode asynchrone validateAndGetUserAsync du UserForm
final userData = await _userFormKey.currentState?.validateAndGetUserAsync(context);
final password = _userFormKey.currentState?.getPassword(); // Récupérer le mot de passe
if (userData != null) {
@@ -134,33 +134,43 @@ class _UserFormDialogState extends State<UserFormDialog> {
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: widget.availableRoles!.map((role) {
return RadioListTile<int>(
title: Text(role.label),
subtitle: Text(
role.description,
style: theme.textTheme.bodySmall,
),
value: role.value,
groupValue: _selectedRole,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_selectedRole = value;
});
},
activeColor: theme.colorScheme.primary,
);
}).toList(),
),
Row(
children: widget.availableRoles!.map((role) {
return Expanded(
child: Row(
children: [
// TODO: Migrer vers RadioGroup quand disponible (Flutter 4.0+)
Radio<int>(
value: role.value,
groupValue: _selectedRole, // ignore: deprecated_member_use
onChanged: widget.readOnly // ignore: deprecated_member_use
? null
: (value) {
setState(() {
_selectedRole = value;
});
},
activeColor: theme.colorScheme.primary,
),
Flexible(
child: GestureDetector(
onTap: widget.readOnly
? null
: () {
setState(() {
_selectedRole = role.value;
});
},
child: Text(
role.label,
style: theme.textTheme.bodyMedium,
),
),
),
],
),
);
}).toList(),
),
const SizedBox(height: 16),
],