Restructuration majeure du projet: migration de flutt vers app, ajout de l'API et mise à jour du site web

This commit is contained in:
d6soft
2025-05-16 09:19:03 +02:00
parent b5aafc424b
commit 5c2620de30
391 changed files with 19780 additions and 7233 deletions

View File

@@ -0,0 +1,557 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
import 'package:geosector_app/presentation/widgets/chat/chat_sidebar.dart';
import 'package:geosector_app/presentation/widgets/chat/chat_messages.dart';
import 'package:geosector_app/presentation/widgets/chat/chat_input.dart';
class AdminCommunicationPage extends StatefulWidget {
const AdminCommunicationPage({Key? key}) : super(key: key);
@override
State<AdminCommunicationPage> createState() => _AdminCommunicationPageState();
}
class _AdminCommunicationPageState extends State<AdminCommunicationPage> {
int selectedContactId = 0;
String selectedContactName = '';
bool isTeamChat = true;
String messageText = '';
bool isReplying = false;
Map<String, dynamic>? replyingTo;
// Données simulées pour les conversations d'équipe
final List<Map<String, dynamic>> teamContacts = [
{
'id': 1,
'name': 'Équipe',
'isGroup': true,
'lastMessage': 'Réunion à 14h aujourd\'hui',
'time': DateTime.now().subtract(const Duration(minutes: 30)),
'unread': 2,
'online': true,
'avatar': 'assets/images/team.png',
},
{
'id': 2,
'name': 'Jean Dupont',
'isGroup': false,
'lastMessage': 'Je serai présent demain',
'time': DateTime.now().subtract(const Duration(hours: 1)),
'unread': 0,
'online': true,
'avatar': 'assets/images/avatar1.png',
},
{
'id': 3,
'name': 'Marie Martin',
'isGroup': false,
'lastMessage': 'Secteur Sud terminé',
'time': DateTime.now().subtract(const Duration(hours: 3)),
'unread': 1,
'online': false,
'avatar': 'assets/images/avatar2.png',
},
{
'id': 4,
'name': 'Pierre Legrand',
'isGroup': false,
'lastMessage': 'J\'ai une question sur mon secteur',
'time': DateTime.now().subtract(const Duration(days: 1)),
'unread': 0,
'online': false,
'avatar': 'assets/images/avatar3.png',
},
];
// Données simulées pour les conversations clients
final List<Map<String, dynamic>> clientContacts = [
{
'id': 101,
'name': 'Martin Durand',
'isGroup': false,
'lastMessage': 'Merci pour votre passage',
'time': DateTime.now().subtract(const Duration(hours: 5)),
'unread': 0,
'online': false,
'avatar': null,
'email': 'martin.durand@example.com',
},
{
'id': 102,
'name': 'Sophie Lambert',
'isGroup': false,
'lastMessage': 'Question concernant le reçu',
'time': DateTime.now().subtract(const Duration(days: 1)),
'unread': 3,
'online': false,
'avatar': null,
'email': 'sophie.lambert@example.com',
},
{
'id': 103,
'name': 'Thomas Bernard',
'isGroup': false,
'lastMessage': 'Rendez-vous manqué',
'time': DateTime.now().subtract(const Duration(days: 2)),
'unread': 0,
'online': false,
'avatar': null,
'email': 'thomas.bernard@example.com',
},
];
// Messages simulés pour la conversation sélectionnée
final Map<int, List<Map<String, dynamic>>> chatMessages = {
1: [
{
'id': 1,
'senderId': 2,
'senderName': 'Jean Dupont',
'message':
'Bonjour à tous, comment avance la collecte dans vos secteurs ?',
'time': DateTime.now().subtract(const Duration(days: 1, hours: 3)),
'isRead': true,
'avatar': 'assets/images/avatar1.png',
},
{
'id': 2,
'senderId': 3,
'senderName': 'Marie Martin',
'message': 'J\'ai terminé le secteur Sud avec 45 passages réalisés !',
'time': DateTime.now()
.subtract(const Duration(days: 1, hours: 2, minutes: 30)),
'isRead': true,
'avatar': 'assets/images/avatar2.png',
},
{
'id': 3,
'senderId': 4,
'senderName': 'Pierre Legrand',
'message':
'Secteur Est en cours, j\'ai réalisé 28 passages pour l\'instant.',
'time': DateTime.now().subtract(const Duration(days: 1, hours: 2)),
'isRead': true,
'avatar': 'assets/images/avatar3.png',
},
{
'id': 4,
'senderId': 0,
'senderName': 'Vous',
'message':
'Parfait, n\'oubliez pas la réunion de demain à 14h pour faire le point !',
'time': DateTime.now().subtract(const Duration(hours: 1)),
'isRead': true,
},
{
'id': 5,
'senderId': 2,
'senderName': 'Jean Dupont',
'message': 'Je serai présent 👍',
'time': DateTime.now().subtract(const Duration(minutes: 30)),
'isRead': false,
'avatar': 'assets/images/avatar1.png',
},
],
2: [
{
'id': 101,
'senderId': 2,
'senderName': 'Jean Dupont',
'message':
'Bonjour, est-ce que je peux commencer le secteur Ouest demain ?',
'time': DateTime.now().subtract(const Duration(days: 2)),
'isRead': true,
'avatar': 'assets/images/avatar1.png',
},
{
'id': 102,
'senderId': 0,
'senderName': 'Vous',
'message': 'Bonjour Jean, oui bien sûr. Les documents sont prêts.',
'time': DateTime.now()
.subtract(const Duration(days: 2))
.add(const Duration(minutes: 15)),
'isRead': true,
},
{
'id': 103,
'senderId': 2,
'senderName': 'Jean Dupont',
'message': 'Merci ! Je passerai les récupérer ce soir.',
'time': DateTime.now()
.subtract(const Duration(days: 2))
.add(const Duration(minutes: 20)),
'isRead': true,
'avatar': 'assets/images/avatar1.png',
},
{
'id': 104,
'senderId': 2,
'senderName': 'Jean Dupont',
'message': 'Je serai présent à la réunion de demain.',
'time': DateTime.now().subtract(const Duration(hours: 1)),
'isRead': true,
'avatar': 'assets/images/avatar1.png',
},
],
101: [
{
'id': 201,
'senderId': 101,
'senderName': 'Martin Durand',
'message':
'Bonjour, je voulais vous remercier pour votre passage. J\'ai bien reçu le reçu par email.',
'time': DateTime.now().subtract(const Duration(days: 1, hours: 5)),
'isRead': true,
},
{
'id': 202,
'senderId': 0,
'senderName': 'Vous',
'message':
'Bonjour M. Durand, je vous remercie pour votre contribution. N\'hésitez pas si vous avez des questions.',
'time': DateTime.now().subtract(const Duration(days: 1, hours: 4)),
'isRead': true,
},
{
'id': 203,
'senderId': 101,
'senderName': 'Martin Durand',
'message': 'Tout est parfait, merci !',
'time': DateTime.now().subtract(const Duration(hours: 5)),
'isRead': true,
},
],
102: [
{
'id': 301,
'senderId': 102,
'senderName': 'Sophie Lambert',
'message':
'Bonjour, je n\'ai pas reçu le reçu suite à mon paiement d\'hier. Pouvez-vous vérifier ?',
'time': DateTime.now().subtract(const Duration(days: 1, hours: 3)),
'isRead': true,
},
{
'id': 302,
'senderId': 0,
'senderName': 'Vous',
'message':
'Bonjour Mme Lambert, je m\'excuse pour ce désagrément. Je vérifie cela immédiatement.',
'time': DateTime.now().subtract(const Duration(days: 1, hours: 2)),
'isRead': true,
},
{
'id': 303,
'senderId': 0,
'senderName': 'Vous',
'message':
'Il semble qu\'il y ait eu un problème technique. Je viens de renvoyer le reçu à votre adresse email. Pourriez-vous vérifier si vous l\'avez bien reçu ?',
'time': DateTime.now().subtract(const Duration(days: 1, hours: 1)),
'isRead': true,
},
{
'id': 304,
'senderId': 102,
'senderName': 'Sophie Lambert',
'message':
'Je n\'ai toujours rien reçu. Mon email est-il correct ? C\'est sophie.lambert@example.com',
'time': DateTime.now().subtract(const Duration(days: 1)),
'isRead': true,
},
{
'id': 305,
'senderId': 102,
'senderName': 'Sophie Lambert',
'message': 'Est-ce que vous pouvez réessayer ?',
'time': DateTime.now().subtract(const Duration(hours: 5)),
'isRead': false,
},
{
'id': 306,
'senderId': 102,
'senderName': 'Sophie Lambert',
'message': 'Toujours pas de nouvelles...',
'time': DateTime.now().subtract(const Duration(hours: 3)),
'isRead': false,
},
{
'id': 307,
'senderId': 102,
'senderName': 'Sophie Lambert',
'message': 'Pouvez-vous me contacter dès que possible ?',
'time': DateTime.now().subtract(const Duration(hours: 1)),
'isRead': false,
},
],
};
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
return Row(
children: [
// Sidebar des contacts (fixe sur desktop, conditional sur mobile)
if (isDesktop || selectedContactId == 0)
SizedBox(
width: isDesktop ? 320 : screenWidth,
child: ChatSidebar(
teamContacts: teamContacts,
clientContacts: clientContacts,
isTeamChat: isTeamChat,
selectedContactId: selectedContactId,
onContactSelected: (contactId, contactName, isTeam) {
setState(() {
selectedContactId = contactId;
selectedContactName = contactName;
isTeamChat = isTeam;
replyingTo = null;
isReplying = false;
});
},
onToggleGroup: (isTeam) {
setState(() {
isTeamChat = isTeam;
selectedContactId = 0;
selectedContactName = '';
});
},
),
),
// Vue des messages (conditionnelle sur mobile)
if (isDesktop || selectedContactId != 0)
Expanded(
child: selectedContactId == 0
? const Center(
child: Text('Sélectionnez une conversation pour commencer'),
)
: Column(
children: [
// En-tête de la conversation
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingL,
vertical: AppTheme.spacingM,
),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
if (!isDesktop)
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
setState(() {
selectedContactId = 0;
selectedContactName = '';
});
},
),
CircleAvatar(
radius: 20,
backgroundColor:
AppTheme.primaryColor.withOpacity(0.2),
backgroundImage:
_getAvatarForContact(selectedContactId),
child: _getAvatarForContact(selectedContactId) ==
null
? Text(
selectedContactName.isNotEmpty
? selectedContactName[0].toUpperCase()
: '',
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold,
),
)
: null,
),
const SizedBox(width: AppTheme.spacingM),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
selectedContactName,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
if (!isTeamChat && selectedContactId > 100)
Text(
_getEmailForContact(selectedContactId),
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.info_outline),
onPressed: () {
// Afficher les détails du contact
},
),
],
),
),
// Messages
Expanded(
child: ChatMessages(
messages: chatMessages[selectedContactId] ?? [],
currentUserId: 0,
onReply: (message) {
setState(() {
isReplying = true;
replyingTo = message;
});
},
),
),
// Zone de réponse
if (isReplying)
Container(
padding: const EdgeInsets.all(AppTheme.spacingM),
color: Colors.grey[100],
child: Row(
children: [
Container(
width: 4,
height: 40,
decoration: BoxDecoration(
color: AppTheme.primaryColor,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: AppTheme.spacingM),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Réponse à ${replyingTo?['senderName']}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: AppTheme.primaryColor,
),
),
Text(
replyingTo?['message'] ?? '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
setState(() {
isReplying = false;
replyingTo = null;
});
},
),
],
),
),
// Zone de saisie du message
ChatInput(
onMessageSent: (text) {
setState(() {
// Ajouter le message à la conversation
if (chatMessages[selectedContactId] != null) {
final newMessageId =
chatMessages[selectedContactId]!.last['id'] +
1;
chatMessages[selectedContactId]!.add({
'id': newMessageId,
'senderId': 0,
'senderName': 'Vous',
'message': text,
'time': DateTime.now(),
'isRead': false,
'replyTo': isReplying ? replyingTo : null,
});
// Mise à jour du dernier message pour le contact
final contactsList =
isTeamChat ? teamContacts : clientContacts;
final contactIndex = contactsList.indexWhere(
(c) => c['id'] == selectedContactId);
if (contactIndex != -1) {
contactsList[contactIndex]['lastMessage'] =
text;
contactsList[contactIndex]['time'] =
DateTime.now();
contactsList[contactIndex]['unread'] = 0;
}
isReplying = false;
replyingTo = null;
}
});
},
),
],
),
),
],
);
}
ImageProvider? _getAvatarForContact(int contactId) {
String? avatarPath;
if (isTeamChat) {
final contact = teamContacts.firstWhere(
(c) => c['id'] == contactId,
orElse: () => {'avatar': null},
);
avatarPath = contact['avatar'];
} else {
final contact = clientContacts.firstWhere(
(c) => c['id'] == contactId,
orElse: () => {'avatar': null},
);
avatarPath = contact['avatar'];
}
return avatarPath != null ? AssetImage(avatarPath) : null;
}
String _getEmailForContact(int contactId) {
if (!isTeamChat) {
final contact = clientContacts.firstWhere(
(c) => c['id'] == contactId,
orElse: () => {'email': ''},
);
return contact['email'] ?? '';
}
return '';
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,279 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/shared/app_theme.dart';
import 'package:geosector_app/presentation/widgets/loading_progress_overlay.dart';
import 'package:geosector_app/core/models/loading_state.dart';
import 'dart:math' as math;
// Import des pages admin
import 'admin_dashboard_home_page.dart';
import 'admin_statistics_page.dart';
import 'admin_history_page.dart';
import 'admin_communication_page.dart';
import 'admin_map_page.dart';
import 'admin_entite.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.withOpacity(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({Key? key}) : super(key: key);
@override
State<AdminDashboardPage> createState() => _AdminDashboardPageState();
}
class _AdminDashboardPageState extends State<AdminDashboardPage>
with WidgetsBindingObserver {
int _selectedIndex = 0;
// Liste des pages à afficher
late final List<Widget> _pages;
// Index de la page Amicale et membres
static const int entitePageIndex = 5;
// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
// Overlay pour afficher la progression du chargement
OverlayEntry? _progressOverlay;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
try {
debugPrint('Initialisation de AdminDashboardPage');
// Vérifier que userRepository est correctement initialisé
if (userRepository == null) {
debugPrint('ERREUR: userRepository est null dans AdminDashboardPage');
} else {
debugPrint('userRepository est correctement initialisé');
// Vérifier l'utilisateur courant
final currentUser = userRepository.getCurrentUser();
if (currentUser == null) {
debugPrint(
'ERREUR: Aucun utilisateur connecté dans AdminDashboardPage',
);
} else {
debugPrint(
'Utilisateur connecté: ${currentUser.username} (${currentUser.id})',
);
}
// Écouter les changements d'état du UserRepository
userRepository.addListener(_handleUserRepositoryChanges);
}
_pages = [
const AdminDashboardHomePage(),
const AdminStatisticsPage(),
const AdminHistoryPage(),
const AdminCommunicationPage(),
const AdminMapPage(),
const AdminEntitePage(),
];
// Initialiser et charger les paramètres
_initSettings();
// 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);
if (userRepository != null) {
userRepository.removeListener(_handleUserRepositoryChanges);
}
_removeProgressOverlay();
super.dispose();
}
// Méthode pour gérer les changements d'état du UserRepository
void _handleUserRepositoryChanges() {
_checkLoadingState();
}
// 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
}
// Méthodes pour gérer l'overlay de progression (désactivées)
void _showProgressOverlay(LoadingState state) {
// La barre de progression est désactivée, ne rien faire
}
void _updateProgressOverlay(LoadingState state) {
// La barre de progression est désactivée, ne rien faire
}
void _removeProgressOverlay() {
// La barre de progression est désactivée, ne rien faire
}
// Initialiser la boîte de paramètres et charger les préférences
Future<void> _initSettings() async {
try {
// 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('adminSelectedPageIndex');
// Vérifier si l'index sauvegardé est valide
if (savedIndex != null && savedIndex is int) {
debugPrint('Index sauvegardé trouvé: $savedIndex');
// S'assurer que l'index est dans les limites valides
if (savedIndex >= 0 && savedIndex < _pages.length) {
setState(() {
_selectedIndex = savedIndex;
});
debugPrint('Index sauvegardé valide, utilisé: $_selectedIndex');
} else {
debugPrint(
'Index sauvegardé invalide ($savedIndex), utilisation de l\'index par défaut: 0',
);
// Réinitialiser l'index sauvegardé à 0 si invalide
_settingsBox.put('adminSelectedPageIndex', 0);
}
} 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('adminSelectedPageIndex', _selectedIndex);
} catch (e) {
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
}
}
@override
Widget build(BuildContext context) {
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: Container(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: _buildNavigationDestinations(),
showNewPassageButton: false,
isAdmin: true,
body: _pages[_selectedIndex],
),
],
);
}
/// Construit la liste des destinations de navigation
List<NavigationDestination> _buildNavigationDestinations() {
// Destinations de base toujours présentes
final List<NavigationDestination> destinations = [
const NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
const NavigationDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: 'Statistiques',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
const NavigationDestination(
icon: Icon(Icons.chat_outlined),
selectedIcon: Icon(Icons.chat),
label: 'Messages',
),
const NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
];
// Ajouter la destination "Amicale et membres"
destinations.add(
const NavigationDestination(
icon: Icon(Icons.business_outlined),
selectedIcon: Icon(Icons.business),
label: 'Amicale',
),
);
return destinations;
}
}

