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

266
app/lib/app.dart Normal file
View File

@@ -0,0 +1,266 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:geosector_app/core/theme/app_theme.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/core/repositories/operation_repository.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/repositories/sector_repository.dart';
import 'package:geosector_app/core/repositories/membre_repository.dart';
import 'package:geosector_app/core/repositories/amicale_repository.dart';
import 'package:geosector_app/core/services/sync_service.dart';
import 'package:geosector_app/core/services/connectivity_service.dart';
import 'package:geosector_app/presentation/auth/splash_page.dart';
import 'package:geosector_app/presentation/auth/login_page.dart';
import 'package:geosector_app/presentation/auth/register_page.dart';
import 'package:geosector_app/presentation/admin/admin_dashboard_page.dart';
import 'package:geosector_app/presentation/user/user_dashboard_page.dart';
// Instances globales des services et repositories
final apiService = ApiService();
final operationRepository = OperationRepository(apiService);
final passageRepository = PassageRepository(apiService);
final userRepository = UserRepository(apiService);
final sectorRepository = SectorRepository(apiService);
final membreRepository = MembreRepository(apiService);
final amicaleRepository = AmicaleRepository(apiService);
final syncService = SyncService(userRepository: userRepository);
final connectivityService = ConnectivityService();
class GeoSectorApp extends StatelessWidget {
const GeoSectorApp({super.key});
@override
Widget build(BuildContext context) {
// Utiliser directement le router sans provider
final router = GoRouter(
initialLocation: '/',
debugLogDiagnostics: true,
refreshListenable:
userRepository, // Écouter les changements d'état d'authentification
// Gestionnaire de redirection global - intercepte toutes les navigations
redirect: (context, state) {
// Détection manuelle des paramètres d'URL pour le Web
if (kIsWeb && state.uri.path == '/login') {
try {
// Obtenir le paramètre 'type' de l'URL actuelle
final typeParam = state.uri.queryParameters['type'];
// Obtenir l'URL brute du navigateur pour comparer
final rawUri = Uri.parse(Uri.base.toString());
final rawTypeParam = rawUri.queryParameters['type'];
print('APP ROUTER: state.uri = ${state.uri}, type = $typeParam');
print('APP ROUTER: rawUri = $rawUri, type = $rawTypeParam');
// Pas de redirection si on a déjà le paramètre type
if (typeParam != null) {
print('APP ROUTER: Param type déjà présent, pas de redirection');
return null; // Pas de redirection
}
// Si un paramètre type=user est présent dans l'URL brute mais pas dans l'état
if (rawTypeParam == 'user' && typeParam == null) {
print(
'APP ROUTER: Paramètre détecté dans l\'URL brute, redirection vers /login?type=user');
return '/login?type=user';
}
} catch (e) {
print('Erreur lors de la récupération des paramètres d\'URL: $e');
}
}
// Sauvegarder le chemin actuel pour l'utilisateur connecté, sauf pour la page de splash
if (state.uri.toString() != '/' && userRepository.isLoggedIn) {
// Ne pas sauvegarder les chemins de login/register
if (!state.uri.toString().startsWith('/login') &&
!state.uri.toString().startsWith('/register')) {
userRepository.updateLastPath(state.uri.toString());
}
}
// Vérifier si l'utilisateur est sur la page de splash
if (state.uri.toString() == '/') {
// Laisser l'utilisateur sur la page de splash, la redirection sera gérée par SplashPage
return null;
}
// Vérifier si l'utilisateur est sur une page d'authentification
final isLoggedIn = userRepository.isLoggedIn;
final isOnLoginPage = state.uri.toString().startsWith('/login');
final isOnRegisterPage = state.uri.toString() == '/register';
final isOnAdminRegisterPage = state.uri.toString() == '/admin-register';
// Si l'utilisateur n'est pas connecté et n'est pas sur une page d'authentification, rediriger vers la page de connexion
if (!isLoggedIn &&
!isOnLoginPage &&
!isOnRegisterPage &&
!isOnAdminRegisterPage) {
return '/login';
}
// Si l'utilisateur est connecté et se trouve sur une page d'authentification, rediriger vers le tableau de bord approprié
if (isLoggedIn &&
(isOnLoginPage || isOnRegisterPage || isOnAdminRegisterPage)) {
// Récupérer le rôle de l'utilisateur directement
final user = userRepository.getCurrentUser();
if (user != null) {
// 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;
}
// Redirection simple basée sur le rôle
if (roleValue > 1) {
debugPrint(
'Router: Redirection vers /admin (rôle $roleValue > 1)');
return '/admin';
} else {
debugPrint(
'Router: Redirection vers /user (rôle $roleValue = 1)');
return '/user';
}
}
}
// Si l'utilisateur est connecté mais essaie d'accéder à la mauvaise page selon son rôle
if (isLoggedIn) {
final user = userRepository.getCurrentUser();
if (user != null) {
// 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;
}
// Vérifier si l'utilisateur est sur la bonne page en fonction de son rôle
final isOnUserPage = state.uri.toString().startsWith('/user');
final isOnAdminPage = state.uri.toString().startsWith('/admin');
// Admin (rôle > 1) essayant d'accéder à une page utilisateur
if (roleValue > 1 && isOnUserPage) {
debugPrint(
'Router: Redirection d\'admin (rôle $roleValue) vers /admin');
return '/admin';
}
// Utilisateur standard (rôle = 1) essayant d'accéder à une page admin
if (roleValue == 1 && isOnAdminPage) {
debugPrint(
'Router: Redirection d\'utilisateur (rôle $roleValue) vers /user');
return '/user';
}
}
}
return null;
},
routes: [
// Splash screen
GoRoute(
path: '/',
builder: (context, state) => const SplashPage(),
),
// Page de connexion utilisateur dédiée
GoRoute(
path: '/login/user',
builder: (context, state) {
print('ROUTER: Accès direct à la route login user');
return const LoginPage(
key: Key('login_page_user'),
loginType: 'user',
);
},
),
// Pages d'authentification standard
GoRoute(
path: '/login',
builder: (context, state) {
// Ajouter des logs de débogage détaillés pour comprendre les paramètres
print('ROUTER DEBUG: Uri complète = ${state.uri}');
print('ROUTER DEBUG: Path = ${state.uri.path}');
print('ROUTER DEBUG: Query params = ${state.uri.queryParameters}');
print(
'ROUTER DEBUG: Has type? ${state.uri.queryParameters.containsKey("type")}');
// Donner la priorité aux paramètres d'URL puis aux extras
String? loginType;
// 1. Essayer d'abord les paramètres d'URL (pour les liens externes)
final queryParams = state.uri.queryParameters;
loginType = queryParams['type'];
print('ROUTER DEBUG: Type from query params = $loginType');
// 2. Si aucun type dans les paramètres d'URL, vérifier les extras (pour la navigation interne)
if (loginType == null &&
state.extra != null &&
state.extra is Map<String, dynamic>) {
final extras = state.extra as Map<String, dynamic>;
loginType = extras['type']?.toString();
print('ROUTER DEBUG: Type from extras = $loginType');
}
// 3. Normaliser et valider le type
if (loginType != null) {
loginType = loginType.trim().toLowerCase();
// Vérifier explicitement que c'est 'user', sinon mettre 'admin'
if (loginType != 'user') {
loginType = 'admin';
}
} else {
// Si aucun type n'est spécifié, retourner la page de splash
print(
'ROUTER: Aucun type spécifié, utilisation de la page splash');
return const SplashPage();
}
print('ROUTER: Type de connexion final: $loginType');
return LoginPage(
key: Key('login_page_${loginType}'),
loginType: loginType,
);
},
),
GoRoute(
path: '/register',
builder: (context, state) => const RegisterPage(),
),
// Pages administrateur
GoRoute(
path: '/admin',
builder: (context, state) => const AdminDashboardPage(),
routes: [
// Ajouter d'autres routes admin ici
],
),
// Pages utilisateur
GoRoute(
path: '/user',
builder: (context, state) => const UserDashboardPage(),
routes: [
// Ajouter d'autres routes utilisateur ici
],
),
],
);
return MaterialApp.router(
debugShowCheckedModeBanner: false,
title: 'GEOSECTOR',
theme: AppTheme.lightTheme,
darkTheme: AppTheme.darkTheme,
themeMode: ThemeMode.system,
routerConfig: router,
);
}
}

82
app/lib/chat/README.md Normal file
View File

@@ -0,0 +1,82 @@
# Module Chat GEOSECTOR
## Structure du module
Le module chat est organisé selon une architecture modulaire respectant la séparation des préoccupations :
```
lib/chat/
├── models/ # Modèles de données
│ ├── conversation_model.dart
│ ├── message_model.dart
│ ├── participant_model.dart
│ └── audience_target_model.dart
├── repositories/ # Gestion des données
│ └── chat_repository.dart
├── services/ # Services techniques
│ ├── chat_api_service.dart
│ └── offline_queue_service.dart
├── widgets/ # Composants UI
│ ├── chat_screen.dart
│ ├── conversations_list.dart
│ ├── message_bubble.dart
│ └── chat_input.dart
├── pages/ # Pages de l'application
│ └── chat_page.dart
├── chat.dart # Point d'entrée avec exports
└── README.md # Documentation du module
```
## Fonctionnalités principales
1. **Conversations** : Support des conversations one-to-one, groupes et annonces
2. **Messages** : Envoi/réception de messages texte et pièces jointes
3. **Participants** : Gestion des participants aux conversations
4. **Annonces** : Diffusion de messages à des groupes spécifiques
5. **Mode hors ligne** : File d'attente pour la synchronisation des données
## Utilisation
### Importation
```dart
import 'package:geosector/chat/chat.dart';
```
### Affichage de la page chat
```dart
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ChatPage()),
);
```
### Création d'une conversation
```dart
final chatRepository = ChatRepository();
final conversation = await chatRepository.createConversation({
'type': 'one_to_one',
'participants': [userId1, userId2],
});
```
## États d'implémentation
- [x] Structure de base
- [ ] Modèles de données complets
- [ ] Intégration avec Hive
- [ ] Services API
- [ ] Gestion hors ligne
- [ ] Widgets visuels
- [ ] Tests unitaires
## À faire
1. Compléter l'implémentation des modèles avec les adaptateurs Hive
2. Implémenter les méthodes dans les services et repositories
3. Créer les widgets visuels avec le design approprié
4. Ajouter les adaptateurs Hive pour le stockage local
5. Implémenter la gestion des pièces jointes
6. Ajouter les tests unitaires

35
app/lib/chat/chat.dart Normal file
View File

@@ -0,0 +1,35 @@
/// Exportation principale du module chat
///
/// Ce fichier centralise les exportations du module chat
/// pour faciliter l'importation dans d'autres parties de l'application
// Models
export 'models/conversation_model.dart';
export 'models/message_model.dart';
export 'models/participant_model.dart';
export 'models/audience_target_model.dart';
export 'models/anonymous_user_model.dart';
export 'models/chat_config.dart';
export 'models/notification_settings.dart';
// Repositories
export 'repositories/chat_repository.dart';
// Services
export 'services/chat_api_service.dart';
export 'services/offline_queue_service.dart';
export 'services/notifications/mqtt_notification_service.dart';
export 'services/notifications/mqtt_config.dart';
// Widgets
export 'widgets/chat_screen.dart';
export 'widgets/conversations_list.dart';
export 'widgets/message_bubble.dart';
export 'widgets/chat_input.dart';
export 'widgets/notification_settings_widget.dart';
// Pages
export 'pages/chat_page.dart';
// Constants
export 'constants/chat_constants.dart';

View File

@@ -0,0 +1,510 @@
# Solution de Chat pour Applications Flutter
## Présentation générale
Cette solution propose un système de chat personnalisé et autonome pour des applications Flutter, avec possibilité d'intégration web. Elle est conçue pour fonctionner dans deux contextes différents :
1. **Chat entre utilisateurs authentifiés** (cas Geosector) : communications one-to-one ou en groupe entre utilisateurs déjà enregistrés dans la base de données.
2. **Chat entre professionnels et visiteurs anonymes** (cas Resalice) : communications initiées par des visiteurs anonymes qui peuvent ensuite être convertis en clients référencés.
## Architecture technique
### 1. Structure générale
La solution s'articule autour de quatre composants principaux :
- **Module Flutter** : Widgets et logique pour l'interface utilisateur mobile
- **Module Web** : Composants pour l'intégration web (compatible avec Flutter Web ou sites traditionnels)
- **API Backend** : Endpoints REST uniquement pour la récupération de l'historique des conversations
- **Module Go Chat Service** : Service de gestion des messages MQTT, modération et synchronisation avec la base de données
### 2. Infrastructure de notifications
#### Broker MQTT
Le système utilise MQTT pour les notifications en temps réel :
- Broker Mosquitto hébergé dans un container Incus
- Connexion sécurisée via SSL/TLS (port 8883)
- Authentification par username/password
- QoS 1 (at least once) pour garantir la livraison
#### Module Go Chat Service
Un service externe en Go qui :
- Écoute les événements MQTT
- Enregistre les messages dans la base de données
- Applique des règles de modération configurables
- Synchronise les notifications avec le stockage
```go
type ChatService struct {
mqttClient mqtt.Client
db *sql.DB
moderator *Moderator
config *ChatConfig
}
type ChatConfig struct {
ApplicationID string
ModeratorEnabled bool
BadWords []string
FloodLimits int
SpamRules map[string]interface{}
Webhooks []string
}
```
### 3. Modèle de données
#### Entités principales
```
Conversation
├── id : Identifiant unique
├── type : Type de conversation (one_to_one, group, anonymous, broadcast, announcement)
├── title : Titre facultatif pour les groupes et obligatoire pour les annonces
├── reply_permission : Niveau de permission pour répondre (all, admins_only, sender_only, none)
├── created_at : Date de création
├── updated_at : Dernière mise à jour
├── is_pinned : Indique si la conversation est épinglée (pour annonces importantes)
├── expiry_date : Date d'expiration optionnelle (pour annonces temporaires)
└── participants : Liste des participants
Message
├── id : Identifiant unique
├── conversation_id : ID de la conversation
├── sender_id : ID de l'expéditeur (null pour anonyme)
├── sender_type : Type d'expéditeur (user, anonymous, system)
├── content : Contenu du message
├── content_type : Type de contenu (text, image, file)
├── created_at : Date d'envoi
├── delivered_at : Date de réception
├── read_at : Date de lecture
├── status : Statut du message (sent, delivered, read, error)
├── is_announcement : Indique s'il s'agit d'une annonce officielle
├── is_moderated : Indique si le message a été modéré
└── moderation_status : Statut de la modération (pending, approved, rejected)
Participant
├── id : Identifiant unique
├── conversation_id : ID de la conversation
├── user_id : ID de l'utilisateur (si authentifié)
├── anonymous_id : ID anonyme (pour Resalice)
├── role : Rôle (admin, member, read_only)
├── joined_at : Date d'ajout à la conversation
├── via_target : Indique si l'utilisateur est inclus via un AudienceTarget
├── can_reply : Possibilité explicite de répondre (override de reply_permission)
└── last_read_message_id : ID du dernier message lu
AudienceTarget
├── id : Identifiant unique
├── conversation_id : ID de la conversation
├── target_type : Type de cible (role, entity, all, combined)
├── target_id : ID du rôle ou de l'entité ciblée (pour compatibility)
├── role_filter : Filtre de rôle pour le ciblage combiné ('all', '1', '2', etc.)
├── entity_filter : Filtre d'entité pour le ciblage combiné ('all', 'id_entité')
└── created_at : Date de création
AnonymousUser (pour Resalice)
├── id : Identifiant unique
├── device_id : Identifiant du dispositif
├── name : Nom temporaire (si fourni)
├── email : Email (si fourni)
├── created_at : Date de création
├── converted_to_user_id : ID utilisateur après conversion
└── metadata : Informations supplémentaires
ChatNotification
├── id : Identifiant unique
├── user_id : ID de l'utilisateur destinataire
├── message_id : ID du message
├── conversation_id : ID de la conversation
├── type : Type de notification
├── status : Statut (sent, delivered, read)
├── sent_at : Date d'envoi
└── read_at : Date de lecture
```
### 4. Backend et API
#### Structure de l'API
L'API sera développée en PHP 8.3 pour s'intégrer avec vos systèmes existants :
```
/api/chat/conversations
GET - Liste des conversations de l'utilisateur
POST - Créer une nouvelle conversation
/api/chat/conversations/{id}
GET - Détails d'une conversation
PUT - Mettre à jour une conversation
DELETE - Supprimer une conversation
/api/chat/conversations/{id}/messages
GET - Messages d'une conversation (pagination) - uniquement pour l'historique
/api/chat/conversations/{id}/participants
GET - Liste des participants
POST - Ajouter un participant
DELETE - Retirer un participant
/api/chat/messages/{id}
PUT - Mettre à jour un message (ex: marquer comme lu)
DELETE - Supprimer un message
/api/chat/anonymous
POST - Démarrer une conversation anonyme
# Nouveaux endpoints pour les annonces
/api/chat/announcements
GET - Liste des annonces pour l'utilisateur
POST - Créer une nouvelle annonce
/api/chat/announcements/{id}/stats
GET - Obtenir les statistiques de lecture (qui a lu/non lu)
/api/chat/audience-targets
GET - Obtenir les cibles disponibles pour l'utilisateur actuel
/api/chat/conversations/{id}/pin
PUT - Épingler/désépingler une conversation
/api/chat/conversations/{id}/reply-permission
PUT - Modifier les permissions de réponse
/api/chat/moderation/rules
GET - Obtenir les règles de modération
PUT - Mettre à jour les règles de modération
```
#### Synchronisation
Le système supporte deux flux de données distincts :
1. **Temps réel via MQTT** :
- Envoi de messages en temps réel
- Notifications instantanées
- Gestion via le module Go
2. **Récupération historique via REST** :
- Chargement de l'historique des conversations
- Synchronisation des anciens messages
- Accès direct à la base de données
- Enregistrement local des messages avec Hive pour le fonctionnement hors ligne
### 5. Widgets Flutter
#### Widgets principaux
1. **ChatScreen** : Écran principal d'une conversation
```dart
ChatScreen({
required String conversationId,
String? title,
Widget? header,
Widget? footer,
bool enableAttachments = true,
bool showTypingIndicator = true,
bool enableReadReceipts = true,
bool isAnnouncement = false,
bool canReply = true,
})
```
2. **ConversationsList** : Liste des conversations
```dart
ConversationsList({
List<ConversationModel>? conversations,
bool loadFromHive = true,
Function(ConversationModel)? onConversationSelected,
bool showLastMessage = true,
bool showUnreadCount = true,
bool showAnnouncementBadge = true,
bool showPinnedFirst = true,
Widget? emptyStateWidget,
})
```
3. **MessageBubble** : Bulle de message
```dart
MessageBubble({
required MessageModel message,
bool showSenderInfo = true,
bool showTimestamp = true,
bool showStatus = true,
bool isAnnouncement = false,
double maxWidth = 300,
})
```
4. **ChatInput** : Zone de saisie de message
```dart
ChatInput({
required Function(String) onSendText,
Function(File)? onSendFile,
Function(File)? onSendImage,
bool enableAttachments = true,
bool enabled = true,
String hintText = 'Saisissez votre message...',
String? disabledMessage = 'Vous ne pouvez pas répondre à cette annonce',
int? maxLength,
})
```
5. **AnonymousChatStarter** : Widget pour démarrer un chat anonyme (Resalice)
```dart
AnonymousChatStarter({
required Function(String?) onChatStarted,
bool requireName = false,
bool requireEmail = false,
String buttonLabel = 'Démarrer une conversation',
Widget? customForm,
})
```
6. **AnnouncementComposer** : Widget pour créer des annonces (Geosector uniquement)
```dart
AnnouncementComposer({
required Function(Map<String, dynamic>) onSend,
List<Map<String, dynamic>>? availableTargets,
String? initialTitle,
String? initialMessage,
bool allowAttachments = true,
bool allowPinning = true,
List<String> replyPermissionOptions = const ['all', 'admins_only', 'sender_only', 'none'],
String defaultReplyPermission = 'none',
DateTime? expiryDate,
bool isGeosector = true, // Active la sélection des destinataires
})
```
### 6. Gestion des notifications MQTT
#### Service MQTT Flutter
```dart
class MqttNotificationService {
final String mqttHost;
final int mqttPort;
final String mqttUsername;
final String mqttPassword;
Future<void> initialize({required String userId}) async {
// Initialisation du client MQTT
await _initializeMqttClient();
// Abonnement aux topics de l'utilisateur
_subscribeToUserTopics(userId);
}
void _subscribeToUserTopics(String userId) {
// Topics pour les messages personnels
client.subscribe('chat/user/$userId/messages', MqttQos.atLeastOnce);
// Topics pour les annonces
client.subscribe('chat/announcement', MqttQos.atLeastOnce);
}
Future<void> _handleMessage(String topic, Map<String, dynamic> data) async {
// Traitement et affichage de la notification locale
await _showLocalNotification(data);
// Stockage local pour la synchronisation
await _syncWithHive(data);
}
// Pour envoyer un message en temps réel
Future<void> sendMessage(String conversationId, String content) async {
final message = {
'conversationId': conversationId,
'content': content,
'senderId': currentUserId,
'timestamp': DateTime.now().toIso8601String(),
};
await client.publishMessage(
'chat/message/send',
MqttQos.atLeastOnce,
MqttClientPayloadBuilder().addString(jsonEncode(message)).payload!,
);
}
}
```
#### Service REST Flutter
```dart
class ChatApiService {
Future<List<Message>> getHistoricalMessages(
String conversationId, {
int page = 1,
int limit = 50,
}) async {
final response = await get('/api/chat/conversations/$conversationId/messages');
return (response.data as List)
.map((json) => Message.fromJson(json))
.toList();
}
// Note: Pas de POST pour les messages - uniquement pour l'historique
}
```
#### Structure des topics MQTT
```
chat/user/{userId}/messages - Messages personnels
chat/conversation/{convId} - Messages de groupe
chat/announcement - Annonces générales
chat/moderation/{msgId} - Résultats de modération
chat/typing/{convId} - Indicateurs de frappe
```
### 7. Module Go Chat Service
Le module Go gère :
1. **Réception MQTT**
- Écoute les topics de chat
- Parse les messages JSON
- Valide le format
2. **Modération**
- Analyse du contenu
- Application des règles configurables
- Filtrage des mots interdits
- Détection de spam
- Notification des résultats
3. **Synchronisation base de données**
- Enregistrement des messages en base
- Création des notifications
- Mise à jour des statuts de livraison
- Gestion des acquittements
**Note importante** : Le module Go n'a aucune interaction avec l'API REST. Il est uniquement connecté au broker MQTT pour recevoir les messages et à la base de données pour les stocker.
4. **Configuration par application**
```yaml
applications:
geosector:
moderator_enabled: true
bad_words: ["liste", "des", "mots"]
flood_limit: 5
spam_rules:
url_limit: 2
repetition_threshold: 0.8
resalice:
moderator_enabled: false
# Configuration différente
```
### 8. Stockage des fichiers
Le système supportera le téléchargement et le partage de fichiers :
1. **Côté serveur** : Stockage dans un répertoire sécurisé avec restriction d'accès
2. **Côté client** : Mise en cache des fichiers pour éviter des téléchargements redondants
3. **Types supportés** : Images, documents, autres fichiers selon configuration
## Cas d'utilisation spécifiques
### 1. Geosector
- **Utilisateurs authentifiés uniquement**
- **Groupes par équipe** avec administrateurs pour les communications internes
- **Modération active** avec filtrage de contenu
- **Historique complet** des conversations
- **Intégration avec la structure existante** des amicales et équipes
- **Annonces et broadcasts**:
- Super admin → tous les admins d'entités
- Admin d'entité → tous les utilisateurs de son entité
- Communications descendantes sans possibilité de réponse
- Statistiques de lecture des annonces importantes
- **Ciblage flexible des destinataires** :
- Par entité (toutes ou une spécifique)
- Par rôle (tous, membres, administrateurs)
- Combinaison entité + rôle (ex: admins de l'entité 5)
- Sélection via le widget `AnnouncementTargetSelector`
### 2. Resalice
- **Chats initiés par des anonymes**
- **Conversation one-to-one uniquement** entre professionnel et client/prospect
- **Pas de modération active** par défaut
- **Conversion client** : Processus pour transformer un utilisateur anonyme en client référencé
- **Conservation des historiques** après conversion
- **Interface professionnelle** adaptée aux échanges client/professionnel
- **Pas de fonctionnalité d'annonce** - uniquement des conversations directes
## Adaptabilité et extensibilité
### 1. Options de personnalisation
- **Thèmes** : Adaptation aux couleurs et styles de l'application
- **Fonctionnalités** : Activation/désactivation de certaines fonctionnalités
- **Comportements** : Configuration des notifications, comportement hors ligne, etc.
- **Modération** : Configuration par application
### 2. Extensions possibles
- **Chatbot** : Possibilité d'intégrer des réponses automatiques
- **Transfert** : Transfert de conversations entre professionnels
- **Intégration CRM** : Liaison avec des systèmes CRM pour le suivi client
- **Analyse** : Statistiques sur les conversations, temps de réponse, etc.
- **Audio/Vidéo** : Support des messages vocaux et vidéo
## Étapes d'implémentation suggérées
1. **Phase 1 : Infrastructure de base** (4-5 semaines)
- Installation et configuration du broker MQTT
- Développement du module Go Chat Service
- Modèles de données et adaptateurs Hive
- Configuration de l'API backend
2. **Phase 2 : Fonctionnalités principales** (4-5 semaines)
- Widgets de base pour affichage/envoi de messages
- Gestion des notifications MQTT
- Système de modération
- Structure de base pour les annonces et broadcasts
3. **Phase 3 : Fonctionnalités avancées** (3-4 semaines)
- Gestion hors ligne et synchronisation
- Support des fichiers et images
- Indicateurs de lecture et d'écriture
- Système de ciblage d'audience pour les annonces
4. **Phase 4 : Cas spécifiques** (3-4 semaines)
- Support des conversations anonymes (Resalice)
- Groupes et permissions avancées (Geosector)
- Statistiques de lecture des annonces
- Interface administrateur pour les annonces globales
- Intégration web complète
Le temps total d'implémentation pour Geosector est estimé à 12-15 semaines pour un développeur expérimenté en Flutter, PHP et Go. L'adaptation ultérieure à Resalice devrait prendre environ 3-4 semaines supplémentaires grâce à la conception modulaire du système.
## Conclusion
Cette solution de chat personnalisée offre un équilibre entre robustesse et simplicité d'intégration. Elle répond aux besoins spécifiques de vos applications tout en restant suffisamment flexible pour s'adapter à d'autres contextes.
Le système prend en charge non seulement les conversations classiques (one-to-one, groupes) mais aussi les communications de type annonce/broadcast où un administrateur peut communiquer des informations importantes à des groupes d'utilisateurs définis par rôle ou entité, avec ou sans possibilité de réponse.
### Points clés de l'architecture
1. **Séparation des flux** :
- **Temps réel** : Via MQTT pour l'envoi de messages et les notifications
- **Historique** : Via REST pour la récupération des anciennes conversations
2. **Modération centrée** : Le module Go gère la modération sans interaction avec l'API REST
3. **Auto-hébergement** :
- Broker MQTT dans votre container Incus
- Module Go dédié pour la gestion des messages
- Contrôle total de l'infrastructure
4. **Configuration flexible** : Modération et comportement adaptables par application
En développant cette solution en interne, vous gardez un contrôle total sur les fonctionnalités et l'expérience utilisateur, tout en assurant une cohérence avec le reste de vos applications. La conception modulaire et réutilisable permettra également un déploiement efficace sur vos différentes plateformes et applications.

View File

@@ -0,0 +1,50 @@
/// Constantes spécifiques au module chat
class ChatConstants {
// Types de conversations
static const String conversationTypeOneToOne = 'one_to_one';
static const String conversationTypeGroup = 'group';
static const String conversationTypeAnonymous = 'anonymous';
static const String conversationTypeBroadcast = 'broadcast';
static const String conversationTypeAnnouncement = 'announcement';
// Types de messages
static const String messageTypeText = 'text';
static const String messageTypeImage = 'image';
static const String messageTypeFile = 'file';
static const String messageTypeSystem = 'system';
// Types d'expéditeurs
static const String senderTypeUser = 'user';
static const String senderTypeAnonymous = 'anonymous';
static const String senderTypeSystem = 'system';
// Rôles des participants
static const String participantRoleAdmin = 'admin';
static const String participantRoleMember = 'member';
static const String participantRoleReadOnly = 'read_only';
// Permissions de réponse
static const String replyPermissionAll = 'all';
static const String replyPermissionAdminsOnly = 'admins_only';
static const String replyPermissionSenderOnly = 'sender_only';
static const String replyPermissionNone = 'none';
// Types de cibles d'audience
static const String targetTypeRole = 'role';
static const String targetTypeEntity = 'entity';
static const String targetTypeAll = 'all';
// Noms des boîtes Hive
static const String conversationsBoxName = 'chat_conversations';
static const String messagesBoxName = 'chat_messages';
static const String participantsBoxName = 'chat_participants';
static const String anonymousUsersBoxName = 'chat_anonymous_users';
static const String offlineQueueBoxName = 'chat_offline_queue';
// Configurations
static const int defaultMessagePageSize = 50;
static const int maxAttachmentSizeMB = 10;
static const int maxMessageLength = 5000;
static const Duration typingIndicatorTimeout = Duration(seconds: 3);
}

View File

@@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import '../chat.dart';
/// Exemple d'intégration du service MQTT dans l'application
///
/// Montre comment initialiser et utiliser le service de notifications MQTT
class MqttIntegrationExample extends StatefulWidget {
const MqttIntegrationExample({super.key});
@override
State<MqttIntegrationExample> createState() => _MqttIntegrationExampleState();
}
class _MqttIntegrationExampleState extends State<MqttIntegrationExample> {
late final MqttNotificationService _notificationService;
bool _isInitialized = false;
String _status = 'Non initialisé';
@override
void initState() {
super.initState();
_initializeMqttService();
}
Future<void> _initializeMqttService() async {
try {
// Initialiser le service avec la configuration
_notificationService = MqttNotificationService(
mqttHost: MqttConfig.host,
mqttPort: MqttConfig.port,
mqttUsername: MqttConfig.username,
mqttPassword: MqttConfig.password,
);
// Configurer les callbacks
_notificationService.onMessageTap = (messageId) {
debugPrint('Notification tapée : $messageId');
// Naviguer vers la conversation correspondante
_navigateToMessage(messageId);
};
_notificationService.onNotificationReceived = (data) {
debugPrint('Notification reçue : $data');
setState(() {
_status = 'Notification reçue : ${data['content']}';
});
};
// Initialiser avec l'ID utilisateur (récupéré du UserRepository)
final userId = _getCurrentUserId(); // À implémenter selon votre logique
await _notificationService.initialize(userId: userId);
setState(() {
_isInitialized = true;
_status = 'Service MQTT initialisé';
});
} catch (e) {
setState(() {
_status = 'Erreur : $e';
});
}
}
String _getCurrentUserId() {
// Dans votre application réelle, vous récupéreriez l'ID utilisateur
// depuis le UserRepository ou le contexte de l'application
return '123'; // Exemple
}
void _navigateToMessage(String messageId) {
// Implémenter la navigation vers le message
// Par exemple :
// Navigator.push(context, MaterialPageRoute(
// builder: (_) => ChatScreen(messageId: messageId),
// ));
}
@override
void dispose() {
_notificationService.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Test MQTT Notifications'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_status,
style: Theme.of(context).textTheme.headlineSmall,
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
if (_isInitialized) ...[
ElevatedButton(
onPressed: () {
_notificationService.pauseNotifications();
setState(() {
_status = 'Notifications en pause';
});
},
child: const Text('Pause Notifications'),
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: () {
_notificationService.resumeNotifications();
setState(() {
_status = 'Notifications actives';
});
},
child: const Text('Reprendre Notifications'),
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: () async {
// Exemple de test en publiant un message
await _notificationService.publishMessage(
'chat/user/${_getCurrentUserId()}/messages',
{
'type': 'chat_message',
'messageId': 'test_${DateTime.now().millisecondsSinceEpoch}',
'content': 'Message de test',
'senderId': '999',
'senderName': 'Système',
},
);
setState(() {
_status = 'Message test envoyé';
});
},
child: const Text('Envoyer Message Test'),
),
] else ...[
const CircularProgressIndicator(),
],
],
),
),
);
}
}
/// Exemple d'intégration dans le main.dart de votre application
void mainExample() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: const MqttIntegrationExample(),
);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:hive/hive.dart';
import 'package:equatable/equatable.dart';
part 'anonymous_user_model.g.dart';
/// Modèle d'utilisateur anonyme pour le système de chat
///
/// Ce modèle représente un utilisateur anonyme (pour le cas Resalice)
/// et permet de tracker sa conversion éventuelle en utilisateur authentifié
@HiveType(typeId: 24)
class AnonymousUserModel extends HiveObject with EquatableMixin {
@HiveField(0)
final String id;
@HiveField(1)
final String deviceId;
@HiveField(2)
final String? name;
@HiveField(3)
final String? email;
@HiveField(4)
final DateTime createdAt;
@HiveField(5)
final String? convertedToUserId;
@HiveField(6)
final Map<String, dynamic>? metadata;
AnonymousUserModel({
required this.id,
required this.deviceId,
this.name,
this.email,
required this.createdAt,
this.convertedToUserId,
this.metadata,
});
/// Crée une instance depuis le JSON
factory AnonymousUserModel.fromJson(Map<String, dynamic> json) {
return AnonymousUserModel(
id: json['id'] as String,
deviceId: json['device_id'] as String,
name: json['name'] as String?,
email: json['email'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
convertedToUserId: json['converted_to_user_id'] as String?,
metadata: json['metadata'] as Map<String, dynamic>?,
);
}
/// Convertit l'instance en JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'device_id': deviceId,
'name': name,
'email': email,
'created_at': createdAt.toIso8601String(),
'converted_to_user_id': convertedToUserId,
'metadata': metadata,
};
}
/// Crée une copie modifiée de l'instance
AnonymousUserModel copyWith({
String? id,
String? deviceId,
String? name,
String? email,
DateTime? createdAt,
String? convertedToUserId,
Map<String, dynamic>? metadata,
}) {
return AnonymousUserModel(
id: id ?? this.id,
deviceId: deviceId ?? this.deviceId,
name: name ?? this.name,
email: email ?? this.email,
createdAt: createdAt ?? this.createdAt,
convertedToUserId: convertedToUserId ?? this.convertedToUserId,
metadata: metadata ?? this.metadata,
);
}
/// Vérifie si l'utilisateur a été converti en utilisateur authentifié
bool get isConverted => convertedToUserId != null;
@override
List<Object?> get props => [
id,
deviceId,
name,
email,
createdAt,
convertedToUserId,
metadata,
];
}

