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

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

View File

@@ -0,0 +1,118 @@
# Guide de migration vers la nouvelle structure
Ce document explique comment migrer l'application GeoSector vers la nouvelle structure de dossiers.
## Nouvelle structure
```
lib/
├── presentation/ # Tout ce qui concerne l'interface utilisateur
│ ├── admin/ # Pages et widgets spécifiques à l'interface administrateur
│ ├── user/ # Pages et widgets spécifiques à l'interface utilisateur
│ │ └── pages/ # Pages de l'interface utilisateur
│ ├── auth/ # Pages et widgets liés à l'authentification
│ ├── public/ # Pages et widgets accessibles sans authentification
│ └── widgets/ # Widgets partagés utilisés dans plusieurs parties de l'application
├── core/ # Logique métier et services (reste inchangé)
│ ├── constants/ # Constantes de l'application
│ ├── data/ # Modèles de données
│ ├── repositories/ # Repositories pour accéder aux données
│ ├── services/ # Services de l'application
│ └── theme/ # Thème de l'application
└── shared/ # Code partagé entre les différentes parties de l'application
```
## Étapes de migration
### 1. Widgets communs
Les widgets communs ont déjà été migrés vers `lib/presentation/widgets/` :
- `dashboard_app_bar.dart`
- `dashboard_layout.dart`
- `responsive_navigation.dart`
### 2. Pages administrateur
Migrer les pages administrateur de `lib/features/admin/` vers `lib/presentation/admin/` :
- `admin_dashboard_page.dart` (déjà migré)
- `admin_statistics_page.dart`
- `admin_history_page.dart`
- `admin_communication_page.dart`
- `admin_map_page.dart`
### 3. Pages utilisateur
Migrer les pages utilisateur de `lib/features/user/presentation/pages/` vers `lib/presentation/user/pages/` :
- Créer le dossier `lib/presentation/user/pages/`
- Migrer les fichiers suivants :
- `user_dashboard_home_page.dart`
- `user_statistics_page.dart`
- `user_history_page.dart`
- `user_communication_page.dart`
- `user_map_page.dart`
### 4. Pages d'authentification
Migrer les pages d'authentification de `lib/features/auth/presentation/` vers `lib/presentation/auth/` :
- `login_page.dart`
- `register_page.dart`
- `forgot_password_page.dart`
- etc.
### 5. Pages publiques
Migrer les pages publiques de `lib/features/public/presentation/` vers `lib/presentation/public/` :
- `landing_page.dart`
- `about_page.dart`
- etc.
### 6. Mise à jour des imports
Après avoir migré tous les fichiers, il faudra mettre à jour les imports dans tous les fichiers pour refléter la nouvelle structure.
Exemple :
```dart
// Ancien import
import 'package:geosector_app/core/widgets/dashboard_app_bar.dart';
// Nouvel import
import 'package:geosector_app/presentation/widgets/dashboard_app_bar.dart';
```
### 7. Mise à jour des routes
Mettre à jour le fichier de routes (`lib/core/routes/app_router.dart`) pour refléter les nouveaux chemins des pages.
### 8. Tests
Après avoir effectué toutes les migrations, exécuter les tests pour s'assurer que tout fonctionne correctement.
## Avantages de la nouvelle structure
1. **Séparation claire des responsabilités** : La nouvelle structure sépare clairement la présentation (UI) de la logique métier (core).
2. **Organisation par fonctionnalité** : Les fichiers sont organisés par fonctionnalité (admin, user, auth, public) plutôt que par type (pages, widgets).
3. **Facilité de maintenance** : Il est plus facile de trouver et de modifier les fichiers liés à une fonctionnalité spécifique.
4. **Évolutivité** : La nouvelle structure est plus évolutive et permet d'ajouter facilement de nouvelles fonctionnalités.
## Approche progressive
La migration peut être effectuée progressivement, en commençant par les widgets communs, puis en migrant les pages une par une. Cela permet de continuer à développer l'application pendant la migration.
## Exemple de migration d'une page
Voici un exemple de migration de la page `admin_dashboard_page.dart` :
1. Copier le fichier de `lib/features/admin/admin_dashboard_page.dart` vers `lib/presentation/admin/admin_dashboard_page.dart`
2. Mettre à jour les imports dans le nouveau fichier
3. Mettre à jour les références à ce fichier dans d'autres fichiers
4. Tester que tout fonctionne correctement
5. Supprimer l'ancien fichier une fois que tout fonctionne
## Conclusion
Cette migration permettra d'améliorer la structure de l'application et de faciliter son évolution future. Elle peut être effectuée progressivement pour minimiser l'impact sur le développement en cours.

View File

@@ -0,0 +1,26 @@
# Structure de présentation
Ce dossier contient tous les éléments liés à l'interface utilisateur de l'application, organisés comme suit :
## Sous-dossiers
- `/admin` : Pages et widgets spécifiques à l'interface administrateur
- `/user` : Pages et widgets spécifiques à l'interface utilisateur
- `/auth` : Pages et widgets liés à l'authentification
- `/public` : Pages et widgets accessibles sans authentification
- `/widgets` : Widgets partagés utilisés dans plusieurs parties de l'application
## Organisation des fichiers
Chaque sous-dossier peut contenir :
- Des pages (écrans complets)
- Des widgets spécifiques à cette section
- Des modèles de données d'UI
- Des utilitaires d'UI spécifiques
## Bonnes pratiques
- Les widgets réutilisables dans plusieurs sections doivent être placés dans `/widgets`
- Les widgets spécifiques à une section doivent être placés dans le sous-dossier correspondant
- Utiliser des imports relatifs pour les fichiers du même module
- Utiliser des imports absolus pour les fichiers d'autres modules

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,279 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/shared/app_theme.dart';
import 'package:geosector_app/presentation/widgets/loading_progress_overlay.dart';
import 'package:geosector_app/core/models/loading_state.dart';
import 'dart:math' as math;
// Import des pages admin
import 'admin_dashboard_home_page.dart';
import 'admin_statistics_page.dart';
import 'admin_history_page.dart';
import 'admin_communication_page.dart';
import 'admin_map_page.dart';
import 'admin_entite.dart';
/// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class AdminDashboardPage extends StatefulWidget {
const AdminDashboardPage({Key? key}) : super(key: key);
@override
State<AdminDashboardPage> createState() => _AdminDashboardPageState();
}
class _AdminDashboardPageState extends State<AdminDashboardPage>
with WidgetsBindingObserver {
int _selectedIndex = 0;
// Liste des pages à afficher
late final List<Widget> _pages;
// Index de la page Amicale et membres
static const int entitePageIndex = 5;
// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
// Overlay pour afficher la progression du chargement
OverlayEntry? _progressOverlay;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
try {
debugPrint('Initialisation de AdminDashboardPage');
// Vérifier que userRepository est correctement initialisé
if (userRepository == null) {
debugPrint('ERREUR: userRepository est null dans AdminDashboardPage');
} else {
debugPrint('userRepository est correctement initialisé');
// Vérifier l'utilisateur courant
final currentUser = userRepository.getCurrentUser();
if (currentUser == null) {
debugPrint(
'ERREUR: Aucun utilisateur connecté dans AdminDashboardPage',
);
} else {
debugPrint(
'Utilisateur connecté: ${currentUser.username} (${currentUser.id})',
);
}
// Écouter les changements d'état du UserRepository
userRepository.addListener(_handleUserRepositoryChanges);
}
_pages = [
const AdminDashboardHomePage(),
const AdminStatisticsPage(),
const AdminHistoryPage(),
const AdminCommunicationPage(),
const AdminMapPage(),
const AdminEntitePage(),
];
// Initialiser et charger les paramètres
_initSettings();
// Vérifier si des données sont en cours de chargement
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkLoadingState();
});
} catch (e) {
debugPrint('ERREUR CRITIQUE dans AdminDashboardPage.initState: $e');
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
if (userRepository != null) {
userRepository.removeListener(_handleUserRepositoryChanges);
}
_removeProgressOverlay();
super.dispose();
}
// Méthode pour gérer les changements d'état du UserRepository
void _handleUserRepositoryChanges() {
_checkLoadingState();
}
// Méthode pour vérifier l'état de chargement (barre de progression désactivée)
void _checkLoadingState() {
// La barre de progression est désactivée, ne rien faire
}
// Méthodes pour gérer l'overlay de progression (désactivées)
void _showProgressOverlay(LoadingState state) {
// La barre de progression est désactivée, ne rien faire
}
void _updateProgressOverlay(LoadingState state) {
// La barre de progression est désactivée, ne rien faire
}
void _removeProgressOverlay() {
// La barre de progression est désactivée, ne rien faire
}
// Initialiser la boîte de paramètres et charger les préférences
Future<void> _initSettings() async {
try {
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
} else {
_settingsBox = Hive.box(AppKeys.settingsBoxName);
}
// Charger l'index de page sélectionné
final savedIndex = _settingsBox.get('adminSelectedPageIndex');
// Vérifier si l'index sauvegardé est valide
if (savedIndex != null && savedIndex is int) {
debugPrint('Index sauvegardé trouvé: $savedIndex');
// S'assurer que l'index est dans les limites valides
if (savedIndex >= 0 && savedIndex < _pages.length) {
setState(() {
_selectedIndex = savedIndex;
});
debugPrint('Index sauvegardé valide, utilisé: $_selectedIndex');
} else {
debugPrint(
'Index sauvegardé invalide ($savedIndex), utilisation de l\'index par défaut: 0',
);
// Réinitialiser l'index sauvegardé à 0 si invalide
_settingsBox.put('adminSelectedPageIndex', 0);
}
} else {
debugPrint(
'Aucun index sauvegardé trouvé, utilisation de l\'index par défaut: 0',
);
}
} catch (e) {
debugPrint('Erreur lors du chargement des paramètres: $e');
}
}
// Sauvegarder les paramètres utilisateur
void _saveSettings() {
try {
// Sauvegarder l'index de page sélectionné
_settingsBox.put('adminSelectedPageIndex', _selectedIndex);
} catch (e) {
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
}
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: Container(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page
DashboardLayout(
title: 'Tableau de bord Administration',
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() {
_selectedIndex = index;
_saveSettings(); // Sauvegarder l'index de page sélectionné
});
},
destinations: _buildNavigationDestinations(),
showNewPassageButton: false,
isAdmin: true,
body: _pages[_selectedIndex],
),
],
);
}
/// Construit la liste des destinations de navigation
List<NavigationDestination> _buildNavigationDestinations() {
// Destinations de base toujours présentes
final List<NavigationDestination> destinations = [
const NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
const NavigationDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: 'Statistiques',
),
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
const NavigationDestination(
icon: Icon(Icons.chat_outlined),
selectedIcon: Icon(Icons.chat),
label: 'Messages',
),
const NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
];
// Ajouter la destination "Amicale et membres"
destinations.add(
const NavigationDestination(
icon: Icon(Icons.business_outlined),
selectedIcon: Icon(Icons.business),
label: 'Amicale',
),
);
return destinations;
}
}

View File

@@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/environment_info_widget.dart';
/// Widget d'information de débogage pour l'administrateur
/// À intégrer où nécessaire dans l'interface administrateur
class AdminDebugInfoWidget extends StatelessWidget {
const AdminDebugInfoWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.all(16.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.bug_report, color: Colors.grey),
const SizedBox(width: 8),
Text(
'Informations de débogage',
style: Theme.of(context).textTheme.titleMedium,
),
],
),
const SizedBox(height: 16),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('Environnement'),
subtitle: const Text('Afficher les informations sur l\'environnement actuel'),
onTap: () => EnvironmentInfoWidget.show(context),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
),
tileColor: Colors.grey.withOpacity(0.1),
),
// Autres options de débogage peuvent être ajoutées ici
],
),
),
);
}
}

View File

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

View File

@@ -0,0 +1,945 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/sector_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
import 'dart:math' as math;
/// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class AdminHistoryPage extends StatefulWidget {
const AdminHistoryPage({Key? key}) : super(key: key);
@override
State<AdminHistoryPage> createState() => _AdminHistoryPageState();
}
class _AdminHistoryPageState extends State<AdminHistoryPage> {
// État des filtres
String searchQuery = '';
String selectedSector = 'Tous';
String selectedUser = 'Tous';
String selectedType = 'Tous';
String selectedPaymentMethod = 'Tous';
String selectedPeriod = 'Dernier mois'; // Période par défaut
DateTimeRange? selectedDateRange;
// IDs pour les filtres
int? selectedSectorId;
int? selectedUserId;
// Listes pour les filtres
List<SectorModel> _sectors = [];
List<UserModel> _users = [];
// Repositories
late PassageRepository _passageRepository;
late SectorRepository _sectorRepository;
late UserRepository _userRepository;
// Passages formatés
List<Map<String, dynamic>> _formattedPassages = [];
// État de chargement
bool _isLoading = true;
String _errorMessage = '';
@override
void initState() {
super.initState();
// Initialiser les filtres
_initializeFilters();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Récupérer les repositories une seule fois
_loadRepositories();
}
// Charger les repositories et les données
void _loadRepositories() {
try {
// Utiliser les instances globales définies dans app.dart
_passageRepository = passageRepository;
_userRepository = userRepository;
_sectorRepository = sectorRepository;
// Charger les secteurs et les utilisateurs
_loadSectorsAndUsers();
// Charger les passages
_loadPassages();
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Erreur lors du chargement des repositories: $e';
});
}
}
// Charger les secteurs et les utilisateurs
void _loadSectorsAndUsers() {
try {
// Récupérer la liste des secteurs
_sectors = _sectorRepository.getAllSectors();
debugPrint('Nombre de secteurs récupérés: ${_sectors.length}');
// Récupérer la liste des utilisateurs
_users = _userRepository.getAllUsers();
debugPrint('Nombre d\'utilisateurs récupérés: ${_users.length}');
} catch (e) {
debugPrint('Erreur lors du chargement des secteurs et utilisateurs: $e');
}
}
// Charger les passages
void _loadPassages() {
setState(() {
_isLoading = true;
});
try {
// Récupérer les passages
final List<PassageModel> allPassages =
_passageRepository.getAllPassages();
// Convertir les passages en format attendu par PassagesListWidget
_formattedPassages = _formatPassagesForWidget(
allPassages, _sectorRepository, _userRepository);
setState(() {
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Erreur lors du chargement des passages: $e';
});
}
}
// Initialiser les filtres
void _initializeFilters() {
// Par défaut, on n'applique pas de filtre par utilisateur ou secteur
selectedSectorId = null;
selectedUserId = null;
// Période par défaut : dernier mois
selectedPeriod = 'Dernier mois';
// Plage de dates par défaut : dernier mois
final DateTime now = DateTime.now();
final DateTime oneMonthAgo = DateTime(now.year, now.month - 1, now.day);
selectedDateRange = DateTimeRange(start: oneMonthAgo, end: now);
}
// Mettre à jour le filtre par secteur
void _updateSectorFilter(String sectorName, int? sectorId) {
setState(() {
selectedSector = sectorName;
selectedSectorId = sectorId;
});
}
// Mettre à jour le filtre par utilisateur
void _updateUserFilter(String userName, int? userId) {
setState(() {
selectedUser = userName;
selectedUserId = userId;
});
}
// Mettre à jour le filtre par période
void _updatePeriodFilter(String period) {
setState(() {
selectedPeriod = period;
// Mettre à jour la plage de dates en fonction de la période
final DateTime now = DateTime.now();
switch (period) {
case 'Derniers 15 jours':
selectedDateRange = DateTimeRange(
start: now.subtract(const Duration(days: 15)),
end: now,
);
break;
case 'Dernière semaine':
selectedDateRange = DateTimeRange(
start: now.subtract(const Duration(days: 7)),
end: now,
);
break;
case 'Dernier mois':
selectedDateRange = DateTimeRange(
start: DateTime(now.year, now.month - 1, now.day),
end: now,
);
break;
case 'Tous':
selectedDateRange = null;
break;
}
});
}
@override
Widget build(BuildContext context) {
// Afficher un widget de chargement ou d'erreur si nécessaire
if (_isLoading) {
return Stack(
children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: Container(width: double.infinity, height: double.infinity),
),
),
const Center(
child: CircularProgressIndicator(),
),
],
);
}
if (_errorMessage.isNotEmpty) {
return _buildErrorWidget(_errorMessage);
}
// Retourner le widget principal avec les données chargées
return Stack(
children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: Container(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de la page
Text(
'Historique des passages',
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 16),
// Filtres supplémentaires (secteur, utilisateur, période)
_buildAdditionalFilters(context),
const SizedBox(height: 16),
// Widget de liste des passages
Expanded(
child: PassagesListWidget(
passages: _formattedPassages,
showFilters: true,
showSearch: true,
showActions: true,
initialSearchQuery: searchQuery,
initialTypeFilter: selectedType,
initialPaymentFilter: selectedPaymentMethod,
// Exclure les passages de type 2 (À finaliser)
excludePassageTypes: [2],
// Filtres par utilisateur et secteur
filterByUserId: selectedUserId,
filterBySectorId: selectedSectorId,
// Période par défaut (dernier mois)
periodFilter: 'lastMonth',
// Plage de dates personnalisée si définie
dateRange: selectedDateRange,
onPassageSelected: (passage) {
_showDetailsDialog(context, passage);
},
onReceiptView: (passage) {
_showReceiptDialog(context, passage);
},
onDetailsView: (passage) {
_showDetailsDialog(context, passage);
},
onPassageEdit: (passage) {
// Action pour modifier le passage
// Cette fonctionnalité pourrait être implémentée ultérieurement
},
),
),
],
),
),
],
);
}
// Widget d'erreur pour afficher un message d'erreur
Widget _buildErrorWidget(String message) {
return Stack(
children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: Container(width: double.infinity, height: double.infinity),
),
),
Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
color: Colors.red,
size: 64,
),
const SizedBox(height: 16),
Text(
'Erreur',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.red[700],
),
),
const SizedBox(height: 8),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
// Recharger la page
setState(() {});
},
child: const Text('Réessayer'),
),
],
),
),
),
],
);
}
// Convertir les passages du modèle Hive vers le format attendu par le widget
List<Map<String, dynamic>> _formatPassagesForWidget(
List<PassageModel> passages,
SectorRepository sectorRepository,
UserRepository userRepository) {
return passages.map((passage) {
// Récupérer le secteur associé au passage
final SectorModel? sector =
sectorRepository.getSectorById(passage.fkSector);
// Récupérer l'utilisateur associé au passage
final UserModel? user = userRepository.getUserById(passage.fkUser);
// Construire l'adresse complète
final String address =
'${passage.numero} ${passage.rue}${passage.rueBis.isNotEmpty ? ' ${passage.rueBis}' : ''}, ${passage.ville}';
// Déterminer si le passage a une erreur d'envoi de reçu
final bool hasError = passage.emailErreur.isNotEmpty;
return {
'id': passage.id,
'date': passage.passedAt,
'address': address,
'fkSector': passage.fkSector,
'sector': sector?.libelle ?? 'Secteur inconnu',
'fkUser': passage.fkUser,
'user': user?.name ?? 'Utilisateur inconnu',
'type': passage.fkType,
'amount': double.tryParse(passage.montant) ?? 0.0,
'payment': passage.fkTypeReglement,
'email': passage.email,
'hasReceipt': passage.nomRecu.isNotEmpty,
'hasError': hasError,
'notes': passage.remarque,
'name': passage.name,
'phone': passage.phone,
// Ajouter d'autres champs nécessaires pour le widget
};
}).toList();
}
void _showReceiptDialog(BuildContext context, Map<String, dynamic> passage) {
final int passageId = passage['id'] as int;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Reçu du passage #$passageId'),
content: const SizedBox(
width: 500,
height: 600,
child: Center(
child: Text('Aperçu du reçu PDF'),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
// Action pour télécharger le reçu
Navigator.pop(context);
},
child: const Text('Télécharger'),
),
],
),
);
}
void _showDetailsDialog(BuildContext context, Map<String, dynamic> passage) {
final int passageId = passage['id'] as int;
final DateTime date = passage['date'] as DateTime;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Détails du passage #$passageId'),
content: SizedBox(
width: 500,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildDetailRow('Date',
'${date.day}/${date.month}/${date.year} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}'),
_buildDetailRow('Adresse', passage['address'] as String),
_buildDetailRow('Secteur', passage['sector'] as String),
_buildDetailRow('Collecteur', passage['user'] as String),
_buildDetailRow(
'Type',
AppKeys.typesPassages[passage['type']]?['titre'] ??
'Inconnu'),
_buildDetailRow('Montant', '${passage['amount']}'),
_buildDetailRow(
'Mode de paiement',
AppKeys.typesReglements[passage['payment']]?['titre'] ??
'Inconnu'),
_buildDetailRow('Email', passage['email'] as String),
_buildDetailRow(
'Reçu envoyé', passage['hasReceipt'] ? 'Oui' : 'Non'),
_buildDetailRow(
'Erreur d\'envoi', passage['hasError'] ? 'Oui' : 'Non'),
_buildDetailRow(
'Notes',
(passage['notes'] as String).isEmpty
? '-'
: passage['notes'] as String),
const SizedBox(height: 16),
const Text(
'Historique des actions',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildHistoryItem(
date,
passage['user'] as String,
'Création du passage',
),
if (passage['hasReceipt'])
_buildHistoryItem(
date.add(const Duration(minutes: 5)),
'Système',
'Envoi du reçu par email',
),
if (passage['hasError'])
_buildHistoryItem(
date.add(const Duration(minutes: 6)),
'Système',
'Erreur lors de l\'envoi du reçu',
),
],
),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
ElevatedButton(
onPressed: () {
// Action pour modifier le passage
Navigator.pop(context);
},
child: const Text('Modifier'),
),
],
),
);
}
Widget _buildDetailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 150,
child: Text(
'$label :',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
Expanded(
child: Text(value),
),
],
),
);
}
Widget _buildHistoryItem(DateTime date, String user, String action) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${date.day}/${date.month}/${date.year} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
),
Text('$user - $action'),
const Divider(),
],
),
);
}
// Construction des filtres supplémentaires
Widget _buildAdditionalFilters(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
color: Colors.white, // Fond opaque
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Filtres avancés',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 16),
// Disposition des filtres en fonction de la taille de l'écran
isDesktop
? Row(
children: [
// Filtre par secteur
Expanded(
child: _buildSectorFilter(theme, _sectors),
),
const SizedBox(width: 16),
// Filtre par utilisateur
Expanded(
child: _buildUserFilter(theme, _users),
),
const SizedBox(width: 16),
// Filtre par période
Expanded(
child: _buildPeriodFilter(theme),
),
],
)
: Column(
children: [
// Filtre par secteur
_buildSectorFilter(theme, _sectors),
const SizedBox(height: 16),
// Filtre par utilisateur
_buildUserFilter(theme, _users),
const SizedBox(height: 16),
// Filtre par période
_buildPeriodFilter(theme),
],
),
],
),
),
);
}
// Construction du filtre par secteur
Widget _buildSectorFilter(ThemeData theme, List<SectorModel> sectors) {
// Vérifier si la liste des secteurs est vide ou si selectedSector n'est pas dans la liste
bool isSelectedSectorValid = selectedSector == 'Tous' ||
sectors.any((s) => s.libelle == selectedSector);
// Si selectedSector n'est pas valide, le réinitialiser à 'Tous'
if (!isSelectedSectorValid) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
selectedSector = 'Tous';
selectedSectorId = null;
});
}
});
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Secteur',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8.0),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: isSelectedSectorValid ? selectedSector : 'Tous',
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
items: [
const DropdownMenuItem<String>(
value: 'Tous',
child: Text('Tous les secteurs'),
),
...sectors.map((sector) {
final String libelle = sector.libelle.isNotEmpty
? sector.libelle
: 'Secteur ${sector.id}';
return DropdownMenuItem<String>(
value: libelle,
child: Text(
libelle,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
],
onChanged: (String? value) {
if (value != null) {
if (value == 'Tous') {
_updateSectorFilter('Tous', null);
} else {
try {
// Trouver le secteur correspondant
final sector = sectors.firstWhere(
(s) => s.libelle == value,
orElse: () => sectors.isNotEmpty
? sectors.first
: throw Exception('Liste de secteurs vide'),
);
// Convertir sector.id en int? si nécessaire
_updateSectorFilter(value, sector.id);
} catch (e) {
debugPrint('Erreur lors de la sélection du secteur: $e');
_updateSectorFilter('Tous', null);
}
}
}
},
),
),
),
],
);
}
// Construction du filtre par utilisateur
Widget _buildUserFilter(ThemeData theme, List<UserModel> users) {
// Vérifier si la liste des utilisateurs est vide ou si selectedUser n'est pas dans la liste
bool isSelectedUserValid = selectedUser == 'Tous' ||
users.any((u) => (u.name ?? 'Utilisateur inconnu') == selectedUser);
// Si selectedUser n'est pas valide, le réinitialiser à 'Tous'
if (!isSelectedUserValid) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
selectedUser = 'Tous';
selectedUserId = null;
});
}
});
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Utilisateur',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8.0),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: isSelectedUserValid ? selectedUser : 'Tous',
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
items: [
const DropdownMenuItem<String>(
value: 'Tous',
child: Text('Tous les utilisateurs'),
),
...users.map((user) {
// S'assurer que user.name n'est pas null
final String userName = user.name ?? 'Utilisateur inconnu';
return DropdownMenuItem<String>(
value: userName,
child: Text(
userName,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
],
onChanged: (String? value) {
if (value != null) {
if (value == 'Tous') {
_updateUserFilter('Tous', null);
} else {
try {
// Trouver l'utilisateur correspondant
final user = users.firstWhere(
(u) => (u.name ?? 'Utilisateur inconnu') == value,
orElse: () => users.isNotEmpty
? users.first
: throw Exception('Liste d\'utilisateurs vide'),
);
// S'assurer que user.name et user.id ne sont pas null
final String userName =
user.name ?? 'Utilisateur inconnu';
final int? userId = user.id;
_updateUserFilter(userName, userId);
} catch (e) {
debugPrint(
'Erreur lors de la sélection de l\'utilisateur: $e');
_updateUserFilter('Tous', null);
}
}
}
},
),
),
),
],
);
}
// Construction du filtre par période
Widget _buildPeriodFilter(ThemeData theme) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Période',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12.0),
decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.outline),
borderRadius: BorderRadius.circular(8.0),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedPeriod,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
items: const [
DropdownMenuItem<String>(
value: 'Tous',
child: Text('Toutes les périodes'),
),
DropdownMenuItem<String>(
value: 'Derniers 15 jours',
child: Text('Derniers 15 jours'),
),
DropdownMenuItem<String>(
value: 'Dernière semaine',
child: Text('Dernière semaine'),
),
DropdownMenuItem<String>(
value: 'Dernier mois',
child: Text('Dernier mois'),
),
],
onChanged: (String? value) {
if (value != null) {
_updatePeriodFilter(value);
}
},
),
),
),
// Afficher la plage de dates sélectionnée si elle existe
if (selectedDateRange != null && selectedPeriod != 'Tous')
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
Icon(
Icons.date_range,
size: 16,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'Du ${selectedDateRange!.start.day}/${selectedDateRange!.start.month}/${selectedDateRange!.start.year} au ${selectedDateRange!.end.day}/${selectedDateRange!.end.month}/${selectedDateRange!.end.year}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
);
}
void _showResendConfirmation(BuildContext context, int passageId) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Renvoyer le reçu'),
content: Text(
'Êtes-vous sûr de vouloir renvoyer le reçu du passage #$passageId ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
// Action pour renvoyer le reçu
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Reçu du passage #$passageId renvoyé avec succès'),
backgroundColor: Colors.green,
),
);
},
child: const Text('Renvoyer'),
),
],
),
);
}
}

View File

@@ -0,0 +1,898 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/presentation/widgets/mapbox_map.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/location_service.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import '../../shared/app_theme.dart';
class AdminMapPage extends StatefulWidget {
const AdminMapPage({Key? key}) : super(key: key);
@override
State<AdminMapPage> createState() => _AdminMapPageState();
}
class _AdminMapPageState extends State<AdminMapPage> {
// Contrôleur de carte
final MapController _mapController = MapController();
// Position actuelle et zoom
LatLng _currentPosition =
const LatLng(48.117266, -1.6777926); // Position initiale sur Rennes
double _currentZoom = 12.0; // Zoom initial
// Données des secteurs et passages
final List<Map<String, dynamic>> _sectors = [];
final List<Map<String, dynamic>> _passages = [];
// États
bool _editMode = false;
int? _selectedSectorId;
List<DropdownMenuItem<int?>> _sectorItems = [];
// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
@override
void initState() {
super.initState();
_initSettings().then((_) {
_loadSectors();
_loadPassages();
});
}
// Initialiser la boîte de paramètres et charger les préférences
Future<void> _initSettings() async {
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
} else {
_settingsBox = Hive.box(AppKeys.settingsBoxName);
}
// Charger le secteur sélectionné
_selectedSectorId = _settingsBox.get('admin_selectedSectorId');
// Charger la position et le zoom
final double? savedLat = _settingsBox.get('admin_mapLat');
final double? savedLng = _settingsBox.get('admin_mapLng');
final double? savedZoom = _settingsBox.get('admin_mapZoom');
if (savedLat != null && savedLng != null) {
_currentPosition = LatLng(savedLat, savedLng);
}
if (savedZoom != null) {
_currentZoom = savedZoom;
}
}
// Sauvegarder les paramètres utilisateur
void _saveSettings() {
// Sauvegarder le secteur sélectionné
if (_selectedSectorId != null) {
_settingsBox.put('admin_selectedSectorId', _selectedSectorId);
}
// Sauvegarder la position et le zoom actuels
_settingsBox.put('admin_mapLat', _currentPosition.latitude);
_settingsBox.put('admin_mapLng', _currentPosition.longitude);
_settingsBox.put('admin_mapZoom', _currentZoom);
}
// Charger les secteurs depuis la boîte Hive
void _loadSectors() {
try {
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
final sectors = sectorsBox.values.toList();
setState(() {
_sectors.clear();
for (final sector in sectors) {
final List<List<double>> coordinates = sector.getCoordinates();
final List<LatLng> points =
coordinates.map((coord) => LatLng(coord[0], coord[1])).toList();
if (points.isNotEmpty) {
_sectors.add({
'id': sector.id,
'name': sector.libelle,
'color': _hexToColor(sector.color),
'points': points,
});
}
}
// Si un secteur était sélectionné précédemment, le centrer
// Mettre à jour les items de la combobox de secteurs
_updateSectorItems();
if (_selectedSectorId != null &&
_sectors.any((s) => s['id'] == _selectedSectorId)) {
_centerMapOnSpecificSector(_selectedSectorId!);
}
// Sinon, centrer la carte sur tous les secteurs
else if (_sectors.isNotEmpty) {
_centerMapOnSectors();
}
});
} catch (e) {
debugPrint('Erreur lors du chargement des secteurs: $e');
}
}
// Charger les passages depuis la boîte Hive
void _loadPassages() {
try {
// Récupérer la boîte des passages
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
// Créer une nouvelle liste temporaire
final List<Map<String, dynamic>> newPassages = [];
// Parcourir tous les passages dans la boîte
for (var i = 0; i < passagesBox.length; i++) {
final passage = passagesBox.getAt(i);
if (passage != null) {
// Vérifier si les coordonnées GPS sont valides
final lat = double.tryParse(passage.gpsLat);
final lng = double.tryParse(passage.gpsLng);
// Filtrer par secteur si un secteur est sélectionné
if (_selectedSectorId != null &&
passage.fkSector != _selectedSectorId) {
continue;
}
if (lat != null && lng != null) {
// Obtenir la couleur du type de passage
Color passageColor = Colors.grey; // Couleur par défaut
// Vérifier si le type de passage existe dans AppKeys.typesPassages
if (AppKeys.typesPassages.containsKey(passage.fkType)) {
// Utiliser la couleur1 du type de passage
final colorValue =
AppKeys.typesPassages[passage.fkType]!['couleur1'] as int;
passageColor = Color(colorValue);
// Ajouter le passage à la liste temporaire
newPassages.add({
'id': passage.id,
'position': LatLng(lat, lng),
'type': passage.fkType,
'color': passageColor,
'model': passage, // Ajouter le modèle complet
});
}
}
}
}
// Mettre à jour la liste des passages dans l'état
setState(() {
_passages.clear();
_passages.addAll(newPassages);
});
// Sauvegarder les paramètres après chargement des passages
_saveSettings();
} catch (e) {
debugPrint('Erreur lors du chargement des passages: $e');
}
}
// Convertir une couleur hexadécimale en Color
Color _hexToColor(String hexColor) {
// Supprimer le # si présent
final String colorStr =
hexColor.startsWith('#') ? hexColor.substring(1) : hexColor;
// Ajouter FF pour l'opacité si nécessaire (6 caractères -> 8 caractères)
final String fullColorStr = colorStr.length == 6 ? 'FF$colorStr' : colorStr;
// Convertir en entier et créer la couleur
return Color(int.parse(fullColorStr, radix: 16));
}
// Centrer la carte sur tous les secteurs
void _centerMapOnSectors() {
if (_sectors.isEmpty) return;
// Trouver les limites de tous les secteurs
double minLat = 90.0;
double maxLat = -90.0;
double minLng = 180.0;
double maxLng = -180.0;
for (final sector in _sectors) {
final points = sector['points'] as List<LatLng>;
for (final point in points) {
minLat = point.latitude < minLat ? point.latitude : minLat;
maxLat = point.latitude > maxLat ? point.latitude : maxLat;
minLng = point.longitude < minLng ? point.longitude : minLng;
maxLng = point.longitude > maxLng ? point.longitude : maxLng;
}
}
// Ajouter un padding aux limites pour s'assurer que tous les secteurs sont entièrement visibles
// avec une marge autour (5% de la taille totale)
final latPadding = (maxLat - minLat) * 0.05;
final lngPadding = (maxLng - minLng) * 0.05;
minLat -= latPadding;
maxLat += latPadding;
minLng -= lngPadding;
maxLng += lngPadding;
// Calculer le centre
final centerLat = (minLat + maxLat) / 2;
final centerLng = (minLng + maxLng) / 2;
// Calculer le zoom approprié en tenant compte des dimensions de l'écran
final mapWidth = MediaQuery.of(context).size.width;
final mapHeight = MediaQuery.of(context).size.height *
0.7; // Estimation de la hauteur de la carte
final zoom = _calculateOptimalZoom(
minLat, maxLat, minLng, maxLng, mapWidth, mapHeight);
// Centrer la carte sur ces limites avec animation
_mapController.move(LatLng(centerLat, centerLng), zoom);
// Mettre à jour l'état pour refléter la nouvelle position
setState(() {
_currentPosition = LatLng(centerLat, centerLng);
_currentZoom = zoom;
});
debugPrint('Carte centrée sur tous les secteurs avec zoom: $zoom');
}
// Mettre à jour les items de la combobox de secteurs
void _updateSectorItems() {
// Créer l'item "Tous les secteurs"
final List<DropdownMenuItem<int?>> items = [
const DropdownMenuItem<int?>(
value: null,
child: Text('Tous les secteurs'),
),
];
// Ajouter tous les secteurs
for (final sector in _sectors) {
items.add(
DropdownMenuItem<int?>(
value: sector['id'] as int,
child: Text(sector['name'] as String),
),
);
}
setState(() {
_sectorItems = items;
});
}
// Centrer la carte sur un secteur spécifique
void _centerMapOnSpecificSector(int sectorId) {
final sectorIndex = _sectors.indexWhere((s) => s['id'] == sectorId);
if (sectorIndex == -1) return;
// Mettre à jour le secteur sélectionné
_selectedSectorId = sectorId;
final sector = _sectors[sectorIndex];
final points = sector['points'] as List<LatLng>;
final sectorName = sector['name'] as String;
debugPrint(
'Centrage sur le secteur: $sectorName (ID: $sectorId) avec ${points.length} points');
if (points.isEmpty) {
debugPrint('Aucun point dans ce secteur!');
return;
}
// Trouver les limites du secteur
double minLat = 90.0;
double maxLat = -90.0;
double minLng = 180.0;
double maxLng = -180.0;
for (final point in points) {
minLat = point.latitude < minLat ? point.latitude : minLat;
maxLat = point.latitude > maxLat ? point.latitude : maxLat;
minLng = point.longitude < minLng ? point.longitude : minLng;
maxLng = point.longitude > maxLng ? point.longitude : maxLng;
}
// Vérifier si les coordonnées sont valides
if (minLat >= maxLat || minLng >= maxLng) {
debugPrint('Coordonnées invalides pour le secteur $sectorName');
return;
}
// Calculer la taille du secteur
final latSpan = maxLat - minLat;
final lngSpan = maxLng - minLng;
// Ajouter un padding minimal aux limites pour s'assurer que le secteur est bien visible
final double latPadding, lngPadding;
if (latSpan < 0.01 || lngSpan < 0.01) {
// Pour les très petits secteurs, utiliser un padding très réduit
latPadding = 0.0003;
lngPadding = 0.0003;
} else if (latSpan < 0.05 || lngSpan < 0.05) {
// Pour les petits secteurs, padding réduit
latPadding = 0.0005;
lngPadding = 0.0005;
} else {
// Pour les secteurs plus grands, utiliser un pourcentage minimal
latPadding = latSpan * 0.03; // 3% au lieu de 10%
lngPadding = lngSpan * 0.03;
}
minLat -= latPadding;
maxLat += latPadding;
minLng -= lngPadding;
maxLng += lngPadding;
// Calculer le centre
final centerLat = (minLat + maxLat) / 2;
final centerLng = (minLng + maxLng) / 2;
// Déterminer le zoom approprié en fonction de la taille du secteur
double zoom;
// Pour les très petits secteurs (comme des quartiers), utiliser un zoom fixe élevé
if (latSpan < 0.01 && lngSpan < 0.01) {
zoom = 16.0; // Zoom élevé pour les petits quartiers
} else if (latSpan < 0.02 && lngSpan < 0.02) {
zoom = 15.0; // Zoom élevé pour les petits quartiers
} else if (latSpan < 0.05 && lngSpan < 0.05) {
zoom =
13.0; // Zoom pour les secteurs de taille moyenne (quelques quartiers)
} else if (latSpan < 0.1 && lngSpan < 0.1) {
zoom = 12.0; // Zoom pour les grands secteurs (ville)
} else {
// Pour les secteurs plus grands, calculer le zoom
final mapWidth = MediaQuery.of(context).size.width;
final mapHeight = MediaQuery.of(context).size.height * 0.7;
zoom = _calculateOptimalZoom(
minLat, maxLat, minLng, maxLng, mapWidth, mapHeight);
}
// Centrer la carte sur le secteur avec animation
_mapController.move(LatLng(centerLat, centerLng), zoom);
// Mettre à jour l'état pour refléter la nouvelle position
setState(() {
_currentPosition = LatLng(centerLat, centerLng);
_currentZoom = zoom;
});
// Recharger les passages pour appliquer le filtre par secteur
_loadPassages();
}
// Calculer le zoom optimal pour afficher une zone géographique dans la fenêtre de la carte
double _calculateOptimalZoom(double minLat, double maxLat, double minLng,
double maxLng, double mapWidth, double mapHeight) {
// Vérifier si les coordonnées sont valides
if (minLat >= maxLat || minLng >= maxLng) {
debugPrint('Coordonnées invalides pour le calcul du zoom');
return 12.0; // Valeur par défaut raisonnable
}
// Calculer la taille en degrés
final latSpan = maxLat - minLat;
final lngSpan = maxLng - minLng;
// Ajouter un facteur de sécurité pour éviter les divisions par zéro
if (latSpan < 0.0000001 || lngSpan < 0.0000001) {
return 15.0; // Zoom élevé pour un point très précis
}
// Formule simplifiée pour le calcul du zoom
double zoom;
if (latSpan < 0.005 || lngSpan < 0.005) {
// Très petite zone (quartier)
zoom = 16.0;
} else if (latSpan < 0.01 || lngSpan < 0.01) {
// Petite zone (quartier)
zoom = 15.0;
} else if (latSpan < 0.02 || lngSpan < 0.02) {
// Petite zone (plusieurs quartiers)
zoom = 14.0;
} else if (latSpan < 0.05 || lngSpan < 0.05) {
// Zone moyenne (ville)
zoom = 13.0;
} else if (latSpan < 0.2 || lngSpan < 0.2) {
// Grande zone (agglomération)
zoom = 11.0;
} else if (latSpan < 0.5 || lngSpan < 0.5) {
// Très grande zone (département)
zoom = 9.0;
} else if (latSpan < 2.0 || lngSpan < 2.0) {
// Région
zoom = 7.0;
} else if (latSpan < 5.0 || lngSpan < 5.0) {
// Pays
zoom = 5.0;
} else {
// Continent ou plus
zoom = 3.0;
}
return zoom;
}
// Obtenir la position actuelle de l'utilisateur
Future<void> _getUserLocation() async {
try {
// Afficher un indicateur de chargement
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Recherche de votre position...'),
duration: Duration(seconds: 2),
),
);
// Obtenir la position actuelle via le service de géolocalisation
final position = await LocationService.getCurrentPosition();
if (position != null) {
// Mettre à jour la position sur la carte
_updateMapPosition(position, zoom: 17);
// Sauvegarder la nouvelle position
_settingsBox.put('admin_mapLat', position.latitude);
_settingsBox.put('admin_mapLng', position.longitude);
// Informer l'utilisateur
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Position actualisée'),
backgroundColor: Colors.green,
duration: Duration(seconds: 1),
),
);
}
} else {
// Informer l'utilisateur en cas d'échec
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Impossible d\'obtenir votre position. Vérifiez vos paramètres de localisation.'),
backgroundColor: Colors.red,
),
);
}
}
} catch (e) {
// Gérer les erreurs
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
// Méthode pour mettre à jour la position sur la carte
void _updateMapPosition(LatLng position, {double? zoom}) {
_mapController.move(
position,
zoom ?? _mapController.camera.zoom,
);
// Mettre à jour les variables d'état
setState(() {
_currentPosition = position;
if (zoom != null) {
_currentZoom = zoom;
}
});
// Sauvegarder les paramètres après mise à jour de la position
_saveSettings();
}
// Méthode pour construire les marqueurs des passages
List<Marker> _buildMarkers() {
if (_passages.isEmpty) {
return [];
}
return _passages.map((passage) {
final int passageType = passage['type'] as int;
final Color color1 =
passage['color'] as Color; // couleur1 du type de passage
// Récupérer la couleur2 du type de passage
Color color2 = Colors.white; // Couleur par défaut
if (AppKeys.typesPassages.containsKey(passageType)) {
final colorValue =
AppKeys.typesPassages[passageType]!['couleur2'] as int;
color2 = Color(colorValue);
}
return Marker(
point: passage['position'] as LatLng,
width: 14.0,
height: 14.0,
child: GestureDetector(
onTap: () {
_showPassageInfo(passage);
},
child: Container(
decoration: BoxDecoration(
color: color1,
shape: BoxShape.circle,
border: Border.all(
color: color2,
width: 1.0,
),
),
),
),
);
}).toList();
}
// Méthode pour construire les polygones des secteurs
List<Polygon> _buildPolygons() {
if (_sectors.isEmpty) {
return [];
}
return _sectors.map((sector) {
final bool isSelected = _selectedSectorId == sector['id'];
final Color sectorColor = sector['color'] as Color;
return Polygon(
points: sector['points'] as List<LatLng>,
color: isSelected
? sectorColor.withOpacity(0.5)
: sectorColor.withOpacity(0.3),
borderColor: isSelected ? sectorColor : sectorColor.withOpacity(0.8),
borderStrokeWidth: isSelected ? 3.0 : 2.0,
);
}).toList();
}
// Afficher les informations d'un passage lorsqu'on clique dessus
void _showPassageInfo(Map<String, dynamic> passage) {
final PassageModel passageModel = passage['model'] as PassageModel;
final int type = passageModel.fkType;
// Construire l'adresse complète
final String adresse =
'${passageModel.numero}, ${passageModel.rueBis} ${passageModel.rue}';
// Informations sur l'étage, l'appartement et la résidence (si habitat = 2)
String? etageInfo;
String? apptInfo;
String? residenceInfo;
if (passageModel.fkHabitat == 2) {
if (passageModel.niveau.isNotEmpty) {
etageInfo = 'Etage ${passageModel.niveau}';
}
if (passageModel.appt.isNotEmpty) {
apptInfo = 'appt. ${passageModel.appt}';
}
if (passageModel.residence.isNotEmpty) {
residenceInfo = passageModel.residence;
}
}
// Formater la date (uniquement si le type n'est pas 2)
String dateInfo = '';
if (type != 2) {
dateInfo = 'Date: ${_formatDate(passageModel.passedAt)}';
}
// Récupérer le nom du passage (si le type n'est pas 6 - Maison vide)
String? nomInfo;
if (type != 6 && passageModel.name.isNotEmpty) {
nomInfo = passageModel.name;
}
// Récupérer les informations de règlement si le type est 1 (Effectué) ou 5 (Lot)
Widget? reglementInfo;
if (type == 1 || type == 5) {
final int typeReglementId = passageModel.fkTypeReglement;
final String montant = passageModel.montant;
// Récupérer les informations du type de règlement
if (AppKeys.typesReglements.containsKey(typeReglementId)) {
final Map<String, dynamic> typeReglement =
AppKeys.typesReglements[typeReglementId]!;
final String titre = typeReglement['titre'] as String;
final Color couleur = Color(typeReglement['couleur'] as int);
final IconData iconData = typeReglement['icon_data'] as IconData;
reglementInfo = Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
Icon(iconData, color: couleur, size: 20),
const SizedBox(width: 8),
Text('$titre: $montant',
style:
TextStyle(color: couleur, fontWeight: FontWeight.bold)),
],
),
);
}
}
// Afficher une bulle d'information
showDialog(
context: context,
builder: (context) => AlertDialog(
contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Adresse: $adresse'),
if (residenceInfo != null) ...[
const SizedBox(height: 4),
Text(residenceInfo)
],
if (etageInfo != null) ...[
const SizedBox(height: 4),
Text(etageInfo)
],
if (apptInfo != null) ...[
const SizedBox(height: 4),
Text(apptInfo)
],
if (dateInfo.isNotEmpty) ...[
const SizedBox(height: 8),
Text(dateInfo)
],
if (nomInfo != null) ...[
const SizedBox(height: 8),
Text('Nom: $nomInfo')
],
if (reglementInfo != null) reglementInfo,
],
),
actionsPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
actions: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
// Bouton d'édition
IconButton(
onPressed: () {
Navigator.of(context).pop();
// Logique pour éditer le passage
debugPrint('Éditer le passage ${passageModel.id}');
},
icon: const Icon(Icons.edit),
color: Colors.blue,
tooltip: 'Modifier',
),
// Bouton de suppression
IconButton(
onPressed: () {
Navigator.of(context).pop();
// Logique pour supprimer le passage
debugPrint('Supprimer le passage ${passageModel.id}');
},
icon: const Icon(Icons.delete),
color: Colors.red,
tooltip: 'Supprimer',
),
],
),
// Bouton de fermeture
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
],
),
);
}
// Formater une date
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
}
// Widget pour les boutons d'action
Widget _buildActionButton({
required IconData icon,
required String tooltip,
required VoidCallback? onPressed,
Color color = Colors.blue,
}) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: FloatingActionButton(
heroTag: tooltip, // Nécessaire pour éviter les conflits de hero tags
onPressed: onPressed,
backgroundColor: onPressed != null ? color : Colors.grey,
tooltip: tooltip,
mini: true,
child: Icon(icon),
),
);
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Carte MapBox
MapboxMap(
initialPosition: _currentPosition,
initialZoom: _currentZoom,
mapController: _mapController,
markers: _buildMarkers(),
polygons: _buildPolygons(),
showControls: true,
onMapEvent: (event) {
if (event is MapEventMove) {
setState(() {
_currentPosition = event.camera.center;
_currentZoom = event.camera.zoom;
});
_saveSettings();
}
},
),
// Bouton Mode édition en haut à droite
Positioned(
right: 16,
top: 16,
child: _buildActionButton(
icon: Icons.edit,
tooltip: 'Mode édition',
color: _editMode ? Colors.green : Colors.blue,
onPressed: () {
setState(() {
_editMode = !_editMode;
});
},
),
),
// Boutons d'action sous le bouton Mode édition
Positioned(
right: 16,
top: 80, // Positionner sous le bouton Mode édition
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (_editMode) ...[
_buildActionButton(
icon: Icons.add,
tooltip: 'Ajouter un secteur',
onPressed: () {
// Action pour ajouter un secteur
},
),
const SizedBox(height: 8),
_buildActionButton(
icon: Icons.edit,
tooltip: 'Modifier le secteur sélectionné',
onPressed: _selectedSectorId != null
? () {
// Action pour modifier le secteur sélectionné
}
: null,
),
const SizedBox(height: 8),
_buildActionButton(
icon: Icons.delete,
tooltip: 'Supprimer le secteur sélectionné',
color: Colors.red,
onPressed: _selectedSectorId != null
? () {
// Action pour supprimer le secteur sélectionné
}
: null,
),
const SizedBox(height: 16),
],
],
),
),
// Bouton Ma position en bas à droite
Positioned(
right: 16,
bottom: 16,
child: _buildActionButton(
icon: Icons.my_location,
tooltip: 'Ma position',
onPressed: () {
_getUserLocation();
},
),
),
// Combobox de sélection de secteurs
Positioned(
left: 16,
top: 16,
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
width: 220, // Largeur fixe pour accommoder les noms longs
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.95),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.location_on, size: 18, color: Colors.blue),
const SizedBox(width: 8),
Expanded(
child: DropdownButton<int?>(
value: _selectedSectorId,
hint: const Text('Tous les secteurs'),
isExpanded: true,
underline: Container(), // Supprimer la ligne sous le dropdown
icon: Icon(Icons.arrow_drop_down, color: Colors.blue),
items: _sectorItems,
onChanged: (int? sectorId) {
setState(() {
_selectedSectorId = sectorId;
});
if (sectorId != null) {
_centerMapOnSpecificSector(sectorId);
} else {
// Si "Tous les secteurs" est sélectionné
_centerMapOnSectors();
// Recharger tous les passages sans filtrage par secteur
_loadPassages();
}
},
),
),
],
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,582 @@
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:geosector_app/presentation/widgets/charts/activity_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_pie_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_pie_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'package:geosector_app/presentation/widgets/charts/combined_chart.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import '../../shared/app_theme.dart';
import 'dart:math' as math;
/// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class AdminStatisticsPage extends StatefulWidget {
const AdminStatisticsPage({Key? key}) : super(key: key);
@override
State<AdminStatisticsPage> createState() => _AdminStatisticsPageState();
}
class _AdminStatisticsPageState extends State<AdminStatisticsPage> {
// Filtres
String _selectedPeriod = 'Jour';
String _selectedFilterType = 'Secteur';
String _selectedSector = 'Tous';
String _selectedUser = 'Tous';
int _daysToShow = 15;
// Liste des périodes et types de filtre
final List<String> _periods = ['Jour', 'Semaine', 'Mois', 'Année'];
final List<String> _filterTypes = ['Secteur', 'Membre'];
// Données simulées pour les secteurs et membres (à remplacer par des données réelles)
final List<String> _sectors = [
'Tous',
'Secteur Nord',
'Secteur Sud',
'Secteur Est',
'Secteur Ouest'
];
final List<String> _members = [
'Tous',
'Jean Dupont',
'Marie Martin',
'Pierre Legrand',
'Sophie Petit',
'Lucas Moreau'
];
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth > 800;
return Stack(
children: [
// Fond dégradé avec petits points blancs
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: Container(width: double.infinity, height: double.infinity),
),
),
// Contenu de la page
SingleChildScrollView(
padding: const EdgeInsets.all(AppTheme.spacingL),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre et description
Text(
'Analyse des statistiques',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingS),
Text(
'Visualisez les statistiques de passages et de collecte pour votre amicale.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey[600],
),
),
const SizedBox(height: AppTheme.spacingL),
// Filtres
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(AppTheme.borderRadiusMedium),
),
color: Colors.white, // Fond opaque
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Filtres',
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingM),
isDesktop
? Row(
children: [
Expanded(child: _buildPeriodDropdown()),
const SizedBox(width: AppTheme.spacingM),
Expanded(child: _buildDaysDropdown()),
const SizedBox(width: AppTheme.spacingM),
Expanded(child: _buildFilterTypeDropdown()),
const SizedBox(width: AppTheme.spacingM),
Expanded(child: _buildFilterDropdown()),
],
)
: Column(
children: [
_buildPeriodDropdown(),
const SizedBox(height: AppTheme.spacingM),
_buildDaysDropdown(),
const SizedBox(height: AppTheme.spacingM),
_buildFilterTypeDropdown(),
const SizedBox(height: AppTheme.spacingM),
_buildFilterDropdown(),
],
),
],
),
),
),
const SizedBox(height: AppTheme.spacingL),
// Graphique d'activité principal
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(AppTheme.borderRadiusMedium),
),
color: Colors.white, // Fond opaque
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Évolution des passages',
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingM),
ActivityChart(
height: 350,
loadFromHive: true,
showAllPassages: true,
title: '',
daysToShow: _daysToShow,
periodType: _selectedPeriod,
userId: _selectedUser != 'Tous'
? _getUserIdFromName(_selectedUser)
: null,
// Si on filtre par secteur, on devrait passer l'ID du secteur
),
],
),
),
),
const SizedBox(height: AppTheme.spacingL),
// Graphiques de répartition
isDesktop
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: _buildChartCard(
'Répartition par type de passage',
PassagePieChart(
size: 300,
loadFromHive: true,
showAllPassages: true,
userId: _selectedUser != 'Tous'
? _getUserIdFromName(_selectedUser)
: null,
),
),
),
const SizedBox(width: AppTheme.spacingM),
Expanded(
child: _buildChartCard(
'Répartition par mode de paiement',
PaymentPieChart(
payments: [
PaymentData(
typeId: 1,
amount: 1500.0,
color: const Color(0xFFFFC107),
icon: Icons.toll,
title: 'Espèce',
),
PaymentData(
typeId: 2,
amount: 2500.0,
color: const Color(0xFF8BC34A),
icon: Icons.wallet,
title: 'Chèque',
),
PaymentData(
typeId: 3,
amount: 1000.0,
color: const Color(0xFF00B0FF),
icon: Icons.credit_card,
title: 'CB',
),
],
size: 300,
),
),
),
],
)
: Column(
children: [
_buildChartCard(
'Répartition par type de passage',
PassagePieChart(
size: 300,
loadFromHive: true,
showAllPassages: true,
userId: _selectedUser != 'Tous'
? _getUserIdFromName(_selectedUser)
: null,
),
),
const SizedBox(height: AppTheme.spacingM),
_buildChartCard(
'Répartition par mode de paiement',
PaymentPieChart(
payments: [
PaymentData(
typeId: 1,
amount: 1500.0,
color: const Color(0xFFFFC107),
icon: Icons.toll,
title: 'Espèce',
),
PaymentData(
typeId: 2,
amount: 2500.0,
color: const Color(0xFF8BC34A),
icon: Icons.wallet,
title: 'Chèque',
),
PaymentData(
typeId: 3,
amount: 1000.0,
color: const Color(0xFF00B0FF),
icon: Icons.credit_card,
title: 'CB',
),
],
size: 300,
),
),
],
),
const SizedBox(height: AppTheme.spacingL),
// Graphique combiné (si disponible)
_buildChartCard(
'Comparaison passages/montants',
const SizedBox(
height: 350,
child: Center(
child: Text('Graphique combiné à implémenter'),
),
),
),
const SizedBox(height: AppTheme.spacingL),
// Actions
Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(AppTheme.borderRadiusMedium),
),
color: Colors.white, // Fond opaque
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Actions',
style:
Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingM),
Wrap(
spacing: AppTheme.spacingM,
runSpacing: AppTheme.spacingM,
children: [
ElevatedButton.icon(
onPressed: () {
// Exporter les statistiques
},
icon: const Icon(Icons.file_download),
label: const Text('Exporter les statistiques'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryColor,
foregroundColor: Colors.white,
),
),
ElevatedButton.icon(
onPressed: () {
// Imprimer les statistiques
},
icon: const Icon(Icons.print),
label: const Text('Imprimer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.buttonSecondaryColor,
foregroundColor: Colors.white,
),
),
ElevatedButton.icon(
onPressed: () {
// Partager les statistiques
},
icon: const Icon(Icons.share),
label: const Text('Partager'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentColor,
foregroundColor: Colors.white,
),
),
],
),
],
),
),
),
],
),
),
],
);
}
// Dropdown pour la période
Widget _buildPeriodDropdown() {
return InputDecorator(
decoration: InputDecoration(
labelText: 'Période',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingM,
vertical: AppTheme.spacingS,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedPeriod,
isDense: true,
isExpanded: true,
items: _periods.map((String period) {
return DropdownMenuItem<String>(
value: period,
child: Text(period),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
setState(() {
_selectedPeriod = newValue;
});
}
},
),
),
);
}
// Dropdown pour le nombre de jours
Widget _buildDaysDropdown() {
return InputDecorator(
decoration: InputDecoration(
labelText: 'Nombre de jours',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingM,
vertical: AppTheme.spacingS,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _daysToShow,
isDense: true,
isExpanded: true,
items: [7, 15, 30, 60, 90, 180, 365].map((int days) {
return DropdownMenuItem<int>(
value: days,
child: Text('$days jours'),
);
}).toList(),
onChanged: (int? newValue) {
if (newValue != null) {
setState(() {
_daysToShow = newValue;
});
}
},
),
),
);
}
// Dropdown pour le type de filtre
Widget _buildFilterTypeDropdown() {
return InputDecorator(
decoration: InputDecoration(
labelText: 'Filtrer par',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingM,
vertical: AppTheme.spacingS,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedFilterType,
isDense: true,
isExpanded: true,
items: _filterTypes.map((String type) {
return DropdownMenuItem<String>(
value: type,
child: Text(type),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
setState(() {
_selectedFilterType = newValue;
// Réinitialiser les filtres spécifiques
_selectedSector = 'Tous';
_selectedUser = 'Tous';
});
}
},
),
),
);
}
// Dropdown pour le filtre spécifique (secteur ou membre)
Widget _buildFilterDropdown() {
final List<String> items =
_selectedFilterType == 'Secteur' ? _sectors : _members;
final String value =
_selectedFilterType == 'Secteur' ? _selectedSector : _selectedUser;
return InputDecorator(
decoration: InputDecoration(
labelText: _selectedFilterType,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingM,
vertical: AppTheme.spacingS,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: value,
isDense: true,
isExpanded: true,
items: items.map((String item) {
return DropdownMenuItem<String>(
value: item,
child: Text(item),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
setState(() {
if (_selectedFilterType == 'Secteur') {
_selectedSector = newValue;
} else {
_selectedUser = newValue;
}
});
}
},
),
),
);
}
// Widget pour envelopper un graphique dans une carte
Widget _buildChartCard(String title, Widget chart) {
return Card(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusMedium),
),
color: Colors.white, // Fond opaque
child: Padding(
padding: const EdgeInsets.all(AppTheme.spacingM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: AppTheme.spacingM),
chart,
],
),
),
);
}
// Méthode utilitaire pour obtenir l'ID utilisateur à partir de son nom
int? _getUserIdFromName(String name) {
// Dans un cas réel, cela nécessiterait une requête au repository
// Pour l'exemple, on utilise une correspondance simple
if (name == 'Jean Dupont') return 1;
if (name == 'Marie Martin') return 2;
if (name == 'Pierre Legrand') return 3;
if (name == 'Sophie Petit') return 4;
if (name == 'Lucas Moreau') return 5;
return null;
}
}

View File

@@ -0,0 +1,904 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'dart:js' as js;
import 'package:go_router/go_router.dart';
import 'package:go_router/src/state.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/custom_button.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/core/services/location_service.dart';
import 'package:geosector_app/core/services/connectivity_service.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
class LoginPage extends StatefulWidget {
final String? loginType;
const LoginPage({super.key, this.loginType});
@override
State<LoginPage> createState() => _LoginPageState();
}
// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _LoginPageState extends State<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _usernameFocusNode = FocusNode();
bool _obscurePassword = true;
// Type de connexion (utilisateur ou administrateur)
late String _loginType;
// État des permissions de géolocalisation
bool _checkingPermission = true;
bool _hasLocationPermission = false;
String? _locationErrorMessage;
// État de la connexion Internet
bool _isConnected = false;
@override
void initState() {
super.initState();
// Vérification du type de connexion
if (widget.loginType == null) {
// Si aucun type n'est spécifié, naviguer vers la splash page
print(
'LoginPage: Aucun type de connexion spécifié, navigation vers splash page');
WidgetsBinding.instance.addPostFrameCallback((_) {
GoRouter.of(context).go('/');
});
_loginType = '';
} else {
_loginType = widget.loginType!;
print('LoginPage: Type de connexion utilisé: $_loginType');
}
// En mode web, essayer de détecter le paramètre dans l'URL directement
// UNIQUEMENT si le loginType n'est pas déjà 'user'
if (kIsWeb && _loginType != 'user') {
try {
final uri = Uri.parse(Uri.base.toString());
// 1. Vérifier d'abord si nous avons déjà le paramètre 'type=user'
final typeParam = uri.queryParameters['type'];
if (typeParam != null && typeParam.trim().toLowerCase() == 'user') {
setState(() {
_loginType = 'user';
});
}
// 2. Sinon, vérifier le fragment d'URL (hash)
else if (uri.fragment.trim().toLowerCase() == 'user') {
setState(() {
_loginType = 'user';
});
}
// 3. Enfin, si toujours pas de type 'user', vérifier le sessionStorage
if (_loginType != 'user') {
WidgetsBinding.instance.addPostFrameCallback((_) {
try {
// Utiliser une approche plus robuste pour accéder au sessionStorage
// Éviter d'utiliser hasProperty qui peut causer des erreurs
final result = js.context.callMethod('eval', [
'''
(function() {
try {
if (window.sessionStorage) {
var value = sessionStorage.getItem('loginType');
return value;
}
return null;
} catch (e) {
console.error('Error accessing sessionStorage:', e);
return null;
}
})()
'''
]);
if (result != null &&
result is String &&
result.toLowerCase() == 'user') {
setState(() {
_loginType = 'user';
print(
'LoginPage: Type détecté depuis sessionStorage: $_loginType');
});
}
} catch (e) {
print('LoginPage: Erreur lors de l\'accès au sessionStorage: $e');
}
});
}
} catch (e) {
print('Erreur lors de la récupération des paramètres d\'URL: $e');
}
}
// Vérifier les permissions de géolocalisation au démarrage seulement sur mobile
if (!kIsWeb) {
_checkLocationPermission();
} else {
// En version web, on considère que les permissions sont accordées
setState(() {
_checkingPermission = false;
_hasLocationPermission = true;
});
}
// Initialiser l'état de la connexion
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_isConnected = connectivityService.isConnected;
});
}
});
// Pré-remplir le champ username avec l'identifiant du dernier utilisateur connecté
// seulement si le rôle correspond au type de login
WidgetsBinding.instance.addPostFrameCallback((_) {
final users = userRepository.getAllUsers();
if (users.isNotEmpty) {
// Trouver l'utilisateur le plus récent (celui avec la date de dernière connexion la plus récente)
users.sort((a, b) => (b.lastSyncedAt).compareTo(a.lastSyncedAt));
final lastUser = users.first;
// Convertir le rôle en int si nécessaire
int roleValue;
if (lastUser.role is String) {
roleValue = int.tryParse(lastUser.role as String) ?? 0;
} else {
roleValue = lastUser.role as int;
}
// Vérifier si le rôle correspond au type de login
bool roleMatches = false;
if (_loginType == 'user' && roleValue == 1) {
roleMatches = true;
debugPrint('Rôle utilisateur (1) correspond au type de login (user)');
} else if (_loginType == 'admin' && roleValue > 1) {
roleMatches = true;
debugPrint(
'Rôle administrateur (${roleValue}) correspond au type de login (admin)');
}
// Pré-remplir le champ username seulement si le rôle correspond
if (roleMatches) {
// Utiliser le username s'il existe, sinon utiliser l'email comme fallback
if (lastUser.username != null && lastUser.username!.isNotEmpty) {
_usernameController.text = lastUser.username!;
// Déplacer le focus sur le champ mot de passe puisque le username est déjà rempli
_usernameFocusNode.unfocus();
debugPrint('Champ username pré-rempli avec: ${lastUser.username}');
} else if (lastUser.email.isNotEmpty) {
_usernameController.text = lastUser.email;
_usernameFocusNode.unfocus();
debugPrint(
'Champ username pré-rempli avec email: ${lastUser.email}');
}
} else {
debugPrint(
'Le rôle (${roleValue}) ne correspond pas au type de login ($_loginType), champ username non pré-rempli');
}
}
});
}
/// Vérifie les permissions de géolocalisation
Future<void> _checkLocationPermission() async {
// Ne pas vérifier les permissions en version web
if (kIsWeb) {
setState(() {
_hasLocationPermission = true;
_checkingPermission = false;
});
return;
}
setState(() {
_checkingPermission = true;
});
// Vérifier si les services de localisation sont activés et si l'application a la permission
final hasPermission = await LocationService.checkAndRequestPermission();
final errorMessage = await LocationService.getLocationErrorMessage();
setState(() {
_hasLocationPermission = hasPermission;
_locationErrorMessage = errorMessage;
_checkingPermission = false;
});
}
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_usernameFocusNode.dispose();
super.dispose();
}
/// Construit l'écran de chargement pendant la vérification des permissions
Widget _buildLoadingScreen(ThemeData theme) {
return Scaffold(
body: SafeArea(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Logo simlifié
Image.asset(
'assets/images/logo-geosector-1024.png',
height: 160,
),
const SizedBox(height: 32),
const CircularProgressIndicator(),
const SizedBox(height: 24),
Text(
'Vérification des permissions...',
style: theme.textTheme.titleMedium,
textAlign: TextAlign.center,
),
],
),
),
),
);
}
/// Construit l'écran de demande de permission de géolocalisation
Widget _buildLocationPermissionScreen(ThemeData theme) {
return Scaffold(
body: Stack(
children: [
// Fond dégradé avec petits points blancs
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: _loginType == 'user'
? [Colors.white, Colors.green.shade300]
: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: Container(width: double.infinity, height: double.infinity),
),
),
SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Card(
elevation: 8,
shadowColor: _loginType == 'user'
? Colors.green.withOpacity(0.5)
: Colors.red.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0)),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo simplifié
Image.asset(
'assets/images/logo-geosector-1024.png',
height: 160,
),
const SizedBox(height: 24),
Text(
'Accès à la localisation requis',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Message d'erreur
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color:
theme.colorScheme.error.withOpacity(0.3)),
),
child: Column(
children: [
Icon(
Icons.location_disabled,
color: theme.colorScheme.error,
size: 48,
),
const SizedBox(height: 16),
Text(
_locationErrorMessage ??
'L\'accès à la localisation est nécessaire pour utiliser cette application.',
style: theme.textTheme.bodyLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Cette application utilise votre position pour enregistrer les passages et assurer le suivi des secteurs géographiques. Sans cette permission, l\'application ne peut pas fonctionner correctement.',
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(height: 32),
// Instructions pour activer la localisation
Text(
'Comment activer la localisation :',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
_buildInstructionStep(theme, 1,
'Ouvrez les paramètres de votre appareil'),
_buildInstructionStep(theme, 2,
'Accédez aux paramètres de confidentialité ou de localisation'),
_buildInstructionStep(theme, 3,
'Recherchez GEOSECTOR dans la liste des applications'),
_buildInstructionStep(theme, 4,
'Activez l\'accès à la localisation pour cette application'),
const SizedBox(height: 32),
// Boutons d'action
CustomButton(
onPressed: () async {
// Ouvrir les paramètres de l'application
await LocationService.openAppSettings();
},
text: 'Ouvrir les paramètres de l\'application',
icon: Icons.settings,
),
const SizedBox(height: 16),
CustomButton(
onPressed: () async {
// Ouvrir les paramètres de localisation
await LocationService.openLocationSettings();
},
text: 'Ouvrir les paramètres de localisation',
icon: Icons.location_on,
backgroundColor: theme.colorScheme.secondary,
),
const SizedBox(height: 16),
CustomButton(
onPressed: () {
// Vérifier à nouveau les permissions
_checkLocationPermission();
},
text: 'Vérifier à nouveau',
icon: Icons.refresh,
backgroundColor: theme.colorScheme.tertiary,
),
],
),
),
),
),
),
),
),
],
),
);
}
/// Construit une étape d'instruction pour activer la localisation
Widget _buildInstructionStep(
ThemeData theme, int stepNumber, String instruction) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'$stepNumber',
style: TextStyle(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
instruction,
style: theme.textTheme.bodyMedium,
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
print('DEBUG BUILD: Reconstruction de LoginPage avec type: $_loginType');
// Utiliser l'instance globale de userRepository
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
// Afficher l'écran de permission de géolocalisation si l'utilisateur n'a pas accordé la permission (sauf en version web)
if (!kIsWeb && _checkingPermission) {
return _buildLoadingScreen(theme);
} else if (!kIsWeb && !_hasLocationPermission) {
return _buildLocationPermissionScreen(theme);
}
return Scaffold(
body: Stack(
children: [
// Fond dégradé avec petits points blancs
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: _loginType == 'user'
? [Colors.white, Colors.green.shade300]
: [Colors.white, Colors.red.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: Container(width: double.infinity, height: double.infinity),
),
),
SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Card(
elevation: 8,
shadowColor: _loginType == 'user'
? Colors.green.withOpacity(0.5)
: Colors.red.withOpacity(0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16.0)),
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo simplifié avec chemin direct
Image.asset(
'assets/images/logo-geosector-1024.png',
height: 140,
),
const SizedBox(height: 24),
Text(
_loginType == 'user'
? 'Connexion Utilisateur'
: 'Connexion Administrateur',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: _loginType == 'user'
? Colors.green
: Colors.red,
),
textAlign: TextAlign.center,
),
// Ajouter un texte de débogage uniquement en mode développement
if (kDebugMode)
Text(
'Type de connexion: $_loginType',
style:
TextStyle(fontSize: 10, color: Colors.grey),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Bienvenue sur GEOSECTOR',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground
.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Indicateur de connectivité
ConnectivityIndicator(),
const SizedBox(height: 16),
// Formulaire de connexion
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
CustomTextField(
controller: _usernameController,
label: 'Identifiant',
hintText: 'Entrez votre identifiant',
prefixIcon: Icons.person_outline,
keyboardType: TextInputType.text,
autofocus: true,
focusNode: _usernameFocusNode,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre identifiant';
}
return null;
},
),
const SizedBox(height: 16),
CustomTextField(
controller: _passwordController,
label: 'Mot de passe',
hintText: 'Entrez votre mot de passe',
prefixIcon: Icons.lock_outline,
obscureText: _obscurePassword,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre mot de passe';
}
return null;
},
onFieldSubmitted: (_) async {
if (!userRepository.isLoading &&
_formKey.currentState!.validate()) {
// Vérifier que le type de connexion est spécifié
if (_loginType.isEmpty) {
print(
'Login: Type non spécifié, redirection vers la page de démarrage');
context.go('/');
return;
}
print(
'Login: Tentative avec type: $_loginType');
final success =
await userRepository.login(
_usernameController.text.trim(),
_passwordController.text,
type: _loginType,
);
if (success && mounted) {
// Récupérer directement le rôle de l'utilisateur
final user =
userRepository.getCurrentUser();
if (user == null) {
debugPrint(
'ERREUR: Utilisateur non trouvé après connexion réussie');
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Erreur de connexion. Veuillez réessayer.'),
backgroundColor: Colors.red,
),
);
return;
}
// Convertir le rôle en int si nécessaire
int roleValue;
if (user.role is String) {
roleValue = int.tryParse(
user.role as String) ??
1;
} else {
roleValue = user.role as int;
}
debugPrint(
'Role de l\'utilisateur: $roleValue');
// Redirection simple basée sur le rôle
if (roleValue > 1) {
debugPrint(
'Redirection vers /admin (rôle > 1)');
context.go('/admin');
} else {
debugPrint(
'Redirection vers /user (rôle = 1)');
context.go('/user');
}
} else if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Échec de la connexion. Vérifiez vos identifiants.'),
backgroundColor: Colors.red,
),
);
}
}
},
),
const SizedBox(height: 8),
// Mot de passe oublié
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {
// Naviguer vers la page de récupération de mot de passe
},
child: Text(
'Mot de passe oublié ?',
style: TextStyle(
color: theme.colorScheme.primary,
),
),
),
),
const SizedBox(height: 24),
// Bouton de connexion
CustomButton(
onPressed: (userRepository.isLoading ||
!_isConnected)
? null
: () async {
if (_formKey.currentState!
.validate()) {
// Vérifier à nouveau les permissions de géolocalisation avant de se connecter (sauf en version web)
if (!kIsWeb) {
await _checkLocationPermission();
// Si l'utilisateur n'a toujours pas accordé les permissions, ne pas continuer
if (!_hasLocationPermission) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'L\'accès à la localisation est nécessaire pour utiliser cette application.'),
backgroundColor: Colors.red,
),
);
return;
}
}
// Vérifier la connexion Internet
await connectivityService
.checkConnectivity();
if (!connectivityService
.isConnected) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: const Text(
'Aucune connexion Internet. La connexion n\'est pas possible hors ligne.'),
backgroundColor:
theme.colorScheme.error,
duration: const Duration(
seconds: 3),
action: SnackBarAction(
label: 'Réessayer',
onPressed: () async {
await connectivityService
.checkConnectivity();
if (connectivityService
.isConnected &&
mounted) {
ScaffoldMessenger.of(
context)
.showSnackBar(
SnackBar(
content: Text(
'Connexion Internet ${connectivityService.connectionType} détectée.'),
backgroundColor:
Colors.green,
),
);
}
},
),
),
);
return;
}
// Vérifier que le type de connexion est spécifié
if (_loginType.isEmpty) {
print(
'Login: Type non spécifié, redirection vers la page de démarrage');
context.go('/');
return;
}
print(
'Login: Tentative avec type: $_loginType');
// Utiliser directement userRepository avec l'overlay de chargement
final success = await userRepository
.loginWithUI(
context,
_usernameController.text.trim(),
_passwordController.text,
type: _loginType,
);
if (success && mounted) {
debugPrint(
'Connexion réussie, tentative de redirection...');
// Récupérer directement le rôle de l'utilisateur
final user = userRepository
.getCurrentUser();
if (user == null) {
debugPrint(
'ERREUR: Utilisateur non trouvé après connexion réussie');
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Erreur de connexion. Veuillez réessayer.'),
backgroundColor: Colors.red,
),
);
return;
}
// Convertir le rôle en int si nécessaire
int roleValue;
if (user.role is String) {
roleValue = int.tryParse(
user.role as String) ??
1;
} else {
roleValue = user.role as int;
}
debugPrint(
'Role de l\'utilisateur: $roleValue');
// Redirection simple basée sur le rôle
if (roleValue > 1) {
debugPrint(
'Redirection vers /admin (rôle > 1)');
context.go('/admin');
} else {
debugPrint(
'Redirection vers /user (rôle = 1)');
context.go('/user');
}
} else if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Échec de la connexion. Vérifiez vos identifiants.'),
backgroundColor: Colors.red,
),
);
}
}
},
text: _isConnected
? 'Se connecter'
: 'Connexion Internet requise',
isLoading: userRepository.isLoading,
),
const SizedBox(height: 24),
// Inscription administrateur uniquement
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Pas encore de compte ?',
style: theme.textTheme.bodyMedium,
),
TextButton(
onPressed: () {
context.go('/register');
},
child: Text(
'Inscription Administrateur',
style: TextStyle(
color: theme.colorScheme.tertiary,
fontWeight: FontWeight.bold,
),
),
),
],
),
// Lien vers la page d'accueil
TextButton(
onPressed: () {
context.go('/');
},
child: Text(
'Retour à l\'accueil',
style: TextStyle(
color: theme.colorScheme.secondary,
),
),
),
],
),
),
],
),
),
),
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,316 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/custom_button.dart';
import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/core/services/connectivity_service.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
class RegisterPage extends StatefulWidget {
const RegisterPage({super.key});
@override
State<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends State<RegisterPage> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _amicaleNameController = TextEditingController();
final _postalCodeController = TextEditingController();
final _cityNameController = TextEditingController();
final _emailController = TextEditingController();
// État de la connexion Internet
bool _isConnected = false;
@override
void initState() {
super.initState();
// Initialiser l'état de la connexion
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
// Utiliser l'instance globale de connectivityService définie dans app.dart
setState(() {
_isConnected = connectivityService.isConnected;
});
}
});
}
@override
void dispose() {
_nameController.dispose();
_amicaleNameController.dispose();
_postalCodeController.dispose();
_cityNameController.dispose();
_emailController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Utiliser l'instance globale de userRepository définie dans app.dart
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
return Scaffold(
body: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 500),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Logo et titre
SvgPicture.asset(
'assets/images/icon-geosector.svg',
height: 140,
fit: BoxFit.contain,
),
const SizedBox(height: 16),
Text(
'Inscription Administrateur',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Enregistrez votre amicale sur GeoSector',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
// Indicateur de connectivité
ConnectivityIndicator(
onConnectivityChanged: (isConnected) {
if (mounted && _isConnected != isConnected) {
setState(() {
_isConnected = isConnected;
});
}
},
),
const SizedBox(height: 16),
// Formulaire d'inscription
Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
CustomTextField(
controller: _nameController,
label: 'Nom complet',
hintText: 'Entrez votre nom complet',
prefixIcon: Icons.person_outline,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre nom complet';
}
return null;
},
),
const SizedBox(height: 16),
CustomTextField(
controller: _emailController,
label: 'Email',
hintText: 'Entrez votre email',
prefixIcon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre email';
}
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$')
.hasMatch(value)) {
return 'Veuillez entrer un email valide';
}
return null;
},
),
const SizedBox(height: 16),
CustomTextField(
controller: _amicaleNameController,
label: 'Nom de l\'amicale',
hintText: 'Entrez le nom de votre amicale',
prefixIcon: Icons.local_fire_department,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer le nom de votre amicale';
}
return null;
},
),
const SizedBox(height: 16),
CustomTextField(
controller: _postalCodeController,
label: 'Code postal de l\'amicale',
hintText: 'Entrez le code postal de votre amicale',
prefixIcon: Icons.location_on_outlined,
keyboardType: TextInputType.number,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer votre code postal';
}
if (!RegExp(r'^[0-9]{5}$').hasMatch(value)) {
return 'Le code postal doit contenir 5 chiffres';
}
return null;
},
),
const SizedBox(height: 16),
CustomTextField(
controller: _cityNameController,
label: 'Commune de l\'amicale',
hintText:
'Entrez le nom de la commune de votre amicale',
prefixIcon: Icons.location_city_outlined,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer le nom de la commune de votre amicale';
}
return null;
},
),
const SizedBox(height: 16),
const SizedBox(height: 32),
// Bouton d'inscription
CustomButton(
onPressed: (userRepository.isLoading || !_isConnected)
? null
: () async {
if (_formKey.currentState!.validate()) {
// Vérifier la connexion Internet avant de soumettre
// Utiliser l'instance globale de connectivityService définie dans app.dart
await connectivityService
.checkConnectivity();
if (!connectivityService.isConnected) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: const Text(
'Aucune connexion Internet. L\'inscription nécessite une connexion active.'),
backgroundColor:
theme.colorScheme.error,
duration:
const Duration(seconds: 3),
action: SnackBarAction(
label: 'Réessayer',
onPressed: () async {
await connectivityService
.checkConnectivity();
if (connectivityService
.isConnected &&
mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
SnackBar(
content: Text(
'Connexion Internet ${connectivityService.connectionType} détectée.'),
backgroundColor:
Colors.green,
),
);
}
},
),
),
);
}
return;
}
final success =
await userRepository.register(
_emailController.text.trim(),
'', // Mot de passe vide, sera généré par le serveur
_nameController.text.trim(),
_amicaleNameController.text.trim(),
_postalCodeController.text,
_cityNameController.text.trim(),
);
if (success && mounted) {
context.go('/user');
} else if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(
const SnackBar(
content: Text(
'Échec de l\'inscription. Veuillez réessayer.'),
backgroundColor: Colors.red,
),
);
}
}
},
text: _isConnected
? 'Enregistrer mon amicale'
: 'Connexion Internet requise',
isLoading: userRepository.isLoading,
),
const SizedBox(height: 24),
// Déjà un compte
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Déjà un compte ?',
style: theme.textTheme.bodyMedium,
),
TextButton(
onPressed: () {
context.go('/login');
},
child: Text(
'Se connecter',
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
],
),
// Lien vers la page d'accueil
TextButton(
onPressed: () {
context.go('/');
},
child: Text(
'Revenir à l\'accueil',
style: TextStyle(
color: theme.colorScheme.secondary,
),
),
),
],
),
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,513 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/theme/app_theme.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/user_model.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/data/models/client_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/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/core/data/models/user_sector_model.dart';
import 'package:geosector_app/chat/models/conversation_model.dart';
import 'package:geosector_app/chat/models/message_model.dart';
import 'package:geosector_app/core/services/hive_reset_state_service.dart';
import 'package:geosector_app/presentation/widgets/clear_cache_dialog.dart';
import 'dart:async';
import 'dart:math' as math;
class SplashPage extends StatefulWidget {
const SplashPage({super.key});
@override
State<SplashPage> createState() => _SplashPageState();
}
// Class pour dessiner les petits points blancs sur le fond
class DotsPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.white.withOpacity(0.5)
..style = PaintingStyle.fill;
final random = math.Random(42); // Seed fixe pour consistance
final numberOfDots = (size.width * size.height) ~/ 1500;
for (int i = 0; i < numberOfDots; i++) {
final x = random.nextDouble() * size.width;
final y = random.nextDouble() * size.height;
final radius = 1.0 + random.nextDouble() * 2.0;
canvas.drawCircle(Offset(x, y), radius, paint);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
class _SplashPageState extends State<SplashPage>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
bool _isInitializing = true;
String _statusMessage = "Initialisation...";
double _progress = 0.0;
bool _showButtons = false;
final List<String> _initializationSteps = [
"Initialisation des services...",
"Vérification de l'authentification...",
"Chargement des données..."
];
@override
void initState() {
super.initState();
// Animation controller sur 5 secondes (augmenté de 3 à 5 secondes)
_animationController = AnimationController(
vsync: this,
duration: const Duration(seconds: 5),
);
// Animation de 4x la taille à 1x la taille (augmenté de 3x à 4x)
_scaleAnimation = Tween<double>(
begin: 4.0, // Commencer à 4x la taille
end: 1.0, // Terminer à la taille normale
).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutBack, // Curve pour un effet de rebond
),
);
// Démarrer l'animation immédiatement
_animationController.forward();
// Vérifier si Hive a été réinitialisé
_checkHiveReset();
// Simuler le processus d'initialisation
_startInitialization();
}
// Méthode pour vérifier si Hive a été réinitialisé et afficher le dialogue si nécessaire
void _checkHiveReset() {
// Vérifier si Hive a été réinitialisé et si le dialogue n'a pas encore été affiché
if (hiveResetStateService.wasReset && !hiveResetStateService.dialogShown) {
// Attendre que le widget soit complètement construit
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
// Afficher le dialogue de nettoyage du cache
ClearCacheDialog.show(
context,
onClose: () {
// Marquer le dialogue comme ayant été affiché
hiveResetStateService.markDialogAsShown();
},
);
}
});
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
void _startInitialization() async {
// Étape 1: Initialisation des services
if (mounted) {
setState(() {
_statusMessage = _initializationSteps[0];
_progress = 0.2;
});
}
// Initialiser toutes les boîtes Hive
await _initializeAllHiveBoxes();
await Future.delayed(const Duration(milliseconds: 500));
// Étape 2: Vérification de l'authentification
if (mounted) {
setState(() {
_statusMessage = _initializationSteps[1];
_progress = 0.4;
});
}
await Future.delayed(const Duration(milliseconds: 500));
// Étape 3: Chargement des données
if (mounted) {
setState(() {
_statusMessage = _initializationSteps[2];
_progress = 1.0; // Directement à 100% après la 3ème étape
});
}
await Future.delayed(const Duration(milliseconds: 500));
if (mounted) {
setState(() {
_isInitializing = false;
_showButtons = true;
});
// Attendre quelques secondes avant de rediriger automatiquement
// si l'utilisateur est déjà connecté
if (userRepository.isLoggedIn) {
Timer(const Duration(seconds: 2), () {
_redirectToAppropriateScreen();
});
}
}
}
// Méthode pour initialiser toutes les boîtes Hive
Future<void> _initializeAllHiveBoxes() async {
try {
debugPrint('Initialisation de toutes les boîtes Hive...');
// Structure pour les boîtes à ouvrir avec leurs noms d'affichage
final boxesToOpen = [
{'name': AppKeys.usersBoxName, 'display': 'Préparation utilisateurs'},
{'name': AppKeys.amicaleBoxName, 'display': 'Préparation amicale'},
{'name': AppKeys.clientsBoxName, 'display': 'Préparation clients'},
{'name': AppKeys.regionsBoxName, 'display': 'Préparation régions'},
{
'name': AppKeys.operationsBoxName,
'display': 'Préparation opérations'
},
{'name': AppKeys.sectorsBoxName, 'display': 'Préparation secteurs'},
{'name': AppKeys.passagesBoxName, 'display': 'Préparation passages'},
{'name': AppKeys.membresBoxName, 'display': 'Préparation membres'},
{
'name': AppKeys.userSectorBoxName,
'display': 'Préparation secteurs utilisateurs'
},
{'name': AppKeys.settingsBoxName, 'display': 'Préparation paramètres'},
{
'name': AppKeys.chatConversationsBoxName,
'display': 'Préparation conversations'
},
{
'name': AppKeys.chatMessagesBoxName,
'display': 'Préparation messages'
},
];
// Calculer l'incrément de progression pour chaque boîte
final progressIncrement = 0.2 / boxesToOpen.length;
double currentProgress = 0.0;
// Ouvrir chaque boîte si elle n'est pas déjà ouverte
for (int i = 0; i < boxesToOpen.length; i++) {
final boxName = boxesToOpen[i]['name'] as String;
final displayName = boxesToOpen[i]['display'] as String;
// Mettre à jour la barre de progression et le message
currentProgress += progressIncrement;
if (mounted) {
setState(() {
_statusMessage = displayName;
_progress =
0.2 * (currentProgress / 0.2); // Normaliser entre 0 et 0.2
});
}
if (!Hive.isBoxOpen(boxName)) {
debugPrint('Ouverture de la boîte $boxName ($displayName)...');
// Ouvrir la boîte avec le type approprié
if (boxName == AppKeys.usersBoxName) {
await Hive.openBox<UserModel>(boxName);
} else if (boxName == AppKeys.amicaleBoxName) {
await Hive.openBox<AmicaleModel>(boxName);
} else if (boxName == AppKeys.clientsBoxName) {
await Hive.openBox<ClientModel>(boxName);
} else if (boxName == AppKeys.regionsBoxName) {
// Ouvrir la boîte des régions sans type spécifique pour l'instant
// car RegionModelAdapter n'est pas encore enregistré
await Hive.openBox(boxName);
} else if (boxName == AppKeys.operationsBoxName) {
await Hive.openBox<OperationModel>(boxName);
} else if (boxName == AppKeys.sectorsBoxName) {
await Hive.openBox<SectorModel>(boxName);
} else if (boxName == AppKeys.passagesBoxName) {
await Hive.openBox<PassageModel>(boxName);
} else if (boxName == AppKeys.membresBoxName) {
await Hive.openBox<MembreModel>(boxName);
} else if (boxName == AppKeys.userSectorBoxName) {
await Hive.openBox<UserSectorModel>(boxName);
} else if (boxName == AppKeys.chatConversationsBoxName) {
await Hive.openBox<ConversationModel>(boxName);
} else if (boxName == AppKeys.chatMessagesBoxName) {
await Hive.openBox<MessageModel>(boxName);
} else {
await Hive.openBox(boxName);
}
debugPrint('Boîte $boxName ouverte avec succès');
} else {
debugPrint('Boîte $boxName déjà ouverte');
}
// Ajouter une temporisation entre chaque ouverture
await Future.delayed(const Duration(milliseconds: 500));
}
// Mettre à jour la barre de progression à 0.2 (20%) à la fin
if (mounted) {
setState(() {
_statusMessage = 'Toutes les boîtes sont prêtes';
_progress = 0.2;
});
}
debugPrint('Toutes les boîtes Hive sont maintenant ouvertes');
} catch (e) {
debugPrint('Erreur lors de l\'initialisation des boîtes Hive: $e');
// En cas d'erreur, mettre à jour le message
if (mounted) {
setState(() {
_statusMessage = 'Erreur lors de l\'initialisation des données';
});
}
}
}
void _redirectToAppropriateScreen() {
if (!mounted) return;
// Utiliser l'instance globale de userRepository définie dans app.dart
if (userRepository.isLoggedIn) {
debugPrint('SplashPage: Redirection d\'utilisateur connecté');
// Récupérer directement le rôle utilisateur
final roleValue = userRepository.getUserRole();
debugPrint('SplashPage: Rôle utilisateur = $roleValue');
// Redirection simple basée sur le rôle
if (roleValue > 1) {
debugPrint('SplashPage: Redirection vers /admin (rôle $roleValue > 1)');
context.go('/admin');
} else {
debugPrint('SplashPage: Redirection vers /user (rôle $roleValue = 1)');
context.go('/user');
}
}
// Ne redirige plus vers la landing page
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
return Scaffold(
body: Stack(
children: [
// Fond dégradé avec petits points blancs
AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.white, Colors.blue.shade300],
),
),
child: CustomPaint(
painter: DotsPainter(),
child: Container(width: double.infinity, height: double.infinity),
),
),
// Contenu principal
SafeArea(
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(flex: 2),
// Logo avec animation de réduction
AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: child,
);
},
child: Image.asset(
'assets/images/logo-geosector-1024.png',
height: 180, // Augmenté de 140 à 180
),
),
const SizedBox(height: 24),
// Titre avec animation fade-in
AnimatedOpacity(
opacity: _isInitializing ? 0.9 : 1.0,
duration: const Duration(milliseconds: 500),
child: Text(
'Geosector',
style: theme.textTheme.headlineLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
),
const SizedBox(height: 16),
// Sous-titre avec nouveau slogan
AnimatedOpacity(
opacity: _isInitializing ? 0.8 : 1.0,
duration: const Duration(milliseconds: 500),
child: Text(
'Une application puissante et intuitive de gestion de vos distributions de calendriers',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color:
theme.colorScheme.onBackground.withOpacity(0.7),
fontWeight: FontWeight.w500,
),
),
),
const Spacer(flex: 1),
// Indicateur de chargement
if (_isInitializing) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(
value: _progress,
backgroundColor: Colors.grey.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(
theme.colorScheme.primary,
),
minHeight: 10, // Augmenté de 6 à 10
),
),
),
const SizedBox(height: 16),
Text(
_statusMessage,
style: theme.textTheme.bodyMedium?.copyWith(
color:
theme.colorScheme.onBackground.withOpacity(0.7),
),
),
],
// Boutons après l'initialisation
if (_showButtons) ...[
// Bouton Connexion Utilisateur
AnimatedOpacity(
opacity: _showButtons ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: ElevatedButton(
onPressed: () {
context.go('/login', extra: {'type': 'user'});
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 40,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
elevation: 2,
),
child: const Text(
'Connexion Utilisateur',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
// Bouton Connexion Administrateur
AnimatedOpacity(
opacity: _showButtons ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: ElevatedButton(
onPressed: () {
context.go('/login', extra: {'type': 'admin'});
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 40,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
elevation: 2,
),
child: const Text(
'Connexion Administrateur',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
const SizedBox(height: 16),
// Lien d'inscription
AnimatedOpacity(
opacity: _showButtons ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: TextButton(
onPressed: () {
context.go('/register');
},
child: Text(
'Pas encore inscrit ?',
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
),
),
],
const Spacer(flex: 1),
],
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1 @@
// Ce fichier sera supprimé, remplacé par la fonctionnalité directe dans splash_page.dart

View File

@@ -0,0 +1,262 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/chat/widgets/conversations_list.dart';
import 'package:geosector_app/chat/widgets/chat_screen.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/chat/models/conversation_model.dart';
class UserCommunicationPage extends StatefulWidget {
const UserCommunicationPage({super.key});
@override
State<UserCommunicationPage> createState() => _UserCommunicationPageState();
}
class _UserCommunicationPageState extends State<UserCommunicationPage> {
String? _selectedConversationId;
late Box<ConversationModel> _conversationsBox;
bool _hasConversations = false;
@override
void initState() {
super.initState();
_checkConversations();
}
Future<void> _checkConversations() async {
try {
_conversationsBox = Hive.box<ConversationModel>(AppKeys.chatConversationsBoxName);
setState(() {
_hasConversations = _conversationsBox.values.isNotEmpty;
});
} catch (e) {
debugPrint('Erreur lors de la vérification des conversations: $e');
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: Colors.transparent,
body: Container(
margin: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withOpacity(0.1),
blurRadius: 20,
spreadRadius: 1,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Column(
children: [
// En-tête du chat
Container(
height: 70,
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withOpacity(0.05),
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withOpacity(0.1),
width: 1,
),
),
),
child: Row(
children: [
Icon(
Icons.chat_bubble_outline,
color: theme.colorScheme.primary,
size: 26,
),
const SizedBox(width: 12),
Text(
'Messages d\'équipe',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.primary,
),
),
const Spacer(),
if (_hasConversations) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppTheme.secondaryColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
'5 en ligne',
style: theme.textTheme.bodySmall?.copyWith(
color: AppTheme.secondaryColor,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(width: 16),
IconButton(
icon: const Icon(Icons.add_circle_outline),
iconSize: 28,
color: theme.colorScheme.primary,
onPressed: () {
// TODO: Créer une nouvelle conversation
},
),
],
],
),
),
// Contenu principal
Expanded(
child: _hasConversations
? Row(
children: [
// Liste des conversations (gauche)
Container(
width: 320,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
border: Border(
right: BorderSide(
color: theme.dividerColor.withOpacity(0.1),
width: 1,
),
),
),
child: ConversationsList(
onConversationSelected: (conversation) {
setState(() {
// TODO: obtenir l'ID de la conversation à partir de l'objet conversation
_selectedConversationId = 'test-conversation-id';
});
},
),
),
// Zone de conversation (droite)
Expanded(
child: Container(
color: theme.colorScheme.surface,
child: _selectedConversationId != null
? ChatScreen(conversationId: _selectedConversationId!)
: _buildEmptyState(theme),
),
),
],
)
: _buildNoConversationsMessage(theme),
),
],
),
),
),
);
}
Widget _buildEmptyState(ThemeData theme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline,
size: 80,
color: theme.colorScheme.primary.withOpacity(0.3),
),
const SizedBox(height: 24),
Text(
'Sélectionnez une conversation',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
'Choisissez une conversation dans la liste\npour commencer à discuter',
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.3),
),
),
],
),
);
}
Widget _buildNoConversationsMessage(ThemeData theme) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.forum_outlined,
size: 100,
color: theme.colorScheme.primary.withOpacity(0.3),
),
const SizedBox(height: 24),
Text(
'Aucune conversation',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Text(
'Vous n\'avez pas encore de conversations.\nCommencez une discussion avec votre équipe !',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: () {
// TODO: Créer une nouvelle conversation
},
icon: const Icon(Icons.add),
label: const Text('Démarrer une conversation'),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
textStyle: const TextStyle(fontSize: 16),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,965 @@
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/repositories/user_repository.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
class UserDashboardHomePage extends StatefulWidget {
const UserDashboardHomePage({super.key});
@override
State<UserDashboardHomePage> createState() => _UserDashboardHomePageState();
}
class _UserDashboardHomePageState extends State<UserDashboardHomePage> {
// Formater une date au format JJ/MM/YYYY
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Utiliser l'instance globale définie dans app.dart
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Builder(builder: (context) {
// Récupérer l'opération actuelle
final operation = userRepository.getCurrentOperation();
if (operation != null) {
return Text(
'${operation.name} (${_formatDate(operation.dateDebut)}-${_formatDate(operation.dateFin)})',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
);
} else {
return Text(
'Tableau de bord',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
);
}
}),
const SizedBox(height: 24),
// Synthèse des passages
_buildSummaryCards(isDesktop),
const SizedBox(height: 24),
// Graphique des passages
_buildPassagesChart(context, theme),
const SizedBox(height: 24),
// Derniers passages
_buildRecentPassages(context, theme),
],
),
),
),
);
}
// Construction des cartes de synthèse
Widget _buildSummaryCards(bool isDesktop) {
return Column(
children: [
_buildCombinedPassagesCard(context, isDesktop),
const SizedBox(height: 16),
_buildCombinedPaymentsCard(isDesktop),
],
);
}
// Méthode pour charger les données de règlements de manière asynchrone
Future<Map<String, dynamic>> _loadPaymentData() async {
// Utiliser un délai plus long pour s'assurer que les données sont chargées
await Future.delayed(const Duration(milliseconds: 1500));
// Utiliser les instances globales définies dans app.dart
final currentUser = userRepository.getCurrentUser();
final int? currentUserId = currentUser?.id;
// Récupérer tous les passages
final passages = passageRepository.getAllPassages();
// Vérifier si les données sont complètement chargées
final int totalPassages = passages.length;
debugPrint(
'Nombre total de passages chargés pour règlements: $totalPassages');
// Si le nombre de passages est trop faible, on considère que les données ne sont pas complètement chargées
if (totalPassages < 100 && !_dataFullyLoaded) {
// Attendre un peu plus et réessayer
await Future.delayed(const Duration(milliseconds: 1000));
// Récupérer à nouveau les passages
final newPassages = passageRepository.getAllPassages();
final newTotalPassages = newPassages.length;
debugPrint(
'Nouveau nombre total de passages chargés pour règlements: $newTotalPassages');
// Si le nombre a augmenté, utiliser les nouvelles données
if (newTotalPassages > totalPassages) {
passages.clear();
passages.addAll(newPassages);
debugPrint(
'Utilisation des nouvelles données de passages pour règlements');
}
}
// Initialiser les montants par type de règlement
final Map<int, double> paymentAmounts = {
0: 0.0, // Pas de règlement
1: 0.0, // Espèces
2: 0.0, // Chèques
3: 0.0, // CB
};
// Compteur pour les passages avec montant > 0
int passagesWithPaymentCount = 0;
// Parcourir les passages et calculer les montants par type de règlement
for (final passage in passages) {
// Vérifier si le passage appartient à l'utilisateur actuel
if (currentUserId != null && passage.fkUser == currentUserId) {
final int typeReglement = passage.fkTypeReglement;
// Convertir la chaîne de montant en double
double montant = 0.0;
try {
// Gérer les formats possibles (virgule ou point)
String montantStr = passage.montant.replaceAll(',', '.');
montant = double.tryParse(montantStr) ?? 0.0;
} catch (e) {
debugPrint('Erreur de conversion du montant: ${passage.montant}');
}
// Ne compter que les passages avec un montant > 0
if (montant > 0) {
passagesWithPaymentCount++;
// Ajouter au montant total par type de règlement
if (paymentAmounts.containsKey(typeReglement)) {
paymentAmounts[typeReglement] =
(paymentAmounts[typeReglement] ?? 0.0) + montant;
} else {
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut (0: Pas de règlement)
paymentAmounts[0] = (paymentAmounts[0] ?? 0.0) + montant;
// Type de règlement inconnu, ajouté à la catégorie 'Pas de règlement'
}
}
}
}
// Afficher les montants par type de règlement pour le débogage
debugPrint('=== MONTANTS PAR TYPE DE RÈGLEMENT ===');
paymentAmounts.forEach((typeId, amount) {
final typeTitle = AppKeys.typesReglements[typeId]?['titre'] ?? 'Inconnu';
debugPrint('Type $typeId ($typeTitle): ${amount.toStringAsFixed(2)}');
});
debugPrint('=====================================');
// Calculer le total des règlements
final double totalPayments =
paymentAmounts.values.fold(0.0, (sum, amount) => sum + amount);
// Convertir les montants en objets PaymentData pour le graphique
final List<PaymentData> paymentDataList =
PaymentUtils.getPaymentDataFromAmounts(paymentAmounts);
// Vérifier si des types de règlement ont un montant de 0
// Si c'est le cas, ajouter un petit montant pour qu'ils apparaissent dans le graphique
for (var payment in paymentDataList) {
if (payment.amount == 0 && payment.typeId != 0) {
// Ignorer le type 0 (Pas de règlement)
debugPrint(
'Type ${payment.typeId} (${payment.title}) a un montant de 0, ajout d\'un petit montant pour l\'affichage');
// Trouver l'index dans la liste
final index = paymentDataList.indexOf(payment);
// Remplacer par un nouvel objet avec un petit montant
paymentDataList[index] = PaymentData(
typeId: payment.typeId,
amount: 0.01, // Petit montant pour qu'il apparaisse dans le graphique
color: payment.color,
icon: payment.icon,
title: payment.title,
);
}
}
// Retourner les données calculées
return {
'paymentAmounts': paymentAmounts,
'totalPayments': totalPayments,
'passagesWithPaymentCount': passagesWithPaymentCount,
'paymentDataList': paymentDataList,
};
}
// Construction d'une carte combinée pour les règlements (liste + graphique)
Widget _buildCombinedPaymentsCard(bool isDesktop) {
return FutureBuilder<Map<String, dynamic>>(
// Utiliser un Future pour s'assurer que les données sont chargées
future: _loadPaymentData(),
builder: (context, snapshot) {
// Afficher un spinner pendant le chargement
if (snapshot.connectionState == ConnectionState.waiting) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: const SizedBox(
height: 300,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Chargement des données de règlements...'),
],
),
),
),
);
}
// En cas d'erreur
if (snapshot.hasError) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: SizedBox(
height: 300,
child: Center(
child: Text(
'Erreur lors du chargement des données: ${snapshot.error}'),
),
),
);
}
// Si les données sont disponibles
if (snapshot.hasData) {
final data = snapshot.data!;
final paymentAmounts =
Map<int, double>.from(data['paymentAmounts'] as Map);
final totalPayments = data['totalPayments'] as double;
final passagesWithPaymentCount =
data['passagesWithPaymentCount'] as int;
final paymentDataList = data['paymentDataList'] as List<PaymentData>;
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Stack(
children: [
// Symbole euro en arrière-plan
Positioned.fill(
child: Center(
child: Icon(
Icons.euro_symbol,
size: 180,
color: Colors.blue.withOpacity(0.07), // Bleuté et estompé
),
),
),
// Contenu principal
Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.payments,
color: AppTheme.accentColor,
size: 24,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Mes règlements sur $passagesWithPaymentCount passages',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
Text(
'${totalPayments.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppTheme.accentColor,
),
),
],
),
const Divider(height: 24),
SizedBox(
height: 250,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Liste des règlements (côté gauche)
Expanded(
flex: isDesktop ? 1 : 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...AppKeys.typesReglements.entries
.map((entry) {
final int typeId = entry.key;
final Map<String, dynamic> typeData =
entry.value;
final double amount =
paymentAmounts[typeId] ?? 0.0;
final Color color =
Color(typeData['couleur'] as int);
return Padding(
padding:
const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
typeData['icon_data'] as IconData,
color: Colors.white,
size: 16,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
typeData['titre'] as String,
style: const TextStyle(
fontSize: 14,
),
),
),
Text(
'${amount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}).toList(),
],
),
),
// Séparateur vertical
if (isDesktop) const VerticalDivider(width: 24),
// Graphique en camembert (côté droit)
Expanded(
flex: isDesktop ? 1 : 2,
child: Container(
padding: const EdgeInsets.all(4.0),
// Réduire légèrement la taille pour éviter la troncature
child: FittedBox(
fit: BoxFit.contain,
child: SizedBox(
width:
200, // Taille réduite pour éviter la troncature
height: 200,
child: PaymentPieChart(
payments: paymentDataList,
size:
200, // Taille fixe au lieu de double.infinity
labelSize:
10, // Réduire davantage la taille des étiquettes
showPercentage: true,
showIcons: false, // Désactiver les icônes
showLegend: false,
isDonut: true,
innerRadius:
'55%', // Augmenter légèrement le rayon interne
enable3DEffect:
false, // Désactiver l'effet 3D pour préserver les couleurs originales
effect3DIntensity:
0.0, // Pas d'intensité 3D
enableEnhancedExplode:
false, // Désactiver l'effet d'explosion amélioré
useGradient:
false, // Ne pas utiliser de dégradés
),
),
),
),
),
],
),
),
],
),
),
],
),
);
}
// Par défaut, retourner un widget vide
return const SizedBox.shrink();
},
);
}
// Variable pour suivre si les données sont complètement chargées
bool _dataFullyLoaded = false;
// Méthode pour charger les données de passages de manière asynchrone
Future<Map<String, dynamic>> _loadPassageData() async {
// Utiliser un délai plus long pour s'assurer que les données sont chargées
await Future.delayed(const Duration(milliseconds: 1500));
// Utiliser les instances globales définies dans app.dart
final currentUser = userRepository.getCurrentUser();
final int? currentUserId = currentUser?.id;
// Récupérer tous les passages
final passages = passageRepository.getAllPassages();
// Vérifier si les données sont complètement chargées
final int totalPassages = passages.length;
debugPrint('Nombre total de passages chargés: $totalPassages');
// Si le nombre de passages est trop faible, on considère que les données ne sont pas complètement chargées
if (totalPassages < 100 && !_dataFullyLoaded) {
// Attendre un peu plus et réessayer
await Future.delayed(const Duration(milliseconds: 1000));
// Récupérer à nouveau les passages
final newPassages = passageRepository.getAllPassages();
final newTotalPassages = newPassages.length;
debugPrint('Nouveau nombre total de passages chargés: $newTotalPassages');
// Si le nombre a augmenté, utiliser les nouvelles données
if (newTotalPassages > totalPassages) {
passages.clear();
passages.addAll(newPassages);
debugPrint('Utilisation des nouvelles données de passages');
}
}
// Marquer les données comme complètement chargées pour éviter de refaire cette vérification
_dataFullyLoaded = true;
// Compter les passages par type
final Map<int, int> passagesCounts = {
1: 0, // Effectués
2: 0, // À finaliser
3: 0, // Refusés
4: 0, // Dons
5: 0, // Lots
6: 0, // Maisons vides
};
// Créer un map pour compter les types de passages
final Map<int, int> typesCount = {};
final Map<int, int> userTypesCount = {};
// Parcourir les passages et les compter par type
for (final passage in passages) {
final typeId = passage.fkType;
final int passageUserId = passage.fkUser;
// Compter les occurrences de chaque type pour le débogage
typesCount[typeId] = (typesCount[typeId] ?? 0) + 1;
// Vérifier si le passage appartient à l'utilisateur actuel ou est de type 2
bool shouldCount = typeId == 2 ||
(currentUserId != null && passageUserId == currentUserId);
if (shouldCount) {
// Compter pour les statistiques de l'utilisateur
userTypesCount[typeId] = (userTypesCount[typeId] ?? 0) + 1;
// Ajouter au compteur des passages par type
if (passagesCounts.containsKey(typeId)) {
passagesCounts[typeId] = passagesCounts[typeId]! + 1;
} else {
// Si le type n'est pas dans notre map, l'ajouter à la catégorie par défaut (2: À finaliser)
passagesCounts[2] = passagesCounts[2]! + 1;
// Type de passage inconnu ajouté à 'A finaliser'
}
}
}
// Calculer le total des passages pour l'utilisateur (somme des valeurs dans userTypesCount)
final int totalUserPassages =
userTypesCount.values.fold(0, (sum, count) => sum + count);
// Retourner les données calculées
return {
'passagesCounts': passagesCounts,
'totalUserPassages': totalUserPassages,
};
}
// Construction d'une carte combinée pour les passages (liste + graphique)
Widget _buildCombinedPassagesCard(BuildContext context, bool isDesktop) {
return FutureBuilder<Map<String, dynamic>>(
// Utiliser un Future pour s'assurer que les données sont chargées
future: _loadPassageData(),
builder: (context, snapshot) {
// Afficher un spinner pendant le chargement
if (snapshot.connectionState == ConnectionState.waiting) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: const SizedBox(
height: 300,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Chargement des données de passages...'),
],
),
),
),
);
}
// En cas d'erreur
if (snapshot.hasError) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: SizedBox(
height: 300,
child: Center(
child: Text(
'Erreur lors du chargement des données: ${snapshot.error}'),
),
),
);
}
// Si les données sont disponibles
if (snapshot.hasData) {
final data = snapshot.data!;
final passagesCounts =
Map<int, int>.from(data['passagesCounts'] as Map);
final totalUserPassages = data['totalUserPassages'] as int;
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.route,
color: AppTheme.primaryColor,
size: 24,
),
const SizedBox(width: 8),
Expanded(
child: Builder(builder: (context) {
return Text(
'Mes passages',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
);
}),
),
Text(
totalUserPassages.toString(),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
],
),
const Divider(height: 24),
SizedBox(
height: 250,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Liste des passages (côté gauche)
Expanded(
flex: isDesktop ? 1 : 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...AppKeys.typesPassages.entries.map((entry) {
final int typeId = entry.key;
final Map<String, dynamic> typeData =
entry.value;
final int count = passagesCounts[typeId] ?? 0;
final Color color =
Color(typeData['couleur2'] as int);
final IconData iconData =
typeData['icon_data'] as IconData;
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(
iconData,
color: Colors.white,
size: 16,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
typeData['titres'] as String,
style: const TextStyle(
fontSize: 14,
),
),
),
Text(
count.toString(),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}).toList(),
],
),
),
// Séparateur vertical
if (isDesktop) const VerticalDivider(width: 24),
// Graphique en camembert (côté droit)
Expanded(
flex: isDesktop ? 1 : 2,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: PassagePieChart(
passagesByType: passagesCounts,
size: double.infinity,
labelSize: 12,
showPercentage: true,
showIcons: false,
showLegend: false,
isDonut: true,
innerRadius: '50%',
),
),
),
],
),
),
],
),
),
);
}
// Par défaut, retourner un widget vide
return const SizedBox.shrink();
},
);
}
// Construction du graphique des passages
Widget _buildPassagesChart(BuildContext context, ThemeData theme) {
// Définir les types de passages à exclure
// Selon la mémoire, le type 2 correspond aux passages "A finaliser"
// et nous voulons les exclure du comptage pour l'utilisateur actuel
final List<int> excludePassageTypes = [2];
// Utiliser le même mécanisme de chargement asynchrone que pour les autres graphiques
return FutureBuilder<Map<String, dynamic>>(
// Utiliser le même Future que pour le graphique des passages
future: _loadPassageData(),
builder: (context, snapshot) {
// Afficher un spinner pendant le chargement
if (snapshot.connectionState == ConnectionState.waiting) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: const SizedBox(
height: 350,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Chargement des données d\'activité...'),
],
),
),
),
);
}
// En cas d'erreur
if (snapshot.hasError) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: SizedBox(
height: 350,
child: Center(
child: Text(
'Erreur lors du chargement des données: ${snapshot.error}'),
),
),
);
}
// Si les données sont disponibles, afficher le graphique
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre supprimé car déjà présent dans le widget ActivityChart
SizedBox(
height:
350, // Augmentation de la hauteur à 350px pour résoudre le problème de l'axe Y
child: ActivityChart(
// Utiliser le chargement depuis Hive directement dans le widget
loadFromHive: true,
// Ne pas filtrer par utilisateur (afficher tous les passages)
showAllPassages: true,
// Exclure les passages de type 2 (A finaliser)
excludePassageTypes: excludePassageTypes,
// Afficher les 15 derniers jours
daysToShow: 15,
periodType: 'Jour',
height:
350, // Augmentation de la hauteur à 350px aussi dans le widget
),
),
],
),
),
);
},
);
}
// Construction de la liste des derniers passages
Widget _buildRecentPassages(BuildContext context, ThemeData theme) {
// Utiliser le même mécanisme de chargement asynchrone que pour les autres widgets
return FutureBuilder<Map<String, dynamic>>(
// Utiliser le même Future que pour les autres widgets
future: _loadPassageData(),
builder: (context, snapshot) {
// Afficher un spinner pendant le chargement
if (snapshot.connectionState == ConnectionState.waiting) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: const SizedBox(
height: 300,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Chargement des derniers passages...'),
],
),
),
),
);
}
// En cas d'erreur
if (snapshot.hasError) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: SizedBox(
height: 300,
child: Center(
child: Text(
'Erreur lors du chargement des données: ${snapshot.error}'),
),
),
);
}
// Si les données sont disponibles, afficher la liste des passages récents
// Utiliser les instances globales définies dans app.dart
final allPassages = passageRepository.getAllPassages();
allPassages.sort((a, b) => b.passedAt.compareTo(a.passedAt));
// Limiter aux 10 passages les plus récents
final recentPassagesModels = allPassages.take(10).toList();
// Convertir les modèles de passage au format attendu par le widget PassagesListWidget
final List<Map<String, dynamic>> recentPassages =
recentPassagesModels.map((passage) {
// Construire l'adresse complète à partir des champs disponibles
final String address =
'${passage.numero} ${passage.rue}${passage.rueBis.isNotEmpty ? ' ${passage.rueBis}' : ''}, ${passage.ville}';
// Convertir le montant en double
final double amount = double.tryParse(passage.montant) ?? 0.0;
return {
'id': passage.id.toString(),
'address': address,
'amount': amount,
'date': passage.passedAt,
'type': passage.fkType,
'payment': passage.fkTypeReglement,
'name': passage.name,
'notes': passage.remarque,
'hasReceipt': passage.nomRecu.isNotEmpty,
'hasError': passage.emailErreur.isNotEmpty,
'fkUser': passage.fkUser, // Ajouter l'ID de l'utilisateur
};
}).toList();
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Derniers passages',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
TextButton(
onPressed: () {
// Naviguer vers la page d'historique
},
child: const Text('Voir tout'),
),
],
),
),
// Utilisation du widget commun PassagesListWidget
PassagesListWidget(
passages: recentPassages,
showFilters: false,
showSearch: false,
showActions: true, // Activer l'affichage des boutons d'action
maxPassages: 10,
// Exclure les passages de type 2 (À finaliser)
excludePassageTypes: [2],
// Filtrer par utilisateur courant
filterByUserId: userRepository.getCurrentUser()?.id,
// Période par défaut (derniers 15 jours)
periodFilter: 'last15',
onPassageSelected: (passage) {
// Action lors de la sélection d'un passage
debugPrint('Passage sélectionné: ${passage['id']}');
},
onDetailsView: (passage) {
// Action lors de l'affichage des détails
debugPrint('Affichage des détails: ${passage['id']}');
},
// Callback pour le bouton de modification
onPassageEdit: (passage) {
// Action lors de la modification d'un passage
debugPrint('Modification du passage: ${passage['id']}');
// Ici, vous pourriez ouvrir un formulaire d'édition
},
// Callback pour le bouton de reçu (uniquement pour les passages de type 1)
onReceiptView: (passage) {
// Action lors de la demande d'affichage du reçu
debugPrint(
'Affichage du reçu pour le passage: ${passage['id']}');
// Ici, vous pourriez générer et afficher un PDF
},
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,378 @@
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:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
// Import des pages utilisateur
import 'user_dashboard_home_page.dart';
import 'user_statistics_page.dart';
import 'user_history_page.dart';
import 'user_communication_page.dart';
import 'user_map_page.dart';
class UserDashboardPage extends StatefulWidget {
const UserDashboardPage({super.key});
@override
State<UserDashboardPage> createState() => _UserDashboardPageState();
}
class _UserDashboardPageState extends State<UserDashboardPage> {
int _selectedIndex = 0;
// Liste des pages à afficher
late final List<Widget> _pages;
// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
@override
void initState() {
super.initState();
_pages = [
const UserDashboardHomePage(),
const UserStatisticsPage(),
const UserHistoryPage(),
const UserCommunicationPage(),
const UserMapPage(),
];
// Initialiser et charger les paramètres
_initSettings();
}
// Initialiser la boîte de paramètres et charger les préférences
Future<void> _initSettings() async {
try {
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
if (!Hive.isBoxOpen(AppKeys.settingsBoxName)) {
_settingsBox = await Hive.openBox(AppKeys.settingsBoxName);
} else {
_settingsBox = Hive.box(AppKeys.settingsBoxName);
}
// Charger l'index de page sélectionné
final savedIndex = _settingsBox.get('selectedPageIndex');
if (savedIndex != null &&
savedIndex is int &&
savedIndex >= 0 &&
savedIndex < _pages.length) {
setState(() {
_selectedIndex = savedIndex;
});
}
} catch (e) {
debugPrint('Erreur lors du chargement des paramètres: $e');
}
}
// Sauvegarder les paramètres utilisateur
void _saveSettings() {
try {
// Sauvegarder l'index de page sélectionné
_settingsBox.put('selectedPageIndex', _selectedIndex);
} catch (e) {
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
}
}
@override
Widget build(BuildContext context) {
// Utiliser l'instance globale définie dans app.dart
final hasOperation = userRepository.getCurrentOperation() != null;
final hasSectors = userRepository.getUserSectors().isNotEmpty;
final isStandardUser = userRepository.currentUser != null &&
userRepository.currentUser!.role ==
'1'; // Rôle 1 = utilisateur standard
// Si l'utilisateur est standard et n'a pas d'opération assignée ou n'a pas de secteur, afficher un message spécial
final bool shouldShowNoOperationMessage = isStandardUser && !hasOperation;
final bool shouldShowNoSectorMessage = isStandardUser && !hasSectors;
// Définir les actions supplémentaires pour l'AppBar
List<Widget>? additionalActions;
if (shouldShowNoOperationMessage || shouldShowNoSectorMessage) {
additionalActions = [
// Bouton de déconnexion uniquement si l'utilisateur n'a pas d'opération
TextButton.icon(
icon: const Icon(Icons.logout, color: Colors.white),
label: const Text('Se déconnecter',
style: TextStyle(color: Colors.white)),
onPressed: () async {
// Utiliser directement userRepository pour la déconnexion
await userRepository.logoutWithUI(context);
// La redirection est gérée dans logoutWithUI
},
style: TextButton.styleFrom(
backgroundColor: AppTheme.accentColor,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
const SizedBox(width: 16), // Espacement à droite
];
}
return shouldShowNoOperationMessage
? _buildNoOperationMessage(context)
: (shouldShowNoSectorMessage
? _buildNoSectorMessage(context)
: DashboardLayout(
title: 'GEOSECTOR',
selectedIndex: _selectedIndex,
onDestinationSelected: (index) {
setState(() {
_selectedIndex = index;
_saveSettings(); // Sauvegarder l'index de page sélectionné
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
NavigationDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: 'Stats',
),
NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
NavigationDestination(
icon: Icon(Icons.chat_outlined),
selectedIcon: Icon(Icons.chat),
label: 'Messages',
),
NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
],
additionalActions: additionalActions,
onNewPassagePressed: () => _showPassageForm(context),
body: _pages[_selectedIndex],
));
}
// Message pour les utilisateurs sans opération assignée
Widget _buildNoOperationMessage(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Container(
padding: const EdgeInsets.all(24),
constraints: const BoxConstraints(maxWidth: 500),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.warning_amber_rounded,
size: 80,
color: theme.colorScheme.error,
),
const SizedBox(height: 24),
Text(
'Aucune opération assignée',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Vous n\'avez pas encore été affecté à une opération. Veuillez contacter votre administrateur pour obtenir un accès.',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
),
),
);
}
// Message pour les utilisateurs sans secteur assigné
Widget _buildNoSectorMessage(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Container(
padding: const EdgeInsets.all(24),
constraints: const BoxConstraints(maxWidth: 500),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.map_outlined,
size: 80,
color: theme.colorScheme.error,
),
const SizedBox(height: 24),
Text(
'Aucun secteur assigné',
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'Vous n\'êtes affecté sur aucun secteur. Contactez votre administrateur pour qu\'il vous en affecte au moins un.',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
textAlign: TextAlign.center,
),
],
),
),
);
}
// Affiche le formulaire de passage
void _showPassageForm(BuildContext context) {
final theme = Theme.of(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(
'Nouveau passage',
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: InputDecoration(
labelText: 'Adresse',
prefixIcon: const Icon(Icons.location_on),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
decoration: InputDecoration(
labelText: 'Type de passage',
prefixIcon: const Icon(Icons.category),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
items: const [
DropdownMenuItem(
value: 1,
child: Text('Effectué'),
),
DropdownMenuItem(
value: 2,
child: Text('À finaliser'),
),
DropdownMenuItem(
value: 3,
child: Text('Refusé'),
),
DropdownMenuItem(
value: 4,
child: Text('Don'),
),
DropdownMenuItem(
value: 5,
child: Text('Lot'),
),
DropdownMenuItem(
value: 6,
child: Text('Maison vide'),
),
],
onChanged: (value) {},
),
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: 'Commentaire',
prefixIcon: const Icon(Icons.comment),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
maxLines: 3,
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(
'Annuler',
style: TextStyle(
color: theme.colorScheme.error,
),
),
),
ElevatedButton(
onPressed: () {
// Enregistrer le passage
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Passage enregistré avec succès'),
backgroundColor: theme.colorScheme.primary,
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
),
child: const Text('Enregistrer'),
),
],
),
);
}
}

View File

@@ -0,0 +1,572 @@
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/passages/passages_list_widget.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
class UserHistoryPage extends StatefulWidget {
const UserHistoryPage({super.key});
@override
State<UserHistoryPage> createState() => _UserHistoryPageState();
}
class _UserHistoryPageState extends State<UserHistoryPage> {
// Liste qui contiendra les passages convertis
List<Map<String, dynamic>> _convertedPassages = [];
// Variables pour indiquer l'état de chargement
bool _isLoading = true;
String _errorMessage = '';
@override
void initState() {
super.initState();
// Charger les passages depuis la box Hive au démarrage
_loadPassages();
}
// Méthode pour charger les passages depuis le repository
Future<void> _loadPassages() async {
setState(() {
_isLoading = true;
_errorMessage = '';
});
try {
// Utiliser l'instance globale définie dans app.dart
// Utiliser la propriété passages qui gère déjà l'ouverture de la box
final List<PassageModel> allPassages = passageRepository.passages;
debugPrint('Nombre total de passages dans la box: ${allPassages.length}');
// Filtrer pour exclure les passages de type 2
List<PassageModel> filtered = [];
for (var passage in allPassages) {
try {
if (passage.fkType != 2) {
filtered.add(passage);
}
} catch (e) {
debugPrint('Erreur lors du filtrage du passage: $e');
// Si nous ne pouvons pas accéder à fkType, ne pas ajouter ce passage
}
}
debugPrint(
'Nombre de passages après filtrage (fkType != 2): ${filtered.length}');
// Afficher la distribution des types de passages pour le débogage
final Map<int, int> typeCount = {};
for (var passage in filtered) {
typeCount[passage.fkType] = (typeCount[passage.fkType] ?? 0) + 1;
}
typeCount.forEach((type, count) {
debugPrint('Type de passage $type: $count passages');
});
// Afficher la plage de dates pour le débogage
if (filtered.isNotEmpty) {
// Trier par date pour trouver min et max
final sortedByDate = List<PassageModel>.from(filtered);
sortedByDate.sort((a, b) => a.passedAt.compareTo(b.passedAt));
final DateTime minDate = sortedByDate.first.passedAt;
final DateTime maxDate = sortedByDate.last.passedAt;
// Log détaillé pour débogage
debugPrint(
'Plage de dates des passages: ${minDate.toString()} à ${maxDate.toString()}');
// Afficher les 5 passages les plus anciens et les 5 plus récents pour débogage
debugPrint('\n--- 5 PASSAGES LES PLUS ANCIENS ---');
for (int i = 0; i < sortedByDate.length && i < 5; i++) {
final p = sortedByDate[i];
debugPrint(
'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}');
}
debugPrint('\n--- 5 PASSAGES LES PLUS RÉCENTS ---');
for (int i = sortedByDate.length - 1;
i >= 0 && i >= sortedByDate.length - 5;
i--) {
final p = sortedByDate[i];
debugPrint(
'ID: ${p.id}, Type: ${p.fkType}, Date: ${p.passedAt}, Adresse: ${p.rue}');
}
// Vérifier la distribution des passages par mois
final Map<String, int> monthCount = {};
for (var passage in filtered) {
final String monthKey =
'${passage.passedAt.year}-${passage.passedAt.month.toString().padLeft(2, '0')}';
monthCount[monthKey] = (monthCount[monthKey] ?? 0) + 1;
}
debugPrint('\n--- DISTRIBUTION PAR MOIS ---');
final sortedMonths = monthCount.keys.toList()..sort();
for (var month in sortedMonths) {
debugPrint('$month: ${monthCount[month]} passages');
}
}
// Convertir les modèles en Maps pour l'affichage avec gestion d'erreurs
List<Map<String, dynamic>> passagesMap = [];
for (var passage in filtered) {
try {
final Map<String, dynamic> passageMap =
_convertPassageModelToMap(passage);
if (passageMap != null) {
passagesMap.add(passageMap);
}
} catch (e) {
debugPrint('Erreur lors de la conversion du passage en map: $e');
// Ignorer ce passage et continuer
}
}
debugPrint('Nombre de passages après conversion: ${passagesMap.length}');
// Trier par date (plus récent en premier) avec gestion d'erreurs
try {
passagesMap.sort((a, b) {
try {
return (b['date'] as DateTime).compareTo(a['date'] as DateTime);
} catch (e) {
debugPrint('Erreur lors de la comparaison des dates: $e');
return 0; // Garder l'ordre actuel en cas d'erreur
}
});
} catch (e) {
debugPrint('Erreur lors du tri des passages: $e');
// Continuer sans tri en cas d'erreur
}
// Debug: vérifier la plage de dates après conversion et tri
if (passagesMap.isNotEmpty) {
debugPrint('\n--- PLAGE DE DATES APRÈS CONVERSION ET TRI ---');
final firstDate = passagesMap.last['date'] as DateTime;
final lastDate = passagesMap.first['date'] as DateTime;
debugPrint('Premier passage: ${firstDate.toString()}');
debugPrint('Dernier passage: ${lastDate.toString()}');
}
setState(() {
_convertedPassages = passagesMap;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Erreur lors du chargement des passages: $e';
_isLoading = false;
});
debugPrint(_errorMessage);
}
}
// Convertir un modèle de passage en Map pour l'affichage avec gestion renforcée des erreurs
Map<String, dynamic> _convertPassageModelToMap(PassageModel passage) {
try {
// Le passage ne peut pas être null en Dart non-nullable,
// mais nous gardons cette structure pour faciliter la gestion des erreurs
// Construire l'adresse complète avec gestion des erreurs
String address = 'Adresse non disponible';
try {
address = _buildFullAddress(passage);
} catch (e) {
debugPrint('Erreur lors de la construction de l\'adresse: $e');
}
// Convertir le montant en double avec sécurité
double amount = 0.0;
try {
if (passage.montant.isNotEmpty) {
amount = double.parse(passage.montant);
}
} catch (e) {
debugPrint('Erreur de conversion du montant: ${passage.montant}: $e');
}
// Récupérer la date avec gestion d'erreur
DateTime date;
try {
date = passage.passedAt;
} catch (e) {
debugPrint('Erreur lors de la récupération de la date: $e');
date = DateTime.now();
}
// Récupérer le type avec gestion d'erreur
int type;
try {
type = passage.fkType;
// Si le type n'est pas dans les types connus, utiliser 0 comme valeur par défaut
if (!AppKeys.typesPassages.containsKey(type)) {
type = 0; // Type inconnu
}
} catch (e) {
debugPrint('Erreur lors de la récupération du type: $e');
type = 0;
}
// Récupérer le type de règlement avec gestion d'erreur
int payment;
try {
payment = passage.fkTypeReglement;
// Si le type de règlement n'est pas dans les types connus, utiliser 0 comme valeur par défaut
if (!AppKeys.typesReglements.containsKey(payment)) {
payment = 0; // Type de règlement inconnu
}
} catch (e) {
debugPrint('Erreur lors de la récupération du type de règlement: $e');
payment = 0;
}
// Gérer les champs optionnels
String name = '';
try {
name = passage.name;
} catch (e) {
debugPrint('Erreur lors de la récupération du nom: $e');
}
String notes = '';
try {
notes = passage.remarque;
} catch (e) {
debugPrint('Erreur lors de la récupération des remarques: $e');
}
// Vérifier si un reçu est disponible avec gestion d'erreur
bool hasReceipt = false;
try {
hasReceipt = amount > 0 && type == 1 && passage.nomRecu.isNotEmpty;
} catch (e) {
debugPrint('Erreur lors de la vérification du reçu: $e');
}
// Vérifier s'il y a une erreur avec gestion d'erreur
bool hasError = false;
try {
hasError = passage.emailErreur.isNotEmpty;
} catch (e) {
debugPrint('Erreur lors de la vérification des erreurs: $e');
}
// Log pour débogage
debugPrint(
'Conversion passage ID: ${passage.id}, Type: $type, Date: $date');
return {
'id': passage.id.toString(),
'address': address,
'amount': amount,
'date': date,
'type': type,
'payment': payment,
'name': name,
'notes': notes,
'hasReceipt': hasReceipt,
'hasError': hasError,
'fkUser': passage.fkUser, // Ajouter l'ID de l'utilisateur
};
} catch (e) {
debugPrint('ERREUR CRITIQUE lors de la conversion du passage: $e');
// Retourner un objet valide par défaut pour éviter les erreurs
// Récupérer l'ID de l'utilisateur courant pour l'objet par défaut
// Utiliser l'instance globale définie dans app.dart
final currentUserId = userRepository.getCurrentUser()?.id;
return {
'id': 'error',
'address': 'Adresse non disponible',
'amount': 0.0,
'date': DateTime.now(),
'type': 0,
'payment': 0,
'name': 'Nom non disponible',
'notes': '',
'hasReceipt': false,
'hasError': true,
'fkUser': currentUserId, // Ajouter l'ID de l'utilisateur courant
};
}
}
// Construire l'adresse complète à partir des composants
String _buildFullAddress(PassageModel passage) {
final List<String> addressParts = [];
// Numéro et rue
if (passage.numero.isNotEmpty) {
addressParts.add('${passage.numero} ${passage.rue}');
} else {
addressParts.add(passage.rue);
}
// Complément rue bis
if (passage.rueBis.isNotEmpty) {
addressParts.add(passage.rueBis);
}
// Résidence/Bâtiment
if (passage.residence.isNotEmpty) {
addressParts.add(passage.residence);
}
// Appartement
if (passage.appt.isNotEmpty) {
addressParts.add('Appt ${passage.appt}');
}
// Niveau
if (passage.niveau.isNotEmpty) {
addressParts.add('Niveau ${passage.niveau}');
}
// Ville
if (passage.ville.isNotEmpty) {
addressParts.add(passage.ville);
}
return addressParts.join(', ');
}
@override
void dispose() {
super.dispose();
}
// Méthode pour afficher les détails d'un passage
void _showPassageDetails(Map<String, dynamic> passage) {
// Récupérer les informations du type de passage et du type de règlement
final typePassage =
AppKeys.typesPassages[passage['type']] as Map<String, dynamic>;
final typeReglement =
AppKeys.typesReglements[passage['payment']] as Map<String, dynamic>;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Détails du passage'),
content: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_buildDetailRow('Adresse', passage['address']),
_buildDetailRow('Nom', passage['name']),
_buildDetailRow('Date',
'${passage['date'].day}/${passage['date'].month}/${passage['date'].year}'),
_buildDetailRow('Type', typePassage['titre']),
_buildDetailRow('Règlement', typeReglement['titre']),
_buildDetailRow('Montant', '${passage['amount']}'),
if (passage['notes'] != null &&
passage['notes'].toString().isNotEmpty)
_buildDetailRow('Notes', passage['notes']),
if (passage['hasReceipt'] == true)
_buildDetailRow('Reçu', 'Disponible'),
if (passage['hasError'] == true)
_buildDetailRow('Erreur', 'Détectée', isError: true),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text('Fermer'),
),
if (passage['hasReceipt'] == true)
TextButton(
onPressed: () {
Navigator.of(context).pop();
_showReceipt(passage);
},
child: Text('Voir le reçu'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_editPassage(passage);
},
child: Text('Modifier'),
),
],
),
);
}
// Méthode pour éditer un passage
void _editPassage(Map<String, dynamic> passage) {
// Implémenter l'ouverture d'un formulaire d'édition
// Cette méthode pourrait naviguer vers une page d'édition
debugPrint('Édition du passage ${passage['id']}');
// Exemple: Navigator.of(context).push(MaterialPageRoute(builder: (_) => EditPassagePage(passage: passage)));
}
// Méthode pour afficher un reçu
void _showReceipt(Map<String, dynamic> passage) {
// Implémenter l'affichage ou la génération d'un reçu
// Cette méthode pourrait générer un PDF et l'afficher
debugPrint('Affichage du reçu pour le passage ${passage['id']}');
// Exemple: Navigator.of(context).push(MaterialPageRoute(builder: (_) => ReceiptPage(passage: passage)));
}
// Helper pour construire une ligne de détails
Widget _buildDetailRow(String label, String value, {bool isError = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text('$label:',
style: TextStyle(fontWeight: FontWeight.bold))),
Expanded(
child: Text(
value,
style: isError ? TextStyle(color: Colors.red) : null,
),
),
],
),
);
}
// Variable pour gérer la recherche
String _searchQuery = '';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avec bouton de rafraîchissement
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Historique des passages',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadPassages,
tooltip: 'Rafraîchir',
),
],
),
),
// Affichage du chargement ou des erreurs
if (_isLoading)
const Expanded(
child: Center(
child: CircularProgressIndicator(),
),
)
else if (_errorMessage.isNotEmpty)
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline,
size: 48, color: Colors.red),
const SizedBox(height: 16),
Text(
'Erreur de chargement',
style: theme.textTheme.titleLarge
?.copyWith(color: Colors.red),
),
const SizedBox(height: 8),
Text(_errorMessage),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadPassages,
child: const Text('Réessayer'),
),
],
),
),
)
// Utilisation du widget PassagesListWidget pour afficher la liste des passages
else
Column(
children: [
// Stat rapide pour l'utilisateur
if (_convertedPassages.isNotEmpty)
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
'${_convertedPassages.length} passages au total (${_convertedPassages.where((p) => (p['date'] as DateTime).isAfter(DateTime(2024, 12, 13))).length} de décembre 2024)',
style: TextStyle(
fontStyle: FontStyle.italic,
color: theme.colorScheme.primary),
),
),
// Widget de liste des passages
Expanded(
child: PassagesListWidget(
passages: _convertedPassages,
showFilters: true,
showSearch: true,
showActions: true,
initialSearchQuery: _searchQuery,
initialTypeFilter:
'Tous', // Toujours commencer avec 'Tous' pour voir tous les types
initialPaymentFilter: 'Tous',
// Exclure les passages de type 2 (À finaliser)
excludePassageTypes: [2],
// Filtrer par utilisateur courant
filterByUserId: userRepository.getCurrentUser()?.id,
// Désactiver les filtres de date implicites
key: ValueKey(
'passages_list_${DateTime.now().millisecondsSinceEpoch}'),
onPassageSelected: (passage) {
// Action lors de la sélection d'un passage
debugPrint('Passage sélectionné: ${passage['id']}');
_showPassageDetails(passage);
},
onDetailsView: (passage) {
// Action lors de l'affichage des détails
debugPrint('Affichage des détails: ${passage['id']}');
_showPassageDetails(passage);
},
onPassageEdit: (passage) {
// Action lors de la modification d'un passage
debugPrint('Modification du passage: ${passage['id']}');
_editPassage(passage);
},
onReceiptView: (passage) {
// Action lors de la demande d'affichage du reçu
debugPrint(
'Affichage du reçu pour le passage: ${passage['id']}');
_showReceipt(passage);
},
),
),
],
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,581 @@
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:fl_chart/fl_chart.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/presentation/widgets/charts/charts.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
class UserStatisticsPage extends StatefulWidget {
const UserStatisticsPage({super.key});
@override
State<UserStatisticsPage> createState() => _UserStatisticsPageState();
}
class _UserStatisticsPageState extends State<UserStatisticsPage> {
// Période sélectionnée
String _selectedPeriod = 'Semaine';
// Secteur sélectionné (0 = tous les secteurs)
int _selectedSectorId = 0;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
return Scaffold(
backgroundColor: Colors.transparent,
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Statistiques',
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 16),
// Filtres
_buildFilters(theme, isDesktop),
const SizedBox(height: 24),
// Graphiques
_buildCharts(theme),
const SizedBox(height: 24),
// Résumé par type de passage
_buildPassageTypeSummary(theme, isDesktop),
const SizedBox(height: 24),
// Résumé par type de règlement
_buildPaymentTypeSummary(theme, isDesktop),
],
),
),
),
);
}
// Construction des filtres
Widget _buildFilters(ThemeData theme, bool isDesktop) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Filtres',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Wrap(
spacing: 16,
runSpacing: 16,
children: [
// Sélection de la période
_buildFilterSection(
'Période',
['Jour', 'Semaine', 'Mois', 'Année'],
_selectedPeriod,
(value) {
setState(() {
_selectedPeriod = value;
});
},
theme,
),
// Sélection du secteur (si l'utilisateur a plusieurs secteurs)
_buildSectorSelector(context, theme),
// Bouton d'application des filtres
ElevatedButton.icon(
onPressed: () {
// Actualiser les statistiques avec les filtres sélectionnés
setState(() {
// Dans une implémentation réelle, on chargerait ici les données
// filtrées par période et secteur
});
},
icon: const Icon(Icons.filter_list),
label: const Text('Appliquer'),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.accentColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
],
),
],
),
),
);
}
// Construction du sélecteur de secteur
Widget _buildSectorSelector(BuildContext context, ThemeData theme) {
// Utiliser l'instance globale définie dans app.dart
// Récupérer les secteurs de l'utilisateur
final sectors = userRepository.getUserSectors();
// Si l'utilisateur n'a qu'un seul secteur, ne pas afficher le sélecteur
if (sectors.length <= 1) {
return const SizedBox.shrink();
}
// Créer la liste des options avec "Tous" comme première option
final List<DropdownMenuItem<int>> items = [
const DropdownMenuItem<int>(
value: 0,
child: Text('Tous les secteurs'),
),
];
// Ajouter les secteurs de l'utilisateur
for (final sector in sectors) {
items.add(
DropdownMenuItem<int>(
value: sector.id,
child: Text(sector.libelle),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Secteur',
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxWidth: 250),
child: DropdownButton<int>(
value: _selectedSectorId,
isExpanded: true,
items: items,
onChanged: (value) {
if (value != null) {
setState(() {
_selectedSectorId = value;
});
}
},
hint: const Text('Sélectionner un secteur'),
),
),
],
);
}
// Construction d'une section de filtre
Widget _buildFilterSection(
String title,
List<String> options,
String selectedValue,
Function(String) onChanged,
ThemeData theme,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall,
),
const SizedBox(height: 8),
SegmentedButton<String>(
segments: options.map((option) {
return ButtonSegment<String>(
value: option,
label: Text(option),
);
}).toList(),
selected: {selectedValue},
onSelectionChanged: (Set<String> selection) {
onChanged(selection.first);
},
style: ButtonStyle(
backgroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
return AppTheme.secondaryColor;
}
return theme.colorScheme.surface;
},
),
foregroundColor: MaterialStateProperty.resolveWith<Color>(
(Set<MaterialState> states) {
if (states.contains(MaterialState.selected)) {
return Colors.white;
}
return theme.colorScheme.onSurface;
},
),
),
),
],
);
}
// Construction des graphiques
Widget _buildCharts(ThemeData theme) {
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Passages et règlements par $_selectedPeriod',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
SizedBox(
height: 300,
child: _buildActivityChart(theme),
),
],
),
),
);
}
// Construction du graphique d'activité
Widget _buildActivityChart(ThemeData theme) {
// Générer des données fictives pour les passages
final now = DateTime.now();
final List<Map<String, dynamic>> passageData = [];
// Récupérer le secteur sélectionné (si applicable)
final String sectorLabel = _selectedSectorId == 0
? 'Tous les secteurs'
: userRepository.getSectorById(_selectedSectorId)?.libelle ??
'Secteur inconnu';
// Déterminer la plage de dates en fonction de la période sélectionnée
DateTime startDate;
int daysToGenerate;
switch (_selectedPeriod) {
case 'Jour':
startDate = DateTime(now.year, now.month, now.day);
daysToGenerate = 1;
break;
case 'Semaine':
// Début de la semaine (lundi)
final weekday = now.weekday;
startDate = now.subtract(Duration(days: weekday - 1));
daysToGenerate = 7;
break;
case 'Mois':
// Début du mois
startDate = DateTime(now.year, now.month, 1);
// Calculer le nombre de jours dans le mois
final lastDayOfMonth = DateTime(now.year, now.month + 1, 0).day;
daysToGenerate = lastDayOfMonth;
break;
case 'Année':
// Début de l'année
startDate = DateTime(now.year, 1, 1);
daysToGenerate = 365;
break;
default:
startDate = DateTime(now.year, now.month, now.day);
daysToGenerate = 7;
}
// Générer des données pour la période sélectionnée
for (int i = 0; i < daysToGenerate; i++) {
final date = startDate.add(Duration(days: i));
// Générer des données pour chaque type de passage
for (int typeId = 1; typeId <= 6; typeId++) {
// Générer un nombre de passages basé sur le jour et le type
final count = (typeId == 1 || typeId == 2)
? (2 + (date.day % 6)) // Plus de passages pour les types 1 et 2
: (date.day % 4); // Moins pour les autres types
if (count > 0) {
passageData.add({
'date': date.toIso8601String(),
'type_passage': typeId,
'nb': count,
});
}
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Afficher le secteur sélectionné si ce n'est pas "Tous"
if (_selectedSectorId != 0)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Text(
'Secteur: $sectorLabel',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
ActivityChart(
passageData: passageData,
periodType: _selectedPeriod,
height: 300,
),
],
);
}
// Construction du résumé par type de passage
Widget _buildPassageTypeSummary(ThemeData theme, bool isDesktop) {
// Dans une implémentation réelle, ces données seraient filtrées par secteur
// en fonction de _selectedSectorId
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Répartition par type de passage',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Row(
children: [
// Graphique circulaire
Expanded(
flex: isDesktop ? 1 : 2,
child: SizedBox(
height: 200,
child: PassagePieChart(
passagesByType: {
1: 60, // Effectués
2: 15, // À finaliser
3: 10, // Refusés
4: 8, // Dons
5: 5, // Lots
6: 2, // Maisons vides
},
size: 140,
labelSize: 12,
showPercentage: true,
showIcons: false, // Désactiver les icônes
isDonut: true, // Activer le format donut
innerRadius: '50%' // Rayon interne du donut
),
),
),
// Légende
if (isDesktop)
Expanded(
flex: 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLegendItem(
'Effectués', '60%', const Color(0xFF4CAF50)),
_buildLegendItem(
'À finaliser', '15%', const Color(0xFFFF9800)),
_buildLegendItem(
'Refusés', '10%', const Color(0xFFF44336)),
_buildLegendItem('Dons', '8%', const Color(0xFF03A9F4)),
_buildLegendItem('Lots', '5%', const Color(0xFF0D47A1)),
_buildLegendItem(
'Maisons vides', '2%', const Color(0xFF9E9E9E)),
],
),
),
],
),
if (!isDesktop)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
_buildLegendItem('Effectués', '60%', const Color(0xFF4CAF50)),
_buildLegendItem(
'À finaliser', '15%', const Color(0xFFFF9800)),
_buildLegendItem('Refusés', '10%', const Color(0xFFF44336)),
_buildLegendItem('Dons', '8%', const Color(0xFF03A9F4)),
_buildLegendItem('Lots', '5%', const Color(0xFF0D47A1)),
_buildLegendItem(
'Maisons vides', '2%', const Color(0xFF9E9E9E)),
],
),
],
),
),
);
}
// Construction du résumé par type de règlement
Widget _buildPaymentTypeSummary(ThemeData theme, bool isDesktop) {
// Dans une implémentation réelle, ces données seraient filtrées par secteur
// en fonction de _selectedSectorId
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Répartition par type de règlement',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Row(
children: [
// Graphique circulaire
Expanded(
flex: isDesktop ? 1 : 2,
child: SizedBox(
height: 200,
child: PieChart(
PieChartData(
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: [
_buildPieChartSection(
'Espèces', 30, const Color(0xFF4CAF50), 0),
_buildPieChartSection(
'Chèques', 45, const Color(0xFF2196F3), 1),
_buildPieChartSection(
'CB', 25, const Color(0xFFF44336), 2),
],
),
),
),
),
// Légende
if (isDesktop)
Expanded(
flex: 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLegendItem(
'Espèces', '30%', const Color(0xFF4CAF50)),
_buildLegendItem(
'Chèques', '45%', const Color(0xFF2196F3)),
_buildLegendItem('CB', '25%', const Color(0xFFF44336)),
],
),
),
],
),
if (!isDesktop)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 16),
_buildLegendItem('Espèces', '30%', const Color(0xFF4CAF50)),
_buildLegendItem('Chèques', '45%', const Color(0xFF2196F3)),
_buildLegendItem('CB', '25%', const Color(0xFFF44336)),
],
),
],
),
),
);
}
// Construction d'une section de graphique circulaire
PieChartSectionData _buildPieChartSection(
String title, double value, Color color, int index) {
return PieChartSectionData(
color: color,
value: value,
title: '$value%',
radius: 60,
titleStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: Colors.white,
),
);
}
// Construction d'un élément de légende
Widget _buildLegendItem(String title, String value, Color color) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
const Spacer(),
Text(
value,
style: TextStyle(
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
/// Widget pour afficher une ligne du tableau d'amicales
/// Affiche les colonnes id, name, codePostal, libRegion et une colonne Actions
/// La colonne Actions contient un bouton Delete pour les utilisateurs avec rôle > 2
/// La ligne entière est cliquable pour afficher les détails de l'amicale
class AmicaleRowWidget extends StatelessWidget {
final AmicaleModel amicale;
final Function(AmicaleModel)? onTap;
final Function(AmicaleModel)? onDelete;
final bool isHeader;
final bool isAlternate;
const AmicaleRowWidget({
Key? key,
required this.amicale,
this.onTap,
this.onDelete,
this.isHeader = false,
this.isAlternate = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final userRole = userRepository.getUserRole();
// Définir les styles en fonction du type de ligne (en-tête ou données)
final textStyle = isHeader
? theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
)
: theme.textTheme.bodyMedium;
// Couleur de fond en fonction du type de ligne
final backgroundColor = isHeader
? theme.colorScheme.primary.withOpacity(0.1)
: (isAlternate
? theme.colorScheme.surface
: theme.colorScheme.background);
return InkWell(
onTap: isHeader || onTap == null ? null : () => onTap!(amicale),
child: Container(
decoration: BoxDecoration(
color: backgroundColor,
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withOpacity(0.3),
width: 1,
),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
children: [
// Colonne ID
Expanded(
flex: 1,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
isHeader ? 'ID' : amicale.id.toString(),
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
),
// Colonne Nom
Expanded(
flex: 4,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
isHeader ? 'Nom' : amicale.name,
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
),
// Colonne Code Postal
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
isHeader ? 'Code Postal' : amicale.codePostal,
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
),
// Colonne Ville
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
isHeader ? 'Ville' : (amicale.ville ?? ''),
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
),
// Colonne Région
Expanded(
flex: 3,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
isHeader ? 'Région' : (amicale.libRegion ?? ''),
style: textStyle,
overflow: TextOverflow.ellipsis,
),
),
),
// Colonne Actions - seulement si l'utilisateur a le rôle > 2 et onDelete n'est pas null
if (isHeader || (userRole > 2 && onDelete != null))
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: isHeader
? Text(
'Actions',
style: textStyle,
overflow: TextOverflow.ellipsis,
)
: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Bouton Delete
IconButton(
icon: Icon(
Icons.delete,
color: theme.colorScheme.error,
size: 20,
),
tooltip: 'Supprimer',
onPressed: () => onDelete!(amicale),
constraints: const BoxConstraints(
minWidth: 36,
minHeight: 36,
),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
],
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/region_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/amicale_row_widget.dart';
import 'package:geosector_app/presentation/widgets/entite_form.dart';
import 'package:provider/provider.dart';
/// Widget de tableau pour afficher une liste d'amicales
///
/// Ce widget affiche un tableau avec les colonnes :
/// - ID
/// - Nom
/// - Code Postal
/// - Région
/// - Actions (boutons selon les droits de l'utilisateur)
///
/// Lorsqu'on clique sur une ligne, une modale s'affiche avec le formulaire EntiteForm
class AmicaleTableWidget extends StatelessWidget {
final List<AmicaleModel> amicales;
final Function(AmicaleModel)? onDelete;
final bool isLoading;
final String? emptyMessage;
final bool readOnly;
const AmicaleTableWidget({
Key? key,
required this.amicales,
this.onDelete,
this.isLoading = false,
this.emptyMessage,
this.readOnly = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// En-tête du tableau - utiliser AmicaleRowWidget pour l'en-tête
AmicaleRowWidget(
amicale: AmicaleModel(
id: 0,
name: '',
codePostal: '',
ville: '',
libRegion: '',
),
isHeader: true,
onTap: null,
onDelete: null,
),
// Corps du tableau
Container(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(8),
bottomRight: Radius.circular(8),
),
border: Border.all(
color: theme.colorScheme.primary.withOpacity(0.1),
width: 1,
),
),
child: _buildTableContent(context),
),
],
);
}
Widget _buildTableContent(BuildContext context) {
// Afficher un indicateur de chargement si isLoading est true
if (isLoading) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Center(child: CircularProgressIndicator()),
);
}
// Afficher un message si la liste est vide
if (amicales.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32.0),
child: Center(
child: Text(
emptyMessage ?? 'Aucune amicale trouvée',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color:
Theme.of(context).colorScheme.onSurface.withOpacity(0.6),
),
),
),
);
}
// Afficher la liste des amicales
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: amicales.length,
itemBuilder: (context, index) {
final amicale = amicales[index];
return AmicaleRowWidget(
amicale: amicale,
isAlternate: index % 2 == 1, // Alterner les couleurs
onTap: (selectedAmicale) =>
_showAmicaleDetails(context, selectedAmicale),
onDelete: onDelete,
);
},
);
}
// Afficher une modale avec le formulaire EntiteForm
void _showAmicaleDetails(BuildContext context, AmicaleModel amicale) {
// Utiliser l'instance globale de userRepository définie dans app.dart
final userRepo = userRepository;
// Créer une instance de RegionRepository
final regionRepo = RegionRepository();
showDialog(
context: context,
builder: (dialogContext) => MultiProvider(
providers: [
// Fournir les repositories nécessaires au formulaire
Provider<UserRepository>.value(value: userRepo),
Provider<RegionRepository>.value(value: regionRepo),
],
child: Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
width: MediaQuery.of(dialogContext).size.width * 0.6,
padding: const EdgeInsets.all(24),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Détails de l\'amicale',
style: Theme.of(dialogContext)
.textTheme
.headlineSmall
?.copyWith(
fontWeight: FontWeight.bold,
color:
Theme.of(dialogContext).colorScheme.primary,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(dialogContext).pop(),
),
],
),
const SizedBox(height: 16),
// Formulaire EntiteForm en mode lecture seule
EntiteForm(
amicale: amicale,
readOnly: readOnly,
onSubmit: (updatedAmicale) {
Navigator.of(dialogContext).pop();
// Ici, vous pourriez ajouter une logique pour mettre à jour l'amicale
},
),
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,484 @@
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:flutter/foundation.dart' show listEquals;
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/services/passage_data_service.dart';
/// Widget de graphique d'activité affichant les passages
class ActivityChart extends StatefulWidget {
/// Liste des données de passage par date et type (si fournie directement)
/// Format attendu: [{"date": String, "type_passage": int, "nb": int}, ...]
final List<Map<String, dynamic>>? passageData;
/// Type de période (Jour, Semaine, Mois, Année)
final String periodType;
/// Hauteur du graphique
final double height;
/// Nombre de jours à afficher (par défaut 15)
final int daysToShow;
/// ID de l'utilisateur pour filtrer les passages (null = tous les utilisateurs)
final int? userId;
/// Types de passages à exclure (par défaut [2] = "À finaliser")
final List<int> excludePassageTypes;
/// Indique si les données doivent être chargées depuis la Hive box
final bool loadFromHive;
/// Callback appelé lorsque la période change
final Function(int days)? onPeriodChanged;
/// Titre du graphique
final String title;
/// Afficher les étiquettes de valeur
final bool showDataLabels;
/// Largeur des colonnes (en pourcentage)
final double columnWidth;
/// Espacement entre les colonnes (en pourcentage)
final double columnSpacing;
/// Si vrai, n'applique aucun filtrage par utilisateur (affiche tous les passages)
final bool showAllPassages;
/// Si vrai, force le rechargement des données
final bool forceRefresh;
const ActivityChart({
super.key,
this.passageData,
this.periodType = 'Jour',
this.height = 350,
this.daysToShow = 15,
this.userId,
this.excludePassageTypes = const [2],
this.loadFromHive = false,
this.onPeriodChanged,
this.title = 'Dernière activité enregistrée sur 15 jours',
this.showDataLabels = true,
this.columnWidth = 0.8,
this.columnSpacing = 0.2,
this.showAllPassages = false,
this.forceRefresh = false,
}) : assert(loadFromHive || passageData != null,
'Soit loadFromHive doit être true, soit passageData doit être fourni');
@override
State<ActivityChart> createState() => _ActivityChartState();
}
/// Classe pour stocker les données d'activité par date
class ActivityData {
final DateTime date;
final String dateStr;
final Map<int, int> passagesByType;
final int totalPassages;
ActivityData({
required this.date,
required this.dateStr,
required this.passagesByType,
}) : totalPassages =
passagesByType.values.fold(0, (sum, count) => sum + count);
}
class _ActivityChartState extends State<ActivityChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
// Données pour les graphiques
List<Map<String, dynamic>> _passageData = [];
List<ActivityData> _chartData = [];
bool _isLoading = true;
bool _hasData = false;
bool _dataLoaded = false;
// Période sélectionnée en jours
int _selectedDays = 15;
// Contrôleur de zoom pour le graphique
late ZoomPanBehavior _zoomPanBehavior;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
// Initialiser la période sélectionnée avec la valeur par défaut du widget
_selectedDays = widget.daysToShow;
// Initialiser le contrôleur de zoom
_zoomPanBehavior = ZoomPanBehavior(
enablePinching: true,
enableDoubleTapZooming: true,
enablePanning: true,
zoomMode: ZoomMode.x,
);
_loadData();
_animationController.forward();
}
/// Trouve la date du passage le plus récent
DateTime _getMostRecentDate() {
final allDates = [
..._passageData.map((data) => DateTime.parse(data['date'] as String)),
];
if (allDates.isEmpty) {
return DateTime.now();
}
return allDates.reduce((a, b) => a.isAfter(b) ? a : b);
}
void _loadData() {
// Marquer comme chargé immédiatement pour éviter les appels multiples pendant le chargement
// Mais permettre un rechargement ultérieur si nécessaire
if (_dataLoaded && _hasData) return;
_dataLoaded = true;
setState(() {
_isLoading = true;
});
if (widget.loadFromHive) {
// Charger les données depuis Hive
WidgetsBinding.instance.addPostFrameCallback((_) {
// Éviter de recharger si le widget a été démonté entre-temps
if (!mounted) return;
try {
// Utiliser les instances globales définies dans app.dart
// Créer une instance du service de données
final passageDataService = PassageDataService(
passageRepository: passageRepository,
userRepository: userRepository,
);
// Utiliser le service pour charger les données
_passageData = passageDataService.loadPassageData(
daysToShow: _selectedDays,
excludePassageTypes: widget.excludePassageTypes,
userId: widget.userId,
showAllPassages: widget.showAllPassages,
);
_prepareChartData();
// Mettre à jour l'état une seule fois après avoir préparé les données
if (mounted) {
setState(() {
_isLoading = false;
_hasData = _chartData.isNotEmpty;
});
}
} catch (e) {
// En cas d'erreur, réinitialiser l'état pour permettre une future tentative
if (mounted) {
setState(() {
_isLoading = false;
_hasData = false;
});
}
}
});
} else {
// Utiliser les données fournies directement
_passageData = widget.passageData ?? [];
_prepareChartData();
setState(() {
_isLoading = false;
_hasData = _chartData.isNotEmpty;
});
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
void didUpdateWidget(ActivityChart oldWidget) {
super.didUpdateWidget(oldWidget);
// Vérifier si les propriétés importantes ont changé
final bool periodChanged = oldWidget.periodType != widget.periodType ||
oldWidget.daysToShow != widget.daysToShow;
final bool dataSourceChanged = widget.loadFromHive
? false
: oldWidget.passageData != widget.passageData;
final bool filteringChanged = oldWidget.userId != widget.userId ||
!listEquals(
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages;
final bool refreshForced = widget.forceRefresh && !oldWidget.forceRefresh;
// Si des paramètres importants ont changé ou si forceRefresh est passé à true, recharger les données
if (periodChanged ||
dataSourceChanged ||
filteringChanged ||
refreshForced) {
_selectedDays = widget.daysToShow;
_dataLoaded = false; // Réinitialiser l'état pour forcer le rechargement
_loadData();
}
}
// La méthode _loadPassageDataFromHive a été intégrée directement dans _loadData
// pour éviter les appels multiples et les problèmes de cycle de vie
/// Prépare les données pour le graphique
void _prepareChartData() {
try {
// Vérifier que les données sont disponibles
if (_passageData.isEmpty) {
_chartData = [];
return;
}
// Obtenir toutes les dates uniques
final Set<String> uniqueDatesSet = {};
for (final data in _passageData) {
if (data.containsKey('date') && data['date'] != null) {
uniqueDatesSet.add(data['date'] as String);
}
}
// Trier les dates
final List<String> uniqueDates = uniqueDatesSet.toList();
uniqueDates.sort();
// Créer les données pour chaque date
_chartData = [];
for (final dateStr in uniqueDates) {
final passagesByType = <int, int>{};
// Initialiser tous les types de passage possibles
for (final typeId in AppKeys.typesPassages.keys) {
if (!widget.excludePassageTypes.contains(typeId)) {
passagesByType[typeId] = 0;
}
}
// Remplir les données de passage
for (final data in _passageData) {
if (data.containsKey('date') &&
data['date'] == dateStr &&
data.containsKey('type_passage') &&
data.containsKey('nb')) {
final typeId = data['type_passage'] as int;
if (!widget.excludePassageTypes.contains(typeId)) {
passagesByType[typeId] = data['nb'] as int;
}
}
}
try {
// Convertir la date en objet DateTime
final dateParts = dateStr.split('-');
if (dateParts.length == 3) {
final year = int.parse(dateParts[0]);
final month = int.parse(dateParts[1]);
final day = int.parse(dateParts[2]);
final date = DateTime(year, month, day);
// Ajouter les données à la liste
_chartData.add(ActivityData(
date: date,
dateStr: dateStr,
passagesByType: passagesByType,
));
}
} catch (e) {
// Silencieux lors des erreurs de conversion de date pour éviter les logs excessifs
}
}
// Trier les données par date
_chartData.sort((a, b) => a.date.compareTo(b.date));
} catch (e) {
// Erreur silencieuse pour éviter les logs excessifs
_chartData = [];
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return SizedBox(
height: widget.height,
child: const Center(
child: CircularProgressIndicator(),
),
);
}
if (!_hasData || _chartData.isEmpty) {
return SizedBox(
height: widget.height,
child: const Center(
child: Text('Aucune donnée disponible'),
),
);
}
// Préparer les données si nécessaire
if (_chartData.isEmpty) {
_prepareChartData();
}
return SizedBox(
height: widget.height,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre (conservé)
if (widget.title.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
left: 16.0, right: 16.0, top: 16.0, bottom: 8.0),
child: Text(
widget.title,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Graphique (occupe maintenant plus d'espace)
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 8.0, 16.0, 16.0),
child: SfCartesianChart(
plotAreaBorderWidth: 0,
legend: Legend(
isVisible: true,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
),
primaryXAxis: DateTimeAxis(
dateFormat: DateFormat('dd/MM'),
intervalType: DateTimeIntervalType.days,
majorGridLines: const MajorGridLines(width: 0),
labelStyle: const TextStyle(fontSize: 10),
// Définir explicitement la plage de dates à afficher
minimum: _chartData.isNotEmpty ? _chartData.first.date : null,
maximum: _chartData.isNotEmpty ? _chartData.last.date : null,
// Assurer que tous les jours sont affichés
interval: 1,
axisLabelFormatter: (AxisLabelRenderDetails details) {
return ChartAxisLabel(details.text, details.textStyle);
},
),
primaryYAxis: NumericAxis(
labelStyle: const TextStyle(fontSize: 10),
axisLine: const AxisLine(width: 0),
majorTickLines: const MajorTickLines(size: 0),
majorGridLines: const MajorGridLines(
width: 0.5,
color: Colors.grey,
dashArray: <double>[5, 5], // Motif de pointillés
),
title: const AxisTitle(
text: 'Passages',
textStyle: TextStyle(fontSize: 10, color: Colors.grey),
),
),
series: _buildSeries(),
tooltipBehavior: TooltipBehavior(enable: true),
zoomPanBehavior: _zoomPanBehavior,
),
),
),
],
),
);
}
/// Construit les séries de données pour le graphique
List<CartesianSeries<ActivityData, DateTime>> _buildSeries() {
final List<CartesianSeries<ActivityData, DateTime>> series = [];
// Vérifier que les données sont disponibles
if (_chartData.isEmpty) {
return series;
}
// Obtenir tous les types de passage (sauf ceux exclus)
final passageTypes = AppKeys.typesPassages.keys
.where((typeId) => !widget.excludePassageTypes.contains(typeId))
.toList();
// Créer les séries pour les passages (colonnes empilées)
for (final typeId in passageTypes) {
// Vérifier que le type existe dans AppKeys
if (!AppKeys.typesPassages.containsKey(typeId)) {
continue;
}
final typeInfo = AppKeys.typesPassages[typeId]!;
// Vérifier que les clés nécessaires existent
if (!typeInfo.containsKey('couleur1') || !typeInfo.containsKey('titre')) {
continue;
}
final typeColor = Color(typeInfo['couleur1'] as int);
final typeName = typeInfo['titre'] as String;
// Calculer le total pour ce type pour déterminer s'il faut l'afficher
int totalForType = 0;
for (final data in _chartData) {
totalForType += data.passagesByType[typeId] ?? 0;
}
// On peut décider de ne pas afficher les types sans données
final addZeroValueTypes = false;
// Ajouter la série pour ce type
if (totalForType > 0 || addZeroValueTypes) {
series.add(
StackedColumnSeries<ActivityData, DateTime>(
name: typeName,
dataSource: _chartData,
xValueMapper: (ActivityData data, _) => data.date,
yValueMapper: (ActivityData data, _) {
final value = data.passagesByType.containsKey(typeId)
? data.passagesByType[typeId]!
: 0;
return value;
},
color: typeColor,
width: widget.columnWidth,
spacing: widget.columnSpacing,
dataLabelSettings: DataLabelSettings(
isVisible: widget.showDataLabels,
labelAlignment: ChartDataLabelAlignment.middle,
textStyle: const TextStyle(fontSize: 8, color: Colors.white),
),
markerSettings: const MarkerSettings(isVisible: false),
animationDuration: 1500,
),
);
}
}
return series;
}
}

View File

@@ -0,0 +1,11 @@
/// Bibliothèque de widgets de graphiques pour l'application GeoSector
library geosector_charts;
export 'payment_data.dart';
export 'payment_pie_chart.dart';
export 'payment_utils.dart';
export 'passage_data.dart';
export 'passage_utils.dart';
export 'passage_pie_chart.dart';
export 'activity_chart.dart';
export 'combined_chart.dart';

View File

@@ -0,0 +1,313 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_data.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_utils.dart';
import 'package:intl/intl.dart';
/// Widget de graphique combiné pour afficher les passages et règlements
class CombinedChart extends StatelessWidget {
/// Liste des données de passage par type
final List<Map<String, dynamic>> passageData;
/// Liste des données de règlement par type
final List<Map<String, dynamic>> paymentData;
/// Type de période (Jour, Semaine, Mois, Année)
final String periodType;
/// Hauteur du graphique
final double height;
/// Largeur des barres
final double barWidth;
/// Rayon des points sur les lignes
final double dotRadius;
/// Épaisseur des lignes
final double lineWidth;
/// Montant maximum pour l'axe Y des règlements
final double? maxYAmount;
/// Nombre maximum pour l'axe Y des passages
final int? maxYCount;
const CombinedChart({
super.key,
required this.passageData,
required this.paymentData,
this.periodType = 'Jour',
this.height = 300,
this.barWidth = 16,
this.dotRadius = 4,
this.lineWidth = 3,
this.maxYAmount,
this.maxYCount,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Convertir les données brutes en modèles structurés
final passagesByType = PassageUtils.getPassageDataByType(passageData);
final paymentsByType = PassageUtils.getPaymentDataByType(paymentData);
// Extraire les dates uniques pour l'axe X
final List<DateTime> allDates = [];
for (final data in passageData) {
final DateTime date = data['date'] is DateTime
? data['date']
: DateTime.parse(data['date']);
if (!allDates.any((d) =>
d.year == date.year && d.month == date.month && d.day == date.day)) {
allDates.add(date);
}
}
// Trier les dates
allDates.sort((a, b) => a.compareTo(b));
// Calculer le maximum pour les axes Y
double maxAmount = 0;
for (final typeData in paymentsByType) {
for (final data in typeData) {
if (data.amount > maxAmount) {
maxAmount = data.amount;
}
}
}
int maxCount = 0;
for (final typeData in passagesByType) {
for (final data in typeData) {
if (data.count > maxCount) {
maxCount = data.count;
}
}
}
// Utiliser les maximums fournis ou calculés
final effectiveMaxYAmount = maxYAmount ?? (maxAmount * 1.2).ceilToDouble();
final effectiveMaxYCount = maxYCount ?? (maxCount * 1.2).ceil();
return SizedBox(
height: height,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: effectiveMaxYCount.toDouble(),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipPadding: const EdgeInsets.all(8),
tooltipMargin: 8,
getTooltipItem: (group, groupIndex, rod, rodIndex) {
final date = allDates[group.x.toInt()];
final formattedDate = DateFormat('dd/MM').format(date);
// Calculer le total des passages pour cette date
int totalPassages = 0;
for (final typeData in passagesByType) {
for (final data in typeData) {
if (data.date.year == date.year &&
data.date.month == date.month &&
data.date.day == date.day) {
totalPassages += data.count;
}
}
}
return BarTooltipItem(
'$formattedDate: $totalPassages passages',
TextStyle(
color: theme.colorScheme.onSurface,
fontWeight: FontWeight.bold,
),
);
},
),
),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
getTitlesWidget: (value, meta) {
if (value >= 0 && value < allDates.length) {
final date = allDates[value.toInt()];
final formattedDate =
PassageUtils.formatDateForChart(date, periodType);
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
formattedDate,
style: TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 10,
),
),
);
}
return const SizedBox();
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
value.toInt().toString(),
style: TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 10,
),
),
);
},
reservedSize: 30,
),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
// Convertir la valeur de l'axe Y des passages à l'échelle des montants
final amountValue =
(value / effectiveMaxYCount) * effectiveMaxYAmount;
return SideTitleWidget(
meta: meta,
space: 8,
child: Text(
'${amountValue.toInt()}',
style: TextStyle(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontSize: 10,
),
),
);
},
reservedSize: 40,
),
),
topTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: FlGridData(
show: true,
getDrawingHorizontalLine: (value) {
return FlLine(
color: theme.dividerColor.withOpacity(0.2),
strokeWidth: 1,
);
},
drawVerticalLine: false,
),
borderData: FlBorderData(show: false),
barGroups: _createBarGroups(allDates, passagesByType),
extraLinesData: ExtraLinesData(
horizontalLines: [],
verticalLines: [],
extraLinesOnTop: true,
),
),
swapAnimationDuration: const Duration(milliseconds: 250),
),
);
}
/// Créer les groupes de barres pour les passages
List<BarChartGroupData> _createBarGroups(
List<DateTime> allDates,
List<List<PassageData>> passagesByType,
) {
final List<BarChartGroupData> groups = [];
for (int i = 0; i < allDates.length; i++) {
final date = allDates[i];
// Calculer le total des passages pour cette date
int totalPassages = 0;
for (final typeData in passagesByType) {
for (final data in typeData) {
if (data.date.year == date.year &&
data.date.month == date.month &&
data.date.day == date.day) {
totalPassages += data.count;
}
}
}
// Créer un groupe de barres pour cette date
groups.add(
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: totalPassages.toDouble(),
color: Colors.blue.shade700,
width: barWidth,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(6),
topRight: Radius.circular(6),
),
),
],
),
);
}
return groups;
}
}
/// Widget de légende pour le graphique combiné
class CombinedChartLegend extends StatelessWidget {
const CombinedChartLegend({super.key});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 16,
runSpacing: 8,
children: [
_buildLegendItem('Passages', Colors.blue.shade700, isBar: true),
_buildLegendItem('Espèces', const Color(0xFF4CAF50)),
_buildLegendItem('Chèques', const Color(0xFF2196F3)),
_buildLegendItem('CB', const Color(0xFFF44336)),
],
);
}
/// Créer un élément de légende
Widget _buildLegendItem(String label, Color color, {bool isBar = false}) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
shape: isBar ? BoxShape.rectangle : BoxShape.circle,
borderRadius: isBar ? BorderRadius.circular(3) : null,
),
),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(fontSize: 12),
),
],
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
/// Modèle de données pour représenter un passage avec sa date, son type et son nombre
class PassageData {
/// Date du passage
final DateTime date;
/// Identifiant du type de passage (1: Effectué, 2: À finaliser, 3: Refusé, etc.)
final int typeId;
/// Nombre de passages
final int count;
/// Couleur associée au type de passage
final Color color;
/// Icône associée au type de passage (chemin vers le fichier d'icône)
final String iconPath;
/// Titre du type de passage
final String title;
const PassageData({
required this.date,
required this.typeId,
required this.count,
required this.color,
required this.iconPath,
required this.title,
});
/// Crée une instance de PassageData à partir d'une date au format ISO 8601
factory PassageData.fromIsoDate({
required String isoDate,
required int typeId,
required int count,
required Color color,
required String iconPath,
required String title,
}) {
return PassageData(
date: DateTime.parse(isoDate),
typeId: typeId,
count: count,
color: color,
iconPath: iconPath,
title: title,
);
}
}
/// Modèle de données pour représenter un règlement avec sa date, son type et son montant
class PaymentAmountData {
/// Date du règlement
final DateTime date;
/// Identifiant du type de règlement (1: Espèces, 2: Chèques, 3: CB)
final int typeId;
/// Montant du règlement
final double amount;
/// Couleur associée au type de règlement
final Color color;
/// Icône associée au type de règlement (chemin vers le fichier d'icône ou IconData)
final dynamic iconData;
/// Titre du type de règlement
final String title;
/// Crée une instance de PaymentAmountData à partir d'une date au format ISO 8601
factory PaymentAmountData.fromIsoDate({
required String isoDate,
required int typeId,
required double amount,
required Color color,
required dynamic iconData,
required String title,
}) {
return PaymentAmountData(
date: DateTime.parse(isoDate),
typeId: typeId,
amount: amount,
color: color,
iconData: iconData,
title: title,
);
}
const PaymentAmountData({
required this.date,
required this.typeId,
required this.amount,
required this.color,
required this.iconData,
required this.title,
});
}

View File

@@ -0,0 +1,549 @@
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:flutter/foundation.dart' show listEquals, mapEquals;
import 'package:intl/intl.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/services/passage_data_service.dart';
/// Modèle de données pour le graphique en camembert des passages
class PassageChartData {
/// Identifiant du type de passage
final int typeId;
/// Nombre de passages de ce type
final int count;
/// Titre du type de passage
final String title;
/// Couleur associée au type de passage
final Color color;
/// Icône associée au type de passage
final IconData icon;
PassageChartData({
required this.typeId,
required this.count,
required this.title,
required this.color,
required this.icon,
});
}
/// Widget de graphique en camembert pour représenter la répartition des passages par type
class PassagePieChart extends StatefulWidget {
/// Liste des données de passages par type sous forme de Map avec typeId et count
/// Si loadFromHive est true, ce paramètre est ignoré
final Map<int, int> passagesByType;
/// Taille du graphique
final double size;
/// Taille des étiquettes
final double labelSize;
/// Afficher les pourcentages
final bool showPercentage;
/// Afficher les icônes
final bool showIcons;
/// Afficher la légende
final bool showLegend;
/// Format donut (anneau)
final bool isDonut;
/// Rayon central pour le format donut (en pourcentage)
final String innerRadius;
/// Charger les données depuis Hive
final bool loadFromHive;
/// ID de l'utilisateur pour filtrer les passages (utilisé seulement si loadFromHive est true)
final int? userId;
/// Types de passages à exclure (utilisé seulement si loadFromHive est true)
final List<int> excludePassageTypes;
/// Afficher tous les passages sans filtrer par utilisateur (utilisé seulement si loadFromHive est true)
final bool showAllPassages;
const PassagePieChart({
super.key,
this.passagesByType = const {},
this.size = 300,
this.labelSize = 12,
this.showPercentage = true,
this.showIcons = true,
this.showLegend = true,
this.isDonut = false,
this.innerRadius = '40%',
this.loadFromHive = false,
this.userId,
this.excludePassageTypes = const [2],
this.showAllPassages = false,
});
@override
State<PassagePieChart> createState() => _PassagePieChartState();
}
class _PassagePieChartState extends State<PassagePieChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
/// Données de passages par type
late Map<int, int> _passagesByType;
/// Variables pour la mise en cache et l'optimisation
bool _dataLoaded = false;
bool _isLoading = false;
List<PassageChartData>? _cachedChartData;
List<CircularChartAnnotation>? _cachedAnnotations;
@override
void initState() {
super.initState();
_passagesByType = widget.passagesByType;
// Initialiser le contrôleur d'animation
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
_animationController.forward();
// Si nous n'utilisons pas Hive, préparer les données immédiatement
if (!widget.loadFromHive) {
_prepareChartData();
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (widget.loadFromHive && !_dataLoaded && !_isLoading) {
_isLoading = true; // Prévenir les chargements multiples
_loadPassageDataFromHive(context);
}
}
@override
void didUpdateWidget(PassagePieChart oldWidget) {
super.didUpdateWidget(oldWidget);
// Vérifier si les propriétés importantes ont changé
final bool dataSourceChanged = widget.loadFromHive
? false
: !mapEquals(oldWidget.passagesByType, widget.passagesByType);
final bool filteringChanged = oldWidget.userId != widget.userId ||
!listEquals(
oldWidget.excludePassageTypes, widget.excludePassageTypes) ||
oldWidget.showAllPassages != widget.showAllPassages;
final bool visualChanged = oldWidget.size != widget.size ||
oldWidget.labelSize != widget.labelSize ||
oldWidget.showPercentage != widget.showPercentage ||
oldWidget.showIcons != widget.showIcons ||
oldWidget.showLegend != widget.showLegend ||
oldWidget.isDonut != widget.isDonut ||
oldWidget.innerRadius != widget.innerRadius;
// Si les paramètres de filtrage ou de source de données ont changé, recharger les données
if (dataSourceChanged || filteringChanged) {
_cachedChartData = null;
_cachedAnnotations = null;
// Relancer l'animation si les données ont changé
_animationController.reset();
_animationController.forward();
if (!widget.loadFromHive) {
_passagesByType = widget.passagesByType;
_prepareChartData();
} else if (!_isLoading) {
_dataLoaded = false;
_isLoading = true;
_loadPassageDataFromHive(context);
}
}
// Si seuls les paramètres visuels ont changé, recalculer les annotations sans recharger les données
else if (visualChanged) {
_cachedAnnotations = null;
}
}
/// Charge les données de passage depuis Hive en utilisant le service PassageDataService
void _loadPassageDataFromHive(BuildContext context) {
// Éviter les appels multiples pendant le chargement
if (_isLoading) {
debugPrint('PassagePieChart: Déjà en cours de chargement, ignoré');
return;
}
// Si les données sont déjà chargées et non vides, ne pas recharger
if (_dataLoaded && _passagesByType.isNotEmpty) {
debugPrint('PassagePieChart: Données déjà chargées, ignoré');
return;
}
debugPrint('PassagePieChart: Début du chargement des données');
setState(() {
_isLoading = true;
});
// Charger les données dans un addPostFrameCallback pour éviter les problèmes de cycle de vie
WidgetsBinding.instance.addPostFrameCallback((_) {
// Vérifier si le widget est toujours monté
if (!mounted) {
debugPrint('PassagePieChart: Widget démonté, chargement annulé');
return;
}
try {
debugPrint('PassagePieChart: Création du service de données');
// Utiliser les instances globales définies dans app.dart
// Vérifier que les repositories sont disponibles
if (passageRepository == null) {
debugPrint('PassagePieChart: ERREUR - passageRepository est null');
if (mounted) {
setState(() {
_isLoading = false;
});
}
return;
}
if (userRepository == null) {
debugPrint('PassagePieChart: ERREUR - userRepository est null');
if (mounted) {
setState(() {
_isLoading = false;
});
}
return;
}
// Créer une instance du service de données
final passageDataService = PassageDataService(
passageRepository: passageRepository,
userRepository: userRepository,
);
debugPrint(
'PassagePieChart: Chargement des données avec excludePassageTypes=${widget.excludePassageTypes}, userId=${widget.userId}, showAllPassages=${widget.showAllPassages}');
// Utiliser le service pour charger les données
final data = passageDataService.loadPassageDataForPieChart(
excludePassageTypes: widget.excludePassageTypes,
userId: widget.userId,
showAllPassages: widget.showAllPassages,
);
debugPrint('PassagePieChart: Données chargées: $data');
// Mettre à jour les données et les états
if (mounted) {
setState(() {
_passagesByType = data;
_dataLoaded = true;
_isLoading = false;
_cachedChartData =
null; // Forcer la régénération des données du graphique
_cachedAnnotations = null;
});
// Préparer les données du graphique
_prepareChartData();
debugPrint('PassagePieChart: Données préparées pour le graphique');
}
} catch (e) {
// Gérer les erreurs et réinitialiser l'état pour permettre une future tentative
debugPrint(
'PassagePieChart: ERREUR lors du chargement des données: $e');
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
});
}
/// Prépare les données pour le graphique en camembert avec mise en cache
List<PassageChartData> _prepareChartData() {
// Utiliser les données en cache si disponibles
if (_cachedChartData != null) {
debugPrint('PassagePieChart: Utilisation des données en cache');
return _cachedChartData!;
}
debugPrint('PassagePieChart: Préparation des données pour le graphique');
debugPrint('PassagePieChart: Données brutes: $_passagesByType');
// Vérifier si les données sont vides
if (_passagesByType.isEmpty) {
debugPrint('PassagePieChart: Aucune donnée disponible');
return [];
}
// Vérifier si les données contiennent uniquement des passages de type 2
bool onlyType2 = true;
_passagesByType.forEach((typeId, count) {
if (typeId != 2 && count > 0) {
onlyType2 = false;
}
});
if (onlyType2) {
debugPrint(
'PassagePieChart: Les données contiennent uniquement des passages de type 2');
}
final List<PassageChartData> chartData = [];
// Créer les données du graphique
_passagesByType.forEach((typeId, count) {
// Vérifier que le type existe et que le compteur est positif
if (count > 0 && AppKeys.typesPassages.containsKey(typeId)) {
// Vérifier si le type est exclu
bool isExcluded = widget.excludePassageTypes.contains(typeId);
if (isExcluded) {
debugPrint('PassagePieChart: Type $typeId exclu');
} else {
final typeInfo = AppKeys.typesPassages[typeId]!;
final typeName = typeInfo['titre'] as String;
debugPrint(
'PassagePieChart: Ajout du type $typeId ($typeName) avec $count passages');
chartData.add(PassageChartData(
typeId: typeId,
count: count,
title: typeName,
color: Color(typeInfo['couleur2'] as int),
icon: typeInfo['icon_data'] as IconData,
));
}
} else {
if (count <= 0) {
debugPrint('PassagePieChart: Type $typeId ignoré car count=$count');
} else if (!AppKeys.typesPassages.containsKey(typeId)) {
debugPrint(
'PassagePieChart: Type $typeId ignoré car non défini dans AppKeys.typesPassages');
}
}
});
debugPrint(
'PassagePieChart: ${chartData.length} types de passages ajoutés au graphique');
// Mettre en cache les données générées
_cachedChartData = chartData;
return chartData;
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
// Si les données doivent être chargées depuis Hive mais ne sont pas encore prêtes
if (widget.loadFromHive && !_dataLoaded) {
return SizedBox(
width: widget.size,
height: widget.size,
child: const Center(
child: CircularProgressIndicator(),
),
);
}
final chartData = _prepareChartData();
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
return SizedBox(
width: widget.size,
height: widget.size,
child: const Center(
child: Text('Aucune donnée disponible'),
),
);
}
// Créer des animations pour différents aspects du graphique
final progressAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
);
final explodeAnimation = CurvedAnimation(
parent: _animationController,
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
);
final opacityAnimation = CurvedAnimation(
parent: _animationController,
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
);
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return SizedBox(
width: widget.size,
height: widget.size,
child: SfCircularChart(
margin: EdgeInsets.zero,
legend: Legend(
isVisible: widget.showLegend,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
textStyle: TextStyle(fontSize: widget.labelSize),
),
tooltipBehavior: TooltipBehavior(enable: true),
series: <CircularSeries>[
widget.isDonut
? DoughnutSeries<PassageChartData, String>(
dataSource: chartData,
xValueMapper: (PassageChartData data, _) => data.title,
yValueMapper: (PassageChartData data, _) => data.count,
pointColorMapper: (PassageChartData data, _) =>
data.color,
enableTooltip: true,
dataLabelMapper: (PassageChartData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0, (sum, item) => sum + item.count);
final percentage = (data.count / total * 100);
return '${percentage.toStringAsFixed(1)}%';
} else {
return data.title;
}
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
textStyle: TextStyle(fontSize: widget.labelSize),
connectorLineSettings: const ConnectorLineSettings(
type: ConnectorType.curve,
length: '15%',
),
),
innerRadius: widget.innerRadius,
explode: true,
explodeIndex: 0,
explodeOffset:
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
opacity: opacityAnimation.value,
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
)
: PieSeries<PassageChartData, String>(
dataSource: chartData,
xValueMapper: (PassageChartData data, _) => data.title,
yValueMapper: (PassageChartData data, _) => data.count,
pointColorMapper: (PassageChartData data, _) =>
data.color,
enableTooltip: true,
dataLabelMapper: (PassageChartData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0, (sum, item) => sum + item.count);
final percentage = (data.count / total * 100);
return '${percentage.toStringAsFixed(1)}%';
} else {
return data.title;
}
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
textStyle: TextStyle(fontSize: widget.labelSize),
connectorLineSettings: const ConnectorLineSettings(
type: ConnectorType.curve,
length: '15%',
),
),
explode: true,
explodeIndex: 0,
explodeOffset:
'${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
opacity: opacityAnimation.value,
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
),
],
annotations:
widget.showIcons ? _buildIconAnnotations(chartData) : null,
),
);
},
);
}
/// Crée les annotations d'icônes pour le graphique avec mise en cache
List<CircularChartAnnotation> _buildIconAnnotations(
List<PassageChartData> chartData) {
// Utiliser les annotations en cache si disponibles
if (_cachedAnnotations != null) {
return _cachedAnnotations!;
}
final List<CircularChartAnnotation> annotations = [];
// Calculer le total pour les pourcentages
int total = chartData.fold(0, (sum, item) => sum + item.count);
if (total == 0) return []; // Éviter la division par zéro
// Position angulaire actuelle (en radians)
double currentAngle = 0;
for (int i = 0; i < chartData.length; i++) {
final data = chartData[i];
final percentage = data.count / total;
// Calculer l'angle central de ce segment
final segmentAngle = percentage * 2 * 3.14159;
final midAngle = currentAngle + (segmentAngle / 2);
// Ajouter une annotation pour l'icône
annotations.add(
CircularChartAnnotation(
widget: Icon(
data.icon,
color: Colors.white,
size: 16,
),
radius: '50%',
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
),
);
// Mettre à jour l'angle actuel
currentAngle += segmentAngle;
}
// Mettre en cache les annotations générées
_cachedAnnotations = annotations;
return annotations;
}
}

View File

@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/charts/passage_data.dart';
import 'package:intl/intl.dart';
/// Utilitaires pour les passages et règlements
class PassageUtils {
/// Convertit les données de passage brutes en liste de PassageData
///
/// [passageData] est une liste d'objets contenant date, type_passage et nb
static List<List<PassageData>> getPassageDataByType(
List<Map<String, dynamic>> passageData) {
// Créer un Map pour stocker les données par type de passage
final Map<int, List<PassageData>> passagesByType = {};
// Initialiser les listes pour chaque type de passage
for (final entry in AppKeys.typesPassages.entries) {
passagesByType[entry.key] = [];
}
// Grouper les passages par type
for (final data in passageData) {
final int typeId = data['type_passage'];
final int count = data['nb'];
if (AppKeys.typesPassages.containsKey(typeId)) {
final typeData = AppKeys.typesPassages[typeId]!;
final Color color = Color(typeData['couleur1'] as int);
final String iconPath = typeData['icone'] as String;
final String title = typeData['titre'] as String;
// Utiliser la méthode factory qui gère les dates au format ISO 8601
if (data['date'] is String) {
passagesByType[typeId]!.add(
PassageData.fromIsoDate(
isoDate: data['date'],
typeId: typeId,
count: count,
color: color,
iconPath: iconPath,
title: title,
),
);
} else {
// Fallback pour les objets DateTime (pour compatibilité)
final DateTime date = data['date'] as DateTime;
passagesByType[typeId]!.add(
PassageData(
date: date,
typeId: typeId,
count: count,
color: color,
iconPath: iconPath,
title: title,
),
);
}
}
}
// Convertir le Map en liste de listes
return passagesByType.values.toList();
}
/// Convertit les données de règlement brutes en liste de PaymentAmountData
///
/// [paymentData] est une liste d'objets contenant date, type_reglement et montant
static List<List<PaymentAmountData>> getPaymentDataByType(
List<Map<String, dynamic>> paymentData) {
// Créer un Map pour stocker les données par type de règlement
final Map<int, List<PaymentAmountData>> paymentsByType = {};
// Initialiser les listes pour chaque type de règlement (sauf 0 qui est "Pas de règlement")
for (final entry in AppKeys.typesReglements.entries) {
if (entry.key > 0) {
// Ignorer le type 0 (Pas de règlement)
paymentsByType[entry.key] = [];
}
}
// Grouper les règlements par type
for (final data in paymentData) {
final int typeId = data['type_reglement'];
final double amount = data['montant'] is double
? data['montant']
: double.parse(data['montant'].toString());
if (typeId > 0 && AppKeys.typesReglements.containsKey(typeId)) {
final typeData = AppKeys.typesReglements[typeId]!;
final Color color = Color(typeData['couleur'] as int);
final dynamic iconData = _getIconForPaymentType(typeId);
final String title = typeData['titre'] as String;
// Utiliser la méthode factory qui gère les dates au format ISO 8601
if (data['date'] is String) {
paymentsByType[typeId]!.add(
PaymentAmountData.fromIsoDate(
isoDate: data['date'],
typeId: typeId,
amount: amount,
color: color,
iconData: iconData,
title: title,
),
);
} else {
// Fallback pour les objets DateTime (pour compatibilité)
final DateTime date = data['date'] as DateTime;
paymentsByType[typeId]!.add(
PaymentAmountData(
date: date,
typeId: typeId,
amount: amount,
color: color,
iconData: iconData,
title: title,
),
);
}
}
}
// Convertir le Map en liste de listes
return paymentsByType.values.toList();
}
/// Génère des données de passage fictives pour les 14 derniers jours
static List<Map<String, dynamic>> generateMockPassageData() {
final List<Map<String, dynamic>> mockData = [];
final now = DateTime.now();
for (int i = 13; i >= 0; i--) {
final date = now.subtract(Duration(days: i));
// Ajouter des données pour chaque type de passage
for (int typeId = 1; typeId <= 6; typeId++) {
// Générer un nombre aléatoire de passages entre 0 et 5
final count = (typeId == 1 || typeId == 2)
? (1 + (date.day % 5)) // Plus de passages pour les types 1 et 2
: (date.day % 3); // Moins pour les autres types
if (count > 0) {
mockData.add({
'date': date,
'type_passage': typeId,
'nb': count,
});
}
}
}
return mockData;
}
/// Génère des données de règlement fictives pour les 14 derniers jours
static List<Map<String, dynamic>> generateMockPaymentData() {
final List<Map<String, dynamic>> mockData = [];
final now = DateTime.now();
for (int i = 13; i >= 0; i--) {
final date = now.subtract(Duration(days: i));
// Ajouter des données pour chaque type de règlement
for (int typeId = 1; typeId <= 3; typeId++) {
// Générer un montant aléatoire
final amount = (typeId * 100.0) + (date.day * 10.0);
mockData.add({
'date': date,
'type_reglement': typeId,
'montant': amount,
});
}
}
return mockData;
}
/// Obtenir l'icône correspondant au type de règlement
/// Retourne un IconData pour les règlements car ils n'ont pas de chemin d'icône défini dans AppKeys
static IconData _getIconForPaymentType(int typeId) {
switch (typeId) {
case 1: // Espèces
return Icons.payments;
case 2: // Chèque
return Icons.money;
case 3: // CB
return Icons.credit_card;
default:
return Icons.euro;
}
}
/// Formater une date pour l'affichage dans les graphiques
static String formatDateForChart(DateTime date, String periodType) {
switch (periodType.toLowerCase()) {
case 'jour':
return DateFormat('dd/MM').format(date);
case 'semaine':
// Calculer le numéro de la semaine dans l'année
final firstDayOfYear = DateTime(date.year, 1, 1);
final dayOfYear = date.difference(firstDayOfYear).inDays;
final weekNumber =
((dayOfYear + firstDayOfYear.weekday - 1) / 7).ceil();
return 'S$weekNumber';
case 'mois':
return DateFormat('MMM').format(date);
case 'année':
return DateFormat('yyyy').format(date);
default:
return DateFormat('dd/MM').format(date);
}
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
/// Modèle de données pour représenter un type de règlement avec son montant
class PaymentData {
/// Identifiant du type de règlement (1: Espèces, 2: Chèques, 3: CB)
final int typeId;
/// Montant du règlement
final double amount;
/// Couleur associée au type de règlement
final Color color;
/// Icône associée au type de règlement
final IconData icon;
/// Titre du type de règlement
final String title;
const PaymentData({
required this.typeId,
required this.amount,
required this.color,
required this.icon,
required this.title,
});
}

View File

@@ -0,0 +1,407 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
import 'dart:math' as math;
/// Widget de graphique en camembert pour représenter la répartition des règlements
class PaymentPieChart extends StatefulWidget {
/// Liste des données de règlement à afficher dans le graphique
final List<PaymentData> payments;
/// Taille du graphique
final double size;
/// Taille des étiquettes
final double labelSize;
/// Afficher les pourcentages
final bool showPercentage;
/// Afficher les icônes
final bool showIcons;
/// Afficher la légende
final bool showLegend;
/// Format donut (anneau)
final bool isDonut;
/// Rayon central pour le format donut (en pourcentage)
final String innerRadius;
/// Activer l'effet 3D
final bool enable3DEffect;
/// Intensité de l'effet 3D (1.0 = normal, 2.0 = fort)
final double effect3DIntensity;
/// Activer l'effet d'explosion plus prononcé
final bool enableEnhancedExplode;
/// Utiliser un dégradé pour simuler l'effet 3D
final bool useGradient;
const PaymentPieChart({
super.key,
required this.payments,
this.size = 300,
this.labelSize = 12,
this.showPercentage = true,
this.showIcons = true,
this.showLegend = true,
this.isDonut = false,
this.innerRadius = '40%',
this.enable3DEffect = false,
this.effect3DIntensity = 1.0,
this.enableEnhancedExplode = false,
this.useGradient = false,
});
@override
State<PaymentPieChart> createState() => _PaymentPieChartState();
}
class _PaymentPieChartState extends State<PaymentPieChart>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
_animationController.forward();
}
@override
void didUpdateWidget(PaymentPieChart oldWidget) {
super.didUpdateWidget(oldWidget);
// Relancer l'animation si les données ont changé
// Utiliser une comparaison plus stricte pour éviter des animations inutiles
bool shouldResetAnimation = false;
if (oldWidget.payments.length != widget.payments.length) {
shouldResetAnimation = true;
} else {
// Comparer les éléments importants uniquement
for (int i = 0; i < oldWidget.payments.length; i++) {
if (i >= widget.payments.length) break;
if (oldWidget.payments[i].amount != widget.payments[i].amount ||
oldWidget.payments[i].title != widget.payments[i].title) {
shouldResetAnimation = true;
break;
}
}
}
if (shouldResetAnimation) {
_animationController.reset();
_animationController.forward();
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
/// Prépare les données pour le graphique en camembert
List<PaymentData> _prepareChartData() {
// Filtrer les règlements avec un montant > 0
return widget.payments.where((payment) => payment.amount > 0).toList();
}
@override
Widget build(BuildContext context) {
final chartData = _prepareChartData();
// Si aucune donnée, afficher un message
if (chartData.isEmpty) {
return SizedBox(
width: widget.size,
height: widget.size,
child: const Center(
child: Text('Aucune donnée disponible'),
),
);
}
// Créer des animations pour différents aspects du graphique
final progressAnimation = CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutCubic,
);
final explodeAnimation = CurvedAnimation(
parent: _animationController,
curve: Interval(0.7, 1.0, curve: Curves.elasticOut),
);
final opacityAnimation = CurvedAnimation(
parent: _animationController,
curve: Interval(0.1, 0.5, curve: Curves.easeIn),
);
return AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return SizedBox(
width: widget.size,
height: widget.size,
child: SfCircularChart(
margin: EdgeInsets.zero,
legend: Legend(
isVisible: widget.showLegend,
position: LegendPosition.bottom,
overflowMode: LegendItemOverflowMode.wrap,
textStyle: TextStyle(fontSize: widget.labelSize),
),
tooltipBehavior: TooltipBehavior(enable: true),
series: <CircularSeries>[
widget.isDonut
? DoughnutSeries<PaymentData, String>(
dataSource: chartData,
xValueMapper: (PaymentData data, _) => data.title,
yValueMapper: (PaymentData data, _) => data.amount,
pointColorMapper: (PaymentData data, _) {
if (widget.enable3DEffect) {
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
final index = chartData.indexOf(data);
final angle =
(index / chartData.length) * 2 * math.pi;
return widget.useGradient
? _createEnhanced3DColor(data.color, angle)
: _create3DColor(
data.color, widget.effect3DIntensity);
}
return data.color;
},
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
enableTooltip: true,
dataLabelMapper: (PaymentData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0.0, (sum, item) => sum + item.amount);
final percentage = (data.amount / total * 100);
return '${percentage.toStringAsFixed(1)}%';
} else {
return data.title;
}
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition
.inside, // Afficher les étiquettes à l'intérieur du donut
textStyle: TextStyle(
fontSize: widget.labelSize,
color: Colors
.white, // Texte blanc pour meilleure lisibilité
fontWeight: FontWeight
.bold, // Texte en gras pour meilleure lisibilité
),
),
innerRadius: widget.innerRadius,
// Effet d'explosion plus prononcé pour donner du relief avec animation
explode: true,
explodeAll: widget.enableEnhancedExplode,
explodeIndex: widget.enableEnhancedExplode ? null : 0,
explodeOffset: widget.enableEnhancedExplode
? widget.enable3DEffect
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
// Effet 3D via l'opacité et les couleurs avec animation
opacity: widget.enable3DEffect
? 0.95 * opacityAnimation.value
: opacityAnimation.value,
// Animation progressive du graphique
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
)
: PieSeries<PaymentData, String>(
dataSource: chartData,
xValueMapper: (PaymentData data, _) => data.title,
yValueMapper: (PaymentData data, _) => data.amount,
pointColorMapper: (PaymentData data, _) {
if (widget.enable3DEffect) {
// Utiliser un angle différent pour chaque segment pour simuler un effet 3D
final index = chartData.indexOf(data);
final angle =
(index / chartData.length) * 2 * math.pi;
return widget.useGradient
? _createEnhanced3DColor(data.color, angle)
: _create3DColor(
data.color, widget.effect3DIntensity);
}
return data.color;
},
// Note: Le gradient n'est pas directement pris en charge dans cette version de Syncfusion
enableTooltip: true,
dataLabelMapper: (PaymentData data, _) {
if (widget.showPercentage) {
// Calculer le pourcentage avec une décimale
final total = chartData.fold(
0.0, (sum, item) => sum + item.amount);
final percentage = (data.amount / total * 100);
return '${percentage.toStringAsFixed(1)}%';
} else {
return data.title;
}
},
dataLabelSettings: DataLabelSettings(
isVisible: true,
labelPosition: ChartDataLabelPosition.outside,
textStyle: TextStyle(fontSize: widget.labelSize),
connectorLineSettings: const ConnectorLineSettings(
type: ConnectorType.curve,
length: '15%',
),
),
// Effet d'explosion plus prononcé pour donner du relief avec animation
explode: true,
explodeAll: widget.enableEnhancedExplode,
explodeIndex: widget.enableEnhancedExplode ? null : 0,
explodeOffset: widget.enableEnhancedExplode
? widget.enable3DEffect
? '${(12 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(8 * explodeAnimation.value).toStringAsFixed(1)}%'
: '${(5 * explodeAnimation.value).toStringAsFixed(1)}%',
// Effet 3D via l'opacité et les couleurs avec animation
opacity: widget.enable3DEffect
? 0.95 * opacityAnimation.value
: opacityAnimation.value,
// Animation progressive du graphique
animationDuration:
0, // On désactive l'animation intégrée car nous utilisons notre propre animation
startAngle: 270,
endAngle: 270 + (360 * progressAnimation.value).toInt(),
),
],
annotations:
widget.showIcons ? _buildIconAnnotations(chartData) : null,
// Paramètres pour améliorer l'effet 3D
palette: widget.enable3DEffect ? _create3DPalette(chartData) : null,
// Ajouter un effet de bordure pour renforcer l'effet 3D
borderWidth: widget.enable3DEffect ? 0.5 : 0,
// Note: La rotation n'est pas directement prise en charge dans cette version de Syncfusion
),
);
},
);
}
/// Crée une couleur avec effet 3D en ajoutant des nuances
Color _create3DColor(Color baseColor, double intensity) {
// Ajuster la luminosité et la saturation pour créer un effet 3D plus prononcé
final hslColor = HSLColor.fromColor(baseColor);
// Augmenter la luminosité pour simuler un éclairage
final adjustedLightness =
(hslColor.lightness + 0.15 * intensity).clamp(0.0, 1.0);
// Augmenter légèrement la saturation pour des couleurs plus vives
final adjustedSaturation =
(hslColor.saturation + 0.05 * intensity).clamp(0.0, 1.0);
return hslColor
.withLightness(adjustedLightness)
.withSaturation(adjustedSaturation)
.toColor();
}
/// Crée une palette de couleurs pour l'effet 3D
List<Color> _create3DPalette(List<PaymentData> chartData) {
List<Color> palette = [];
// Créer des variations de couleurs pour chaque segment
for (var i = 0; i < chartData.length; i++) {
var data = chartData[i];
// Calculer un angle pour chaque segment pour simuler un éclairage directionnel
final angle = (i / chartData.length) * 2 * math.pi;
// Créer un effet d'ombre et de lumière en fonction de l'angle
final hslColor = HSLColor.fromColor(data.color);
// Ajuster la luminosité en fonction de l'angle
final lightAdjustment = 0.15 * widget.effect3DIntensity * math.sin(angle);
final adjustedLightness = (hslColor.lightness -
0.1 * widget.effect3DIntensity +
lightAdjustment)
.clamp(0.0, 1.0);
// Ajuster la saturation pour plus de profondeur
final adjustedSaturation =
(hslColor.saturation + 0.1 * widget.effect3DIntensity)
.clamp(0.0, 1.0);
final enhancedColor = hslColor
.withLightness(adjustedLightness)
.withSaturation(adjustedSaturation)
.toColor();
palette.add(enhancedColor);
}
return palette;
}
/// Crée une couleur avec effet 3D plus avancé
Color _createEnhanced3DColor(Color baseColor, double angle) {
// Simuler un effet de lumière directionnel
final hslColor = HSLColor.fromColor(baseColor);
// Ajuster la luminosité en fonction de l'angle pour simuler un éclairage
final adjustedLightness = hslColor.lightness +
(0.2 * widget.effect3DIntensity * math.sin(angle)).clamp(-0.3, 0.3);
return hslColor.withLightness(adjustedLightness.clamp(0.0, 1.0)).toColor();
}
/// Crée les annotations d'icônes pour le graphique
List<CircularChartAnnotation> _buildIconAnnotations(
List<PaymentData> chartData) {
final List<CircularChartAnnotation> annotations = [];
// Calculer le total pour les pourcentages
double total = chartData.fold(0.0, (sum, item) => sum + item.amount);
// Position angulaire actuelle (en radians)
double currentAngle = 0;
for (int i = 0; i < chartData.length; i++) {
final data = chartData[i];
final percentage = data.amount / total;
// Calculer l'angle central de ce segment
final segmentAngle = percentage * 2 * 3.14159;
final midAngle = currentAngle + (segmentAngle / 2);
// Ajouter une annotation pour l'icône
annotations.add(
CircularChartAnnotation(
widget: Icon(
data.icon,
color: Colors.white,
size: 16,
),
radius: '50%',
angle: (midAngle * (180 / 3.14159)).toInt(), // Convertir en degrés
),
);
// Mettre à jour l'angle actuel
currentAngle += segmentAngle;
}
return annotations;
}
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/charts/payment_data.dart';
/// Utilitaires pour les paiements et règlements
class PaymentUtils {
/// Convertit les données de règlement depuis les constantes AppKeys
///
/// [paymentAmounts] est une Map associant l'ID du type de règlement à son montant
static List<PaymentData> getPaymentDataFromAmounts(
Map<int, double> paymentAmounts) {
final List<PaymentData> paymentDataList = [];
// Parcourir tous les types de règlements définis dans AppKeys
AppKeys.typesReglements.forEach((typeId, typeData) {
// Vérifier si nous avons un montant pour ce type de règlement
final double amount = paymentAmounts[typeId] ?? 0.0;
// Créer un objet PaymentData pour ce type de règlement
final PaymentData paymentData = PaymentData(
typeId: typeId,
amount: amount,
color: Color(typeData['couleur'] as int),
icon: typeData['icon_data'] as IconData,
title: typeData['titre'] as String,
);
paymentDataList.add(paymentData);
});
return paymentDataList;
}
}

View File

@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
/// Widget pour la zone de saisie des messages
class ChatInput extends StatefulWidget {
final Function(String) onMessageSent;
const ChatInput({
Key? key,
required this.onMessageSent,
}) : super(key: key);
@override
State<ChatInput> createState() => _ChatInputState();
}
class _ChatInputState extends State<ChatInput> {
final TextEditingController _controller = TextEditingController();
bool _isComposing = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.spacingM,
vertical: AppTheme.spacingS,
),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 5,
offset: const Offset(0, -2),
),
],
),
child: Row(
children: [
// Bouton pour ajouter des pièces jointes
IconButton(
icon: const Icon(Icons.attach_file),
color: AppTheme.primaryColor,
onPressed: () {
// Afficher les options de pièces jointes
_showAttachmentOptions(context);
},
),
// Champ de saisie du message
Expanded(
child: TextField(
controller: _controller,
decoration: InputDecoration(
hintText: 'Écrivez votre message...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.grey[100],
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
textCapitalization: TextCapitalization.sentences,
maxLines: null,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
onChanged: (text) {
setState(() {
_isComposing = text.isNotEmpty;
});
},
),
),
// Bouton d'envoi
IconButton(
icon: Icon(
_isComposing ? Icons.send : Icons.mic,
color: _isComposing ? AppTheme.primaryColor : Colors.grey[600],
),
onPressed: _isComposing
? () {
final text = _controller.text.trim();
if (text.isNotEmpty) {
widget.onMessageSent(text);
_controller.clear();
setState(() {
_isComposing = false;
});
}
}
: () {
// Activer la reconnaissance vocale
},
),
],
),
);
}
// Afficher les options de pièces jointes
void _showAttachmentOptions(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (context) => Container(
padding: const EdgeInsets.symmetric(
vertical: AppTheme.spacingL,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Ajouter une pièce jointe',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const SizedBox(height: AppTheme.spacingL),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildAttachmentOption(
context,
Icons.photo,
'Photo',
Colors.green,
() {
Navigator.pop(context);
// Sélectionner une photo
},
),
_buildAttachmentOption(
context,
Icons.camera_alt,
'Caméra',
Colors.blue,
() {
Navigator.pop(context);
// Prendre une photo
},
),
_buildAttachmentOption(
context,
Icons.insert_drive_file,
'Document',
Colors.orange,
() {
Navigator.pop(context);
// Sélectionner un document
},
),
_buildAttachmentOption(
context,
Icons.location_on,
'Position',
Colors.red,
() {
Navigator.pop(context);
// Partager la position
},
),
],
),
const SizedBox(height: AppTheme.spacingL),
],
),
),
);
}
// Construire une option de pièce jointe
Widget _buildAttachmentOption(
BuildContext context,
IconData icon,
String label,
Color color,
VoidCallback onTap,
) {
return InkWell(
onTap: onTap,
child: Column(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(
icon,
color: color,
size: 28,
),
),
const SizedBox(height: 8),
Text(
label,
style: TextStyle(
color: Colors.grey[800],
fontSize: 12,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
/// Widget pour afficher les messages d'une conversation
class ChatMessages extends StatelessWidget {
final List<Map<String, dynamic>> messages;
final int currentUserId;
final Function(Map<String, dynamic>) onReply;
const ChatMessages({
Key? key,
required this.messages,
required this.currentUserId,
required this.onReply,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return messages.isEmpty
? const Center(
child: Text('Aucun message dans cette conversation'),
)
: ListView.builder(
padding: const EdgeInsets.all(AppTheme.spacingM),
itemCount: messages.length,
reverse:
false, // Afficher les messages du plus ancien au plus récent
itemBuilder: (context, index) {
final message = messages[index];
final isCurrentUser = message['senderId'] == currentUserId;
final hasReply = message['replyTo'] != null;
return Padding(
padding: const EdgeInsets.only(bottom: AppTheme.spacingM),
child: Column(
crossAxisAlignment: isCurrentUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
// Afficher le message auquel on répond
if (hasReply) ...[
Container(
margin: EdgeInsets.only(
left: isCurrentUser ? 0 : 40,
right: isCurrentUser ? 40 : 0,
bottom: 4,
),
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Réponse à ${message['replyTo']['senderName']}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: AppTheme.primaryColor,
),
),
Text(
message['replyTo']['message'],
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
// Message principal
Row(
mainAxisAlignment: isCurrentUser
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar (seulement pour les messages des autres)
if (!isCurrentUser)
CircleAvatar(
radius: 16,
backgroundColor:
AppTheme.primaryColor.withOpacity(0.2),
backgroundImage: message['avatar'] != null
? AssetImage(message['avatar'] as String)
: null,
child: message['avatar'] == null
? Text(
message['senderName'].isNotEmpty
? message['senderName'][0].toUpperCase()
: '',
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold,
fontSize: 12,
),
)
: null,
),
const SizedBox(width: 8),
// Contenu du message
Flexible(
child: Column(
crossAxisAlignment: isCurrentUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
// Nom de l'expéditeur (seulement pour les messages des autres)
if (!isCurrentUser)
Padding(
padding:
const EdgeInsets.only(left: 4, bottom: 2),
child: Text(
message['senderName'],
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
// Bulle de message
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: isCurrentUser
? AppTheme.primaryColor
: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
child: Text(
message['message'],
style: TextStyle(
color: isCurrentUser
? Colors.white
: Colors.black87,
),
),
),
// Heure et statut
Padding(
padding: const EdgeInsets.only(top: 4, left: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message['time']),
style: TextStyle(
fontSize: 10,
color: Colors.grey[600],
),
),
const SizedBox(width: 4),
if (isCurrentUser)
Icon(
message['isRead']
? Icons.done_all
: Icons.done,
size: 12,
color: message['isRead']
? Colors.blue
: Colors.grey[600],
),
],
),
),
],
),
),
const SizedBox(width: 8),
// Menu d'actions (seulement pour les messages des autres)
if (!isCurrentUser)
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
size: 16,
color: Colors.grey[600],
),
padding: EdgeInsets.zero,
itemBuilder: (context) => [
const PopupMenuItem<String>(
value: 'reply',
child: Row(
children: [
Icon(Icons.reply, size: 16),
SizedBox(width: 8),
Text('Répondre'),
],
),
),
const PopupMenuItem<String>(
value: 'copy',
child: Row(
children: [
Icon(Icons.content_copy, size: 16),
SizedBox(width: 8),
Text('Copier'),
],
),
),
],
onSelected: (value) {
if (value == 'reply') {
onReply(message);
} else if (value == 'copy') {
// Copier le message
}
},
),
],
),
],
),
);
},
);
}
// Formater l'heure du message
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/shared/app_theme.dart';
/// Widget pour afficher la barre latérale des contacts
class ChatSidebar extends StatelessWidget {
final List<Map<String, dynamic>> teamContacts;
final List<Map<String, dynamic>> clientContacts;
final bool isTeamChat;
final int selectedContactId;
final Function(int, String, bool) onContactSelected;
final Function(bool) onToggleGroup;
const ChatSidebar({
Key? key,
required this.teamContacts,
required this.clientContacts,
required this.isTeamChat,
required this.selectedContactId,
required this.onContactSelected,
required this.onToggleGroup,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
// En-tête avec les onglets
Container(
padding: const EdgeInsets.all(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: [
Expanded(
child: _buildTabButton(
context,
'Équipe',
isTeamChat,
() => onToggleGroup(true),
),
),
const SizedBox(width: AppTheme.spacingS),
Expanded(
child: _buildTabButton(
context,
'Clients',
!isTeamChat,
() => onToggleGroup(false),
),
),
],
),
),
// Liste des contacts
Expanded(
child: Container(
color: Colors.grey[100],
child: ListView(
padding: EdgeInsets.zero,
children: [
// Afficher les contacts appropriés en fonction de l'onglet sélectionné
...isTeamChat
? teamContacts.map(
(contact) => _buildContactItem(context, contact, true))
: clientContacts.map((contact) =>
_buildContactItem(context, contact, false)),
],
),
),
),
],
);
}
// Construire un bouton d'onglet
Widget _buildTabButton(
BuildContext context,
String label,
bool isSelected,
VoidCallback onPressed,
) {
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? AppTheme.primaryColor : Colors.grey[200],
foregroundColor: isSelected ? Colors.white : Colors.black,
elevation: isSelected ? 2 : 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.borderRadiusSmall),
),
),
child: Text(label),
);
}
// Construire un élément de contact
Widget _buildContactItem(
BuildContext context,
Map<String, dynamic> contact,
bool isTeam,
) {
final bool isSelected = contact['id'] == selectedContactId;
final bool hasUnread = (contact['unread'] as int) > 0;
return ListTile(
selected: isSelected,
selectedTileColor: Colors.blue.withOpacity(0.1),
leading: CircleAvatar(
backgroundColor: AppTheme.primaryColor.withOpacity(0.2),
backgroundImage: contact['avatar'] != null
? AssetImage(contact['avatar'] as String)
: null,
child: contact['avatar'] == null
? Text(
(contact['name'] as String).isNotEmpty
? (contact['name'] as String)[0].toUpperCase()
: '',
style: const TextStyle(
color: AppTheme.primaryColor,
fontWeight: FontWeight.bold,
),
)
: null,
),
title: Row(
children: [
Expanded(
child: Text(
contact['name'] as String,
style: TextStyle(
fontWeight: hasUnread ? FontWeight.bold : FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
if (contact['online'] == true)
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.green,
shape: BoxShape.circle,
),
),
],
),
subtitle: Text(
contact['lastMessage'] as String,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: hasUnread ? FontWeight.bold : FontWeight.normal,
color: hasUnread ? Colors.black87 : Colors.grey[600],
),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
_formatTime(contact['time'] as DateTime),
style: TextStyle(
fontSize: 12,
color: hasUnread ? AppTheme.primaryColor : Colors.grey[500],
),
),
const SizedBox(height: 4),
if (hasUnread)
Container(
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppTheme.primaryColor,
shape: BoxShape.circle,
),
child: Text(
(contact['unread'] as int).toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
onTap: () => onContactSelected(
contact['id'] as int,
contact['name'] as String,
isTeam,
),
);
}
// Formater l'heure du dernier message
String _formatTime(DateTime time) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final messageDate = DateTime(time.year, time.month, time.day);
if (messageDate == today) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
} else if (messageDate == yesterday) {
return 'Hier';
} else {
return '${time.day}/${time.month}';
}
}
}

View File

@@ -0,0 +1,153 @@
import 'package:flutter/material.dart';
/// Widget de dialogue pour informer l'utilisateur qu'il doit vider le cache de son navigateur
/// et recharger l'application en raison d'une incompatibilité après une mise à jour.
class ClearCacheDialog extends StatelessWidget {
/// Callback appelé lorsque l'utilisateur ferme le dialogue
final VoidCallback? onClose;
const ClearCacheDialog({
Key? key,
this.onClose,
}) : super(key: key);
/// Affiche le dialogue de nettoyage du cache
static Future<void> show(BuildContext context,
{VoidCallback? onClose}) async {
return showDialog<void>(
context: context,
barrierDismissible:
false, // L'utilisateur doit appuyer sur un bouton pour fermer le dialogue
builder: (BuildContext dialogContext) {
return ClearCacheDialog(onClose: onClose);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: Colors.orange,
size: 28,
),
const SizedBox(width: 12),
Text(
'Mise à jour requise',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Une incompatibilité a été détectée avec les données stockées localement. Veuillez suivre ces étapes pour résoudre le problème :',
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: 16),
_buildInstructionStep(
context,
1,
'Videz le cache de votre navigateur',
'Dans Chrome : Menu > Plus d\'outils > Effacer les données de navigation > Sélectionnez "Cookies et données de site" > Effacer les données'),
const SizedBox(height: 12),
_buildInstructionStep(
context,
2,
'Fermez complètement le navigateur',
'Assurez-vous de fermer toutes les fenêtres du navigateur'),
const SizedBox(height: 12),
_buildInstructionStep(context, 3, 'Rouvrez l\'application',
'Reconnectez-vous à l\'application pour récupérer vos données depuis le serveur'),
const SizedBox(height: 16),
Text(
'Note : Cette opération est nécessaire en raison d\'une mise à jour de la structure des données. Toutes vos données seront récupérées depuis le serveur après reconnexion.',
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
if (onClose != null) {
onClose!();
}
},
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
child: const Text('J\'ai compris'),
),
],
);
}
/// Construit une étape d'instruction avec un numéro, un titre et une description
Widget _buildInstructionStep(
BuildContext context, int stepNumber, String title, String description) {
final theme = Theme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'$stepNumber',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
description,
style: theme.textTheme.bodyMedium,
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/services/connectivity_service.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
/// Widget qui affiche l'état de la connexion Internet
class ConnectivityIndicator extends StatelessWidget {
/// Si true, affiche un message d'erreur lorsque l'appareil est déconnecté
final bool showErrorMessage;
/// Si true, affiche un badge avec le type de connexion (WiFi, données mobiles)
final bool showConnectionType;
/// Callback appelé lorsque l'état de la connexion change
final Function(bool isConnected)? onConnectivityChanged;
const ConnectivityIndicator({
super.key,
this.showErrorMessage = true,
this.showConnectionType = true,
this.onConnectivityChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Utiliser l'instance globale de connectivityService définie dans app.dart
final isConnected = connectivityService.isConnected;
final connectionType = connectivityService.connectionType;
final connectionStatus = connectivityService.connectionStatus;
// Appeler le callback si fourni, mais pas directement dans le build
// pour éviter les problèmes de rendu
WidgetsBinding.instance.addPostFrameCallback((_) {
if (onConnectivityChanged != null) {
onConnectivityChanged!(isConnected);
}
});
if (!isConnected && showErrorMessage) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: theme.colorScheme.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.colorScheme.error.withOpacity(0.3),
),
),
child: Row(
children: [
Icon(
Icons.wifi_off,
color: theme.colorScheme.error,
size: 18,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Aucune connexion Internet. Certaines fonctionnalités peuvent être limitées.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
),
],
),
);
} else if (isConnected && showConnectionType) {
// Obtenir la couleur et l'icône en fonction du type de connexion
final color = _getConnectionColor(connectionStatus, theme);
final icon = _getConnectionIcon(connectionStatus);
return Container(
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: color.withOpacity(0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: color,
size: 14,
),
const SizedBox(width: 4),
Text(
connectionType,
style: theme.textTheme.bodySmall?.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
// Si aucune condition n'est remplie ou si showErrorMessage et showConnectionType sont false
return const SizedBox.shrink();
}
/// Retourne l'icône correspondant au type de connexion
IconData _getConnectionIcon(List<ConnectivityResult> statusList) {
// Utiliser le premier type de connexion qui n'est pas 'none'
ConnectivityResult status = statusList.firstWhere(
(result) => result != ConnectivityResult.none,
orElse: () => ConnectivityResult.none);
switch (status) {
case ConnectivityResult.wifi:
return Icons.wifi;
case ConnectivityResult.mobile:
return Icons.signal_cellular_alt;
case ConnectivityResult.ethernet:
return Icons.lan;
case ConnectivityResult.bluetooth:
return Icons.bluetooth;
case ConnectivityResult.vpn:
return Icons.vpn_key;
default:
return Icons.wifi_off;
}
}
/// Retourne la couleur correspondant au type de connexion
Color _getConnectionColor(
List<ConnectivityResult> statusList, ThemeData theme) {
// Utiliser le premier type de connexion qui n'est pas 'none'
ConnectivityResult status = statusList.firstWhere(
(result) => result != ConnectivityResult.none,
orElse: () => ConnectivityResult.none);
switch (status) {
case ConnectivityResult.wifi:
return Colors.green;
case ConnectivityResult.mobile:
return Colors.blue;
case ConnectivityResult.ethernet:
return Colors.purple;
case ConnectivityResult.bluetooth:
return Colors.indigo;
case ConnectivityResult.vpn:
return Colors.orange;
default:
return theme.colorScheme.error;
}
}
}

View File

@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
class CustomButton extends StatelessWidget {
final VoidCallback? onPressed;
final String text;
final IconData? icon;
final bool isLoading;
final double? width;
final Color? backgroundColor;
final Color? textColor;
const CustomButton({
super.key,
required this.onPressed,
required this.text,
this.icon,
this.isLoading = false,
this.width,
this.backgroundColor,
this.textColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: width,
child: ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor ?? theme.colorScheme.primary,
foregroundColor: textColor ?? Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 2,
),
child: isLoading
? SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
textColor ?? Colors.white,
),
),
)
: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon),
const SizedBox(width: 8),
],
Text(
text,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CustomTextField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String? hintText;
final IconData? prefixIcon;
final Widget? suffixIcon;
final bool obscureText;
final TextInputType keyboardType;
final String? Function(String?)? validator;
final List<TextInputFormatter>? inputFormatters;
final int? maxLines;
final int? minLines;
final bool readOnly;
final VoidCallback? onTap;
final Function(String)? onChanged;
final bool autofocus;
final FocusNode? focusNode;
final String? errorText;
final Color? fillColor;
final String? helperText;
final Function(String)? onFieldSubmitted;
const CustomTextField({
super.key,
required this.controller,
required this.label,
this.hintText,
this.prefixIcon,
this.suffixIcon,
this.obscureText = false,
this.keyboardType = TextInputType.text,
this.validator,
this.inputFormatters,
this.maxLines = 1,
this.minLines,
this.readOnly = false,
this.onTap,
this.onChanged,
this.autofocus = false,
this.focusNode,
this.errorText,
this.fillColor,
this.helperText,
this.onFieldSubmitted,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (label.isNotEmpty) ...[
Text(
label,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
],
// Ajouter un Container avec une ombre pour créer un effet d'élévation
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: TextFormField(
controller: controller,
obscureText: obscureText,
keyboardType: keyboardType,
validator: validator,
inputFormatters: inputFormatters,
maxLines: maxLines,
minLines: minLines,
readOnly: readOnly,
onTap: onTap,
onChanged: onChanged,
onFieldSubmitted: onFieldSubmitted,
autofocus: autofocus,
focusNode: focusNode,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground,
),
decoration: InputDecoration(
hintText: hintText,
hintStyle: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground.withOpacity(0.5),
),
errorText: errorText,
helperText: helperText,
helperStyle: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onBackground.withOpacity(0.6),
),
prefixIcon: prefixIcon != null
? Icon(prefixIcon, color: theme.colorScheme.primary)
: null,
suffixIcon: suffixIcon,
// Couleur de fond différente selon l'état (lecture seule ou éditable)
fillColor: fillColor ??
(readOnly
? const Color(0xFFF8F9FA) // Gris plus clair pour readOnly
: const Color(
0xFFECEFF1)), // Gris plus foncé pour éditable
filled: true,
// Ajouter une élévation avec une petite ombre
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
// Ajouter une ombre pour créer un effet d'élévation
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
gapPadding: 0,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.error,
width: 2,
),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: theme.colorScheme.error,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:geosector_app/presentation/widgets/profile_dialog.dart';
import 'package:go_router/go_router.dart';
/// AppBar personnalisée pour les tableaux de bord
class DashboardAppBar extends StatelessWidget implements PreferredSizeWidget {
/// Le titre principal de l'AppBar (généralement le nom de l'application)
final String title;
/// Le titre de la page actuelle (optionnel)
final String? pageTitle;
/// Actions supplémentaires à afficher dans l'AppBar
final List<Widget>? additionalActions;
/// Indique si le bouton "Nouveau passage" doit être affiché
final bool showNewPassageButton;
/// Callback appelé lorsque le bouton "Nouveau passage" est pressé
final VoidCallback? onNewPassagePressed;
/// Indique si l'utilisateur est un administrateur
final bool isAdmin;
/// Callback appelé lorsque le bouton de déconnexion est pressé
final VoidCallback? onLogoutPressed;
const DashboardAppBar({
Key? key,
required this.title,
this.pageTitle,
this.additionalActions,
this.showNewPassageButton = true,
this.onNewPassagePressed,
this.isAdmin = false,
this.onLogoutPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppBar(
title: _buildTitle(context),
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
elevation: 4,
leading: _buildLogo(),
actions: _buildActions(context),
);
}
/// Construction du logo dans l'AppBar
Widget _buildLogo() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: Image.asset(
'assets/images/logo-geosector-1024.png',
width: 40,
height: 40,
),
);
}
/// Construction des actions de l'AppBar
List<Widget> _buildActions(BuildContext context) {
final theme = Theme.of(context);
final List<Widget> actions = [];
// Ajouter l'indicateur de connectivité
actions.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
child: const ConnectivityIndicator(
showErrorMessage: false,
showConnectionType: true,
),
),
);
// Ajouter les actions supplémentaires si elles existent
if (additionalActions != null && additionalActions!.isNotEmpty) {
actions.addAll(additionalActions!);
} else if (showNewPassageButton) {
// Ajouter le bouton "Nouveau passage" en haut à droite
actions.add(
TextButton.icon(
icon: const Icon(Icons.add_location_alt, color: Colors.white),
label: const Text('Nouveau passage',
style: TextStyle(color: Colors.white)),
onPressed: onNewPassagePressed,
style: TextButton.styleFrom(
backgroundColor: theme.colorScheme.secondary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
);
}
// Ajouter le bouton "Mon compte"
actions.add(
IconButton(
icon: const Icon(Icons.person),
tooltip: 'Mon compte',
onPressed: () {
// Afficher la boîte de dialogue de profil avec l'utilisateur actuel
final user = userRepository.currentUser;
if (user != null) {
ProfileDialog.show(context, user);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Erreur: Utilisateur non trouvé'),
backgroundColor: theme.colorScheme.error,
),
);
}
},
),
);
// Ajouter le bouton de déconnexion
actions.add(
IconButton(
icon: const Icon(Icons.logout),
tooltip: 'Déconnexion',
onPressed: onLogoutPressed ??
() {
// Si aucun callback n'est fourni, utiliser le userRepository global
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Déconnexion'),
content:
const Text('Voulez-vous vraiment vous déconnecter ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
// Utiliser directement userRepository pour la déconnexion
// qui gère à la fois le nettoyage des données et la redirection
await userRepository.logoutWithUI(context);
// La redirection est gérée dans logoutWithUI
},
child: const Text('Déconnexion'),
),
],
),
);
},
),
);
actions.add(const SizedBox(width: 8)); // Espacement à droite
return actions;
}
/// Construction du titre de l'AppBar
Widget _buildTitle(BuildContext context) {
// Si aucun titre de page n'est fourni, afficher simplement le titre principal
if (pageTitle == null) {
return Text(title);
}
// Construire un titre composé en fonction du rôle de l'utilisateur
final String prefix = isAdmin ? 'Administration' : title;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(prefix),
const Text(' - '),
Text(pageTitle!),
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/presentation/widgets/dashboard_app_bar.dart';
import 'package:geosector_app/presentation/widgets/responsive_navigation.dart';
/// Layout commun pour les tableaux de bord utilisateur et administrateur
/// Combine DashboardAppBar et ResponsiveNavigation
class DashboardLayout extends StatelessWidget {
/// Le contenu principal à afficher
final Widget body;
/// Le titre de la page
final String title;
/// L'index de la page sélectionnée
final int selectedIndex;
/// Callback appelé lorsqu'un élément de navigation est sélectionné
final Function(int) onDestinationSelected;
/// Liste des destinations de navigation
final List<NavigationDestination> destinations;
/// Actions supplémentaires à afficher dans l'AppBar
final List<Widget>? additionalActions;
/// Indique si le bouton "Nouveau passage" doit être affiché
final bool showNewPassageButton;
/// Callback appelé lorsque le bouton "Nouveau passage" est pressé
final VoidCallback? onNewPassagePressed;
/// Widgets à afficher en bas de la sidebar
final List<Widget>? sidebarBottomItems;
/// Indique si l'utilisateur est un administrateur
final bool isAdmin;
/// Callback appelé lorsque le bouton de déconnexion est pressé
final VoidCallback? onLogoutPressed;
const DashboardLayout({
Key? key,
required this.body,
required this.title,
required this.selectedIndex,
required this.onDestinationSelected,
required this.destinations,
this.additionalActions,
this.showNewPassageButton = true,
this.onNewPassagePressed,
this.sidebarBottomItems,
this.isAdmin = false,
this.onLogoutPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
try {
debugPrint('Building DashboardLayout');
// Vérifier que les destinations ne sont pas vides
if (destinations.isEmpty) {
debugPrint('ERREUR: destinations est vide dans DashboardLayout');
return const Scaffold(
body: Center(
child: Text('Erreur: Aucune destination de navigation disponible'),
),
);
}
// Vérifier que selectedIndex est valide
if (selectedIndex < 0 || selectedIndex >= destinations.length) {
debugPrint('ERREUR: selectedIndex invalide dans DashboardLayout');
return Scaffold(
body: Center(
child:
Text('Erreur: Index de navigation invalide ($selectedIndex)'),
),
);
}
return Scaffold(
backgroundColor: Colors
.transparent, // Fond transparent pour laisser voir le AdminBackground
appBar: DashboardAppBar(
title: title,
pageTitle: destinations[selectedIndex].label,
additionalActions: additionalActions,
showNewPassageButton: showNewPassageButton,
onNewPassagePressed: onNewPassagePressed,
isAdmin: isAdmin,
onLogoutPressed: onLogoutPressed,
),
body: ResponsiveNavigation(
title:
title, // Même si le titre n'est pas affiché dans la navigation, il est utilisé pour la cohérence
body: body,
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
destinations: destinations,
// Ne pas afficher le bouton "Nouveau passage" dans la navigation car il est déjà dans l'AppBar
showNewPassageButton: false,
onNewPassagePressed: onNewPassagePressed,
sidebarBottomItems: sidebarBottomItems,
isAdmin: isAdmin,
// Ne pas afficher l'AppBar dans la navigation car nous utilisons DashboardAppBar
showAppBar: false,
),
);
} catch (e) {
debugPrint('ERREUR CRITIQUE dans DashboardLayout.build: $e');
// Afficher une interface de secours en cas d'erreur
return Scaffold(
appBar: AppBar(
title: Text('Erreur - $title'),
backgroundColor: Colors.red,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 64),
const SizedBox(height: 16),
const Text(
'Une erreur est survenue',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text('Détails: $e'),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
Navigator.of(context)
.pushNamedAndRemoveUntil('/', (route) => false);
},
child: const Text('Retour à l\'accueil'),
),
],
),
),
);
}
}
}

View File

@@ -0,0 +1,141 @@
# Documentation des Widgets Amicale
Cette documentation explique comment utiliser les widgets `AmicaleRowWidget` et `AmicaleTableWidget` pour afficher et gérer les données des amicales dans l'application.
## AmicaleRowWidget
Le widget `AmicaleRowWidget` représente une ligne dans un tableau d'amicales. Il affiche les informations d'une amicale avec les colonnes suivantes :
- ID
- Nom
- Code Postal
- Région
- Actions (boutons selon les droits de l'utilisateur)
### Propriétés
| Propriété | Type | Description |
| ------------- | --------------- | ---------------------------------------------------------------------------------- |
| `amicale` | `AmicaleModel` | **Obligatoire**. L'objet amicale à afficher. |
| `onEdit` | `VoidCallback?` | Fonction appelée lorsque l'utilisateur clique sur le bouton d'édition. |
| `onDelete` | `VoidCallback?` | Fonction appelée lorsque l'utilisateur clique sur le bouton de suppression. |
| `isAlternate` | `bool` | Indique si la ligne doit avoir une couleur de fond alternée. Par défaut à `false`. |
### Gestion des droits d'accès
Le widget gère automatiquement l'affichage des boutons d'action en fonction du rôle de l'utilisateur :
- Le bouton d'édition (crayon) est visible pour tous les utilisateurs avec un rôle > 1
- Le bouton de suppression (corbeille) est visible uniquement pour les utilisateurs avec un rôle > 2
### Exemple d'utilisation
```dart
AmicaleRowWidget(
amicale: amicale,
isAlternate: index % 2 == 1, // Alterner les couleurs
onEdit: () {
// Code pour gérer l'édition
},
onDelete: () {
// Code pour gérer la suppression
},
)
```
## AmicaleTableWidget
Le widget `AmicaleTableWidget` affiche un tableau complet d'amicales avec un en-tête et des lignes. Il utilise le widget `AmicaleRowWidget` pour afficher chaque ligne.
### Propriétés
| Propriété | Type | Description |
| -------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| `amicales` | `List<AmicaleModel>` | **Obligatoire**. La liste des amicales à afficher. |
| `onEdit` | `Function(AmicaleModel)?` | Fonction appelée lorsque l'utilisateur clique sur le bouton d'édition d'une amicale. |
| `onDelete` | `Function(AmicaleModel)?` | Fonction appelée lorsque l'utilisateur clique sur le bouton de suppression d'une amicale. |
| `isLoading` | `bool` | Indique si les données sont en cours de chargement. Affiche un indicateur de chargement si `true`. Par défaut à `false`. |
| `emptyMessage` | `String?` | Message à afficher lorsque la liste des amicales est vide. |
### États du tableau
Le widget gère automatiquement différents états :
1. **Chargement** : Affiche un indicateur de chargement circulaire lorsque `isLoading` est `true`.
2. **Liste vide** : Affiche un message lorsque la liste des amicales est vide.
3. **Affichage normal** : Affiche la liste des amicales avec des lignes alternées.
### Exemple d'utilisation
```dart
AmicaleTableWidget(
amicales: _amicales,
isLoading: _isLoading,
onEdit: (amicale) {
// Code pour gérer l'édition de l'amicale
},
onDelete: (amicale) {
// Code pour gérer la suppression de l'amicale
},
emptyMessage: 'Aucune amicale trouvée. Veuillez en créer une nouvelle.',
)
```
## Intégration avec AmicaleRepository
Pour utiliser ces widgets avec le repository des amicales, vous devez :
1. Récupérer les amicales depuis le repository :
```dart
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
final amicales = amicaleRepository.getAllAmicales();
```
2. Gérer les actions d'édition et de suppression :
```dart
void _handleEdit(AmicaleModel amicale) {
// Naviguer vers la page d'édition ou afficher une boîte de dialogue
}
Future<void> _handleDelete(AmicaleModel amicale) async {
// Afficher une confirmation puis supprimer
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
await amicaleRepository.deleteAmicale(amicale.id);
// Recharger la liste
setState(() {
_amicales = amicaleRepository.getAllAmicales();
});
}
```
## Exemple complet
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/amicale_table_example.dart`.
## Personnalisation
### Styles
Les widgets utilisent les styles du thème de l'application pour la cohérence visuelle. Vous pouvez personnaliser l'apparence en modifiant le thème ou en surchargeant les styles dans votre implémentation.
### Colonnes et flexibilité
Les colonnes du tableau ont des valeurs de flex prédéfinies pour une mise en page optimale :
- ID : flex 1
- Nom : flex 4
- Code Postal : flex 2
- Région : flex 3
- Actions : flex 2
Vous pouvez ajuster ces valeurs en modifiant le code source si nécessaire.
## Bonnes pratiques
1. **Gestion des erreurs** : Ajoutez toujours une gestion des erreurs lors de l'interaction avec le repository.
2. **Confirmation des actions** : Demandez toujours une confirmation avant de supprimer une amicale.
3. **Actualisation des données** : Prévoyez un moyen de rafraîchir les données (bouton ou pull-to-refresh).
4. **Pagination** : Pour les grandes listes, envisagez d'implémenter une pagination.

View File

@@ -0,0 +1,204 @@
# Documentation du Widget EntiteForm
Cette documentation décrit le widget `EntiteForm` créé pour la création et la modification des entités (amicales) dans l'application GeoSector.
## Description
Le widget `EntiteForm` est un formulaire complet permettant de créer ou modifier une entité (amicale). Il gère l'affichage de tous les champs nécessaires, la validation des données et les restrictions d'accès basées sur le rôle de l'utilisateur.
## Propriétés
- `amicale` (AmicaleModel?, optionnel) : Le modèle d'amicale à modifier. Si null, le formulaire sera en mode création.
- `onSubmit` (Function(AmicaleModel)?, optionnel) : Callback appelé lorsque le formulaire est soumis avec succès.
- `readOnly` (bool, défaut: false) : Si true, tous les champs du formulaire seront en lecture seule.
## Champs du formulaire
Le formulaire inclut les champs suivants :
### Informations générales
- **Nom** : Nom de l'amicale (obligatoire)
### Adresse
- **Adresse ligne 1** : Première ligne d'adresse
- **Adresse ligne 2** : Seconde ligne d'adresse (optionnelle)
- **Code Postal** : Code postal (validation pour 5 chiffres)
- **Ville** : Nom de la ville
- **Région** : Sélection de la région via un dropdown
### Contact
- **Téléphone fixe** : Numéro de téléphone fixe (validation pour 10 chiffres)
- **Téléphone mobile** : Numéro de téléphone mobile (validation pour 10 chiffres)
- **Email** : Adresse email (obligatoire, avec validation de format)
### Informations avancées (visibles uniquement pour les administrateurs ou si déjà remplies)
- **GPS Latitude** : Coordonnée GPS latitude
- **GPS Longitude** : Coordonnée GPS longitude
- **Stripe ID** : Identifiant Stripe pour les paiements
### Options
- **Mode démo** : Indique si l'amicale est en mode démo
- **Copie des mails reçus** : Indique si l'amicale reçoit une copie des emails
- **Accepte les SMS** : Indique si l'amicale accepte les SMS
- **Actif** : Indique si l'amicale est active
## Restrictions d'accès
Certains champs sont soumis à des restrictions d'accès basées sur le rôle de l'utilisateur :
- Les champs suivants sont en lecture seule pour les utilisateurs avec un rôle ≤ 2 :
- fkRegion/libRegion
- gpsLat
- gpsLng
- stripeId
- chkDemo
- chkActive
## Exemple d'utilisation
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/entite_form_example.dart`.
### Utilisation simple
```dart
// Création d'une nouvelle amicale
EntiteForm(
onSubmit: (amicale) {
// Gérer la soumission
print('Nouvelle amicale: ${amicale.name}');
},
)
// Modification d'une amicale existante
EntiteForm(
amicale: amicaleExistante,
onSubmit: (amicale) {
// Gérer la soumission
print('Amicale modifiée: ${amicale.name}');
},
)
// Affichage en lecture seule
EntiteForm(
amicale: amicaleExistante,
readOnly: true,
)
```
### Utilisation avec gestion d'état
```dart
class _MyWidgetState extends State<MyWidget> {
AmicaleModel? _amicale;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadAmicale();
}
Future<void> _loadAmicale() async {
setState(() {
_isLoading = true;
});
try {
if (widget.amicaleId != null) {
// Récupérer l'amicale depuis le repository
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
setState(() {
_amicale = amicale;
_isLoading = false;
});
} else {
// Création d'une nouvelle amicale
setState(() {
_amicale = null;
_isLoading = false;
});
}
} catch (e) {
debugPrint('Erreur lors du chargement de l\'amicale: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleSubmit(AmicaleModel amicale) async {
try {
final amicaleRepository = Provider.of<AmicaleRepository>(context, listen: false);
// Sauvegarder l'amicale
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
// Afficher un message de confirmation
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
backgroundColor: Colors.green,
),
);
} catch (e) {
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la sauvegarde: $e'),
backgroundColor: Colors.red,
),
);
}
}
@override
Widget build(BuildContext context) {
return _isLoading
? const Center(child: CircularProgressIndicator())
: EntiteForm(
amicale: _amicale,
onSubmit: _handleSubmit,
);
}
}
```
## Intégration avec le système de rôles
Le widget utilise le `UserRepository` pour déterminer le rôle de l'utilisateur actuel et appliquer les restrictions d'accès en conséquence. Assurez-vous que le `UserRepository` est disponible dans l'arbre des widgets via un `Provider`.
```dart
// Dans le widget parent
return MultiProvider(
providers: [
Provider<UserRepository>(
create: (context) => userRepository,
),
Provider<AmicaleRepository>(
create: (context) => amicaleRepository,
),
],
child: MyWidget(),
);
```
## Personnalisation
Le widget utilise le thème de l'application pour le style. Vous pouvez personnaliser l'apparence en modifiant le thème ou en étendant le widget pour créer votre propre version personnalisée.
## Validation des données
Le formulaire inclut une validation pour les champs suivants :
- **Nom** : Ne peut pas être vide
- **Code Postal** : Doit contenir 5 chiffres s'il est rempli
- **Téléphone fixe** : Doit contenir 10 chiffres s'il est rempli
- **Téléphone mobile** : Doit contenir 10 chiffres s'il est rempli
- **Email** : Ne peut pas être vide et doit contenir un '@' et un '.'

View File

@@ -0,0 +1,160 @@
# Documentation du Widget EntiteForm avec RegionRepository
Cette documentation explique comment utiliser le widget `EntiteForm` avec le `RegionRepository` pour afficher et gérer les régions dans le formulaire d'entité.
## Intégration du RegionRepository
Le widget `EntiteForm` est conçu pour fonctionner avec le `RegionRepository` afin de récupérer la liste des régions disponibles pour le champ de sélection de région. Voici comment l'intégrer :
### 1. Initialisation du RegionRepository
Le `RegionRepository` doit être initialisé et fourni au widget `EntiteForm` via un `Provider`. Voici un exemple d'initialisation :
```dart
final regionRepository = RegionRepository();
await regionRepository.init();
```
### 2. Fournir le RegionRepository via Provider
Pour que le widget `EntiteForm` puisse accéder au `RegionRepository`, vous devez le fournir via un `Provider` :
```dart
MultiProvider(
providers: [
ChangeNotifierProvider<RegionRepository>.value(value: regionRepository),
// Autres providers si nécessaire
],
child: EntiteForm(
amicale: amicale,
onSubmit: handleSubmit,
readOnly: false,
),
)
```
### 3. Mise à jour des régions depuis l'API
Lorsque l'API renvoie les données des régions dans la réponse de login, vous devez les mettre à jour dans le `RegionRepository` :
```dart
// Dans le service qui gère la connexion
void handleLoginResponse(Map<String, dynamic> response) {
// Autres traitements...
// Mise à jour des régions si présentes dans la réponse
if (response.containsKey('regions') && response['regions'] is List) {
final regionRepository = Provider.of<RegionRepository>(context, listen: false);
regionRepository.updateRegionsFromApi(response['regions']);
}
}
```
## Fonctionnement avec les restrictions d'accès
Le widget `EntiteForm` gère automatiquement les restrictions d'accès basées sur le rôle de l'utilisateur :
- Pour les utilisateurs avec un rôle ≤ 2, le champ de sélection de région est en lecture seule
- Pour les utilisateurs avec un rôle > 2, le champ de sélection de région est modifiable
## Filtrage des régions selon le code postal
Le `RegionRepository` offre une méthode `getRegionByPostalCode` qui permet de filtrer les régions en fonction du code postal :
```dart
// Récupérer la région correspondant au code postal
final codePostal = '75001';
final region = regionRepository.getRegionByPostalCode(codePostal);
if (region != null) {
// Utiliser la région trouvée
print('Région trouvée : ${region.libelle}');
}
```
Cette fonctionnalité est particulièrement utile pour pré-remplir le champ de région lorsque l'utilisateur entre un code postal.
## Exemple complet d'utilisation
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/entite_form_with_regions_example.dart`.
### Exemple simplifié
```dart
class MyWidget extends StatefulWidget {
@override
State<MyWidget> createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
late RegionRepository _regionRepository;
AmicaleModel? _amicale;
bool _isLoading = true;
@override
void initState() {
super.initState();
_regionRepository = RegionRepository();
_initData();
}
Future<void> _initData() async {
setState(() {
_isLoading = true;
});
try {
// Initialiser le repository des régions
await _regionRepository.init();
// Charger l'amicale si nécessaire
// ...
setState(() {
_isLoading = false;
});
} catch (e) {
debugPrint('Erreur lors de l\'initialisation: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleSubmit(AmicaleModel amicale) {
// Traiter la soumission du formulaire
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider<RegionRepository>.value(value: _regionRepository),
],
child: Scaffold(
appBar: AppBar(title: Text('Formulaire d\'entité')),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: EntiteForm(
amicale: _amicale,
onSubmit: _handleSubmit,
readOnly: false,
),
),
),
);
}
}
```
## Mise à jour du code postal et de la région
Pour mettre à jour automatiquement la région lorsque l'utilisateur change le code postal, vous pouvez étendre le widget `EntiteForm` ou créer un wrapper qui écoute les changements du champ de code postal et met à jour la région en conséquence.
## Remarques importantes
1. Assurez-vous que le `RegionRepository` est initialisé avant d'afficher le formulaire.
2. Le widget `EntiteForm` s'adapte automatiquement au rôle de l'utilisateur pour les restrictions d'accès.
3. Les régions sont filtrées en fonction du code postal de l'amicale pour les utilisateurs avec un rôle ≤ 2.
4. Pour les utilisateurs avec un rôle > 2, toutes les régions sont disponibles dans le dropdown.

View File

@@ -0,0 +1,207 @@
# Documentation des Widgets Membre
Cette documentation décrit les widgets créés pour afficher et gérer les données des membres dans l'application GeoSector.
## Widgets disponibles
### 1. MembreRowWidget
Widget qui représente une ligne individuelle dans un tableau de membres. Il affiche les informations d'un membre et des boutons d'action pour l'édition et la suppression.
#### Propriétés
- `membre` (MembreModel, requis) : Le modèle de membre à afficher
- `onEdit` (Function()?, optionnel) : Callback appelé lorsque le bouton d'édition est pressé
- `onDelete` (Function()?, optionnel) : Callback appelé lorsque le bouton de suppression est pressé
#### Colonnes affichées
- ID : Identifiant unique du membre
- Prénom (firstName) : Prénom du membre
- Nom (name) : Nom de famille du membre
- Secteur (sectName) : Nom du secteur auquel le membre est associé
- Rôle (fkRole) : Rôle du membre (affiché sous forme de texte : User, Admin, Super)
- Actions : Boutons d'édition et de suppression
### 2. MembreTableWidget
Widget qui affiche un tableau complet de membres avec en-tête et lignes. Il utilise le widget `MembreRowWidget` pour afficher chaque ligne.
#### Propriétés
- `membres` (List<MembreModel>, requis) : La liste des membres à afficher
- `onEdit` (Function(MembreModel)?, optionnel) : Callback appelé lorsque le bouton d'édition est pressé pour un membre
- `onDelete` (Function(MembreModel)?, optionnel) : Callback appelé lorsque le bouton de suppression est pressé pour un membre
- `showHeader` (bool, défaut: true) : Indique si l'en-tête du tableau doit être affiché
- `height` (double?, optionnel) : Hauteur du tableau (null pour prendre toute la hauteur disponible)
- `padding` (EdgeInsetsGeometry?, optionnel) : Padding du tableau
## Exemple d'utilisation
Un exemple complet d'utilisation est disponible dans le fichier `app/lib/presentation/widgets/examples/membre_table_example.dart`.
### Utilisation simple
```dart
// S'assurer que la boîte Hive est ouverte
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
}
// Récupérer les membres depuis la boîte Hive
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
final membres = membresBox.values.toList();
// Afficher le tableau
return MembreTableWidget(
membres: membres,
onEdit: (membre) {
// Gérer l'édition
},
onDelete: (membre) {
// Gérer la suppression
},
);
```
### Utilisation avec gestion d'état
```dart
class _MyWidgetState extends State<MyWidget> {
List<MembreModel> _membres = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadMembres();
}
Future<void> _loadMembres() async {
setState(() {
_isLoading = true;
});
try {
// S'assurer que la boîte Hive est ouverte
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
}
// Récupérer les membres depuis la boîte Hive
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
final membres = membresBox.values.toList();
setState(() {
_membres = membres;
_isLoading = false;
});
} catch (e) {
debugPrint('Erreur lors du chargement des membres: $e');
setState(() {
_isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return _isLoading
? const Center(child: CircularProgressIndicator())
: MembreTableWidget(
membres: _membres,
onEdit: _handleEdit,
onDelete: _handleDelete,
);
}
}
```
## Gestion des événements
### Édition d'un membre
```dart
void _handleEdit(MembreModel membre) {
// Exemple de gestion de l'événement d'édition
debugPrint('Édition du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
// Ici, vous pourriez ouvrir une boîte de dialogue ou naviguer vers une page d'édition
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Édition de membre'),
content: Text('Vous avez demandé à éditer le membre ${membre.firstName} ${membre.name}'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
);
}
```
### Suppression d'un membre
```dart
void _handleDelete(MembreModel membre) {
// Exemple de gestion de l'événement de suppression
debugPrint('Suppression du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
// Demander confirmation avant de supprimer
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmation de suppression'),
content: Text('Êtes-vous sûr de vouloir supprimer le membre ${membre.firstName} ${membre.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
// Fermer la boîte de dialogue
Navigator.of(context).pop();
try {
// Supprimer le membre de la boîte Hive
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
await membresBox.delete(membre.id);
// Mettre à jour l'état
setState(() {
_membres = _membres.where((m) => m.id != membre.id).toList();
});
// Afficher un message de confirmation
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Membre ${membre.firstName} ${membre.name} supprimé'),
backgroundColor: Colors.green,
),
);
} catch (e) {
debugPrint('Erreur lors de la suppression du membre: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la suppression: $e'),
backgroundColor: Colors.red,
),
);
}
},
child: const Text('Supprimer'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
}
```
## Personnalisation
Les widgets utilisent le thème de l'application pour le style. Vous pouvez personnaliser l'apparence en modifiant le thème ou en étendant les widgets pour créer vos propres versions personnalisées.

View File

@@ -0,0 +1,644 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/repositories/region_repository.dart';
import 'package:provider/provider.dart';
import 'custom_text_field.dart';
class EntiteForm extends StatefulWidget {
final AmicaleModel? amicale;
final Function(AmicaleModel)? onSubmit;
final bool readOnly;
const EntiteForm({
Key? key,
this.amicale,
this.onSubmit,
this.readOnly = false,
}) : super(key: key);
@override
State<EntiteForm> createState() => _EntiteFormState();
}
class _EntiteFormState extends State<EntiteForm> {
final _formKey = GlobalKey<FormState>();
// Controllers
late final TextEditingController _nameController;
late final TextEditingController _adresse1Controller;
late final TextEditingController _adresse2Controller;
late final TextEditingController _codePostalController;
late final TextEditingController _villeController;
late final TextEditingController _phoneController;
late final TextEditingController _mobileController;
late final TextEditingController _emailController;
late final TextEditingController _gpsLatController;
late final TextEditingController _gpsLngController;
late final TextEditingController _stripeIdController;
// Form values
int? _fkRegion;
String? _libRegion;
bool _chkDemo = false;
bool _chkCopieMailRecu = false;
bool _chkAcceptSms = false;
bool _chkActive = true;
bool _chkStripe = false;
// Liste des régions (sera chargée depuis le store)
List<Map<String, dynamic>> _regions = [];
@override
void initState() {
super.initState();
// Initialize controllers with amicale data if available
final amicale = widget.amicale;
_nameController = TextEditingController(text: amicale?.name ?? '');
_adresse1Controller = TextEditingController(text: amicale?.adresse1 ?? '');
_adresse2Controller = TextEditingController(text: amicale?.adresse2 ?? '');
_codePostalController =
TextEditingController(text: amicale?.codePostal ?? '');
_villeController = TextEditingController(text: amicale?.ville ?? '');
_phoneController = TextEditingController(text: amicale?.phone ?? '');
_mobileController = TextEditingController(text: amicale?.mobile ?? '');
_emailController = TextEditingController(text: amicale?.email ?? '');
_gpsLatController = TextEditingController(text: amicale?.gpsLat ?? '');
_gpsLngController = TextEditingController(text: amicale?.gpsLng ?? '');
_stripeIdController = TextEditingController(text: amicale?.stripeId ?? '');
_fkRegion = amicale?.fkRegion;
_libRegion = amicale?.libRegion;
_chkDemo = amicale?.chkDemo ?? false;
_chkCopieMailRecu = amicale?.chkCopieMailRecu ?? false;
_chkAcceptSms = amicale?.chkAcceptSms ?? false;
_chkActive = amicale?.chkActive ?? true;
_chkStripe = amicale?.chkStripe ?? false;
// Charger les régions depuis le repository
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadRegions();
});
}
void _loadRegions() {
try {
final regionRepository =
Provider.of<RegionRepository>(context, listen: false);
if (!regionRepository.isLoaded) {
// Initialiser le repository si ce n'est pas déjà fait
regionRepository.init().then((_) {
setState(() {
_regions = regionRepository.getRegionsForDropdown();
});
});
} else {
setState(() {
_regions = regionRepository.getRegionsForDropdown();
});
}
} catch (e) {
debugPrint('Erreur lors du chargement des régions: $e');
// Utiliser une liste vide en cas d'erreur
setState(() {
_regions = [];
});
}
}
@override
void dispose() {
_nameController.dispose();
_adresse1Controller.dispose();
_adresse2Controller.dispose();
_codePostalController.dispose();
_villeController.dispose();
_phoneController.dispose();
_mobileController.dispose();
_emailController.dispose();
_gpsLatController.dispose();
_gpsLngController.dispose();
_stripeIdController.dispose();
super.dispose();
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
final amicale = widget.amicale?.copyWith(
name: _nameController.text,
adresse1: _adresse1Controller.text,
adresse2: _adresse2Controller.text,
codePostal: _codePostalController.text,
ville: _villeController.text,
fkRegion: _fkRegion,
libRegion: _libRegion,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
gpsLat: _gpsLatController.text,
gpsLng: _gpsLngController.text,
stripeId: _stripeIdController.text,
chkDemo: _chkDemo,
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
chkStripe: _chkStripe,
) ??
AmicaleModel(
id: 0, // Sera remplacé par l'API
name: _nameController.text,
adresse1: _adresse1Controller.text,
adresse2: _adresse2Controller.text,
codePostal: _codePostalController.text,
ville: _villeController.text,
fkRegion: _fkRegion,
libRegion: _libRegion,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
gpsLat: _gpsLatController.text,
gpsLng: _gpsLngController.text,
stripeId: _stripeIdController.text,
chkDemo: _chkDemo,
chkCopieMailRecu: _chkCopieMailRecu,
chkAcceptSms: _chkAcceptSms,
chkActive: _chkActive,
);
if (widget.onSubmit != null) {
widget.onSubmit!(amicale);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final userRepository = Provider.of<UserRepository>(context, listen: false);
final userRole = userRepository.getUserRole();
// Déterminer si l'utilisateur peut modifier les champs restreints
final bool canEditRestrictedFields = userRole > 2;
// Lecture seule pour les champs restreints si l'utilisateur n'a pas les droits
final bool restrictedFieldsReadOnly =
widget.readOnly || !canEditRestrictedFields;
// Calculer la largeur maximale du formulaire pour les écrans larges
final screenWidth = MediaQuery.of(context).size.width;
final formMaxWidth = screenWidth > 800 ? 600.0 : screenWidth;
return Form(
key: _formKey,
child: Container(
constraints: BoxConstraints(maxWidth: formMaxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom
CustomTextField(
controller: _nameController,
label: "Nom",
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer un nom";
}
return null;
},
),
const SizedBox(height: 16),
// Bloc Adresse
Text(
"Adresse",
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
// Adresse 1
CustomTextField(
controller: _adresse1Controller,
label: "Adresse ligne 1",
readOnly: widget.readOnly,
),
const SizedBox(height: 16),
// Adresse 2
CustomTextField(
controller: _adresse2Controller,
label: "Adresse ligne 2",
readOnly: widget.readOnly,
),
const SizedBox(height: 16),
// Code Postal et Ville
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Code Postal
Expanded(
flex: 1,
child: CustomTextField(
controller: _codePostalController,
label: "Code Postal",
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(5),
],
readOnly: widget.readOnly,
validator: (value) {
if (value != null &&
value.isNotEmpty &&
value.length < 5) {
return "Le code postal doit contenir 5 chiffres";
}
return null;
},
),
),
const SizedBox(width: 16),
// Ville
Expanded(
flex: 2,
child: CustomTextField(
controller: _villeController,
label: "Ville",
readOnly: widget.readOnly,
),
),
],
),
const SizedBox(height: 16),
// Région
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Région",
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
_buildRegionDropdown(restrictedFieldsReadOnly),
],
),
const SizedBox(height: 16),
// Contact
Text(
"Contact",
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
// Téléphone fixe et mobile sur la même ligne
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Téléphone fixe
Expanded(
child: CustomTextField(
controller: _phoneController,
label: "Téléphone fixe",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null &&
value.isNotEmpty &&
value.length < 10) {
return "Le numéro de téléphone doit contenir 10 chiffres";
}
return null;
},
),
),
const SizedBox(width: 16),
// Téléphone mobile
Expanded(
child: CustomTextField(
controller: _mobileController,
label: "Téléphone mobile",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null &&
value.isNotEmpty &&
value.length < 10) {
return "Le numéro de mobile doit contenir 10 chiffres";
}
return null;
},
),
),
],
),
const SizedBox(height: 16),
// Email
CustomTextField(
controller: _emailController,
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer l'adresse email";
}
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
}
return null;
},
),
const SizedBox(height: 16),
// Informations avancées (visibles uniquement pour les administrateurs)
if (canEditRestrictedFields ||
(_gpsLatController.text.isNotEmpty ||
_gpsLngController.text.isNotEmpty ||
_stripeIdController.text.isNotEmpty)) ...[
Text(
"Informations avancées",
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
// GPS Latitude et Longitude
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// GPS Latitude
Expanded(
child: CustomTextField(
controller: _gpsLatController,
label: "GPS Latitude",
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
readOnly: restrictedFieldsReadOnly,
),
),
const SizedBox(width: 16),
// GPS Longitude
Expanded(
child: CustomTextField(
controller: _gpsLngController,
label: "GPS Longitude",
keyboardType:
const TextInputType.numberWithOptions(decimal: true),
readOnly: restrictedFieldsReadOnly,
),
),
],
),
const SizedBox(height: 16),
// Stripe Checkbox et Stripe ID sur la même ligne
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Checkbox Stripe
Checkbox(
value: _chkStripe,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkStripe = value!;
});
},
activeColor: const Color(0xFF20335E),
),
Text(
"Stripe activé",
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 16),
// Stripe ID
Expanded(
child: CustomTextField(
controller: _stripeIdController,
label: "Stripe ID",
readOnly: restrictedFieldsReadOnly,
),
),
],
),
const SizedBox(height: 16),
],
// Options
Text(
"Options",
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
// Checkbox Demo
_buildCheckboxOption(
label: "Mode démo",
value: _chkDemo,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkDemo = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Copie Mail Reçu
_buildCheckboxOption(
label: "Copie des mails reçus",
value: _chkCopieMailRecu,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkCopieMailRecu = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Accept SMS
_buildCheckboxOption(
label: "Accepte les SMS",
value: _chkAcceptSms,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_chkAcceptSms = value!;
});
},
),
const SizedBox(height: 8),
// Checkbox Active
_buildCheckboxOption(
label: "Actif",
value: _chkActive,
onChanged: restrictedFieldsReadOnly
? null
: (value) {
setState(() {
_chkActive = value!;
});
},
),
const SizedBox(height: 25),
// Bouton Enregistrer
if (!widget.readOnly)
Center(
child: ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF20335E),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
minimumSize: const Size(200, 50),
),
child: const Text(
'Enregistrer',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
),
);
}
Widget _buildCheckboxOption({
required String label,
required bool value,
required Function(bool?)? onChanged,
}) {
final theme = Theme.of(context);
return Row(
children: [
Checkbox(
value: value,
onChanged: onChanged,
activeColor: const Color(0xFF20335E),
),
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
],
);
}
Widget _buildRegionDropdown(bool readOnly) {
final theme = Theme.of(context);
// Si en lecture seule, afficher simplement le texte
if (readOnly && _libRegion != null) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
_libRegion!,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground,
),
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFF4F5F6).withOpacity(0.85),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFF20335E).withOpacity(0.1),
width: 1,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: _fkRegion,
isExpanded: true,
hint: const Text("Sélectionnez une région"),
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF20335E),
),
style: theme.textTheme.bodyMedium?.copyWith(
color: const Color(0xFF20335E),
),
dropdownColor: Colors.white,
items: _regions
.map<DropdownMenuItem<int>>((Map<String, dynamic> region) {
return DropdownMenuItem<int>(
value: region['id'] as int,
child: Text(region['name'] as String),
);
}).toList(),
onChanged: readOnly
? null
: (int? newValue) {
setState(() {
_fkRegion = newValue;
// Trouver le libellé correspondant
if (newValue != null) {
final selectedRegion = _regions.firstWhere(
(region) => region['id'] == newValue,
orElse: () => {'id': newValue, 'name': ''},
);
_libRegion = selectedRegion['name'] as String;
} else {
_libRegion = null;
}
});
},
),
),
);
}
}

View File

@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:geosector_app/core/services/api_service.dart';
/// Widget qui affiche les informations sur l'environnement actuel
/// Utile pour le débogage
class EnvironmentInfoWidget extends StatelessWidget {
final bool showInDialog;
const EnvironmentInfoWidget({
Key? key,
this.showInDialog = false,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final apiService = Provider.of<ApiService>(context, listen: false);
final environment = apiService.getCurrentEnvironment();
final apiUrl = apiService.getCurrentApiUrl();
final appIdentifier = apiService.getCurrentAppIdentifier();
final content = Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'🌍 Environnement GeoSector',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: _getEnvironmentColor(environment)),
),
const SizedBox(height: 16),
_buildInfoRow(context, 'Environnement', environment),
const Divider(),
_buildInfoRow(context, 'URL API', apiUrl),
const Divider(),
_buildInfoRow(context, 'App Identifier', appIdentifier),
const SizedBox(height: 20),
if (!showInDialog)
Align(
alignment: Alignment.center,
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
),
],
),
);
if (showInDialog) {
return content;
}
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: content,
);
}
Widget _buildInfoRow(BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(fontWeight: FontWeight.bold),
),
),
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
);
}
Color _getEnvironmentColor(String environment) {
switch (environment) {
case 'DEV':
return Colors.green;
case 'REC':
return Colors.orange;
case 'PROD':
return Colors.red;
default:
return Colors.grey;
}
}
/// Méthode statique pour afficher les informations sur l'environnement
/// dans une boîte de dialogue
static void show(BuildContext context) {
showDialog(
context: context,
builder: (context) => const EnvironmentInfoWidget(),
);
}
}

View File

@@ -0,0 +1,234 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/presentation/widgets/amicale_table_widget.dart';
import 'package:provider/provider.dart';
/// Exemple d'utilisation du widget AmicaleTableWidget
///
/// Ce widget montre comment intégrer le tableau d'amicales dans une page
/// et comment gérer les actions d'édition et de suppression.
class AmicaleTableExample extends StatefulWidget {
const AmicaleTableExample({Key? key}) : super(key: key);
@override
State<AmicaleTableExample> createState() => _AmicaleTableExampleState();
}
class _AmicaleTableExampleState extends State<AmicaleTableExample> {
bool _isLoading = true;
List<AmicaleModel> _amicales = [];
String? _errorMessage;
@override
void initState() {
super.initState();
_loadAmicales();
}
Future<void> _loadAmicales() async {
setState(() {
_isLoading = true;
_errorMessage = null;
});
try {
// Récupérer les amicales depuis le repository
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
final amicales = amicaleRepository.getAllAmicales();
setState(() {
_amicales = amicales;
_isLoading = false;
});
} catch (e) {
setState(() {
_errorMessage = 'Erreur lors du chargement des amicales: $e';
_isLoading = false;
});
}
}
void _handleEdit(AmicaleModel amicale) {
// Afficher une boîte de dialogue de confirmation
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Modifier l\'amicale'),
content: Text('Voulez-vous modifier l\'amicale ${amicale.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
// Naviguer vers la page de modification
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => EditAmicalePage(amicale: amicale),
// ),
// );
},
child: const Text('Modifier'),
),
],
),
);
}
void _handleDelete(AmicaleModel amicale) {
// Afficher une boîte de dialogue de confirmation
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer l\'amicale'),
content: Text(
'Êtes-vous sûr de vouloir supprimer l\'amicale ${amicale.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
onPressed: () {
Navigator.of(context).pop();
_deleteAmicale(amicale);
},
child: const Text('Supprimer'),
),
],
),
);
}
Future<void> _deleteAmicale(AmicaleModel amicale) async {
try {
setState(() {
_isLoading = true;
});
// Supprimer l'amicale via le repository
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
await amicaleRepository.deleteAmicale(amicale.id);
// Recharger la liste
await _loadAmicales();
// Afficher un message de succès
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Amicale ${amicale.name} supprimée avec succès'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
setState(() {
_isLoading = false;
_errorMessage = 'Erreur lors de la suppression: $e';
});
// Afficher un message d'erreur
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $_errorMessage'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Liste des amicales'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadAmicales,
tooltip: 'Actualiser',
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre et description
Text(
'Gestion des amicales',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Consultez, modifiez ou supprimez les amicales selon vos droits d\'accès.',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 24),
// Message d'erreur si présent
if (_errorMessage != null)
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withOpacity(0.3)),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 12),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
),
),
],
),
),
// Tableau des amicales
Expanded(
child: AmicaleTableWidget(
amicales: _amicales,
isLoading: _isLoading,
onDelete: _handleDelete,
emptyMessage:
'Aucune amicale trouvée. Veuillez en créer une nouvelle.',
readOnly: false, // Permettre la modification dans la modale
),
),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
// Naviguer vers la page de création
// Navigator.of(context).push(
// MaterialPageRoute(
// builder: (context) => CreateAmicalePage(),
// ),
// );
},
tooltip: 'Ajouter une amicale',
child: const Icon(Icons.add),
),
);
}
}

View File

@@ -0,0 +1,195 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/entite_form.dart';
import 'package:provider/provider.dart';
/// Exemple d'utilisation du widget EntiteForm
///
/// Ce widget montre comment intégrer le formulaire d'entité dans une page
/// et comment gérer les événements de soumission.
class EntiteFormExample extends StatefulWidget {
final int? amicaleId;
final bool readOnly;
const EntiteFormExample({
Key? key,
this.amicaleId,
this.readOnly = false,
}) : super(key: key);
@override
State<EntiteFormExample> createState() => _EntiteFormExampleState();
}
class _EntiteFormExampleState extends State<EntiteFormExample> {
AmicaleModel? _amicale;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadAmicale();
}
Future<void> _loadAmicale() async {
setState(() {
_isLoading = true;
});
try {
if (widget.amicaleId != null) {
// Récupérer l'amicale depuis le repository
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
setState(() {
_amicale = amicale;
_isLoading = false;
});
} else {
// Création d'une nouvelle amicale
setState(() {
_amicale = null;
_isLoading = false;
});
}
} catch (e) {
debugPrint('Erreur lors du chargement de l\'amicale: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleSubmit(AmicaleModel amicale) async {
try {
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
// Sauvegarder l'amicale
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
// Afficher un message de confirmation
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
backgroundColor: Colors.green,
),
);
// Retourner à la page précédente
Navigator.of(context).pop();
}
} catch (e) {
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la sauvegarde: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
final userRepository = Provider.of<UserRepository>(context, listen: false);
final userRole = userRepository.getUserRole();
final bool canCreate = userRole >
1; // Seuls les utilisateurs avec rôle > 1 peuvent créer/modifier
return Scaffold(
appBar: AppBar(
title: Text(widget.amicaleId != null
? (widget.readOnly
? 'Détails de l\'amicale'
: 'Modifier l\'amicale')
: 'Nouvelle amicale'),
actions: [
if (!widget.readOnly && _amicale != null)
IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _showDeleteConfirmation(context),
tooltip: 'Supprimer',
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: !canCreate && _amicale == null
? const Center(
child: Text(
'Vous n\'avez pas les droits pour créer une amicale'),
)
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: EntiteForm(
amicale: _amicale,
onSubmit: widget.readOnly ? null : _handleSubmit,
readOnly: widget.readOnly,
),
),
);
}
void _showDeleteConfirmation(BuildContext context) {
if (_amicale == null) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmation de suppression'),
content: Text(
'Êtes-vous sûr de vouloir supprimer l\'amicale ${_amicale!.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
try {
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
await amicaleRepository.deleteAmicale(_amicale!.id);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Amicale ${_amicale!.name} supprimée'),
backgroundColor: Colors.green,
),
);
// Retourner à la page précédente
Navigator.of(context).pop();
}
} catch (e) {
debugPrint('Erreur lors de la suppression de l\'amicale: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la suppression: $e'),
backgroundColor: Colors.red,
),
);
}
}
},
child: const Text('Supprimer'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
}
}

View File

@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/repositories/region_repository.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/entite_form.dart';
import 'package:provider/provider.dart';
/// Exemple d'utilisation du widget EntiteForm avec le RegionRepository
///
/// Ce widget montre comment intégrer le formulaire d'entité dans une page
/// et comment utiliser le RegionRepository pour charger les régions.
class EntiteFormWithRegionsExample extends StatefulWidget {
final int? amicaleId;
final bool readOnly;
const EntiteFormWithRegionsExample({
Key? key,
this.amicaleId,
this.readOnly = false,
}) : super(key: key);
@override
State<EntiteFormWithRegionsExample> createState() =>
_EntiteFormWithRegionsExampleState();
}
class _EntiteFormWithRegionsExampleState
extends State<EntiteFormWithRegionsExample> {
AmicaleModel? _amicale;
bool _isLoading = true;
late RegionRepository _regionRepository;
@override
void initState() {
super.initState();
_regionRepository = RegionRepository();
_initRepositories();
}
Future<void> _initRepositories() async {
setState(() {
_isLoading = true;
});
try {
// Initialiser le repository des régions
await _regionRepository.init();
// Charger l'amicale si un ID est fourni
if (widget.amicaleId != null) {
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
final amicale = amicaleRepository.getAmicaleById(widget.amicaleId!);
setState(() {
_amicale = amicale;
_isLoading = false;
});
} else {
setState(() {
_isLoading = false;
});
}
} catch (e) {
debugPrint('Erreur lors de l\'initialisation: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleSubmit(AmicaleModel amicale) async {
try {
final amicaleRepository =
Provider.of<AmicaleRepository>(context, listen: false);
// Sauvegarder l'amicale
final savedAmicale = await amicaleRepository.saveAmicale(amicale);
// Afficher un message de confirmation
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Amicale ${savedAmicale.name} sauvegardée avec succès'),
backgroundColor: Colors.green,
),
);
// Retourner à la page précédente
Navigator.of(context).pop();
}
} catch (e) {
debugPrint('Erreur lors de la sauvegarde de l\'amicale: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la sauvegarde: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
// Fournir le RegionRepository pour qu'il soit accessible par le widget EntiteForm
ChangeNotifierProvider<RegionRepository>.value(
value: _regionRepository),
],
child: Scaffold(
appBar: AppBar(
title: Text(widget.amicaleId != null
? (widget.readOnly
? 'Détails de l\'amicale'
: 'Modifier l\'amicale')
: 'Nouvelle amicale'),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: EntiteForm(
amicale: _amicale,
onSubmit: widget.readOnly ? null : _handleSubmit,
readOnly: widget.readOnly,
),
),
),
);
}
}

View File

@@ -0,0 +1,167 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/presentation/widgets/membre_table_widget.dart';
import 'package:hive_flutter/hive_flutter.dart';
/// Exemple d'utilisation du widget MembreTableWidget
///
/// Ce widget montre comment intégrer le tableau de membres dans une page
/// et comment gérer les événements d'édition et de suppression.
class MembreTableExample extends StatefulWidget {
const MembreTableExample({Key? key}) : super(key: key);
@override
State<MembreTableExample> createState() => _MembreTableExampleState();
}
class _MembreTableExampleState extends State<MembreTableExample> {
List<MembreModel> _membres = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadMembres();
}
Future<void> _loadMembres() async {
setState(() {
_isLoading = true;
});
try {
// S'assurer que la boîte Hive est ouverte
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
}
// Récupérer les membres depuis la boîte Hive
final membresBox = Hive.box<MembreModel>(AppKeys.membresBoxName);
final membres = membresBox.values.toList();
setState(() {
_membres = membres;
_isLoading = false;
});
} catch (e) {
debugPrint('Erreur lors du chargement des membres: $e');
setState(() {
_isLoading = false;
});
}
}
void _handleEdit(MembreModel membre) {
// Exemple de gestion de l'événement d'édition
debugPrint(
'Édition du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
// Ici, vous pourriez ouvrir une boîte de dialogue ou naviguer vers une page d'édition
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Édition de membre'),
content: Text(
'Vous avez demandé à éditer le membre ${membre.firstName} ${membre.name}'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
);
}
void _handleDelete(MembreModel membre) {
// Exemple de gestion de l'événement de suppression
debugPrint(
'Suppression du membre: ${membre.firstName} ${membre.name} (ID: ${membre.id})');
// Demander confirmation avant de supprimer
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirmation de suppression'),
content: Text(
'Êtes-vous sûr de vouloir supprimer le membre ${membre.firstName} ${membre.name} ?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
TextButton(
onPressed: () async {
// Fermer la boîte de dialogue
Navigator.of(context).pop();
try {
// Supprimer le membre de la boîte Hive
final membresBox =
Hive.box<MembreModel>(AppKeys.membresBoxName);
await membresBox.delete(membre.id);
// Mettre à jour l'état
setState(() {
_membres = _membres.where((m) => m.id != membre.id).toList();
});
// Afficher un message de confirmation
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Membre ${membre.firstName} ${membre.name} supprimé'),
backgroundColor: Colors.green,
),
);
}
} catch (e) {
debugPrint('Erreur lors de la suppression du membre: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la suppression: $e'),
backgroundColor: Colors.red,
),
);
}
}
},
child: const Text('Supprimer'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Tableau des Membres'),
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadMembres,
tooltip: 'Rafraîchir',
),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: _isLoading
? const Center(child: CircularProgressIndicator())
: MembreTableWidget(
membres: _membres,
onEdit: _handleEdit,
onDelete: _handleDelete,
height:
null, // Utiliser null pour que le widget prenne toute la hauteur disponible
),
),
);
}
}

View File

@@ -0,0 +1,110 @@
import 'package:flutter/material.dart';
/// Widget d'aide commun pour toute l'application
/// Affiche une boîte de dialogue modale avec une aide contextuelle
/// basée sur la page courante
class HelpDialog extends StatelessWidget {
/// Nom de la page courante pour laquelle l'aide est demandée
final String currentPage;
const HelpDialog({
Key? key,
required this.currentPage,
}) : super(key: key);
/// Affiche la boîte de dialogue d'aide
static void show(BuildContext context, String currentPage) {
showDialog(
context: context,
builder: (context) => HelpDialog(currentPage: currentPage),
);
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final theme = Theme.of(context);
// Déterminer si nous sommes sur un appareil mobile ou un ordinateur de bureau
final isDesktop = size.width > 900;
// Calculer la largeur de la boîte de dialogue
// 90% de la largeur de l'écran pour les mobiles
// 50% de la largeur de l'écran pour les ordinateurs de bureau (max 600px)
final dialogWidth = isDesktop
? size.width * 0.5 > 600
? 600.0
: size.width * 0.5
: size.width * 0.9;
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
// Définir la largeur de la boîte de dialogue
child: Container(
width: dialogWidth,
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de l'aide avec le nom de la page courante
Row(
children: [
Icon(
Icons.help_outline,
color: theme.colorScheme.primary,
size: 28,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Aide - Page $currentPage',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
const Divider(height: 32),
// Contenu de l'aide (à personnaliser selon la page)
Text(
'Contenu d\'aide pour la page "$currentPage".',
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: 16),
Text(
'Cette section sera personnalisée avec des instructions spécifiques pour chaque page de l\'application.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 24),
// Bouton pour fermer la boîte de dialogue
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
child: const Text('Fermer'),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
/// Widget de dialogue pour informer l'utilisateur que les données locales ont été réinitialisées
/// en raison d'une incompatibilité après une mise à jour de l'application.
class HiveResetDialog extends StatelessWidget {
/// Callback appelé lorsque l'utilisateur ferme le dialogue
final VoidCallback? onClose;
const HiveResetDialog({
Key? key,
this.onClose,
}) : super(key: key);
/// Affiche le dialogue de réinitialisation Hive
static Future<void> show(BuildContext context,
{VoidCallback? onClose}) async {
return showDialog<void>(
context: context,
barrierDismissible:
false, // L'utilisateur doit appuyer sur un bouton pour fermer le dialogue
builder: (BuildContext dialogContext) {
return HiveResetDialog(onClose: onClose);
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: Row(
children: [
Icon(
Icons.sync_problem,
color: theme.colorScheme.error,
size: 28,
),
const SizedBox(width: 12),
Text(
'Données réinitialisées',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onSurface,
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Suite à une mise à jour de l\'application, vos données locales ont dû être réinitialisées pour assurer la compatibilité.',
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: 16),
Text(
'Que s\'est-il passé ?',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Une incompatibilité a été détectée entre la structure des données de la version précédente et celle de la version actuelle. Pour éviter tout problème, les données locales ont été effacées.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(
'Que dois-je faire ?',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Vous devez vous reconnecter à votre compte pour récupérer vos données depuis le serveur. Toutes vos données seront restaurées automatiquement après la connexion.',
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
Text(
'Note : Si vous aviez des modifications non synchronisées, elles ont été perdues. Nous vous recommandons de synchroniser régulièrement vos données.',
style: theme.textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
if (onClose != null) {
onClose!();
}
},
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
child: const Text('J\'ai compris'),
),
],
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:flutter/material.dart';
/// Widget d'overlay de chargement qui affiche un spinner centré avec un message optionnel
/// Utilisé pour les opérations longues comme la connexion, déconnexion et synchronisation
class LoadingOverlay extends StatelessWidget {
final String? message;
final Color backgroundColor;
final Color spinnerColor;
final Color textColor;
final double spinnerSize;
final double strokeWidth;
const LoadingOverlay({
Key? key,
this.message,
this.backgroundColor = Colors.black54,
this.spinnerColor = Colors.white,
this.textColor = Colors.white,
this.spinnerSize = 60.0,
this.strokeWidth = 5.0,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
color: backgroundColor,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: spinnerSize,
height: spinnerSize,
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(spinnerColor),
strokeWidth: strokeWidth,
),
),
if (message != null) ...[ // Afficher le texte seulement si message n'est pas null
const SizedBox(height: 24),
Text(
message!,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: textColor,
),
textAlign: TextAlign.center,
),
],
],
),
),
);
}
/// Méthode statique pour afficher l'overlay de chargement
static Future<T> show<T>({
required BuildContext context,
required Future<T> future,
String? message,
double spinnerSize = 60.0,
double strokeWidth = 5.0,
}) async {
// Afficher l'overlay
final overlayEntry = OverlayEntry(
builder: (context) => LoadingOverlay(
message: message,
spinnerSize: spinnerSize,
strokeWidth: strokeWidth,
),
);
Overlay.of(context).insert(overlayEntry);
try {
// Attendre que le future se termine
final result = await future;
// Supprimer l'overlay
overlayEntry.remove();
return result;
} catch (e) {
// En cas d'erreur, supprimer l'overlay et relancer l'erreur
overlayEntry.remove();
rethrow;
}
}
}

View File

@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'dart:ui';
/// Widget d'overlay de chargement amélioré qui affiche une barre de progression
/// avec un effet de flou sur l'arrière-plan et un message détaillé sur l'étape en cours
class LoadingProgressOverlay extends StatefulWidget {
final String? message;
final double progress;
final String? stepDescription;
final Color backgroundColor;
final Color progressColor;
final Color textColor;
final double blurAmount;
final bool showPercentage;
const LoadingProgressOverlay({
Key? key,
this.message,
required this.progress,
this.stepDescription,
this.backgroundColor = Colors.black54,
this.progressColor = Colors.white,
this.textColor = Colors.white,
this.blurAmount = 5.0,
this.showPercentage = true,
}) : super(key: key);
@override
State<LoadingProgressOverlay> createState() => _LoadingProgressOverlayState();
}
class _LoadingProgressOverlayState extends State<LoadingProgressOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _progressAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_progressAnimation = Tween<double>(begin: 0, end: widget.progress).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_animationController.forward();
}
@override
void didUpdateWidget(LoadingProgressOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.progress != widget.progress) {
_progressAnimation = Tween<double>(
begin: oldWidget.progress,
end: widget.progress,
).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
_animationController.reset();
_animationController.forward();
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BackdropFilter(
filter: ImageFilter.blur(
sigmaX: widget.blurAmount, sigmaY: widget.blurAmount),
child: Container(
color: widget.backgroundColor,
child: Center(
child: Container(
width: MediaQuery.of(context).size.width * 0.85,
padding: const EdgeInsets.all(30),
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.9),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black45,
blurRadius: 15,
spreadRadius: 5,
offset: const Offset(0, 5),
),
],
border: Border.all(
color: Colors.white.withOpacity(0.1),
width: 1.5,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.message != null) ...[
Text(
widget.message!,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: widget.textColor,
letterSpacing: 0.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
],
AnimatedBuilder(
animation: _progressAnimation,
builder: (context, child) {
return Column(
children: [
LinearProgressIndicator(
value: _progressAnimation.value,
backgroundColor:
widget.progressColor.withOpacity(0.3),
valueColor: AlwaysStoppedAnimation<Color>(
widget.progressColor),
minHeight: 15,
borderRadius: BorderRadius.circular(8),
),
if (widget.showPercentage) ...[
const SizedBox(height: 8),
Text(
'${(_progressAnimation.value * 100).toInt()}%',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: widget.textColor,
letterSpacing: 1.2,
),
),
],
],
);
},
),
if (widget.stepDescription != null) ...[
const SizedBox(height: 16),
Text(
widget.stepDescription!,
style: TextStyle(
fontSize: 16,
color: widget.textColor.withOpacity(0.9),
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
],
],
),
),
),
),
);
}
}
/// Classe utilitaire pour gérer l'overlay de chargement avec progression
class LoadingProgressOverlayUtils {
/// Méthode pour afficher l'overlay de chargement avec progression
static OverlayEntry show({
required BuildContext context,
String? message,
double progress = 0.0,
String? stepDescription,
double blurAmount = 5.0,
bool showPercentage = true,
}) {
final overlayEntry = OverlayEntry(
builder: (context) => LoadingProgressOverlay(
message: message,
progress: progress,
stepDescription: stepDescription,
blurAmount: blurAmount,
showPercentage: showPercentage,
),
);
Overlay.of(context).insert(overlayEntry);
return overlayEntry;
}
/// Méthode pour mettre à jour l'overlay existant
static void update({
required OverlayEntry overlayEntry,
String? message,
required double progress,
String? stepDescription,
}) {
overlayEntry.markNeedsBuild();
}
}

View File

@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/app.dart'; // Pour accéder à l'instance globale de ApiService
/// Widget de carte réutilisable utilisant Mapbox
///
/// Ce widget encapsule un FlutterMap avec des tuiles Mapbox et fournit
/// des fonctionnalités pour afficher des marqueurs, des polygones et des contrôles.
class MapboxMap extends StatefulWidget {
/// Position initiale de la carte
final LatLng initialPosition;
/// Niveau de zoom initial
final double initialZoom;
/// Liste des marqueurs à afficher
final List<Marker>? markers;
/// Liste des polygones à afficher
final List<Polygon>? polygons;
/// Contrôleur de carte externe (optionnel)
final MapController? mapController;
/// Callback appelé lorsque la carte est déplacée
final void Function(MapEvent)? onMapEvent;
/// Afficher les boutons de contrôle (zoom, localisation)
final bool showControls;
/// Style de la carte Mapbox (optionnel)
/// Si non spécifié, utilise le style par défaut 'mapbox/streets-v12'
final String? mapStyle;
const MapboxMap({
super.key,
this.initialPosition = const LatLng(48.1173, -1.6778), // Rennes par défaut
this.initialZoom = 13.0,
this.markers,
this.polygons,
this.mapController,
this.onMapEvent,
this.showControls = true,
this.mapStyle,
});
@override
State<MapboxMap> createState() => _MapboxMapState();
}
class _MapboxMapState extends State<MapboxMap> {
/// Contrôleur de carte interne
late final MapController _mapController;
/// Niveau de zoom actuel
double _currentZoom = 13.0;
@override
void initState() {
super.initState();
_mapController = widget.mapController ?? MapController();
_currentZoom = widget.initialZoom;
}
@override
void dispose() {
// Ne pas disposer le contrôleur s'il a été fourni de l'extérieur
if (widget.mapController == null) {
_mapController.dispose();
}
super.dispose();
}
/// Construit un bouton de contrôle de carte
Widget _buildMapButton({
required IconData icon,
required VoidCallback onPressed,
}) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: IconButton(
icon: Icon(icon, size: 20),
onPressed: onPressed,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
);
}
@override
Widget build(BuildContext context) {
// Déterminer l'URL du template de tuiles Mapbox
// Utiliser l'environnement actuel pour obtenir la bonne clé API
final String environment = apiService.getCurrentEnvironment();
final String mapboxToken = AppKeys.getMapboxApiKey(environment);
final String mapStyle = widget.mapStyle ?? 'mapbox/streets-v11';
final String urlTemplate =
'https://api.mapbox.com/styles/v1/$mapStyle/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
return Stack(
children: [
// Carte principale
FlutterMap(
mapController: _mapController,
options: MapOptions(
initialCenter: widget.initialPosition,
initialZoom: widget.initialZoom,
onMapEvent: (event) {
if (event is MapEventMove) {
setState(() {
// Dans flutter_map 8.1.1, nous devons utiliser le contrôleur pour obtenir le zoom actuel
_currentZoom = _mapController.camera.zoom;
});
}
// Appeler le callback externe si fourni
if (widget.onMapEvent != null) {
widget.onMapEvent!(event);
}
},
),
children: [
// Tuiles de la carte (Mapbox)
TileLayer(
urlTemplate: urlTemplate,
userAgentPackageName: 'app.geosector.fr',
maxNativeZoom: 19,
additionalOptions: {
'accessToken': mapboxToken,
},
),
// Polygones
if (widget.polygons != null && widget.polygons!.isNotEmpty)
PolygonLayer(polygons: widget.polygons!),
// Marqueurs
if (widget.markers != null && widget.markers!.isNotEmpty)
MarkerLayer(markers: widget.markers!),
],
),
// Boutons de contrôle
if (widget.showControls)
Positioned(
bottom: 16,
right: 16,
child: Column(
children: [
// Bouton de zoom +
_buildMapButton(
icon: Icons.add,
onPressed: () {
_mapController.move(
_mapController.camera.center,
_mapController.camera.zoom + 1,
);
},
),
const SizedBox(height: 8),
// Bouton de zoom -
_buildMapButton(
icon: Icons.remove,
onPressed: () {
_mapController.move(
_mapController.camera.center,
_mapController.camera.zoom - 1,
);
},
),
const SizedBox(height: 8),
// Bouton de localisation
_buildMapButton(
icon: Icons.my_location,
onPressed: () {
_mapController.move(
widget.initialPosition,
15,
);
},
),
],
),
),
],
);
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
class MembreRowWidget extends StatelessWidget {
final MembreModel membre;
final Function()? onEdit;
final Function()? onDelete;
const MembreRowWidget({
Key? key,
required this.membre,
this.onEdit,
this.onDelete,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.0),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// ID
Expanded(
flex: 1,
child: Text(
membre.id.toString(),
style: theme.textTheme.bodyMedium,
),
),
// Prénom (firstName)
Expanded(
flex: 2,
child: Text(
membre.firstName,
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
// Nom (name)
Expanded(
flex: 2,
child: Text(
membre.name,
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
// Secteur (sectName)
Expanded(
flex: 2,
child: Text(
membre.sectName ?? '',
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
// Rôle (fkRole)
Expanded(
flex: 1,
child: Text(
_getRoleName(membre.fkRole),
style: theme.textTheme.bodyMedium,
),
),
// Actions
Expanded(
flex: 2,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// Bouton Edit
IconButton(
icon: const Icon(Icons.edit, size: 20),
color: theme.colorScheme.primary,
onPressed: onEdit,
tooltip: 'Modifier',
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(8),
),
// Bouton Delete
IconButton(
icon: const Icon(Icons.delete, size: 20),
color: theme.colorScheme.error,
onPressed: onDelete,
tooltip: 'Supprimer',
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(8),
),
],
),
),
],
),
);
}
// Méthode pour convertir l'ID de rôle en nom lisible
String _getRoleName(int roleId) {
switch (roleId) {
case 1:
return 'User';
case 2:
return 'Admin';
case 3:
return 'Super';
default:
return roleId.toString();
}
}
}

View File

@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/presentation/widgets/membre_row_widget.dart';
class MembreTableWidget extends StatelessWidget {
final List<MembreModel> membres;
final Function(MembreModel)? onEdit;
final Function(MembreModel)? onDelete;
final bool showHeader;
final double? height;
final EdgeInsetsGeometry? padding;
const MembreTableWidget({
Key? key,
required this.membres,
this.onEdit,
this.onDelete,
this.showHeader = true,
this.height,
this.padding,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: height,
padding: padding ?? const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8.0),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête du tableau
if (showHeader)
Padding(
padding:
const EdgeInsets.only(bottom: 16.0, left: 16.0, right: 16.0),
child: Row(
children: [
// ID
Expanded(
flex: 1,
child: Text(
'ID',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Prénom (firstName)
Expanded(
flex: 2,
child: Text(
'Prénom',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Nom (name)
Expanded(
flex: 2,
child: Text(
'Nom',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Secteur (sectName)
Expanded(
flex: 2,
child: Text(
'Secteur',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Rôle (fkRole)
Expanded(
flex: 1,
child: Text(
'Rôle',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
// Actions
Expanded(
flex: 2,
child: Text(
'Actions',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.end,
),
),
],
),
),
// Liste des membres
Expanded(
child: membres.isEmpty
? Center(
child: Text(
'Aucun membre disponible',
style: theme.textTheme.bodyMedium,
),
)
: ListView.separated(
itemCount: membres.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 8.0),
itemBuilder: (context, index) {
final membre = membres[index];
return MembreRowWidget(
membre: membre,
onEdit: onEdit != null ? () => onEdit!(membre) : null,
onDelete:
onDelete != null ? () => onDelete!(membre) : null,
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,422 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'custom_text_field.dart';
class PassageForm extends StatefulWidget {
final Function(Map<String, dynamic>)? onSubmit;
final Map<String, dynamic>? initialData;
const PassageForm({
Key? key,
this.onSubmit,
this.initialData,
}) : super(key: key);
@override
State<PassageForm> createState() => _PassageFormState();
}
class _PassageFormState extends State<PassageForm> {
final _formKey = GlobalKey<FormState>();
// Controllers
late final TextEditingController _villeController;
late final TextEditingController _adresseController;
late final TextEditingController _nomHabitantController;
late final TextEditingController _emailController;
late final TextEditingController _montantController;
late final TextEditingController _commentairesController;
// Form values
String _typeHabitat = 'Individuel';
String _typeReglement = 'Espèces';
@override
void initState() {
super.initState();
// Initialize controllers with initial data if available
final data = widget.initialData ?? {};
_villeController = TextEditingController(text: data['ville'] ?? '');
_adresseController = TextEditingController(text: data['adresse'] ?? '');
_nomHabitantController =
TextEditingController(text: data['nomHabitant'] ?? '');
_emailController = TextEditingController(text: data['email'] ?? '');
_montantController = TextEditingController(text: data['montant'] ?? '');
_commentairesController =
TextEditingController(text: data['commentaires'] ?? '');
_typeHabitat = data['typeHabitat'] ?? 'Individuel';
_typeReglement = data['typeReglement'] ?? 'Espèces';
}
@override
void dispose() {
_villeController.dispose();
_adresseController.dispose();
_nomHabitantController.dispose();
_emailController.dispose();
_montantController.dispose();
_commentairesController.dispose();
super.dispose();
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
final formData = {
'ville': _villeController.text,
'adresse': _adresseController.text,
'typeHabitat': _typeHabitat,
'nomHabitant': _nomHabitantController.text,
'email': _emailController.text,
'montant': _montantController.text,
'typeReglement': _typeReglement,
'commentaires': _commentairesController.text,
};
if (widget.onSubmit != null) {
widget.onSubmit!(formData);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Ville
CustomTextField(
controller: _villeController,
label: 'Ville',
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer une ville';
}
return null;
},
),
const SizedBox(height: 16),
// Adresse
CustomTextField(
controller: _adresseController,
label: 'Adresse',
validator: (value) {
if (value == null || value.isEmpty) {
return 'Veuillez entrer une adresse';
}
return null;
},
),
const SizedBox(height: 16),
// Type d'habitat
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Type d'habitat",
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
Row(
children: [
_buildRadioOption(
value: 'Individuel',
groupValue: _typeHabitat,
onChanged: (value) {
setState(() {
_typeHabitat = value!;
});
},
),
const SizedBox(width: 40),
_buildRadioOption(
value: 'Collectif',
groupValue: _typeHabitat,
onChanged: (value) {
setState(() {
_typeHabitat = value!;
});
},
),
],
),
],
),
const SizedBox(height: 16),
// Nom de l'habitant
CustomTextField(
controller: _nomHabitantController,
label: "Nom de l'habitant",
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom de l'habitant";
}
return null;
},
),
const SizedBox(height: 16),
// Email de l'habitant
CustomTextField(
controller: _emailController,
label: "Adresse email de l'habitant",
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.isEmpty) {
return null; // Email optionnel
}
// Simple email validation
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
}
return null;
},
),
const SizedBox(height: 16),
// Montant et Type de règlement
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Montant reçu
Expanded(
flex: 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Montant reçu",
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _montantController,
keyboardType:
TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d+\.?\d{0,2}')),
],
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onBackground,
),
decoration: InputDecoration(
hintText: '0.00 €',
hintStyle: theme.textTheme.bodyLarge?.copyWith(
color:
theme.colorScheme.onBackground.withOpacity(0.5),
),
fillColor: const Color(0xFFF4F5F6),
filled: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color:
theme.colorScheme.onBackground.withOpacity(0.1),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color:
theme.colorScheme.onBackground.withOpacity(0.1),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Requis';
}
return null;
},
),
],
),
),
const SizedBox(width: 20),
// Type de règlement
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Type de règlement",
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
_buildDropdown(),
],
),
),
],
),
const SizedBox(height: 16),
// Commentaires
CustomTextField(
controller: _commentairesController,
label: "Commentaires",
hintText: "Placeholder",
maxLines: 3,
),
const SizedBox(height: 25),
// Titre de section
Text(
"Mise à jour du passage effectué",
style: theme.textTheme.bodyLarge?.copyWith(
color: const Color(0xFF20335E),
),
),
const SizedBox(height: 16),
// Bouton Enregistrer
Center(
child: ElevatedButton(
onPressed: _submitForm,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF20335E),
foregroundColor: Colors.white,
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
minimumSize: const Size(200, 50),
),
child: const Text(
'Enregistrer',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
Widget _buildRadioOption({
required String value,
required String groupValue,
required Function(String?) onChanged,
}) {
final theme = Theme.of(context);
final isSelected = value == groupValue;
return Row(
children: [
Radio<String>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
activeColor: const Color(0xFF20335E),
),
Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
],
);
}
Widget _buildDropdown() {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFF4F5F6).withOpacity(0.85),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFF20335E).withOpacity(0.1),
width: 1,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _typeReglement,
isExpanded: true,
icon: const Icon(
Icons.keyboard_arrow_down,
color: Color(0xFF20335E),
),
style: theme.textTheme.bodyMedium?.copyWith(
color: const Color(0xFF20335E),
),
dropdownColor: Colors.white,
items: <String>['Espèces', 'CB', 'Chèque']
.map<DropdownMenuItem<String>>((String value) {
return DropdownMenuItem<String>(
value: value,
child: Row(
children: [
_getPaymentIcon(value),
const SizedBox(width: 8),
Text(value),
],
),
);
}).toList(),
onChanged: (String? newValue) {
setState(() {
_typeReglement = newValue!;
});
},
),
),
);
}
Widget _getPaymentIcon(String type) {
switch (type) {
case 'Espèces':
return const Icon(Icons.payments_outlined,
color: Color(0xFF20335E), size: 20);
case 'CB':
return const Icon(Icons.credit_card,
color: Color(0xFF20335E), size: 20);
case 'Chèque':
return const Icon(Icons.account_balance_wallet_outlined,
color: Color(0xFF20335E), size: 20);
default:
return const SizedBox.shrink();
}
}
}

View File

@@ -0,0 +1,918 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
/// Un widget réutilisable pour afficher une liste de passages avec filtres
class PassagesListWidget extends StatefulWidget {
/// Liste des passages à afficher
final List<Map<String, dynamic>> passages;
/// Titre de la section (optionnel)
final String? title;
/// Nombre maximum de passages à afficher (optionnel)
final int? maxPassages;
/// Si vrai, les filtres seront affichés
final bool showFilters;
/// Si vrai, la barre de recherche sera affichée
final bool showSearch;
/// Si vrai, les boutons d'action (détails, modifier, etc.) seront affichés
final bool showActions;
/// Callback appelé lorsqu'un passage est sélectionné
final Function(Map<String, dynamic>)? onPassageSelected;
/// Callback appelé lorsqu'un passage est modifié
final Function(Map<String, dynamic>)? onPassageEdit;
/// Callback appelé lorsqu'un reçu est demandé
final Function(Map<String, dynamic>)? onReceiptView;
/// Callback appelé lorsque les détails sont demandés
final Function(Map<String, dynamic>)? onDetailsView;
/// Filtres initiaux (optionnels)
final String? initialTypeFilter;
final String? initialPaymentFilter;
final String? initialSearchQuery;
/// Filtres avancés (optionnels)
/// Liste des types de passages à exclure (ex: [2] pour exclure les passages "À finaliser")
final List<int>? excludePassageTypes;
/// ID de l'utilisateur pour filtrer les passages (null = tous les utilisateurs)
final int? filterByUserId;
/// ID du secteur pour filtrer les passages (null = tous les secteurs)
final int? filterBySectorId;
/// Période de filtrage pour la date passedAt
final String? periodFilter; // 'last15', 'lastWeek', 'lastMonth', 'custom'
/// Plage de dates personnalisée pour le filtrage (utilisé si periodFilter = 'custom')
final DateTimeRange? dateRange;
const PassagesListWidget({
super.key,
required this.passages,
this.title,
this.maxPassages,
this.showFilters = true,
this.showSearch = true,
this.showActions = true,
this.onPassageSelected,
this.onPassageEdit,
this.onReceiptView,
this.onDetailsView,
this.initialTypeFilter,
this.initialPaymentFilter,
this.initialSearchQuery,
this.excludePassageTypes,
this.filterByUserId,
this.filterBySectorId,
this.periodFilter,
this.dateRange,
});
@override
State<PassagesListWidget> createState() => _PassagesListWidgetState();
}
class _PassagesListWidgetState extends State<PassagesListWidget> {
// Filtres
late String _selectedTypeFilter;
late String _selectedPaymentFilter;
late String _searchQuery;
// Contrôleur de recherche
final TextEditingController _searchController = TextEditingController();
@override
void initState() {
super.initState();
// Initialiser les filtres
_selectedTypeFilter = widget.initialTypeFilter ?? 'Tous';
_selectedPaymentFilter = widget.initialPaymentFilter ?? 'Tous';
_searchQuery = widget.initialSearchQuery ?? '';
_searchController.text = _searchQuery;
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
// Liste filtrée avec gestion des erreurs
List<Map<String, dynamic>> get _filteredPassages {
try {
var filtered = widget.passages.where((passage) {
try {
// Vérification que le passage est valide
if (passage == null) {
return false;
}
// Exclure les types de passages spécifiés
if (widget.excludePassageTypes != null &&
passage.containsKey('type') &&
widget.excludePassageTypes!.contains(passage['type'])) {
return false;
}
// Filtrer par utilisateur
if (widget.filterByUserId != null &&
passage.containsKey('fkUser') &&
passage['fkUser'] != widget.filterByUserId) {
return false;
}
// Filtrer par secteur
if (widget.filterBySectorId != null &&
passage.containsKey('fkSector') &&
passage['fkSector'] != widget.filterBySectorId) {
return false;
}
// Filtre par type
if (_selectedTypeFilter != 'Tous') {
try {
final typeEntries = AppKeys.typesPassages.entries.where(
(entry) => entry.value['titre'] == _selectedTypeFilter);
if (typeEntries.isNotEmpty) {
final typeIndex = typeEntries.first.key;
if (!passage.containsKey('type') ||
passage['type'] != typeIndex) {
return false;
}
}
} catch (e) {
debugPrint('Erreur de filtrage par type: $e');
}
}
// Filtre par type de règlement
if (_selectedPaymentFilter != 'Tous') {
try {
final paymentEntries = AppKeys.typesReglements.entries.where(
(entry) => entry.value['titre'] == _selectedPaymentFilter);
if (paymentEntries.isNotEmpty) {
final paymentIndex = paymentEntries.first.key;
if (!passage.containsKey('payment') ||
passage['payment'] != paymentIndex) {
return false;
}
}
} catch (e) {
debugPrint('Erreur de filtrage par type de règlement: $e');
}
}
// Filtre par recherche
if (_searchQuery.isNotEmpty) {
try {
final query = _searchQuery.toLowerCase();
final address = passage.containsKey('address')
? passage['address']?.toString().toLowerCase() ?? ''
: '';
final name = passage.containsKey('name')
? passage['name']?.toString().toLowerCase() ?? ''
: '';
final notes = passage.containsKey('notes')
? passage['notes']?.toString().toLowerCase() ?? ''
: '';
return address.contains(query) ||
name.contains(query) ||
notes.contains(query);
} catch (e) {
debugPrint('Erreur de filtrage par recherche: $e');
return false;
}
}
return true;
} catch (e) {
debugPrint('Erreur lors du filtrage d\'un passage: $e');
return false;
}
}).toList();
// Trier les passages par date (les plus récents d'abord)
filtered.sort((a, b) {
if (a.containsKey('date') && b.containsKey('date')) {
final DateTime dateA = a['date'] as DateTime;
final DateTime dateB = b['date'] as DateTime;
return dateB.compareTo(dateA); // Ordre décroissant
}
return 0;
});
// Limiter le nombre de passages si maxPassages est défini
if (widget.maxPassages != null && filtered.length > widget.maxPassages!) {
filtered = filtered.sublist(0, widget.maxPassages!);
}
return filtered;
} catch (e) {
debugPrint('Erreur critique dans _filteredPassages: $e');
return [];
}
}
// Vérifier si un passage appartient à l'utilisateur courant
bool _isPassageOwnedByCurrentUser(Map<String, dynamic> passage) {
// Utiliser directement le champ isOwnedByCurrentUser s'il existe
if (passage.containsKey('isOwnedByCurrentUser')) {
return passage['isOwnedByCurrentUser'] == true;
}
// Sinon, vérifier si le passage appartient à l'utilisateur filtré
if (widget.filterByUserId != null && passage.containsKey('fkUser')) {
return passage['fkUser'].toString() == widget.filterByUserId.toString();
}
// Par défaut, considérer que le passage n'appartient pas à l'utilisateur courant
return false;
}
// Widget pour construire la ligne d'informations du passage (date, nom, montant, règlement)
Widget _buildPassageInfoRow(Map<String, dynamic> passage, ThemeData theme,
DateFormat dateFormat, Map<String, dynamic> typeReglement) {
try {
final bool hasName = passage.containsKey('name') &&
(passage['name'] as String?).toString().isNotEmpty;
final double amount =
passage.containsKey('amount') ? passage['amount'] as double : 0.0;
final bool hasValidAmount = amount > 0;
final bool isTypeEffectue = passage.containsKey('type') &&
passage['type'] == 1; // Type 1 = Effectué
final bool isOwnedByCurrentUser = _isPassageOwnedByCurrentUser(passage);
// Déterminer si nous sommes dans une page admin (pas de filterByUserId)
final bool isAdminPage = widget.filterByUserId == null;
// Dans les pages admin, tous les passages sont affichés normalement
// Dans les pages user, seuls les passages de l'utilisateur courant sont affichés normalement
final bool shouldGreyOut = !isAdminPage && !isOwnedByCurrentUser;
// Définir des styles différents en fonction du propriétaire du passage et du type de page
final TextStyle? baseTextStyle = shouldGreyOut
? theme.textTheme.bodyMedium
?.copyWith(color: theme.colorScheme.onSurface.withOpacity(0.5))
: theme.textTheme.bodyMedium;
return Row(
children: [
// Partie gauche: Date et informations
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Date (toujours affichée)
Row(
children: [
Icon(
Icons.calendar_today,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
passage.containsKey('date')
? dateFormat.format(passage['date'] as DateTime)
: 'Date non disponible',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
),
],
),
const SizedBox(height: 4),
// Ligne avec nom, montant et type de règlement
Row(
children: [
// Nom (si connu)
if (hasName)
Flexible(
child: Text(
passage['name'] as String,
style: baseTextStyle,
overflow: TextOverflow.ellipsis,
),
),
// Montant et type de règlement (si montant > 0)
if (hasValidAmount) ...[
const SizedBox(width: 8),
Icon(
Icons.euro,
size: 16,
color: theme.colorScheme.onSurface.withOpacity(0.6),
),
const SizedBox(width: 4),
Text(
'${passage['amount']}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.6),
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
// Type de règlement
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Color(typeReglement['couleur'] as int)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
typeReglement['titre'] as String,
style: TextStyle(
color: Color(typeReglement['couleur'] as int),
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
],
),
],
),
),
// Partie droite: Boutons d'action
if (widget.showActions) ...[
// Bouton Reçu (pour les passages de type 1 - Effectué)
// Dans la page admin, afficher pour tous les passages
// Dans la page user, uniquement pour les passages de l'utilisateur courant
if (isTypeEffectue &&
widget.onReceiptView != null &&
(isAdminPage || isOwnedByCurrentUser))
IconButton(
icon: const Icon(Icons.picture_as_pdf, color: Colors.green),
tooltip: 'Reçu',
onPressed: () => widget.onReceiptView!(passage),
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(8.0),
iconSize: 20,
),
// Bouton Modifier
// Dans la page admin, afficher pour tous les passages
// Dans la page user, uniquement pour les passages de l'utilisateur courant
if (widget.onPassageEdit != null &&
(isAdminPage || isOwnedByCurrentUser))
IconButton(
icon: const Icon(Icons.edit, color: Colors.blue),
tooltip: 'Modifier',
onPressed: () => widget.onPassageEdit!(passage),
constraints: const BoxConstraints(),
padding: const EdgeInsets.all(8.0),
iconSize: 20,
),
],
],
);
} catch (e) {
debugPrint(
'Erreur lors de la construction de la ligne d\'informations du passage: $e');
return const SizedBox();
}
}
// Construction d'une carte pour un passage
Widget _buildPassageCard(
Map<String, dynamic> passage, ThemeData theme, bool isDesktop) {
try {
// Vérification des données et valeurs par défaut
final int type = passage.containsKey('type') ? passage['type'] as int : 0;
final Map<String, dynamic> typePassage =
AppKeys.typesPassages[type] ?? AppKeys.typesPassages[1]!;
final int paymentType =
passage.containsKey('payment') ? passage['payment'] as int : 0;
final Map<String, dynamic> typeReglement =
AppKeys.typesReglements[paymentType] ?? AppKeys.typesReglements[0]!;
final DateFormat dateFormat = DateFormat('dd/MM/yyyy HH:mm');
final bool isOwnedByCurrentUser = _isPassageOwnedByCurrentUser(passage);
// Déterminer si nous sommes dans une page admin (pas de filterByUserId)
final bool isAdminPage = widget.filterByUserId == null;
// Dans les pages admin, tous les passages sont affichés normalement
// Dans les pages user, seuls les passages de l'utilisateur courant sont affichés normalement
final bool shouldGreyOut = !isAdminPage && !isOwnedByCurrentUser;
final bool isClickable = isAdminPage || isOwnedByCurrentUser;
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
// Appliquer une couleur grisée uniquement dans les pages user et si le passage n'appartient pas à l'utilisateur courant
color: shouldGreyOut
? theme.colorScheme.surface.withOpacity(0.7)
: theme.colorScheme.surface,
child: InkWell(
// Rendre le passage cliquable uniquement s'il appartient à l'utilisateur courant
// ou si nous sommes dans la page admin
onTap: isClickable && widget.onPassageSelected != null
? () => widget.onPassageSelected!(passage)
: null,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Icône du type de passage
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Color(typePassage['couleur1'] as int)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
typePassage['icon_data'] as IconData,
color: Color(typePassage['couleur1'] as int),
),
),
const SizedBox(width: 10),
// Informations principales
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
passage['address'] as String,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Color(typePassage['couleur1'] as int)
.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
typePassage['titre'] as String,
style: TextStyle(
color:
Color(typePassage['couleur1'] as int),
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
],
),
const SizedBox(height: 2),
// Utilisation du widget de ligne d'informations pour tous les types de passages
_buildPassageInfoRow(
passage, theme, dateFormat, typeReglement),
],
),
),
],
),
if (passage['notes'] != null &&
passage['notes'].toString().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 6.0),
child: Text(
'Notes: ${passage['notes']}',
style: theme.textTheme.bodyMedium?.copyWith(
fontStyle: FontStyle.italic,
color: theme.colorScheme.onSurface.withOpacity(0.7),
),
),
),
// Indicateur d'erreur (si présent)
if (passage['hasError'] == true)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
Icon(
Icons.error_outline,
color: Colors.red,
size: 16,
),
const SizedBox(width: 4),
Text(
'Erreur détectée',
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.red,
),
),
],
),
),
],
),
),
),
);
} catch (e) {
debugPrint('Erreur lors de la construction de la carte de passage: $e');
return const SizedBox();
}
}
// Construction d'un filtre déroulant (version standard)
Widget _buildDropdownFilter(
String label,
String selectedValue,
List<String> options,
Function(String) onChanged,
ThemeData theme,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
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: selectedValue,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
items: options.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (String? value) {
if (value != null) {
onChanged(value);
}
},
),
),
),
],
);
}
// Construction d'un filtre déroulant (version compacte)
Widget _buildCompactDropdownFilter(
String label,
String selectedValue,
List<String> options,
Function(String) onChanged,
ThemeData theme,
) {
return Row(
children: [
Text(
'$label:',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
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: selectedValue,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
items: options.map((String value) {
return DropdownMenuItem<String>(
value: value,
child: Text(
value,
style: theme.textTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (String? value) {
if (value != null) {
onChanged(value);
}
},
),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre (si fourni)
if (widget.title != null)
Padding(
padding: const EdgeInsets.all(10.0),
child: Text(
widget.title!,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.primary,
),
),
),
// Filtres (si activés)
if (widget.showFilters) _buildFilters(theme, isDesktop),
// Liste des passages dans une card de hauteur fixe avec défilement
Container(
height: 600, // Hauteur fixe de 600px
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: theme.shadowColor.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: _filteredPassages.isEmpty
? Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search_off,
size: 64,
color: theme.colorScheme.onSurface.withOpacity(0.3),
),
const SizedBox(height: 16),
Text(
'Aucun passage trouvé',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
const SizedBox(height: 8),
Text(
'Essayez de modifier vos filtres de recherche',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
],
),
),
)
: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: _filteredPassages.length,
itemBuilder: (context, index) {
final passage = _filteredPassages[index];
return _buildPassageCard(passage, theme, isDesktop);
},
),
),
],
);
}
// Construction du panneau de filtres
Widget _buildFilters(ThemeData theme, bool isDesktop) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
color: theme.colorScheme.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (isDesktop)
// Version compacte pour le web (desktop)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barre de recherche (si activée)
if (widget.showSearch)
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher par adresse ou nom...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: theme.colorScheme.outline,
width: 1.0,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 14.0),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
),
),
// Filtre par type de passage
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: _buildCompactDropdownFilter(
'Type',
_selectedTypeFilter,
[
'Tous',
...AppKeys.typesPassages.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedTypeFilter = value;
});
},
theme,
),
),
),
// Filtre par type de règlement
Expanded(
child: _buildCompactDropdownFilter(
'Règlement',
_selectedPaymentFilter,
[
'Tous',
...AppKeys.typesReglements.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedPaymentFilter = value;
});
},
theme,
),
),
],
),
)
else
// Version mobile (non-desktop)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Barre de recherche (si activée)
if (widget.showSearch)
Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher par adresse ou nom...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() {
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(
color: theme.colorScheme.outline,
width: 1.0,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 14.0),
),
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
),
),
// Filtres
Row(
children: [
// Filtre par type de passage
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: _buildDropdownFilter(
'Type',
_selectedTypeFilter,
[
'Tous',
...AppKeys.typesPassages.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedTypeFilter = value;
});
},
theme,
),
),
),
// Filtre par type de règlement
Expanded(
child: _buildDropdownFilter(
'Règlement',
_selectedPaymentFilter,
[
'Tous',
...AppKeys.typesReglements.values
.map((type) => type['titre'] as String)
],
(value) {
setState(() {
_selectedPaymentFilter = value;
});
},
theme,
),
),
],
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/app.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/presentation/widgets/user_form.dart';
class ProfileDialog extends StatefulWidget {
final UserModel user;
const ProfileDialog({
Key? key,
required this.user,
}) : super(key: key);
/// Méthode statique pour afficher la boîte de dialogue
static Future<bool?> show(BuildContext context, UserModel user) {
return showDialog<bool>(
context: context,
builder: (context) => ProfileDialog(user: user),
);
}
@override
State<ProfileDialog> createState() => _ProfileDialogState();
}
class _ProfileDialogState extends State<ProfileDialog> {
final _formKey = GlobalKey<FormState>();
late UserModel _user;
bool _isLoading = false;
@override
void initState() {
super.initState();
_user = widget.user;
}
// Fonction pour capitaliser la première lettre de chaque mot
String _capitalizeFirstLetter(String text) {
if (text.isEmpty) return text;
return text.split(' ').map((word) {
if (word.isEmpty) return word;
return word[0].toUpperCase() + word.substring(1).toLowerCase();
}).join(' ');
}
// Fonction pour mettre en majuscule
String _toUpperCase(String text) {
return text.toUpperCase();
}
// Fonction pour valider et soumettre le formulaire
Future<void> _saveProfile(UserModel updatedUser) async {
// Validation supplémentaire
if (!_validateUser(updatedUser)) {
return;
}
setState(() {
_isLoading = true;
});
try {
// Formatage des données
final formattedUser = updatedUser.copyWith(
name: _toUpperCase(updatedUser.name ?? ''),
firstName: _capitalizeFirstLetter(updatedUser.firstName ?? ''),
);
// Sauvegarde de l'utilisateur
await userRepository.saveUser(formattedUser);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Profil mis à jour avec succès'),
backgroundColor: Colors.green,
),
);
Navigator.of(context).pop(true); // Fermer la modale avec succès
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la mise à jour du profil: $e'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
// Validation supplémentaire
bool _validateUser(UserModel user) {
// Vérifier que l'email est valide
if (user.email.isEmpty ||
!user.email.contains('@') ||
!user.email.contains('.')) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez entrer une adresse email valide'),
backgroundColor: Colors.red,
),
);
return false;
}
// Vérifier que le nom ou le sectName est renseigné
if ((user.name == null || user.name!.isEmpty) &&
(user.sectName == null || user.sectName!.isEmpty)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le nom ou le nom du secteur doit être renseigné'),
backgroundColor: Colors.red,
),
);
return false;
}
// Vérifier que le téléphone fixe est valide s'il est renseigné
if (user.phone != null &&
user.phone!.isNotEmpty &&
user.phone!.length != 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Le numéro de téléphone fixe doit contenir 10 chiffres'),
backgroundColor: Colors.red,
),
);
return false;
}
// Vérifier que le téléphone mobile est valide s'il est renseigné
if (user.mobile != null &&
user.mobile!.isNotEmpty &&
user.mobile!.length != 10) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Le numéro de téléphone mobile doit contenir 10 chiffres'),
backgroundColor: Colors.red,
),
);
return false;
}
return true;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
backgroundColor: Colors.transparent,
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
constraints: const BoxConstraints(
maxWidth: 600,
maxHeight: 700,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Mon compte',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
tooltip: 'Fermer',
),
],
),
const Divider(),
const SizedBox(height: 10),
// Formulaire
Expanded(
child: SingleChildScrollView(
child: UserForm(
user: _user,
onSubmit: _saveProfile,
),
),
),
// Boutons
const SizedBox(height: 20),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed:
_isLoading ? null : () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 12),
),
child: const Text('Fermer'),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: _isLoading
? null
: () {
// Appeler directement la méthode onSubmit du UserForm
// qui va déclencher la validation et la soumission
_saveProfile(_user);
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 12),
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Text('Enregistrer'),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,716 @@
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:go_router/go_router.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/help_dialog.dart';
import 'package:geosector_app/presentation/widgets/profile_dialog.dart';
/// Widget qui fournit une navigation responsive pour l'application.
/// Affiche une sidebar en mode desktop et une bottomBar en mode mobile.
class ResponsiveNavigation extends StatefulWidget {
/// Le contenu principal à afficher
final Widget body;
/// Le titre de la page
final String title;
/// L'index de la page sélectionnée
final int selectedIndex;
/// Callback appelé lorsqu'un élément de navigation est sélectionné
final Function(int) onDestinationSelected;
/// Liste des destinations de navigation
final List<NavigationDestination> destinations;
/// Actions supplémentaires à afficher dans l'AppBar
final List<Widget>? additionalActions;
/// Indique si le bouton "Nouveau passage" doit être affiché
final bool showNewPassageButton;
/// Callback appelé lorsque le bouton "Nouveau passage" est pressé
final VoidCallback? onNewPassagePressed;
/// Clé de la boîte Hive pour sauvegarder les paramètres
final String settingsBoxKey;
/// Clé pour sauvegarder l'état de la sidebar
final String sidebarStateKey;
/// Widgets à afficher en bas de la sidebar
final List<Widget>? sidebarBottomItems;
/// Indique si l'utilisateur est un administrateur
final bool isAdmin;
/// Indique si l'AppBar doit être affiché
final bool showAppBar;
const ResponsiveNavigation({
Key? key,
required this.body,
required this.title,
required this.selectedIndex,
required this.onDestinationSelected,
required this.destinations,
this.additionalActions,
this.showNewPassageButton = true,
this.onNewPassagePressed,
this.settingsBoxKey = AppKeys.settingsBoxName,
this.sidebarStateKey = 'isSidebarMinimized',
this.sidebarBottomItems,
this.isAdmin = false,
this.showAppBar = true,
}) : super(key: key);
@override
State<ResponsiveNavigation> createState() => _ResponsiveNavigationState();
}
class _ResponsiveNavigationState extends State<ResponsiveNavigation> {
/// État de la barre latérale (minimisée ou non)
bool _isSidebarMinimized = false;
/// Référence à la boîte Hive pour les paramètres
late Box _settingsBox;
@override
void initState() {
super.initState();
_initSettings();
}
/// Initialiser la boîte de paramètres et charger les préférences
Future<void> _initSettings() async {
try {
// Ouvrir la boîte de paramètres si elle n'est pas déjà ouverte
if (!Hive.isBoxOpen(widget.settingsBoxKey)) {
_settingsBox = await Hive.openBox(widget.settingsBoxKey);
} else {
_settingsBox = Hive.box(widget.settingsBoxKey);
}
// Charger l'état de la barre latérale
final sidebarState = _settingsBox.get(widget.sidebarStateKey);
if (sidebarState != null && sidebarState is bool) {
setState(() {
_isSidebarMinimized = sidebarState;
});
}
} catch (e) {
debugPrint('Erreur lors du chargement des paramètres: $e');
}
}
/// Sauvegarder l'état de la barre latérale
void _saveSettings() {
try {
// Sauvegarder l'état de la barre latérale
_settingsBox.put(widget.sidebarStateKey, _isSidebarMinimized);
} catch (e) {
debugPrint('Erreur lors de la sauvegarde des paramètres: $e');
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final isDesktop = size.width > 900;
return Scaffold(
appBar: widget.showAppBar
? AppBar(
title: Text(widget.title),
actions: _buildAppBarActions(context),
)
: null,
body: isDesktop ? _buildDesktopLayout() : _buildMobileLayout(),
bottomNavigationBar: (isDesktop) ? null : _buildBottomNavigationBar(),
);
}
/// Construction du layout pour les écrans de bureau (web)
Widget _buildDesktopLayout() {
return Row(
children: [
_buildSidebar(),
Expanded(
child: Container(
color: Colors
.transparent, // Fond transparent pour voir l'AdminBackground
child: widget.body,
),
),
],
);
}
/// Construction du layout pour les écrans mobiles
Widget _buildMobileLayout() {
return Container(
color: Colors.transparent, // Fond transparent pour voir l'AdminBackground
child: widget.body,
);
}
/// Construction des actions de l'AppBar
List<Widget> _buildAppBarActions(BuildContext context) {
List<Widget> actions = [];
// Ajouter les actions supplémentaires si elles existent
if (widget.additionalActions != null &&
widget.additionalActions!.isNotEmpty) {
actions.addAll(widget.additionalActions!);
} else if (widget.showNewPassageButton && widget.selectedIndex == 0) {
// Ajouter le bouton "Nouveau passage" en haut à droite pour la page d'accueil
actions.add(
TextButton.icon(
icon: const Icon(Icons.add_location_alt, color: Colors.white),
label: const Text('Nouveau passage',
style: TextStyle(color: Colors.white)),
onPressed: widget.onNewPassagePressed ??
() {
// Fonction par défaut si onNewPassagePressed n'est pas fourni
_showPassageForm(context);
},
style: TextButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.secondary,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
),
);
actions.add(const SizedBox(width: 16)); // Espacement à droite
}
return actions;
}
/// Construction de la barre de navigation inférieure pour mobile
Widget _buildBottomNavigationBar() {
final theme = Theme.of(context);
return NavigationBar(
selectedIndex: widget.selectedIndex,
onDestinationSelected: widget.onDestinationSelected,
backgroundColor: theme.colorScheme.surface,
elevation: 8,
destinations: widget.destinations,
);
}
/// Obtenir le nom complet de l'utilisateur (prénom + nom)
String _getFullUserName(BuildContext context) {
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
if (user == null) return 'Utilisateur';
String fullName = '';
// Ajouter le prénom si disponible
if (user.firstName != null && user.firstName!.isNotEmpty) {
fullName += user.firstName!;
}
// Ajouter le nom
if (user.name != null && user.name!.isNotEmpty) {
// Ajouter un espace si le prénom est déjà présent
if (fullName.isNotEmpty) {
fullName += ' ';
}
fullName += user.name!;
}
// Si aucun nom n'a été trouvé, utiliser 'Utilisateur' par défaut
return fullName.isEmpty ? 'Utilisateur' : fullName;
}
/// Obtenir les initiales du prénom et du nom de l'utilisateur
String _getUserInitials(BuildContext context) {
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
if (user == null) return 'U';
String initials = '';
// Ajouter l'initiale du prénom si disponible
if (user.firstName != null && user.firstName!.isNotEmpty) {
initials += user.firstName!.substring(0, 1).toUpperCase();
}
// Ajouter l'initiale du nom
if (user.name != null && user.name!.isNotEmpty) {
initials += user.name!.substring(0, 1).toUpperCase();
}
// Si aucune initiale n'a été trouvée, utiliser 'U' par défaut
return initials.isEmpty ? 'U' : initials;
}
/// Afficher le sectName entre parenthèses s'il existe
Widget _buildSectNameText(BuildContext context) {
final theme = Theme.of(context);
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
// Si l'utilisateur n'a pas de sectName ou s'il est vide, retourner un widget vide
if (user == null || user.sectName == null || user.sectName!.isEmpty) {
return const SizedBox.shrink();
}
// Sinon, afficher le sectName entre parenthèses
return Text(
'(${user.sectName})',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
);
}
/// Construction de la barre latérale pour la version web
Widget _buildSidebar() {
final theme = Theme.of(context);
return Card(
margin: EdgeInsets.zero,
elevation: 4,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
child: Container(
width: _isSidebarMinimized ? 70 : 250,
color: theme.colorScheme.surface,
child: Column(
children: [
// Bouton pour minimiser/maximiser la barre latérale
Align(
alignment: _isSidebarMinimized
? Alignment.center
: Alignment.centerRight,
child: Padding(
padding:
EdgeInsets.only(top: 8, right: _isSidebarMinimized ? 0 : 8),
child: IconButton(
icon: Icon(_isSidebarMinimized
? Icons.chevron_right
: Icons.chevron_left),
onPressed: () {
setState(() {
_isSidebarMinimized = !_isSidebarMinimized;
_saveSettings(); // Sauvegarder l'état de la barre latérale
});
},
tooltip: _isSidebarMinimized ? 'Développer' : 'Réduire',
),
),
),
const SizedBox(height: 8),
if (!_isSidebarMinimized)
CircleAvatar(
radius: 40,
backgroundColor: theme.colorScheme.primary,
child: Text(
_getUserInitials(context),
style: TextStyle(
fontSize: 28,
color: theme.colorScheme.onPrimary,
),
),
),
const SizedBox(height: 8),
if (!_isSidebarMinimized) ...[
Text(
_getFullUserName(context),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
// Afficher le sectName entre parenthèses s'il existe
_buildSectNameText(context),
Text(
userRepository.currentUser?.email ?? '',
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 24),
] else
const SizedBox(height: 8),
const Divider(),
// Éléments de navigation
for (int i = 0; i < widget.destinations.length; i++)
_buildNavItem(
i, widget.destinations[i].label, widget.destinations[i].icon),
const Spacer(),
const Divider(),
// Éléments du bas de la sidebar
if (widget.sidebarBottomItems != null && !_isSidebarMinimized)
...widget.sidebarBottomItems!,
// Éléments par défaut du bas de la sidebar
if (!_isSidebarMinimized)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Text(
'Paramètres',
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
_SettingsItem(
icon: Icons.person,
title: 'Mon compte',
subtitle: null,
isSidebarMinimized: _isSidebarMinimized,
onTap: () {
// Afficher la boîte de dialogue de profil avec l'utilisateur actuel
// Utiliser l'instance globale définie dans app.dart
final user = userRepository.currentUser;
if (user != null) {
// Passer l'objet utilisateur complet
ProfileDialog.show(context, user);
} else {
// Afficher un message d'erreur si l'utilisateur n'est pas trouvé
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Erreur: Utilisateur non trouvé'),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
},
),
// Option "Amicale & membres" - uniquement pour les administrateurs avec le rôle 2 et en version web
if (widget.isAdmin &&
userRepository.currentUser?.role == 2 &&
MediaQuery.of(context).size.width > 900)
_SettingsItem(
icon: Icons.people,
title: 'Amicale & membres',
isSidebarMinimized: _isSidebarMinimized,
onTap: () {
// Navigation vers le tableau de bord admin avec sélection de l'onglet "Amicale et membres"
context.go('/admin');
// Sélectionner l'onglet "Amicale et membres" (index 5)
// Nous devons sauvegarder cet index dans les paramètres pour que le tableau de bord
// puisse le récupérer et sélectionner le bon onglet
final settingsBox = Hive.box(AppKeys.settingsBoxName);
settingsBox.put('adminSelectedPageIndex', 5);
// Notifier l'utilisateur que la page est en cours de chargement
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content:
Text('Chargement de la page Amicale & membres...'),
duration: Duration(seconds: 2),
),
);
// Attendre un court instant pour permettre à la navigation de se terminer
Future.delayed(const Duration(milliseconds: 300), () {
// Forcer la sélection de l'onglet Amicale & membres
if (widget.isAdmin && widget.selectedIndex != 5) {
widget.onDestinationSelected(5);
}
});
},
),
// Option "Opérations" - uniquement pour les administrateurs et en version web
if (widget.isAdmin && MediaQuery.of(context).size.width > 900)
_SettingsItem(
icon: Icons.calendar_today,
title: 'Opérations',
isSidebarMinimized: _isSidebarMinimized,
onTap: () {
// Navigation vers le tableau de bord admin
context.go('/admin');
// Note: Pas de page spécifique pour le moment, juste un placeholder
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fonctionnalité à venir'),
duration: Duration(seconds: 2),
),
);
},
),
const SizedBox(height: 16),
_SettingsItem(
icon: Icons.help_outline,
title: 'Aide',
isSidebarMinimized: _isSidebarMinimized,
onTap: () {
// Afficher la boîte de dialogue d'aide avec le titre de la page courante
HelpDialog.show(context, widget.title);
},
),
],
),
),
);
}
/// Construction d'un élément de navigation pour la barre latérale
Widget _buildNavItem(int index, String title, Widget icon) {
final theme = Theme.of(context);
final isSelected = widget.selectedIndex == index;
final IconData? iconData = (icon is Icon) ? (icon as Icon).icon : null;
// Remplacer certains titres si l'interface est de type "user"
String displayTitle = title;
if (!widget.isAdmin) {
if (title == "Accueil") {
displayTitle = "Tableau de bord";
} else if (title == "Stats") {
displayTitle = "Statistiques";
}
}
if (_isSidebarMinimized) {
// Version minimisée - afficher uniquement l'icône
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Tooltip(
message: displayTitle,
child: InkWell(
onTap: () {
widget.onDestinationSelected(index);
},
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary.withOpacity(0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: iconData != null
? Icon(
iconData,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(0.6),
size: 24,
)
: icon,
),
),
),
);
} else {
// Version normale avec texte et icône
return ListTile(
leading: iconData != null
? Icon(
iconData,
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurface.withOpacity(0.6),
)
: icon,
title: Text(
displayTitle,
style: TextStyle(
color: isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurface,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
),
),
tileColor:
isSelected ? theme.colorScheme.primary.withOpacity(0.1) : null,
onTap: () {
widget.onDestinationSelected(index);
},
);
}
}
/// Affiche le formulaire de passage
void _showPassageForm(BuildContext context) {
final theme = Theme.of(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(
'Nouveau passage',
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
decoration: InputDecoration(
labelText: 'Adresse',
prefixIcon: const Icon(Icons.location_on),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 16),
DropdownButtonFormField<int>(
decoration: InputDecoration(
labelText: 'Type de passage',
prefixIcon: const Icon(Icons.category),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
items: const [
DropdownMenuItem(
value: 1,
child: Text('Effectué'),
),
DropdownMenuItem(
value: 2,
child: Text('À finaliser'),
),
DropdownMenuItem(
value: 3,
child: Text('Refusé'),
),
DropdownMenuItem(
value: 4,
child: Text('Don'),
),
DropdownMenuItem(
value: 5,
child: Text('Lot'),
),
DropdownMenuItem(
value: 6,
child: Text('Maison vide'),
),
],
onChanged: (value) {},
),
const SizedBox(height: 16),
TextField(
decoration: InputDecoration(
labelText: 'Commentaire',
prefixIcon: const Icon(Icons.comment),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
maxLines: 3,
),
],
),
),
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: Text(
'Annuler',
style: TextStyle(
color: theme.colorScheme.error,
),
),
),
ElevatedButton(
onPressed: () {
// Enregistrer le passage
Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Passage enregistré avec succès'),
backgroundColor: theme.colorScheme.primary,
),
);
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
),
child: const Text('Enregistrer'),
),
],
),
);
}
}
/// Widget pour les éléments de paramètres
class _SettingsItem extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final Widget? trailing;
final VoidCallback onTap;
final bool isSidebarMinimized;
const _SettingsItem({
required this.icon,
required this.title,
this.subtitle,
this.trailing,
required this.onTap,
required this.isSidebarMinimized,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (isSidebarMinimized) {
// Version minimisée - afficher uniquement l'icône
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Tooltip(
message: title,
child: InkWell(
onTap: onTap,
child: Container(
width: 50,
height: 50,
decoration: BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
color: theme.colorScheme.primary,
size: 24,
),
),
),
),
);
} else {
// Version normale avec texte et icône
return ListTile(
leading: Icon(
icon,
color: theme.colorScheme.primary,
),
title: Text(title),
subtitle: subtitle != null ? Text(subtitle!) : null,
trailing: trailing,
onTap: onTap,
);
}
}
}

View File

@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/shared/app_theme.dart';
class SectorDistributionCard extends StatefulWidget {
final String title;
final double? height;
final EdgeInsetsGeometry? padding;
final bool forceRefresh;
const SectorDistributionCard({
Key? key,
this.title = 'Répartition par secteur',
this.height,
this.padding,
this.forceRefresh = false,
}) : super(key: key);
@override
State<SectorDistributionCard> createState() => _SectorDistributionCardState();
}
class _SectorDistributionCardState extends State<SectorDistributionCard> {
List<Map<String, dynamic>> sectorStats = [];
bool isLoading = true;
@override
void initState() {
super.initState();
_loadSectorData();
}
@override
void didUpdateWidget(SectorDistributionCard oldWidget) {
super.didUpdateWidget(oldWidget);
// Recharger les données si forceRefresh est passé à true
if (widget.forceRefresh && !oldWidget.forceRefresh) {
_loadSectorData();
}
}
Future<void> _loadSectorData() async {
setState(() {
isLoading = true;
});
try {
// S'assurer que les boîtes Hive sont ouvertes
if (!Hive.isBoxOpen(AppKeys.sectorsBoxName)) {
await Hive.openBox<SectorModel>(AppKeys.sectorsBoxName);
}
if (!Hive.isBoxOpen(AppKeys.passagesBoxName)) {
await Hive.openBox<PassageModel>(AppKeys.passagesBoxName);
}
// Récupérer tous les secteurs
final sectorsBox = Hive.box<SectorModel>(AppKeys.sectorsBoxName);
final List<SectorModel> sectors = sectorsBox.values.toList();
// Récupérer tous les passages
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final List<PassageModel> passages = passagesBox.values.toList();
// Compter les passages par secteur (en excluant ceux où fkType==2)
final Map<int, int> sectorCounts = {};
for (final passage in passages) {
// Exclure les passages où fkType==2
if (passage.fkSector != null && passage.fkType != 2) {
sectorCounts[passage.fkSector!] =
(sectorCounts[passage.fkSector!] ?? 0) + 1;
}
}
// Préparer les données pour l'affichage
List<Map<String, dynamic>> stats = [];
for (final sector in sectors) {
final count = sectorCounts[sector.id] ?? 0;
if (count > 0) {
stats.add({
'name': sector.libelle,
'count': count,
'color': sector.color.isEmpty
? 0xFF4B77BE
: int.tryParse(sector.color.replaceAll('#', '0xFF')) ??
0xFF4B77BE,
});
}
}
setState(() {
sectorStats = stats;
isLoading = false;
});
} catch (e) {
debugPrint('Erreur lors du chargement des données de secteur: $e');
setState(() {
isLoading = false;
});
}
}
@override
Widget build(BuildContext context) {
return Container(
height: widget.height,
padding: widget.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(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
widget.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
if (isLoading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
IconButton(
icon: const Icon(Icons.refresh, size: 20),
onPressed: _loadSectorData,
tooltip: 'Rafraîchir',
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
const SizedBox(height: AppTheme.spacingM),
Expanded(
child: isLoading
? const Center(child: CircularProgressIndicator())
: sectorStats.isEmpty
? const Center(
child: Text('Aucune donnée de secteur disponible'))
: ListView.builder(
itemCount: sectorStats.length,
itemBuilder: (context, index) {
final sector = sectorStats[index];
return _buildSectorItem(
context,
sector['name'],
sector['count'],
Color(sector['color']),
);
},
),
),
],
),
);
}
Widget _buildSectorItem(
BuildContext context,
String name,
int count,
Color color,
) {
final totalCount =
sectorStats.fold(0, (sum, item) => sum + (item['count'] as int));
final percentage = totalCount > 0 ? (count / totalCount) * 100 : 0;
return Padding(
padding: const EdgeInsets.only(bottom: AppTheme.spacingS),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
name,
style: const TextStyle(fontSize: 14),
overflow: TextOverflow.ellipsis,
),
),
Text(
'$count (${percentage.toInt()}%)',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: percentage / 100,
backgroundColor: Colors.grey[200],
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 8,
borderRadius: BorderRadius.circular(4),
),
],
),
);
}
}

View File

@@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'custom_text_field.dart';
class UserForm extends StatefulWidget {
final UserModel? user;
final Function(UserModel)? onSubmit;
final bool readOnly;
const UserForm({
Key? key,
this.user,
this.onSubmit,
this.readOnly = false,
}) : super(key: key);
@override
State<UserForm> createState() => _UserFormState();
}
class _UserFormState extends State<UserForm> {
final _formKey = GlobalKey<FormState>();
// Controllers
late final TextEditingController _usernameController;
late final TextEditingController _firstNameController;
late final TextEditingController _nameController;
late final TextEditingController _phoneController;
late final TextEditingController _mobileController;
late final TextEditingController _emailController;
late final TextEditingController _dateNaissanceController;
late final TextEditingController _dateEmbaucheController;
// Form values
int _fkTitre = 1; // 1 = M., 2 = Mme
DateTime? _dateNaissance;
DateTime? _dateEmbauche;
@override
void initState() {
super.initState();
// Initialize controllers with user data if available
final user = widget.user;
_usernameController = TextEditingController(text: user?.username ?? '');
_firstNameController = TextEditingController(text: user?.firstName ?? '');
_nameController = TextEditingController(text: user?.name ?? '');
_phoneController = TextEditingController(text: user?.phone ?? '');
_mobileController = TextEditingController(text: user?.mobile ?? '');
_emailController = TextEditingController(text: user?.email ?? '');
_dateNaissance = user?.dateNaissance;
_dateEmbauche = user?.dateEmbauche;
_dateNaissanceController = TextEditingController(
text: _dateNaissance != null
? DateFormat('dd/MM/yyyy').format(_dateNaissance!)
: '');
_dateEmbaucheController = TextEditingController(
text: _dateEmbauche != null
? DateFormat('dd/MM/yyyy').format(_dateEmbauche!)
: '');
_fkTitre = user?.fkTitre ?? 1;
}
@override
void dispose() {
_usernameController.dispose();
_firstNameController.dispose();
_nameController.dispose();
_phoneController.dispose();
_mobileController.dispose();
_emailController.dispose();
_dateNaissanceController.dispose();
_dateEmbaucheController.dispose();
super.dispose();
}
// Méthode simplifiée pour sélectionner une date
void _selectDate(BuildContext context, bool isDateNaissance) {
// Utiliser un bloc try-catch pour capturer toutes les erreurs possibles
try {
// Afficher le sélecteur de date sans spécifier de locale
showDatePicker(
context: context,
initialDate: DateTime.now(), // Toujours utiliser la date actuelle
firstDate: DateTime(1900),
lastDate: DateTime.now(),
// Ne pas spécifier de locale pour éviter les problèmes
).then((DateTime? picked) {
// Vérifier si une date a été sélectionnée
if (picked != null) {
setState(() {
// Mettre à jour la date et le texte du contrôleur
if (isDateNaissance) {
_dateNaissance = picked;
_dateNaissanceController.text =
DateFormat('dd/MM/yyyy').format(picked);
} else {
_dateEmbauche = picked;
_dateEmbaucheController.text =
DateFormat('dd/MM/yyyy').format(picked);
}
});
}
}).catchError((error) {
// Gérer les erreurs spécifiques au sélecteur de date
debugPrint('Erreur lors de la sélection de la date: $error');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors de la sélection de la date'),
backgroundColor: Colors.red,
),
);
});
} catch (e) {
// Gérer toutes les autres erreurs
debugPrint('Exception lors de l\'affichage du sélecteur de date: $e');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Impossible d\'afficher le sélecteur de date'),
backgroundColor: Colors.red,
),
);
}
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
final user = widget.user?.copyWith(
firstName: _firstNameController.text,
name: _nameController.text,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
) ??
UserModel(
id: 0, // Sera remplacé par l'API
firstName: _firstNameController.text,
name: _nameController.text,
phone: _phoneController.text,
mobile: _mobileController.text,
email: _emailController.text,
fkTitre: _fkTitre,
dateNaissance: _dateNaissance,
dateEmbauche: _dateEmbauche,
role: 1, // Valeur par défaut
createdAt: DateTime.now(),
lastSyncedAt: DateTime.now(),
);
if (widget.onSubmit != null) {
widget.onSubmit!(user);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Nom d'utilisateur (en lecture seule)
CustomTextField(
controller: _usernameController,
label: "Nom d'utilisateur",
readOnly: true, // Toujours en lecture seule
prefixIcon: Icons.account_circle,
),
const SizedBox(height: 16),
// Titre (M. ou Mme)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Titre",
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: theme.colorScheme.onBackground,
),
),
const SizedBox(height: 8),
Row(
children: [
_buildRadioOption(
value: 1,
label: 'M.',
groupValue: _fkTitre,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_fkTitre = value!;
});
},
),
const SizedBox(width: 40),
_buildRadioOption(
value: 2,
label: 'Mme',
groupValue: _fkTitre,
onChanged: widget.readOnly
? null
: (value) {
setState(() {
_fkTitre = value!;
});
},
),
],
),
],
),
const SizedBox(height: 16),
// Prénom
CustomTextField(
controller: _firstNameController,
label: "Prénom",
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le prénom";
}
return null;
},
),
const SizedBox(height: 16),
// Nom
CustomTextField(
controller: _nameController,
label: "Nom",
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer le nom";
}
return null;
},
),
const SizedBox(height: 16),
// Téléphone fixe
CustomTextField(
controller: _phoneController,
label: "Téléphone fixe",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro de téléphone doit contenir 10 chiffres";
}
return null;
},
),
const SizedBox(height: 16),
// Téléphone mobile
CustomTextField(
controller: _mobileController,
label: "Téléphone mobile",
keyboardType: TextInputType.phone,
readOnly: widget.readOnly,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
],
validator: (value) {
if (value != null && value.isNotEmpty && value.length < 10) {
return "Le numéro de mobile doit contenir 10 chiffres";
}
return null;
},
),
const SizedBox(height: 16),
// Email
CustomTextField(
controller: _emailController,
label: "Email",
keyboardType: TextInputType.emailAddress,
readOnly: widget.readOnly,
validator: (value) {
if (value == null || value.isEmpty) {
return "Veuillez entrer l'adresse email";
}
if (!value.contains('@') || !value.contains('.')) {
return "Veuillez entrer une adresse email valide";
}
return null;
},
),
const SizedBox(height: 16),
// Date de naissance
CustomTextField(
controller: _dateNaissanceController,
label: "Date de naissance",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, true),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 16),
// Date d'embauche
CustomTextField(
controller: _dateEmbaucheController,
label: "Date d'embauche",
readOnly: true,
onTap: widget.readOnly ? null : () => _selectDate(context, false),
suffixIcon: Icon(
Icons.calendar_today,
color: theme.colorScheme.primary,
),
),
// Espace en bas du formulaire
const SizedBox(height: 16),
],
),
);
}
Widget _buildRadioOption({
required int value,
required String label,
required int groupValue,
required Function(int?)? onChanged,
}) {
final theme = Theme.of(context);
final isSelected = value == groupValue;
return Row(
children: [
Radio<int>(
value: value,
groupValue: groupValue,
onChanged: onChanged,
activeColor: const Color(0xFF20335E),
),
Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onBackground,
fontWeight: FontWeight.w500,
),
),
],
);
}
}