Initialisation du projet geosector complet (web + flutter)
This commit is contained in:
557
flutt/lib/presentation/admin/admin_communication_page.dart
Normal file
557
flutt/lib/presentation/admin/admin_communication_page.dart
Normal 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 '';
|
||||
}
|
||||
}
|
||||
887
flutt/lib/presentation/admin/admin_dashboard_home_page.dart
Normal file
887
flutt/lib/presentation/admin/admin_dashboard_home_page.dart
Normal file
@@ -0,0 +1,887 @@
|
||||
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:hive_flutter/hive_flutter.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/activity_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
|
||||
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
|
||||
import 'package:geosector_app/presentation/widgets/sector_distribution_card.dart';
|
||||
import 'package:geosector_app/core/repositories/user_repository.dart';
|
||||
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
||||
import 'package:geosector_app/core/data/models/passage_model.dart';
|
||||
import 'package:geosector_app/core/data/models/operation_model.dart';
|
||||
import 'package:geosector_app/core/data/models/sector_model.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
import 'package:geosector_app/shared/app_theme.dart';
|
||||
|
||||
class AdminDashboardHomePage extends StatefulWidget {
|
||||
const AdminDashboardHomePage({Key? key}) : super(key: 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;
|
||||
|
||||
// Données pour les graphiques
|
||||
List<PaymentData> paymentData = [];
|
||||
Map<int, int> passagesByType = {};
|
||||
|
||||
// Future pour initialiser les boîtes Hive
|
||||
late Future<void> _initFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initialiser les boîtes Hive avant de charger les données
|
||||
_initFuture = _initHiveBoxes().then((_) {
|
||||
// Charger les données une fois les boîtes initialisées
|
||||
_loadDashboardData();
|
||||
});
|
||||
}
|
||||
|
||||
// Méthode pour initialiser les boîtes Hive nécessaires
|
||||
Future<void> _initHiveBoxes() async {
|
||||
try {
|
||||
debugPrint('Initialisation des boîtes Hive...');
|
||||
|
||||
// Ouvrir la boîte des opérations si elle n'est pas déjà ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.operationsBoxName)) {
|
||||
debugPrint('Ouverture de la boîte operations...');
|
||||
try {
|
||||
await Hive.openBox<OperationModel>(AppKeys.operationsBoxName);
|
||||
debugPrint('Boîte operations ouverte avec succès');
|
||||
} catch (boxError) {
|
||||
debugPrint(
|
||||
'Erreur lors de l\'ouverture de la boîte operations: $boxError');
|
||||
// Continuer malgré l'erreur
|
||||
}
|
||||
} else {
|
||||
debugPrint('Boîte operations déjà ouverte');
|
||||
}
|
||||
|
||||
// Ouvrir la boîte des passages si elle n'est pas déjà ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.passagesBoxName)) {
|
||||
debugPrint('Ouverture de la boîte passages...');
|
||||
try {
|
||||
await Hive.openBox<PassageModel>(AppKeys.passagesBoxName);
|
||||
debugPrint('Boîte passages ouverte avec succès');
|
||||
} catch (boxError) {
|
||||
debugPrint(
|
||||
'Erreur lors de l\'ouverture de la boîte passages: $boxError');
|
||||
// Continuer malgré l'erreur
|
||||
}
|
||||
} else {
|
||||
debugPrint('Boîte passages déjà ouverte');
|
||||
}
|
||||
|
||||
// Ouvrir la boîte des secteurs si elle n'est pas déjà ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
|
||||
debugPrint('Ouverture de la boîte sectors...');
|
||||
try {
|
||||
await Hive.openBox<SectorModel>(AppKeys.sectorsBoxName);
|
||||
debugPrint('Boîte sectors ouverte avec succès');
|
||||
} catch (boxError) {
|
||||
debugPrint(
|
||||
'Erreur lors de l\'ouverture de la boîte sectors: $boxError');
|
||||
// Continuer malgré l'erreur
|
||||
}
|
||||
} else {
|
||||
debugPrint('Boîte sectors déjà ouverte');
|
||||
}
|
||||
|
||||
debugPrint('Initialisation des boîtes Hive terminée');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation des boîtes Hive: $e');
|
||||
// Ne pas propager l'erreur, mais retourner normalement
|
||||
// pour éviter que le FutureBuilder ne reste bloqué en état d'erreur
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
setState(() {
|
||||
isLoading = true;
|
||||
});
|
||||
|
||||
try {
|
||||
debugPrint('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
|
||||
|
||||
// S'assurer que la boîte des opérations est ouverte avant d'y accéder
|
||||
OperationModel? currentOperation;
|
||||
try {
|
||||
// Vérifier si la boîte Hive est ouverte
|
||||
if (!Hive.isBoxOpen(AppKeys.operationsBoxName)) {
|
||||
debugPrint(
|
||||
'Ouverture de la boîte operations dans _loadDashboardData...');
|
||||
try {
|
||||
await Hive.openBox<OperationModel>(AppKeys.operationsBoxName);
|
||||
debugPrint(
|
||||
'Boîte operations ouverte avec succès dans _loadDashboardData');
|
||||
} catch (boxError) {
|
||||
debugPrint(
|
||||
'Erreur lors de l\'ouverture de la boîte operations dans _loadDashboardData: $boxError');
|
||||
// Continuer malgré l'erreur
|
||||
}
|
||||
}
|
||||
|
||||
// Récupérer l'opération en cours
|
||||
debugPrint('Récupération de l\'opération en cours...');
|
||||
currentOperation = userRepository.getCurrentOperation();
|
||||
debugPrint('Opération récupérée: ${currentOperation?.id ?? "null"}');
|
||||
} catch (boxError) {
|
||||
debugPrint('Erreur lors de la récupération de l\'opération: $boxError');
|
||||
// Afficher un message d'erreur ou gérer l'erreur de manière appropriée
|
||||
}
|
||||
|
||||
if (currentOperation != null) {
|
||||
// Charger les passages pour l'opération en cours
|
||||
final passages =
|
||||
passageRepository.getPassagesByOperation(currentOperation.id);
|
||||
|
||||
// Calculer le nombre total de passages
|
||||
totalPassages = passages.length;
|
||||
|
||||
// Calculer le montant total collecté
|
||||
totalAmounts = passages.fold(
|
||||
0.0,
|
||||
(sum, passage) =>
|
||||
sum +
|
||||
(passage.montant != null && 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;
|
||||
}
|
||||
|
||||
// Charger les statistiques par membre
|
||||
memberStats = [];
|
||||
final Map<int, int> memberCounts = {};
|
||||
|
||||
// Compter les passages par membre
|
||||
for (final passage in passages) {
|
||||
if (passage.fkUser != null) {
|
||||
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));
|
||||
}
|
||||
|
||||
setState(() {
|
||||
isDataLoaded = true;
|
||||
isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du chargement des données: $e');
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
debugPrint('Building AdminDashboardHomePage');
|
||||
return FutureBuilder<void>(
|
||||
future: _initFuture,
|
||||
builder: (context, snapshot) {
|
||||
// Afficher un indicateur de chargement pendant l'initialisation des boîtes Hive
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
debugPrint('FutureBuilder: ConnectionState.waiting');
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
}
|
||||
|
||||
// Même si nous avons une erreur, nous continuons à afficher le contenu
|
||||
// car nous avons modifié _initHiveBoxes pour ne pas propager les erreurs
|
||||
if (snapshot.hasError) {
|
||||
debugPrint('FutureBuilder: hasError - ${snapshot.error}');
|
||||
// Nous affichons un message d'erreur mais continuons à afficher le contenu
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content:
|
||||
Text('Erreur lors de l\'initialisation: ${snapshot.error}'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 5),
|
||||
action: SnackBarAction(
|
||||
label: 'Réessayer',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_initFuture = _initHiveBoxes().then((_) {
|
||||
_loadDashboardData();
|
||||
});
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
debugPrint('FutureBuilder: Initialisation réussie');
|
||||
}
|
||||
|
||||
// L'initialisation a réussi, afficher le contenu
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth > 800;
|
||||
// Utiliser l'instance globale définie dans app.dart
|
||||
|
||||
// Récupérer l'opération en cours (les boîtes sont déjà ouvertes)
|
||||
final currentOperation = userRepository.getCurrentOperation();
|
||||
|
||||
// Titre dynamique avec l'ID et le nom de l'opération
|
||||
final String title = currentOperation != null
|
||||
? 'Synthèse de l\'opération #${currentOperation.id} ${currentOperation.name}'
|
||||
: 'Synthèse de l\'opération';
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingL),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
// Réduire la taille de police en version web
|
||||
fontSize: isDesktop ? 18 : null,
|
||||
),
|
||||
overflow: TextOverflow
|
||||
.ellipsis, // Tronquer avec ... si trop long
|
||||
maxLines: 1, // Forcer une seule ligne
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// Bouton de rafraîchissement
|
||||
if (!isLoading)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Rafraîchir les données',
|
||||
onPressed: _loadDashboardData,
|
||||
)
|
||||
else
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
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) ...[
|
||||
// Cartes de synthèse
|
||||
isDesktop
|
||||
? Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildSummaryCard(
|
||||
context,
|
||||
'Passages totaux',
|
||||
totalPassages.toString(),
|
||||
Icons.map_outlined,
|
||||
AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildSummaryCard(
|
||||
context,
|
||||
'Montant collecté',
|
||||
'${totalAmounts.toStringAsFixed(2)} €',
|
||||
Icons.euro_outlined,
|
||||
AppTheme.buttonSuccessColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: SectorDistributionCard(
|
||||
height: 200,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
_buildSummaryCard(
|
||||
context,
|
||||
'Passages totaux',
|
||||
totalPassages.toString(),
|
||||
Icons.map_outlined,
|
||||
AppTheme.primaryColor,
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
_buildSummaryCard(
|
||||
context,
|
||||
'Montant collecté',
|
||||
'${totalAmounts.toStringAsFixed(2)} €',
|
||||
Icons.euro_outlined,
|
||||
AppTheme.buttonSuccessColor,
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
SectorDistributionCard(
|
||||
height: 200,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphique d'activité
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: const ActivityChart(
|
||||
height: 350,
|
||||
loadFromHive: true,
|
||||
showAllPassages:
|
||||
true, // Tous les passages, pas seulement ceux de l'utilisateur courant
|
||||
title: 'Passages réalisés par jour (15 derniers jours)',
|
||||
daysToShow: 15,
|
||||
),
|
||||
// Si vous avez besoin de passer l'ID de l'opération en cours, décommentez les lignes suivantes
|
||||
// child: ActivityChart(
|
||||
// height: 350,
|
||||
// loadFromHive: true,
|
||||
// showAllPassages: true,
|
||||
// title: 'Passages réalisés par jour (15 derniers jours)',
|
||||
// daysToShow: 15,
|
||||
// operationId: userRepository.getCurrentOperation()?.id,
|
||||
// ),
|
||||
),
|
||||
|
||||
const SizedBox(height: AppTheme.spacingL),
|
||||
|
||||
// Graphiques de répartition
|
||||
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),
|
||||
|
||||
// Actions rapides
|
||||
Text(
|
||||
'Actions rapides',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Wrap(
|
||||
spacing: AppTheme.spacingM,
|
||||
runSpacing: AppTheme.spacingM,
|
||||
children: [
|
||||
_buildActionButton(
|
||||
context,
|
||||
'Exporter les données',
|
||||
Icons.file_download_outlined,
|
||||
AppTheme.buttonPrimaryColor,
|
||||
() {},
|
||||
),
|
||||
_buildActionButton(
|
||||
context,
|
||||
'Envoyer un message',
|
||||
Icons.message_outlined,
|
||||
AppTheme.buttonSuccessColor,
|
||||
() {},
|
||||
),
|
||||
_buildActionButton(
|
||||
context,
|
||||
'Gérer les secteurs',
|
||||
Icons.map_outlined,
|
||||
AppTheme.accentColor,
|
||||
() {},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummaryCard(
|
||||
BuildContext context,
|
||||
String label,
|
||||
String value,
|
||||
IconData icon,
|
||||
Color color,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius:
|
||||
BorderRadius.circular(AppTheme.borderRadiusSmall),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color: color,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.spacingM),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingM),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 24,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChartCard(
|
||||
BuildContext context,
|
||||
String title,
|
||||
Widget chart,
|
||||
) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
chart,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construit la carte de répartition par type de passage avec liste
|
||||
Widget _buildPassageTypeCard(BuildContext context) {
|
||||
return Container(
|
||||
height: 300, // Hauteur fixe de 300px
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par type de passage',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$totalPassages passages',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: AppTheme.spacingM),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Graphique à gauche
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
height: 180, // Taille réduite
|
||||
child: const PassagePieChart(
|
||||
size: 180,
|
||||
loadFromHive: true,
|
||||
showAllPassages: true,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
showIcons: false,
|
||||
showLegend: false,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des types à droite
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.end, // Alignement à droite
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
...AppKeys.typesPassages.entries.map((entry) {
|
||||
final int typeId = entry.key;
|
||||
final Map<String, dynamic> typeInfo = entry.value;
|
||||
final int count = passagesByType[typeId] ?? 0;
|
||||
final Color color =
|
||||
Color(typeInfo['couleur2'] as int);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment
|
||||
.end, // Alignement à droite
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'$count ${typeInfo['titres']}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
textAlign: TextAlign
|
||||
.right, // Texte aligné à droite
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Construit la carte de répartition par mode de paiement
|
||||
Widget _buildPaymentTypeCard(BuildContext context) {
|
||||
return Container(
|
||||
height: 300, // Hauteur fixe de 300px
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
|
||||
boxShadow: AppTheme.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingM),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Répartition par mode de paiement',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${totalAmounts.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: AppTheme.buttonSuccessColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: AppTheme.spacingM),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// Graphique à gauche
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: SizedBox(
|
||||
height: 180, // Taille réduite
|
||||
child: PaymentPieChart(
|
||||
size: 180,
|
||||
payments: paymentData,
|
||||
isDonut: true,
|
||||
innerRadius: '50%',
|
||||
showIcons: false,
|
||||
showLegend: false,
|
||||
enable3DEffect: true,
|
||||
effect3DIntensity: 1.5,
|
||||
enableEnhancedExplode: false, // Désactiver l'explosion
|
||||
useGradient: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Liste des types de règlement à droite
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: AppTheme.spacingM),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.end, // Alignement à droite
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
...[1, 2, 3].map((typeId) {
|
||||
// Uniquement les types 1, 2 et 3
|
||||
if (!AppKeys.typesReglements.containsKey(typeId)) {
|
||||
return const SizedBox
|
||||
.shrink(); // Ignorer si le type n'existe pas
|
||||
}
|
||||
|
||||
final Map<String, dynamic> typeInfo =
|
||||
AppKeys.typesReglements[typeId]!;
|
||||
|
||||
// Calculer le montant total pour ce type de règlement
|
||||
double amount = 0.0;
|
||||
for (final payment in paymentData) {
|
||||
if (payment.typeId == typeId) {
|
||||
amount = payment.amount;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Ne pas afficher si le montant est 0
|
||||
if (amount <= 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final Color color =
|
||||
Color(typeInfo['couleur'] as int);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment
|
||||
.end, // Alignement à droite
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${amount.toStringAsFixed(2)} € ${typeInfo['titre']}',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
textAlign: TextAlign
|
||||
.right, // Texte aligné à droite
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
183
flutt/lib/presentation/admin/admin_dashboard_page.dart
Normal file
183
flutt/lib/presentation/admin/admin_dashboard_page.dart
Normal file
@@ -0,0 +1,183 @@
|
||||
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: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 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 AdminDashboardPage extends StatefulWidget {
|
||||
const AdminDashboardPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AdminDashboardPage> createState() => _AdminDashboardPageState();
|
||||
}
|
||||
|
||||
class _AdminDashboardPageState extends State<AdminDashboardPage> {
|
||||
int _selectedIndex = 0;
|
||||
|
||||
// Liste des pages à afficher
|
||||
late final List<Widget> _pages;
|
||||
|
||||
// Index de la page Amicale et membres (utilisé pour la navigation conditionnelle)
|
||||
static const int entitePageIndex = 5;
|
||||
|
||||
// Référence à la boîte Hive pour les paramètres
|
||||
late Box _settingsBox;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
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})',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_pages = [
|
||||
const AdminDashboardHomePage(),
|
||||
const AdminStatisticsPage(),
|
||||
const AdminHistoryPage(),
|
||||
const AdminCommunicationPage(),
|
||||
const AdminMapPage(),
|
||||
// La page AdminEntitePage est maintenant accessible uniquement via le menu Paramètres
|
||||
];
|
||||
|
||||
// Initialiser et charger les paramètres
|
||||
_initSettings();
|
||||
} catch (e) {
|
||||
debugPrint('ERREUR CRITIQUE dans AdminDashboardPage.initState: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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',
|
||||
),
|
||||
];
|
||||
|
||||
// Nous ne voulons plus ajouter la destination "Amicale et membres" ici
|
||||
// car elle est accessible uniquement via le menu Paramètres
|
||||
|
||||
return destinations;
|
||||
}
|
||||
}
|
||||
56
flutt/lib/presentation/admin/admin_entite.dart
Normal file
56
flutt/lib/presentation/admin/admin_entite.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Page d'administration de l'amicale et des membres
|
||||
/// Cette page est intégrée dans le tableau de bord administrateur
|
||||
class AdminEntitePage extends StatelessWidget {
|
||||
const AdminEntitePage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return 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),
|
||||
// Contenu principal
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.people_outline,
|
||||
size: 64,
|
||||
color: theme.colorScheme.primary.withOpacity(0.7),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Page en construction',
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Cette section permettra la gestion des amicales et de leurs membres.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
877
flutt/lib/presentation/admin/admin_history_page.dart
Normal file
877
flutt/lib/presentation/admin/admin_history_page.dart
Normal file
@@ -0,0 +1,877 @@
|
||||
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';
|
||||
|
||||
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 const Scaffold(
|
||||
backgroundColor:
|
||||
Color(0xFFFFEBEE), // Fond rouge clair pour l'interface admin
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_errorMessage.isNotEmpty) {
|
||||
return _buildErrorWidget(_errorMessage);
|
||||
}
|
||||
|
||||
// Retourner le widget principal avec les données chargées
|
||||
return Scaffold(
|
||||
backgroundColor:
|
||||
const Color(0xFFFFEBEE), // Fond rouge clair pour l'interface admin
|
||||
body: 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 Scaffold(
|
||||
backgroundColor:
|
||||
const Color(0xFFFFEBEE), // Fond rouge clair pour l'interface admin
|
||||
body: 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),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
898
flutt/lib/presentation/admin/admin_map_page.dart
Normal file
898
flutt/lib/presentation/admin/admin_map_page.dart
Normal 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();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
529
flutt/lib/presentation/admin/admin_statistics_page.dart
Normal file
529
flutt/lib/presentation/admin/admin_statistics_page.dart
Normal file
@@ -0,0 +1,529 @@
|
||||
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';
|
||||
|
||||
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 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),
|
||||
),
|
||||
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),
|
||||
),
|
||||
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),
|
||||
),
|
||||
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),
|
||||
),
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user