View File

@@ -0,0 +1,46 @@
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({Key? key}) : super(key: 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.withOpacity(0.1),
),
// Autres options de débogage peuvent être ajoutées ici
],
),
),
);
}
}

View File

@@ -0,0 +1,361 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/presentation/widgets/amicale_table_widget.dart';
import 'package:geosector_app/presentation/widgets/membre_table_widget.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.withOpacity(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;
}
/// Page d'administration de l'amicale et des membres
/// Cette page est intégrée dans le tableau de bord administrateur
class AdminEntitePage extends StatefulWidget {
const AdminEntitePage({Key? key}) : super(key: key);
@override
State<AdminEntitePage> createState() => _AdminEntitePageState();
}
class _AdminEntitePageState extends State<AdminEntitePage> {
bool _isLoading = true;
AmicaleModel? _amicale;
List<MembreModel> _membres = [];
String? _errorMessage;
@override
void initState() {
super.initState();
_loadData();
}
Future<void> _loadData() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Récupérer l'utilisateur connecté en utilisant l'instance globale
final currentUser = userRepository.getCurrentUser();
if (currentUser == null) {
setState(() {
_errorMessage = 'Utilisateur non connecté';
_isLoading = false;
});
return;
}
// Vérifier si fkEntite est null
if (currentUser.fkEntite == null) {
setState(() {
_errorMessage = 'Utilisateur non associé à une amicale';
_isLoading = false;
});
return;
}
// Récupérer l'amicale de l'utilisateur en utilisant l'instance globale
final amicale = amicaleRepository.getAmicaleById(currentUser.fkEntite!);
if (amicale == null) {
setState(() {
_errorMessage = 'Amicale non trouvée';
_isLoading = false;
});
return;
}
// Récupérer tous les membres
// Note: Dans un cas réel, nous devrions filtrer les membres par amicale,
// mais le modèle MembreModel n'a pas de champ fkEntite pour le moment
final membres = membreRepository.getAllMembres();
setState(() {
_amicale = amicale;
_membres = membres;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Erreur lors du chargement des données: $e';
_isLoading = false;
});
}
}
void _handleEditAmicale(AmicaleModel amicale) {
// Afficher une boîte de dialogue de confirmation
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Modifier l\'amicale'),
content: Text('Voulez-vous modifier l\'amicale ${amicale.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
// Naviguer vers la page de modification
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => EditAmicalePage(amicale: amicale),
// ),
// );
},
child: const Text('Modifier'),
),
],
),
);
}
void _handleEditMembre(MembreModel membre) {
// Afficher une boîte de dialogue de confirmation
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Modifier le membre'),
content: Text(
'Voulez-vous modifier le membre ${membre.firstName} ${membre.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
// Naviguer vers la page de modification
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => EditMembrePage(membre: membre),
// ),
// );
},
child: const Text('Modifier'),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
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: Container(width: double.infinity, height: double.infinity),
),
),
// Contenu principal
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de la page
Text(
'Mon amicale et ses membres',
style: theme.textTheme.headlineMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
// Message d'erreur si présent
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
),
),
],
),
),
// Contenu principal
if (_isLoading)
const Expanded(
child: Center(
child: CircularProgressIndicator(),
),
)
else if (_amicale == null)
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.business_outlined,
size: 64,
color: theme.colorScheme.primary.withOpacity(0.7),
),
const SizedBox(height: 16),
Text(
'Aucune amicale associée',
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
'Vous n\'êtes pas associé à une amicale.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge,
),
],
),
),
)
else
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Amicale
Text(
'Informations de l\'amicale',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
// Tableau Amicale
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: AmicaleTableWidget(
amicales: [_amicale!],
// Pas de bouton de suppression pour sa propre amicale
onDelete: null,
),
),
const SizedBox(height: 32),
// Section Membres
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Membres de l\'amicale',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
ElevatedButton.icon(
onPressed: () {
// Naviguer vers la page d'ajout de membre
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => AddMembrePage(amicaleId: _amicale!.id),
// ),
// );
},
icon: const Icon(Icons.add),
label: const Text('Ajouter un membre'),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
),
),
],
),
const SizedBox(height: 16),
// Tableau Membres
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: MembreTableWidget(
membres: _membres,
onEdit: _handleEditMembre,
// Pas de bouton de suppression pour les membres de sa propre amicale
// sauf si l'utilisateur a un rôle élevé
onDelete: null,
),
),
),
],
),
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,945 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:geosector_app/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/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/theme/app_theme.dart';
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.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.withOpacity(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 AdminHistoryPage extends StatefulWidget {
const AdminHistoryPage({Key? key}) : super(key: key);
@override
State<AdminHistoryPage> createState() => _AdminHistoryPageState();
}
class _AdminHistoryPageState extends State<AdminHistoryPage> {
// État des filtres
String searchQuery = '';
String selectedSector = 'Tous';
String selectedUser = 'Tous';
String selectedType = 'Tous';
String selectedPaymentMethod = 'Tous';
String selectedPeriod = 'Dernier mois'; // Période par défaut
DateTimeRange? selectedDateRange;
// IDs pour les filtres
int? selectedSectorId;
int? selectedUserId;
// Listes pour les filtres
List<SectorModel> _sectors = [];
List<UserModel> _users = [];
// Repositories
late PassageRepository _passageRepository;
late SectorRepository _sectorRepository;
late UserRepository _userRepository;
// Passages formatés
List<Map<String, dynamic>> _formattedPassages = [];
// État de chargement
bool _isLoading = true;
String _errorMessage = '';
@override
void initState() {
super.initState();
// Initialiser les filtres
_initializeFilters();
}
@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;
// Charger les secteurs et les utilisateurs
_loadSectorsAndUsers();
// Charger les passages
_loadPassages();
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Erreur lors du chargement des repositories: $e';
});
}
}
// Charger les secteurs et les utilisateurs
void _loadSectorsAndUsers() {
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 utilisateurs
_users = _userRepository.getAllUsers();
debugPrint('Nombre d\'utilisateurs récupérés: ${_users.length}');
} catch (e) {
debugPrint('Erreur lors du chargement des secteurs et utilisateurs: $e');
}
}
// Charger les passages
void _loadPassages() {
setState(() {
_isLoading = true;
});
try {
// Récupérer les passages
final List<PassageModel> allPassages =
_passageRepository.getAllPassages();
// Convertir les passages en format attendu par PassagesListWidget
_formattedPassages = _formatPassagesForWidget(
allPassages, _sectorRepository, _userRepository);
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 par utilisateur ou secteur
selectedSectorId = null;
selectedUserId = null;
// Période par défaut : dernier mois
selectedPeriod = 'Dernier mois';
// Plage de dates par défaut : dernier mois
final DateTime now = DateTime.now();
final DateTime oneMonthAgo = DateTime(now.year, now.month - 1, now.day);
selectedDateRange = DateTimeRange(start: oneMonthAgo, end: now);
}
// Mettre à jour le filtre par secteur
void _updateSectorFilter(String sectorName, int? sectorId) {
setState(() {
selectedSector = sectorName;
selectedSectorId = sectorId;
});
}
// Mettre à jour le filtre par utilisateur
void _updateUserFilter(String userName, int? userId) {
setState(() {
selectedUser = userName;
selectedUserId = userId;
});
}
// 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;
}
});
}
@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: Container(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: Container(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de la page
Text(
'Historique des passages',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 16),
// Filtres supplémentaires (secteur, utilisateur, période)
_buildAdditionalFilters(context),
const SizedBox(height: 16),
// Widget de liste des passages
Expanded(
child: PassagesListWidget(
passages: _formattedPassages,
showFilters: true,
showSearch: true,
showActions: true,
initialSearchQuery: searchQuery,
initialTypeFilter: selectedType,
initialPaymentFilter: selectedPaymentMethod,
// Exclure les passages de type 2 (À finaliser)
excludePassageTypes: [2],
// Filtres par utilisateur et secteur
filterByUserId: selectedUserId,
filterBySectorId: selectedSectorId,
// Période par défaut (dernier mois)
periodFilter: 'lastMonth',
// Plage de dates personnalisée si définie
dateRange: selectedDateRange,
onPassageSelected: (passage) {
_showDetailsDialog(context, passage);
},
onReceiptView: (passage) {
_showReceiptDialog(context, passage);
},
onDetailsView: (passage) {
_showDetailsDialog(context, passage);
},
onPassageEdit: (passage) {
// Action pour modifier le passage
// Cette fonctionnalité pourrait être implémentée ultérieurement
},
),
),
],
),
),
],
);
}
// 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: Container(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: 24,
fontWeight: FontWeight.bold,
color: Colors.red[700],
),
),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 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,
UserRepository userRepository) {
return passages.map((passage) {
// Récupérer le secteur associé au passage
final SectorModel? sector =
sectorRepository.getSectorById(passage.fkSector);
// Récupérer l'utilisateur associé au passage
final UserModel? user = userRepository.getUserById(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;
return {
'id': passage.id,
'date': passage.passedAt,
'address': address,
'fkSector': passage.fkSector,
'sector': sector?.libelle ?? 'Secteur inconnu',
'fkUser': passage.fkUser,
'user': user?.name ?? 'Utilisateur 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,
// Ajouter d'autres champs nécessaires 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'),
),
],
),
);
}
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'),
),
ElevatedButton(
onPressed: () {
// Action pour modifier le passage
Navigator.pop(context);
},
child: const Text('Modifier'),
),
],
),
);
}
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: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
Text('$user - $action'),
const Divider(),
],
),
);
}
// Construction des filtres supplémentaires
Widget _buildAdditionalFilters(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
color: Colors.white, // Fond opaque
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Filtres avancés',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 16),
// Disposition des filtres en fonction de la taille de l'écran
isDesktop
? Row(
children: [
// Filtre par secteur
Expanded(
child: _buildSectorFilter(theme, _sectors),
),
const SizedBox(width: 16),
// Filtre par utilisateur
Expanded(
child: _buildUserFilter(theme, _users),
),
const SizedBox(width: 16),
// Filtre par période
Expanded(
child: _buildPeriodFilter(theme),
),
],
)
: Column(
children: [
// Filtre par secteur
_buildSectorFilter(theme, _sectors),
const SizedBox(height: 16),
// Filtre par utilisateur
_buildUserFilter(theme, _users),
const SizedBox(height: 16),
// Filtre par période
_buildPeriodFilter(theme),
],
),
],
),
),
);
}
// Construction du filtre par secteur
Widget _buildSectorFilter(ThemeData theme, List<SectorModel> sectors) {
// Vérifier si la liste des secteurs est vide ou si selectedSector n'est pas dans la liste
bool isSelectedSectorValid = selectedSector == 'Tous' ||
sectors.any((s) => s.libelle == selectedSector);
// Si selectedSector n'est pas valide, le réinitialiser à 'Tous'
if (!isSelectedSectorValid) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
selectedSector = 'Tous';
selectedSectorId = null;
});
}
});
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Secteur',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8.0),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: isSelectedSectorValid ? selectedSector : 'Tous',
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
items: [
const DropdownMenuItem<String>(
value: 'Tous',
child: Text('Tous les secteurs'),
),
...sectors.map((sector) {
final String libelle = sector.libelle.isNotEmpty
? sector.libelle
: 'Secteur ${sector.id}';
return DropdownMenuItem<String>(
value: libelle,
child: Text(
libelle,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
],
onChanged: (String? value) {
if (value != null) {
if (value == 'Tous') {
_updateSectorFilter('Tous', null);
} else {
try {
// Trouver le secteur correspondant
final sector = sectors.firstWhere(
(s) => s.libelle == value,
orElse: () => sectors.isNotEmpty
? sectors.first
: throw Exception('Liste de secteurs vide'),
);
// Convertir sector.id en int? si nécessaire
_updateSectorFilter(value, sector.id);
} catch (e) {
debugPrint('Erreur lors de la sélection du secteur: $e');
_updateSectorFilter('Tous', null);
}
}
}
},
),
),
),
],
);
}
// Construction du filtre par utilisateur
Widget _buildUserFilter(ThemeData theme, List<UserModel> users) {
// Vérifier si la liste des utilisateurs est vide ou si selectedUser n'est pas dans la liste
bool isSelectedUserValid = selectedUser == 'Tous' ||
users.any((u) => (u.name ?? 'Utilisateur inconnu') == selectedUser);
// Si selectedUser n'est pas valide, le réinitialiser à 'Tous'
if (!isSelectedUserValid) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
selectedUser = 'Tous';
selectedUserId = null;
});
}
});
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Utilisateur',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8.0),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: isSelectedUserValid ? selectedUser : 'Tous',
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
items: [
const DropdownMenuItem<String>(
value: 'Tous',
child: Text('Tous les utilisateurs'),
),
...users.map((user) {
// S'assurer que user.name n'est pas null
final String userName = user.name ?? 'Utilisateur inconnu';
return DropdownMenuItem<String>(
value: userName,
child: Text(
userName,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
],
onChanged: (String? value) {
if (value != null) {
if (value == 'Tous') {
_updateUserFilter('Tous', null);
} else {
try {
// Trouver l'utilisateur correspondant
final user = users.firstWhere(
(u) => (u.name ?? 'Utilisateur inconnu') == value,
orElse: () => users.isNotEmpty
? users.first
: throw Exception('Liste d\'utilisateurs vide'),
);
// S'assurer que user.name et user.id ne sont pas null
final String userName =
user.name ?? 'Utilisateur inconnu';
final int? userId = user.id;
_updateUserFilter(userName, userId);
} catch (e) {
debugPrint(
'Erreur lors de la sélection de l\'utilisateur: $e');
_updateUserFilter('Tous', null);
}
}
}
},
),
),
),
],
);
}
// Construction du filtre par période
Widget _buildPeriodFilter(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Période',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8.0),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedPeriod,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
items: const [
DropdownMenuItem<String>(
value: 'Tous',
child: Text('Toutes les périodes'),
),
DropdownMenuItem<String>(
value: 'Derniers 15 jours',
child: Text('Derniers 15 jours'),
),
DropdownMenuItem<String>(
value: 'Dernière semaine',
child: Text('Dernière semaine'),
),
DropdownMenuItem<String>(
value: 'Dernier mois',
child: Text('Dernier mois'),
),
],
onChanged: (String? value) {
if (value != null) {
_updatePeriodFilter(value);
}
},
),
),
),
// Afficher la plage de dates sélectionnée si elle existe
if (selectedDateRange != null && selectedPeriod != 'Tous')
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
Icon(
Icons.date_range,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Du ${selectedDateRange!.start.day}/${selectedDateRange!.start.month}/${selectedDateRange!.start.year} au ${selectedDateRange!.end.day}/${selectedDateRange!.end.month}/${selectedDateRange!.end.year}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
);
}
void _showResendConfirmation(BuildContext context, int passageId) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Renvoyer le reçu'),
content: Text(
'Êtes-vous sûr de vouloir renvoyer le reçu du passage #$passageId ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
// Action pour renvoyer le reçu
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Reçu du passage #$passageId renvoyé avec succès'),
backgroundColor: Colors.green,
),
);
},
child: const Text('Renvoyer'),
),
],
),
);
}
}