View File

@@ -0,0 +1,59 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'anonymous_user_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AnonymousUserModelAdapter extends TypeAdapter<AnonymousUserModel> {
@override
final int typeId = 24;
@override
AnonymousUserModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AnonymousUserModel(
id: fields[0] as String,
deviceId: fields[1] as String,
name: fields[2] as String?,
email: fields[3] as String?,
createdAt: fields[4] as DateTime,
convertedToUserId: fields[5] as String?,
metadata: (fields[6] as Map?)?.cast<String, dynamic>(),
);
}
@override
void write(BinaryWriter writer, AnonymousUserModel obj) {
writer
..writeByte(7)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.deviceId)
..writeByte(2)
..write(obj.name)
..writeByte(3)
..write(obj.email)
..writeByte(4)
..write(obj.createdAt)
..writeByte(5)
..write(obj.convertedToUserId)
..writeByte(6)
..write(obj.metadata);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AnonymousUserModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,138 @@
import 'package:hive/hive.dart';
import 'package:equatable/equatable.dart';
part 'audience_target_model.g.dart';
/// Modèle de cible d'audience pour le système de chat
///
/// Ce modèle représente une cible d'audience pour les annonces et broadcasts
/// Il supporte maintenant le ciblage combiné avec les filtres de rôle et d'entité
@HiveType(typeId: 23)
class AudienceTargetModel extends HiveObject with EquatableMixin {
@HiveField(0)
final String id;
@HiveField(1)
final String conversationId;
@HiveField(2)
final String targetType;
@HiveField(3)
final String? targetId;
@HiveField(4)
final DateTime createdAt;
@HiveField(5)
final String? roleFilter;
@HiveField(6)
final String? entityFilter;
AudienceTargetModel({
required this.id,
required this.conversationId,
required this.targetType,
this.targetId,
required this.createdAt,
this.roleFilter,
this.entityFilter,
});
/// Crée une instance depuis le JSON
factory AudienceTargetModel.fromJson(Map<String, dynamic> json) {
return AudienceTargetModel(
id: json['id'] as String,
conversationId: json['conversation_id'] as String,
targetType: json['target_type'] as String,
targetId: json['target_id'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
roleFilter: json['role_filter'] as String?,
entityFilter: json['entity_filter'] as String?,
);
}
/// Convertit l'instance en JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'conversation_id': conversationId,
'target_type': targetType,
'target_id': targetId,
'created_at': createdAt.toIso8601String(),
'role_filter': roleFilter,
'entity_filter': entityFilter,
};
}
/// Crée une copie modifiée de l'instance
AudienceTargetModel copyWith({
String? id,
String? conversationId,
String? targetType,
String? targetId,
DateTime? createdAt,
String? roleFilter,
String? entityFilter,
}) {
return AudienceTargetModel(
id: id ?? this.id,
conversationId: conversationId ?? this.conversationId,
targetType: targetType ?? this.targetType,
targetId: targetId ?? this.targetId,
createdAt: createdAt ?? this.createdAt,
roleFilter: roleFilter ?? this.roleFilter,
entityFilter: entityFilter ?? this.entityFilter,
);
}
/// Vérifie si l'utilisateur est ciblé par cette règle
bool targetsUser({
required String userId,
required int userRole,
required String userEntityId,
}) {
switch (targetType) {
case 'all':
return true;
case 'role':
if (roleFilter != null && roleFilter != 'all') {
return userRole.toString() == roleFilter;
}
return true;
case 'entity':
if (entityFilter != null && entityFilter != 'all') {
return userEntityId == entityFilter;
}
return true;
case 'combined':
bool matchesRole = true;
bool matchesEntity = true;
if (roleFilter != null && roleFilter != 'all') {
matchesRole = userRole.toString() == roleFilter;
}
if (entityFilter != null && entityFilter != 'all') {
matchesEntity = userEntityId == entityFilter;
}
return matchesRole && matchesEntity;
default:
return false;
}
}
@override
List<Object?> get props => [
id,
conversationId,
targetType,
targetId,
createdAt,
roleFilter,
entityFilter,
];
}

View File

@@ -0,0 +1,59 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'audience_target_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AudienceTargetModelAdapter extends TypeAdapter<AudienceTargetModel> {
@override
final int typeId = 23;
@override
AudienceTargetModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AudienceTargetModel(
id: fields[0] as String,
conversationId: fields[1] as String,
targetType: fields[2] as String,
targetId: fields[3] as String?,
createdAt: fields[4] as DateTime,
roleFilter: fields[5] as String?,
entityFilter: fields[6] as String?,
);
}
@override
void write(BinaryWriter writer, AudienceTargetModel obj) {
writer
..writeByte(7)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.conversationId)
..writeByte(2)
..write(obj.targetType)
..writeByte(3)
..write(obj.targetId)
..writeByte(4)
..write(obj.createdAt)
..writeByte(5)
..write(obj.roleFilter)
..writeByte(6)
..write(obj.entityFilter);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AudienceTargetModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,15 @@
// Fichier central pour regrouper tous les adaptateurs Hive du module chat
// Exports des modèles et leurs adaptateurs
export 'conversation_model.dart';
export 'message_model.dart';
export 'participant_model.dart';
export 'anonymous_user_model.dart';
export 'audience_target_model.dart';
export 'notification_settings.dart';
// Fonction pour enregistrer tous les adaptateurs Hive du chat
Future<void> registerChatHiveAdapters() async {
// Les adaptateurs sont déjà générés dans les fichiers .g.dart
// Ils sont automatiquement enregistrés lors de l'appel de registerAdapter
}

View File

@@ -0,0 +1,104 @@
import 'package:equatable/equatable.dart';
/// Configuration du module chat
///
/// Permet d'adapter le comportement du chat selon l'application
/// (Geosector ou Resalice)
class ChatConfig with EquatableMixin {
/// Active/désactive les annonces
final bool enableAnnouncements;
/// Active/désactive la sélection de cibles pour les annonces
final bool enableTargetSelection;
/// Active/désactive les statistiques des annonces
final bool showAnnouncementStats;
/// Permission de réponse par défaut
final String defaultReplyPermission;
/// Active/désactive les conversations anonymes
final bool enableAnonymousConversations;
/// Active/désactive les conversations de groupe
final bool enableGroupConversations;
/// Types de conversation autorisés
final List<String> allowedConversationTypes;
/// Taille maximale des fichiers en Mo
final int maxAttachmentSizeMB;
/// Nombre de messages par page
final int messagePageSize;
ChatConfig({
this.enableAnnouncements = true,
this.enableTargetSelection = true,
this.showAnnouncementStats = true,
this.defaultReplyPermission = 'none',
this.enableAnonymousConversations = false,
this.enableGroupConversations = true,
this.allowedConversationTypes = const [
'one_to_one',
'group',
'announcement',
'broadcast'
],
this.maxAttachmentSizeMB = 10,
this.messagePageSize = 50,
});
/// Configuration par défaut pour Geosector
factory ChatConfig.geosector() {
return ChatConfig(
enableAnnouncements: true,
enableTargetSelection: true,
showAnnouncementStats: true,
defaultReplyPermission: 'none',
enableAnonymousConversations: false,
enableGroupConversations: true,
allowedConversationTypes: const [
'one_to_one',
'group',
'announcement',
'broadcast'
],
);
}
/// Configuration par défaut pour Resalice
factory ChatConfig.resalice() {
return ChatConfig(
enableAnnouncements: false,
enableTargetSelection: false,
showAnnouncementStats: false,
defaultReplyPermission: 'all',
enableAnonymousConversations: true,
enableGroupConversations: false,
allowedConversationTypes: const [
'one_to_one',
'anonymous'
],
);
}
/// Vérifie si un type de conversation est autorisé
bool isConversationTypeAllowed(String type) {
return allowedConversationTypes.contains(type);
}
@override
List<Object?> get props => [
enableAnnouncements,
enableTargetSelection,
showAnnouncementStats,
defaultReplyPermission,
enableAnonymousConversations,
enableGroupConversations,
allowedConversationTypes,
maxAttachmentSizeMB,
messagePageSize,
];
}

View File

@@ -0,0 +1,139 @@
import 'package:hive/hive.dart';
import 'package:equatable/equatable.dart';
import 'participant_model.dart';
part 'conversation_model.g.dart';
/// Modèle de conversation pour le système de chat
///
/// Ce modèle représente une conversation entre utilisateurs
/// Il supporte différents types de conversations :
/// - one_to_one : conversation privée entre 2 utilisateurs
/// - group : groupe de plusieurs utilisateurs
/// - anonymous : conversation avec un utilisateur anonyme
/// - broadcast : message diffusé à plusieurs utilisateurs
/// - announcement : annonce officielle
@HiveType(typeId: 20)
class ConversationModel extends HiveObject with EquatableMixin {
@HiveField(0)
final String id;
@HiveField(1)
final String type;
@HiveField(2)
final String? title;
@HiveField(3)
final DateTime createdAt;
@HiveField(4)
final DateTime updatedAt;
@HiveField(5)
final List<ParticipantModel> participants;
@HiveField(6)
final bool isSynced;
@HiveField(7)
final String replyPermission;
@HiveField(8)
final bool isPinned;
@HiveField(9)
final DateTime? expiryDate;
ConversationModel({
required this.id,
required this.type,
this.title,
required this.createdAt,
required this.updatedAt,
required this.participants,
this.isSynced = false,
this.replyPermission = 'all',
this.isPinned = false,
this.expiryDate,
});
/// Crée une instance depuis le JSON
factory ConversationModel.fromJson(Map<String, dynamic> json) {
return ConversationModel(
id: json['id'] as String,
type: json['type'] as String,
title: json['title'] as String?,
createdAt: DateTime.parse(json['created_at'] as String),
updatedAt: DateTime.parse(json['updated_at'] as String),
participants: (json['participants'] as List?)
?.map((e) => ParticipantModel.fromJson(e as Map<String, dynamic>))
.toList() ??
[],
isSynced: json['is_synced'] as bool? ?? false,
replyPermission: json['reply_permission'] as String? ?? 'all',
isPinned: json['is_pinned'] as bool? ?? false,
expiryDate: json['expiry_date'] != null
? DateTime.parse(json['expiry_date'] as String)
: null,
);
}
/// Convertit l'instance en JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'type': type,
'title': title,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'participants': participants.map((e) => e.toJson()).toList(),
'is_synced': isSynced,
'reply_permission': replyPermission,
'is_pinned': isPinned,
'expiry_date': expiryDate?.toIso8601String(),
};
}
/// Crée une copie modifiée de l'instance
ConversationModel copyWith({
String? id,
String? type,
String? title,
DateTime? createdAt,
DateTime? updatedAt,
List<ParticipantModel>? participants,
bool? isSynced,
String? replyPermission,
bool? isPinned,
DateTime? expiryDate,
}) {
return ConversationModel(
id: id ?? this.id,
type: type ?? this.type,
title: title ?? this.title,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
participants: participants ?? this.participants,
isSynced: isSynced ?? this.isSynced,
replyPermission: replyPermission ?? this.replyPermission,
isPinned: isPinned ?? this.isPinned,
expiryDate: expiryDate ?? this.expiryDate,
);
}
@override
List<Object?> get props => [
id,
type,
title,
createdAt,
updatedAt,
participants,
isSynced,
replyPermission,
isPinned,
expiryDate,
];
}

View File

@@ -0,0 +1,68 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'conversation_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ConversationModelAdapter extends TypeAdapter<ConversationModel> {
@override
final int typeId = 20;
@override
ConversationModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ConversationModel(
id: fields[0] as String,
type: fields[1] as String,
title: fields[2] as String?,
createdAt: fields[3] as DateTime,
updatedAt: fields[4] as DateTime,
participants: (fields[5] as List).cast<ParticipantModel>(),
isSynced: fields[6] as bool,
replyPermission: fields[7] as String,
isPinned: fields[8] as bool,
expiryDate: fields[9] as DateTime?,
);
}
@override
void write(BinaryWriter writer, ConversationModel obj) {
writer
..writeByte(10)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.type)
..writeByte(2)
..write(obj.title)
..writeByte(3)
..write(obj.createdAt)
..writeByte(4)
..write(obj.updatedAt)
..writeByte(5)
..write(obj.participants)
..writeByte(6)
..write(obj.isSynced)
..writeByte(7)
..write(obj.replyPermission)
..writeByte(8)
..write(obj.isPinned)
..writeByte(9)
..write(obj.expiryDate);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ConversationModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,140 @@
import 'package:hive/hive.dart';
import 'package:equatable/equatable.dart';
part 'message_model.g.dart';
/// Modèle de message pour le système de chat
///
/// Ce modèle représente un message échangé dans une conversation
@HiveType(typeId: 21)
class MessageModel extends HiveObject with EquatableMixin {
@HiveField(0)
final String id;
@HiveField(1)
final String conversationId;
@HiveField(2)
final String? senderId;
@HiveField(3)
final String senderType;
@HiveField(4)
final String content;
@HiveField(5)
final String contentType;
@HiveField(6)
final DateTime createdAt;
@HiveField(7)
final DateTime? deliveredAt;
@HiveField(8)
final DateTime? readAt;
@HiveField(9)
final String status;
@HiveField(10)
final bool isAnnouncement;
MessageModel({
required this.id,
required this.conversationId,
this.senderId,
required this.senderType,
required this.content,
required this.contentType,
required this.createdAt,
this.deliveredAt,
this.readAt,
required this.status,
this.isAnnouncement = false,
});
/// Crée une instance depuis le JSON
factory MessageModel.fromJson(Map<String, dynamic> json) {
return MessageModel(
id: json['id'] as String,
conversationId: json['conversation_id'] as String,
senderId: json['sender_id'] as String?,
senderType: json['sender_type'] as String,
content: json['content'] as String,
contentType: json['content_type'] as String,
createdAt: DateTime.parse(json['created_at'] as String),
deliveredAt: json['delivered_at'] != null
? DateTime.parse(json['delivered_at'] as String)
: null,
readAt: json['read_at'] != null
? DateTime.parse(json['read_at'] as String)
: null,
status: json['status'] as String,
isAnnouncement: json['is_announcement'] as bool? ?? false,
);
}
/// Convertit l'instance en JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'conversation_id': conversationId,
'sender_id': senderId,
'sender_type': senderType,
'content': content,
'content_type': contentType,
'created_at': createdAt.toIso8601String(),
'delivered_at': deliveredAt?.toIso8601String(),
'read_at': readAt?.toIso8601String(),
'status': status,
'is_announcement': isAnnouncement,
};
}
/// Crée une copie modifiée de l'instance
MessageModel copyWith({
String? id,
String? conversationId,
String? senderId,
String? senderType,
String? content,
String? contentType,
DateTime? createdAt,
DateTime? deliveredAt,
DateTime? readAt,
String? status,
bool? isAnnouncement,
}) {
return MessageModel(
id: id ?? this.id,
conversationId: conversationId ?? this.conversationId,
senderId: senderId ?? this.senderId,
senderType: senderType ?? this.senderType,
content: content ?? this.content,
contentType: contentType ?? this.contentType,
createdAt: createdAt ?? this.createdAt,
deliveredAt: deliveredAt ?? this.deliveredAt,
readAt: readAt ?? this.readAt,
status: status ?? this.status,
isAnnouncement: isAnnouncement ?? this.isAnnouncement,
);
}
@override
List<Object?> get props => [
id,
conversationId,
senderId,
senderType,
content,
contentType,
createdAt,
deliveredAt,
readAt,
status,
isAnnouncement,
];
}

View File

@@ -0,0 +1,71 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'message_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class MessageModelAdapter extends TypeAdapter<MessageModel> {
@override
final int typeId = 21;
@override
MessageModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return MessageModel(
id: fields[0] as String,
conversationId: fields[1] as String,
senderId: fields[2] as String?,
senderType: fields[3] as String,
content: fields[4] as String,
contentType: fields[5] as String,
createdAt: fields[6] as DateTime,
deliveredAt: fields[7] as DateTime?,
readAt: fields[8] as DateTime?,
status: fields[9] as String,
isAnnouncement: fields[10] as bool,
);
}
@override
void write(BinaryWriter writer, MessageModel obj) {
writer
..writeByte(11)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.conversationId)
..writeByte(2)
..write(obj.senderId)
..writeByte(3)
..write(obj.senderType)
..writeByte(4)
..write(obj.content)
..writeByte(5)
..write(obj.contentType)
..writeByte(6)
..write(obj.createdAt)
..writeByte(7)
..write(obj.deliveredAt)
..writeByte(8)
..write(obj.readAt)
..writeByte(9)
..write(obj.status)
..writeByte(10)
..write(obj.isAnnouncement);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MessageModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,160 @@
import 'package:hive/hive.dart';
import 'package:equatable/equatable.dart';
part 'notification_settings.g.dart';
/// Paramètres de notification pour le chat
///
/// Permet à l'utilisateur de configurer ses préférences de notification
@HiveType(typeId: 25)
class NotificationSettings extends HiveObject with EquatableMixin {
@HiveField(0)
final bool enableNotifications;
@HiveField(1)
final bool soundEnabled;
@HiveField(2)
final bool vibrationEnabled;
@HiveField(3)
final List<String> mutedConversations;
@HiveField(4)
final bool showPreview;
@HiveField(5)
final Map<String, bool> conversationNotifications;
@HiveField(6)
final bool doNotDisturb;
@HiveField(7)
final DateTime? doNotDisturbStart;
@HiveField(8)
final DateTime? doNotDisturbEnd;
@HiveField(9)
final String? deviceToken;
NotificationSettings({
this.enableNotifications = true,
this.soundEnabled = true,
this.vibrationEnabled = true,
this.mutedConversations = const [],
this.showPreview = true,
this.conversationNotifications = const {},
this.doNotDisturb = false,
this.doNotDisturbStart,
this.doNotDisturbEnd,
this.deviceToken,
});
/// Crée une instance depuis le JSON
factory NotificationSettings.fromJson(Map<String, dynamic> json) {
return NotificationSettings(
enableNotifications: json['enable_notifications'] as bool? ?? true,
soundEnabled: json['sound_enabled'] as bool? ?? true,
vibrationEnabled: json['vibration_enabled'] as bool? ?? true,
mutedConversations: List<String>.from(json['muted_conversations'] ?? []),
showPreview: json['show_preview'] as bool? ?? true,
conversationNotifications: Map<String, bool>.from(json['conversation_notifications'] ?? {}),
doNotDisturb: json['do_not_disturb'] as bool? ?? false,
doNotDisturbStart: json['do_not_disturb_start'] != null
? DateTime.parse(json['do_not_disturb_start'])
: null,
doNotDisturbEnd: json['do_not_disturb_end'] != null
? DateTime.parse(json['do_not_disturb_end'])
: null,
deviceToken: json['device_token'] as String?,
);
}
/// Convertit l'instance en JSON
Map<String, dynamic> toJson() {
return {
'enable_notifications': enableNotifications,
'sound_enabled': soundEnabled,
'vibration_enabled': vibrationEnabled,
'muted_conversations': mutedConversations,
'show_preview': showPreview,
'conversation_notifications': conversationNotifications,
'do_not_disturb': doNotDisturb,
'do_not_disturb_start': doNotDisturbStart?.toIso8601String(),
'do_not_disturb_end': doNotDisturbEnd?.toIso8601String(),
'device_token': deviceToken,
};
}
/// Crée une copie modifiée de l'instance
NotificationSettings copyWith({
bool? enableNotifications,
bool? soundEnabled,
bool? vibrationEnabled,
List<String>? mutedConversations,
bool? showPreview,
Map<String, bool>? conversationNotifications,
bool? doNotDisturb,
DateTime? doNotDisturbStart,
DateTime? doNotDisturbEnd,
String? deviceToken,
}) {
return NotificationSettings(
enableNotifications: enableNotifications ?? this.enableNotifications,
soundEnabled: soundEnabled ?? this.soundEnabled,
vibrationEnabled: vibrationEnabled ?? this.vibrationEnabled,
mutedConversations: mutedConversations ?? this.mutedConversations,
showPreview: showPreview ?? this.showPreview,
conversationNotifications: conversationNotifications ?? this.conversationNotifications,
doNotDisturb: doNotDisturb ?? this.doNotDisturb,
doNotDisturbStart: doNotDisturbStart ?? this.doNotDisturbStart,
doNotDisturbEnd: doNotDisturbEnd ?? this.doNotDisturbEnd,
deviceToken: deviceToken ?? this.deviceToken,
);
}
/// Vérifie si une conversation est en mode silencieux
bool isConversationMuted(String conversationId) {
return mutedConversations.contains(conversationId);
}
/// Vérifie si les notifications sont activées pour une conversation
bool areNotificationsEnabled(String conversationId) {
if (!enableNotifications) return false;
if (isConversationMuted(conversationId)) return false;
if (doNotDisturb && _isInDoNotDisturbPeriod()) return false;
return conversationNotifications[conversationId] ?? true;
}
/// Vérifie si on est dans la période "Ne pas déranger"
bool _isInDoNotDisturbPeriod() {
if (!doNotDisturb) return false;
if (doNotDisturbStart == null || doNotDisturbEnd == null) return false;
final now = DateTime.now();
if (doNotDisturbStart!.isBefore(doNotDisturbEnd!)) {
// Période normale (ex: 22h à 8h)
return now.isAfter(doNotDisturbStart!) && now.isBefore(doNotDisturbEnd!);
} else {
// Période qui chevauche minuit (ex: 20h à 6h)
return now.isAfter(doNotDisturbStart!) || now.isBefore(doNotDisturbEnd!);
}
}
@override
List<Object?> get props => [
enableNotifications,
soundEnabled,
vibrationEnabled,
mutedConversations,
showPreview,
conversationNotifications,
doNotDisturb,
doNotDisturbStart,
doNotDisturbEnd,
deviceToken,
];
}

View File

@@ -0,0 +1,68 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'notification_settings.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class NotificationSettingsAdapter extends TypeAdapter<NotificationSettings> {
@override
final int typeId = 25;
@override
NotificationSettings read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return NotificationSettings(
enableNotifications: fields[0] as bool,
soundEnabled: fields[1] as bool,
vibrationEnabled: fields[2] as bool,
mutedConversations: (fields[3] as List).cast<String>(),
showPreview: fields[4] as bool,
conversationNotifications: (fields[5] as Map).cast<String, bool>(),
doNotDisturb: fields[6] as bool,
doNotDisturbStart: fields[7] as DateTime?,
doNotDisturbEnd: fields[8] as DateTime?,
deviceToken: fields[9] as String?,
);
}
@override
void write(BinaryWriter writer, NotificationSettings obj) {
writer
..writeByte(10)
..writeByte(0)
..write(obj.enableNotifications)
..writeByte(1)
..write(obj.soundEnabled)
..writeByte(2)
..write(obj.vibrationEnabled)
..writeByte(3)
..write(obj.mutedConversations)
..writeByte(4)
..write(obj.showPreview)
..writeByte(5)
..write(obj.conversationNotifications)
..writeByte(6)
..write(obj.doNotDisturb)
..writeByte(7)
..write(obj.doNotDisturbStart)
..writeByte(8)
..write(obj.doNotDisturbEnd)
..writeByte(9)
..write(obj.deviceToken);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is NotificationSettingsAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,118 @@
import 'package:hive/hive.dart';
import 'package:equatable/equatable.dart';
part 'participant_model.g.dart';
/// Modèle de participant pour le système de chat
///
/// Ce modèle représente un participant à une conversation
@HiveType(typeId: 22)
class ParticipantModel extends HiveObject with EquatableMixin {
@HiveField(0)
final String id;
@HiveField(1)
final String conversationId;
@HiveField(2)
final String? userId;
@HiveField(3)
final String? anonymousId;
@HiveField(4)
final String role;
@HiveField(5)
final DateTime joinedAt;
@HiveField(6)
final String? lastReadMessageId;
@HiveField(7)
final bool viaTarget;
@HiveField(8)
final bool? canReply;
ParticipantModel({
required this.id,
required this.conversationId,
this.userId,
this.anonymousId,
required this.role,
required this.joinedAt,
this.lastReadMessageId,
this.viaTarget = false,
this.canReply,
});
/// Crée une instance depuis le JSON
factory ParticipantModel.fromJson(Map<String, dynamic> json) {
return ParticipantModel(
id: json['id'] as String,
conversationId: json['conversation_id'] as String,
userId: json['user_id'] as String?,
anonymousId: json['anonymous_id'] as String?,
role: json['role'] as String,
joinedAt: DateTime.parse(json['joined_at'] as String),
lastReadMessageId: json['last_read_message_id'] as String?,
viaTarget: json['via_target'] as bool? ?? false,
canReply: json['can_reply'] as bool?,
);
}
/// Convertit l'instance en JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'conversation_id': conversationId,
'user_id': userId,
'anonymous_id': anonymousId,
'role': role,
'joined_at': joinedAt.toIso8601String(),
'last_read_message_id': lastReadMessageId,
'via_target': viaTarget,
'can_reply': canReply,
};
}
/// Crée une copie modifiée de l'instance
ParticipantModel copyWith({
String? id,
String? conversationId,
String? userId,
String? anonymousId,
String? role,
DateTime? joinedAt,
String? lastReadMessageId,
bool? viaTarget,
bool? canReply,
}) {
return ParticipantModel(
id: id ?? this.id,
conversationId: conversationId ?? this.conversationId,
userId: userId ?? this.userId,
anonymousId: anonymousId ?? this.anonymousId,
role: role ?? this.role,
joinedAt: joinedAt ?? this.joinedAt,
lastReadMessageId: lastReadMessageId ?? this.lastReadMessageId,
viaTarget: viaTarget ?? this.viaTarget,
canReply: canReply ?? this.canReply,
);
}
@override
List<Object?> get props => [
id,
conversationId,
userId,
anonymousId,
role,
joinedAt,
lastReadMessageId,
viaTarget,
canReply,
];
}

View File

@@ -0,0 +1,65 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'participant_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ParticipantModelAdapter extends TypeAdapter<ParticipantModel> {
@override
final int typeId = 22;
@override
ParticipantModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ParticipantModel(
id: fields[0] as String,
conversationId: fields[1] as String,
userId: fields[2] as String?,
anonymousId: fields[3] as String?,
role: fields[4] as String,
joinedAt: fields[5] as DateTime,
lastReadMessageId: fields[6] as String?,
viaTarget: fields[7] as bool,
canReply: fields[8] as bool?,
);
}
@override
void write(BinaryWriter writer, ParticipantModel obj) {
writer
..writeByte(9)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.conversationId)
..writeByte(2)
..write(obj.userId)
..writeByte(3)
..write(obj.anonymousId)
..writeByte(4)
..write(obj.role)
..writeByte(5)
..write(obj.joinedAt)
..writeByte(6)
..write(obj.lastReadMessageId)
..writeByte(7)
..write(obj.viaTarget)
..writeByte(8)
..write(obj.canReply);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ParticipantModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import '../widgets/conversations_list.dart';
import '../widgets/chat_screen.dart';
/// Page principale du module chat
///
/// Cette page sert de point d'entrée pour le module chat
/// et gère la navigation entre les conversations
class ChatPage extends StatefulWidget {
const ChatPage({super.key});
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
String? _selectedConversationId;
@override
Widget build(BuildContext context) {
final isLargeScreen = MediaQuery.of(context).size.width > 900;
if (isLargeScreen) {
// Vue desktop (séparée en deux panneaux)
return Scaffold(
body: Row(
children: [
// Liste des conversations à gauche
SizedBox(
width: 300,
child: ConversationsList(
onConversationSelected: (conversation) {
setState(() {
_selectedConversationId = 'conversation-id'; // TODO: obtenir l'ID de la conversation
});
},
),
),
const VerticalDivider(width: 1),
// Conversation sélectionnée à droite
Expanded(
child: _selectedConversationId != null
? ChatScreen(conversationId: _selectedConversationId!)
: const Center(child: Text('Sélectionnez une conversation')),
),
],
),
);
} else {
// Vue mobile
return Scaffold(
appBar: AppBar(
title: const Text('Chat'),
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: () {
// TODO: Créer une nouvelle conversation
},
),
],
),
body: ConversationsList(
onConversationSelected: (conversation) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => ChatScreen(
conversationId: 'conversation-id', // TODO: obtenir l'ID de la conversation
),
),
);
},
),
);
}
}
}

View File

@@ -0,0 +1,364 @@
import 'package:hive/hive.dart';
import 'package:uuid/uuid.dart';
import '../../core/constants/app_keys.dart';
import '../models/conversation_model.dart';
import '../models/message_model.dart';
import '../models/participant_model.dart';
import '../services/chat_api_service.dart';
import '../services/notifications/mqtt_notification_service.dart';
/// Repository pour la gestion des fonctionnalités de chat
///
/// Ce repository centralise toutes les opérations liées au chat,
/// y compris la gestion des conversations, des messages et des participants
class ChatRepository {
final ChatApiService _apiService;
final MqttNotificationService _mqttService;
ChatRepository(this._apiService, this._mqttService);
/// Liste des conversations de l'utilisateur
Future<List<ConversationModel>> getConversations({bool forceRefresh = false}) async {
try {
// Récupérer depuis Hive
var box = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
var localConversations = box.values.toList();
// Si on force le rafraîchissement ou qu'on n'a pas de données locales
if (forceRefresh || localConversations.isEmpty) {
try {
// Récupérer depuis l'API
var apiConversations = await _apiService.getConversations();
// Mettre à jour Hive
await box.clear();
for (var conversation in apiConversations) {
await box.put(conversation.id, conversation);
}
return apiConversations;
} catch (e) {
// Si l'API échoue, utiliser les données locales
if (localConversations.isNotEmpty) {
return localConversations;
}
rethrow;
}
}
return localConversations;
} catch (e) {
throw Exception('Erreur lors de la récupération des conversations: $e');
}
}
/// Récupère une conversation spécifique
Future<ConversationModel> getConversation(String id) async {
try {
// Vérifier d'abord dans Hive
var box = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
var localConversation = box.get(id);
if (localConversation != null) {
return localConversation;
}
// Sinon récupérer depuis l'API
var apiConversation = await _apiService.getConversation(id);
await box.put(id, apiConversation);
return apiConversation;
} catch (e) {
throw Exception('Erreur lors de la récupération de la conversation: $e');
}
}
/// Crée une nouvelle conversation
Future<ConversationModel> createConversation(Map<String, dynamic> data) async {
try {
// Créer via l'API
var conversation = await _apiService.createConversation(data);
// Sauvegarder dans Hive
var box = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
await box.put(conversation.id, conversation);
// S'abonner aux notifications de la conversation
await _mqttService.subscribeToConversation(conversation.id);
return conversation;
} catch (e) {
throw Exception('Erreur lors de la création de la conversation: $e');
}
}
/// Supprime une conversation
Future<void> deleteConversation(String id) async {
try {
// Supprimer via l'API
await _apiService.deleteConversation(id);
// Supprimer de Hive
var box = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
await box.delete(id);
// Se désabonner des notifications
await _mqttService.unsubscribeFromConversation(id);
} catch (e) {
throw Exception('Erreur lors de la suppression de la conversation: $e');
}
}
/// Épingle/désépingle une conversation
Future<void> pinConversation(String id, bool isPinned) async {
try {
await _apiService.pinConversation(id, isPinned);
// Mettre à jour dans Hive
var box = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
var conversation = box.get(id);
if (conversation != null) {
await box.put(id, conversation.copyWith(isPinned: isPinned));
}
} catch (e) {
throw Exception('Erreur lors de l\'épinglage de la conversation: $e');
}
}
/// Met à jour les permissions de réponse
Future<void> updateReplyPermission(String id, String replyPermission) async {
try {
await _apiService.updateReplyPermission(id, replyPermission);
// Mettre à jour dans Hive
var box = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
var conversation = box.get(id);
if (conversation != null) {
await box.put(id, conversation.copyWith(replyPermission: replyPermission));
}
} catch (e) {
throw Exception('Erreur lors de la mise à jour des permissions: $e');
}
}
/// Récupère les messages d'une conversation
Future<List<MessageModel>> getMessages(String conversationId, {int page = 1, int limit = 50}) async {
try {
// Récupérer depuis Hive
var box = await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
var localMessages = box.values
.where((m) => m.conversationId == conversationId)
.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
// Si on a assez de messages localement
if (localMessages.length >= page * limit) {
return localMessages.skip((page - 1) * limit).take(limit).toList();
}
try {
// Récupérer depuis l'API
var apiMessages = await _apiService.getMessages(conversationId, page: page, limit: limit);
// Mettre à jour Hive
for (var message in apiMessages) {
await box.put(message.id, message);
}
return apiMessages;
} catch (e) {
// Si l'API échoue, utiliser les données locales
if (localMessages.isNotEmpty) {
return localMessages.skip((page - 1) * limit).take(limit).toList();
}
rethrow;
}
} catch (e) {
throw Exception('Erreur lors de la récupération des messages: $e');
}
}
/// Envoie un message via MQTT
Future<void> sendMessage(String conversationId, Map<String, dynamic> messageData) async {
try {
// Générer un ID unique pour le message
var messageId = const Uuid().v4();
var userId = messageData['senderId'] as String?;
// Créer le message
var message = MessageModel(
id: messageId,
conversationId: conversationId,
senderId: userId,
senderType: 'user',
content: messageData['content'] as String,
contentType: messageData['contentType'] as String? ?? 'text',
createdAt: DateTime.now(),
status: 'sent',
isAnnouncement: messageData['isAnnouncement'] as bool? ?? false,
);
// Sauvegarder temporairement dans Hive
var box = await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
await box.put(messageId, message);
// Publier via MQTT
await _mqttService.publishMessage('chat/message/send', {
'messageId': messageId,
'conversationId': conversationId,
'senderId': userId,
'content': message.content,
'contentType': message.contentType,
'timestamp': message.createdAt.toIso8601String(),
'isAnnouncement': message.isAnnouncement,
});
} catch (e) {
throw Exception('Erreur lors de l\'envoi du message: $e');
}
}
/// Marque un message comme lu
Future<void> markMessageAsRead(String messageId) async {
try {
// Mettre à jour via l'API
await _apiService.markMessageAsRead(messageId);
// Mettre à jour dans Hive
var box = await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
var message = box.get(messageId);
if (message != null) {
await box.put(messageId, message.copyWith(
status: 'read',
readAt: DateTime.now(),
));
}
} catch (e) {
throw Exception('Erreur lors du marquage comme lu: $e');
}
}
/// Ajoute un participant à une conversation
Future<void> addParticipant(String conversationId, Map<String, dynamic> participantData) async {
try {
await _apiService.addParticipant(conversationId, participantData);
// Mettre à jour la conversation dans Hive
var conversationBox = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
var conversation = conversationBox.get(conversationId);
if (conversation != null) {
var updatedParticipants = List<ParticipantModel>.from(conversation.participants);
updatedParticipants.add(ParticipantModel.fromJson(participantData));
await conversationBox.put(conversationId, conversation.copyWith(participants: updatedParticipants));
}
} catch (e) {
throw Exception('Erreur lors de l\'ajout du participant: $e');
}
}
/// Retire un participant d'une conversation
Future<void> removeParticipant(String conversationId, String participantId) async {
try {
await _apiService.removeParticipant(conversationId, participantId);
// Mettre à jour la conversation dans Hive
var conversationBox = await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
var conversation = conversationBox.get(conversationId);
if (conversation != null) {
var updatedParticipants = List<ParticipantModel>.from(conversation.participants);
updatedParticipants.removeWhere((p) => p.id == participantId);
await conversationBox.put(conversationId, conversation.copyWith(participants: updatedParticipants));
}
} catch (e) {
throw Exception('Erreur lors du retrait du participant: $e');
}
}
/// Crée un utilisateur anonyme (pour Resalice)
Future<String> createAnonymousUser({String? name, String? email}) async {
try {
return await _apiService.createAnonymousUser(name: name, email: email);
} catch (e) {
throw Exception('Erreur lors de la création de l\'utilisateur anonyme: $e');
}
}
/// Convertit un utilisateur anonyme en utilisateur authentifié
Future<void> convertAnonymousToUser(String anonymousId, String userId) async {
try {
// Mettre à jour tous les messages de l'utilisateur anonyme
var messageBox = await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
var messages = messageBox.values.where((m) => m.senderId == anonymousId).toList();
for (var message in messages) {
await messageBox.put(message.id, message.copyWith(
senderId: userId,
senderType: 'user',
));
}
} catch (e) {
throw Exception('Erreur lors de la conversion de l\'utilisateur: $e');
}
}
/// Récupère les annonces
Future<List<ConversationModel>> getAnnouncements({bool forceRefresh = false}) async {
try {
// Filtrer les conversations pour n'avoir que les annonces
var conversations = await getConversations(forceRefresh: forceRefresh);
return conversations.where((c) => c.type == 'announcement').toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des annonces: $e');
}
}
/// Crée une nouvelle annonce
Future<ConversationModel> createAnnouncement(Map<String, dynamic> data) async {
try {
// Créer la conversation comme une annonce
data['type'] = 'announcement';
return await createConversation(data);
} catch (e) {
throw Exception('Erreur lors de la création de l\'annonce: $e');
}
}
/// Récupère les statistiques d'une annonce
Future<Map<String, dynamic>> getAnnouncementStats(String conversationId) async {
try {
return await _apiService.getAnnouncementStats(conversationId);
} catch (e) {
throw Exception('Erreur lors de la récupération des statistiques: $e');
}
}
/// Récupère les cibles d'audience disponibles
Future<List<Map<String, dynamic>>> getAvailableAudienceTargets() async {
try {
return await _apiService.getAvailableAudienceTargets();
} catch (e) {
throw Exception('Erreur lors de la récupération des cibles: $e');
}
}
/// Ajoute une cible d'audience
Future<void> addAudienceTarget(String conversationId, Map<String, dynamic> targetData) async {
try {
// L'ajout des cibles d'audience est géré lors de la création de l'annonce
// Mais on pourrait avoir besoin de modifier les cibles plus tard
throw UnimplementedError('Ajout de cible non encore implémenté');
} catch (e) {
throw Exception('Erreur lors de l\'ajout de cible: $e');
}
}
/// Retire une cible d'audience
Future<void> removeAudienceTarget(String conversationId, String targetId) async {
try {
// Le retrait des cibles d'audience est géré lors de la création de l'annonce
throw UnimplementedError('Retrait de cible non encore implémenté');
} catch (e) {
throw Exception('Erreur lors du retrait de cible: $e');
}
}
}

