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