View File

@@ -0,0 +1,898 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/presentation/widgets/mapbox_map.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/location_service.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import '../../shared/app_theme.dart';
class AdminMapPage extends StatefulWidget {
const AdminMapPage({Key? key}) : super(key: key);
@override
State<AdminMapPage> createState() => _AdminMapPageState();
}
class _AdminMapPageState extends State<AdminMapPage> {
// 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 = [];
// États
bool _editMode = false;
int? _selectedSectorId;
List<DropdownMenuItem<int?>> _sectorItems = [];
// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
@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 le secteur sélectionné
_selectedSectorId = _settingsBox.get('admin_selectedSectorId');
// Charger la position et le zoom
final double? savedLat = _settingsBox.get('admin_mapLat');
final double? savedLng = _settingsBox.get('admin_mapLng');
final double? savedZoom = _settingsBox.get('admin_mapZoom');
if (savedLat != null && savedLng != null) {
_currentPosition = LatLng(savedLat, savedLng);
}
if (savedZoom != null) {
_currentZoom = savedZoom;
}
}
// Sauvegarder les paramètres utilisateur
void _saveSettings() {
// Sauvegarder le secteur sélectionné
if (_selectedSectorId != null) {
_settingsBox.put('admin_selectedSectorId', _selectedSectorId);
}
// Sauvegarder la position et le zoom actuels
_settingsBox.put('admin_mapLat', _currentPosition.latitude);
_settingsBox.put('admin_mapLng', _currentPosition.longitude);
_settingsBox.put('admin_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,
});
}
}
// Si un secteur était sélectionné précédemment, le centrer
// Mettre à jour les items de la combobox de secteurs
_updateSectorItems();
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');
}
}
// 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
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');
}
}
// 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');
}
// 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;
});
}
// 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;
}
// 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;
// Ajouter un padding minimal aux limites pour s'assurer que le secteur est bien visible
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;
// 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);
}
// 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;
});
// Recharger les passages pour appliquer le filtre par secteur
_loadPassages();
}
// 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) {
// 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;
// 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
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;
}
return zoom;
}
// 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('admin_mapLat', position.latitude);
_settingsBox.put('admin_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,
),
);
}
}
}
// 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();
}
// Méthode pour construire les marqueurs des passages
List<Marker> _buildMarkers() {
if (_passages.isEmpty) {
return [];
}
return _passages.map((passage) {
final int passageType = passage['type'] as int;
final Color color1 =
passage['color'] as Color; // couleur1 du type de passage
// Récupérer la couleur2 du type de passage
Color color2 = Colors.white; // Couleur par défaut
if (AppKeys.typesPassages.containsKey(passageType)) {
final colorValue =
AppKeys.typesPassages[passageType]!['couleur2'] as int;
color2 = Color(colorValue);
}
return Marker(
point: passage['position'] as LatLng,
width: 14.0,
height: 14.0,
child: GestureDetector(
onTap: () {
_showPassageInfo(passage);
},
child: Container(
decoration: BoxDecoration(
color: color1,
shape: BoxShape.circle,
border: Border.all(
color: color2,
width: 1.0,
),
),
),
),
);
}).toList();
}
// Méthode pour construire les polygones des secteurs
List<Polygon> _buildPolygons() {
if (_sectors.isEmpty) {
return [];
}
return _sectors.map((sector) {
final bool isSelected = _selectedSectorId == sector['id'];
final Color sectorColor = sector['color'] as Color;
return Polygon(
points: sector['points'] as List<LatLng>,
color: isSelected
? sectorColor.withOpacity(0.5)
: sectorColor.withOpacity(0.3),
borderColor: isSelected ? sectorColor : sectorColor.withOpacity(0.8),
borderStrokeWidth: isSelected ? 3.0 : 2.0,
);
}).toList();
}
// Afficher les informations d'un passage lorsqu'on clique dessus
void _showPassageInfo(Map<String, dynamic> passage) {
final PassageModel passageModel = passage['model'] as PassageModel;
final int type = passageModel.fkType;
// Construire l'adresse complète
final String adresse =
'${passageModel.numero}, ${passageModel.rueBis} ${passageModel.rue}';
// Informations sur l'étage, l'appartement et la résidence (si habitat = 2)
String? etageInfo;
String? apptInfo;
String? residenceInfo;
if (passageModel.fkHabitat == 2) {
if (passageModel.niveau.isNotEmpty) {
etageInfo = 'Etage ${passageModel.niveau}';
}
if (passageModel.appt.isNotEmpty) {
apptInfo = 'appt. ${passageModel.appt}';
}
if (passageModel.residence.isNotEmpty) {
residenceInfo = passageModel.residence;
}
}
// Formater la date (uniquement si le type n'est pas 2)
String dateInfo = '';
if (type != 2) {
dateInfo = 'Date: ${_formatDate(passageModel.passedAt)}';
}
// Récupérer le nom du passage (si le type n'est pas 6 - Maison vide)
String? nomInfo;
if (type != 6 && passageModel.name.isNotEmpty) {
nomInfo = passageModel.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) {
final int typeReglementId = passageModel.fkTypeReglement;
final String montant = passageModel.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 = Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
Icon(iconData, color: couleur, size: 20),
const SizedBox(width: 8),
Text('$titre: $montant',
style:
TextStyle(color: couleur, fontWeight: FontWeight.bold)),
],
),
);
}
}
// Afficher une bulle d'information
showDialog(
context: context,
builder: (context) => AlertDialog(
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Adresse: $adresse'),
if (residenceInfo != null) ...[
const SizedBox(height: 4),
Text(residenceInfo)
],
if (etageInfo != null) ...[
const SizedBox(height: 4),
Text(etageInfo)
],
if (apptInfo != null) ...[
const SizedBox(height: 4),
Text(apptInfo)
],
if (dateInfo.isNotEmpty) ...[
const SizedBox(height: 8),
Text(dateInfo)
],
if (nomInfo != null) ...[
const SizedBox(height: 8),
Text('Nom: $nomInfo')
],
if (reglementInfo != null) reglementInfo,
],
),
actionsPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
// Bouton d'édition
IconButton(
onPressed: () {
Navigator.of(context).pop();
// Logique pour éditer le passage
debugPrint('Éditer le passage ${passageModel.id}');
},
icon: const Icon(Icons.edit),
color: Colors.blue,
tooltip: 'Modifier',
),
// Bouton de suppression
IconButton(
onPressed: () {
Navigator.of(context).pop();
// Logique pour supprimer le passage
debugPrint('Supprimer le passage ${passageModel.id}');
},
icon: const Icon(Icons.delete),
color: Colors.red,
tooltip: 'Supprimer',
),
],
),
// Bouton de fermeture
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
],
),
);
}
// Formater une date
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
}
// Widget pour les boutons d'action
Widget _buildActionButton({
required IconData icon,
required String tooltip,
required VoidCallback? onPressed,
Color color = Colors.blue,
}) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: FloatingActionButton(
heroTag: tooltip, // Nécessaire pour éviter les conflits de hero tags
onPressed: onPressed,
backgroundColor: onPressed != null ? color : Colors.grey,
tooltip: tooltip,
mini: true,
child: Icon(icon),
),
);
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Carte MapBox
MapboxMap(
initialPosition: _currentPosition,
initialZoom: _currentZoom,
mapController: _mapController,
markers: _buildMarkers(),
polygons: _buildPolygons(),
showControls: true,
onMapEvent: (event) {
if (event is MapEventMove) {
setState(() {
_currentPosition = event.camera.center;
_currentZoom = event.camera.zoom;
});
_saveSettings();
}
},
),
// Bouton Mode édition en haut à droite
Positioned(
right: 16,
top: 16,
child: _buildActionButton(
icon: Icons.edit,
tooltip: 'Mode édition',
color: _editMode ? Colors.green : Colors.blue,
onPressed: () {
setState(() {
_editMode = !_editMode;
});
},
),
),
// Boutons d'action sous le bouton Mode édition
Positioned(
right: 16,
top: 80, // Positionner sous le bouton Mode édition
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (_editMode) ...[
_buildActionButton(
icon: Icons.add,
tooltip: 'Ajouter un secteur',
onPressed: () {
// Action pour ajouter un secteur
},
),
const SizedBox(height: 8),
_buildActionButton(
icon: Icons.edit,
tooltip: 'Modifier le secteur sélectionné',
onPressed: _selectedSectorId != null
? () {
// Action pour modifier le secteur sélectionné
}
: null,
),
const SizedBox(height: 8),
_buildActionButton(
icon: Icons.delete,
tooltip: 'Supprimer le secteur sélectionné',
color: Colors.red,
onPressed: _selectedSectorId != null
? () {
// Action pour supprimer le secteur sélectionné
}
: null,
),
const SizedBox(height: 16),
],
],
),
),
// Bouton Ma position en bas à droite
Positioned(
right: 16,
bottom: 16,
child: _buildActionButton(
icon: Icons.my_location,
tooltip: 'Ma position',
onPressed: () {
_getUserLocation();
},
),
),
// Combobox de sélection de secteurs
Positioned(
left: 16,
top: 16,
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.withOpacity(0.95),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
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: 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();
}
},
),
),
],
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,582 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:geosector_app/presentation/widgets/charts/activity_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'package:geosector_app/presentation/widgets/charts/combined_chart.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import '../../shared/app_theme.dart';
import '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.withOpacity(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({Key? key}) : super(key: key);
@override
State<AdminStatisticsPage> createState() => _AdminStatisticsPageState();
}
class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
// Filtres
String _selectedPeriod = 'Jour';
String _selectedFilterType = 'Secteur';
String _selectedSector = 'Tous';
String _selectedUser = 'Tous';
int _daysToShow = 15;
// Liste des périodes et types de filtre
final List<String> _periods = ['Jour', 'Semaine', 'Mois', 'Année'];
final List<String> _filterTypes = ['Secteur', 'Membre'];
// Données simulées pour les secteurs et membres (à remplacer par des données réelles)
final List<String> _sectors = [
'Tous',
'Secteur Nord',
'Secteur Sud',
'Secteur Est',
'Secteur Ouest'
];
final List<String> _members = [
'Tous',
'Jean Dupont',
'Marie Martin',
'Pierre Legrand',
'Sophie Petit',
'Lucas Moreau'
];
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
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: Container(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page
SingleChildScrollView(
padding: const EdgeInsets.all(AppTheme.spacingL),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre et description
Text(
'Analyse des statistiques',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingS),
Text(
'Visualisez les statistiques de passages et de collecte pour votre amicale.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: AppTheme.spacingL),
// 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
? Row(
children: [
Expanded(child: _buildPeriodDropdown()),
const SizedBox(width: AppTheme.spacingM),
Expanded(child: _buildDaysDropdown()),
const SizedBox(width: AppTheme.spacingM),
Expanded(child: _buildFilterTypeDropdown()),
const SizedBox(width: AppTheme.spacingM),
Expanded(child: _buildFilterDropdown()),
],
)
: Column(
children: [
_buildPeriodDropdown(),
const SizedBox(height: AppTheme.spacingM),
_buildDaysDropdown(),
const SizedBox(height: AppTheme.spacingM),
_buildFilterTypeDropdown(),
const SizedBox(height: AppTheme.spacingM),
_buildFilterDropdown(),
],
),
],
),
),
),
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,
loadFromHive: true,
showAllPassages: true,
title: '',
daysToShow: _daysToShow,
periodType: _selectedPeriod,
userId: _selectedUser != 'Tous'
? _getUserIdFromName(_selectedUser)
: null,
// Si on filtre par secteur, on devrait passer l'ID du secteur
),
],
),
),
),
const SizedBox(height: AppTheme.spacingL),
// Graphiques de répartition
isDesktop
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildChartCard(
'Répartition par type de passage',
PassagePieChart(
size: 300,
loadFromHive: true,
showAllPassages: true,
userId: _selectedUser != 'Tous'
? _getUserIdFromName(_selectedUser)
: null,
),
),
),
const SizedBox(width: AppTheme.spacingM),
Expanded(
child: _buildChartCard(
'Répartition par mode de paiement',
PaymentPieChart(
payments: [
PaymentData(
typeId: 1,
amount: 1500.0,
color: const Color(0xFFFFC107),
icon: Icons.toll,
title: 'Espèce',
),
PaymentData(
typeId: 2,
amount: 2500.0,
color: const Color(0xFF8BC34A),
icon: Icons.wallet,
title: 'Chèque',
),
PaymentData(
typeId: 3,
amount: 1000.0,
color: const Color(0xFF00B0FF),
icon: Icons.credit_card,
title: 'CB',
),
],
size: 300,
),
),
),
],
)
: Column(
children: [
_buildChartCard(
'Répartition par type de passage',
PassagePieChart(
size: 300,
loadFromHive: true,
showAllPassages: true,
userId: _selectedUser != 'Tous'
? _getUserIdFromName(_selectedUser)
: null,
),
),
const SizedBox(height: AppTheme.spacingM),
_buildChartCard(
'Répartition par mode de paiement',
PaymentPieChart(
payments: [
PaymentData(
typeId: 1,
amount: 1500.0,
color: const Color(0xFFFFC107),
icon: Icons.toll,
title: 'Espèce',
),
PaymentData(
typeId: 2,
amount: 2500.0,
color: const Color(0xFF8BC34A),
icon: Icons.wallet,
title: 'Chèque',
),
PaymentData(
typeId: 3,
amount: 1000.0,
color: const Color(0xFF00B0FF),
icon: Icons.credit_card,
title: 'CB',
),
],
size: 300,
),
),
],
),
const SizedBox(height: AppTheme.spacingL),
// Graphique combiné (si disponible)
_buildChartCard(
'Comparaison passages/montants',
const SizedBox(
height: 350,
child: Center(
child: Text('Graphique combiné à implémenter'),
),
),
),
const SizedBox(height: AppTheme.spacingL),
// Actions
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(
'Actions',
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingM),
Wrap(
spacing: AppTheme.spacingM,
runSpacing: AppTheme.spacingM,
children: [
ElevatedButton.icon(
onPressed: () {
// Exporter les statistiques
},
icon: const Icon(Icons.file_download),
label: const Text('Exporter les statistiques'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
ElevatedButton.icon(
onPressed: () {
// Imprimer les statistiques
},
icon: const Icon(Icons.print),
label: const Text('Imprimer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.buttonSecondaryColor,
foregroundColor: Colors.white,
),
),
ElevatedButton.icon(
onPressed: () {
// Partager les statistiques
},
icon: const Icon(Icons.share),
label: const Text('Partager'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentColor,
foregroundColor: Colors.white,
),
),
],
),
],
),
),
),
],
),
),
],
);
}
// 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 le type de filtre
Widget _buildFilterTypeDropdown() {
return InputDecorator(
decoration: InputDecoration(
labelText: 'Filtrer par',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingM,
vertical: AppTheme.spacingS,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedFilterType,
isDense: true,
isExpanded: true,
items: _filterTypes.map((String type) {
return DropdownMenuItem<String>(
value: type,
child: Text(type),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
setState(() {
_selectedFilterType = newValue;
// Réinitialiser les filtres spécifiques
_selectedSector = 'Tous';
_selectedUser = 'Tous';
});
}
},
),
),
);
}
// Dropdown pour le filtre spécifique (secteur ou membre)
Widget _buildFilterDropdown() {
final List<String> items =
_selectedFilterType == 'Secteur' ? _sectors : _members;
final String value =
_selectedFilterType == 'Secteur' ? _selectedSector : _selectedUser;
return InputDecorator(
decoration: InputDecoration(
labelText: _selectedFilterType,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingM,
vertical: AppTheme.spacingS,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: value,
isDense: true,
isExpanded: true,
items: items.map((String item) {
return DropdownMenuItem<String>(
value: item,
child: Text(item),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
setState(() {
if (_selectedFilterType == 'Secteur') {
_selectedSector = newValue;
} else {
_selectedUser = newValue;
}
});
}
},
),
),
);
}
// 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 utilisateur à partir de son nom
int? _getUserIdFromName(String name) {
// Dans un cas réel, cela nécessiterait une requête au repository
// Pour l'exemple, on utilise une correspondance simple
if (name == 'Jean Dupont') return 1;
if (name == 'Marie Martin') return 2;
if (name == 'Pierre Legrand') return 3;
if (name == 'Sophie Petit') return 4;
if (name == 'Lucas Moreau') return 5;
return null;
}
}