View File

@@ -0,0 +1,213 @@
-- Script de création des tables chat pour MariaDB
-- Compatible avec le module chat GEOSECTOR
-- Création des tables pour le système de chat
-- Table des salles de discussion
DROP TABLE IF EXISTS `chat_rooms`;
CREATE TABLE `chat_rooms` (
`id` varchar(50) NOT NULL,
`type` enum('privee', 'groupe', 'liste_diffusion', 'broadcast', 'announcement') NOT NULL,
`title` varchar(100) DEFAULT NULL,
`date_creation` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`fk_user` int unsigned NOT NULL,
`fk_entite` int unsigned DEFAULT NULL,
`statut` enum('active', 'archive') NOT NULL DEFAULT 'active',
`description` text,
`reply_permission` enum('all', 'admins_only', 'sender_only', 'none') NOT NULL DEFAULT 'all',
`is_pinned` tinyint(1) unsigned NOT NULL DEFAULT 0,
`expiry_date` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user` (`fk_user`),
KEY `idx_entite` (`fk_entite`),
KEY `idx_type` (`type`),
KEY `idx_statut` (`statut`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table des participants aux salles de discussion
DROP TABLE IF EXISTS `chat_participants`;
CREATE TABLE `chat_participants` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`id_room` varchar(50) NOT NULL,
`id_user` int unsigned DEFAULT NULL,
`anonymous_id` varchar(50) DEFAULT NULL,
`role` enum('administrateur', 'participant', 'en_lecture_seule') NOT NULL DEFAULT 'participant',
`date_ajout` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`notification_activee` tinyint(1) unsigned NOT NULL DEFAULT 1,
`last_read_message_id` varchar(50) DEFAULT NULL,
`via_target` tinyint(1) unsigned NOT NULL DEFAULT 0,
`can_reply` tinyint(1) unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_room` (`id_room`),
KEY `idx_user` (`id_user`),
KEY `idx_anonymous_id` (`anonymous_id`),
CONSTRAINT `fk_chat_participants_room` FOREIGN KEY (`id_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE,
CONSTRAINT `uc_room_user` UNIQUE (`id_room`, `id_user`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table des messages
DROP TABLE IF EXISTS `chat_messages`;
CREATE TABLE `chat_messages` (
`id` varchar(50) NOT NULL,
`fk_room` varchar(50) NOT NULL,
`fk_user` int unsigned DEFAULT NULL,
`sender_type` enum('user', 'anonymous', 'system') NOT NULL DEFAULT 'user',
`content` text,
`content_type` enum('text', 'image', 'file') NOT NULL DEFAULT 'text',
`date_sent` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`date_delivered` timestamp NULL DEFAULT NULL,
`date_read` timestamp NULL DEFAULT NULL,
`statut` enum('envoye', 'livre', 'lu', 'error') NOT NULL DEFAULT 'envoye',
`is_announcement` tinyint(1) unsigned NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_room` (`fk_room`),
KEY `idx_user` (`fk_user`),
KEY `idx_date` (`date_sent`),
KEY `idx_status` (`statut`),
CONSTRAINT `fk_chat_messages_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table des cibles d'audience
DROP TABLE IF EXISTS `chat_audience_targets`;
CREATE TABLE `chat_audience_targets` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`fk_room` varchar(50) NOT NULL,
`target_type` enum('role', 'entity', 'all', 'combined') NOT NULL DEFAULT 'all',
`target_id` varchar(50) DEFAULT NULL,
`role_filter` varchar(20) DEFAULT NULL,
`entity_filter` varchar(50) DEFAULT NULL,
`date_creation` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_room` (`fk_room`),
KEY `idx_type` (`target_type`),
CONSTRAINT `fk_chat_audience_targets_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table des listes de diffusion
DROP TABLE IF EXISTS `chat_broadcast_lists`;
CREATE TABLE `chat_broadcast_lists` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`fk_room` varchar(50) NOT NULL,
`name` varchar(100) NOT NULL,
`description` text,
`fk_user_creator` int unsigned NOT NULL,
`date_creation` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_room` (`fk_room`),
KEY `idx_user_creator` (`fk_user_creator`),
CONSTRAINT `fk_chat_broadcast_lists_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour suivre la lecture des messages
DROP TABLE IF EXISTS `chat_read_messages`;
CREATE TABLE `chat_read_messages` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`fk_message` varchar(50) NOT NULL,
`fk_user` int unsigned NOT NULL,
`date_read` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_message` (`fk_message`),
KEY `idx_user` (`fk_user`),
CONSTRAINT `uc_message_user` UNIQUE (`fk_message`, `fk_user`),
CONSTRAINT `fk_chat_read_messages_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table des notifications
DROP TABLE IF EXISTS `chat_notifications`;
CREATE TABLE `chat_notifications` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`fk_user` int unsigned NOT NULL,
`fk_message` varchar(50) DEFAULT NULL,
`fk_room` varchar(50) DEFAULT NULL,
`type` varchar(50) NOT NULL,
`contenu` text,
`date_creation` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`date_lecture` timestamp NULL DEFAULT NULL,
`statut` enum('non_lue', 'lue') NOT NULL DEFAULT 'non_lue',
PRIMARY KEY (`id`),
KEY `idx_user` (`fk_user`),
KEY `idx_message` (`fk_message`),
KEY `idx_room` (`fk_room`),
KEY `idx_statut` (`statut`),
CONSTRAINT `fk_chat_notifications_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE SET NULL,
CONSTRAINT `fk_chat_notifications_room` FOREIGN KEY (`fk_room`) REFERENCES `chat_rooms` (`id`) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table des utilisateurs anonymes (pour Resalice)
DROP TABLE IF EXISTS `chat_anonymous_users`;
CREATE TABLE `chat_anonymous_users` (
`id` varchar(50) NOT NULL,
`device_id` varchar(100) NOT NULL,
`name` varchar(100) DEFAULT NULL,
`email` varchar(100) DEFAULT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`converted_to_user_id` int unsigned DEFAULT NULL,
`metadata` json DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_device_id` (`device_id`),
KEY `idx_converted_user` (`converted_to_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour la file d'attente hors ligne
DROP TABLE IF EXISTS `chat_offline_queue`;
CREATE TABLE `chat_offline_queue` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`user_id` int unsigned NOT NULL,
`operation_type` varchar(50) NOT NULL,
`operation_data` json NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
`processed_at` timestamp NULL DEFAULT NULL,
`status` enum('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending',
`error_message` text,
PRIMARY KEY (`id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_status` (`status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Table pour les pièces jointes
DROP TABLE IF EXISTS `chat_attachments`;
CREATE TABLE `chat_attachments` (
`id` varchar(50) NOT NULL,
`fk_message` varchar(50) NOT NULL,
`file_name` varchar(255) NOT NULL,
`file_path` varchar(500) NOT NULL,
`file_type` varchar(100) NOT NULL,
`file_size` int unsigned NOT NULL,
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_message` (`fk_message`),
CONSTRAINT `fk_chat_attachments_message` FOREIGN KEY (`fk_message`) REFERENCES `chat_messages` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Vues utiles
-- Vue des messages avec informations utilisateur
CREATE OR REPLACE VIEW `chat_messages_with_users` AS
SELECT
m.*,
u.name as sender_name,
u.username as sender_username,
u.fk_entite as sender_entity_id
FROM chat_messages m
LEFT JOIN users u ON m.fk_user = u.id;
-- Vue des conversations avec compte de messages non lus
CREATE OR REPLACE VIEW `chat_conversations_unread` AS
SELECT
r.*,
COUNT(DISTINCT m.id) as total_messages,
COUNT(DISTINCT rm.id) as read_messages,
COUNT(DISTINCT m.id) - COUNT(DISTINCT rm.id) as unread_messages,
(SELECT date_sent FROM chat_messages
WHERE fk_room = r.id
ORDER BY date_sent DESC LIMIT 1) as last_message_date
FROM chat_rooms r
LEFT JOIN chat_messages m ON r.id = m.fk_room
LEFT JOIN chat_read_messages rm ON m.id = rm.fk_message
GROUP BY r.id;
-- Index supplémentaires pour les performances
CREATE INDEX idx_messages_unread ON chat_messages(fk_room, statut);
CREATE INDEX idx_participants_active ON chat_participants(id_room, id_user, notification_activee);
CREATE INDEX idx_notifications_unread ON chat_notifications(fk_user, statut);

View File

@@ -0,0 +1,323 @@
<?php
/**
* Service d'envoi de notifications MQTT pour le chat
*
* Ce script gère l'envoi des notifications via MQTT depuis le backend PHP
*/
require_once 'vendor/autoload.php'; // PhpMqtt
use PhpMqtt\Client\MqttClient;
use PhpMqtt\Client\ConnectionSettings;
class MqttNotificationSender {
private $mqtt;
private $db;
private $config;
public function __construct($dbConnection, $mqttConfig) {
$this->db = $dbConnection;
$this->config = $mqttConfig;
// Initialiser le client MQTT
$this->initializeMqttClient();
}
private function initializeMqttClient() {
$this->mqtt = new MqttClient(
$this->config['host'],
$this->config['port'],
'geosector_api_' . uniqid(), // Client ID unique
MqttClient::MQTT_3_1_1
);
$connectionSettings = (new ConnectionSettings)
->setUsername($this->config['username'])
->setPassword($this->config['password'])
->setKeepAliveInterval(60)
->setConnectTimeout(30)
->setUseTls($this->config['use_ssl'] ?? false);
$this->mqtt->connect($connectionSettings, true);
}
/**
* Envoie une notification pour un nouveau message
*/
public function sendMessageNotification($receiverId, $senderId, $messageId, $content, $conversationId) {
try {
// Vérifier les préférences de notification
$settings = $this->getUserNotificationSettings($receiverId);
if (!$this->shouldSendNotification($settings, $conversationId)) {
return ['status' => 'skipped', 'reason' => 'notification_settings'];
}
// Obtenir les informations de l'expéditeur
$sender = $this->getSenderInfo($senderId);
// Obtenir le nom de la conversation
$conversationName = $this->getConversationName($conversationId, $receiverId);
// Préparer le payload de la notification
$payload = [
'type' => 'chat_message',
'messageId' => $messageId,
'conversationId' => $conversationId,
'senderId' => $senderId,
'senderName' => $sender['name'] ?? 'Utilisateur',
'content' => $settings['show_preview'] ? $content : 'Nouveau message',
'conversationName' => $conversationName,
'timestamp' => time(),
];
// Définir le topic MQTT
$topic = sprintf('chat/user/%s/messages', $receiverId);
// Publier le message
$this->mqtt->publish($topic, json_encode($payload), 1);
// Enregistrer la notification dans la base de données
$this->saveNotificationToDatabase($receiverId, $messageId, $conversationId, $payload);
return [
'status' => 'success',
'topic' => $topic
];
} catch (Exception $e) {
return [
'status' => 'error',
'reason' => $e->getMessage()
];
}
}
/**
* Envoie une annonce à plusieurs utilisateurs
*/
public function sendBroadcastAnnouncement($audienceTargets, $messageId, $title, $content, $conversationId) {
$results = [];
$userIds = $this->resolveAudienceTargets($audienceTargets);
foreach ($userIds as $userId) {
// Préparer le payload pour l'annonce
$payload = [
'type' => 'announcement',
'messageId' => $messageId,
'conversationId' => $conversationId,
'title' => $title,
'content' => $content,
'timestamp' => time(),
];
// Envoyer à chaque utilisateur
$topic = sprintf('chat/user/%s/messages', $userId);
try {
$this->mqtt->publish($topic, json_encode($payload), 1);
$results[$userId] = ['status' => 'success'];
// Enregistrer la notification
$this->saveNotificationToDatabase($userId, $messageId, $conversationId, $payload);
} catch (Exception $e) {
$results[$userId] = ['status' => 'error', 'reason' => $e->getMessage()];
}
}
// Publier aussi sur le topic général des annonces
$this->mqtt->publish('chat/announcement', json_encode($payload), 1);
return $results;
}
/**
* Envoie une notification à une conversation spécifique
*/
public function sendConversationNotification($conversationId, $messageId, $senderId, $content) {
$participants = $this->getConversationParticipants($conversationId);
foreach ($participants as $participant) {
if ($participant['id'] !== $senderId) {
$this->sendMessageNotification(
$participant['id'],
$senderId,
$messageId,
$content,
$conversationId
);
}
}
}
/**
* Vérifie si une notification doit être envoyée
*/
private function shouldSendNotification($settings, $conversationId) {
if (!$settings['enable_notifications']) {
return false;
}
if (in_array($conversationId, $settings['muted_conversations'])) {
return false;
}
if ($settings['do_not_disturb'] && $this->isInDoNotDisturbPeriod($settings)) {
return false;
}
return true;
}
/**
* Récupère les paramètres de notification de l'utilisateur
*/
private function getUserNotificationSettings($userId) {
$stmt = $this->db->prepare("
SELECT * FROM notification_settings
WHERE user_id = ?
");
$stmt->execute([$userId]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
// Valeurs par défaut si pas de préférences
return $result ?: [
'enable_notifications' => true,
'show_preview' => true,
'muted_conversations' => [],
'do_not_disturb' => false,
'do_not_disturb_start' => null,
'do_not_disturb_end' => null,
];
}
/**
* Vérifie si on est dans la période "Ne pas déranger"
*/
private function isInDoNotDisturbPeriod($settings) {
if (!$settings['do_not_disturb']) {
return false;
}
$now = new DateTime();
$start = new DateTime($settings['do_not_disturb_start']);
$end = new DateTime($settings['do_not_disturb_end']);
if ($start < $end) {
return $now >= $start && $now <= $end;
} else {
// Période qui chevauche minuit
return $now >= $start || $now <= $end;
}
}
/**
* Enregistre la notification dans la base de données
*/
private function saveNotificationToDatabase($userId, $messageId, $conversationId, $payload) {
$stmt = $this->db->prepare("
INSERT INTO chat_notifications
(fk_user, fk_message, fk_room, type, contenu, statut)
VALUES (?, ?, ?, ?, ?, 'non_lue')
");
$stmt->execute([
$userId,
$messageId,
$conversationId,
$payload['type'],
json_encode($payload)
]);
}
/**
* Récupère les informations de l'expéditeur
*/
private function getSenderInfo($senderId) {
$stmt = $this->db->prepare("
SELECT id, name, username
FROM users
WHERE id = ?
");
$stmt->execute([$senderId]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
/**
* Récupère le nom de la conversation
*/
private function getConversationName($conversationId, $userId) {
$stmt = $this->db->prepare("
SELECT title
FROM chat_rooms
WHERE id = ?
");
$stmt->execute([$conversationId]);
return $stmt->fetchColumn();
}
/**
* Récupère les participants d'une conversation
*/
private function getConversationParticipants($conversationId) {
$stmt = $this->db->prepare("
SELECT id_user as id, role
FROM chat_participants
WHERE id_room = ? AND notification_activee = 1
");
$stmt->execute([$conversationId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Résout les cibles d'audience en une liste d'IDs utilisateur
*/
private function resolveAudienceTargets($targets) {
$userIds = [];
foreach ($targets as $target) {
switch ($target['target_type']) {
case 'all':
$stmt = $this->db->query("SELECT id FROM users WHERE chk_active = 1");
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
break;
case 'role':
$stmt = $this->db->prepare("SELECT id FROM users WHERE fk_role = ?");
$stmt->execute([$target['role_filter']]);
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
break;
case 'entity':
$stmt = $this->db->prepare("SELECT id FROM users WHERE fk_entite = ?");
$stmt->execute([$target['entity_filter']]);
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
break;
case 'combined':
$stmt = $this->db->prepare("
SELECT id FROM users
WHERE fk_role = ? AND fk_entite = ?
");
$stmt->execute([$target['role_filter'], $target['entity_filter']]);
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
break;
}
}
return array_unique($userIds);
}
/**
* Ferme la connexion MQTT
*/
public function disconnect() {
if ($this->mqtt) {
$this->mqtt->disconnect();
}
}
}

View File

@@ -0,0 +1,263 @@
<?php
/**
* Script d'envoi de notifications push pour le chat
*
* Ce script est appelé par l'API backend pour envoyer des notifications
* lorsqu'un nouveau message est reçu
*/
require_once 'vendor/autoload.php';
use Kreait\Firebase\Factory;
use Kreait\Firebase\Messaging\CloudMessage;
use Kreait\Firebase\Messaging\Notification;
class ChatNotificationSender {
private $messaging;
private $db;
public function __construct($firebaseServiceAccount, $dbConnection) {
$factory = (new Factory)->withServiceAccount($firebaseServiceAccount);
$this->messaging = $factory->createMessaging();
$this->db = $dbConnection;
}
/**
* Envoie une notification à un utilisateur pour un nouveau message
*/
public function sendMessageNotification($userId, $messageId, $senderId, $content, $conversationId) {
try {
// Récupérer les préférences de notification de l'utilisateur
$settings = $this->getUserNotificationSettings($userId);
if (!$settings['enable_notifications']) {
return ['status' => 'skipped', 'reason' => 'notifications_disabled'];
}
// Vérifier si la conversation est en silencieux
if (in_array($conversationId, $settings['muted_conversations'])) {
return ['status' => 'skipped', 'reason' => 'conversation_muted'];
}
// Vérifier le mode Ne pas déranger
if ($this->isInDoNotDisturbPeriod($settings)) {
return ['status' => 'skipped', 'reason' => 'do_not_disturb'];
}
// Obtenir le token du device
$deviceToken = $this->getUserDeviceToken($userId);
if (!$deviceToken) {
return ['status' => 'error', 'reason' => 'no_device_token'];
}
// Obtenir les informations de l'expéditeur
$sender = $this->getSenderInfo($senderId);
// Obtenir le nom de la conversation
$conversationName = $this->getConversationName($conversationId, $userId);
// Préparation du contenu de la notification
$title = $conversationName ?? $sender['name'];
$body = $settings['show_preview'] ? $content : 'Nouveau message';
// Créer le message Firebase
$message = CloudMessage::withTarget('token', $deviceToken)
->withNotification(Notification::create($title, $body))
->withData([
'type' => 'chat_message',
'messageId' => $messageId,
'conversationId' => $conversationId,
'senderId' => $senderId,
'click_action' => 'FLUTTER_NOTIFICATION_CLICK',
])
->withAndroidConfig([
'priority' => 'high',
'notification' => [
'sound' => $settings['sound_enabled'] ? 'default' : null,
'channel_id' => 'chat_messages',
'icon' => 'ic_launcher',
],
])
->withApnsConfig([
'payload' => [
'aps' => [
'sound' => $settings['sound_enabled'] ? 'default' : null,
'badge' => 1, // TODO: Calculer le nombre réel de messages non lus
],
],
]);
// Envoyer la notification
$result = $this->messaging->send($message);
// Enregistrer la notification dans la base de données
$this->saveNotificationToDatabase($userId, $messageId, $conversationId, $title, $body);
return [
'status' => 'success',
'message_id' => $result,
];
} catch (Exception $e) {
return [
'status' => 'error',
'reason' => $e->getMessage(),
];
}
}
/**
* Envoie une notification de type broadcast
*/
public function sendBroadcastNotification($audienceTargets, $messageId, $content, $conversationId) {
$results = [];
// Résoudre les cibles d'audience
$userIds = $this->resolveAudienceTargets($audienceTargets);
foreach ($userIds as $userId) {
$result = $this->sendMessageNotification($userId, $messageId, null, $content, $conversationId);
$results[$userId] = $result;
}
return $results;
}
/**
* Enregistre la notification dans la base de données
*/
private function saveNotificationToDatabase($userId, $messageId, $conversationId, $title, $body) {
$stmt = $this->db->prepare("
INSERT INTO chat_notifications (fk_user, fk_message, fk_room, type, contenu, statut)
VALUES (?, ?, ?, 'chat_message', ?, 'non_lue')
");
$stmt->execute([$userId, $messageId, $conversationId, json_encode([
'title' => $title,
'body' => $body,
])]);
}
/**
* Récupère les préférences de notification de l'utilisateur
*/
private function getUserNotificationSettings($userId) {
// Implémenter la logique pour récupérer les paramètres
return [
'enable_notifications' => true,
'sound_enabled' => true,
'vibration_enabled' => true,
'muted_conversations' => [],
'show_preview' => true,
'do_not_disturb' => false,
'do_not_disturb_start' => null,
'do_not_disturb_end' => null,
];
}
/**
* Vérifie si on est dans la période Ne pas déranger
*/
private function isInDoNotDisturbPeriod($settings) {
if (!$settings['do_not_disturb']) {
return false;
}
$now = new DateTime();
$start = new DateTime($settings['do_not_disturb_start']);
$end = new DateTime($settings['do_not_disturb_end']);
if ($start < $end) {
return $now >= $start && $now <= $end;
} else {
// Période qui chevauche minuit
return $now >= $start || $now <= $end;
}
}
/**
* Récupère le token du device de l'utilisateur
*/
private function getUserDeviceToken($userId) {
$stmt = $this->db->prepare("
SELECT device_token
FROM notification_settings
WHERE user_id = ? AND device_token IS NOT NULL
ORDER BY updated_at DESC LIMIT 1
");
$stmt->execute([$userId]);
return $stmt->fetchColumn();
}
/**
* Récupère les informations de l'expéditeur
*/
private function getSenderInfo($senderId) {
$stmt = $this->db->prepare("
SELECT id, name, username
FROM users
WHERE id = ?
");
$stmt->execute([$senderId]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
/**
* Récupère le nom de la conversation
*/
private function getConversationName($conversationId, $userId) {
$stmt = $this->db->prepare("
SELECT title
FROM chat_rooms
WHERE id = ?
");
$stmt->execute([$conversationId]);
return $stmt->fetchColumn();
}
/**
* Résout les cibles d'audience en une liste d'IDs utilisateur
*/
private function resolveAudienceTargets($targets) {
$userIds = [];
foreach ($targets as $target) {
switch ($target['target_type']) {
case 'all':
// Récupérer tous les utilisateurs
$stmt = $this->db->query("SELECT id FROM users WHERE chk_active = 1");
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
break;
case 'role':
// Récupérer les utilisateurs par rôle
$stmt = $this->db->prepare("SELECT id FROM users WHERE fk_role = ?");
$stmt->execute([$target['role_filter']]);
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
break;
case 'entity':
// Récupérer les utilisateurs par entité
$stmt = $this->db->prepare("SELECT id FROM users WHERE fk_entite = ?");
$stmt->execute([$target['entity_filter']]);
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
break;
case 'combined':
// Récupérer les utilisateurs par combinaison de rôle et entité
$stmt = $this->db->prepare("
SELECT id FROM users
WHERE fk_role = ? AND fk_entite = ?
");
$stmt->execute([$target['role_filter'], $target['entity_filter']]);
$userIds = array_merge($userIds, $stmt->fetchAll(PDO::FETCH_COLUMN));
break;
}
}
return array_unique($userIds);
}
}

View File

@@ -0,0 +1,97 @@
/// Service API pour la communication avec le backend du chat
///
/// Ce service gère toutes les requêtes HTTP vers l'API chat
class ChatApiService {
final String baseUrl;
final String? authToken;
ChatApiService({
required this.baseUrl,
this.authToken,
});
/// Récupère les conversations
Future<Map<String, dynamic>> fetchConversations() async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
/// Récupère les messages d'une conversation
Future<Map<String, dynamic>> fetchMessages(String conversationId, {int page = 1, int limit = 50}) async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
/// Crée une nouvelle conversation
Future<Map<String, dynamic>> createConversation(Map<String, dynamic> data) async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
/// Envoie un message
Future<Map<String, dynamic>> sendMessage(String conversationId, Map<String, dynamic> messageData) async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
/// Marque un message comme lu
Future<Map<String, dynamic>> markMessageAsRead(String messageId) async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
/// Ajoute un participant
Future<Map<String, dynamic>> addParticipant(String conversationId, Map<String, dynamic> participantData) async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
/// Retire un participant
Future<Map<String, dynamic>> removeParticipant(String conversationId, String participantId) async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
/// Crée un utilisateur anonyme
Future<Map<String, dynamic>> createAnonymousUser({String? name, String? email}) async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
/// Récupère les annonces
Future<Map<String, dynamic>> fetchAnnouncements() async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
/// Crée une annonce
Future<Map<String, dynamic>> createAnnouncement(Map<String, dynamic> data) async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
/// Récupère les statistiques d'une annonce
Future<Map<String, dynamic>> fetchAnnouncementStats(String conversationId) async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
/// Récupère les cibles d'audience disponibles
Future<Map<String, dynamic>> fetchAvailableAudienceTargets() async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
/// Met à jour une conversation
Future<Map<String, dynamic>> updateConversation(String id, Map<String, dynamic> data) async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
/// Supprime une conversation
Future<void> deleteConversation(String id) async {
// TODO: Implémenter la requête HTTP
throw UnimplementedError();
}
}

View File

@@ -0,0 +1,214 @@
# Notifications MQTT pour le Chat GEOSECTOR
## Vue d'ensemble
Ce système de notifications utilise MQTT pour fournir des notifications push en temps réel pour le module chat. Il offre une alternative légère à Firebase Cloud Messaging (FCM) et peut être auto-hébergé dans votre infrastructure.
## Architecture
### Composants principaux
1. **MqttNotificationService** (Flutter)
- Service de notification côté client
- Gère la connexion au broker MQTT
- Traite les messages entrants
- Affiche les notifications locales
2. **MqttConfig** (Flutter)
- Configuration centralisée pour MQTT
- Gestion des topics
- Paramètres de connexion
3. **MqttNotificationSender** (PHP)
- Service backend pour envoyer les notifications
- Interface avec la base de données
- Gestion des cibles d'audience
## Configuration du broker MQTT
### Container Incus
Le broker MQTT (Eclipse Mosquitto recommandé) doit être installé dans votre container Incus :
```bash
# Installer Mosquitto
apt-get update
apt-get install mosquitto mosquitto-clients
# Configurer Mosquitto
vi /etc/mosquitto/mosquitto.conf
```
Configuration recommandée :
```
listener 1883
allow_anonymous false
password_file /etc/mosquitto/passwd
# Pour SSL/TLS
listener 8883
cafile /etc/mosquitto/ca.crt
certfile /etc/mosquitto/server.crt
keyfile /etc/mosquitto/server.key
```
### Sécurité
Pour un environnement de production, il est fortement recommandé :
1. D'utiliser SSL/TLS (port 8883)
2. De configurer l'authentification par mot de passe
3. De limiter les IPs pouvant se connecter
4. De configurer des ACLs pour restreindre l'accès aux topics
## Structure des topics MQTT
### Topics utilisateur
- `chat/user/{userId}/messages` - Messages personnels pour l'utilisateur
- `chat/user/{userId}/groups/{groupId}` - Messages des groupes de l'utilisateur
### Topics globaux
- `chat/announcement` - Annonces générales
- `chat/broadcast` - Diffusions à grande échelle
### Topics conversation
- `chat/conversation/{conversationId}` - Messages spécifiques à une conversation
## Intégration Flutter
### Dépendances requises
Ajoutez ces dépendances à votre `pubspec.yaml` :
```yaml
dependencies:
mqtt5_client: ^4.0.0 # ou mqtt_client selon votre préférence
flutter_local_notifications: ^17.0.0
```
### Initialisation
```dart
// Dans main.dart
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final notificationService = MqttNotificationService();
await notificationService.initialize(userId: currentUserId);
runApp(const GeoSectorApp());
}
```
### Utilisation
```dart
// Écouter les messages
notificationService.onMessageTap = (messageId) {
// Naviguer vers le message
Navigator.pushNamed(context, '/chat/$messageId');
};
// Publier un message
await notificationService.publishMessage(
'chat/user/$userId/messages',
{'content': 'Test message'},
);
```
## Gestion des notifications
### Paramètres utilisateur
Les utilisateurs peuvent configurer :
- Activation/désactivation des notifications
- Conversations en silencieux
- Mode "Ne pas déranger"
- Aperçu du contenu
### Persistance des notifications
Les notifications sont enregistrées dans la table `chat_notifications` pour :
- Traçabilité
- Statistiques
- Synchronisation
## Tests
### Test de connexion
```dart
final service = MqttNotificationService();
await service.initialize(userId: 'test_user');
// Vérifie les logs pour confirmer la connexion
```
### Test d'envoi
```php
$sender = new MqttNotificationSender($db, $mqttConfig);
$result = $sender->sendMessageNotification(
'receiver_id',
'sender_id',
'message_id',
'Test message',
'conversation_id'
);
```
## Surveillance et maintenance
### Logs
Les logs sont disponibles dans :
- Logs Flutter (console debug)
- Logs Mosquitto (`/var/log/mosquitto/mosquitto.log`)
- Logs PHP (selon configuration)
### Métriques à surveiller
- Nombre de connexions actives
- Latence des messages
- Taux d'échec des notifications
- Consommation mémoire/CPU du broker
## Comparaison avec Firebase
### Avantages MQTT
1. **Auto-hébergé** : Contrôle total de l'infrastructure
2. **Léger** : Moins de ressources que Firebase
3. **Coût** : Gratuit (uniquement coûts d'infrastructure)
4. **Personnalisable** : Configuration fine du broker
### Inconvénients
1. **Maintenance** : Nécessite une gestion du broker
2. **Évolutivité** : Requiert dimensionnement et clustering
3. **Fonctionnalités** : Moins de services intégrés que Firebase
## Évolutions futures
1. **WebSocket** : Ajout optionnel pour temps réel strict
2. **Clustering** : Pour haute disponibilité
3. **Analytics** : Dashboard de monitoring
4. **Webhooks** : Intégration avec d'autres services
## Dépannage
### Problèmes courants
1. **Connexion échouée**
- Vérifier username/password
- Vérifier port/hostname
- Vérifier firewall
2. **Messages non reçus**
- Vérifier abonnement aux topics
- Vérifier QoS
- Vérifier paramètres notifications
3. **Performance dégradée**
- Augmenter keepAlive
- Ajuster reconnectInterval
- Vérifier charge serveur

View File

@@ -0,0 +1,202 @@
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:flutter/foundation.dart';
/// Service de gestion des notifications chat
///
/// Gère l'envoi et la réception des notifications pour le module chat
class ChatNotificationService {
static final ChatNotificationService _instance = ChatNotificationService._internal();
factory ChatNotificationService() => _instance;
ChatNotificationService._internal();
final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
// Callback pour les actions sur les notifications
Function(String messageId)? onMessageTap;
Function(Map<String, dynamic>)? onBackgroundMessage;
/// Initialise le service de notifications
Future<void> initialize() async {
// Demander les permissions
await _requestPermissions();
// Initialiser les notifications locales
await _initializeLocalNotifications();
// Configurer les handlers de messages
_configureFirebaseHandlers();
// Obtenir le token du device
await _initializeDeviceToken();
}
/// Demande les permissions pour les notifications
Future<bool> _requestPermissions() async {
NotificationSettings settings = await _firebaseMessaging.requestPermission(
alert: true,
badge: true,
sound: true,
provisional: false,
);
return settings.authorizationStatus == AuthorizationStatus.authorized;
}
/// Initialise les notifications locales
Future<void> _initializeLocalNotifications() async {
const AndroidInitializationSettings androidSettings =
AndroidInitializationSettings('@mipmap/ic_launcher');
final DarwinInitializationSettings iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
onDidReceiveLocalNotification: _onDidReceiveLocalNotification,
);
final InitializationSettings initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
await _localNotifications.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationTap,
);
}
/// Configure les handlers Firebase
void _configureFirebaseHandlers() {
// Message reçu quand l'app est au premier plan
FirebaseMessaging.onMessage.listen(_onForegroundMessage);
// Message reçu quand l'app est en arrière-plan
FirebaseMessaging.onMessageOpenedApp.listen(_onBackgroundMessageOpened);
// Handler pour les messages en arrière-plan terminé
FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);
}
/// Handler pour les messages reçus au premier plan
Future<void> _onForegroundMessage(RemoteMessage message) async {
if (message.notification != null) {
// Afficher une notification locale
await _showLocalNotification(
title: message.notification!.title ?? 'Nouveau message',
body: message.notification!.body ?? '',
payload: message.data['messageId'] ?? '',
);
}
}
/// Handler pour les messages ouverts depuis l'arrière-plan
void _onBackgroundMessageOpened(RemoteMessage message) {
final messageId = message.data['messageId'];
if (messageId != null) {
onMessageTap?.call(messageId);
}
}
/// Affiche une notification locale
Future<void> _showLocalNotification({
required String title,
required String body,
required String payload,
}) async {
const AndroidNotificationDetails androidDetails = AndroidNotificationDetails(
'chat_messages',
'Messages de chat',
channelDescription: 'Notifications pour les nouveaux messages de chat',
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
);
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const NotificationDetails notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _localNotifications.show(
DateTime.now().microsecondsSinceEpoch,
title,
body,
notificationDetails,
payload: payload,
);
}
/// Handler pour le clic sur une notification
void _onNotificationTap(NotificationResponse response) {
final payload = response.payload;
if (payload != null) {
onMessageTap?.call(payload);
}
}
/// Handler pour les notifications iOS reçues au premier plan
void _onDidReceiveLocalNotification(int id, String? title, String? body, String? payload) {
// Traitement spécifique iOS si nécessaire
}
/// Obtient et stocke le token du device
Future<String?> _initializeDeviceToken() async {
String? token = await _firebaseMessaging.getToken();
if (token != null) {
// Envoyer le token au serveur pour stocker
await _sendTokenToServer(token);
}
// Écouter les changements de token
_firebaseMessaging.onTokenRefresh.listen(_sendTokenToServer);
return token;
}
/// Envoie le token FCM au serveur
Future<void> _sendTokenToServer(String token) async {
try {
// Appel API pour enregistrer le token
// await chatApiService.registerDeviceToken(token);
debugPrint('Device token enregistré : $token');
} catch (e) {
debugPrint('Erreur lors de l\'enregistrement du token : $e');
}
}
/// S'abonner aux notifications pour une conversation
Future<void> subscribeToConversation(String conversationId) async {
await _firebaseMessaging.subscribeToTopic('chat_$conversationId');
}
/// Se désabonner des notifications pour une conversation
Future<void> unsubscribeFromConversation(String conversationId) async {
await _firebaseMessaging.unsubscribeFromTopic('chat_$conversationId');
}
/// Désactive temporairement les notifications
Future<void> pauseNotifications() async {
await _firebaseMessaging.setAutoInitEnabled(false);
}
/// Réactive les notifications
Future<void> resumeNotifications() async {
await _firebaseMessaging.setAutoInitEnabled(true);
}
}
/// Handler pour les messages en arrière-plan
@pragma('vm:entry-point')
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
// Traitement des messages en arrière-plan
debugPrint('Message reçu en arrière-plan : ${message.messageId}');
}

View File

@@ -0,0 +1,74 @@
/// Configuration pour le broker MQTT
///
/// Centralise les paramètres de connexion au broker MQTT
class MqttConfig {
// Configuration du serveur MQTT
static const String host = 'mqtt.geosector.fr';
static const int port = 1883;
static const int securePort = 8883;
static const bool useSsl = false;
// Configuration d'authentification
static const String username = 'geosector_chat';
static const String password = 'secure_password_here';
// Préfixes des topics MQTT
static const String topicBase = 'chat';
static const String topicUserMessages = '$topicBase/user';
static const String topicAnnouncements = '$topicBase/announcement';
static const String topicGroups = '$topicBase/groups';
static const String topicConversations = '$topicBase/conversation';
// Configuration des sessions
static const int keepAliveInterval = 60;
static const int reconnectInterval = 5;
static const bool cleanSession = true;
// Configuration des notifications
static const int notificationRetryCount = 3;
static const Duration notificationTimeout = Duration(seconds: 30);
/// Génère un client ID unique pour chaque session
static String generateClientId(String userId) {
return 'chat_${userId}_${DateTime.now().millisecondsSinceEpoch}';
}
/// Retourne l'URL complète du broker selon la configuration SSL
static String get brokerUrl {
if (useSsl) {
return '$host:$securePort';
} else {
return '$host:$port';
}
}
/// Retourne le topic pour les messages d'un utilisateur
static String getUserMessageTopic(String userId) {
return '$topicUserMessages/$userId/messages';
}
/// Retourne le topic pour les annonces globales
static String getAnnouncementTopic() {
return topicAnnouncements;
}
/// Retourne le topic pour une conversation spécifique
static String getConversationTopic(String conversationId) {
return '$topicConversations/$conversationId';
}
/// Retourne le topic pour un groupe spécifique
static String getGroupTopic(String groupId) {
return '$topicGroups/$groupId';
}
/// Retourne les topics auxquels un utilisateur doit s'abonner
static List<String> getUserSubscriptionTopics(String userId) {
return [
getUserMessageTopic(userId),
getAnnouncementTopic(),
// Ajoutez d'autres topics selon les besoins
];
}
}

View File

@@ -0,0 +1,322 @@
import 'dart:async';
import 'dart:convert';
import 'package:mqtt5_client/mqtt5_client.dart';
import 'package:mqtt5_client/mqtt5_server_client.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
/// Service de gestion des notifications chat via MQTT
///
/// Utilise MQTT pour recevoir des notifications en temps réel
/// et afficher des notifications locales
class MqttNotificationService {
static final MqttNotificationService _instance = MqttNotificationService._internal();
factory MqttNotificationService() => _instance;
MqttNotificationService._internal();
late MqttServerClient _client;
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
// Configuration
final String mqttHost;
final int mqttPort;
final String mqttUsername;
final String mqttPassword;
final String clientId;
// État
bool _initialized = false;
String? _userId;
StreamSubscription? _messageSubscription;
// Callbacks
Function(String messageId)? onMessageTap;
Function(Map<String, dynamic>)? onNotificationReceived;
MqttNotificationService({
this.mqttHost = 'mqtt.geosector.fr',
this.mqttPort = 1883,
this.mqttUsername = '',
this.mqttPassword = '',
String? clientId,
}) : clientId = clientId ?? 'geosector_chat_${DateTime.now().millisecondsSinceEpoch}';
/// Initialise le service de notifications
Future<void> initialize({required String userId}) async {
if (_initialized) return;
_userId = userId;
// Initialiser les notifications locales
await _initializeLocalNotifications();
// Initialiser le client MQTT
await _initializeMqttClient();
_initialized = true;
}
/// Initialise le client MQTT
Future<void> _initializeMqttClient() async {
try {
_client = MqttServerClient.withPort(mqttHost, clientId, mqttPort);
_client.logging(on: kDebugMode);
_client.keepAlivePeriod = 60;
_client.onConnected = _onConnected;
_client.onDisconnected = _onDisconnected;
_client.onSubscribed = _onSubscribed;
_client.autoReconnect = true;
// Configurer les options de connexion
final connMessage = MqttConnectMessage()
.authenticateAs(mqttUsername, mqttPassword)
.withClientIdentifier(clientId)
.startClean()
.keepAliveFor(60);
_client.connectionMessage = connMessage;
// Se connecter
await _connect();
} catch (e) {
debugPrint('Erreur lors de l\'initialisation MQTT : $e');
rethrow;
}
}
/// Se connecte au broker MQTT
Future<void> _connect() async {
try {
await _client.connect();
} catch (e) {
debugPrint('Erreur de connexion MQTT : $e');
_client.disconnect();
rethrow;
}
}
/// Callback lors de la connexion
void _onConnected() {
debugPrint('Connecté au broker MQTT');
// S'abonner aux topics de l'utilisateur
if (_userId != null) {
_subscribeToUserTopics(_userId!);
}
// Écouter les messages
_messageSubscription = _client.updates?.listen(_onMessageReceived);
}
/// Callback lors de la déconnexion
void _onDisconnected() {
debugPrint('Déconnecté du broker MQTT');
// Tenter une reconnexion
if (_client.autoReconnect) {
Future.delayed(const Duration(seconds: 5), () {
_connect();
});
}
}
/// Callback lors de l'abonnement
void _onSubscribed(MqttSubscription subscription) {
debugPrint('Abonné au topic : ${subscription.topic.rawTopic}');
}
/// S'abonner aux topics de l'utilisateur
void _subscribeToUserTopics(String userId) {
// Topic pour les messages personnels
_client.subscribe('chat/user/$userId/messages', MqttQos.atLeastOnce);
// Topic pour les annonces
_client.subscribe('chat/announcement', MqttQos.atLeastOnce);
// Topic pour les groupes de l'utilisateur (si disponibles)
_client.subscribe('chat/user/$userId/groups/+', MqttQos.atLeastOnce);
}
/// Gère les messages reçus
void _onMessageReceived(List<MqttReceivedMessage<MqttMessage>> messages) {
for (var message in messages) {
final topic = message.topic;
final payload = message.payload as MqttPublishMessage;
final messageText = MqttUtilities.bytesToStringAsString(payload.payload.message!);
try {
final data = jsonDecode(messageText) as Map<String, dynamic>;
_handleNotification(topic, data);
} catch (e) {
debugPrint('Erreur lors du décodage du message : $e');
}
}
}
/// Traite la notification reçue
Future<void> _handleNotification(String topic, Map<String, dynamic> data) async {
// Vérifier les paramètres de notification de l'utilisateur
if (!await _shouldShowNotification(data)) {
return;
}
String title = '';
String body = '';
String messageId = '';
String conversationId = '';
if (topic.startsWith('chat/user/')) {
// Message personnel
title = data['senderName'] ?? 'Nouveau message';
body = data['content'] ?? '';
messageId = data['messageId'] ?? '';
conversationId = data['conversationId'] ?? '';
} else if (topic.startsWith('chat/announcement')) {
// Annonce
title = data['title'] ?? 'Annonce';
body = data['content'] ?? '';
messageId = data['messageId'] ?? '';
conversationId = data['conversationId'] ?? '';
}
// Afficher la notification locale
await _showLocalNotification(
title: title,
body: body,
payload: jsonEncode({
'messageId': messageId,
'conversationId': conversationId,
}),
);
// Appeler le callback si défini
onNotificationReceived?.call(data);
}
/// Vérifie si la notification doit être affichée
Future<bool> _shouldShowNotification(Map<String, dynamic> data) async {
// TODO: Vérifier les paramètres de notification de l'utilisateur
// - Notifications désactivées
// - Conversation en silencieux
// - Mode Ne pas déranger
return true;
}
/// Initialise les notifications locales
Future<void> _initializeLocalNotifications() async {
const androidSettings = AndroidInitializationSettings('@mipmap/ic_launcher');
const iosSettings = DarwinInitializationSettings(
requestAlertPermission: true,
requestBadgePermission: true,
requestSoundPermission: true,
);
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
await _localNotifications.initialize(
initSettings,
onDidReceiveNotificationResponse: _onNotificationTap,
);
}
/// Affiche une notification locale
Future<void> _showLocalNotification({
required String title,
required String body,
required String payload,
}) async {
const androidDetails = AndroidNotificationDetails(
'chat_messages',
'Messages de chat',
channelDescription: 'Notifications pour les nouveaux messages de chat',
importance: Importance.high,
priority: Priority.high,
icon: '@mipmap/ic_launcher',
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
const notificationDetails = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _localNotifications.show(
DateTime.now().microsecondsSinceEpoch,
title,
body,
notificationDetails,
payload: payload,
);
}
/// Handler pour le clic sur une notification
void _onNotificationTap(NotificationResponse response) {
final payload = response.payload;
if (payload != null) {
try {
final data = jsonDecode(payload) as Map<String, dynamic>;
final messageId = data['messageId'] as String?;
if (messageId != null) {
onMessageTap?.call(messageId);
}
} catch (e) {
debugPrint('Erreur lors du traitement du clic sur notification : $e');
}
}
}
/// Publie un message MQTT
Future<void> publishMessage(String topic, Map<String, dynamic> message) async {
if (_client.connectionStatus?.state != MqttConnectionState.connected) {
await _connect();
}
final messagePayload = jsonEncode(message);
final builder = MqttPayloadBuilder();
builder.addString(messagePayload);
_client.publishMessage(topic, MqttQos.atLeastOnce, builder.payload!);
}
/// S'abonner à une conversation spécifique
Future<void> subscribeToConversation(String conversationId) async {
if (_client.connectionStatus?.state == MqttConnectionState.connected) {
_client.subscribe('chat/conversation/$conversationId', MqttQos.atLeastOnce);
}
}
/// Se désabonner d'une conversation
Future<void> unsubscribeFromConversation(String conversationId) async {
if (_client.connectionStatus?.state == MqttConnectionState.connected) {
_client.unsubscribeStringTopic('chat/conversation/$conversationId');
}
}
/// Désactive temporairement les notifications
void pauseNotifications() {
_client.pause();
}
/// Réactive les notifications
void resumeNotifications() {
_client.resume();
}
/// Libère les ressources
void dispose() {
_messageSubscription?.cancel();
_client.disconnect();
_initialized = false;
}
}

View File

@@ -0,0 +1,46 @@
/// Service de gestion de la file d'attente hors ligne
///
/// Ce service gère les opérations chat en mode hors ligne
/// et les synchronise lorsque la connexion revient
class OfflineQueueService {
// TODO: Ajouter le service de connectivité
OfflineQueueService();
/// Ajoute une opération en attente
Future<void> addPendingOperation(String operationType, Map<String, dynamic> data) async {
// TODO: Implémenter l'ajout à la file d'attente
throw UnimplementedError();
}
/// Traite les opérations en attente
Future<void> processPendingOperations() async {
// TODO: Implémenter le traitement des opérations
throw UnimplementedError();
}
/// Écoute les changements de connectivité
void listenToConnectivityChanges() {
// TODO: Implémenter l'écoute des changements
throw UnimplementedError();
}
/// Vérifie si une opération est en file d'attente
bool hasOperationInQueue(String operationType, String id) {
// TODO: Implémenter la vérification
throw UnimplementedError();
}
/// Supprime une opération de la file d'attente
Future<void> removeOperationFromQueue(String operationType, String id) async {
// TODO: Implémenter la suppression
throw UnimplementedError();
}
/// Dispose des ressources
void dispose() {
// TODO: Implémenter le dispose
throw UnimplementedError();
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
/// Zone de saisie de message
///
/// Ce widget permet à l'utilisateur de saisir et envoyer des messages
class ChatInput extends StatefulWidget {
final Function(String) onSendText;
final Function(dynamic)? onSendFile;
final Function(dynamic)? onSendImage;
final bool enableAttachments;
final bool enabled;
final String hintText;
final String? disabledMessage;
final int? maxLength;
const ChatInput({
super.key,
required this.onSendText,
this.onSendFile,
this.onSendImage,
this.enableAttachments = true,
this.enabled = true,
this.hintText = 'Saisissez votre message...',
this.disabledMessage = 'Vous ne pouvez pas répondre à cette annonce',
this.maxLength,
});
@override
State<ChatInput> createState() => _ChatInputState();
}
class _ChatInputState extends State<ChatInput> {
final TextEditingController _textController = TextEditingController();
@override
Widget build(BuildContext context) {
if (!widget.enabled) {
return Container(
padding: const EdgeInsets.all(8),
color: Colors.grey.shade200,
child: Text(
widget.disabledMessage ?? '',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
);
}
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade300)),
),
child: Row(
children: [
if (widget.enableAttachments)
IconButton(
icon: const Icon(Icons.attach_file),
onPressed: () {
// TODO: Gérer les pièces jointes
},
),
Expanded(
child: TextField(
controller: _textController,
decoration: InputDecoration(
hintText: widget.hintText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
),
maxLength: widget.maxLength,
maxLines: null,
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: () {
if (_textController.text.trim().isNotEmpty) {
widget.onSendText(_textController.text.trim());
_textController.clear();
}
},
),
],
),
);
}
@override
void dispose() {
_textController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
/// Écran principal d'une conversation
///
/// Ce widget affiche une conversation complète avec :
/// - Liste des messages
/// - Zone de saisie
/// - En-tête et pied de page personnalisables
class ChatScreen extends StatefulWidget {
final String conversationId;
final String? title;
final Widget? header;
final Widget? footer;
final bool enableAttachments;
final bool showTypingIndicator;
final bool enableReadReceipts;
final bool isAnnouncement;
final bool canReply;
const ChatScreen({
super.key,
required this.conversationId,
this.title,
this.header,
this.footer,
this.enableAttachments = true,
this.showTypingIndicator = true,
this.enableReadReceipts = true,
this.isAnnouncement = false,
this.canReply = true,
});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
@override
void initState() {
super.initState();
// TODO: Initialiser les données du chat
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title ?? 'Chat'),
// TODO: Ajouter les actions de l'AppBar
),
body: Column(
children: [
if (widget.header != null) widget.header!,
Expanded(
child: Container(
// TODO: Implémenter la liste des messages
child: const Center(child: Text('Messages à venir...')),
),
),
if (widget.footer != null) widget.footer!,
if (widget.canReply)
Container(
// TODO: Implémenter la zone de saisie
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Text('Zone de saisie à venir...'),
),
),
],
),
);
}
@override
void dispose() {
// TODO: Libérer les ressources
super.dispose();
}
}

View File

@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
/// Liste des conversations
///
/// Ce widget affiche la liste des conversations de l'utilisateur
/// avec leurs derniers messages et statuts
class ConversationsList extends StatefulWidget {
final List<dynamic>? conversations;
final bool loadFromHive;
final Function(dynamic)? onConversationSelected;
final bool showLastMessage;
final bool showUnreadCount;
final bool showAnnouncementBadge;
final bool showPinnedFirst;
final Widget? emptyStateWidget;
const ConversationsList({
super.key,
this.conversations,
this.loadFromHive = true,
this.onConversationSelected,
this.showLastMessage = true,
this.showUnreadCount = true,
this.showAnnouncementBadge = true,
this.showPinnedFirst = true,
this.emptyStateWidget,
});
@override
State<ConversationsList> createState() => _ConversationsListState();
}
class _ConversationsListState extends State<ConversationsList> {
late List<dynamic> _conversations;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadConversations();
}
Future<void> _loadConversations() async {
if (widget.loadFromHive) {
// TODO: Charger depuis Hive
} else {
_conversations = widget.conversations ?? [];
}
setState(() {
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_conversations.isEmpty) {
return widget.emptyStateWidget ?? const Center(child: Text('Aucune conversation'));
}
return ListView.builder(
itemCount: _conversations.length,
itemBuilder: (context, index) {
final conversation = _conversations[index];
// TODO: Créer le widget de conversation
return ListTile(
title: Text('Conversation ${index + 1}'),
subtitle: const Text('Derniers messages...'),
onTap: () => widget.onConversationSelected?.call(conversation),
);
},
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
/// Bulle de message
///
/// Ce widget affiche un message dans une conversation
/// avec les informations associées
class MessageBubble extends StatelessWidget {
final dynamic message; // TODO: Remplacer par MessageModel
final bool showSenderInfo;
final bool showTimestamp;
final bool showStatus;
final bool isAnnouncement;
final double maxWidth;
const MessageBubble({
super.key,
required this.message,
this.showSenderInfo = true,
this.showTimestamp = true,
this.showStatus = true,
this.isAnnouncement = false,
this.maxWidth = 300,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showSenderInfo) CircleAvatar(child: Text('S')),
Expanded(
child: Container(
constraints: BoxConstraints(maxWidth: maxWidth),
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: isAnnouncement ? Colors.orange.shade100 : Colors.blue.shade100,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showSenderInfo)
Text(
'Expéditeur',
style: TextStyle(fontWeight: FontWeight.bold),
),
Text('Contenu du message...'),
if (showTimestamp || showStatus)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (showTimestamp) Text('12:34', style: TextStyle(fontSize: 12)),
if (showStatus) const SizedBox(width: 4),
if (showStatus) Icon(Icons.check, size: 16),
],
),
],
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import '../models/notification_settings.dart';
/// Widget pour les paramètres de notification
///
/// Permet à l'utilisateur de configurer ses préférences de notification
class NotificationSettingsWidget extends StatelessWidget {
final NotificationSettings settings;
final Function(NotificationSettings) onSettingsChanged;
const NotificationSettingsWidget({
super.key,
required this.settings,
required this.onSettingsChanged,
});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16.0),
children: [
// Notifications générales
SwitchListTile(
title: const Text('Activer les notifications'),
subtitle: const Text('Recevoir des notifications pour les nouveaux messages'),
value: settings.enableNotifications,
onChanged: (value) {
onSettingsChanged(settings.copyWith(enableNotifications: value));
},
),
if (settings.enableNotifications) ...[
// Sons et vibrations
SwitchListTile(
title: const Text('Sons'),
subtitle: const Text('Jouer un son à la réception'),
value: settings.soundEnabled,
onChanged: (value) {
onSettingsChanged(settings.copyWith(soundEnabled: value));
},
),
SwitchListTile(
title: const Text('Vibration'),
subtitle: const Text('Vibrer à la réception'),
value: settings.vibrationEnabled,
onChanged: (value) {
onSettingsChanged(settings.copyWith(vibrationEnabled: value));
},
),
// Aperçu des messages
SwitchListTile(
title: const Text('Aperçu du message'),
subtitle: const Text('Afficher le contenu dans la notification'),
value: settings.showPreview,
onChanged: (value) {
onSettingsChanged(settings.copyWith(showPreview: value));
},
),
const Divider(),
// Mode Ne pas déranger
SwitchListTile(
title: const Text('Ne pas déranger'),
subtitle: settings.doNotDisturb && settings.doNotDisturbStart != null
? Text('Actif de ${_formatTime(settings.doNotDisturbStart!)} à ${_formatTime(settings.doNotDisturbEnd!)}')
: null,
value: settings.doNotDisturb,
onChanged: (value) {
if (value) {
_showTimeRangePicker(context);
} else {
onSettingsChanged(settings.copyWith(doNotDisturb: false));
}
},
),
if (settings.doNotDisturb)
ListTile(
title: const Text('Horaires'),
subtitle: Text('${_formatTime(settings.doNotDisturbStart!)} - ${_formatTime(settings.doNotDisturbEnd!)}'),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () => _showTimeRangePicker(context),
),
const Divider(),
// Conversations en silencieux
if (settings.mutedConversations.isNotEmpty) ...[
const ListTile(
title: Text('Conversations en silencieux'),
subtitle: Text('Ces conversations n\'enverront pas de notifications'),
),
...settings.mutedConversations.map(
(conversationId) => ListTile(
title: Text('Conversation $conversationId'), // TODO: Récupérer le vrai nom
trailing: IconButton(
icon: const Icon(Icons.volume_up),
onPressed: () {
final muted = List<String>.from(settings.mutedConversations);
muted.remove(conversationId);
onSettingsChanged(settings.copyWith(mutedConversations: muted));
},
),
),
),
],
],
],
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
}
Future<void> _showTimeRangePicker(BuildContext context) async {
TimeOfDay? startTime = await showTimePicker(
context: context,
initialTime: settings.doNotDisturbStart != null
? TimeOfDay.fromDateTime(settings.doNotDisturbStart!)
: const TimeOfDay(hour: 22, minute: 0),
helpText: 'Heure de début',
);
if (startTime != null) {
final now = DateTime.now();
final start = DateTime(now.year, now.month, now.day, startTime.hour, startTime.minute);
TimeOfDay? endTime = await showTimePicker(
context: context,
initialTime: settings.doNotDisturbEnd != null
? TimeOfDay.fromDateTime(settings.doNotDisturbEnd!)
: const TimeOfDay(hour: 8, minute: 0),
helpText: 'Heure de fin',
);
if (endTime != null) {
DateTime end = DateTime(now.year, now.month, now.day, endTime.hour, endTime.minute);
// Si l'heure de fin est avant l'heure de début, on considère qu'elle est le lendemain
if (end.isBefore(start)) {
end = end.add(const Duration(days: 1));
}
onSettingsChanged(
settings.copyWith(
doNotDisturb: true,
doNotDisturbStart: start,
doNotDisturbEnd: end,
),
);
}
}
}
}

View File

@@ -0,0 +1,192 @@
/// Fichier contenant toutes les constantes utilisées dans l'application
/// Centralise les clés, noms de boîtes Hive, et autres constantes
/// pour faciliter la maintenance et éviter les erreurs de frappe
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
class AppKeys {
// Noms des boîtes Hive
static const String usersBoxName = 'users';
static const String amicaleBoxName = 'amicale';
static const String clientsBoxName = 'clients';
static const String operationsBoxName = 'operations';
static const String sectorsBoxName = 'sectors';
static const String passagesBoxName = 'passages';
static const String settingsBoxName = 'settings';
static const String membresBoxName = 'membres';
static const String userSectorBoxName = 'user_sector';
static const String chatConversationsBoxName = 'chat_conversations';
static const String chatMessagesBoxName = 'chat_messages';
static const String regionsBoxName = 'regions';
// Rôles utilisateurs
static const int roleUser = 1;
static const int roleAdmin1 = 2;
static const int roleAdmin2 = 4;
static const int roleAdmin3 = 9;
// URLs API pour les différents environnements
static const String baseApiUrlDev = 'https://dapp.geosector.fr/api';
static const String baseApiUrlRec = 'https://rapp.geosector.fr/api';
static const String baseApiUrlProd = 'https://app.geosector.fr/api';
// Identifiants d'application pour les différents environnements
static const String appIdentifierDev = 'dapp.geosector.fr';
static const String appIdentifierRec = 'rapp.geosector.fr';
static const String appIdentifierProd = 'app.geosector.fr';
// Endpoints API
static const String loginEndpoint = '/login';
static const String logoutEndpoint = '/logout';
static const String registerEndpoint = '/register';
static const String syncDataEndpoint = '/data/sync';
static const String sectorsEndpoint = '/sectors';
// Durées
static const Duration connectionTimeout = Duration(seconds: 5);
static const Duration receiveTimeout = Duration(seconds: 30);
static const Duration sessionDefaultExpiry = Duration(days: 7);
// Clés API externes
static const String mapboxApiKeyDev =
'pk.eyJ1IjoicHZkNnNvZnQiLCJhIjoiY21hanVmNjN5MTM5djJtczdsMW92cjQ0ciJ9.pUCMuvWPB3cuBaPh4ywTAw';
static const String mapboxApiKeyRec =
'pk.eyJ1IjoicHZkNnNvZnQiLCJhIjoiY21hanVlZ3FiMGx0NDJpc2k4YnkxaWZ2dSJ9.OqGJtjlWRgB4fIjECCB8WA';
static const String mapboxApiKeyProd =
'pk.eyJ1IjoicHZkNnNvZnQiLCJhIjoiY204dTNhNmd0MGV1ZzJqc2pnNnB0NjYxdSJ9.TA5Mvliyn91Oi01F_2Yuxw';
// Méthode pour obtenir la clé API Mapbox en fonction de l'environnement actuel
static String getMapboxApiKey(String environment) {
// Utiliser l'environnement passé en paramètre pour déterminer quelle clé retourner
switch (environment) {
case 'DEV':
return mapboxApiKeyDev;
case 'REC':
return mapboxApiKeyRec;
case 'PROD':
default:
return mapboxApiKeyProd;
}
}
// Pour la compatibilité avec le code existant, on garde un getter qui utilise
// l'environnement actuel (à utiliser uniquement si l'ApiService n'est pas disponible)
static String get mapboxApiKey {
// Note: Cette implémentation est une solution de secours et devrait être évitée
// Il est préférable d'utiliser getMapboxApiKey(apiService.getCurrentEnvironment())
// Détection basique de l'environnement basée sur l'URL en mode web
if (kIsWeb) {
// Essayer d'accéder à l'URL actuelle (fonctionne uniquement en mode web)
try {
final String currentUrl = Uri.base.toString().toLowerCase();
if (currentUrl.contains('dapp.geosector.fr')) {
return mapboxApiKeyDev;
} else if (currentUrl.contains('rapp.geosector.fr')) {
return mapboxApiKeyRec;
}
} catch (e) {
// En cas d'erreur, utiliser la clé de production par défaut
print('Erreur lors de la détection de l\'environnement: $e');
}
}
// Par défaut, retourner la clé de production
return mapboxApiKeyProd;
}
// Headers
static const String sessionHeader = 'Authorization';
// En-têtes par défaut pour les requêtes API
// Note: Ces en-têtes seront complétés dynamiquement dans ApiService
static const Map<String, String> defaultHeaders = {
'Content-Type': 'application/json',
'X-Client-Type': kIsWeb ? 'web' : 'mobile',
'Accept': 'application/json',
};
// Civilités
static const Map<int, String> civilites = {
1: 'M.',
2: 'Mme',
};
// Types de règlements (basés sur la maquette Figma)
static const Map<int, Map<String, dynamic>> typesReglements = {
0: {
'titre': 'Pas de règlement',
'couleur': 0xFF757575, // Gris foncé
'icon_data': Icons.money_off,
},
1: {
'titre': 'Espèce',
'couleur': 0xFFB87333, // Couleur cuivrée
'icon_data': Icons.payments_outlined,
},
2: {
'titre': 'Chèque',
'couleur': 0xFFD8D5EC, // Violet clair (Figma)
'icon_data': Icons.account_balance_wallet_outlined,
},
3: {
'titre': 'CB',
'couleur': 0xFF0099FF, // Bleu flashy
'icon_data': Icons.credit_card,
},
};
// Types de passages (basés sur la maquette Figma)
static const Map<int, Map<String, dynamic>> typesPassages = {
1: {
'titres': 'Effectués',
'titre': 'Effectué',
'couleur1': 0xFF00E09D, // Vert (Figma)
'couleur2': 0xFF00E09D, // Vert (Figma)
'couleur3': 0xFF00E09D, // Vert (Figma)
'icon_data': Icons.task_alt,
},
2: {
'titres': 'À finaliser',
'titre': 'À finaliser',
'couleur1': 0xFFFFFFFF, // Blanc
'couleur2': 0xFFF7A278, // Orange (Figma)
'couleur3': 0xFFE65100, // Orange foncé
'icon_data': Icons.refresh,
},
3: {
'titres': 'Refusés',
'titre': 'Refusé',
'couleur1': 0xFFE41B13, // Rouge (Figma)
'couleur2': 0xFFE41B13, // Rouge (Figma)
'couleur3': 0xFFE41B13, // Rouge (Figma)
'icon_data': Icons.block,
},
4: {
'titres': 'Dons',
'titre': 'Don',
'couleur1': 0xFF395AA7, // Bleu (Figma)
'couleur2': 0xFF395AA7, // Bleu (Figma)
'couleur3': 0xFF395AA7, // Bleu (Figma)
'icon_data': Icons.volunteer_activism,
},
5: {
'titres': 'Lots',
'titre': 'Lot',
'couleur1': 0xFF20335E, // Bleu foncé (Figma)
'couleur2': 0xFF20335E, // Bleu foncé (Figma)
'couleur3': 0xFF20335E, // Bleu foncé (Figma)
'icon_data': Icons.layers,
},
6: {
'titres': 'Maisons vides',
'titre': 'Maison vide',
'couleur1': 0xFFB8B8B8, // Gris (Figma)
'couleur2': 0xFFB8B8B8, // Gris (Figma)
'couleur3': 0xFFB8B8B8, // Gris (Figma)
'icon_data': Icons.home_outlined,
},
};
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,249 @@
import 'package:hive/hive.dart';
part 'amicale_model.g.dart';
@HiveType(typeId: 11)
class AmicaleModel extends HiveObject {
@HiveField(0)
final int id;
@HiveField(1)
final String name;
@HiveField(2)
final String adresse1;
@HiveField(3)
final String adresse2;
@HiveField(4)
final String codePostal;
@HiveField(5)
final String ville;
@HiveField(6)
final int? fkRegion;
@HiveField(7)
final String? libRegion;
@HiveField(8)
final int? fkType;
@HiveField(9)
final String phone;
@HiveField(10)
final String mobile;
@HiveField(11)
final String email;
@HiveField(12)
final String gpsLat;
@HiveField(13)
final String gpsLng;
@HiveField(14)
final String stripeId;
@HiveField(15)
final bool chkDemo;
@HiveField(16)
final bool chkCopieMailRecu;
@HiveField(17)
final bool chkAcceptSms;
@HiveField(18)
final bool chkActive;
@HiveField(19)
final bool chkStripe;
@HiveField(20)
final DateTime? createdAt;
@HiveField(21)
final DateTime? updatedAt;
AmicaleModel({
required this.id,
required this.name,
this.adresse1 = '',
this.adresse2 = '',
this.codePostal = '',
this.ville = '',
this.fkRegion,
this.libRegion,
this.fkType,
this.phone = '',
this.mobile = '',
this.email = '',
this.gpsLat = '',
this.gpsLng = '',
this.stripeId = '',
this.chkDemo = false,
this.chkCopieMailRecu = false,
this.chkAcceptSms = false,
this.chkActive = true,
this.chkStripe = false,
this.createdAt,
this.updatedAt,
});
// Factory pour convertir depuis JSON (API)
factory AmicaleModel.fromJson(Map<String, dynamic> json) {
// Convertir l'ID en int, qu'il soit déjà int ou string
final dynamic rawId = json['id'];
final int id = rawId is String ? int.parse(rawId) : rawId as int;
// Convertir fk_region en int si présent
final dynamic rawFkRegion = json['fk_region'];
final int? fkRegion = rawFkRegion != null
? (rawFkRegion is String ? int.parse(rawFkRegion) : rawFkRegion as int)
: null;
// Convertir fk_type en int si présent
final dynamic rawFkType = json['fk_type'];
final int? fkType = rawFkType != null
? (rawFkType is String ? int.parse(rawFkType) : rawFkType as int)
: null;
// Convertir les booléens
final bool chkDemo = json['chk_demo'] == 1 || json['chk_demo'] == true;
final bool chkCopieMailRecu =
json['chk_copie_mail_recu'] == 1 || json['chk_copie_mail_recu'] == true;
final bool chkAcceptSms =
json['chk_accept_sms'] == 1 || json['chk_accept_sms'] == true;
final bool chkActive =
json['chk_active'] == 1 || json['chk_active'] == true;
final bool chkStripe =
json['chk_stripe'] == 1 || json['chk_stripe'] == true;
// Traiter les dates si présentes
DateTime? createdAt;
if (json['created_at'] != null && json['created_at'] != '') {
try {
createdAt = DateTime.parse(json['created_at']);
} catch (e) {
createdAt = null;
}
}
DateTime? updatedAt;
if (json['updated_at'] != null && json['updated_at'] != '') {
try {
updatedAt = DateTime.parse(json['updated_at']);
} catch (e) {
updatedAt = null;
}
}
return AmicaleModel(
id: id,
name: json['name'] ?? '',
adresse1: json['adresse1'] ?? '',
adresse2: json['adresse2'] ?? '',
codePostal: json['code_postal'] ?? '',
ville: json['ville'] ?? '',
fkRegion: fkRegion,
libRegion: json['lib_region'],
fkType: fkType,
phone: json['phone'] ?? '',
mobile: json['mobile'] ?? '',
email: json['email'] ?? '',
gpsLat: json['gps_lat'] ?? '',
gpsLng: json['gps_lng'] ?? '',
stripeId: json['stripe_id'] ?? '',
chkDemo: chkDemo,
chkCopieMailRecu: chkCopieMailRecu,
chkAcceptSms: chkAcceptSms,
chkActive: chkActive,
chkStripe: chkStripe,
createdAt: createdAt,
updatedAt: updatedAt,
);
}
// Convertir en JSON pour l'API
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'adresse1': adresse1,
'adresse2': adresse2,
'code_postal': codePostal,
'ville': ville,
'fk_region': fkRegion,
'lib_region': libRegion,
'fk_type': fkType,
'phone': phone,
'mobile': mobile,
'email': email,
'gps_lat': gpsLat,
'gps_lng': gpsLng,
'stripe_id': stripeId,
'chk_demo': chkDemo ? 1 : 0,
'chk_copie_mail_recu': chkCopieMailRecu ? 1 : 0,
'chk_accept_sms': chkAcceptSms ? 1 : 0,
'chk_active': chkActive ? 1 : 0,
'chk_stripe': chkStripe ? 1 : 0,
'created_at': createdAt?.toIso8601String(),
'updated_at': updatedAt?.toIso8601String(),
};
}
// Copier avec de nouvelles valeurs
AmicaleModel copyWith({
String? name,
String? adresse1,
String? adresse2,
String? codePostal,
String? ville,
int? fkRegion,
String? libRegion,
int? fkType,
String? phone,
String? mobile,
String? email,
String? gpsLat,
String? gpsLng,
String? stripeId,
bool? chkDemo,
bool? chkCopieMailRecu,
bool? chkAcceptSms,
bool? chkActive,
bool? chkStripe,
DateTime? createdAt,
DateTime? updatedAt,
}) {
return AmicaleModel(
id: this.id,
name: name ?? this.name,
adresse1: adresse1 ?? this.adresse1,
adresse2: adresse2 ?? this.adresse2,
codePostal: codePostal ?? this.codePostal,
ville: ville ?? this.ville,
fkRegion: fkRegion ?? this.fkRegion,
libRegion: libRegion ?? this.libRegion,
fkType: fkType ?? this.fkType,
phone: phone ?? this.phone,
mobile: mobile ?? this.mobile,
email: email ?? this.email,
gpsLat: gpsLat ?? this.gpsLat,
gpsLng: gpsLng ?? this.gpsLng,
stripeId: stripeId ?? this.stripeId,
chkDemo: chkDemo ?? this.chkDemo,
chkCopieMailRecu: chkCopieMailRecu ?? this.chkCopieMailRecu,
chkAcceptSms: chkAcceptSms ?? this.chkAcceptSms,
chkActive: chkActive ?? this.chkActive,
chkStripe: chkStripe ?? this.chkStripe,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
);
}
}

View File

@@ -0,0 +1,104 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'amicale_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AmicaleModelAdapter extends TypeAdapter<AmicaleModel> {
@override
final int typeId = 11;
@override
AmicaleModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AmicaleModel(
id: fields[0] as int,
name: fields[1] as String,
adresse1: fields[2] as String,
adresse2: fields[3] as String,
codePostal: fields[4] as String,
ville: fields[5] as String,
fkRegion: fields[6] as int?,
libRegion: fields[7] as String?,
fkType: fields[8] as int?,
phone: fields[9] as String,
mobile: fields[10] as String,
email: fields[11] as String,
gpsLat: fields[12] as String,
gpsLng: fields[13] as String,
stripeId: fields[14] as String,
chkDemo: fields[15] as bool,
chkCopieMailRecu: fields[16] as bool,
chkAcceptSms: fields[17] as bool,
chkActive: fields[18] as bool,
chkStripe: fields[19] as bool,
createdAt: fields[20] as DateTime?,
updatedAt: fields[21] as DateTime?,
);
}
@override
void write(BinaryWriter writer, AmicaleModel obj) {
writer
..writeByte(22)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.name)
..writeByte(2)
..write(obj.adresse1)
..writeByte(3)
..write(obj.adresse2)
..writeByte(4)
..write(obj.codePostal)
..writeByte(5)
..write(obj.ville)
..writeByte(6)
..write(obj.fkRegion)
..writeByte(7)
..write(obj.libRegion)
..writeByte(8)
..write(obj.fkType)
..writeByte(9)
..write(obj.phone)
..writeByte(10)
..write(obj.mobile)
..writeByte(11)
..write(obj.email)
..writeByte(12)
..write(obj.gpsLat)
..writeByte(13)
..write(obj.gpsLng)
..writeByte(14)
..write(obj.stripeId)
..writeByte(15)
..write(obj.chkDemo)
..writeByte(16)
..write(obj.chkCopieMailRecu)
..writeByte(17)
..write(obj.chkAcceptSms)
..writeByte(18)
..write(obj.chkActive)
..writeByte(19)
..write(obj.chkStripe)
..writeByte(20)
..write(obj.createdAt)
..writeByte(21)
..write(obj.updatedAt);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AmicaleModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,200 @@
import 'package:hive/hive.dart';
part 'client_model.g.dart';
@HiveType(typeId: 10)
class ClientModel extends HiveObject {
@HiveField(0)
final int id;
@HiveField(1)
final String name;
@HiveField(2)
final String? adresse1;
@HiveField(3)
final String? adresse2;
@HiveField(4)
final String? codePostal;
@HiveField(5)
final String? ville;
@HiveField(6)
final int? fkRegion;
@HiveField(7)
final String? libRegion;
@HiveField(8)
final int? fkType;
@HiveField(9)
final String? phone;
@HiveField(10)
final String? mobile;
@HiveField(11)
final String? email;
@HiveField(12)
final String? gpsLat;
@HiveField(13)
final String? gpsLng;
@HiveField(14)
final String? stripeId;
@HiveField(15)
final bool? chkDemo;
@HiveField(16)
final bool? chkCopieMailRecu;
@HiveField(17)
final bool? chkAcceptSms;
@HiveField(18)
final bool? chkActive;
ClientModel({
required this.id,
required this.name,
this.adresse1,
this.adresse2,
this.codePostal,
this.ville,
this.fkRegion,
this.libRegion,
this.fkType,
this.phone,
this.mobile,
this.email,
this.gpsLat,
this.gpsLng,
this.stripeId,
this.chkDemo,
this.chkCopieMailRecu,
this.chkAcceptSms,
this.chkActive,
});
// Factory pour convertir depuis JSON (API)
factory ClientModel.fromJson(Map<String, dynamic> json) {
// Convertir l'ID en int, qu'il soit déjà int ou string
final dynamic rawId = json['id'];
final int id = rawId is String ? int.parse(rawId) : rawId as int;
// Convertir fk_region en int si présent
int? fkRegion;
if (json['fk_region'] != null) {
final dynamic rawFkRegion = json['fk_region'];
fkRegion =
rawFkRegion is String ? int.parse(rawFkRegion) : rawFkRegion as int;
}
// Convertir fk_type en int si présent
int? fkType;
if (json['fk_type'] != null) {
final dynamic rawFkType = json['fk_type'];
fkType = rawFkType is String ? int.parse(rawFkType) : rawFkType as int;
}
return ClientModel(
id: id,
name: json['name'] ?? '',
adresse1: json['adresse1'],
adresse2: json['adresse2'],
codePostal: json['code_postal'],
ville: json['ville'],
fkRegion: fkRegion,
libRegion: json['lib_region'],
fkType: fkType,
phone: json['phone'],
mobile: json['mobile'],
email: json['email'],
gpsLat: json['gps_lat'],
gpsLng: json['gps_lng'],
stripeId: json['stripe_id'],
chkDemo: json['chk_demo'] == 1 || json['chk_demo'] == true,
chkCopieMailRecu: json['chk_copie_mail_recu'] == 1 ||
json['chk_copie_mail_recu'] == true,
chkAcceptSms:
json['chk_accept_sms'] == 1 || json['chk_accept_sms'] == true,
chkActive: json['chk_active'] == 1 || json['chk_active'] == true,
);
}
// Convertir en JSON pour l'API
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'adresse1': adresse1,
'adresse2': adresse2,
'code_postal': codePostal,
'ville': ville,
'fk_region': fkRegion,
'lib_region': libRegion,
'fk_type': fkType,
'phone': phone,
'mobile': mobile,
'email': email,
'gps_lat': gpsLat,
'gps_lng': gpsLng,
'stripe_id': stripeId,
'chk_demo': chkDemo,
'chk_copie_mail_recu': chkCopieMailRecu,
'chk_accept_sms': chkAcceptSms,
'chk_active': chkActive,
};
}
// Copier avec de nouvelles valeurs
ClientModel copyWith({
String? name,
String? adresse1,
String? adresse2,
String? codePostal,
String? ville,
int? fkRegion,
String? libRegion,
int? fkType,
String? phone,
String? mobile,
String? email,
String? gpsLat,
String? gpsLng,
String? stripeId,
bool? chkDemo,
bool? chkCopieMailRecu,
bool? chkAcceptSms,
bool? chkActive,
}) {
return ClientModel(
id: this.id,
name: name ?? this.name,
adresse1: adresse1 ?? this.adresse1,
adresse2: adresse2 ?? this.adresse2,
codePostal: codePostal ?? this.codePostal,
ville: ville ?? this.ville,
fkRegion: fkRegion ?? this.fkRegion,
libRegion: libRegion ?? this.libRegion,
fkType: fkType ?? this.fkType,
phone: phone ?? this.phone,
mobile: mobile ?? this.mobile,
email: email ?? this.email,
gpsLat: gpsLat ?? this.gpsLat,
gpsLng: gpsLng ?? this.gpsLng,
stripeId: stripeId ?? this.stripeId,
chkDemo: chkDemo ?? this.chkDemo,
chkCopieMailRecu: chkCopieMailRecu ?? this.chkCopieMailRecu,
chkAcceptSms: chkAcceptSms ?? this.chkAcceptSms,
chkActive: chkActive ?? this.chkActive,
);
}
}

View File

@@ -0,0 +1,95 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'client_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class ClientModelAdapter extends TypeAdapter<ClientModel> {
@override
final int typeId = 10;
@override
ClientModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return ClientModel(
id: fields[0] as int,
name: fields[1] as String,
adresse1: fields[2] as String?,
adresse2: fields[3] as String?,
codePostal: fields[4] as String?,
ville: fields[5] as String?,
fkRegion: fields[6] as int?,
libRegion: fields[7] as String?,
fkType: fields[8] as int?,
phone: fields[9] as String?,
mobile: fields[10] as String?,
email: fields[11] as String?,
gpsLat: fields[12] as String?,
gpsLng: fields[13] as String?,
stripeId: fields[14] as String?,
chkDemo: fields[15] as bool?,
chkCopieMailRecu: fields[16] as bool?,
chkAcceptSms: fields[17] as bool?,
chkActive: fields[18] as bool?,
);
}
@override
void write(BinaryWriter writer, ClientModel obj) {
writer
..writeByte(19)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.name)
..writeByte(2)
..write(obj.adresse1)
..writeByte(3)
..write(obj.adresse2)
..writeByte(4)
..write(obj.codePostal)
..writeByte(5)
..write(obj.ville)
..writeByte(6)
..write(obj.fkRegion)
..writeByte(7)
..write(obj.libRegion)
..writeByte(8)
..write(obj.fkType)
..writeByte(9)
..write(obj.phone)
..writeByte(10)
..write(obj.mobile)
..writeByte(11)
..write(obj.email)
..writeByte(12)
..write(obj.gpsLat)
..writeByte(13)
..write(obj.gpsLng)
..writeByte(14)
..write(obj.stripeId)
..writeByte(15)
..write(obj.chkDemo)
..writeByte(16)
..write(obj.chkCopieMailRecu)
..writeByte(17)
..write(obj.chkAcceptSms)
..writeByte(18)
..write(obj.chkActive);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is ClientModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,137 @@
import 'package:hive/hive.dart';
part 'membre_model.g.dart';
@HiveType(typeId: 5) // Utilisation d'un typeId unique
class MembreModel extends HiveObject {
@HiveField(0)
final int id;
@HiveField(1)
final int fkRole;
@HiveField(2)
final int fkTitre;
@HiveField(3)
final String firstName;
@HiveField(4)
final String? sectName;
@HiveField(5)
final DateTime? dateNaissance;
@HiveField(6)
final DateTime? dateEmbauche;
@HiveField(7)
final int chkActive;
@HiveField(8)
final String name;
@HiveField(9)
final String username;
@HiveField(10)
final String email;
MembreModel({
required this.id,
required this.fkRole,
required this.fkTitre,
required this.firstName,
this.sectName,
this.dateNaissance,
this.dateEmbauche,
required this.chkActive,
required this.name,
required this.username,
required this.email,
});
// Factory pour convertir depuis JSON (API)
factory MembreModel.fromJson(Map<String, dynamic> json) {
// Convertir l'ID en int, qu'il soit déjà int ou string
final dynamic rawId = json['id'];
final int id = rawId is String ? int.parse(rawId) : rawId as int;
// Convertir le rôle en int, qu'il soit déjà int ou string
final dynamic rawRole = json['fk_role'];
final int fkRole = rawRole is String ? int.parse(rawRole) : rawRole as int;
// Convertir le titre en int, qu'il soit déjà int ou string
final dynamic rawTitre = json['fk_titre'];
final int fkTitre =
rawTitre is String ? int.parse(rawTitre) : rawTitre as int;
// Convertir le chkActive en int, qu'il soit déjà int ou string
final dynamic rawActive = json['chk_active'];
final int chkActive =
rawActive is String ? int.parse(rawActive) : rawActive as int;
return MembreModel(
id: id,
fkRole: fkRole,
fkTitre: fkTitre,
firstName: json['first_name'] ?? '',
sectName: json['sect_name'],
dateNaissance: json['date_naissance'] != null
? DateTime.parse(json['date_naissance'])
: null,
dateEmbauche: json['date_embauche'] != null
? DateTime.parse(json['date_embauche'])
: null,
chkActive: chkActive,
name: json['name'] ?? '',
username: json['username'] ?? '',
email: json['email'] ?? '',
);
}
// Convertir en JSON pour l'API
Map<String, dynamic> toJson() {
return {
'id': id,
'fk_role': fkRole,
'fk_titre': fkTitre,
'first_name': firstName,
'sect_name': sectName,
'date_naissance': dateNaissance?.toIso8601String(),
'date_embauche': dateEmbauche?.toIso8601String(),
'chk_active': chkActive,
'name': name,
'username': username,
'email': email,
};
}
// Copier avec de nouvelles valeurs
MembreModel copyWith({
int? fkRole,
int? fkTitre,
String? firstName,
String? sectName,
DateTime? dateNaissance,
DateTime? dateEmbauche,
int? chkActive,
String? name,
String? username,
String? email,
}) {
return MembreModel(
id: this.id,
fkRole: fkRole ?? this.fkRole,
fkTitre: fkTitre ?? this.fkTitre,
firstName: firstName ?? this.firstName,
sectName: sectName ?? this.sectName,
dateNaissance: dateNaissance ?? this.dateNaissance,
dateEmbauche: dateEmbauche ?? this.dateEmbauche,
chkActive: chkActive ?? this.chkActive,
name: name ?? this.name,
username: username ?? this.username,
email: email ?? this.email,
);
}
}

View File

@@ -0,0 +1,71 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'membre_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class MembreModelAdapter extends TypeAdapter<MembreModel> {
@override
final int typeId = 5;
@override
MembreModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return MembreModel(
id: fields[0] as int,
fkRole: fields[1] as int,
fkTitre: fields[2] as int,
firstName: fields[3] as String,
sectName: fields[4] as String?,
dateNaissance: fields[5] as DateTime?,
dateEmbauche: fields[6] as DateTime?,
chkActive: fields[7] as int,
name: fields[8] as String,
username: fields[9] as String,
email: fields[10] as String,
);
}
@override
void write(BinaryWriter writer, MembreModel obj) {
writer
..writeByte(11)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.fkRole)
..writeByte(2)
..write(obj.fkTitre)
..writeByte(3)
..write(obj.firstName)
..writeByte(4)
..write(obj.sectName)
..writeByte(5)
..write(obj.dateNaissance)
..writeByte(6)
..write(obj.dateEmbauche)
..writeByte(7)
..write(obj.chkActive)
..writeByte(8)
..write(obj.name)
..writeByte(9)
..write(obj.username)
..writeByte(10)
..write(obj.email);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is MembreModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,85 @@
import 'package:hive/hive.dart';
part 'operation_model.g.dart';
@HiveType(typeId: 1)
class OperationModel extends HiveObject {
@HiveField(0)
final int id;
@HiveField(1)
final String name;
@HiveField(2)
final DateTime dateDebut;
@HiveField(3)
final DateTime dateFin;
@HiveField(4)
DateTime lastSyncedAt;
@HiveField(5)
bool isActive;
@HiveField(6)
bool isSynced;
OperationModel({
required this.id,
required this.name,
required this.dateDebut,
required this.dateFin,
required this.lastSyncedAt,
this.isActive = true,
this.isSynced = false,
});
// Factory pour convertir depuis JSON (API)
factory OperationModel.fromJson(Map<String, dynamic> json) {
// Convertir l'ID en int, qu'il soit déjà int ou string
final dynamic rawId = json['id'];
final int id = rawId is String ? int.parse(rawId) : rawId as int;
return OperationModel(
id: id,
name: json['name'],
dateDebut: DateTime.parse(json['date_deb']),
dateFin: DateTime.parse(json['date_fin']),
lastSyncedAt: DateTime.now(),
isActive: true,
isSynced: true,
);
}
// Convertir en JSON pour l'API
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'date_deb': dateDebut.toIso8601String().split('T')[0], // Format YYYY-MM-DD
'date_fin': dateFin.toIso8601String().split('T')[0], // Format YYYY-MM-DD
'is_active': isActive,
};
}
// Copier avec de nouvelles valeurs
OperationModel copyWith({
String? name,
DateTime? dateDebut,
DateTime? dateFin,
bool? isActive,
bool? isSynced,
DateTime? lastSyncedAt,
}) {
return OperationModel(
id: this.id,
name: name ?? this.name,
dateDebut: dateDebut ?? this.dateDebut,
dateFin: dateFin ?? this.dateFin,
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
isActive: isActive ?? this.isActive,
isSynced: isSynced ?? this.isSynced,
);
}
}

View File

@@ -0,0 +1,59 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'operation_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class OperationModelAdapter extends TypeAdapter<OperationModel> {
@override
final int typeId = 1;
@override
OperationModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return OperationModel(
id: fields[0] as int,
name: fields[1] as String,
dateDebut: fields[2] as DateTime,
dateFin: fields[3] as DateTime,
lastSyncedAt: fields[4] as DateTime,
isActive: fields[5] as bool,
isSynced: fields[6] as bool,
);
}
@override
void write(BinaryWriter writer, OperationModel obj) {
writer
..writeByte(7)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.name)
..writeByte(2)
..write(obj.dateDebut)
..writeByte(3)
..write(obj.dateFin)
..writeByte(4)
..write(obj.lastSyncedAt)
..writeByte(5)
..write(obj.isActive)
..writeByte(6)
..write(obj.isSynced);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is OperationModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,291 @@
import 'package:hive/hive.dart';
part 'passage_model.g.dart';
@HiveType(typeId: 4)
class PassageModel extends HiveObject {
@HiveField(0)
final int id;
@HiveField(1)
final int fkOperation;
@HiveField(2)
final int fkSector;
@HiveField(3)
final int fkUser;
@HiveField(4)
final int fkType;
@HiveField(5)
final String fkAdresse;
@HiveField(6)
final DateTime passedAt;
@HiveField(7)
final String numero;
@HiveField(8)
final String rue;
@HiveField(9)
final String rueBis;
@HiveField(10)
final String ville;
@HiveField(11)
final String residence;
@HiveField(12)
final int fkHabitat;
@HiveField(13)
final String appt;
@HiveField(14)
final String niveau;
@HiveField(15)
final String gpsLat;
@HiveField(16)
final String gpsLng;
@HiveField(17)
final String nomRecu;
@HiveField(18)
final String remarque;
@HiveField(19)
final String montant;
@HiveField(20)
final int fkTypeReglement;
@HiveField(21)
final String emailErreur;
@HiveField(22)
final int nbPassages;
@HiveField(23)
final String name;
@HiveField(24)
final String email;
@HiveField(25)
final String phone;
@HiveField(26)
DateTime lastSyncedAt;
@HiveField(27)
bool isActive;
@HiveField(28)
bool isSynced;
PassageModel({
required this.id,
required this.fkOperation,
required this.fkSector,
required this.fkUser,
required this.fkType,
required this.fkAdresse,
required this.passedAt,
required this.numero,
required this.rue,
this.rueBis = '',
required this.ville,
this.residence = '',
required this.fkHabitat,
this.appt = '',
this.niveau = '',
required this.gpsLat,
required this.gpsLng,
this.nomRecu = '',
this.remarque = '',
required this.montant,
required this.fkTypeReglement,
this.emailErreur = '',
required this.nbPassages,
required this.name,
this.email = '',
this.phone = '',
required this.lastSyncedAt,
this.isActive = true,
this.isSynced = false,
});
// Factory pour convertir depuis JSON (API)
factory PassageModel.fromJson(Map<String, dynamic> json) {
// Convertir l'ID en int, qu'il soit déjà int ou string
final dynamic rawId = json['id'];
final int id = rawId is String ? int.parse(rawId) : rawId as int;
// Convertir les autres champs numériques
final dynamic rawFkOperation = json['fk_operation'];
final int fkOperation = rawFkOperation is String ? int.parse(rawFkOperation) : rawFkOperation as int;
final dynamic rawFkSector = json['fk_sector'];
final int fkSector = rawFkSector is String ? int.parse(rawFkSector) : rawFkSector as int;
final dynamic rawFkUser = json['fk_user'];
final int fkUser = rawFkUser is String ? int.parse(rawFkUser) : rawFkUser as int;
final dynamic rawFkType = json['fk_type'];
final int fkType = rawFkType is String ? int.parse(rawFkType) : rawFkType as int;
final dynamic rawFkHabitat = json['fk_habitat'];
final int fkHabitat = rawFkHabitat is String ? int.parse(rawFkHabitat) : rawFkHabitat as int;
final dynamic rawFkTypeReglement = json['fk_type_reglement'];
final int fkTypeReglement = rawFkTypeReglement is String ? int.parse(rawFkTypeReglement) : rawFkTypeReglement as int;
final dynamic rawNbPassages = json['nb_passages'];
final int nbPassages = rawNbPassages is String ? int.parse(rawNbPassages) : rawNbPassages as int;
// Convertir la date
final DateTime passedAt = DateTime.parse(json['passed_at']);
return PassageModel(
id: id,
fkOperation: fkOperation,
fkSector: fkSector,
fkUser: fkUser,
fkType: fkType,
fkAdresse: json['fk_adresse'] as String,
passedAt: passedAt,
numero: json['numero'] as String,
rue: json['rue'] as String,
rueBis: json['rue_bis'] as String? ?? '',
ville: json['ville'] as String,
residence: json['residence'] as String? ?? '',
fkHabitat: fkHabitat,
appt: json['appt'] as String? ?? '',
niveau: json['niveau'] as String? ?? '',
gpsLat: json['gps_lat'] as String,
gpsLng: json['gps_lng'] as String,
nomRecu: json['nom_recu'] as String? ?? '',
remarque: json['remarque'] as String? ?? '',
montant: json['montant'] as String,
fkTypeReglement: fkTypeReglement,
emailErreur: json['email_erreur'] as String? ?? '',
nbPassages: nbPassages,
name: json['name'] as String,
email: json['email'] as String? ?? '',
phone: json['phone'] as String? ?? '',
lastSyncedAt: DateTime.now(),
isActive: true,
isSynced: true,
);
}
// Convertir en JSON pour l'API
Map<String, dynamic> toJson() {
return {
'id': id,
'fk_operation': fkOperation,
'fk_sector': fkSector,
'fk_user': fkUser,
'fk_type': fkType,
'fk_adresse': fkAdresse,
'passed_at': passedAt.toIso8601String(),
'numero': numero,
'rue': rue,
'rue_bis': rueBis,
'ville': ville,
'residence': residence,
'fk_habitat': fkHabitat,
'appt': appt,
'niveau': niveau,
'gps_lat': gpsLat,
'gps_lng': gpsLng,
'nom_recu': nomRecu,
'remarque': remarque,
'montant': montant,
'fk_type_reglement': fkTypeReglement,
'email_erreur': emailErreur,
'nb_passages': nbPassages,
'name': name,
'email': email,
'phone': phone,
};
}
// Copier avec de nouvelles valeurs
PassageModel copyWith({
int? id,
int? fkOperation,
int? fkSector,
int? fkUser,
int? fkType,
String? fkAdresse,
DateTime? passedAt,
String? numero,
String? rue,
String? rueBis,
String? ville,
String? residence,
int? fkHabitat,
String? appt,
String? niveau,
String? gpsLat,
String? gpsLng,
String? nomRecu,
String? remarque,
String? montant,
int? fkTypeReglement,
String? emailErreur,
int? nbPassages,
String? name,
String? email,
String? phone,
DateTime? lastSyncedAt,
bool? isActive,
bool? isSynced,
}) {
return PassageModel(
id: id ?? this.id,
fkOperation: fkOperation ?? this.fkOperation,
fkSector: fkSector ?? this.fkSector,
fkUser: fkUser ?? this.fkUser,
fkType: fkType ?? this.fkType,
fkAdresse: fkAdresse ?? this.fkAdresse,
passedAt: passedAt ?? this.passedAt,
numero: numero ?? this.numero,
rue: rue ?? this.rue,
rueBis: rueBis ?? this.rueBis,
ville: ville ?? this.ville,
residence: residence ?? this.residence,
fkHabitat: fkHabitat ?? this.fkHabitat,
appt: appt ?? this.appt,
niveau: niveau ?? this.niveau,
gpsLat: gpsLat ?? this.gpsLat,
gpsLng: gpsLng ?? this.gpsLng,
nomRecu: nomRecu ?? this.nomRecu,
remarque: remarque ?? this.remarque,
montant: montant ?? this.montant,
fkTypeReglement: fkTypeReglement ?? this.fkTypeReglement,
emailErreur: emailErreur ?? this.emailErreur,
nbPassages: nbPassages ?? this.nbPassages,
name: name ?? this.name,
email: email ?? this.email,
phone: phone ?? this.phone,
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
isActive: isActive ?? this.isActive,
isSynced: isSynced ?? this.isSynced,
);
}
@override
String toString() {
return 'PassageModel(id: $id, fkOperation: $fkOperation, fkSector: $fkSector, fkUser: $fkUser, fkType: $fkType, adresse: $fkAdresse, ville: $ville, montant: $montant)';
}
}

View File

@@ -0,0 +1,125 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'passage_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class PassageModelAdapter extends TypeAdapter<PassageModel> {
@override
final int typeId = 4;
@override
PassageModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return PassageModel(
id: fields[0] as int,
fkOperation: fields[1] as int,
fkSector: fields[2] as int,
fkUser: fields[3] as int,
fkType: fields[4] as int,
fkAdresse: fields[5] as String,
passedAt: fields[6] as DateTime,
numero: fields[7] as String,
rue: fields[8] as String,
rueBis: fields[9] as String,
ville: fields[10] as String,
residence: fields[11] as String,
fkHabitat: fields[12] as int,
appt: fields[13] as String,
niveau: fields[14] as String,
gpsLat: fields[15] as String,
gpsLng: fields[16] as String,
nomRecu: fields[17] as String,
remarque: fields[18] as String,
montant: fields[19] as String,
fkTypeReglement: fields[20] as int,
emailErreur: fields[21] as String,
nbPassages: fields[22] as int,
name: fields[23] as String,
email: fields[24] as String,
phone: fields[25] as String,
lastSyncedAt: fields[26] as DateTime,
isActive: fields[27] as bool,
isSynced: fields[28] as bool,
);
}
@override
void write(BinaryWriter writer, PassageModel obj) {
writer
..writeByte(29)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.fkOperation)
..writeByte(2)
..write(obj.fkSector)
..writeByte(3)
..write(obj.fkUser)
..writeByte(4)
..write(obj.fkType)
..writeByte(5)
..write(obj.fkAdresse)
..writeByte(6)
..write(obj.passedAt)
..writeByte(7)
..write(obj.numero)
..writeByte(8)
..write(obj.rue)
..writeByte(9)
..write(obj.rueBis)
..writeByte(10)
..write(obj.ville)
..writeByte(11)
..write(obj.residence)
..writeByte(12)
..write(obj.fkHabitat)
..writeByte(13)
..write(obj.appt)
..writeByte(14)
..write(obj.niveau)
..writeByte(15)
..write(obj.gpsLat)
..writeByte(16)
..write(obj.gpsLng)
..writeByte(17)
..write(obj.nomRecu)
..writeByte(18)
..write(obj.remarque)
..writeByte(19)
..write(obj.montant)
..writeByte(20)
..write(obj.fkTypeReglement)
..writeByte(21)
..write(obj.emailErreur)
..writeByte(22)
..write(obj.nbPassages)
..writeByte(23)
..write(obj.name)
..writeByte(24)
..write(obj.email)
..writeByte(25)
..write(obj.phone)
..writeByte(26)
..write(obj.lastSyncedAt)
..writeByte(27)
..write(obj.isActive)
..writeByte(28)
..write(obj.isSynced);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is PassageModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,89 @@
import 'package:hive/hive.dart';
part 'region_model.g.dart';
@HiveType(typeId: 7) // Assurez-vous que cet ID est unique
class RegionModel extends HiveObject {
@HiveField(0)
final int id;
@HiveField(1)
final int fkPays;
@HiveField(2)
final String libelle;
@HiveField(3)
final String? libelleLong;
@HiveField(4)
final String? tableOsm;
@HiveField(5)
final String? departements;
@HiveField(6)
final bool chkActive;
RegionModel({
required this.id,
required this.fkPays,
required this.libelle,
this.libelleLong,
this.tableOsm,
this.departements,
this.chkActive = true,
});
// Constructeur de copie
RegionModel copyWith({
int? id,
int? fkPays,
String? libelle,
String? libelleLong,
String? tableOsm,
String? departements,
bool? chkActive,
}) {
return RegionModel(
id: id ?? this.id,
fkPays: fkPays ?? this.fkPays,
libelle: libelle ?? this.libelle,
libelleLong: libelleLong ?? this.libelleLong,
tableOsm: tableOsm ?? this.tableOsm,
departements: departements ?? this.departements,
chkActive: chkActive ?? this.chkActive,
);
}
// Conversion depuis JSON
factory RegionModel.fromJson(Map<String, dynamic> json) {
return RegionModel(
id: json['id'] as int,
fkPays: json['fk_pays'] as int,
libelle: json['libelle'] as String,
libelleLong: json['libelle_long'] as String?,
tableOsm: json['table_osm'] as String?,
departements: json['departements'] as String?,
chkActive: json['chk_active'] == 1 || json['chk_active'] == true,
);
}
// Conversion vers JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'fk_pays': fkPays,
'libelle': libelle,
'libelle_long': libelleLong,
'table_osm': tableOsm,
'departements': departements,
'chk_active': chkActive ? 1 : 0,
};
}
@override
String toString() {
return 'RegionModel(id: $id, libelle: $libelle)';
}
}

View File

@@ -0,0 +1,59 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'region_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class RegionModelAdapter extends TypeAdapter<RegionModel> {
@override
final int typeId = 7;
@override
RegionModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return RegionModel(
id: fields[0] as int,
fkPays: fields[1] as int,
libelle: fields[2] as String,
libelleLong: fields[3] as String?,
tableOsm: fields[4] as String?,
departements: fields[5] as String?,
chkActive: fields[6] as bool,
);
}
@override
void write(BinaryWriter writer, RegionModel obj) {
writer
..writeByte(7)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.fkPays)
..writeByte(2)
..write(obj.libelle)
..writeByte(3)
..write(obj.libelleLong)
..writeByte(4)
..write(obj.tableOsm)
..writeByte(5)
..write(obj.departements)
..writeByte(6)
..write(obj.chkActive);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is RegionModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,85 @@
import 'package:hive/hive.dart';
part 'sector_model.g.dart';
@HiveType(typeId: 3)
class SectorModel extends HiveObject {
@HiveField(0)
final int id;
@HiveField(1)
final String libelle;
@HiveField(2)
final String color;
@HiveField(3)
final String sector;
SectorModel({
required this.id,
required this.libelle,
required this.color,
required this.sector,
});
// Factory pour convertir depuis JSON (API)
factory SectorModel.fromJson(Map<String, dynamic> json) {
return SectorModel(
id: json['id'] is String ? int.parse(json['id']) : json['id'] as int,
libelle: json['libelle'] as String,
color: json['color'] as String,
sector: json['sector'] as String,
);
}
// Convertir en JSON pour l'API
Map<String, dynamic> toJson() {
return {
'id': id,
'libelle': libelle,
'color': color,
'sector': sector,
};
}
// Copier avec de nouvelles valeurs
SectorModel copyWith({
int? id,
String? libelle,
String? color,
String? sector,
}) {
return SectorModel(
id: id ?? this.id,
libelle: libelle ?? this.libelle,
color: color ?? this.color,
sector: sector ?? this.sector,
);
}
// Obtenir les coordonnées du secteur sous forme de liste de points
List<List<double>> getCoordinates() {
final List<List<double>> coordinates = [];
// Le format est "lat1/lng1#lat2/lng2#lat3/lng3#..."
final List<String> points = sector.split('#');
for (final String point in points) {
if (point.isEmpty) continue;
final List<String> latLng = point.split('/');
if (latLng.length == 2) {
try {
final double lat = double.parse(latLng[0]);
final double lng = double.parse(latLng[1]);
coordinates.add([lat, lng]);
} catch (e) {
// Ignorer les points mal formatés
}
}
}
return coordinates;
}
}

View File

@@ -0,0 +1,50 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'sector_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class SectorModelAdapter extends TypeAdapter<SectorModel> {
@override
final int typeId = 3;
@override
SectorModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return SectorModel(
id: fields[0] as int,
libelle: fields[1] as String,
color: fields[2] as String,
sector: fields[3] as String,
);
}
@override
void write(BinaryWriter writer, SectorModel obj) {
writer
..writeByte(4)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.libelle)
..writeByte(2)
..write(obj.color)
..writeByte(3)
..write(obj.sector);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is SectorModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,242 @@
import 'package:hive/hive.dart';
part 'user_model.g.dart';
@HiveType(typeId: 0)
class UserModel extends HiveObject {
@HiveField(0)
final int id;
@HiveField(1)
final String email;
@HiveField(2)
String? name;
@HiveField(11)
String? username;
@HiveField(10)
String? firstName;
@HiveField(3)
final int role;
@HiveField(4)
final DateTime createdAt;
@HiveField(5)
DateTime lastSyncedAt;
@HiveField(6)
bool isActive;
@HiveField(7)
bool isSynced;
@HiveField(8)
String? sessionId;
@HiveField(9)
DateTime? sessionExpiry;
@HiveField(12)
String? lastPath;
@HiveField(13)
String? sectName;
@HiveField(14)
int? fkEntite;
@HiveField(15)
int? fkTitre;
@HiveField(16)
String? phone;
@HiveField(17)
String? mobile;
@HiveField(18)
DateTime? dateNaissance;
@HiveField(19)
DateTime? dateEmbauche;
UserModel({
required this.id,
required this.email,
this.name,
this.username,
this.firstName,
required this.role,
required this.createdAt,
required this.lastSyncedAt,
this.isActive = true,
this.isSynced = false,
this.sessionId,
this.sessionExpiry,
this.lastPath,
this.sectName,
this.fkEntite,
this.fkTitre,
this.phone,
this.mobile,
this.dateNaissance,
this.dateEmbauche,
});
// Factory pour convertir depuis JSON (API)
factory UserModel.fromJson(Map<String, dynamic> json) {
// Convertir l'ID en int, qu'il soit déjà int ou string
final dynamic rawId = json['id'];
final int id = rawId is String ? int.parse(rawId) : rawId as int;
// Convertir le rôle en int, qu'il soit déjà int ou string
final dynamic rawRole = json['role'] ?? json['fk_role'];
final int role = rawRole is String ? int.parse(rawRole) : rawRole as int;
// Convertir fk_entite en int si présent
final dynamic rawFkEntite = json['fk_entite'];
final int? fkEntite = rawFkEntite != null
? (rawFkEntite is String ? int.parse(rawFkEntite) : rawFkEntite as int)
: null;
// Convertir fk_titre en int si présent
final dynamic rawFkTitre = json['fk_titre'];
final int? fkTitre = rawFkTitre != null
? (rawFkTitre is String ? int.parse(rawFkTitre) : rawFkTitre as int)
: null;
// Traiter les dates si présentes
DateTime? dateNaissance;
if (json['date_naissance'] != null && json['date_naissance'] != '') {
try {
dateNaissance = DateTime.parse(json['date_naissance']);
} catch (e) {
dateNaissance = null;
}
}
DateTime? dateEmbauche;
if (json['date_embauche'] != null && json['date_embauche'] != '') {
try {
dateEmbauche = DateTime.parse(json['date_embauche']);
} catch (e) {
dateEmbauche = null;
}
}
return UserModel(
id: id,
email: json['email'] ?? '',
name: json['name'],
username: json['username'],
firstName: json['first_name'],
role: role,
createdAt: json['created_at'] != null
? DateTime.parse(json['created_at'])
: DateTime.now(),
lastSyncedAt: DateTime.now(),
isActive: json['is_active'] ?? true,
isSynced: true,
sessionId: json['session_id'],
sessionExpiry: json['session_expiry'] != null
? DateTime.parse(json['session_expiry'])
: null,
sectName: json['sect_name'],
fkEntite: fkEntite,
fkTitre: fkTitre,
phone: json['phone'],
mobile: json['mobile'],
dateNaissance: dateNaissance,
dateEmbauche: dateEmbauche,
);
}
// Convertir en JSON pour l'API
Map<String, dynamic> toJson() {
return {
'id': id,
'email': email,
'name': name,
'username': username,
'first_name': firstName,
'role': role,
'created_at': createdAt.toIso8601String(),
'is_active': isActive,
'session_id': sessionId,
'session_expiry': sessionExpiry?.toIso8601String(),
'last_path': lastPath,
'sect_name': sectName,
'fk_entite': fkEntite,
'fk_titre': fkTitre,
'phone': phone,
'mobile': mobile,
'date_naissance': dateNaissance?.toIso8601String(),
'date_embauche': dateEmbauche?.toIso8601String(),
};
}
// Copier avec de nouvelles valeurs
UserModel copyWith({
String? email,
String? name,
String? username,
String? firstName,
int? role,
bool? isActive,
bool? isSynced,
DateTime? lastSyncedAt,
String? sessionId,
DateTime? sessionExpiry,
String? lastPath,
String? sectName,
int? fkEntite,
int? fkTitre,
String? phone,
String? mobile,
DateTime? dateNaissance,
DateTime? dateEmbauche,
}) {
return UserModel(
id: this.id,
email: email ?? this.email,
name: name ?? this.name,
username: username ?? this.username,
firstName: firstName ?? this.firstName,
role: role ?? this.role,
createdAt: this.createdAt,
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
isActive: isActive ?? this.isActive,
isSynced: isSynced ?? this.isSynced,
sessionId: sessionId ?? this.sessionId,
sessionExpiry: sessionExpiry ?? this.sessionExpiry,
lastPath: lastPath ?? this.lastPath,
sectName: sectName ?? this.sectName,
fkEntite: fkEntite ?? this.fkEntite,
fkTitre: fkTitre ?? this.fkTitre,
phone: phone ?? this.phone,
mobile: mobile ?? this.mobile,
dateNaissance: dateNaissance ?? this.dateNaissance,
dateEmbauche: dateEmbauche ?? this.dateEmbauche,
);
}
// Vérifier si la session est valide
bool get hasValidSession {
if (sessionId == null || sessionExpiry == null) {
return false;
}
return sessionExpiry!.isAfter(DateTime.now());
}
// Effacer les données de session
UserModel clearSession() {
return copyWith(
sessionId: null,
sessionExpiry: null,
);
}
}

View File

@@ -0,0 +1,98 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class UserModelAdapter extends TypeAdapter<UserModel> {
@override
final int typeId = 0;
@override
UserModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return UserModel(
id: fields[0] as int,
email: fields[1] as String,
name: fields[2] as String?,
username: fields[11] as String?,
firstName: fields[10] as String?,
role: fields[3] as int,
createdAt: fields[4] as DateTime,
lastSyncedAt: fields[5] as DateTime,
isActive: fields[6] as bool,
isSynced: fields[7] as bool,
sessionId: fields[8] as String?,
sessionExpiry: fields[9] as DateTime?,
lastPath: fields[12] as String?,
sectName: fields[13] as String?,
fkEntite: fields[14] as int?,
fkTitre: fields[15] as int?,
phone: fields[16] as String?,
mobile: fields[17] as String?,
dateNaissance: fields[18] as DateTime?,
dateEmbauche: fields[19] as DateTime?,
);
}
@override
void write(BinaryWriter writer, UserModel obj) {
writer
..writeByte(20)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.email)
..writeByte(2)
..write(obj.name)
..writeByte(11)
..write(obj.username)
..writeByte(10)
..write(obj.firstName)
..writeByte(3)
..write(obj.role)
..writeByte(4)
..write(obj.createdAt)
..writeByte(5)
..write(obj.lastSyncedAt)
..writeByte(6)
..write(obj.isActive)
..writeByte(7)
..write(obj.isSynced)
..writeByte(8)
..write(obj.sessionId)
..writeByte(9)
..write(obj.sessionExpiry)
..writeByte(12)
..write(obj.lastPath)
..writeByte(13)
..write(obj.sectName)
..writeByte(14)
..write(obj.fkEntite)
..writeByte(15)
..write(obj.fkTitre)
..writeByte(16)
..write(obj.phone)
..writeByte(17)
..write(obj.mobile)
..writeByte(18)
..write(obj.dateNaissance)
..writeByte(19)
..write(obj.dateEmbauche);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is UserModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,80 @@
import 'package:hive/hive.dart';
part 'user_sector_model.g.dart';
/// Modèle pour stocker les associations entre utilisateurs et secteurs
///
/// Cette classe représente l'association entre un utilisateur et un secteur,
/// telle que reçue de l'API dans la réponse users_sectors.
@HiveType(
typeId: 7) // Assurez-vous que cet ID est unique parmi vos modèles Hive
class UserSectorModel extends HiveObject {
@HiveField(0)
final int id; // ID de l'utilisateur
@HiveField(1)
final String? firstName;
@HiveField(2)
final String? sectName;
@HiveField(3)
final int fkSector; // ID du secteur
@HiveField(4)
final String? name;
UserSectorModel({
required this.id,
this.firstName,
this.sectName,
required this.fkSector,
this.name,
});
/// Crée un modèle UserSectorModel à partir d'un objet JSON
factory UserSectorModel.fromJson(Map<String, dynamic> json) {
return UserSectorModel(
id: json['id'] is String ? int.parse(json['id']) : json['id'],
firstName: json['first_name'],
sectName: json['sect_name'],
fkSector: json['fk_sector'] is String
? int.parse(json['fk_sector'])
: json['fk_sector'],
name: json['name'],
);
}
/// Convertit le modèle en objet JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'first_name': firstName,
'sect_name': sectName,
'fk_sector': fkSector,
'name': name,
};
}
/// Crée une copie du modèle avec des valeurs potentiellement modifiées
UserSectorModel copyWith({
int? id,
String? firstName,
String? sectName,
int? fkSector,
String? name,
}) {
return UserSectorModel(
id: id ?? this.id,
firstName: firstName ?? this.firstName,
sectName: sectName ?? this.sectName,
fkSector: fkSector ?? this.fkSector,
name: name ?? this.name,
);
}
@override
String toString() {
return 'UserSectorModel(id: $id, firstName: $firstName, sectName: $sectName, fkSector: $fkSector, name: $name)';
}
}

View File

@@ -0,0 +1,53 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_sector_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class UserSectorModelAdapter extends TypeAdapter<UserSectorModel> {
@override
final int typeId = 7;
@override
UserSectorModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return UserSectorModel(
id: fields[0] as int,
firstName: fields[1] as String?,
sectName: fields[2] as String?,
fkSector: fields[3] as int,
name: fields[4] as String?,
);
}
@override
void write(BinaryWriter writer, UserSectorModel obj) {
writer
..writeByte(5)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.firstName)
..writeByte(2)
..write(obj.sectName)
..writeByte(3)
..write(obj.fkSector)
..writeByte(4)
..write(obj.name);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is UserSectorModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,75 @@
/// Modèle pour suivre l'état du chargement des données
class LoadingState {
/// Progression globale (0.0 à 1.0)
final double progress;
/// Description de l'étape en cours
final String? stepDescription;
/// Message principal
final String? message;
/// Indique si le chargement est terminé
final bool isCompleted;
/// Indique si une erreur s'est produite
final bool hasError;
/// Message d'erreur éventuel
final String? errorMessage;
const LoadingState({
this.progress = 0.0,
this.stepDescription,
this.message,
this.isCompleted = false,
this.hasError = false,
this.errorMessage,
});
/// Crée un nouvel état de chargement avec les valeurs mises à jour
LoadingState copyWith({
double? progress,
String? stepDescription,
String? message,
bool? isCompleted,
bool? hasError,
String? errorMessage,
}) {
return LoadingState(
progress: progress ?? this.progress,
stepDescription: stepDescription ?? this.stepDescription,
message: message ?? this.message,
isCompleted: isCompleted ?? this.isCompleted,
hasError: hasError ?? this.hasError,
errorMessage: errorMessage ?? this.errorMessage,
);
}
/// État initial du chargement
static const initial = LoadingState(
progress: 0.0,
message: 'Chargement en cours...',
isCompleted: false,
hasError: false,
);
/// État de chargement terminé avec succès
static const completed = LoadingState(
progress: 1.0,
message: 'Chargement terminé',
isCompleted: true,
hasError: false,
);
/// Crée un état d'erreur
static LoadingState error(String message) {
return LoadingState(
progress: 0.0,
message: 'Erreur de chargement',
errorMessage: message,
isCompleted: true,
hasError: true,
);
}
}

View File

@@ -0,0 +1,295 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/data/models/amicale_model.dart';
class AmicaleRepository extends ChangeNotifier {
// Utilisation de getters lazy pour n'accéder à la boîte que lorsque nécessaire
Box<AmicaleModel> get _amicaleBox =>
Hive.box<AmicaleModel>(AppKeys.amicaleBoxName);
final ApiService _apiService;
bool _isLoading = false;
AmicaleRepository(this._apiService);
// Getters
bool get isLoading => _isLoading;
// Méthode pour vérifier si une boîte est ouverte et l'ouvrir si nécessaire
Future<void> _ensureBoxIsOpen() async {
try {
if (!Hive.isBoxOpen(AppKeys.amicaleBoxName)) {
debugPrint('Ouverture de la boîte amicale...');
await Hive.openBox<AmicaleModel>(AppKeys.amicaleBoxName);
}
} catch (e) {
debugPrint('Erreur lors de l\'ouverture de la boîte amicale: $e');
throw Exception('Impossible d\'ouvrir la boîte amicale: $e');
}
}
// Récupérer toutes les amicales
List<AmicaleModel> getAllAmicales() {
try {
_ensureBoxIsOpen();
return _amicaleBox.values.toList();
} catch (e) {
debugPrint('Erreur lors de la récupération des amicales: $e');
return [];
}
}
// Récupérer une amicale par son ID
AmicaleModel? getAmicaleById(int id) {
try {
_ensureBoxIsOpen();
return _amicaleBox.get(id);
} catch (e) {
debugPrint('Erreur lors de la récupération de l\'amicale: $e');
return null;
}
}
// Récupérer l'amicale de l'utilisateur connecté (basé sur fkEntite)
AmicaleModel? getAmicaleByUserId(int userId, int fkEntite) {
try {
_ensureBoxIsOpen();
return _amicaleBox.get(fkEntite);
} catch (e) {
debugPrint(
'Erreur lors de la récupération de l\'amicale de l\'utilisateur: $e');
return null;
}
}
// Créer ou mettre à jour une amicale localement
Future<AmicaleModel> saveAmicale(AmicaleModel amicale) async {
await _ensureBoxIsOpen();
await _amicaleBox.put(amicale.id, amicale);
notifyListeners(); // Notifier les changements pour mettre à jour l'UI
return amicale;
}
// Supprimer une amicale localement
Future<void> deleteAmicale(int id) async {
await _ensureBoxIsOpen();
await _amicaleBox.delete(id);
notifyListeners();
}
// Vider la boîte des amicales
Future<void> clearAmicales() async {
await _ensureBoxIsOpen();
await _amicaleBox.clear();
notifyListeners();
}
// Traiter les données des amicales reçues de l'API
Future<void> processAmicalesData(dynamic amicalesData) async {
try {
debugPrint('Traitement des données des amicales...');
debugPrint('Détails amicale: $amicalesData');
// Vérifier que les données sont au bon format
if (amicalesData == null) {
debugPrint('Aucune donnée d\'amicale à traiter');
return;
}
// Vider la boîte avant d'ajouter les nouvelles données
await _ensureBoxIsOpen();
await _amicaleBox.clear();
int count = 0;
// Cas 1: Les données sont une liste d'amicales
if (amicalesData is List) {
for (final amicaleData in amicalesData) {
try {
final amicale = AmicaleModel.fromJson(amicaleData);
await _amicaleBox.put(amicale.id, amicale);
count++;
debugPrint('Amicale traitée: ${amicale.name} (ID: ${amicale.id})');
} catch (e) {
debugPrint('Erreur lors du traitement d\'une amicale: $e');
}
}
}
// Cas 2: Les données sont un objet avec une clé 'data' contenant une liste
else if (amicalesData is Map && amicalesData.containsKey('data')) {
final amicalesList = amicalesData['data'] as List<dynamic>;
for (final amicaleData in amicalesList) {
try {
final amicale = AmicaleModel.fromJson(amicaleData);
await _amicaleBox.put(amicale.id, amicale);
count++;
debugPrint('Amicale traitée: ${amicale.name} (ID: ${amicale.id})');
} catch (e) {
debugPrint('Erreur lors du traitement d\'une amicale: $e');
}
}
}
// Cas 3: Les données sont un objet amicale unique (pas une liste)
else if (amicalesData is Map) {
try {
// Convertir Map<dynamic, dynamic> en Map<String, dynamic>
final Map<String, dynamic> amicaleMap = {};
amicalesData.forEach((key, value) {
if (key is String) {
amicaleMap[key] = value;
}
});
final amicale = AmicaleModel.fromJson(amicaleMap);
await _amicaleBox.put(amicale.id, amicale);
count++;
debugPrint(
'Amicale unique traitée: ${amicale.name} (ID: ${amicale.id})');
} catch (e) {
debugPrint('Erreur lors du traitement de l\'amicale unique: $e');
debugPrint('Exception détaillée: $e');
}
} else {
debugPrint('Format de données d\'amicale non reconnu');
return;
}
debugPrint('$count amicales traitées et stockées');
notifyListeners();
} catch (e) {
debugPrint('Erreur lors du traitement des amicales: $e');
}
}
// Récupérer les amicales depuis l'API
Future<List<AmicaleModel>> fetchAmicalesFromApi() async {
_isLoading = true;
notifyListeners();
try {
final response = await _apiService.get('/amicales');
if (response.statusCode == 200) {
final amicalesData = response.data;
await processAmicalesData(amicalesData);
return getAllAmicales();
} else {
debugPrint(
'Erreur lors de la récupération des amicales: ${response.statusCode}');
return [];
}
} catch (e) {
debugPrint('Erreur lors de la récupération des amicales: $e');
return [];
} finally {
_isLoading = false;
notifyListeners();
}
}
// Récupérer une amicale spécifique depuis l'API
Future<AmicaleModel?> fetchAmicaleByIdFromApi(int id) async {
_isLoading = true;
notifyListeners();
try {
final response = await _apiService.get('/amicales/$id');
if (response.statusCode == 200) {
final amicaleData = response.data;
final amicale = AmicaleModel.fromJson(amicaleData);
await saveAmicale(amicale);
return amicale;
} else {
debugPrint(
'Erreur lors de la récupération de l\'amicale: ${response.statusCode}');
return null;
}
} catch (e) {
debugPrint('Erreur lors de la récupération de l\'amicale: $e');
return null;
} finally {
_isLoading = false;
notifyListeners();
}
}
// Mettre à jour une amicale via l'API
Future<AmicaleModel?> updateAmicaleViaApi(AmicaleModel amicale) async {
_isLoading = true;
notifyListeners();
try {
final response = await _apiService.put(
'/amicales/${amicale.id}',
data: amicale.toJson(),
);
if (response.statusCode == 200) {
final updatedAmicaleData = response.data;
final updatedAmicale = AmicaleModel.fromJson(updatedAmicaleData);
await saveAmicale(updatedAmicale);
return updatedAmicale;
} else {
debugPrint(
'Erreur lors de la mise à jour de l\'amicale: ${response.statusCode}');
return null;
}
} catch (e) {
debugPrint('Erreur lors de la mise à jour de l\'amicale: $e');
return null;
} finally {
_isLoading = false;
notifyListeners();
}
}
// Filtrer les amicales par nom
List<AmicaleModel> searchAmicalesByName(String query) {
if (query.isEmpty) {
return getAllAmicales();
}
final lowercaseQuery = query.toLowerCase();
return _amicaleBox.values
.where((amicale) => amicale.name.toLowerCase().contains(lowercaseQuery))
.toList();
}
// Filtrer les amicales par type
List<AmicaleModel> getAmicalesByType(int type) {
return _amicaleBox.values
.where((amicale) => amicale.fkType == type)
.toList();
}
// Filtrer les amicales par région
List<AmicaleModel> getAmicalesByRegion(int regionId) {
return _amicaleBox.values
.where((amicale) => amicale.fkRegion == regionId)
.toList();
}
// Filtrer les amicales actives
List<AmicaleModel> getActiveAmicales() {
return _amicaleBox.values.where((amicale) => amicale.chkActive).toList();
}
// Filtrer les amicales par code postal
List<AmicaleModel> getAmicalesByPostalCode(String postalCode) {
return _amicaleBox.values
.where((amicale) => amicale.codePostal == postalCode)
.toList();
}
// Filtrer les amicales par ville
List<AmicaleModel> getAmicalesByCity(String city) {
final lowercaseCity = city.toLowerCase();
return _amicaleBox.values
.where((amicale) => amicale.ville.toLowerCase().contains(lowercaseCity))
.toList();
}
}

View File

@@ -0,0 +1,179 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/data/models/client_model.dart';
class ClientRepository extends ChangeNotifier {
// Utilisation de getters lazy pour n'accéder à la boîte que lorsque nécessaire
Box<ClientModel> get _clientBox =>
Hive.box<ClientModel>(AppKeys.clientsBoxName);
final ApiService _apiService;
bool _isLoading = false;
ClientRepository(this._apiService);
// Getters
bool get isLoading => _isLoading;
// Méthode pour vérifier si une boîte est ouverte et l'ouvrir si nécessaire
Future<void> _ensureBoxIsOpen() async {
try {
if (!Hive.isBoxOpen(AppKeys.clientsBoxName)) {
debugPrint('Ouverture de la boîte clients...');
await Hive.openBox<ClientModel>(AppKeys.clientsBoxName);
}
} catch (e) {
debugPrint('Erreur lors de l\'ouverture de la boîte clients: $e');
throw Exception('Impossible d\'ouvrir la boîte clients: $e');
}
}
// Récupérer tous les clients
List<ClientModel> getAllClients() {
try {
_ensureBoxIsOpen();
return _clientBox.values.toList();
} catch (e) {
debugPrint('Erreur lors de la récupération des clients: $e');
return [];
}
}
// Récupérer un client par son ID
ClientModel? getClientById(int id) {
try {
_ensureBoxIsOpen();
return _clientBox.get(id);
} catch (e) {
debugPrint('Erreur lors de la récupération du client: $e');
return null;
}
}
// Créer ou mettre à jour un client localement
Future<ClientModel> saveClient(ClientModel client) async {
await _ensureBoxIsOpen();
await _clientBox.put(client.id, client);
notifyListeners(); // Notifier les changements pour mettre à jour l'UI
return client;
}
// Supprimer un client localement
Future<void> deleteClient(int id) async {
await _ensureBoxIsOpen();
await _clientBox.delete(id);
notifyListeners();
}
// Vider la boîte des clients
Future<void> clearClients() async {
await _ensureBoxIsOpen();
await _clientBox.clear();
notifyListeners();
}
// Traiter les données des clients reçues de l'API
Future<void> processClientsData(dynamic clientsData) async {
try {
debugPrint('Traitement des données des clients...');
// Vérifier que les données sont au bon format
if (clientsData == null) {
debugPrint('Aucune donnée de client à traiter');
return;
}
List<dynamic> clientsList;
if (clientsData is List) {
clientsList = clientsData;
} else if (clientsData is Map && clientsData.containsKey('data')) {
clientsList = clientsData['data'] as List<dynamic>;
} else {
debugPrint('Format de données de clients non reconnu');
return;
}
// Vider la boîte avant d'ajouter les nouvelles données
await _ensureBoxIsOpen();
await _clientBox.clear();
// Traiter chaque client
int count = 0;
for (final clientData in clientsList) {
try {
final client = ClientModel.fromJson(clientData);
await _clientBox.put(client.id, client);
count++;
debugPrint('Client traité: ${client.name} (ID: ${client.id})');
} catch (e) {
debugPrint('Erreur lors du traitement d\'un client: $e');
}
}
debugPrint('$count clients traités et stockés');
notifyListeners();
} catch (e) {
debugPrint('Erreur lors du traitement des clients: $e');
}
}
// Récupérer les clients depuis l'API
Future<List<ClientModel>> fetchClientsFromApi() async {
_isLoading = true;
notifyListeners();
try {
final response = await _apiService.get('/clients');
if (response.statusCode == 200) {
final clientsData = response.data;
await processClientsData(clientsData);
return getAllClients();
} else {
debugPrint(
'Erreur lors de la récupération des clients: ${response.statusCode}');
return [];
}
} catch (e) {
debugPrint('Erreur lors de la récupération des clients: $e');
return [];
} finally {
_isLoading = false;
notifyListeners();
}
}
// Filtrer les clients par nom
List<ClientModel> searchClientsByName(String query) {
if (query.isEmpty) {
return getAllClients();
}
final lowercaseQuery = query.toLowerCase();
return _clientBox.values
.where((client) => client.name.toLowerCase().contains(lowercaseQuery))
.toList();
}
// Filtrer les clients par type
List<ClientModel> getClientsByType(int type) {
return _clientBox.values.where((client) => client.fkType == type).toList();
}
// Filtrer les clients par région
List<ClientModel> getClientsByRegion(int regionId) {
return _clientBox.values
.where((client) => client.fkRegion == regionId)
.toList();
}
// Filtrer les clients actifs
List<ClientModel> getActiveClients() {
return _clientBox.values
.where((client) => client.chkActive == true)
.toList();
}
}

View File

@@ -0,0 +1,208 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
class MembreRepository extends ChangeNotifier {
// Utilisation de getters lazy pour n'accéder à la boîte que lorsque nécessaire
Box<MembreModel> get _membreBox =>
Hive.box<MembreModel>(AppKeys.membresBoxName);
final ApiService _apiService;
bool _isLoading = false;
MembreRepository(this._apiService);
// Getters
bool get isLoading => _isLoading;
List<MembreModel> get membres => getAllMembres();
// Méthode pour vérifier si une boîte est ouverte et l'ouvrir si nécessaire
Future<void> _ensureBoxIsOpen() async {
try {
if (!Hive.isBoxOpen(AppKeys.membresBoxName)) {
debugPrint('Ouverture de la boîte ${AppKeys.membresBoxName}...');
await Hive.openBox<MembreModel>(AppKeys.membresBoxName);
debugPrint('Boîte ${AppKeys.membresBoxName} ouverte avec succès');
}
} catch (e) {
debugPrint(
'Erreur lors de l\'ouverture de la boîte ${AppKeys.membresBoxName}: $e');
throw Exception(
'Impossible d\'ouvrir la boîte ${AppKeys.membresBoxName}: $e');
}
}
// Récupérer tous les membres
List<MembreModel> getAllMembres() {
try {
return _membreBox.values.toList();
} catch (e) {
debugPrint('Erreur lors de la récupération des membres: $e');
return [];
}
}
// Récupérer un membre par son ID
MembreModel? getMembreById(int id) {
try {
return _membreBox.get(id);
} catch (e) {
debugPrint('Erreur lors de la récupération du membre: $e');
return null;
}
}
// Créer ou mettre à jour un membre
Future<MembreModel> saveMembre(MembreModel membre) async {
await _ensureBoxIsOpen();
await _membreBox.put(membre.id, membre);
notifyListeners();
return membre;
}
// Supprimer un membre
Future<void> deleteMembre(int id) async {
await _ensureBoxIsOpen();
await _membreBox.delete(id);
notifyListeners();
}
// Récupérer les membres depuis l'API (uniquement pour l'interface admin)
Future<List<MembreModel>> fetchMembresFromApi() async {
_isLoading = true;
notifyListeners();
try {
final hasConnection = await _apiService.hasInternetConnection();
if (!hasConnection) {
debugPrint(
'Pas de connexion Internet, utilisation des données locales');
return getAllMembres();
}
// Endpoint à adapter selon votre API
final response = await _apiService.get('/membres');
final List<dynamic> membresData = response.data['membres'];
// Vider la boîte avant d'ajouter les nouveaux membres
await _ensureBoxIsOpen();
await _membreBox.clear();
final List<MembreModel> membres = [];
for (var membreData in membresData) {
try {
final membre = MembreModel.fromJson(membreData);
await _membreBox.put(membre.id, membre);
membres.add(membre);
} catch (e) {
debugPrint('Erreur lors du traitement d\'un membre: $e');
continue;
}
}
notifyListeners();
return membres;
} catch (e) {
debugPrint(
'Erreur lors de la récupération des membres depuis l\'API: $e');
return getAllMembres();
} finally {
_isLoading = false;
notifyListeners();
}
}
// Créer un membre via l'API
Future<MembreModel?> createMembreViaApi(MembreModel membre) async {
_isLoading = true;
notifyListeners();
try {
final hasConnection = await _apiService.hasInternetConnection();
if (!hasConnection) {
debugPrint('Pas de connexion Internet, impossible de créer le membre');
return null;
}
// Endpoint à adapter selon votre API
final response =
await _apiService.post('/membres', data: membre.toJson());
final membreData = response.data['membre'];
final newMembre = MembreModel.fromJson(membreData);
await saveMembre(newMembre);
return newMembre;
} catch (e) {
debugPrint('Erreur lors de la création du membre via l\'API: $e');
return null;
} finally {
_isLoading = false;
notifyListeners();
}
}
// Mettre à jour un membre via l'API
Future<MembreModel?> updateMembreViaApi(MembreModel membre) async {
_isLoading = true;
notifyListeners();
try {
final hasConnection = await _apiService.hasInternetConnection();
if (!hasConnection) {
debugPrint(
'Pas de connexion Internet, impossible de mettre à jour le membre');
return null;
}
// Endpoint à adapter selon votre API
final response =
await _apiService.put('/membres/${membre.id}', data: membre.toJson());
final membreData = response.data['membre'];
final updatedMembre = MembreModel.fromJson(membreData);
await saveMembre(updatedMembre);
return updatedMembre;
} catch (e) {
debugPrint('Erreur lors de la mise à jour du membre via l\'API: $e');
return null;
} finally {
_isLoading = false;
notifyListeners();
}
}
// Supprimer un membre via l'API
Future<bool> deleteMembreViaApi(int id) async {
_isLoading = true;
notifyListeners();
try {
final hasConnection = await _apiService.hasInternetConnection();
if (!hasConnection) {
debugPrint(
'Pas de connexion Internet, impossible de supprimer le membre');
return false;
}
// Endpoint à adapter selon votre API
await _apiService.delete('/membres/$id');
// Supprimer localement
await deleteMembre(id);
return true;
} catch (e) {
debugPrint('Erreur lors de la suppression du membre via l\'API: $e');
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
}

View File

@@ -0,0 +1,215 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/operation_model.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
class OperationRepository extends ChangeNotifier {
// Utiliser un getter lazy pour n'accéder à la boîte que lorsque nécessaire
// et vérifier qu'elle est ouverte avant accès
Box<OperationModel> get _operationBox {
_ensureBoxIsOpen();
return Hive.box<OperationModel>(AppKeys.operationsBoxName);
}
// Méthode pour vérifier si la boîte est ouverte et l'ouvrir si nécessaire
Future<void> _ensureBoxIsOpen() async {
final boxName = AppKeys.operationsBoxName;
if (!Hive.isBoxOpen(boxName)) {
debugPrint('Ouverture de la boîte $boxName dans OperationRepository...');
await Hive.openBox<OperationModel>(boxName);
}
}
final ApiService _apiService;
bool _isLoading = false;
OperationRepository(this._apiService);
// Getters
bool get isLoading => _isLoading;
List<OperationModel> get operations => getAllOperations();
// Récupérer toutes les opérations
List<OperationModel> getAllOperations() {
return _operationBox.values.toList();
}
// Récupérer une opération par son ID
OperationModel? getOperationById(int id) {
return _operationBox.get(id);
}
// Sauvegarder une opération
Future<void> saveOperation(OperationModel operation) async {
await _operationBox.put(operation.id, operation);
notifyListeners();
}
// Supprimer une opération
Future<void> deleteOperation(int id) async {
await _operationBox.delete(id);
notifyListeners();
}
// Créer ou mettre à jour des opérations à partir des données de l'API
Future<void> processOperationsFromApi(List<dynamic> operationsData) async {
_isLoading = true;
notifyListeners();
try {
for (var operationData in operationsData) {
final operationJson = operationData as Map<String, dynamic>;
final operationId = operationJson['id'] is String
? int.parse(operationJson['id'])
: operationJson['id'] as int;
// Vérifier si l'opération existe déjà
OperationModel? existingOperation = getOperationById(operationId);
if (existingOperation == null) {
// Créer une nouvelle opération
final newOperation = OperationModel.fromJson(operationJson);
await saveOperation(newOperation);
} else {
// Mettre à jour l'opération existante
final updatedOperation = existingOperation.copyWith(
name: operationJson['name'],
dateDebut: DateTime.parse(operationJson['date_deb']),
dateFin: DateTime.parse(operationJson['date_fin']),
lastSyncedAt: DateTime.now(),
isSynced: true,
);
await saveOperation(updatedOperation);
}
}
} catch (e) {
debugPrint('Erreur lors du traitement des opérations: $e');
} finally {
_isLoading = false;
notifyListeners();
}
}
// Créer une opération
Future<bool> createOperation(String name, DateTime dateDebut, DateTime dateFin) async {
_isLoading = true;
notifyListeners();
try {
// Préparer les données pour l'API
final Map<String, dynamic> data = {
'name': name,
'date_deb': dateDebut.toIso8601String().split('T')[0], // Format YYYY-MM-DD
'date_fin': dateFin.toIso8601String().split('T')[0], // Format YYYY-MM-DD
};
// Appeler l'API pour créer l'opération
final response = await _apiService.post('/operations', data: data);
if (response.statusCode == 201 || response.statusCode == 200) {
// Récupérer l'ID de la nouvelle opération
final operationId = response.data['id'] is String
? int.parse(response.data['id'])
: response.data['id'] as int;
// Créer l'opération localement
final newOperation = OperationModel(
id: operationId,
name: name,
dateDebut: dateDebut,
dateFin: dateFin,
lastSyncedAt: DateTime.now(),
isActive: true,
isSynced: true,
);
await saveOperation(newOperation);
return true;
}
return false;
} catch (e) {
debugPrint('Erreur lors de la création de l\'opération: $e');
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
// Mettre à jour une opération
Future<bool> updateOperation(int id, {String? name, DateTime? dateDebut, DateTime? dateFin, bool? isActive}) async {
_isLoading = true;
notifyListeners();
try {
// Récupérer l'opération existante
final existingOperation = getOperationById(id);
if (existingOperation == null) {
return false;
}
// Préparer les données pour l'API
final Map<String, dynamic> data = {
'id': id,
'name': name ?? existingOperation.name,
'date_deb': (dateDebut ?? existingOperation.dateDebut).toIso8601String().split('T')[0],
'date_fin': (dateFin ?? existingOperation.dateFin).toIso8601String().split('T')[0],
'is_active': isActive ?? existingOperation.isActive,
};
// Appeler l'API pour mettre à jour l'opération
final response = await _apiService.put('/operations/$id', data: data);
if (response.statusCode == 200) {
// Mettre à jour l'opération localement
final updatedOperation = existingOperation.copyWith(
name: name,
dateDebut: dateDebut,
dateFin: dateFin,
isActive: isActive,
lastSyncedAt: DateTime.now(),
isSynced: true,
);
await saveOperation(updatedOperation);
return true;
}
return false;
} catch (e) {
debugPrint('Erreur lors de la mise à jour de l\'opération: $e');
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
// Supprimer une opération via l'API
Future<bool> deleteOperationViaApi(int id) async {
_isLoading = true;
notifyListeners();
try {
// Appeler l'API pour supprimer l'opération
final response = await _apiService.delete('/operations/$id');
if (response.statusCode == 200 || response.statusCode == 204) {
// Supprimer l'opération localement
await deleteOperation(id);
return true;
}
return false;
} catch (e) {
debugPrint('Erreur lors de la suppression de l\'opération: $e');
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
}

View File

@@ -0,0 +1,502 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
class PassageRepository extends ChangeNotifier {
// Utiliser un getter lazy pour n'accéder à la boîte que lorsque nécessaire
// et vérifier qu'elle est ouverte avant accès
Box<PassageModel>? _box;
Box<PassageModel> get _passageBox {
if (_box != null && _box!.isOpen) {
return _box!;
}
if (!Hive.isBoxOpen(AppKeys.passagesBoxName)) {
throw StateError(
'La boîte ${AppKeys.passagesBoxName} n\'est pas ouverte. Appelez _ensureBoxIsOpen() avant d\'accéder à la boîte.');
}
_box = Hive.box<PassageModel>(AppKeys.passagesBoxName);
return _box!;
}
// Méthode pour vérifier si la boîte est ouverte et l'ouvrir si nécessaire
Future<void> _ensureBoxIsOpen() async {
final boxName = AppKeys.passagesBoxName;
// Si nous avons déjà une référence à la boîte et qu'elle est ouverte, retourner
if (_box != null && _box!.isOpen) {
return;
}
// Si la boîte est déjà ouverte, récupérer la référence
if (Hive.isBoxOpen(boxName)) {
_box = Hive.box<PassageModel>(boxName);
debugPrint(
'PassageRepository: Boîte $boxName déjà ouverte, référence récupérée');
return;
}
// Sinon, ouvrir la boîte
try {
debugPrint('PassageRepository: Ouverture de la boîte $boxName...');
_box = await Hive.openBox<PassageModel>(boxName);
debugPrint('PassageRepository: Boîte $boxName ouverte avec succès');
} catch (e) {
debugPrint(
'PassageRepository: ERREUR lors de l\'ouverture de la boîte $boxName: $e');
rethrow; // Propager l'erreur pour permettre une gestion appropriée
}
}
final ApiService _apiService;
bool _isLoading = false;
PassageRepository(this._apiService);
// Getters
bool get isLoading => _isLoading;
List<PassageModel> get passages => getAllPassages();
// Récupérer tous les passages
List<PassageModel> getAllPassages() {
try {
// S'assurer que la boîte est ouverte avant d'y accéder
_ensureBoxIsOpen().then((_) {
debugPrint(
'PassageRepository: Boîte ouverte avec succès pour getAllPassages');
}).catchError((e) {
debugPrint(
'PassageRepository: Erreur lors de l\'ouverture de la boîte pour getAllPassages: $e');
});
return _passageBox.values.toList();
} catch (e) {
debugPrint('PassageRepository: Erreur dans getAllPassages: $e');
return []; // Retourner une liste vide en cas d'erreur
}
}
// Récupérer un passage par son ID
PassageModel? getPassageById(int id) {
try {
// S'assurer que la boîte est ouverte avant d'y accéder
_ensureBoxIsOpen().then((_) {
debugPrint(
'PassageRepository: Boîte ouverte avec succès pour getPassageById');
}).catchError((e) {
debugPrint(
'PassageRepository: Erreur lors de l\'ouverture de la boîte pour getPassageById: $e');
});
return _passageBox.get(id);
} catch (e) {
debugPrint('PassageRepository: Erreur dans getPassageById: $e');
return null;
}
}
// Récupérer les passages par secteur
List<PassageModel> getPassagesBySector(int sectorId) {
try {
// S'assurer que la boîte est ouverte avant d'y accéder
_ensureBoxIsOpen().then((_) {
debugPrint(
'PassageRepository: Boîte ouverte avec succès pour getPassagesBySector');
}).catchError((e) {
debugPrint(
'PassageRepository: Erreur lors de l\'ouverture de la boîte pour getPassagesBySector: $e');
});
return _passageBox.values
.where((passage) => passage.fkSector == sectorId)
.toList();
} catch (e) {
debugPrint('PassageRepository: Erreur dans getPassagesBySector: $e');
return [];
}
}
// Récupérer les passages par opération
List<PassageModel> getPassagesByOperation(int operationId) {
try {
// S'assurer que la boîte est ouverte avant d'y accéder
_ensureBoxIsOpen().then((_) {
debugPrint(
'PassageRepository: Boîte ouverte avec succès pour getPassagesByOperation');
}).catchError((e) {
debugPrint(
'PassageRepository: Erreur lors de l\'ouverture de la boîte pour getPassagesByOperation: $e');
});
return _passageBox.values
.where((passage) => passage.fkOperation == operationId)
.toList();
} catch (e) {
debugPrint('PassageRepository: Erreur dans getPassagesByOperation: $e');
return [];
}
}
// Récupérer les passages par type
List<PassageModel> getPassagesByType(int typeId) {
try {
// S'assurer que la boîte est ouverte avant d'y accéder
_ensureBoxIsOpen().then((_) {
debugPrint(
'PassageRepository: Boîte ouverte avec succès pour getPassagesByType');
}).catchError((e) {
debugPrint(
'PassageRepository: Erreur lors de l\'ouverture de la boîte pour getPassagesByType: $e');
});
return _passageBox.values
.where((passage) => passage.fkType == typeId)
.toList();
} catch (e) {
debugPrint('PassageRepository: Erreur dans getPassagesByType: $e');
return [];
}
}
// Récupérer les passages par type de règlement
List<PassageModel> getPassagesByPaymentType(int paymentTypeId) {
try {
// S'assurer que la boîte est ouverte avant d'y accéder
_ensureBoxIsOpen().then((_) {
debugPrint(
'PassageRepository: Boîte ouverte avec succès pour getPassagesByPaymentType');
}).catchError((e) {
debugPrint(
'PassageRepository: Erreur lors de l\'ouverture de la boîte pour getPassagesByPaymentType: $e');
});
return _passageBox.values
.where((passage) => passage.fkTypeReglement == paymentTypeId)
.toList();
} catch (e) {
debugPrint('PassageRepository: Erreur dans getPassagesByPaymentType: $e');
return [];
}
}
// Sauvegarder un passage
Future<void> savePassage(PassageModel passage) async {
await _passageBox.put(passage.id, passage);
notifyListeners();
}
// Supprimer un passage
Future<void> deletePassage(int id) async {
await _passageBox.delete(id);
notifyListeners();
}
// Traiter les passages reçus de l'API
Future<void> processPassagesFromApi(List<dynamic> passagesData) async {
_isLoading = true;
notifyListeners();
try {
for (var passageData in passagesData) {
final passageJson = passageData as Map<String, dynamic>;
final passageId = passageJson['id'] is String
? int.parse(passageJson['id'])
: passageJson['id'] as int;
// Vérifier si le passage existe déjà
PassageModel? existingPassage = getPassageById(passageId);
if (existingPassage == null) {
// Créer un nouveau passage
final newPassage = PassageModel.fromJson(passageJson);
await savePassage(newPassage);
} else {
// Mettre à jour le passage existant avec les nouvelles données
final updatedPassage = PassageModel.fromJson(passageJson).copyWith(
lastSyncedAt: DateTime.now(),
isActive: existingPassage.isActive,
isSynced: true,
);
await savePassage(updatedPassage);
}
}
} catch (e) {
debugPrint('Erreur lors du traitement des passages: $e');
} finally {
_isLoading = false;
notifyListeners();
}
}
// Créer un nouveau passage
Future<bool> createPassage({
required int fkOperation,
required int fkSector,
required int fkUser,
required int fkType,
required String fkAdresse,
required DateTime passedAt,
required String numero,
required String rue,
String rueBis = '',
required String ville,
String residence = '',
required int fkHabitat,
String appt = '',
String niveau = '',
required String gpsLat,
required String gpsLng,
String nomRecu = '',
String remarque = '',
required String montant,
required int fkTypeReglement,
String name = '',
String email = '',
String phone = '',
}) async {
_isLoading = true;
notifyListeners();
try {
// Préparer les données pour l'API
final Map<String, dynamic> data = {
'fk_operation': fkOperation,
'fk_sector': fkSector,
'fk_user': fkUser,
'fk_type': fkType,
'fk_adresse': fkAdresse,
'passed_at': passedAt.toIso8601String(),
'numero': numero,
'rue': rue,
'rue_bis': rueBis,
'ville': ville,
'residence': residence,
'fk_habitat': fkHabitat,
'appt': appt,
'niveau': niveau,
'gps_lat': gpsLat,
'gps_lng': gpsLng,
'nom_recu': nomRecu,
'remarque': remarque,
'montant': montant,
'fk_type_reglement': fkTypeReglement,
'name': name,
'email': email,
'phone': phone,
};
// Appeler l'API pour créer le passage
final response = await _apiService.post('/passages', data: data);
if (response.statusCode == 201 || response.statusCode == 200) {
// Récupérer l'ID du nouveau passage
final passageId = response.data['id'] is String
? int.parse(response.data['id'])
: response.data['id'] as int;
// Créer le modèle local
final newPassage = PassageModel(
id: passageId,
fkOperation: fkOperation,
fkSector: fkSector,
fkUser: fkUser,
fkType: fkType,
fkAdresse: fkAdresse,
passedAt: passedAt,
numero: numero,
rue: rue,
rueBis: rueBis,
ville: ville,
residence: residence,
fkHabitat: fkHabitat,
appt: appt,
niveau: niveau,
gpsLat: gpsLat,
gpsLng: gpsLng,
nomRecu: nomRecu,
remarque: remarque,
montant: montant,
fkTypeReglement: fkTypeReglement,
nbPassages: 1, // Par défaut pour un nouveau passage
name: name,
email: email,
phone: phone,
lastSyncedAt: DateTime.now(),
isActive: true,
isSynced: true,
);
await savePassage(newPassage);
return true;
} else {
debugPrint(
'Erreur lors de la création du passage: ${response.statusMessage}');
return false;
}
} catch (e) {
debugPrint('Erreur lors de la création du passage: $e');
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
// Mettre à jour un passage existant
Future<bool> updatePassage(PassageModel passage) async {
_isLoading = true;
notifyListeners();
try {
// Préparer les données pour l'API
final Map<String, dynamic> data = passage.toJson();
// Appeler l'API pour mettre à jour le passage
final response =
await _apiService.put('/passages/${passage.id}', data: data);
if (response.statusCode == 200) {
// Mettre à jour le modèle local
final updatedPassage = passage.copyWith(
lastSyncedAt: DateTime.now(),
isSynced: true,
);
await savePassage(updatedPassage);
return true;
} else {
debugPrint(
'Erreur lors de la mise à jour du passage: ${response.statusMessage}');
// Marquer comme non synchronisé mais sauvegarder localement
final updatedPassage = passage.copyWith(
lastSyncedAt: DateTime.now(),
isSynced: false,
);
await savePassage(updatedPassage);
return false;
}
} catch (e) {
debugPrint('Erreur lors de la mise à jour du passage: $e');
// Marquer comme non synchronisé mais sauvegarder localement
final updatedPassage = passage.copyWith(
lastSyncedAt: DateTime.now(),
isSynced: false,
);
await savePassage(updatedPassage);
return false;
} finally {
_isLoading = false;
notifyListeners();
}
}
// Synchroniser tous les passages non synchronisés
Future<void> syncUnsyncedPassages() async {
try {
final hasConnection = await _apiService.hasInternetConnection();
if (!hasConnection) {
return;
}
final unsyncedPassages =
_passageBox.values.where((passage) => !passage.isSynced).toList();
if (unsyncedPassages.isEmpty) {
return;
}
_isLoading = true;
notifyListeners();
for (final passage in unsyncedPassages) {
try {
if (passage.id < 0) {
// Nouveau passage créé localement, à envoyer à l'API
await createPassage(
fkOperation: passage.fkOperation,
fkSector: passage.fkSector,
fkUser: passage.fkUser,
fkType: passage.fkType,
fkAdresse: passage.fkAdresse,
passedAt: passage.passedAt,
numero: passage.numero,
rue: passage.rue,
rueBis: passage.rueBis,
ville: passage.ville,
residence: passage.residence,
fkHabitat: passage.fkHabitat,
appt: passage.appt,
niveau: passage.niveau,
gpsLat: passage.gpsLat,
gpsLng: passage.gpsLng,
nomRecu: passage.nomRecu,
remarque: passage.remarque,
montant: passage.montant,
fkTypeReglement: passage.fkTypeReglement,
name: passage.name,
email: passage.email,
phone: passage.phone,
);
// Supprimer l'ancien passage avec ID temporaire
await deletePassage(passage.id);
} else {
// Passage existant à mettre à jour
await updatePassage(passage);
}
} catch (e) {
debugPrint(
'Erreur lors de la synchronisation du passage ${passage.id}: $e');
}
}
} catch (e) {
debugPrint('Erreur lors de la synchronisation des passages: $e');
} finally {
_isLoading = false;
notifyListeners();
}
}
// Récupérer les passages depuis l'API
Future<void> fetchPassages() async {
try {
final hasConnection = await _apiService.hasInternetConnection();
if (!hasConnection) {
return;
}
_isLoading = true;
notifyListeners();
final response = await _apiService.get('/passages');
if (response.statusCode == 200) {
final List<dynamic> passagesData = response.data;
await processPassagesFromApi(passagesData);
}
} catch (e) {
debugPrint('Erreur lors de la récupération des passages: $e');
} finally {
_isLoading = false;
notifyListeners();
}
}
// Vider tous les passages
Future<void> clearAllPassages() async {
await _passageBox.clear();
notifyListeners();
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/foundation.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/data/models/region_model.dart';
import 'package:hive_flutter/hive_flutter.dart';
class RegionRepository extends ChangeNotifier {
late Box<RegionModel> _regionBox;
List<RegionModel> _regions = [];
bool _isLoaded = false;
// Getter pour les régions
List<RegionModel> get regions => _regions;
bool get isLoaded => _isLoaded;
// Initialisation du repository
Future<void> init() async {
if (!Hive.isBoxOpen(AppKeys.regionsBoxName)) {
_regionBox = await Hive.openBox<RegionModel>(AppKeys.regionsBoxName);
} else {
_regionBox = Hive.box<RegionModel>(AppKeys.regionsBoxName);
}
_loadRegions();
}
// Chargement des régions depuis la boîte Hive
void _loadRegions() {
_regions = _regionBox.values.toList();
_isLoaded = true;
notifyListeners();
}
// Mise à jour des régions depuis l'API
Future<void> updateRegionsFromApi(List<dynamic> regionsData) async {
await _regionBox.clear();
for (var regionData in regionsData) {
final region = RegionModel.fromJson(regionData);
await _regionBox.put(region.id, region);
}
_loadRegions();
}
// Récupérer une région par son ID
RegionModel? getRegionById(int id) {
return _regionBox.get(id);
}
// Récupérer une région par son code postal (2 premiers chiffres)
RegionModel? getRegionByPostalCode(String postalCode) {
if (postalCode.length < 2) return null;
final departement = postalCode.substring(0, 2);
for (var region in _regions) {
if (region.departements != null &&
region.departements!.split(',').contains(departement)) {
return region;
}
}
return null;
}
// Récupérer toutes les régions actives
List<RegionModel> getActiveRegions() {
return _regions.where((region) => region.chkActive).toList();
}
// Convertir les régions en format pour le dropdown
List<Map<String, dynamic>> getRegionsForDropdown() {
return _regions
.where((region) => region.chkActive)
.map((region) => {
'id': region.id,
'name': region.libelle,
})
.toList();
}
// Fermeture de la boîte Hive
Future<void> close() async {
await _regionBox.close();
}
}

View File

@@ -0,0 +1,149 @@
import 'package:hive/hive.dart';
import 'package:geosector_app/core/data/models/sector_model.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
class SectorRepository {
final ApiService _apiService;
SectorRepository(this._apiService);
// Utiliser un getter lazy pour n'accéder à la boîte que lorsque nécessaire
// et vérifier qu'elle est ouverte avant accès
Box<SectorModel> get _sectorsBox {
_ensureBoxIsOpen();
return Hive.box<SectorModel>(AppKeys.sectorsBoxName);
}
// Méthode pour vérifier si la boîte est ouverte et l'ouvrir si nécessaire
Future<void> _ensureBoxIsOpen() async {
final boxName = AppKeys.sectorsBoxName;
if (!Hive.isBoxOpen(boxName)) {
print('Ouverture de la boîte $boxName dans SectorRepository...');
await Hive.openBox<SectorModel>(boxName);
}
}
// Récupérer tous les secteurs depuis la base de données locale
List<SectorModel> getAllSectors() {
return _sectorsBox.values.toList();
}
// Récupérer un secteur par son ID
SectorModel? getSectorById(int id) {
try {
return _sectorsBox.values.firstWhere(
(sector) => sector.id == id,
);
} catch (e) {
return null;
}
}
// Sauvegarder les secteurs dans la base de données locale
Future<void> saveSectors(List<SectorModel> sectors) async {
// Vider la box avant d'ajouter les nouveaux secteurs
await _sectorsBox.clear();
// Ajouter les nouveaux secteurs
for (final sector in sectors) {
await _sectorsBox.put(sector.id, sector);
}
}
// Ajouter ou mettre à jour un secteur
Future<void> saveSector(SectorModel sector) async {
await _sectorsBox.put(sector.id, sector);
}
// Supprimer un secteur
Future<void> deleteSector(int id) async {
await _sectorsBox.delete(id);
}
// Récupérer les secteurs depuis l'API
Future<List<SectorModel>> fetchSectorsFromApi() async {
try {
final response = await _apiService.get(AppKeys.sectorsEndpoint);
final Map<String, dynamic> responseData = response as Map<String, dynamic>;
if (responseData['status'] == 'success' && responseData['sectors'] != null) {
final List<dynamic> sectorsJson = responseData['sectors'];
final List<SectorModel> sectors = sectorsJson
.map((json) => SectorModel.fromJson(json))
.toList();
// Sauvegarder les secteurs localement
await saveSectors(sectors);
return sectors;
}
return [];
} catch (e) {
// En cas d'erreur, retourner les secteurs locaux
return getAllSectors();
}
}
// Créer un nouveau secteur via l'API
Future<SectorModel?> createSector(SectorModel sector) async {
try {
final response = await _apiService.post(
AppKeys.sectorsEndpoint,
data: sector.toJson(),
);
final Map<String, dynamic> responseData = response as Map<String, dynamic>;
if (responseData['status'] == 'success' && responseData['sector'] != null) {
final SectorModel newSector = SectorModel.fromJson(responseData['sector']);
await saveSector(newSector);
return newSector;
}
return null;
} catch (e) {
return null;
}
}
// Mettre à jour un secteur via l'API
Future<SectorModel?> updateSector(SectorModel sector) async {
try {
final response = await _apiService.put(
'${AppKeys.sectorsEndpoint}/${sector.id}',
data: sector.toJson(),
);
final Map<String, dynamic> responseData = response as Map<String, dynamic>;
if (responseData['status'] == 'success' && responseData['sector'] != null) {
final SectorModel updatedSector = SectorModel.fromJson(responseData['sector']);
await saveSector(updatedSector);
return updatedSector;
}
return null;
} catch (e) {
return null;
}
}
// Supprimer un secteur via l'API
Future<bool> deleteSectorFromApi(int id) async {
try {
final response = await _apiService.delete(
'${AppKeys.sectorsEndpoint}/$id',
);
final Map<String, dynamic> responseData = response as Map<String, dynamic>;
if (responseData['status'] == 'success') {
await deleteSector(id);
return true;
}
return false;
} catch (e) {
return false;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,269 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/foundation.dart';
import 'package:geosector_app/core/data/models/user_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:retry/retry.dart';
import 'package:universal_html/html.dart' as html;
class ApiService {
final Dio _dio = Dio();
late final String _baseUrl;
late final String _appIdentifier;
String? _sessionId;
// Détermine l'environnement actuel (DEV, REC, PROD) en fonction de l'URL
String _determineEnvironment() {
if (!kIsWeb) {
// En mode non-web, utiliser l'environnement de développement par défaut
return 'DEV';
}
final currentUrl = html.window.location.href.toLowerCase();
if (currentUrl.contains('dapp.geosector.fr')) {
return 'DEV';
} else if (currentUrl.contains('rapp.geosector.fr')) {
return 'REC';
} else {
return 'PROD';
}
}
// Configure l'URL de base API et l'identifiant d'application selon l'environnement
void _configureEnvironment() {
final env = _determineEnvironment();
switch (env) {
case 'DEV':
_baseUrl = AppKeys.baseApiUrlDev;
_appIdentifier = AppKeys.appIdentifierDev;
break;
case 'REC':
_baseUrl = AppKeys.baseApiUrlRec;
_appIdentifier = AppKeys.appIdentifierRec;
break;
default: // PROD
_baseUrl = AppKeys.baseApiUrlProd;
_appIdentifier = AppKeys.appIdentifierProd;
}
debugPrint('GEOSECTOR 🔗 Environnement: $env, API: $_baseUrl');
}
ApiService() {
// Configurer l'environnement
_configureEnvironment();
// Configurer Dio
_dio.options.baseUrl = _baseUrl;
_dio.options.connectTimeout = AppKeys.connectionTimeout;
_dio.options.receiveTimeout = AppKeys.receiveTimeout;
// Ajouter les en-têtes par défaut avec l'identifiant d'application adapté à l'environnement
final headers = Map<String, String>.from(AppKeys.defaultHeaders);
headers['X-App-Identifier'] = _appIdentifier;
_dio.options.headers.addAll(headers);
// Ajouter des intercepteurs pour l'authentification par session
_dio.interceptors.add(InterceptorsWrapper(onRequest: (options, handler) {
// Ajouter le session_id comme token Bearer aux en-têtes si disponible
if (_sessionId != null) {
options.headers[AppKeys.sessionHeader] = 'Bearer $_sessionId';
}
return handler.next(options);
}, onError: (DioException error, handler) {
// Gérer les erreurs d'authentification (401)
if (error.response?.statusCode == 401) {
// Session expirée ou invalide
_sessionId = null;
}
return handler.next(error);
}));
}
// Définir l'ID de session
void setSessionId(String? sessionId) {
_sessionId = sessionId;
}
// Obtenir l'environnement actuel (utile pour le débogage)
String getCurrentEnvironment() {
return _determineEnvironment();
}
// Obtenir l'URL API actuelle (utile pour le débogage)
String getCurrentApiUrl() {
return _baseUrl;
}
// Obtenir l'identifiant d'application actuel (utile pour le débogage)
String getCurrentAppIdentifier() {
return _appIdentifier;
}
// Vérifier la connectivité réseau
Future<bool> hasInternetConnection() async {
final connectivityResult = await (Connectivity().checkConnectivity());
return connectivityResult != ConnectivityResult.none;
}
// Méthode POST générique
Future<Response> post(String path, {dynamic data}) async {
try {
return await _dio.post(path, data: data);
} catch (e) {
rethrow;
}
}
// Méthode GET générique
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
try {
return await _dio.get(path, queryParameters: queryParameters);
} catch (e) {
rethrow;
}
}
// Méthode PUT générique
Future<Response> put(String path, {dynamic data}) async {
try {
return await _dio.put(path, data: data);
} catch (e) {
rethrow;
}
}
// Méthode DELETE générique
Future<Response> delete(String path) async {
try {
return await _dio.delete(path);
} catch (e) {
rethrow;
}
}
// Authentification avec PHP session
Future<Map<String, dynamic>> login(String username, String password, {required String type}) async {
try {
final response = await _dio.post(AppKeys.loginEndpoint, data: {
'username': username,
'password': password,
'type': type, // Ajouter le type de connexion (user ou admin)
});
// Vérifier la structure de la réponse
final data = response.data as Map<String, dynamic>;
final status = data['status'] as String?;
// Afficher le message en cas d'erreur
if (status != 'success') {
final message = data['message'] as String?;
debugPrint('Erreur d\'authentification: $message');
}
// Si le statut est 'success', récupérer le session_id
if (status == 'success' && data.containsKey('session_id')) {
final sessionId = data['session_id'];
// Définir la session pour les futures requêtes
if (sessionId != null) {
setSessionId(sessionId);
}
}
return data;
} catch (e) {
rethrow;
}
}
// Déconnexion
Future<void> logout() async {
try {
if (_sessionId != null) {
await _dio.post(AppKeys.logoutEndpoint);
_sessionId = null;
}
} catch (e) {
// Même en cas d'erreur, on réinitialise la session
_sessionId = null;
rethrow;
}
}
// Utilisateurs
Future<List<UserModel>> getUsers() async {
try {
final response = await retry(
() => _dio.get('/users'),
retryIf: (e) => e is SocketException || e is TimeoutException,
);
return (response.data as List)
.map((json) => UserModel.fromJson(json))
.toList();
} catch (e) {
// Gérer les erreurs
rethrow;
}
}
Future<UserModel> getUserById(int id) async {
try {
final response = await _dio.get('/users/$id');
return UserModel.fromJson(response.data);
} catch (e) {
rethrow;
}
}
Future<UserModel> createUser(UserModel user) async {
try {
final response = await _dio.post('/users', data: user.toJson());
return UserModel.fromJson(response.data);
} catch (e) {
rethrow;
}
}
Future<UserModel> updateUser(UserModel user) async {
try {
final response = await _dio.put('/users/${user.id}', data: user.toJson());
return UserModel.fromJson(response.data);
} catch (e) {
rethrow;
}
}
Future<void> deleteUser(String id) async {
try {
await _dio.delete('/users/$id');
} catch (e) {
rethrow;
}
}
// Espace réservé pour les futures méthodes de gestion des profils
// Espace réservé pour les futures méthodes de gestion des données
// Synchronisation en batch
Future<Map<String, dynamic>> syncData({
List<UserModel>? users,
}) async {
try {
final Map<String, dynamic> payload = {
if (users != null) 'users': users.map((u) => u.toJson()).toList(),
};
final response = await _dio.post('/sync', data: payload);
return response.data;
} catch (e) {
rethrow;
}
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
import 'package:geosector_app/presentation/widgets/loading_overlay.dart';
/// Service qui gère les opérations d'authentification avec affichage d'un overlay de chargement
class AuthService {
final UserRepository _userRepository;
AuthService(this._userRepository);
/// Méthode de connexion avec affichage d'un overlay de chargement
Future<bool> login(BuildContext context, String username, String password,
{required String type}) async {
return await LoadingOverlay.show(
context: context,
spinnerSize: 80.0, // Spinner plus grand
strokeWidth: 6.0, // Trait plus épais
future: _userRepository.login(username, password, type: type),
);
}
/// Méthode de déconnexion avec affichage d'un overlay de chargement
/// et redirection vers la page de démarrage
Future<bool> logout(BuildContext context) async {
final bool result = await LoadingOverlay.show(
context: context,
spinnerSize: 80.0, // Spinner plus grand
strokeWidth: 6.0, // Trait plus épais
future: _userRepository.logout(),
);
// Si la déconnexion a réussi, rediriger vers la page de démarrage
if (result && context.mounted) {
// Utiliser GoRouter pour naviguer vers la page de démarrage
GoRouter.of(context).go('/');
}
return result;
}
/// Vérifie si un utilisateur est connecté
bool isLoggedIn() {
return _userRepository.isLoggedIn;
}
/// Récupère le rôle de l'utilisateur connecté
int getUserRole() {
return _userRepository.getUserRole();
}
}

View File

@@ -0,0 +1,157 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
/// Service qui gère la surveillance de l'état de connectivité de l'appareil
class ConnectivityService extends ChangeNotifier {
final Connectivity _connectivity = Connectivity();
late StreamSubscription<List<ConnectivityResult>> _connectivitySubscription;
List<ConnectivityResult> _connectionStatus = [ConnectivityResult.none];
bool _isInitialized = false;
/// Indique si l'appareil est connecté à Internet
bool get isConnected {
// Vérifie si la liste contient au moins un type de connexion autre que 'none'
return _connectionStatus.any((result) => result != ConnectivityResult.none);
}
/// Indique si l'appareil est connecté via WiFi
bool get isWifi => _connectionStatus.contains(ConnectivityResult.wifi);
/// Indique si l'appareil est connecté via données mobiles (4G, 5G, etc.)
bool get isMobile => _connectionStatus.contains(ConnectivityResult.mobile);
/// Retourne le type de connexion actuel (WiFi, données mobiles, etc.)
List<ConnectivityResult> get connectionStatus => _connectionStatus;
/// Retourne le premier type de connexion actif (pour compatibilité avec l'ancien code)
ConnectivityResult get primaryConnectionStatus {
// Retourne le premier type de connexion qui n'est pas 'none', ou 'none' si tous sont 'none'
return _connectionStatus.firstWhere(
(result) => result != ConnectivityResult.none,
orElse: () => ConnectivityResult.none
);
}
/// Obtient une description textuelle du type de connexion
String get connectionType {
// Si aucune connexion n'est disponible
if (!isConnected) {
return 'Aucune connexion';
}
// Utiliser le premier type de connexion actif
ConnectivityResult primaryStatus = primaryConnectionStatus;
switch (primaryStatus) {
case ConnectivityResult.wifi:
return 'WiFi';
case ConnectivityResult.mobile:
return 'Données mobiles';
case ConnectivityResult.ethernet:
return 'Ethernet';
case ConnectivityResult.bluetooth:
return 'Bluetooth';
case ConnectivityResult.vpn:
return 'VPN';
case ConnectivityResult.none:
return 'Aucune connexion';
default:
return 'Inconnu';
}
}
/// Constructeur du service de connectivité
ConnectivityService() {
_initConnectivity();
}
/// Initialise le service et commence à écouter les changements de connectivité
Future<void> _initConnectivity() async {
if (_isInitialized) return;
try {
// En version web, on considère par défaut que la connexion est disponible
// car la vérification de connectivité est moins fiable sur le web
if (kIsWeb) {
_connectionStatus = [ConnectivityResult.wifi]; // Valeur par défaut pour le web
} else {
_connectionStatus = await _connectivity.checkConnectivity();
}
// S'abonner aux changements de connectivité
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
_isInitialized = true;
} catch (e) {
debugPrint('Erreur lors de l\'initialisation du service de connectivité: $e');
// En cas d'erreur en version web, on suppose que la connexion est disponible
// car l'application web ne peut pas fonctionner sans connexion de toute façon
if (kIsWeb) {
_connectionStatus = [ConnectivityResult.wifi];
_isInitialized = true;
}
}
notifyListeners();
}
/// Met à jour l'état de la connexion lorsqu'il change
void _updateConnectionStatus(List<ConnectivityResult> results) {
// Vérifier si la liste des résultats a changé
bool hasChanged = false;
// Si les listes ont des longueurs différentes, elles sont différentes
if (_connectionStatus.length != results.length) {
hasChanged = true;
} else {
// Vérifier si les éléments sont différents
for (int i = 0; i < _connectionStatus.length; i++) {
if (i >= results.length || _connectionStatus[i] != results[i]) {
hasChanged = true;
break;
}
}
}
if (hasChanged) {
_connectionStatus = results;
notifyListeners();
}
}
/// Vérifie manuellement l'état actuel de la connexion
Future<List<ConnectivityResult>> checkConnectivity() async {
try {
// En version web, on considère par défaut que la connexion est disponible
if (kIsWeb) {
// En version web, on peut tenter de faire une requête réseau légère pour vérifier la connectivité
// mais pour l'instant, on suppose que la connexion est disponible
final results = [ConnectivityResult.wifi];
_updateConnectionStatus(results);
return results;
} else {
// Version mobile - utiliser l'API standard
final results = await _connectivity.checkConnectivity();
_updateConnectionStatus(results);
return results;
}
} catch (e) {
debugPrint('Erreur lors de la vérification de la connectivité: $e');
// En cas d'erreur, on conserve l'état actuel
return _connectionStatus;
}
}
@override
void dispose() {
try {
_connectivitySubscription.cancel();
} catch (e) {
debugPrint('Erreur lors de l\'annulation de l\'abonnement de connectivité: $e');
}
super.dispose();
}
}

View File

@@ -0,0 +1,149 @@
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
// Importations conditionnelles pour le web vs non-web
import 'js_interface.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/hive_web_fix.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/core/data/models/region_model.dart';
import 'package:geosector_app/chat/models/chat_adapters.dart';
/// Service pour réinitialiser et recréer les Hive Boxes
/// Utilisé pour résoudre les problèmes d'incompatibilité après mise à jour des modèles
class HiveResetService {
/// Réinitialise complètement Hive et recrée les boîtes nécessaires
static Future<bool> resetAndRecreateHiveBoxes() async {
try {
debugPrint(
'HiveResetService: Début de la réinitialisation complète de Hive');
// Approche plus radicale pour le web : supprimer directement IndexedDB
if (kIsWeb) {
// Utiliser JavaScript pour supprimer complètement la base de données IndexedDB
evalJs('''
(function() {
return new Promise(function(resolve, reject) {
// Fermer toutes les connexions IndexedDB
if (window.indexedDB) {
console.log("Suppression complète d'IndexedDB...");
var request = indexedDB.deleteDatabase("geosector_app");
request.onsuccess = function() {
console.log("IndexedDB supprimé avec succès");
resolve(true);
};
request.onerror = function(event) {
console.log("Erreur lors de la suppression d'IndexedDB", event);
reject(event);
};
} else {
console.log("IndexedDB n'est pas disponible");
resolve(false);
}
});
})();
''');
// Attendre un peu pour s'assurer que la suppression est terminée
await Future.delayed(const Duration(milliseconds: 1000));
// Réinitialiser Hive
await Hive.initFlutter();
} else {
// Pour les plateformes mobiles, on utilise une approche différente
await Hive.deleteFromDisk();
await Hive.initFlutter();
}
// Réenregistrer tous les adaptateurs
_registerAdapters();
// Rouvrir les boîtes essentielles
await _reopenEssentialBoxes();
debugPrint(
'HiveResetService: Réinitialisation complète terminée avec succès');
return true;
} catch (e) {
debugPrint('HiveResetService: Erreur lors de la réinitialisation: $e');
return false;
}
}
/// Ferme toutes les boîtes Hive ouvertes
static Future<void> _closeAllBoxes() async {
final boxNames = [
AppKeys.usersBoxName,
AppKeys.amicaleBoxName,
AppKeys.clientsBoxName,
AppKeys.operationsBoxName,
AppKeys.sectorsBoxName,
AppKeys.passagesBoxName,
AppKeys.settingsBoxName,
AppKeys.membresBoxName,
AppKeys.userSectorBoxName,
AppKeys.chatConversationsBoxName,
AppKeys.chatMessagesBoxName,
AppKeys.regionsBoxName,
];
for (final boxName in boxNames) {
if (Hive.isBoxOpen(boxName)) {
debugPrint('HiveResetService: Fermeture de la boîte $boxName');
await Hive.box(boxName).close();
}
}
}
/// Enregistre tous les adaptateurs Hive
static void _registerAdapters() {
debugPrint('HiveResetService: Enregistrement des adaptateurs Hive');
// Enregistrer les adaptateurs pour les modèles principaux
Hive.registerAdapter(UserModelAdapter());
Hive.registerAdapter(AmicaleModelAdapter());
Hive.registerAdapter(ClientModelAdapter());
Hive.registerAdapter(OperationModelAdapter());
Hive.registerAdapter(SectorModelAdapter());
Hive.registerAdapter(PassageModelAdapter());
Hive.registerAdapter(MembreModelAdapter());
Hive.registerAdapter(UserSectorModelAdapter());
// Enregistrer les adaptateurs pour le chat
Hive.registerAdapter(ConversationModelAdapter());
Hive.registerAdapter(MessageModelAdapter());
Hive.registerAdapter(ParticipantModelAdapter());
Hive.registerAdapter(AnonymousUserModelAdapter());
Hive.registerAdapter(AudienceTargetModelAdapter());
Hive.registerAdapter(NotificationSettingsAdapter());
// Vérifier si RegionModelAdapter est disponible
try {
Hive.registerAdapter(RegionModelAdapter());
} catch (e) {
debugPrint('HiveResetService: RegionModelAdapter non disponible: $e');
}
}
/// Rouvre les boîtes essentielles
static Future<void> _reopenEssentialBoxes() async {
debugPrint('HiveResetService: Réouverture des boîtes essentielles');
// Ouvrir les boîtes essentielles au démarrage
await Hive.openBox<UserModel>(AppKeys.usersBoxName);
await Hive.openBox<AmicaleModel>(AppKeys.amicaleBoxName);
await Hive.openBox<ClientModel>(AppKeys.clientsBoxName);
await Hive.openBox(AppKeys.settingsBoxName);
// Ouvrir les boîtes de chat
await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter/foundation.dart';
/// Service pour gérer l'état de réinitialisation de Hive
/// Permet de stocker l'information indiquant si Hive a été réinitialisé
/// et de notifier les widgets intéressés
class HiveResetStateService extends ChangeNotifier {
/// Indique si Hive a été réinitialisé
bool _wasReset = false;
/// Indique si le dialogue de réinitialisation a déjà été affiché
bool _dialogShown = false;
/// Getter pour savoir si Hive a été réinitialisé
bool get wasReset => _wasReset;
/// Getter pour savoir si le dialogue a déjà été affiché
bool get dialogShown => _dialogShown;
/// Marque Hive comme ayant été réinitialisé
void markAsReset() {
_wasReset = true;
notifyListeners();
}
/// Marque le dialogue comme ayant été affiché
void markDialogAsShown() {
_dialogShown = true;
notifyListeners();
}
/// Réinitialise l'état (à utiliser après une déconnexion par exemple)
void reset() {
_wasReset = false;
_dialogShown = false;
notifyListeners();
}
}
/// Instance globale du service
final hiveResetStateService = HiveResetStateService();

View File

@@ -0,0 +1,182 @@
import 'dart:async';
import 'dart:js' as js;
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
/// Service pour gérer les problèmes spécifiques à Hive en version web
class HiveWebFix {
/// Nettoie en toute sécurité les boîtes Hive en version web
/// Cette méthode est plus sûre que de supprimer directement IndexedDB
static Future<void> safeCleanHiveBoxes({List<String>? excludeBoxes}) async {
if (!kIsWeb) return;
try {
debugPrint(
'HiveWebFix: Nettoyage sécurisé des boîtes Hive en version web');
// Liste des boîtes à nettoyer
final boxesToClean = [
AppKeys.operationsBoxName,
AppKeys.sectorsBoxName,
AppKeys.passagesBoxName,
];
// Exclure certaines boîtes si spécifié
if (excludeBoxes != null) {
boxesToClean.removeWhere((box) => excludeBoxes.contains(box));
}
// Nettoyer chaque boîte individuellement au lieu de supprimer IndexedDB
for (final boxName in boxesToClean) {
try {
if (Hive.isBoxOpen(boxName)) {
debugPrint('HiveWebFix: Nettoyage de la boîte $boxName');
final box = Hive.box(boxName);
await box.clear();
debugPrint('HiveWebFix: Boîte $boxName nettoyée avec succès');
} else {
debugPrint(
'HiveWebFix: La boîte $boxName n\'est pas ouverte, ouverture temporaire');
final box = await Hive.openBox(boxName);
await box.clear();
await box.close();
debugPrint('HiveWebFix: Boîte $boxName nettoyée et fermée');
}
} catch (e) {
debugPrint(
'HiveWebFix: Erreur lors du nettoyage de la boîte $boxName: $e');
}
}
debugPrint('HiveWebFix: Nettoyage sécurisé terminé');
} catch (e) {
debugPrint('HiveWebFix: Erreur lors du nettoyage sécurisé: $e');
}
}
/// Vérifie l'intégrité des boîtes Hive et tente de les réparer si nécessaire
static Future<bool> checkAndRepairHiveBoxes() async {
if (!kIsWeb) return true;
try {
debugPrint('HiveWebFix: Vérification de l\'intégrité des boîtes Hive');
// Vérifier si IndexedDB est accessible
final isIndexedDBAvailable = js.context.hasProperty('indexedDB');
if (!isIndexedDBAvailable) {
debugPrint(
'HiveWebFix: IndexedDB n\'est pas disponible dans ce navigateur');
return false;
}
// Liste des boîtes essentielles
final essentialBoxes = [
AppKeys.usersBoxName,
AppKeys.settingsBoxName,
];
// Vérifier chaque boîte essentielle
for (final boxName in essentialBoxes) {
try {
if (!Hive.isBoxOpen(boxName)) {
debugPrint(
'HiveWebFix: Ouverture de la boîte essentielle $boxName');
await Hive.openBox(boxName);
}
// Vérifier si la boîte est accessible
final box = Hive.box(boxName);
// Tenter une opération simple pour vérifier l'intégrité
final length = box.length;
debugPrint(
'HiveWebFix: Boîte $boxName accessible avec $length éléments');
} catch (e) {
debugPrint('HiveWebFix: Erreur d\'accès à la boîte $boxName: $e');
// Tenter de réparer en réinitialisant Hive
try {
debugPrint(
'HiveWebFix: Tentative de réparation de la boîte $boxName');
// Fermer la boîte si elle est ouverte
if (Hive.isBoxOpen(boxName)) {
await Hive.box(boxName).close();
}
// Réouvrir la boîte
await Hive.openBox(boxName);
debugPrint('HiveWebFix: Boîte $boxName réparée avec succès');
} catch (repairError) {
debugPrint(
'HiveWebFix: Échec de la réparation de la boîte $boxName: $repairError');
return false;
}
}
}
debugPrint('HiveWebFix: Toutes les boîtes essentielles sont intègres');
return true;
} catch (e) {
debugPrint('HiveWebFix: Erreur lors de la vérification d\'intégrité: $e');
return false;
}
}
/// Réinitialise complètement Hive en cas de problème grave
/// À utiliser en dernier recours car cela supprimera toutes les données
static Future<void> resetHiveCompletely() async {
if (!kIsWeb) return;
try {
debugPrint('HiveWebFix: Réinitialisation complète de Hive');
// Fermer toutes les boîtes ouvertes
final boxesToClose = [
AppKeys.usersBoxName,
AppKeys.operationsBoxName,
AppKeys.sectorsBoxName,
AppKeys.passagesBoxName,
AppKeys.settingsBoxName,
];
for (final boxName in boxesToClose) {
if (Hive.isBoxOpen(boxName)) {
debugPrint('HiveWebFix: Fermeture de la boîte $boxName');
await Hive.box(boxName).close();
}
}
// Supprimer IndexedDB avec une approche plus sûre
js.context.callMethod('eval', [
'''
(function() {
return new Promise(function(resolve, reject) {
var request = indexedDB.deleteDatabase("geosector_app");
request.onsuccess = function() {
console.log("IndexedDB nettoyé avec succès");
resolve(true);
};
request.onerror = function(event) {
console.log("Erreur lors du nettoyage d'IndexedDB", event);
reject(event);
};
});
})();
'''
]);
// Attendre un peu pour s'assurer que la suppression est terminée
await Future.delayed(const Duration(milliseconds: 500));
// Réinitialiser Hive
await Hive.initFlutter();
// Réenregistrer les adaptateurs
// Note: Cette partie devrait être gérée par le code principal de l'application
debugPrint('HiveWebFix: Réinitialisation complète terminée');
} catch (e) {
debugPrint('HiveWebFix: Erreur lors de la réinitialisation complète: $e');
}
}
}

View File

@@ -0,0 +1,20 @@
/// Interface pour les fonctionnalités JavaScript
/// Importe conditionnellement dart:js pour le web ou un stub pour les autres plateformes
library js_interface;
import 'package:flutter/foundation.dart';
// Importation conditionnelle basée sur la plateforme
import 'js_stub.dart' if (dart.library.js) 'dart:js' as js;
/// Exporte le contexte JavaScript pour être utilisé dans d'autres fichiers
final context = js.context;
/// Fonction utilitaire pour évaluer du code JavaScript sur le web
/// Ne fait rien sur les plateformes non-web
dynamic evalJs(String code) {
if (kIsWeb) {
return js.context.callMethod('eval', [code]);
}
return null;
}

View File

@@ -0,0 +1,11 @@
/// Stub pour dart:js pour les plateformes non-web
/// Fournit une implémentation vide des fonctionnalités de dart:js
class JsContext {
dynamic callMethod(String method, [List<dynamic>? args]) {
// Ne fait rien sur les plateformes non-web
return null;
}
}
/// Contexte JavaScript stub
final JsContext context = JsContext();

View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
/// Service de géolocalisation pour gérer les permissions et l'accès à la position
class LocationService {
/// Vérifie si les services de localisation sont activés
static Future<bool> isLocationServiceEnabled() async {
// En version web, on considère que les services de localisation sont toujours activés
// car la vérification est gérée différemment par le navigateur
if (kIsWeb) {
return true;
}
return await Geolocator.isLocationServiceEnabled();
}
/// Vérifie et demande les permissions de localisation
/// Retourne true si l'autorisation est accordée, false sinon
static Future<bool> checkAndRequestPermission() async {
// En version web, on considère que les permissions sont toujours accordées
// car la gestion des permissions est différente et gérée par le navigateur
if (kIsWeb) {
return true;
}
try {
// Vérifier si les services de localisation sont activés
bool serviceEnabled = await isLocationServiceEnabled();
if (!serviceEnabled) {
// Les services de localisation ne sont pas activés, on ne peut pas demander la permission
return false;
}
// Vérifier le statut actuel de la permission
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
// Demander la permission
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied) {
// La permission a été refusée
return false;
}
}
if (permission == LocationPermission.deniedForever) {
// La permission a été refusée définitivement
return false;
}
// La permission est accordée (whileInUse ou always)
return true;
} catch (e) {
debugPrint('Erreur lors de la vérification des permissions de localisation: $e');
// En cas d'erreur, on retourne false pour être sûr
return false;
}
}
/// Obtient la position actuelle de l'utilisateur
/// Retourne null si la position ne peut pas être obtenue
static Future<LatLng?> getCurrentPosition() async {
try {
// En version web, la géolocalisation fonctionne différemment
// et peut être bloquée par le navigateur si le site n'est pas en HTTPS
if (kIsWeb) {
try {
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
return LatLng(position.latitude, position.longitude);
} catch (e) {
debugPrint('Erreur lors de l\'obtention de la position en version web: $e');
// En version web, en cas d'erreur, on peut retourner une position par défaut
// ou null selon les besoins de l'application
return null;
}
}
// Version mobile
// Vérifier si l'autorisation est accordée
bool hasPermission = await checkAndRequestPermission();
if (!hasPermission) {
return null;
}
// Obtenir la position actuelle
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
);
return LatLng(position.latitude, position.longitude);
} catch (e) {
debugPrint('Erreur lors de l\'obtention de la position: $e');
return null;
}
}
/// Vérifie si l'application peut accéder à la position de l'utilisateur
/// Retourne un message d'erreur si l'accès n'est pas possible, null sinon
static Future<String?> getLocationErrorMessage() async {
// En version web, on considère qu'il n'y a pas d'erreur de localisation
// car la gestion des permissions est gérée par le navigateur
if (kIsWeb) {
return null;
}
try {
// Vérifier si les services de localisation sont activés
bool serviceEnabled = await isLocationServiceEnabled();
if (!serviceEnabled) {
return 'Les services de localisation sont désactivés. Veuillez les activer dans les paramètres de votre appareil.';
}
// Vérifier le statut actuel de la permission
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
return 'L\'accès à la localisation a été refusé. Cette application ne peut pas fonctionner sans cette autorisation.';
}
if (permission == LocationPermission.deniedForever) {
return 'L\'accès à la localisation a été définitivement refusé. Veuillez l\'autoriser dans les paramètres de votre appareil.';
}
return null; // Pas d'erreur
} catch (e) {
debugPrint('Erreur lors de la vérification des erreurs de localisation: $e');
// En cas d'erreur, on retourne null pour ne pas bloquer l'application
return null;
}
}
/// Ouvre les paramètres de l'application pour permettre à l'utilisateur de modifier les autorisations
static Future<void> openAppSettings() async {
// En version web, cette fonctionnalité n'est pas disponible
if (kIsWeb) {
debugPrint('Ouverture des paramètres de l\'application non disponible en version web');
return;
}
try {
await Geolocator.openAppSettings();
} catch (e) {
debugPrint('Erreur lors de l\'ouverture des paramètres de l\'application: $e');
}
}
/// Ouvre les paramètres de localisation de l'appareil
static Future<void> openLocationSettings() async {
// En version web, cette fonctionnalité n'est pas disponible
if (kIsWeb) {
debugPrint('Ouverture des paramètres de localisation non disponible en version web');
return;
}
try {
await Geolocator.openLocationSettings();
} catch (e) {
debugPrint('Erreur lors de l\'ouverture des paramètres de localisation: $e');
}
}
}

View File

@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.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';
/// Service pour charger et filtrer les données de passages
class PassageDataService {
final PassageRepository passageRepository;
final UserRepository userRepository;
PassageDataService({
required this.passageRepository,
required this.userRepository,
});
/// Charge les données de passage depuis Hive
///
/// [daysToShow] : Nombre de jours à afficher
/// [excludePassageTypes] : Types de passages à exclure
/// [userId] : ID de l'utilisateur pour filtrer les passages (null = utilisateur actuel)
/// [showAllPassages] : Si vrai, n'applique aucun filtrage par utilisateur
List<Map<String, dynamic>> loadPassageData({
required int daysToShow,
List<int> excludePassageTypes = const [2],
int? userId,
bool showAllPassages = false,
}) {
// Récupérer tous les passages
final passages = passageRepository.getAllPassages();
// Filtrer les passages pour exclure ceux avec fkType dans la liste d'exclusion
final filteredPassages = passages
.where((p) => !excludePassageTypes.contains(p.fkType))
.toList();
if (filteredPassages.isEmpty) {
return [];
}
// Déterminer si on filtre par utilisateur ou si on prend tous les passages
final passagesToUse = showAllPassages
? filteredPassages
: _filterPassagesByUser(filteredPassages, userId);
if (passagesToUse.isEmpty) {
debugPrint('Aucun passage trouvé après filtrage');
return [];
}
// Trouver la date du passage le plus récent
passagesToUse.sort((a, b) => b.passedAt.compareTo(a.passedAt));
final DateTime referenceDate = passagesToUse.first.passedAt;
debugPrint(
'Date de référence pour le graphique: ${DateFormat('dd/MM/yyyy').format(referenceDate)}');
// Définir la date de début (N jours avant la date de référence)
final startDate = referenceDate.subtract(Duration(days: daysToShow - 1));
debugPrint(
'Date de début pour le graphique: ${DateFormat('dd/MM/yyyy').format(startDate)}');
debugPrint(
'Plage de dates du graphique: ${DateFormat('dd/MM/yyyy').format(startDate)} - ${DateFormat('dd/MM/yyyy').format(referenceDate)}');
// Regrouper les passages par date et type
final Map<String, Map<int, int>> passagesByDateAndType = {};
// Initialiser le dictionnaire avec les N derniers jours
for (int i = daysToShow - 1; i >= 0; i--) {
final date = referenceDate.subtract(Duration(days: i));
final dateStr =
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
passagesByDateAndType[dateStr] = {};
}
// Ajouter tous les types de passage possibles pour chaque date
for (final dateStr in passagesByDateAndType.keys) {
for (final typeId in AppKeys.typesPassages.keys) {
// Exclure les types dans la liste d'exclusion
if (!excludePassageTypes.contains(typeId)) {
passagesByDateAndType[dateStr]![typeId] = 0;
}
}
}
// Parcourir les passages et les regrouper par date et type
for (final passage in passagesToUse) {
if (passage.passedAt
.isAfter(startDate.subtract(const Duration(days: 1))) &&
passage.passedAt
.isBefore(referenceDate.add(const Duration(days: 1)))) {
final dateStr =
'${passage.passedAt.year}-${passage.passedAt.month.toString().padLeft(2, '0')}-${passage.passedAt.day.toString().padLeft(2, '0')}';
final typeId = passage.fkType;
// Vérifier que le type n'est pas exclu
if (!excludePassageTypes.contains(typeId)) {
// Si la date existe dans notre dictionnaire, mettre à jour le compteur
if (passagesByDateAndType.containsKey(dateStr)) {
if (!passagesByDateAndType[dateStr]!.containsKey(typeId)) {
passagesByDateAndType[dateStr]![typeId] = 0;
}
passagesByDateAndType[dateStr]![typeId] =
(passagesByDateAndType[dateStr]![typeId] ?? 0) + 1;
}
}
}
}
// Convertir les données au format attendu par le graphique
final List<Map<String, dynamic>> result = [];
passagesByDateAndType.forEach((dateStr, typesCounts) {
typesCounts.forEach((typeId, count) {
result.add({
'date': dateStr,
'type_passage': typeId,
'nb': count,
});
});
});
return result;
}
/// Filtre les passages par utilisateur
List<dynamic> _filterPassagesByUser(List<dynamic> passages, int? userId) {
// Récupérer l'ID de l'utilisateur actuel si nécessaire
final int? currentUserId = userId ?? userRepository.getCurrentUser()?.id;
// Filtrer les passages pour l'utilisateur actuel
final userPassages = passages
.where((p) => currentUserId == null || p.fkUser == currentUserId)
.toList();
if (userPassages.isEmpty) {
debugPrint('Aucun passage trouvé pour l\'utilisateur $currentUserId');
}
return userPassages;
}
/// Charge et prépare les données pour le graphique en camembert
///
/// [excludePassageTypes] : Types de passages à exclure
/// [userId] : ID de l'utilisateur pour filtrer les passages (null = utilisateur actuel)
/// [showAllPassages] : Si vrai, n'applique aucun filtrage par utilisateur
Map<int, int> loadPassageDataForPieChart({
List<int> excludePassageTypes = const [2],
int? userId,
bool showAllPassages = false,
}) {
// Récupérer tous les passages
final passages = passageRepository.getAllPassages();
// Filtrer les passages pour exclure ceux avec fkType dans la liste d'exclusion
final filteredPassages = passages
.where((p) => !excludePassageTypes.contains(p.fkType))
.toList();
if (filteredPassages.isEmpty) {
return {};
}
// Déterminer si on filtre par utilisateur ou si on prend tous les passages
final passagesToUse = showAllPassages
? filteredPassages
: _filterPassagesByUser(filteredPassages, userId);
if (passagesToUse.isEmpty) {
debugPrint('Aucun passage trouvé après filtrage');
return {};
}
// Compter les passages par type
final Map<int, int> passagesByType = {};
// Initialiser les compteurs pour tous les types de passage
for (final typeId in AppKeys.typesPassages.keys) {
// Exclure les types dans la liste d'exclusion
if (!excludePassageTypes.contains(typeId)) {
passagesByType[typeId] = 0;
}
}
// Compter les passages par type
for (final passage in passagesToUse) {
final typeId = passage.fkType;
if (!excludePassageTypes.contains(typeId)) {
passagesByType[typeId] = (passagesByType[typeId] ?? 0) + 1;
}
}
return passagesByType;
}
}

View File

@@ -0,0 +1,96 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:geosector_app/core/repositories/user_repository.dart';
class SyncService {
final UserRepository _userRepository;
StreamSubscription? _connectivitySubscription;
Timer? _periodicSyncTimer;
bool _isSyncing = false;
final Duration _syncInterval = const Duration(minutes: 15);
SyncService({
required UserRepository userRepository,
}) : _userRepository = userRepository {
_initConnectivityListener();
_initPeriodicSync();
}
// Initialiser l'écouteur de connectivité
void _initConnectivityListener() {
_connectivitySubscription = Connectivity()
.onConnectivityChanged
.listen((List<ConnectivityResult> results) {
// Vérifier si au moins un type de connexion est disponible
if (results.any((result) => result != ConnectivityResult.none)) {
// Lorsque la connexion est rétablie, déclencher une synchronisation
syncAll();
}
});
}
// Initialiser la synchronisation périodique
void _initPeriodicSync() {
_periodicSyncTimer = Timer.periodic(_syncInterval, (timer) {
syncAll();
});
}
// Synchroniser toutes les données
Future<void> syncAll() async {
if (_isSyncing) return;
_isSyncing = true;
try {
// Synchroniser les utilisateurs
await _userRepository.syncAllUsers();
} catch (e) {
// Gérer les erreurs de synchronisation
print('Erreur lors de la synchronisation: $e');
} finally {
_isSyncing = false;
}
}
// Synchroniser uniquement les données d'un utilisateur spécifique
Future<void> syncUserData(int userId) async {
try {
// Cette méthode pourrait être étendue à l'avenir pour synchroniser d'autres données utilisateur
await _userRepository.refreshFromServer();
} catch (e) {
print('Erreur lors de la synchronisation des données utilisateur: $e');
}
}
// Forcer le rafraîchissement depuis le serveur
Future<void> forceRefresh() async {
if (_isSyncing) return;
_isSyncing = true;
try {
// Rafraîchir depuis le serveur
await _userRepository.refreshFromServer();
} catch (e) {
print('Erreur lors du rafraîchissement forcé: $e');
} finally {
_isSyncing = false;
}
}
// Obtenir l'état de synchronisation
Map<String, dynamic> getSyncStatus() {
return {
'isSyncing': _isSyncing,
};
}
// Nettoyer les ressources
void dispose() {
_connectivitySubscription?.cancel();
_periodicSyncTimer?.cancel();
}
}

View File

@@ -0,0 +1,190 @@
import 'package:flutter/material.dart';
class AppTheme {
// Couleurs du thème basées sur la maquette Figma
static const Color primaryColor = Color(0xFF20335E); // Bleu foncé
static const Color secondaryColor = Color(0xFF9DC7C8); // Bleu clair
static const Color accentColor = Color(0xFF00E09D); // Vert
static const Color errorColor = Color(0xFFE41B13); // Rouge
static const Color warningColor = Color(0xFFF7A278); // Orange
static const Color backgroundLightColor =
Color(0xFFF4F5F6); // Gris très clair
static const Color backgroundDarkColor = Color(0xFF111827);
static const Color textLightColor = Color(0xFF000000); // Noir
static const Color textDarkColor = Color(0xFFF9FAFB);
// Thème clair
static ThemeData get lightTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
fontFamily: 'Figtree', // Utilisation directe de la police locale
colorScheme: ColorScheme.light(
primary: primaryColor,
secondary: secondaryColor,
tertiary: accentColor,
background: backgroundLightColor,
surface: Colors.white,
onPrimary: Colors.white,
onSecondary: Colors.white,
onBackground: textLightColor,
onSurface: textLightColor,
),
textTheme: const TextTheme().copyWith(
displayLarge: const TextStyle(fontFamily: 'Figtree'),
displayMedium: const TextStyle(fontFamily: 'Figtree'),
displaySmall: const TextStyle(fontFamily: 'Figtree'),
headlineLarge: const TextStyle(fontFamily: 'Figtree'),
headlineMedium: const TextStyle(fontFamily: 'Figtree'),
headlineSmall: const TextStyle(fontFamily: 'Figtree'),
titleLarge: const TextStyle(fontFamily: 'Figtree'),
titleMedium: const TextStyle(fontFamily: 'Figtree'),
titleSmall: const TextStyle(fontFamily: 'Figtree'),
bodyLarge: const TextStyle(fontFamily: 'Figtree'),
bodyMedium: const TextStyle(fontFamily: 'Figtree'),
bodySmall: const TextStyle(fontFamily: 'Figtree'),
labelLarge: const TextStyle(fontFamily: 'Figtree'),
labelMedium: const TextStyle(fontFamily: 'Figtree'),
labelSmall: const TextStyle(fontFamily: 'Figtree'),
),
appBarTheme: const AppBarTheme(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
elevation: 0,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
textStyle: const TextStyle(
fontFamily: 'Figtree',
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: backgroundLightColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: textLightColor.withOpacity(0.1),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: textLightColor.withOpacity(0.1),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
cardTheme: CardTheme(
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
);
}
// Thème sombre
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
fontFamily: 'Figtree', // Utilisation directe de la police locale
colorScheme: ColorScheme.dark(
primary: primaryColor,
secondary: secondaryColor,
tertiary: accentColor,
background: backgroundDarkColor,
surface: const Color(0xFF1F2937),
onPrimary: Colors.white,
onSecondary: Colors.white,
onBackground: textDarkColor,
onSurface: textDarkColor,
),
textTheme: const TextTheme().copyWith(
displayLarge: const TextStyle(fontFamily: 'Figtree'),
displayMedium: const TextStyle(fontFamily: 'Figtree'),
displaySmall: const TextStyle(fontFamily: 'Figtree'),
headlineLarge: const TextStyle(fontFamily: 'Figtree'),
headlineMedium: const TextStyle(fontFamily: 'Figtree'),
headlineSmall: const TextStyle(fontFamily: 'Figtree'),
titleLarge: const TextStyle(fontFamily: 'Figtree'),
titleMedium: const TextStyle(fontFamily: 'Figtree'),
titleSmall: const TextStyle(fontFamily: 'Figtree'),
bodyLarge: const TextStyle(fontFamily: 'Figtree'),
bodyMedium: const TextStyle(fontFamily: 'Figtree'),
bodySmall: const TextStyle(fontFamily: 'Figtree'),
labelLarge: const TextStyle(fontFamily: 'Figtree'),
labelMedium: const TextStyle(fontFamily: 'Figtree'),
labelSmall: const TextStyle(fontFamily: 'Figtree'),
),
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xFF1F2937),
foregroundColor: Colors.white,
elevation: 0,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
textStyle: const TextStyle(
fontFamily: 'Figtree',
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF374151),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: textDarkColor.withOpacity(0.1),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: textDarkColor.withOpacity(0.1),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: primaryColor, width: 2),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
cardTheme: CardTheme(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
color: const Color(0xFF1F2937),
),
);
}
}

100
app/lib/main.dart Normal file
View File

@@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:geosector_app/app.dart';
import 'package:hive_flutter/hive_flutter.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/core/data/models/region_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/services/hive_reset_service.dart';
import 'package:geosector_app/core/services/hive_reset_state_service.dart';
// Import centralisé pour les modèles chat
import 'package:geosector_app/chat/models/chat_adapters.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Configurer le routage par chemin (URLs sans #)
setUrlStrategy(PathUrlStrategy());
// Initialiser Hive avec gestion des erreurs
bool hiveInitialized = false;
try {
// Initialiser Hive
await Hive.initFlutter();
// Enregistrer les adaptateurs Hive pour les modèles principaux
Hive.registerAdapter(UserModelAdapter());
Hive.registerAdapter(AmicaleModelAdapter());
Hive.registerAdapter(ClientModelAdapter());
Hive.registerAdapter(OperationModelAdapter());
Hive.registerAdapter(SectorModelAdapter());
Hive.registerAdapter(PassageModelAdapter());
Hive.registerAdapter(MembreModelAdapter());
Hive.registerAdapter(UserSectorModelAdapter());
// TODO: Décommenter après avoir généré le fichier region_model.g.dart
// Hive.registerAdapter(RegionModelAdapter());
// Enregistrer les adaptateurs Hive pour le chat
Hive.registerAdapter(ConversationModelAdapter());
Hive.registerAdapter(MessageModelAdapter());
Hive.registerAdapter(ParticipantModelAdapter());
Hive.registerAdapter(AnonymousUserModelAdapter());
Hive.registerAdapter(AudienceTargetModelAdapter());
Hive.registerAdapter(NotificationSettingsAdapter());
// Ouvrir uniquement les boîtes essentielles au démarrage
try {
// La boîte des utilisateurs est nécessaire pour vérifier si un utilisateur est déjà connecté
await Hive.openBox<UserModel>(AppKeys.usersBoxName);
// Boîte pour les amicales
await Hive.openBox<AmicaleModel>(AppKeys.amicaleBoxName);
// Boîte pour les clients
await Hive.openBox<ClientModel>(AppKeys.clientsBoxName);
// Boîte pour les préférences utilisateur générales
await Hive.openBox(AppKeys.settingsBoxName);
// Ouvrir les boîtes de chat également au démarrage pour le cache local
await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
hiveInitialized = true;
} catch (e) {
debugPrint('Erreur lors de l\'ouverture des boîtes Hive: $e');
// Une erreur s'est produite lors de l'ouverture des boîtes, probablement due à une incompatibilité
// Nous allons réinitialiser Hive
hiveInitialized = false;
}
} catch (e) {
debugPrint('Erreur lors de l\'initialisation de Hive: $e');
hiveInitialized = false;
}
// Si Hive n'a pas été initialisé correctement, marquer l'état pour afficher le dialogue
if (!hiveInitialized) {
debugPrint(
'Incompatibilité détectée dans les données Hive. Marquage pour affichage du dialogue...');
// Marquer Hive comme ayant été réinitialisé pour afficher le dialogue plus tard
hiveResetStateService.markAsReset();
}
// Les autres boîtes (operations, sectors, passages, user_sector) seront ouvertes après connexion
// dans UserRepository.login() via la méthode _ensureBoxIsOpen()
// Définir l'orientation de l'application
await SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
// Lancer l'application directement sans AppProviders
runApp(const GeoSectorApp());
}

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

Some files were not shown because too many files have changed in this diff Show More