feat: Release version 3.1.4 - Mode terrain et génération PDF

 Nouvelles fonctionnalités:
- Ajout du mode terrain pour utilisation mobile hors connexion
- Génération automatique de reçus PDF avec template personnalisé
- Révision complète du système de cartes avec amélioration des performances

🔧 Améliorations techniques:
- Refactoring du module chat avec architecture simplifiée
- Optimisation du système de sécurité NIST SP 800-63B
- Amélioration de la gestion des secteurs géographiques
- Support UTF-8 étendu pour les noms d'utilisateurs

📱 Application mobile:
- Nouveau mode terrain dans user_field_mode_page
- Interface utilisateur adaptative pour conditions difficiles
- Synchronisation offline améliorée

🗺️ Cartographie:
- Optimisation des performances MapBox
- Meilleure gestion des tuiles hors ligne
- Amélioration de l'affichage des secteurs

📄 Documentation:
- Ajout guide Android (ANDROID-GUIDE.md)
- Documentation sécurité API (API-SECURITY.md)
- Guide module chat (CHAT_MODULE.md)

🐛 Corrections:
- Résolution des erreurs 400 lors de la création d'utilisateurs
- Correction de la validation des noms d'utilisateurs
- Fix des problèmes de synchronisation chat

🤖 Generated with Claude Code (https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-19 19:38:03 +02:00
parent c1f23c4345
commit 5ab03751e1
1823 changed files with 272663 additions and 198438 deletions

View File

@@ -1,82 +1,198 @@
# Module Chat GEOSECTOR
# Module Chat Autonome pour GEOSECTOR
## Structure du module
## Description
Module de chat **totalement autonome et portable** pour l'application GEOSECTOR.
- **100% indépendant** : Peut être réutilisé dans n'importe quelle application Flutter
- **API REST uniquement** : Plus de dépendance MQTT
- **Architecture simple** : Code maintenable et facilement compréhensible
- **Cache local Hive** : Fonctionnement offline avec synchronisation automatique
- **Gestion des rôles** : Permissions configurables via YAML
- **Autocomplete intelligent** : Sélection des destinataires selon les permissions
Le module chat est organisé selon une architecture modulaire respectant la séparation des préoccupations :
## Architecture simplifiée
### Structure minimale
```
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
├── models/ # 2 modèles simples
│ ├── room.dart
── message.dart
├── services/ # Services de gestion
── chat_service.dart # Service principal
│ └── chat_config_loader.dart # Chargeur de configuration
├── widgets/ # Composants réutilisables
│ └── recipient_selector.dart # Sélecteur de destinataires
├── pages/ # 2 pages essentielles
── rooms_page.dart
│ └── chat_page.dart
├── chat.dart # Point d'entrée avec exports
└── README.md # Documentation du module
├── chat_config.yaml # Configuration des permissions
└── chat_module.dart # Point d'entrée
```
## Fonctionnalités principales
## Intégration dans votre application
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';
### 1. Installation
```yaml
# pubspec.yaml
dependencies:
dio: ^5.4.0
hive: ^2.2.3
hive_flutter: ^1.1.0
yaml: ^3.1.2
```
### Affichage de la page chat
### 2. Initialisation
```dart
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const ChatPage()),
await ChatModule.init(
apiUrl: 'https://api.geosector.fr',
userId: currentUser.id,
userName: currentUser.name,
userRole: currentUser.fkRole, // 1, 2 ou 9
userEntite: currentUser.entiteId, // ID de l'amicale
authToken: authToken, // Optionnel
);
```
### Création d'une conversation
### 3. Utilisation
```dart
final chatRepository = ChatRepository();
final conversation = await chatRepository.createConversation({
'type': 'one_to_one',
'participants': [userId1, userId2],
});
// Ouvrir le chat
ChatModule.openChat(context);
// Ou obtenir directement une page
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatModule.getRoomsPage(),
),
);
```
## États d'implémentation
## Gestion des permissions par rôle
- [x] Structure de base
- [ ] Modèles de données complets
- [ ] Intégration avec Hive
- [ ] Services API
- [ ] Gestion hors ligne
- [ ] Widgets visuels
- [ ] Tests unitaires
### Configuration (chat_config.yaml)
Les permissions sont définies dans un fichier YAML facilement modifiable :
## À faire
#### Rôle 1 (Membre)
- Peut discuter avec les membres de son amicale (rôle 1)
- Peut discuter avec l'admin de son amicale (rôle 2)
- Restriction : même entité uniquement
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
#### Rôle 2 (Admin Amicale)
- Peut discuter avec tous les membres de son amicale
- Peut discuter avec les superadmins (rôle 9)
- Peut créer des groupes de discussion
#### Rôle 9 (Super Admin)
- Peut envoyer des messages à tous les admins d'amicale
- Peut sélectionner des destinataires spécifiques
- Peut faire du broadcast à tous les admins
## API Backend requise
### Endpoints REST
- `GET /api/chat/rooms` - Liste des conversations filtrées par rôle
- `POST /api/chat/rooms` - Créer une conversation avec vérification des permissions
- `GET /api/chat/rooms/{id}/messages` - Messages d'une conversation
- `POST /api/chat/rooms/{id}/messages` - Envoyer un message
- `POST /api/chat/rooms/{id}/read` - Marquer comme lu
- `GET /api/chat/recipients` - Liste des destinataires possibles selon le rôle
### Structure base de données
```sql
-- Tables préfixées "chat_"
CREATE TABLE chat_rooms (
id VARCHAR(36) PRIMARY KEY,
title VARCHAR(255),
type ENUM('private', 'group', 'broadcast'),
created_at TIMESTAMP,
created_by INT
);
CREATE TABLE chat_messages (
id VARCHAR(36) PRIMARY KEY,
room_id VARCHAR(36),
content TEXT,
sender_id INT,
sent_at TIMESTAMP,
FOREIGN KEY (room_id) REFERENCES chat_rooms(id)
);
CREATE TABLE chat_participants (
room_id VARCHAR(36),
user_id INT,
role INT,
entite_id INT,
joined_at TIMESTAMP,
PRIMARY KEY (room_id, user_id)
);
CREATE TABLE chat_read_receipts (
message_id VARCHAR(36),
user_id INT,
read_at TIMESTAMP,
PRIMARY KEY (message_id, user_id)
);
```
## Stockage local Hive
### TypeIds réservés
- TypeId 50: Room
- TypeId 51: Message
- TypeIds 52-60: Réservés pour extensions futures
### Boxes Hive
- `chat_rooms`: Conversations en cache
- `chat_messages`: Messages en cache
## Interface utilisateur
### Design minimaliste et professionnel
- Palette de couleurs sobre (blanc, gris, bleu accent)
- Pas d'animations superflues
- Focus sur la lisibilité et l'efficacité
- Interface responsive pour toutes les plateformes
### Composants principaux
1. **Liste des conversations** : Vue simple avec badges non-lus
2. **Page de chat** : Messages avec bulles, input simple
3. **Sélecteur de destinataires** : Autocomplete avec filtrage par rôle
## Planning de développement (2 sessions)
### Session 1 : Architecture de base ✅
- [x] Nettoyage et suppression du MQTT
- [x] Création des modèles simples (Room, Message)
- [x] Service unique ChatService
- [x] Pages minimales (RoomsPage, ChatPage)
- [x] Module d'entrée ChatModule
- [x] Gestion des rôles et permissions via YAML
- [x] Widget autocomplete pour sélection des destinataires
- [x] Adaptation de l'UI selon les permissions
### Session 2 : Finalisation
- [ ] Tests d'intégration avec l'API
- [ ] Documentation API complète
- [ ] Optimisations performances
- [ ] Gestion erreurs robuste
## Points d'attention
### Sécurité
- Authentification via token JWT
- Vérification des permissions côté serveur
- Validation des inputs
### Performance
- Cache Hive pour mode offline
- Pagination des messages
- Lazy loading des conversations
### Maintenance
- Code simple et documenté
- Architecture modulaire
- Configuration externalisée (YAML)
## Support
Pour toute question ou problème, consulter la documentation principale de l'application GEOSECTOR.

170
app/lib/chat/TODO_CHAT.md Normal file
View File

@@ -0,0 +1,170 @@
# 📋 TODO List - Module Chat v1.0.0
## 🎯 Objectif
Aligner le module chat avec l'API backend et optimiser l'expérience utilisateur.
## 📊 État d'avancement global : 8/13 (62%)
---
## 🔴 PRIORITÉ HAUTE - Fonctionnalités essentielles
### 1. ✅ Pagination des messages
- **Description** : Implémenter la pagination avec limit=50 et paramètre `before`
- **Endpoint** : `GET /api/chat/rooms/{id}/messages?limit=50&before={message_id}`
- **Fichiers** : `chat_service.dart`, `chat_page.dart`
- **Statut** : ✅ COMPLÉTÉ
### 2. ✅ Chargement progressif
- **Description** : Ajouter bouton "Charger plus" en haut de la conversation
- **UI** : Bouton ou indicateur en haut de la liste des messages
- **Fichiers** : `chat_page.dart`
- **Statut** : ✅ COMPLÉTÉ
### 3. ✅ Gestion du flag has_more
- **Description** : Gérer la réponse API pour savoir s'il reste des messages
- **Logique** : Désactiver "Charger plus" si `has_more: false`
- **Fichiers** : `message.dart`, `chat_service.dart`
- **Statut** : ✅ COMPLÉTÉ
---
## 🟠 PRIORITÉ MOYENNE - Amélioration UX
### 4. ✅ Message initial à la création
- **Description** : Ajouter `initial_message` lors de la création d'une room
- **Endpoint** : `POST /api/chat/rooms` avec body `{..., "initial_message": "..."}`
- **Fichiers** : `chat_service.dart`, `recipient_selector.dart`
- **Statut** : ✅ COMPLÉTÉ
### 5. ⬜ Extraction infos chat du login
- **Description** : Récupérer `total_rooms`, `unread_messages` depuis la réponse login
- **Intégration** : Stocker dans Hive ou service global
- **Fichiers** : `user_repository.dart`, `chat_service.dart`
- **Temps estimé** : 20 min
### 6. ⬜ Badge messages non lus
- **Description** : Afficher un badge rouge avec le nombre sur l'icône chat
- **UI** : Badge sur NavigationDestination et dans la sidebar
- **Fichiers** : `responsive_navigation.dart`, `admin_dashboard_page.dart`
- **Temps estimé** : 15 min
### 7. ✅ Pull-to-refresh RoomsPage
- **Description** : Ajouter RefreshIndicator pour rafraîchir la liste des rooms
- **Geste** : Tirer vers le bas pour recharger
- **Fichiers** : `rooms_page.dart`
- **Statut** : ✅ COMPLÉTÉ (déjà implémenté)
### 8. ✅ Pull-to-refresh ChatPage
- **Description** : Ajouter RefreshIndicator pour rafraîchir les messages
- **Geste** : Tirer vers le bas pour recharger les derniers messages
- **Fichiers** : `chat_page.dart`
- **Statut** : ✅ COMPLÉTÉ
---
## 🟡 PRIORITÉ BASSE - Optimisations
### 9. ✅ Optimiser cache Hive
- **Description** : Limiter à 100 messages max par room dans le cache
- **Logique** : Supprimer les plus anciens si > 100
- **Fichiers** : `chat_service.dart`
- **Statut** : ✅ COMPLÉTÉ
### 10. ⬜ Optimiser le polling
- **Description** : Polling actif seulement si la page chat est visible
- **Optimisation** : Pause/reprise selon WidgetsBindingObserver
- **Fichiers** : `chat_service.dart`, `rooms_page.dart`
- **Temps estimé** : 15 min
### 11. ✅ Indicateurs de chargement
- **Description** : Ajouter spinners lors du chargement de la pagination
- **UI** : CircularProgressIndicator pendant les requêtes
- **Fichiers** : `chat_page.dart`
- **Statut** : ✅ COMPLÉTÉ
### 12. ⬜ Auto-refresh au retour
- **Description** : Rafraîchir quand l'app revient au premier plan
- **Implémentation** : WidgetsBindingObserver avec AppLifecycleState
- **Fichiers** : `chat_module.dart`, `rooms_page.dart`
- **Temps estimé** : 10 min
---
## 📚 DOCUMENTATION
### 13. ⬜ Mettre à jour README.md
- **Description** : Documenter toutes les nouvelles fonctionnalités
- **Sections** : Pagination, pull-to-refresh, badges, optimisations
- **Fichiers** : `README.md`
- **Temps estimé** : 15 min
---
## ⏱️ Temps total restant : ~1h
## 🚀 Ordre de développement recommandé
### Phase 1 : Core ✅ COMPLÉTÉ
1. Pagination des messages ✅
2. Chargement progressif ✅
3. Gestion has_more ✅
### Phase 2 : Intégration (En cours)
4. Message initial ✅
5. Infos chat login ⬜
6. Badge non lus ⬜
7. Pull-to-refresh rooms ✅
8. Pull-to-refresh chat ✅
### Phase 3 : Polish (Partiellement complété)
9. Optimiser cache ✅
10. Optimiser polling ⬜
11. Indicateurs chargement ✅
12. Auto-refresh ⬜
### Phase 4 : Documentation
13. README.md ⬜
---
## 📝 Notes
- **Version actuelle** : 1.0.0
- **Date de création** : 2025-01-17
- **Dernière mise à jour** : 2025-01-17
- **Développeur** : Module Chat Team
## 🔗 Références
- [API Documentation](../../../docs/API_CHAT.md)
- [Architecture Flutter](../README.md)
- [Permissions YAML](../chat_config.yaml)
## ✨ Améliorations implémentées
### Pagination
- Limite de 50 messages par requête
- Paramètre `before` pour charger les messages plus anciens
- Gestion du flag `has_more` pour indiquer s'il reste des messages
- Bouton "Charger plus" en haut de la conversation
### Optimisations cache
- Limitation à 100 messages maximum par room dans Hive
- Suppression automatique des messages les plus anciens
- Méthode `_saveMessagesToCache` dédiée
### Message initial
- Champ de texte ajouté dans le dialog de sélection des destinataires
- Paramètre `initial_message` dans les méthodes de création de room
- Support dans l'API pour envoyer le message initial
### Pull-to-refresh
- RefreshIndicator dans RoomsPage (déjà existant)
- RefreshIndicator dans ChatPage pour rafraîchir les messages
- Rechargement complet des messages récents
### Indicateurs de chargement
- Spinner pendant le chargement de la pagination
- État `_isLoadingMore` pour gérer l'affichage
- CircularProgressIndicator de taille réduite

View File

@@ -1,36 +0,0 @@
/// Exportation principale du module chat
///
/// Ce fichier centralise les exportations du module chat
/// pour faciliter l'importation dans d'autres parties de l'application
library;
// 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,84 @@
# Configuration du module Chat
# Regles de permissions par role
# Version du module
module_info:
version: "1.0.0"
name: "Chat Module Light"
description: "Module de chat autonome et portable pour GEOSECTOR"
chat_permissions:
# Role 1: Membre standard
role_1:
name: "Membre"
description: "Membre de l'amicale"
can_message_with:
- role: 1
condition: "same_entite" # Meme amicale seulement
description: "Collegues membres"
- role: 2
condition: "same_entite" # Admin de sa propre amicale
description: "Administrateur de votre amicale"
can_create_group: false
can_broadcast: false
help_text: "Vous pouvez discuter avec les membres de votre amicale"
# Role 2: Administrateur d'amicale
role_2:
name: "Admin Amicale"
description: "Administrateur d'une amicale"
can_message_with:
- role: 1
condition: "same_entite" # Membres de son amicale
description: "Membres de votre amicale"
- role: 2
condition: "same_entite" # Autres admins de son amicale
description: "Co-administrateurs"
- role: 9
condition: "all" # Tous les superadmins
description: "Super administrateurs"
can_create_group: true
can_broadcast: false
help_text: "Vous pouvez discuter avec les membres de votre amicale et les super admins"
# Role 9: Super administrateur
role_9:
name: "Super Admin"
description: "Administrateur systeme"
can_message_with:
- role: 2
condition: "all" # Tous les admins d'amicale
description: "Administrateurs d'amicale"
allow_selection: true # Permet selection multiple
allow_broadcast: true # Permet envoi groupe
can_create_group: true
can_broadcast: true
help_text: "Vous pouvez envoyer des messages a tous les administrateurs d'amicale ou selectionner des destinataires specifiques"
# Configuration de l'interface
ui_config:
show_role_badge: true
show_entite_info: true
enable_autocomplete: true
min_search_length: 2
# Messages par defaut
messages:
no_permission: "Vous n'avez pas la permission de creer cette conversation"
no_recipients: "Aucun destinataire disponible"
search_placeholder: "Rechercher un destinataire..."
new_conversation: "Nouvelle conversation"
select_recipients: "Selectionner les destinataires"
# Couleurs par role (optionnel)
role_colors:
1: "#64748B" # Gris pour membre
2: "#2563EB" # Bleu pour admin
9: "#DC2626" # Rouge pour superadmin
# Configuration API
api_config:
recipients_endpoint: "/chat/recipients"
create_room_endpoint: "/chat/rooms"
require_entite_for_role_1: true
require_entite_for_role_2: true

59
app/lib/chat/chat_module.dart Executable file
View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'services/chat_service.dart';
import 'pages/rooms_page.dart';
import 'pages/chat_page.dart';
/// Module de chat autonome avec gestion des rôles
///
/// Les permissions sont gérées via le fichier chat_config.yaml
class ChatModule {
/// Initialiser le module chat avec support des rôles
///
/// @param apiUrl URL de base de l'API
/// @param userId ID de l'utilisateur connecté
/// @param userName Nom de l'utilisateur
/// @param userRole Rôle de l'utilisateur (1: membre, 2: admin amicale, 9: superadmin)
/// @param userEntite ID de l'entité/amicale de l'utilisateur (optionnel)
/// @param authToken Token JWT d'authentification (optionnel)
static Future<void> init({
required String apiUrl,
required int userId,
required String userName,
required int userRole,
int? userEntite,
String? authToken,
}) async {
await ChatService.init(
apiUrl: apiUrl,
userId: userId,
userName: userName,
userRole: userRole,
userEntite: userEntite,
authToken: authToken,
);
}
/// Obtenir la page de liste des conversations
static Widget getRoomsPage() => const RoomsPage();
/// Obtenir la page de chat pour une room spécifique
static Widget getChatPage(String roomId, String roomTitle) => ChatPage(
roomId: roomId,
roomTitle: roomTitle,
);
/// Naviguer vers le chat depuis n'importe où
static void openChat(BuildContext context) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => const RoomsPage(),
),
);
}
/// Nettoyer les ressources
static void dispose() {
ChatService.instance.dispose();
}
}

View File

@@ -1,510 +0,0 @@
# 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

@@ -1,51 +0,0 @@
/// Constantes spécifiques au module chat
library;
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

@@ -1,166 +0,0 @@
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 const MaterialApp(
home: MqttIntegrationExample(),
);
}
}

View File

@@ -0,0 +1,118 @@
/// Exemple d'utilisation du module chat
///
/// Ce fichier montre comment intégrer le module chat dans votre application
import 'package:flutter/material.dart';
import 'chat_module.dart';
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Chat Module Example',
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
bool _isInitialized = false;
@override
void initState() {
super.initState();
_initChat();
}
Future<void> _initChat() async {
// Initialiser le module chat avec vos paramètres et rôle
await ChatModule.init(
apiUrl: 'https://api.votre-domaine.com',
userId: 123, // ID de l'utilisateur connecté
userName: 'Jean Dupont', // Nom de l'utilisateur
userRole: 2, // Rôle: 1=membre, 2=admin amicale, 9=superadmin
userEntite: 5, // ID de l'amicale/entité (optionnel)
authToken: 'votre-token-jwt', // Token d'authentification (optionnel)
);
setState(() => _isInitialized = true);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Application Example'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Bienvenue dans votre application'),
const SizedBox(height: 20),
if (_isInitialized)
ElevatedButton(
onPressed: () {
// Ouvrir le chat
ChatModule.openChat(context);
},
child: const Text('Ouvrir le Chat'),
)
else
const CircularProgressIndicator(),
],
),
),
);
}
@override
void dispose() {
// Nettoyer les ressources du chat
ChatModule.dispose();
super.dispose();
}
}
/// Comment l'utiliser dans votre application :
///
/// 1. Copier le dossier lib/chat dans votre projet
///
/// 2. Ajouter les dépendances dans pubspec.yaml :
/// dependencies:
/// dio: ^5.4.0
/// hive: ^2.2.3
/// hive_flutter: ^1.1.0
/// dev_dependencies:
/// hive_generator: ^2.0.1
/// build_runner: ^2.4.8
///
/// 3. Initialiser le module au démarrage :
/// await ChatModule.init(
/// apiUrl: 'https://votre-api.com',
/// userId: currentUserId,
/// userName: currentUserName,
/// userRole: currentUserRole, // 1, 2 ou 9
/// userEntite: currentUserEntite, // ID amicale
/// authToken: authToken,
/// );
///
/// 4. Ouvrir le chat depuis n'importe où :
/// ChatModule.openChat(context);
///
/// 5. Ou naviguer directement vers une conversation :
/// Navigator.push(
/// context,
/// MaterialPageRoute(
/// builder: (_) => ChatModule.getChatPage(roomId, roomTitle),
/// ),
/// );

View File

@@ -1,104 +0,0 @@
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: 23)
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

@@ -1,138 +0,0 @@
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: 24)
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

@@ -1,15 +0,0 @@
// 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

@@ -1,104 +0,0 @@
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

@@ -1,139 +0,0 @@
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

@@ -1,68 +0,0 @@
// 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,63 @@
import 'package:hive/hive.dart';
part 'message.g.dart';
/// Modèle simple de message
@HiveType(typeId: 51)
class Message extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String roomId;
@HiveField(2)
final String content;
@HiveField(3)
final int senderId;
@HiveField(4)
final String senderName;
@HiveField(5)
final DateTime sentAt;
@HiveField(6)
final bool isMe;
@HiveField(7)
final bool isRead;
Message({
required this.id,
required this.roomId,
required this.content,
required this.senderId,
required this.senderName,
required this.sentAt,
this.isMe = false,
this.isRead = false,
});
// Simple factory depuis JSON
factory Message.fromJson(Map<String, dynamic> json, int currentUserId) {
return Message(
id: json['id'],
roomId: json['fk_room'],
content: json['content'] ?? '',
senderId: json['fk_user'] ?? 0,
senderName: json['sender_name'] ?? 'Anonyme',
sentAt: DateTime.parse(json['date_sent']),
isMe: json['fk_user'] == currentUserId,
isRead: json['statut'] == 'lu',
);
}
// Simple conversion en JSON pour envoi
Map<String, dynamic> toJson() => {
'fk_room': roomId,
'content': content,
'fk_user': senderId,
};
}

View File

@@ -1,50 +1,53 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'audience_target_model.dart';
part of 'message.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class AudienceTargetModelAdapter extends TypeAdapter<AudienceTargetModel> {
class MessageAdapter extends TypeAdapter<Message> {
@override
final int typeId = 24;
final int typeId = 51;
@override
AudienceTargetModel read(BinaryReader reader) {
Message read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return AudienceTargetModel(
return Message(
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?,
roomId: fields[1] as String,
content: fields[2] as String,
senderId: fields[3] as int,
senderName: fields[4] as String,
sentAt: fields[5] as DateTime,
isMe: fields[6] as bool,
isRead: fields[7] as bool,
);
}
@override
void write(BinaryWriter writer, AudienceTargetModel obj) {
void write(BinaryWriter writer, Message obj) {
writer
..writeByte(7)
..writeByte(8)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.conversationId)
..write(obj.roomId)
..writeByte(2)
..write(obj.targetType)
..write(obj.content)
..writeByte(3)
..write(obj.targetId)
..write(obj.senderId)
..writeByte(4)
..write(obj.createdAt)
..write(obj.senderName)
..writeByte(5)
..write(obj.roleFilter)
..write(obj.sentAt)
..writeByte(6)
..write(obj.entityFilter);
..write(obj.isMe)
..writeByte(7)
..write(obj.isRead);
}
@override
@@ -53,7 +56,7 @@ class AudienceTargetModelAdapter extends TypeAdapter<AudienceTargetModel> {
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is AudienceTargetModelAdapter &&
other is MessageAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -1,140 +0,0 @@
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

@@ -1,71 +0,0 @@
// 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

@@ -1,160 +0,0 @@
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

@@ -1,68 +0,0 @@
// 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

@@ -1,118 +0,0 @@
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

@@ -1,65 +0,0 @@
// 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,61 @@
import 'package:hive/hive.dart';
part 'room.g.dart';
/// Modèle simple de conversation/room
@HiveType(typeId: 50)
class Room extends HiveObject {
@HiveField(0)
final String id;
@HiveField(1)
final String title;
@HiveField(2)
final String type; // 'private' ou 'group'
@HiveField(3)
final DateTime createdAt;
@HiveField(4)
final String? lastMessage;
@HiveField(5)
final DateTime? lastMessageAt;
@HiveField(6)
final int unreadCount;
Room({
required this.id,
required this.title,
required this.type,
required this.createdAt,
this.lastMessage,
this.lastMessageAt,
this.unreadCount = 0,
});
// Simple factory depuis JSON
factory Room.fromJson(Map<String, dynamic> json) {
return Room(
id: json['id'],
title: json['title'] ?? 'Sans titre',
type: json['type'] ?? 'private',
createdAt: DateTime.parse(json['date_creation']),
lastMessage: json['last_message'],
lastMessageAt: json['last_message_at'] != null
? DateTime.parse(json['last_message_at'])
: null,
unreadCount: json['unread_count'] ?? 0,
);
}
// Simple conversion en JSON
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'type': type,
'date_creation': createdAt.toIso8601String(),
};
}

View File

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

View File

@@ -1,81 +1,359 @@
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
import 'package:hive_flutter/hive_flutter.dart';
import '../models/message.dart';
import '../services/chat_service.dart';
import '../services/chat_config_loader.dart';
/// Page simple de chat
class ChatPage extends StatefulWidget {
const ChatPage({super.key});
final String roomId;
final String roomTitle;
const ChatPage({
super.key,
required this.roomId,
required this.roomTitle,
});
@override
State<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
String? _selectedConversationId;
final _service = ChatService.instance;
final _messageController = TextEditingController();
final _scrollController = ScrollController();
bool _isLoading = true;
bool _isLoadingMore = false;
bool _hasMore = true;
List<Message> _messages = [];
String? _oldestMessageId;
@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) => const ChatScreen(
conversationId:
'conversation-id', // TODO: obtenir l'ID de la conversation
),
),
);
},
),
void initState() {
super.initState();
_loadInitialMessages();
_service.markAsRead(widget.roomId);
}
Future<void> _loadInitialMessages() async {
setState(() => _isLoading = true);
final result = await _service.getMessages(widget.roomId);
final messages = result['messages'] as List<Message>;
setState(() {
_messages = messages;
_hasMore = result['has_more'] as bool;
if (messages.isNotEmpty) {
_oldestMessageId = messages.first.id;
}
_isLoading = false;
});
// Attendre un peu avant de scroller pour laisser le temps au ListView de se construire
Future.delayed(const Duration(milliseconds: 100), _scrollToBottom);
}
Future<void> _loadMoreMessages() async {
if (_isLoadingMore || !_hasMore || _oldestMessageId == null) return;
setState(() => _isLoadingMore = true);
final result = await _service.getMessages(widget.roomId, beforeMessageId: _oldestMessageId);
final newMessages = result['messages'] as List<Message>;
setState(() {
// Insérer les messages plus anciens au début
_messages = [...newMessages, ..._messages];
_hasMore = result['has_more'] as bool;
if (newMessages.isNotEmpty) {
_oldestMessageId = newMessages.first.id;
}
_isLoadingMore = false;
});
}
void _scrollToBottom() {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
}
Future<void> _sendMessage() async {
final text = _messageController.text.trim();
if (text.isEmpty) return;
_messageController.clear();
await _service.sendMessage(widget.roomId, text);
_scrollToBottom();
}
@override
Widget build(BuildContext context) {
// Obtenir le rôle de l'utilisateur pour la colorisation
final userRole = _service.getUserRole();
// Déterminer la couleur du badge selon le rôle
Color badgeColor;
switch (userRole) {
case 1:
badgeColor = Colors.green; // Vert pour les membres
break;
case 2:
badgeColor = Colors.blue; // Bleu pour les admins amicale
break;
case 9:
badgeColor = Colors.red; // Rouge pour les super admins
break;
default:
badgeColor = Colors.grey; // Gris par défaut
}
// Obtenir la version du module
final moduleVersion = ChatConfigLoader.instance.getModuleVersion();
return Scaffold(
backgroundColor: const Color(0xFFFAFAFA),
appBar: AppBar(
title: Text(widget.roomTitle),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1E293B),
elevation: 0,
),
body: Stack(
children: [
Column(
children: [
// Messages
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: ValueListenableBuilder<Box<Message>>(
valueListenable: _service.messagesBox.listenable(),
builder: (context, box, _) {
// Mettre à jour la liste avec les nouveaux messages envoyés
final recentMessages = box.values
.where((m) => m.roomId == widget.roomId &&
!_messages.any((msg) => msg.id == m.id))
.toList();
// Combiner les messages chargés et les nouveaux
final allMessages = [..._messages, ...recentMessages]
..sort((a, b) => a.sentAt.compareTo(b.sentAt));
if (allMessages.isEmpty) {
return Center(
child: Text(
'Aucun message',
style: TextStyle(
color: Colors.grey[600],
fontSize: 16,
),
),
);
}
return Column(
children: [
// Bouton "Charger plus" en haut
if (_hasMore)
Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: _isLoadingMore
? const SizedBox(
height: 40,
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
)
: TextButton.icon(
onPressed: _loadMoreMessages,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('Charger plus de messages'),
style: TextButton.styleFrom(
foregroundColor: const Color(0xFF2563EB),
),
),
),
// Liste des messages avec pull-to-refresh
Expanded(
child: RefreshIndicator(
onRefresh: _loadInitialMessages,
child: ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: allMessages.length,
itemBuilder: (context, index) {
final message = allMessages[index];
return _MessageBubble(message: message);
},
),
),
),
],
);
},
),
),
// Input
Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Colors.grey[200]!),
),
),
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: 'Message...',
hintStyle: TextStyle(color: Colors.grey[400]),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
),
IconButton(
icon: const Icon(Icons.send),
color: const Color(0xFF2563EB),
onPressed: _sendMessage,
),
],
),
),
],
),
// Badge de version en bas à droite
Positioned(
bottom: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: badgeColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: badgeColor.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.chat_bubble_outline,
size: 14,
color: Colors.white.withOpacity(0.9),
),
const SizedBox(width: 4),
Text(
'v$moduleVersion',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
),
);
}
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
super.dispose();
}
}
/// Widget simple pour une bulle de message
class _MessageBubble extends StatelessWidget {
final Message message;
const _MessageBubble({required this.message});
@override
Widget build(BuildContext context) {
final isMe = message.isMe;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: isMe ? const Color(0xFFEFF6FF) : Colors.white,
borderRadius: BorderRadius.circular(8),
border: !isMe ? Border.all(color: Colors.grey[300]!) : null,
),
child: Column(
crossAxisAlignment:
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
if (!isMe)
Text(
message.senderName,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF2563EB),
),
),
const SizedBox(height: 2),
Text(
message.content,
style: const TextStyle(fontSize: 14),
),
const SizedBox(height: 2),
Text(
_formatTime(message.sentAt),
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
),
),
],
),
),
);
}
String _formatTime(DateTime date) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,340 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../models/room.dart';
import '../services/chat_service.dart';
import '../services/chat_config_loader.dart';
import '../widgets/recipient_selector.dart';
import 'chat_page.dart';
/// Page de liste des conversations avec gestion des permissions
class RoomsPage extends StatefulWidget {
const RoomsPage({super.key});
@override
State<RoomsPage> createState() => _RoomsPageState();
}
class _RoomsPageState extends State<RoomsPage> {
final _service = ChatService.instance;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadRooms();
}
Future<void> _loadRooms() async {
setState(() => _isLoading = true);
await _service.getRooms();
setState(() => _isLoading = false);
}
@override
Widget build(BuildContext context) {
final roleLabel = ChatConfigLoader.instance.getRoleName(_service.currentUserRole);
final helpText = ChatConfigLoader.instance.getHelpText(_service.currentUserRole);
return Scaffold(
backgroundColor: const Color(0xFFFAFAFA),
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Messages'),
Text(
roleLabel,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.normal),
),
],
),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1E293B),
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.add),
onPressed: _createNewConversation,
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: _loadRooms,
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: ValueListenableBuilder<Box<Room>>(
valueListenable: _service.roomsBox.listenable(),
builder: (context, box, _) {
final rooms = box.values.toList()
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
if (rooms.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Aucune conversation',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
TextButton(
onPressed: _createNewConversation,
child: const Text('Démarrer une conversation'),
),
if (helpText.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
helpText,
style: TextStyle(
fontSize: 13,
color: Colors.grey[500],
),
textAlign: TextAlign.center,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadRooms,
child: ListView.builder(
itemCount: rooms.length,
itemBuilder: (context, index) {
final room = rooms[index];
return _RoomTile(room: room);
},
),
);
},
),
);
}
Future<void> _createNewConversation() async {
final currentRole = _service.currentUserRole;
final config = ChatConfigLoader.instance.getPossibleRecipientsConfig(currentRole);
// Déterminer si on permet la sélection multiple
// Pour role 1 (membre), permettre la sélection multiple pour contacter plusieurs membres/admins
// Pour role 2 (admin amicale), permettre la sélection multiple pour GEOSECTOR ou Amicale
// Pour role 9 (super admin), permettre la sélection multiple selon config
final allowMultiple = (currentRole == 1) || (currentRole == 2) ||
(currentRole == 9 && config.any((c) => c['allow_selection'] == true));
// Ouvrir le dialog de sélection
final result = await RecipientSelectorDialog.show(
context,
allowMultiple: allowMultiple,
);
if (result != null) {
final recipients = result['recipients'] as List<Map<String, dynamic>>?;
final initialMessage = result['initial_message'] as String?;
if (recipients != null && recipients.isNotEmpty) {
try {
Room? newRoom;
if (recipients.length == 1) {
// Conversation privée
final recipient = recipients.first;
newRoom = await _service.createPrivateRoom(
recipientId: recipient['id'],
recipientName: recipient['name'],
recipientRole: recipient['role'],
recipientEntite: recipient['entite_id'],
initialMessage: initialMessage,
);
} else {
// Conversation de groupe
final participantIds = recipients.map((r) => r['id'] as int).toList();
// Déterminer le titre en fonction du type de groupe
String title;
if (currentRole == 1) {
// Pour un membre
final hasAdmins = recipients.any((r) => r['role'] == 2);
final hasMembers = recipients.any((r) => r['role'] == 1);
if (hasAdmins && !hasMembers) {
title = 'Administrateurs Amicale';
} else if (recipients.length > 3) {
title = '${recipients.take(3).map((r) => r['name']).join(', ')} et ${recipients.length - 3} autres';
} else {
title = recipients.map((r) => r['name']).join(', ');
}
} else if (currentRole == 2) {
// Pour un admin d'amicale
final hasSuperAdmins = recipients.any((r) => r['role'] == 9);
final hasMembers = recipients.any((r) => r['role'] == 1);
if (hasSuperAdmins && !hasMembers) {
title = 'GEOSECTOR Support';
} else if (!hasSuperAdmins && hasMembers && recipients.length > 5) {
title = 'Amicale - Tous les membres';
} else if (recipients.length > 3) {
title = '${recipients.take(3).map((r) => r['name']).join(', ')} et ${recipients.length - 3} autres';
} else {
title = recipients.map((r) => r['name']).join(', ');
}
} else {
// Pour un super admin
if (recipients.length > 3) {
title = '${recipients.take(3).map((r) => r['name']).join(', ')} et ${recipients.length - 3} autres';
} else {
title = recipients.map((r) => r['name']).join(', ');
}
}
// Créer la room avec le bon type
newRoom = await _service.createRoom(
title: title,
participantIds: participantIds,
type: (currentRole == 1 || currentRole == 2) ? 'group' : 'broadcast',
initialMessage: initialMessage,
);
}
if (newRoom != null && mounted) {
// Naviguer vers la nouvelle conversation
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatPage(
roomId: newRoom!.id,
roomTitle: newRoom.title,
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: Colors.red,
),
);
}
}
}
}
}
}
/// Widget simple pour une tuile de room
class _RoomTile extends StatelessWidget {
final Room room;
const _RoomTile({required this.room});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(color: Colors.grey[200]!),
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: CircleAvatar(
backgroundColor: const Color(0xFF2563EB),
child: Text(
room.title[0].toUpperCase(),
style: const TextStyle(color: Colors.white),
),
),
title: Text(
room.title,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
subtitle: room.lastMessage != null
? Text(
room.lastMessage!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.grey[600]),
)
: null,
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (room.lastMessageAt != null)
Text(
_formatTime(room.lastMessageAt!),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
if (room.unreadCount > 0)
Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
borderRadius: BorderRadius.circular(10),
),
child: Text(
room.unreadCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatPage(
roomId: room.id,
roomTitle: room.title,
),
),
);
},
),
);
}
String _formatTime(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays > 0) {
return '${diff.inDays}j';
} else if (diff.inHours > 0) {
return '${diff.inHours}h';
} else if (diff.inMinutes > 0) {
return '${diff.inMinutes}m';
} else {
return 'Maintenant';
}
}
}

View File

@@ -0,0 +1,326 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../models/room.dart';
import '../services/chat_service.dart';
import '../services/chat_config_loader.dart';
import '../widgets/recipient_selector.dart';
import 'chat_page.dart';
/// Version embarquée de RoomsPage sans AppBar pour intégration
class RoomsPageEmbedded extends StatefulWidget {
final VoidCallback? onAddPressed;
final VoidCallback? onRefreshPressed;
const RoomsPageEmbedded({
super.key,
this.onAddPressed,
this.onRefreshPressed,
});
@override
State<RoomsPageEmbedded> createState() => RoomsPageEmbeddedState();
}
class RoomsPageEmbeddedState extends State<RoomsPageEmbedded> {
final _service = ChatService.instance;
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadRooms();
}
Future<void> _loadRooms() async {
setState(() => _isLoading = true);
await _service.getRooms();
setState(() => _isLoading = false);
widget.onRefreshPressed?.call();
}
@override
Widget build(BuildContext context) {
final helpText = ChatConfigLoader.instance.getHelpText(_service.currentUserRole);
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
return ValueListenableBuilder<Box<Room>>(
valueListenable: _service.roomsBox.listenable(),
builder: (context, box, _) {
final rooms = box.values.toList()
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
if (rooms.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Aucune conversation',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
TextButton(
onPressed: widget.onAddPressed ?? createNewConversation,
child: const Text('Démarrer une conversation'),
),
if (helpText.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
helpText,
style: TextStyle(
fontSize: 13,
color: Colors.grey[500],
),
textAlign: TextAlign.center,
),
),
],
),
);
}
return RefreshIndicator(
onRefresh: _loadRooms,
child: ListView.builder(
itemCount: rooms.length,
itemBuilder: (context, index) {
final room = rooms[index];
return _RoomTile(room: room);
},
),
);
},
);
}
Future<void> createNewConversation() async {
final currentRole = _service.currentUserRole;
final config = ChatConfigLoader.instance.getPossibleRecipientsConfig(currentRole);
// Déterminer si on permet la sélection multiple
// Pour role 1 (membre), permettre la sélection multiple pour contacter plusieurs membres/admins
// Pour role 2 (admin amicale), permettre la sélection multiple pour GEOSECTOR ou Amicale
// Pour role 9 (super admin), permettre la sélection multiple selon config
final allowMultiple = (currentRole == 1) || (currentRole == 2) ||
(currentRole == 9 && config.any((c) => c['allow_selection'] == true));
// Ouvrir le dialog de sélection
final result = await RecipientSelectorDialog.show(
context,
allowMultiple: allowMultiple,
);
if (result != null) {
final recipients = result['recipients'] as List<Map<String, dynamic>>?;
final initialMessage = result['initial_message'] as String?;
if (recipients != null && recipients.isNotEmpty) {
try {
Room? newRoom;
if (recipients.length == 1) {
// Conversation privée
final recipient = recipients.first;
newRoom = await _service.createPrivateRoom(
recipientId: recipient['id'],
recipientName: recipient['name'],
recipientRole: recipient['role'],
recipientEntite: recipient['entite_id'],
initialMessage: initialMessage,
);
} else {
// Conversation de groupe
final participantIds = recipients.map((r) => r['id'] as int).toList();
// Déterminer le titre en fonction du type de groupe
String title;
if (currentRole == 1) {
// Pour un membre
final hasAdmins = recipients.any((r) => r['role'] == 2);
final hasMembers = recipients.any((r) => r['role'] == 1);
if (hasAdmins && !hasMembers) {
title = 'Administrateurs Amicale';
} else if (recipients.length > 3) {
title = '${recipients.take(3).map((r) => r['name']).join(', ')} et ${recipients.length - 3} autres';
} else {
title = recipients.map((r) => r['name']).join(', ');
}
} else if (currentRole == 2) {
// Pour un admin d'amicale
final hasSuperAdmins = recipients.any((r) => r['role'] == 9);
final hasMembers = recipients.any((r) => r['role'] == 1);
if (hasSuperAdmins && !hasMembers) {
title = 'GEOSECTOR Support';
} else if (!hasSuperAdmins && hasMembers && recipients.length > 5) {
title = 'Amicale - Tous les membres';
} else if (recipients.length > 3) {
title = '${recipients.take(3).map((r) => r['name']).join(', ')} et ${recipients.length - 3} autres';
} else {
title = recipients.map((r) => r['name']).join(', ');
}
} else {
// Pour un super admin
if (recipients.length > 3) {
title = '${recipients.take(3).map((r) => r['name']).join(', ')} et ${recipients.length - 3} autres';
} else {
title = recipients.map((r) => r['name']).join(', ');
}
}
// Créer la room avec le bon type
newRoom = await _service.createRoom(
title: title,
participantIds: participantIds,
type: (currentRole == 1 || currentRole == 2) ? 'group' : 'broadcast',
initialMessage: initialMessage,
);
}
if (newRoom != null && mounted) {
// Naviguer vers la nouvelle conversation
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatPage(
roomId: newRoom!.id,
roomTitle: newRoom.title,
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(e.toString()),
backgroundColor: Colors.red,
),
);
}
}
}
}
}
// Méthode publique pour rafraîchir
void refresh() {
_loadRooms();
}
}
/// Widget simple pour une tuile de room
class _RoomTile extends StatelessWidget {
final Room room;
const _RoomTile({required this.room});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(color: Colors.grey[200]!),
),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: CircleAvatar(
backgroundColor: const Color(0xFF2563EB),
child: Text(
room.title[0].toUpperCase(),
style: const TextStyle(color: Colors.white),
),
),
title: Text(
room.title,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
subtitle: room.lastMessage != null
? Text(
room.lastMessage!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.grey[600]),
)
: null,
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (room.lastMessageAt != null)
Text(
_formatTime(room.lastMessageAt!),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
if (room.unreadCount > 0)
Container(
margin: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
borderRadius: BorderRadius.circular(10),
),
child: Text(
room.unreadCount.toString(),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
),
),
],
),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => ChatPage(
roomId: room.id,
roomTitle: room.title,
),
),
);
},
),
);
}
String _formatTime(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays > 0) {
return '${diff.inDays}j';
} else if (diff.inHours > 0) {
return '${diff.inHours}h';
} else if (diff.inMinutes > 0) {
return '${diff.inMinutes}m';
} else {
return 'Maintenant';
}
}
}

View File

@@ -1,364 +0,0 @@
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

@@ -1,213 +0,0 @@
-- 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

@@ -1,323 +0,0 @@
<?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

@@ -1,263 +0,0 @@
<?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

@@ -1,107 +0,0 @@
/// Service API pour la communication avec le backend du chat
///
/// Ce service gère toutes les requêtes HTTP vers l'API chat
library;
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,238 @@
import 'package:flutter/services.dart';
import 'package:yaml/yaml.dart';
/// Classe pour charger et gérer la configuration du chat depuis chat_config.yaml
class ChatConfigLoader {
static ChatConfigLoader? _instance;
static ChatConfigLoader get instance => _instance ??= ChatConfigLoader._();
Map<String, dynamic>? _config;
ChatConfigLoader._();
/// Charger la configuration depuis le fichier YAML
Future<void> loadConfig() async {
try {
// Charger le fichier YAML
final yamlString = await rootBundle.loadString('lib/chat/chat_config.yaml');
// Vérifier que le contenu n'est pas vide
if (yamlString.isEmpty) {
print('Fichier de configuration chat vide, utilisation de la configuration par défaut');
_config = _getDefaultConfig();
return;
}
// Parser le YAML
dynamic yamlMap;
try {
yamlMap = loadYaml(yamlString);
} catch (parseError) {
print('Erreur de parsing YAML (utilisation de la config par défaut): $parseError');
print('Contenu YAML problématique (premiers 500 caractères): ${yamlString.substring(0, yamlString.length > 500 ? 500 : yamlString.length)}');
_config = _getDefaultConfig();
return;
}
// Convertir en Map<String, dynamic>
_config = _convertYamlToMap(yamlMap);
print('Configuration chat chargée avec succès');
} catch (e) {
print('Erreur lors du chargement de la configuration chat: $e');
// Utiliser une configuration par défaut en cas d'erreur
_config = _getDefaultConfig();
}
}
/// Convertir YamlMap en Map standard
dynamic _convertYamlToMap(dynamic yamlData) {
if (yamlData is YamlMap) {
final map = <String, dynamic>{};
yamlData.forEach((key, value) {
map[key.toString()] = _convertYamlToMap(value);
});
return map;
} else if (yamlData is YamlList) {
return yamlData.map((item) => _convertYamlToMap(item)).toList();
} else {
return yamlData;
}
}
/// Obtenir les permissions pour un rôle
Map<String, dynamic> getPermissionsForRole(int role) {
if (_config == null) {
return {};
}
final permissions = _config!['chat_permissions'] as Map<String, dynamic>?;
if (permissions == null) {
return {};
}
return permissions['role_$role'] as Map<String, dynamic>? ?? {};
}
/// Vérifier si un utilisateur peut envoyer un message à un autre
bool canSendMessageTo({
required int senderRole,
required int recipientRole,
int? senderEntite,
int? recipientEntite,
}) {
final permissions = getPermissionsForRole(senderRole);
final canMessageWith = permissions['can_message_with'] as List<dynamic>?;
if (canMessageWith == null) {
return false;
}
for (final rule in canMessageWith) {
if (rule['role'] == recipientRole) {
final condition = rule['condition'] as String?;
switch (condition) {
case 'same_entite':
return senderEntite != null &&
recipientEntite != null &&
senderEntite == recipientEntite;
case 'all':
return true;
default:
return false;
}
}
}
return false;
}
/// Obtenir les destinataires possibles pour un rôle
List<Map<String, dynamic>> getPossibleRecipientsConfig(int role) {
final permissions = getPermissionsForRole(role);
final canMessageWith = permissions['can_message_with'] as List<dynamic>?;
if (canMessageWith == null) {
return [];
}
return canMessageWith.map((rule) {
return {
'role': rule['role'],
'condition': rule['condition'],
'description': rule['description'],
'allow_selection': rule['allow_selection'] ?? false,
'allow_broadcast': rule['allow_broadcast'] ?? false,
};
}).toList();
}
/// Obtenir le nom d'un rôle
String getRoleName(int role) {
final permissions = getPermissionsForRole(role);
return permissions['name'] as String? ?? 'Utilisateur';
}
/// Obtenir la description d'un rôle
String getRoleDescription(int role) {
final permissions = getPermissionsForRole(role);
return permissions['description'] as String? ?? '';
}
/// Obtenir le texte d'aide pour un rôle
String getHelpText(int role) {
final permissions = getPermissionsForRole(role);
return permissions['help_text'] as String? ?? '';
}
/// Vérifier si un rôle peut créer des groupes
bool canCreateGroup(int role) {
final permissions = getPermissionsForRole(role);
return permissions['can_create_group'] as bool? ?? false;
}
/// Vérifier si un rôle peut faire du broadcast
bool canBroadcast(int role) {
final permissions = getPermissionsForRole(role);
return permissions['can_broadcast'] as bool? ?? false;
}
/// Obtenir la configuration UI
Map<String, dynamic> getUIConfig() {
return _config?['ui_config'] as Map<String, dynamic>? ?? {};
}
/// Obtenir les messages de l'interface
Map<String, dynamic> getUIMessages() {
final uiConfig = getUIConfig();
return uiConfig['messages'] as Map<String, dynamic>? ?? {};
}
/// Obtenir la couleur d'un rôle
String getRoleColor(int role) {
final uiConfig = getUIConfig();
final roleColors = uiConfig['role_colors'] as Map<String, dynamic>?;
return roleColors?[role.toString()] as String? ?? '#64748B';
}
/// Obtenir les informations du module
Map<String, dynamic> getModuleInfo() {
return _config?['module_info'] as Map<String, dynamic>? ?? {
'version': '1.0.0',
'name': 'Chat Module Light',
'description': 'Module de chat autonome et portable pour GEOSECTOR'
};
}
/// Obtenir la version du module
String getModuleVersion() {
final moduleInfo = getModuleInfo();
return moduleInfo['version'] as String? ?? '1.0.0';
}
/// Configuration par défaut si le fichier YAML n'est pas trouvé
Map<String, dynamic> _getDefaultConfig() {
return {
'chat_permissions': {
'role_1': {
'name': 'Membre',
'can_message_with': [
{'role': 1, 'condition': 'same_entite'},
{'role': 2, 'condition': 'same_entite'},
],
'can_create_group': false,
'can_broadcast': false,
'help_text': 'Vous pouvez discuter avec les membres de votre amicale',
},
'role_2': {
'name': 'Admin Amicale',
'can_message_with': [
{'role': 1, 'condition': 'same_entite'},
{'role': 2, 'condition': 'same_entite'},
{'role': 9, 'condition': 'all'},
],
'can_create_group': true,
'can_broadcast': false,
'help_text': 'Vous pouvez discuter avec les membres et les super admins',
},
'role_9': {
'name': 'Super Admin',
'can_message_with': [
{'role': 2, 'condition': 'all', 'allow_selection': true, 'allow_broadcast': true},
],
'can_create_group': true,
'can_broadcast': true,
'help_text': 'Vous pouvez envoyer des messages aux administrateurs d\'amicale',
},
},
'ui_config': {
'show_role_badge': true,
'enable_autocomplete': true,
'messages': {
'no_permission': 'Vous n\'avez pas la permission',
'search_placeholder': 'Rechercher...',
},
},
};
}
}

View File

@@ -0,0 +1,112 @@
import 'package:flutter/foundation.dart';
/// Service pour gérer les informations globales du chat (badges, stats)
/// Récupère les infos depuis l'API au login et les maintient à jour
class ChatInfoService extends ChangeNotifier {
static ChatInfoService? _instance;
static ChatInfoService get instance => _instance ??= ChatInfoService._();
ChatInfoService._();
// Stats du chat
int _totalRooms = 0;
int _unreadMessages = 0;
DateTime? _lastUpdate;
// Getters
int get totalRooms => _totalRooms;
int get unreadMessages => _unreadMessages;
DateTime? get lastUpdate => _lastUpdate;
bool get hasUnread => _unreadMessages > 0;
/// Met à jour les infos depuis la réponse de login
/// Attend une structure : { "chat": { "total_rooms": 5, "unread_messages": 12 } }
void updateFromLogin(Map<String, dynamic> loginData) {
debugPrint('📊 ChatInfoService: Mise à jour depuis login');
final chatData = loginData['chat'];
if (chatData != null && chatData is Map<String, dynamic>) {
_totalRooms = chatData['total_rooms'] ?? 0;
_unreadMessages = chatData['unread_messages'] ?? 0;
_lastUpdate = DateTime.now();
debugPrint('💬 Chat stats - Rooms: $_totalRooms, Non lus: $_unreadMessages');
notifyListeners();
} else {
debugPrint('⚠️ Pas de données chat dans la réponse login');
}
}
/// Met à jour directement les stats
void updateStats({int? totalRooms, int? unreadMessages}) {
bool hasChanged = false;
if (totalRooms != null && totalRooms != _totalRooms) {
_totalRooms = totalRooms;
hasChanged = true;
}
if (unreadMessages != null && unreadMessages != _unreadMessages) {
_unreadMessages = unreadMessages;
hasChanged = true;
}
if (hasChanged) {
_lastUpdate = DateTime.now();
notifyListeners();
}
}
/// Décrémente le nombre de messages non lus
void decrementUnread(int count) {
if (count > 0) {
_unreadMessages = (_unreadMessages - count).clamp(0, 999);
_lastUpdate = DateTime.now();
notifyListeners();
}
}
/// Marque tous les messages d'une room comme lus
void markRoomAsRead(int messagesCount) {
if (messagesCount > 0) {
decrementUnread(messagesCount);
}
}
/// Incrémente le nombre de messages non lus (nouveau message reçu)
void incrementUnread(int count) {
if (count > 0) {
_unreadMessages = (_unreadMessages + count).clamp(0, 999);
_lastUpdate = DateTime.now();
notifyListeners();
}
}
/// Réinitialise les stats (au logout)
void reset() {
_totalRooms = 0;
_unreadMessages = 0;
_lastUpdate = null;
notifyListeners();
}
/// Force un refresh des stats depuis l'API
Future<void> refreshFromApi() async {
// Cette méthode pourrait appeler directement l'API
// Pour l'instant on la laisse vide, elle sera utile plus tard
debugPrint('📊 ChatInfoService: Refresh depuis API demandé');
}
/// Retourne un label formaté pour le badge
String get badgeLabel {
if (_unreadMessages == 0) return '';
if (_unreadMessages > 99) return '99+';
return _unreadMessages.toString();
}
/// Debug info
@override
String toString() {
return 'ChatInfoService(rooms: $_totalRooms, unread: $_unreadMessages, lastUpdate: $_lastUpdate)';
}
}

View File

@@ -0,0 +1,468 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../models/room.dart';
import '../models/message.dart';
import 'chat_config_loader.dart';
import 'chat_info_service.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
/// Service de chat avec règles métier configurables via YAML
/// Les permissions sont définies dans chat_config.yaml
class ChatService {
static ChatService? _instance;
static ChatService get instance => _instance!;
late final Dio _dio;
late final Box<Room> _roomsBox;
late final Box<Message> _messagesBox;
late int _currentUserId;
late String _currentUserName;
late int _currentUserRole;
late int? _currentUserEntite;
String? _authToken;
Timer? _syncTimer;
/// Initialisation avec gestion des rôles et configuration YAML
static Future<void> init({
required String apiUrl,
required int userId,
required String userName,
required int userRole,
int? userEntite,
String? authToken,
}) async {
_instance = ChatService._();
// Charger la configuration depuis le YAML
await ChatConfigLoader.instance.loadConfig();
// Initialiser Hive
await Hive.initFlutter();
Hive.registerAdapter(RoomAdapter());
Hive.registerAdapter(MessageAdapter());
// Ouvrir les boxes en utilisant les constantes centralisées
_instance!._roomsBox = await Hive.openBox<Room>(AppKeys.chatRoomsBoxName);
_instance!._messagesBox = await Hive.openBox<Message>(AppKeys.chatMessagesBoxName);
// Configurer l'utilisateur
_instance!._currentUserId = userId;
_instance!._currentUserName = userName;
_instance!._currentUserRole = userRole;
_instance!._currentUserEntite = userEntite;
_instance!._authToken = authToken;
// Configurer Dio
_instance!._dio = Dio(BaseOptions(
baseUrl: apiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: authToken != null ? {'Authorization': 'Bearer $authToken'} : {},
));
// Démarrer la synchronisation
_instance!._startSync();
}
ChatService._();
/// Obtenir les destinataires possibles selon le rôle
Future<List<Map<String, dynamic>>> getPossibleRecipients({String? search}) async {
try {
// L'API utilise le token pour identifier l'utilisateur et son rôle
String endpoint = '/chat/recipients';
final params = <String, dynamic>{};
if (search != null && search.isNotEmpty) {
params['search'] = search;
}
final response = await _dio.get(endpoint, queryParameters: params);
// Gérer différents formats de réponse
if (response.data is List) {
return List<Map<String, dynamic>>.from(response.data);
} else if (response.data is Map && response.data['recipients'] != null) {
return List<Map<String, dynamic>>.from(response.data['recipients']);
} else if (response.data is Map && response.data['data'] != null) {
return List<Map<String, dynamic>>.from(response.data['data']);
} else {
print('⚠️ Format inattendu pour /chat/recipients: ${response.data.runtimeType}');
return [];
}
} catch (e) {
print('⚠️ Erreur getPossibleRecipients: $e');
// Fallback sur logique locale selon le rôle
return _getLocalRecipients();
}
}
/// Logique locale de récupération des destinataires
List<Map<String, dynamic>> _getLocalRecipients() {
// Cette méthode devrait accéder à la box membres de l'app principale
// Pour l'instant on retourne une liste vide
return [];
}
/// Vérifier si l'utilisateur peut créer une conversation avec un destinataire
bool canCreateConversationWith(int recipientRole, {int? recipientEntite}) {
return ChatConfigLoader.instance.canSendMessageTo(
senderRole: _currentUserRole,
recipientRole: recipientRole,
senderEntite: _currentUserEntite,
recipientEntite: recipientEntite,
);
}
/// Obtenir les rooms filtrées selon les permissions
Future<List<Room>> getRooms() async {
try {
// L'API filtre automatiquement selon le token Bearer
final response = await _dio.get('/chat/rooms');
// Debug : afficher le type et le contenu de la réponse
print('📊 Type de réponse /chat/rooms: ${response.data.runtimeType}');
if (response.data is Map) {
print('📊 Clés de la réponse: ${(response.data as Map).keys.toList()}');
}
// Gérer différents formats de réponse API
List<dynamic> roomsData;
if (response.data is Map) {
// La plupart du temps, l'API retourne un objet avec status et rooms
if (response.data['rooms'] != null) {
roomsData = response.data['rooms'] as List;
print('✅ Réponse API: status=${response.data['status']}, ${roomsData.length} rooms');
} else if (response.data['data'] != null) {
roomsData = response.data['data'] as List;
print('✅ Réponse avec propriété "data" (${roomsData.length} rooms)');
} else {
// Pas de propriété rooms ou data, liste vide
print('⚠️ Réponse sans rooms ni data: ${response.data}');
roomsData = [];
}
} else if (response.data is List) {
// Si c'est directement une liste (moins courant)
roomsData = response.data as List;
print('✅ Réponse est directement une liste avec ${roomsData.length} rooms');
} else {
// Format complètement inattendu
print('⚠️ Format de réponse inattendu pour /chat/rooms: ${response.data.runtimeType}');
roomsData = [];
}
final rooms = roomsData
.map((json) => Room.fromJson(json))
.toList();
// Sauvegarder dans Hive
await _roomsBox.clear();
for (final room in rooms) {
await _roomsBox.put(room.id, room);
}
// Mettre à jour les stats globales
final totalUnread = rooms.fold<int>(0, (sum, room) => sum + room.unreadCount);
ChatInfoService.instance.updateStats(
totalRooms: rooms.length,
unreadMessages: totalUnread,
);
return rooms;
} catch (e) {
print('Erreur lors de la récupération des rooms (utilisation du cache): $e');
// Fallback sur le cache local en cas d'erreur API (404, etc.)
return _roomsBox.values.toList()
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
}
}
/// Créer une room avec vérification des permissions
Future<Room?> createRoom({
required String title,
required List<int> participantIds,
String? type,
String? initialMessage,
}) async {
try {
// Vérifier les permissions localement d'abord
// L'API fera aussi une vérification
final data = {
'title': title,
'type': type ?? (_currentUserRole == 9 ? 'broadcast' : 'private'),
'participants': participantIds,
// L'API récupère le rôle et l'entité depuis le token
};
// Ajouter le message initial s'il est fourni
if (initialMessage != null && initialMessage.isNotEmpty) {
data['initial_message'] = initialMessage;
}
final response = await _dio.post('/chat/rooms', data: data);
final room = Room.fromJson(response.data);
await _roomsBox.put(room.id, room);
return room;
} catch (e) {
return null;
}
}
/// Créer une conversation one-to-one
Future<Room?> createPrivateRoom({
required int recipientId,
required String recipientName,
required int recipientRole,
int? recipientEntite,
String? initialMessage,
}) async {
// Vérifier les permissions via la configuration
if (!canCreateConversationWith(recipientRole, recipientEntite: recipientEntite)) {
final messages = ChatConfigLoader.instance.getUIMessages();
throw Exception(messages['no_permission'] ?? 'Permission refusée');
}
return createRoom(
title: recipientName,
participantIds: [recipientId],
type: 'private',
initialMessage: initialMessage,
);
}
/// Créer une conversation de groupe (pour superadmin)
Future<Room?> createGroupRoom({
required String title,
required List<int> adminIds,
String? initialMessage,
}) async {
if (_currentUserRole != 9) {
throw Exception('Seuls les superadmins peuvent créer des groupes');
}
return createRoom(
title: title,
participantIds: adminIds,
type: 'broadcast',
initialMessage: initialMessage,
);
}
/// Obtenir les messages d'une room avec pagination
Future<Map<String, dynamic>> getMessages(String roomId, {String? beforeMessageId}) async {
try {
final params = <String, dynamic>{
'limit': 50,
};
if (beforeMessageId != null) {
params['before'] = beforeMessageId;
}
final response = await _dio.get('/chat/rooms/$roomId/messages', queryParameters: params);
// Gérer différents formats de réponse
List<dynamic> messagesData;
bool hasMore = false;
if (response.data is List) {
// Si c'est directement une liste de messages
messagesData = response.data as List;
} else if (response.data is Map) {
// Si c'est un objet avec messages et has_more
messagesData = response.data['messages'] ?? response.data['data'] ?? [];
hasMore = response.data['has_more'] ?? false;
} else {
print('⚠️ Format inattendu pour les messages: ${response.data.runtimeType}');
messagesData = [];
}
final messages = messagesData
.map((json) => Message.fromJson(json, _currentUserId))
.toList();
// Sauvegarder dans Hive (en limitant à 100 messages par room)
await _saveMessagesToCache(roomId, messages);
return {
'messages': messages,
'has_more': hasMore,
};
} catch (e) {
print('Erreur getMessages: $e');
// Fallback sur le cache local
final cachedMessages = _messagesBox.values
.where((m) => m.roomId == roomId)
.toList()
..sort((a, b) => a.sentAt.compareTo(b.sentAt));
return {
'messages': cachedMessages,
'has_more': false,
};
}
}
/// Sauvegarder les messages dans le cache en limitant à 100 par room
Future<void> _saveMessagesToCache(String roomId, List<Message> newMessages) async {
// Obtenir tous les messages existants pour cette room
final existingMessages = _messagesBox.values
.where((m) => m.roomId == roomId)
.toList()
..sort((a, b) => b.sentAt.compareTo(a.sentAt)); // Plus récent en premier
// Ajouter les nouveaux messages
for (final message in newMessages) {
await _messagesBox.put(message.id, message);
}
// Si on dépasse 100 messages, supprimer les plus anciens
final allMessages = [...existingMessages, ...newMessages]
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
if (allMessages.length > 100) {
final messagesToDelete = allMessages.skip(100).toList();
for (final message in messagesToDelete) {
await _messagesBox.delete(message.id);
}
}
}
/// Envoyer un message
Future<Message?> sendMessage(String roomId, String content) async {
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
final tempMessage = Message(
id: tempId,
roomId: roomId,
content: content,
senderId: _currentUserId,
senderName: _currentUserName,
sentAt: DateTime.now(),
isMe: true,
);
await _messagesBox.put(tempId, tempMessage);
try {
final response = await _dio.post(
'/chat/rooms/$roomId/messages',
data: {
'content': content,
// L'API récupère le sender depuis le token
},
);
final finalMessage = Message.fromJson(response.data, _currentUserId);
await _messagesBox.delete(tempId);
await _messagesBox.put(finalMessage.id, finalMessage);
// Mettre à jour la room
final room = _roomsBox.get(roomId);
if (room != null) {
final updatedRoom = Room(
id: room.id,
title: room.title,
type: room.type,
createdAt: room.createdAt,
lastMessage: content,
lastMessageAt: DateTime.now(),
unreadCount: 0,
);
await _roomsBox.put(roomId, updatedRoom);
}
return finalMessage;
} catch (e) {
return tempMessage;
}
}
/// Marquer comme lu
Future<void> markAsRead(String roomId) async {
try {
await _dio.post('/chat/rooms/$roomId/read');
final room = _roomsBox.get(roomId);
if (room != null) {
// Décrémenter les messages non lus dans ChatInfoService
if (room.unreadCount > 0) {
ChatInfoService.instance.decrementUnread(room.unreadCount);
}
final updatedRoom = Room(
id: room.id,
title: room.title,
type: room.type,
createdAt: room.createdAt,
lastMessage: room.lastMessage,
lastMessageAt: room.lastMessageAt,
unreadCount: 0,
);
await _roomsBox.put(roomId, updatedRoom);
}
} catch (e) {
// Ignorer
}
}
/// Synchronisation périodique
void _startSync() {
_syncTimer?.cancel();
_syncTimer = Timer.periodic(const Duration(seconds: 30), (_) {
getRooms();
});
}
/// Nettoyer les ressources
void dispose() {
_syncTimer?.cancel();
_dio.close();
}
// Getters
Box<Room> get roomsBox => _roomsBox;
Box<Message> get messagesBox => _messagesBox;
int get currentUserId => _currentUserId;
/// Obtenir le rôle de l'utilisateur actuel
int getUserRole() => _currentUserRole;
int get currentUserRole => _currentUserRole;
String get currentUserName => _currentUserName;
/// Obtenir le label du rôle
String getRoleLabel(int role) {
switch (role) {
case 1:
return 'Membre';
case 2:
return 'Admin Amicale';
case 9:
return 'Super Admin';
default:
return 'Utilisateur';
}
}
/// Obtenir la description des permissions
String getPermissionsDescription() {
switch (_currentUserRole) {
case 1:
return 'Vous pouvez discuter avec les membres de votre amicale';
case 2:
return 'Vous pouvez discuter avec les membres et les super admins';
case 9:
return 'Vous pouvez envoyer des messages aux administrateurs d\'amicale';
default:
return '';
}
}
}

View File

@@ -1,214 +0,0 @@
# 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

@@ -1,205 +0,0 @@
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();
// 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

@@ -1,75 +0,0 @@
/// Configuration pour le broker MQTT
///
/// Centralise les paramètres de connexion au broker MQTT
library;
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

@@ -1,329 +0,0 @@
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

@@ -1,48 +0,0 @@
/// 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
library;
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

@@ -1,98 +0,0 @@
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

@@ -1,80 +0,0 @@
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

@@ -1,78 +0,0 @@
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

@@ -1,72 +0,0 @@
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) const 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)
const Text(
'Expéditeur',
style: TextStyle(fontWeight: FontWeight.bold),
),
const Text('Contenu du message...'),
if (showTimestamp || showStatus)
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (showTimestamp)
const Text('12:34', style: TextStyle(fontSize: 12)),
if (showStatus) const SizedBox(width: 4),
if (showStatus) const Icon(Icons.check, size: 16),
],
),
],
),
),
),
],
),
);
}
}

View File

@@ -1,159 +0,0 @@
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,599 @@
import 'package:flutter/material.dart';
import '../services/chat_service.dart';
import '../services/chat_config_loader.dart';
/// Widget pour sélectionner les destinataires avec autocomplete
/// Respecte les règles de permissions définies dans chat_config.yaml
class RecipientSelector extends StatefulWidget {
final Function(List<Map<String, dynamic>>) onRecipientsSelected;
final bool allowMultiple;
const RecipientSelector({
super.key,
required this.onRecipientsSelected,
this.allowMultiple = false,
});
@override
State<RecipientSelector> createState() => _RecipientSelectorState();
}
class _RecipientSelectorState extends State<RecipientSelector> {
final _service = ChatService.instance;
final _searchController = TextEditingController();
final _selectedRecipients = <Map<String, dynamic>>[];
List<Map<String, dynamic>> _suggestions = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadInitialRecipients();
}
Future<void> _loadInitialRecipients() async {
setState(() => _isLoading = true);
try {
final recipients = await _service.getPossibleRecipients();
setState(() {
_suggestions = recipients;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
}
Future<void> _searchRecipients(String query) async {
if (query.length < 2) {
_loadInitialRecipients();
return;
}
setState(() => _isLoading = true);
try {
final recipients = await _service.getPossibleRecipients(search: query);
setState(() {
_suggestions = recipients;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
}
void _toggleRecipient(Map<String, dynamic> recipient) {
setState(() {
if (widget.allowMultiple) {
final exists = _selectedRecipients.any((r) => r['id'] == recipient['id']);
if (exists) {
_selectedRecipients.removeWhere((r) => r['id'] == recipient['id']);
} else {
_selectedRecipients.add(recipient);
}
} else {
_selectedRecipients.clear();
_selectedRecipients.add(recipient);
}
});
widget.onRecipientsSelected(_selectedRecipients);
}
Widget _buildRoleBadge(int role) {
final color = ChatConfigLoader.instance.getRoleColor(role);
final name = ChatConfigLoader.instance.getRoleName(role);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: _hexToColor(color).withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: _hexToColor(color).withOpacity(0.3)),
),
child: Text(
name,
style: TextStyle(
fontSize: 11,
color: _hexToColor(color),
fontWeight: FontWeight.w600,
),
),
);
}
Color _hexToColor(String hex) {
final buffer = StringBuffer();
if (hex.length == 6 || hex.length == 7) buffer.write('ff');
buffer.write(hex.replaceFirst('#', ''));
return Color(int.parse(buffer.toString(), radix: 16));
}
@override
Widget build(BuildContext context) {
final currentRole = _service.currentUserRole;
final config = ChatConfigLoader.instance.getPossibleRecipientsConfig(currentRole);
final canBroadcast = config.any((c) => c['allow_broadcast'] == true);
final canSelect = config.any((c) => c['allow_selection'] == true);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// En-tête avec options pour membre role 1
if (currentRole == 1) ...[
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sélectionner les destinataires',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
// Bouton Admin pour contacter tous les admins de l'amicale
ActionChip(
label: const Text('Administrateurs'),
avatar: const Icon(Icons.admin_panel_settings, size: 18),
backgroundColor: Colors.red.shade50,
onPressed: () async {
final allRecipients = await _service.getPossibleRecipients();
setState(() {
_selectedRecipients.clear();
// Sélectionner tous les admins de l'amicale (role 2)
_selectedRecipients.addAll(
allRecipients.where((r) => r['role'] == 2)
);
});
widget.onRecipientsSelected(_selectedRecipients);
},
),
if (_selectedRecipients.isNotEmpty)
ActionChip(
label: Text('${_selectedRecipients.length} sélectionné(s)'),
avatar: const Icon(Icons.check_circle, size: 18),
backgroundColor: Colors.orange.shade50,
onPressed: () {
setState(() => _selectedRecipients.clear());
widget.onRecipientsSelected(_selectedRecipients);
},
),
],
),
const SizedBox(height: 8),
Text(
'Ou sélectionnez des membres individuellement :',
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
),
],
),
),
const Divider(height: 1),
],
// En-tête avec options pour admin role 2
if (currentRole == 2) ...[
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sélectionner les destinataires',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
// Bouton GEOSECTOR pour contacter tous les super-admins
ActionChip(
label: const Text('GEOSECTOR'),
avatar: const Icon(Icons.business, size: 18),
backgroundColor: Colors.blue.shade50,
onPressed: () async {
final allRecipients = await _service.getPossibleRecipients();
setState(() {
_selectedRecipients.clear();
// Sélectionner tous les super-admins (role 9)
_selectedRecipients.addAll(
allRecipients.where((r) => r['role'] == 9)
);
});
widget.onRecipientsSelected(_selectedRecipients);
},
),
// Bouton Amicale pour contacter tous les membres de son amicale
ActionChip(
label: const Text('Toute l\'Amicale'),
avatar: const Icon(Icons.group, size: 18),
backgroundColor: Colors.green.shade50,
onPressed: () async {
final allRecipients = await _service.getPossibleRecipients();
setState(() {
_selectedRecipients.clear();
// Sélectionner tous les membres de l'amicale (role 1)
_selectedRecipients.addAll(
allRecipients.where((r) => r['role'] == 1)
);
});
widget.onRecipientsSelected(_selectedRecipients);
},
),
if (_selectedRecipients.isNotEmpty)
ActionChip(
label: Text('${_selectedRecipients.length} sélectionné(s)'),
avatar: const Icon(Icons.check_circle, size: 18),
backgroundColor: Colors.orange.shade50,
onPressed: () {
setState(() => _selectedRecipients.clear());
widget.onRecipientsSelected(_selectedRecipients);
},
),
],
),
const SizedBox(height: 8),
Text(
'Ou sélectionnez des membres individuellement :',
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
),
],
),
),
const Divider(height: 1),
],
// En-tête avec options pour super-admin
if (currentRole == 9) ...[
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Sélectionner les destinataires',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
if (canBroadcast)
ActionChip(
label: const Text('Tous les admins'),
avatar: const Icon(Icons.groups, size: 18),
onPressed: () async {
final allAdmins = await _service.getPossibleRecipients();
setState(() {
_selectedRecipients.clear();
_selectedRecipients.addAll(
allAdmins.where((r) => r['role'] == 2)
);
});
widget.onRecipientsSelected(_selectedRecipients);
},
),
if (_selectedRecipients.isNotEmpty)
ActionChip(
label: Text('${_selectedRecipients.length} sélectionné(s)'),
avatar: const Icon(Icons.check_circle, size: 18),
backgroundColor: Colors.green.shade50,
onPressed: () {
setState(() => _selectedRecipients.clear());
widget.onRecipientsSelected(_selectedRecipients);
},
),
],
),
],
),
),
const Divider(height: 1),
],
// Barre de recherche
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: ChatConfigLoader.instance.getUIMessages()['search_placeholder']
?? 'Rechercher...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
onChanged: _searchRecipients,
),
),
// Liste des suggestions
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _suggestions.isEmpty
? Center(
child: Text(
ChatConfigLoader.instance.getUIMessages()['no_recipients']
?? 'Aucun destinataire disponible',
style: TextStyle(color: Colors.grey[600]),
),
)
: ListView.builder(
itemCount: _suggestions.length,
itemBuilder: (context, index) {
final recipient = _suggestions[index];
final isSelected = _selectedRecipients.any(
(r) => r['id'] == recipient['id']
);
return ListTile(
leading: CircleAvatar(
backgroundColor: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade200,
child: Text(
recipient['name']?.substring(0, 1).toUpperCase() ?? '?',
style: TextStyle(
color: isSelected ? Colors.white : Colors.grey[700],
fontWeight: FontWeight.w600,
),
),
),
title: Text(
recipient['name'] ?? 'Sans nom',
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: recipient['entite_name'] != null
? Text(
recipient['entite_name'],
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
),
)
: null,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (recipient['role'] != null)
_buildRoleBadge(recipient['role']),
if (widget.allowMultiple || canSelect)
Checkbox(
value: isSelected,
onChanged: (_) => _toggleRecipient(recipient),
),
],
),
onTap: () => _toggleRecipient(recipient),
);
},
),
),
// Bouton de validation
if (_selectedRecipients.isNotEmpty)
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Colors.grey.shade200),
),
),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(_selectedRecipients),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
backgroundColor: Theme.of(context).primaryColor,
),
child: Text(
widget.allowMultiple
? 'Créer conversation avec ${_selectedRecipients.length} personne(s)'
: 'Créer conversation',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
],
);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
}
/// Dialog pour sélectionner les destinataires
class RecipientSelectorDialog extends StatelessWidget {
final bool allowMultiple;
const RecipientSelectorDialog({
super.key,
this.allowMultiple = false,
});
static Future<Map<String, dynamic>?> show(
BuildContext context, {
bool allowMultiple = false,
}) async {
return showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => RecipientSelectorDialog(
allowMultiple: allowMultiple,
),
);
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.8,
maxWidth: 500,
),
child: _RecipientSelectorWithMessage(
allowMultiple: allowMultiple,
),
),
);
}
}
/// Widget interne pour gérer la sélection et le message initial
class _RecipientSelectorWithMessage extends StatefulWidget {
final bool allowMultiple;
const _RecipientSelectorWithMessage({
required this.allowMultiple,
});
@override
State<_RecipientSelectorWithMessage> createState() => _RecipientSelectorWithMessageState();
}
class _RecipientSelectorWithMessageState extends State<_RecipientSelectorWithMessage> {
List<Map<String, dynamic>> _selectedRecipients = [];
final _messageController = TextEditingController();
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Sélecteur de destinataires
Expanded(
child: RecipientSelector(
allowMultiple: widget.allowMultiple,
onRecipientsSelected: (recipients) {
setState(() {
_selectedRecipients = recipients;
});
},
),
),
// Champ de message initial si des destinataires sont sélectionnés
if (_selectedRecipients.isNotEmpty) ...[
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(
top: BorderSide(color: Colors.grey.shade200),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Message initial (optionnel)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
),
const SizedBox(height: 8),
TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: 'Écrivez votre premier message...',
hintStyle: TextStyle(color: Colors.grey[400]),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
maxLines: 3,
minLines: 2,
),
],
),
),
// Bouton de validation
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Colors.grey.shade200),
),
),
child: SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop({
'recipients': _selectedRecipients,
'initial_message': _messageController.text.trim(),
});
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
backgroundColor: Theme.of(context).primaryColor,
),
child: Text(
widget.allowMultiple
? 'Créer conversation avec ${_selectedRecipients.length} personne(s)'
: 'Créer conversation',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
],
],
);
}
@override
void dispose() {
_messageController.dispose();
super.dispose();
}
}

View File

@@ -17,7 +17,7 @@ class AppKeys {
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 chatRoomsBoxName = 'chat_rooms';
static const String chatMessagesBoxName = 'chat_messages';
static const String regionsBoxName = 'regions';

View File

@@ -17,6 +17,7 @@ import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/data/models/membre_model.dart';
import 'package:geosector_app/presentation/widgets/loading_spin_overlay.dart';
import 'package:geosector_app/core/models/loading_state.dart';
import 'package:geosector_app/chat/services/chat_info_service.dart';
class UserRepository extends ChangeNotifier {
bool _isLoading = false;
@@ -276,7 +277,17 @@ class UserRepository extends ChangeNotifier {
}
}
// Étape 5: Traitement de toutes les autres données via DataLoadingService
// Étape 5: Traitement des infos chat
if (apiResult['chat'] != null) {
try {
ChatInfoService.instance.updateFromLogin(apiResult);
debugPrint('💬 Infos chat mises à jour');
} catch (chatError) {
debugPrint('⚠️ Erreur traitement infos chat: $chatError');
}
}
// Étape 6: Traitement de toutes les autres données via DataLoadingService
try {
await DataLoadingService.instance.processLoginData(apiResult);
} catch (processingError) {
@@ -316,6 +327,9 @@ class UserRepository extends ChangeNotifier {
// Effacer les données via les services singleton
await CurrentUserService.instance.clearUser();
await CurrentAmicaleService.instance.clearAmicale();
// Réinitialiser les infos chat
ChatInfoService.instance.reset();
// Nettoyage des données via HiveService (préserve les utilisateurs)
await HiveService.instance.cleanDataOnLogout();

View File

@@ -65,6 +65,14 @@ class ConnectivityService extends ChangeNotifier {
/// Constructeur du service de connectivité
ConnectivityService() {
// Initialiser avec une connexion par défaut (supposée disponible)
// Ceci sera mis à jour rapidement par _initConnectivity()
if (kIsWeb) {
_connectionStatus = [ConnectivityResult.wifi];
} else {
// Sur mobile, on suppose qu'il y a au moins une connexion jusqu'à vérification
_connectionStatus = [ConnectivityResult.wifi];
}
_initConnectivity();
}

View File

@@ -11,8 +11,7 @@ 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/amicale_model.dart';
import 'package:geosector_app/core/repositories/client_repository.dart';
import 'package:geosector_app/chat/models/conversation_model.dart';
import 'package:geosector_app/chat/models/message_model.dart';
// Chat imports removed - using new simplified chat module
import 'package:geosector_app/core/models/loading_state.dart';
/// Service singleton pour gérer le chargement et la gestion des données au login
@@ -54,10 +53,7 @@ class DataLoadingService extends ChangeNotifier {
Hive.box<UserSectorModel>(AppKeys.userSectorBoxName);
Box<AmicaleModel> get _amicaleBox =>
Hive.box<AmicaleModel>(AppKeys.amicaleBoxName);
Box<ConversationModel> get _chatConversationBox =>
Hive.box<ConversationModel>(AppKeys.chatConversationsBoxName);
Box<MessageModel> get _chatMessageBox =>
Hive.box<MessageModel>(AppKeys.chatMessagesBoxName);
// Chat boxes removed - handled by new chat module
Box get _settingsBox => Hive.box(AppKeys.settingsBoxName);
/// Traite toutes les données reçues de l'API lors du login
@@ -569,7 +565,7 @@ class DataLoadingService extends ChangeNotifier {
AppKeys.membresBoxName,
AppKeys.userSectorBoxName,
AppKeys.amicaleBoxName,
AppKeys.chatConversationsBoxName,
AppKeys.chatRoomsBoxName,
AppKeys.chatMessagesBoxName,
AppKeys.settingsBoxName,
];

View File

@@ -8,7 +8,7 @@ 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';
// Chat adapters removed - handled by new chat module
class HiveAdapters {
/// Enregistre tous les TypeAdapters nécessaires
@@ -42,24 +42,7 @@ class HiveAdapters {
Hive.registerAdapter(AmicaleModelAdapter());
}
// Chat adapters
if (!Hive.isAdapterRegistered(20)) {
Hive.registerAdapter(ConversationModelAdapter());
}
if (!Hive.isAdapterRegistered(21)) {
Hive.registerAdapter(MessageModelAdapter());
}
if (!Hive.isAdapterRegistered(22)) {
Hive.registerAdapter(ParticipantModelAdapter());
}
if (!Hive.isAdapterRegistered(23)) {
Hive.registerAdapter(AnonymousUserModelAdapter());
}
if (!Hive.isAdapterRegistered(24)) {
Hive.registerAdapter(AudienceTargetModelAdapter());
}
if (!Hive.isAdapterRegistered(25)) {
Hive.registerAdapter(NotificationSettingsAdapter());
}
// Chat adapters are now handled by the chat module itself
// TypeIds 50-60 are reserved for chat module
}
}

View File

@@ -13,7 +13,8 @@ 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';
import 'package:geosector_app/chat/models/room.dart';
import 'package:geosector_app/chat/models/message.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
@@ -91,12 +92,8 @@ class HiveResetService {
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());
Hive.registerAdapter(RoomAdapter());
Hive.registerAdapter(MessageAdapter());
// Vérifier si RegionModelAdapter est disponible
try {
@@ -117,7 +114,7 @@ class HiveResetService {
await Hive.openBox(AppKeys.settingsBoxName);
// Ouvrir les boîtes de chat
await Hive.openBox<ConversationModel>(AppKeys.chatConversationsBoxName);
await Hive.openBox<MessageModel>(AppKeys.chatMessagesBoxName);
await Hive.openBox<Room>(AppKeys.chatRoomsBoxName);
await Hive.openBox<Message>(AppKeys.chatMessagesBoxName);
}
}

View File

@@ -15,8 +15,7 @@ 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';
// Chat imports removed - using new simplified chat module
/// Service singleton centralisé pour la gestion complète des Box Hive
/// Utilisé par main.dart pour l'initialisation et par logout pour le nettoyage
@@ -35,8 +34,7 @@ class HiveService {
HiveBoxConfig<PassageModel>(AppKeys.passagesBoxName, 'PassageModel'),
HiveBoxConfig<MembreModel>(AppKeys.membresBoxName, 'MembreModel'),
HiveBoxConfig<UserSectorModel>(AppKeys.userSectorBoxName, 'UserSectorModel'),
HiveBoxConfig<ConversationModel>(AppKeys.chatConversationsBoxName, 'ConversationModel'),
HiveBoxConfig<MessageModel>(AppKeys.chatMessagesBoxName, 'MessageModel'),
// Chat boxes removed - handled by new chat module
HiveBoxConfig<dynamic>(AppKeys.settingsBoxName, 'Settings'),
HiveBoxConfig<dynamic>(AppKeys.regionsBoxName, 'Regions'),
];
@@ -403,12 +401,7 @@ class HiveService {
case 'UserSectorModel':
await Hive.openBox<UserSectorModel>(config.name);
break;
case 'ConversationModel':
await Hive.openBox<ConversationModel>(config.name);
break;
case 'MessageModel':
await Hive.openBox<MessageModel>(config.name);
break;
// Chat boxes removed - handled by new chat module
default:
// Pour Settings, Regions, etc.
await Hive.openBox(config.name);
@@ -455,6 +448,37 @@ class HiveService {
return true;
}
/// Vérification rapide que les boxes critiques sont initialisées
/// Utilisé par LoginPage pour détecter si une redirection vers SplashPage est nécessaire
bool areBoxesInitialized() {
try {
// Vérifier seulement les boxes critiques pour le login
final criticalBoxes = [
AppKeys.userBoxName, // Nécessaire pour getCurrentUser
AppKeys.membresBoxName, // Nécessaire pour le pré-remplissage
AppKeys.settingsBoxName, // Nécessaire pour les préférences
];
for (final boxName in criticalBoxes) {
if (!Hive.isBoxOpen(boxName)) {
debugPrint('⚠️ Box critique non ouverte: $boxName');
return false;
}
}
// Vérifier aussi le flag d'initialisation
if (!_isInitialized) {
debugPrint('⚠️ HiveService non initialisé');
return false;
}
return true;
} catch (e) {
debugPrint('❌ Erreur vérification boxes: $e');
return false;
}
}
/// Récupération sécurisée d'une Box typée
Box<T> getTypedBox<T>(String boxName) {
if (!Hive.isBoxOpen(boxName)) {

View File

@@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/theme/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';
import 'package:geosector_app/chat/chat_module.dart';
import 'package:geosector_app/chat/pages/rooms_page_embedded.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
class AdminCommunicationPage extends StatefulWidget {
const AdminCommunicationPage({super.key});
@@ -12,546 +14,238 @@ class AdminCommunicationPage extends StatefulWidget {
}
class _AdminCommunicationPageState extends State<AdminCommunicationPage> {
int selectedContactId = 0;
String selectedContactName = '';
bool isTeamChat = true;
String messageText = '';
bool isReplying = false;
Map<String, dynamic>? replyingTo;
bool _isChatInitialized = false;
bool _isInitializing = false;
String? _initError;
GlobalKey<RoomsPageEmbeddedState>? _roomsPageKey;
// 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',
},
];
@override
void initState() {
super.initState();
_initializeChat();
}
// 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',
},
];
Future<void> _initializeChat() async {
if (_isInitializing) return;
setState(() {
_isInitializing = true;
_initError = null;
});
// 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,
},
],
};
try {
// Récupérer les informations utilisateur
final currentUser = CurrentUserService.instance;
final apiService = ApiService.instance;
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
if (currentUser.currentUser == null) {
throw Exception('Administrateur non connecté');
}
// Initialiser le module chat avec les informations de l'administrateur
await ChatModule.init(
apiUrl: apiService.baseUrl,
userId: currentUser.currentUser!.id,
userName: currentUser.userName ?? currentUser.userEmail ?? 'Administrateur',
userRole: currentUser.currentUser!.role,
userEntite: currentUser.fkEntite ?? currentAmicale?.id,
authToken: currentUser.sessionId,
);
setState(() {
_isChatInitialized = true;
_isInitializing = false;
_roomsPageKey = GlobalKey<RoomsPageEmbeddedState>();
});
} catch (e) {
setState(() {
_initError = e.toString();
_isInitializing = false;
});
debugPrint('Erreur initialisation chat admin: $e');
}
}
void _refreshRooms() {
_roomsPageKey?.currentState?.refresh();
}
@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 = '';
});
},
),
final theme = Theme.of(context);
return 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),
),
// 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;
}
});
},
),
],
),
),
],
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: _buildContent(theme),
),
);
}
ImageProvider? _getAvatarForContact(int contactId) {
String? avatarPath;
if (isTeamChat) {
final contact = teamContacts.firstWhere(
(c) => c['id'] == contactId,
orElse: () => {'avatar': null},
Widget _buildContent(ThemeData theme) {
if (_isInitializing) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
'Initialisation du chat administrateur...',
style: theme.textTheme.bodyLarge,
),
],
),
);
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': ''},
if (_initError != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Erreur d\'initialisation chat',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.error,
),
),
const SizedBox(height: 8),
Text(
_initError!,
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _initializeChat,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.error,
foregroundColor: Colors.white,
),
),
],
),
);
return contact['email'] ?? '';
}
return '';
if (_isChatInitialized) {
// Afficher le module chat avec un header simplifié
return Column(
children: [
// En-tête simplifié avec boutons intégrés
Container(
height: 60,
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
color: Colors.red.shade50,
border: Border(
bottom: BorderSide(
color: theme.dividerColor.withOpacity(0.1),
width: 1,
),
),
),
child: Row(
children: [
Icon(
Icons.admin_panel_settings,
color: Colors.red.shade600,
size: 24,
),
const SizedBox(width: 12),
Text(
'Messages Administration',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.red.shade600,
),
),
const Spacer(),
// Boutons d'action
IconButton(
icon: Icon(Icons.add, color: Colors.red.shade600),
onPressed: () {
// Déclencher la création d'une nouvelle conversation
// Cela sera géré par RoomsPageEmbedded
_roomsPageKey?.currentState?.createNewConversation();
},
tooltip: 'Nouvelle conversation',
),
IconButton(
icon: Icon(Icons.refresh, color: Colors.red.shade600),
onPressed: _refreshRooms,
tooltip: 'Actualiser',
),
],
),
),
// Module chat sans AppBar
Expanded(
child: RoomsPageEmbedded(
key: _roomsPageKey,
onRefreshPressed: () {
// Callback optionnel après refresh
debugPrint('Conversations actualisées');
},
),
),
],
);
}
// État initial
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(
'Chat administrateur non initialisé',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _initializeChat,
icon: const Icon(Icons.power_settings_new),
label: const Text('Initialiser le chat'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
],
),
);
}
}
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'dart:math' as math;
@@ -146,13 +147,25 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
// Ajouter les éléments de base
for (final item in _baseNavigationItems) {
destinations.add(
NavigationDestination(
icon: Icon(item.icon),
selectedIcon: Icon(item.selectedIcon),
label: item.label,
),
);
// Utiliser createBadgedNavigationDestination pour les messages
if (item.label == 'Messages') {
destinations.add(
createBadgedNavigationDestination(
icon: Icon(item.icon),
selectedIcon: Icon(item.selectedIcon),
label: item.label,
showBadge: true,
),
);
} else {
destinations.add(
NavigationDestination(
icon: Icon(item.icon),
selectedIcon: Icon(item.selectedIcon),
label: item.label,
),
);
}
}
// Ajouter les éléments admin si l'utilisateur a le rôle requis
@@ -165,13 +178,25 @@ class _AdminDashboardPageState extends State<AdminDashboardPage> with WidgetsBin
}
if (item.requiredRole == null || item.requiredRole == 2) {
destinations.add(
NavigationDestination(
icon: Icon(item.icon),
selectedIcon: Icon(item.selectedIcon),
label: item.label,
),
);
// Utiliser createBadgedNavigationDestination pour les messages
if (item.label == 'Messages') {
destinations.add(
createBadgedNavigationDestination(
icon: Icon(item.icon),
selectedIcon: Icon(item.selectedIcon),
label: item.label,
showBadge: true,
),
);
} else {
destinations.add(
NavigationDestination(
icon: Icon(item.icon),
selectedIcon: Icon(item.selectedIcon),
label: item.label,
),
);
}
}
}
}

View File

@@ -3981,6 +3981,8 @@ class _AdminMapPageState extends State<AdminMapPage> {
initialZoom: _currentZoom,
mapController: _mapController,
disableDrag: _isDraggingPoint,
// Utiliser OpenStreetMap temporairement sur mobile si Mapbox échoue
useOpenStreetMap: !kIsWeb, // true sur mobile, false sur web
labelMarkers: _buildSectorLabels(),
markers: [
..._buildMarkers(),

View File

@@ -12,6 +12,7 @@ import 'package:geosector_app/presentation/widgets/custom_text_field.dart';
import 'package:geosector_app/presentation/widgets/connectivity_indicator.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
import 'package:geosector_app/core/services/hive_service.dart'; // Pour vérifier l'initialisation
class LoginPage extends StatefulWidget {
final String? loginType;
@@ -58,7 +59,7 @@ class _LoginPageState extends State<LoginPage> {
// État de la connexion Internet
bool _isConnected = false;
bool _isConnected = true; // Par défaut, on suppose qu'il y a une connexion
Future<void> _getAppVersion() async {
try {
@@ -95,7 +96,28 @@ class _LoginPageState extends State<LoginPage> {
void initState() {
super.initState();
// Vérification du type de connexion
// VÉRIFICATION CRITIQUE : S'assurer que Hive est initialisé
// Cette vérification DOIT se faire avant tout accès aux repositories
if (!HiveService.instance.areBoxesInitialized()) {
debugPrint('⚠️ LoginPage: Boxes Hive non initialisées, redirection vers SplashPage');
// Construire les paramètres pour la redirection après initialisation
final loginType = widget.loginType ?? 'admin';
// Rediriger immédiatement vers SplashPage avec les bons paramètres
// pour que SplashPage puisse rediriger automatiquement après l'initialisation
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
context.go('/?action=login&type=$loginType');
}
});
// Initialiser avec des valeurs par défaut pour éviter les erreurs
_loginType = '';
return; // IMPORTANT : Arrêter l'exécution du reste de initState
}
// Vérification du type de connexion (seulement si Hive est initialisé)
if (widget.loginType == null) {
// Si aucun type n'est spécifié, naviguer vers la splash page
print(
@@ -191,7 +213,14 @@ class _LoginPageState extends State<LoginPage> {
// 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();
// Vérifier à nouveau que les boxes sont disponibles
if (!HiveService.instance.areBoxesInitialized()) {
debugPrint('⚠️ Boxes non disponibles pour pré-remplir le username');
return;
}
try {
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)
@@ -236,6 +265,10 @@ class _LoginPageState extends State<LoginPage> {
'Le rôle ($roleValue) ne correspond pas au type de login ($_loginType), champ username non pré-rempli');
}
}
} catch (e) {
debugPrint('Erreur lors du pré-remplissage: $e');
// Continuer sans pré-remplir en cas d'erreur
}
});
}

View File

@@ -27,10 +27,12 @@ class _SectorDialogState extends State<SectorDialog> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _nameFocusNode = FocusNode();
final _searchController = TextEditingController();
Color _selectedColor = Colors.blue;
final List<int> _selectedMemberIds = [];
bool _isLoading = false;
bool _membersLoaded = false;
String _searchQuery = '';
@override
void initState() {
@@ -108,6 +110,7 @@ class _SectorDialogState extends State<SectorDialog> {
void dispose() {
_nameController.dispose();
_nameFocusNode.dispose();
_searchController.dispose();
super.dispose();
}
@@ -162,69 +165,111 @@ class _SectorDialogState extends State<SectorDialog> {
}
void _showColorPicker() {
// Liste de couleurs prédéfinies
final List<Color> colors = [
Colors.red,
Colors.pink,
Colors.purple,
Colors.deepPurple,
Colors.indigo,
Colors.blue,
Colors.lightBlue,
Colors.cyan,
Colors.teal,
Colors.green,
Colors.lightGreen,
Colors.lime,
Colors.yellow,
Colors.amber,
Colors.orange,
Colors.deepOrange,
Colors.brown,
Colors.grey,
Colors.blueGrey,
const Color(0xFF1E88E5), // Bleu personnalisé
const Color(0xFF43A047), // Vert personnalisé
const Color(0xFFE53935), // Rouge personnalisé
const Color(0xFFFFB300), // Ambre personnalisé
const Color(0xFF8E24AA), // Violet personnalisé
];
// Grille 6x6 de couleurs suivant le spectre
// 6 colonnes: Rouge, Orange, Jaune, Vert, Bleu, Violet
// 6 lignes: variations de luminosité/saturation
final List<Color> colors = _generateSpectralColorGrid();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Choisir une couleur'),
contentPadding: const EdgeInsets.fromLTRB(20, 12, 20, 20),
content: Container(
width: double.maxFinite,
child: GridView.builder(
shrinkWrap: true,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
),
itemCount: colors.length,
itemBuilder: (context, index) {
final color = colors[index];
return InkWell(
onTap: () {
setState(() {
_selectedColor = color;
});
Navigator.of(context).pop();
},
child: Container(
decoration: BoxDecoration(
color: color,
border: Border.all(
color: _selectedColor == color ? Colors.black : Colors.grey,
width: _selectedColor == color ? 3 : 1,
width: 280, // Largeur fixe pour contrôler la taille
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
color: Colors.grey.shade50,
),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 6,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
childAspectRatio: 1.0,
),
itemCount: colors.length,
itemBuilder: (context, index) {
final color = colors[index];
final isSelected = _selectedColor.value == color.value;
return InkWell(
onTap: () {
setState(() {
_selectedColor = color;
});
Navigator.of(context).pop();
},
child: Container(
width: 35,
height: 35,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: isSelected ? Colors.black87 : Colors.grey.shade400,
width: isSelected ? 2.5 : 0.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: Colors.black.withOpacity(0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
]
: null,
),
child: isSelected
? const Icon(
Icons.check,
color: Colors.white,
size: 18,
shadows: [
Shadow(
color: Colors.black,
blurRadius: 2,
),
],
)
: null,
),
);
},
),
),
const SizedBox(height: 12),
// Affichage de la couleur sélectionnée
Container(
height: 40,
decoration: BoxDecoration(
color: _selectedColor,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.grey.shade400),
),
child: Center(
child: Text(
'Couleur sélectionnée',
style: TextStyle(
color: _selectedColor.computeLuminance() > 0.5
? Colors.black87
: Colors.white,
fontWeight: FontWeight.w500,
fontSize: 13,
),
borderRadius: BorderRadius.circular(8),
),
),
);
},
),
],
),
),
actions: [
@@ -237,19 +282,107 @@ class _SectorDialogState extends State<SectorDialog> {
);
}
// Mettre en surbrillance les termes recherchés dans le texte
List<TextSpan> _highlightSearchTerms(String text) {
if (_searchQuery.isEmpty) {
return [TextSpan(text: text)];
}
final List<TextSpan> spans = [];
final lowerText = text.toLowerCase();
int start = 0;
int index = lowerText.indexOf(_searchQuery, start);
while (index != -1) {
// Ajouter le texte avant le terme trouvé
if (index > start) {
spans.add(TextSpan(
text: text.substring(start, index),
));
}
// Ajouter le terme trouvé en surbrillance
spans.add(TextSpan(
text: text.substring(index, index + _searchQuery.length),
style: const TextStyle(
backgroundColor: Colors.yellow,
fontWeight: FontWeight.bold,
),
));
start = index + _searchQuery.length;
index = lowerText.indexOf(_searchQuery, start);
}
// Ajouter le reste du texte
if (start < text.length) {
spans.add(TextSpan(
text: text.substring(start),
));
}
return spans;
}
// Générer une grille 6x6 de couleurs spectrales
List<Color> _generateSpectralColorGrid() {
final List<Color> colors = [];
// 6 teintes de base (colonnes)
final List<double> hues = [
0, // Rouge
30, // Orange
60, // Jaune
120, // Vert
210, // Bleu
270, // Violet
];
// 6 variations de luminosité/saturation (lignes)
// Du plus clair au plus foncé
final List<Map<String, double>> variations = [
{'saturation': 0.3, 'lightness': 0.85}, // Très clair
{'saturation': 0.5, 'lightness': 0.70}, // Clair
{'saturation': 0.7, 'lightness': 0.55}, // Moyen clair
{'saturation': 0.85, 'lightness': 0.45}, // Moyen foncé
{'saturation': 0.95, 'lightness': 0.35}, // Foncé
{'saturation': 1.0, 'lightness': 0.25}, // Très foncé
];
// Générer la grille ligne par ligne
for (final variation in variations) {
for (final hue in hues) {
colors.add(
HSLColor.fromAHSL(
1.0,
hue,
variation['saturation']!,
variation['lightness']!,
).toColor(),
);
}
}
return colors;
}
@override
Widget build(BuildContext context) {
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
final screenHeight = MediaQuery.of(context).size.height;
final dialogHeight = (screenHeight * 0.8).clamp(0.0, 800.0); // 80% de l'écran avec max 800px
return AlertDialog(
title: Text(widget.existingSector == null ? 'Nouveau secteur' : 'Modifier le secteur'),
content: SingleChildScrollView(
content: Container(
width: 450, // Largeur fixe pour la dialog
height: dialogHeight, // Hauteur avec maximum de 800px
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section scrollable pour nom et couleur
// Nom du secteur
TextFormField(
controller: _nameController,
@@ -329,6 +462,37 @@ class _SectorDialogState extends State<SectorDialog> {
],
),
const SizedBox(height: 10),
// Champ de recherche pour filtrer les membres
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher par prénom, nom ou nom de tournée...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_searchController.clear();
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onChanged: (value) {
setState(() {
_searchQuery = value.toLowerCase();
});
},
),
const SizedBox(height: 10),
if (_selectedMemberIds.isEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
@@ -341,62 +505,111 @@ class _SectorDialogState extends State<SectorDialog> {
),
),
),
// Liste des membres avec scrolling et filtre
if (currentAmicale != null)
ValueListenableBuilder<Box<MembreModel>>(
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
builder: (context, box, _) {
debugPrint('=== Build liste membres - IDs présélectionnés: $_selectedMemberIds ===');
final membres = box.values
.where((m) => m.fkEntite == currentAmicale.id)
.toList();
if (membres.isEmpty) {
return const Center(
child: Text('Aucun membre disponible'),
);
}
return Container(
constraints: const BoxConstraints(maxHeight: 200),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: ListView.builder(
shrinkWrap: true,
itemCount: membres.length,
itemBuilder: (context, index) {
final membre = membres[index];
final isSelected = _selectedMemberIds.contains(membre.id);
Expanded(
child: ValueListenableBuilder<Box<MembreModel>>(
valueListenable: Hive.box<MembreModel>(AppKeys.membresBoxName).listenable(),
builder: (context, box, _) {
debugPrint('=== Build liste membres - IDs présélectionnés: $_selectedMemberIds ===');
// Filtrer les membres de l'amicale
var membres = box.values
.where((m) => m.fkEntite == currentAmicale.id)
.toList();
// Appliquer le filtre de recherche
if (_searchQuery.isNotEmpty) {
membres = membres.where((membre) {
final firstName = membre.firstName?.toLowerCase() ?? '';
final lastName = membre.name?.toLowerCase() ?? '';
final sectName = membre.sectName?.toLowerCase() ?? '';
// Log pour debug
if (index < 3) { // Limiter les logs aux 3 premiers membres
debugPrint('Membre ${index}: ${membre.firstName} ${membre.name} (ID: ${membre.id}) - isSelected: $isSelected');
}
return CheckboxListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 0.0),
title: Text(
'${membre.firstName} ${membre.name}${membre.sectName != null && membre.sectName!.isNotEmpty ? ' (${membre.sectName})' : ''}',
style: const TextStyle(fontSize: 14),
return firstName.contains(_searchQuery) ||
lastName.contains(_searchQuery) ||
sectName.contains(_searchQuery);
}).toList();
}
if (membres.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(20.0),
child: Text(
_searchQuery.isNotEmpty
? 'Aucun membre trouvé pour "$_searchQuery"'
: 'Aucun membre disponible',
style: TextStyle(
color: Colors.grey[600],
fontSize: 14,
),
),
value: isSelected,
onChanged: (bool? value) {
setState(() {
if (value == true) {
_selectedMemberIds.add(membre.id);
} else {
_selectedMemberIds.remove(membre.id);
}
});
},
);
},
),
);
},
),
);
}
// Afficher le nombre de résultats
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Text(
'${membres.length} membre${membres.length > 1 ? 's' : ''} ${_searchQuery.isNotEmpty ? 'trouvé${membres.length > 1 ? 's' : ''}' : 'disponible${membres.length > 1 ? 's' : ''}'}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[700],
),
),
),
Expanded(
child: Container(
decoration: BoxDecoration(
border: Border.all(color: Colors.grey),
borderRadius: BorderRadius.circular(8),
),
child: ListView.builder(
itemCount: membres.length,
itemBuilder: (context, index) {
final membre = membres[index];
final isSelected = _selectedMemberIds.contains(membre.id);
// Log pour debug
if (index < 3) { // Limiter les logs aux 3 premiers membres
debugPrint('Membre ${index}: ${membre.firstName} ${membre.name} (ID: ${membre.id}) - isSelected: $isSelected');
}
return CheckboxListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 0.0),
title: RichText(
text: TextSpan(
style: const TextStyle(fontSize: 14, color: Colors.black87),
children: _highlightSearchTerms(
'${membre.firstName} ${membre.name}${membre.sectName != null && membre.sectName!.isNotEmpty ? ' (${membre.sectName})' : ''}',
),
),
),
value: isSelected,
onChanged: (bool? value) {
setState(() {
if (value == true) {
_selectedMemberIds.add(membre.id);
} else {
_selectedMemberIds.remove(membre.id);
}
});
},
);
},
),
),
),
],
);
},
),
),
],
),

View File

@@ -1,10 +1,9 @@
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';
import 'package:geosector_app/chat/chat_module.dart';
import 'package:geosector_app/core/services/current_user_service.dart';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
class UserCommunicationPage extends StatefulWidget {
const UserCommunicationPage({super.key});
@@ -14,24 +13,54 @@ class UserCommunicationPage extends StatefulWidget {
}
class _UserCommunicationPageState extends State<UserCommunicationPage> {
String? _selectedConversationId;
late Box<ConversationModel> _conversationsBox;
bool _hasConversations = false;
bool _isChatInitialized = false;
bool _isInitializing = false;
String? _initError;
@override
void initState() {
super.initState();
_checkConversations();
_initializeChat();
}
Future<void> _checkConversations() async {
Future<void> _initializeChat() async {
if (_isInitializing) return;
setState(() {
_isInitializing = true;
_initError = null;
});
try {
_conversationsBox = Hive.box<ConversationModel>(AppKeys.chatConversationsBoxName);
// Récupérer les informations utilisateur
final currentUser = CurrentUserService.instance;
final apiService = ApiService.instance;
final currentAmicale = CurrentAmicaleService.instance.currentAmicale;
if (currentUser.currentUser == null) {
throw Exception('Utilisateur non connecté');
}
// Initialiser le module chat avec les informations de l'utilisateur
await ChatModule.init(
apiUrl: apiService.baseUrl,
userId: currentUser.currentUser!.id,
userName: currentUser.userName ?? currentUser.userEmail ?? 'Utilisateur',
userRole: currentUser.currentUser!.role,
userEntite: currentUser.fkEntite ?? currentAmicale?.id,
authToken: currentUser.sessionId,
);
setState(() {
_hasConversations = _conversationsBox.values.isNotEmpty;
_isChatInitialized = true;
_isInitializing = false;
});
} catch (e) {
debugPrint('Erreur lors de la vérification des conversations: $e');
setState(() {
_initError = e.toString();
_isInitializing = false;
});
debugPrint('Erreur initialisation chat: $e');
}
}
@@ -57,131 +86,69 @@ class _UserCommunicationPageState extends State<UserCommunicationPage> {
),
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),
),
],
),
child: _buildContent(theme),
),
),
);
}
Widget _buildEmptyState(ThemeData theme) {
Widget _buildContent(ThemeData theme) {
if (_isInitializing) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(
'Initialisation du chat...',
style: theme.textTheme.bodyLarge,
),
],
),
);
}
if (_initError != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Erreur d\'initialisation',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.colorScheme.error,
),
),
const SizedBox(height: 8),
Text(
_initError!,
style: theme.textTheme.bodyMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _initializeChat,
icon: const Icon(Icons.refresh),
label: const Text('Réessayer'),
),
],
),
);
}
if (_isChatInitialized) {
// Afficher directement le module chat
return ChatModule.getRoomsPage();
}
// État initial
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -193,70 +160,24 @@ class _UserCommunicationPageState extends State<UserCommunicationPage> {
),
const SizedBox(height: 24),
Text(
'Sélectionnez une conversation',
'Chat non initialisé',
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),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _initializeChat,
child: const Text('Initialiser le chat'),
),
],
),
);
}
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),
),
),
],
),
);
@override
void dispose() {
// Ne pas disposer le chat ici car il est partagé
super.dispose();
}
}
}

View File

@@ -4,6 +4,7 @@ import 'package:hive_flutter/hive_flutter.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/presentation/widgets/dashboard_layout.dart';
import 'package:geosector_app/presentation/widgets/passages/passage_form.dart';
import 'package:geosector_app/presentation/widgets/badged_navigation_destination.dart';
// Import des pages utilisateur
import 'user_dashboard_home_page.dart';
@@ -11,6 +12,7 @@ import 'user_statistics_page.dart';
import 'user_history_page.dart';
import 'user_communication_page.dart';
import 'user_map_page.dart';
import 'user_field_mode_page.dart';
class UserDashboardPage extends StatefulWidget {
const UserDashboardPage({super.key});
@@ -37,6 +39,7 @@ class _UserDashboardPageState extends State<UserDashboardPage> {
const UserHistoryPage(),
const UserCommunicationPage(),
const UserMapPage(),
const UserFieldModePage(),
];
// Initialiser et charger les paramètres
@@ -140,32 +143,38 @@ class _UserDashboardPageState extends State<UserDashboardPage> {
_saveSettings(); // Sauvegarder l'index de page sélectionné
});
},
destinations: const [
NavigationDestination(
destinations: [
const NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Tableau de bord',
),
NavigationDestination(
const NavigationDestination(
icon: Icon(Icons.bar_chart_outlined),
selectedIcon: Icon(Icons.bar_chart),
label: 'Stats',
),
NavigationDestination(
const NavigationDestination(
icon: Icon(Icons.history_outlined),
selectedIcon: Icon(Icons.history),
label: 'Historique',
),
NavigationDestination(
icon: Icon(Icons.chat_outlined),
selectedIcon: Icon(Icons.chat),
createBadgedNavigationDestination(
icon: const Icon(Icons.chat_outlined),
selectedIcon: const Icon(Icons.chat),
label: 'Messages',
showBadge: true,
),
NavigationDestination(
const NavigationDestination(
icon: Icon(Icons.map_outlined),
selectedIcon: Icon(Icons.map),
label: 'Carte',
),
const NavigationDestination(
icon: Icon(Icons.explore_outlined),
selectedIcon: Icon(Icons.explore),
label: 'Terrain',
),
],
onNewPassagePressed: () => _showPassageForm(context),
body: _pages[_selectedIndex],

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:hive_flutter/hive_flutter.dart';
@@ -600,6 +601,8 @@ class _UserMapPageState extends State<UserMapPage> {
initialPosition: _currentPosition,
initialZoom: _currentZoom,
mapController: _mapController,
// Utiliser OpenStreetMap sur mobile, Mapbox sur web
useOpenStreetMap: !kIsWeb,
markers: _buildPassageMarkers(),
polygons: _buildSectorPolygons(),
showControls: true,

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/chat/services/chat_info_service.dart';
/// Fonction helper pour créer une NavigationDestination avec badge
NavigationDestination createBadgedNavigationDestination({
required Icon icon,
required Icon selectedIcon,
required String label,
bool showBadge = false,
}) {
if (!showBadge) {
return NavigationDestination(
icon: icon,
selectedIcon: selectedIcon,
label: label,
);
}
// Créer les icônes avec badge
final badgedIcon = BadgedIcon(
icon: icon.icon!,
showBadge: true,
);
final badgedSelectedIcon = BadgedIcon(
icon: selectedIcon.icon!,
showBadge: true,
);
return NavigationDestination(
icon: badgedIcon,
selectedIcon: badgedSelectedIcon,
label: label,
);
}
/// Widget pour afficher un badge sur une icône
class BadgedIcon extends StatelessWidget {
final IconData icon;
final bool showBadge;
final Color? color;
final double? size;
const BadgedIcon({
super.key,
required this.icon,
this.showBadge = false,
this.color,
this.size,
});
@override
Widget build(BuildContext context) {
final iconWidget = Icon(icon, color: color, size: size);
if (!showBadge) {
return iconWidget;
}
return AnimatedBuilder(
animation: ChatInfoService.instance,
builder: (context, _) {
final unreadCount = ChatInfoService.instance.unreadMessages;
final badgeLabel = ChatInfoService.instance.badgeLabel;
if (unreadCount == 0) {
return iconWidget;
}
return Badge(
label: Text(
badgeLabel,
style: const TextStyle(fontSize: 10),
),
backgroundColor: Colors.red,
textColor: Colors.white,
child: iconWidget,
);
},
);
}
}

View File

@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map_cache/flutter_map_cache.dart';
import 'package:http_cache_file_store/http_cache_file_store.dart';
@@ -46,6 +47,9 @@ class MapboxMap extends StatefulWidget {
/// Désactive le drag de la carte
final bool disableDrag;
/// Utiliser OpenStreetMap au lieu de Mapbox (en cas de problème de token)
final bool useOpenStreetMap;
const MapboxMap({
super.key,
@@ -60,6 +64,7 @@ class MapboxMap extends StatefulWidget {
this.showControls = true,
this.mapStyle,
this.disableDrag = false,
this.useOpenStreetMap = false,
});
@override
@@ -70,7 +75,8 @@ class _MapboxMapState extends State<MapboxMap> {
/// Contrôleur de carte interne
late final MapController _mapController;
/// Niveau de zoom actuel
/// Niveau de zoom actuel (utilisé pour l'affichage futur)
// ignore: unused_field
double _currentZoom = 13.0;
/// Provider de cache pour les tuiles
@@ -91,7 +97,9 @@ class _MapboxMapState extends State<MapboxMap> {
Future<void> _initializeCache() async {
try {
final dir = await getTemporaryDirectory();
final cacheStore = FileCacheStore('${dir.path}${Platform.pathSeparator}MapboxTileCache');
// Utiliser un nom de cache différent selon le provider
final cacheName = widget.useOpenStreetMap ? 'OSMTileCache' : 'MapboxTileCache';
final cacheStore = FileCacheStore('${dir.path}${Platform.pathSeparator}$cacheName');
_tileProvider = CachedTileProvider(
store: cacheStore,
@@ -105,12 +113,14 @@ class _MapboxMapState extends State<MapboxMap> {
_cacheInitialized = true;
});
}
debugPrint('MapboxMap: Cache initialisé avec succès pour ${widget.useOpenStreetMap ? "OpenStreetMap" : "Mapbox"}');
} catch (e) {
debugPrint('Erreur lors de l\'initialisation du cache: $e');
debugPrint('MapboxMap: Erreur lors de l\'initialisation du cache: $e');
// En cas d'erreur, on continue sans cache
if (mounted) {
setState(() {
_cacheInitialized = true;
_tileProvider = null; // Utiliser NetworkTileProvider en fallback
});
}
}
@@ -138,7 +148,7 @@ class _MapboxMapState extends State<MapboxMap> {
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 6,
offset: const Offset(0, 3),
),
@@ -155,19 +165,41 @@ class _MapboxMapState extends State<MapboxMap> {
@override
Widget build(BuildContext context) {
// Déterminer l'URL du template de tuiles Mapbox
// Utiliser l'environnement actuel pour obtenir la bonne clé API
final String environment = ApiService.instance.getCurrentEnvironment();
final String mapboxToken = AppKeys.getMapboxApiKey(environment);
final String mapStyle = widget.mapStyle ?? 'mapbox/streets-v11';
final String urlTemplate = 'https://api.mapbox.com/styles/v1/$mapStyle/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
String urlTemplate;
if (widget.useOpenStreetMap) {
// Utiliser OpenStreetMap comme alternative
urlTemplate = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
debugPrint('MapboxMap: Utilisation d\'OpenStreetMap');
} else {
// Déterminer l'URL du template de tuiles Mapbox
// Utiliser l'environnement actuel pour obtenir la bonne clé API
final String environment = ApiService.instance.getCurrentEnvironment();
final String mapboxToken = AppKeys.getMapboxApiKey(environment);
// Essayer différentes API Mapbox selon la plateforme
if (kIsWeb) {
// Sur web, on peut utiliser l'API styles
urlTemplate = 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/256/{z}/{x}/{y}@2x?access_token=$mapboxToken';
} else {
// Sur mobile, utiliser l'API v4 qui fonctionne mieux avec les tokens standards
// Format: mapbox.streets pour les rues, mapbox.satellite pour satellite
urlTemplate = 'https://api.tiles.mapbox.com/v4/mapbox.streets/{z}/{x}/{y}@2x.png?access_token=$mapboxToken';
}
// Debug pour vérifier la configuration
debugPrint('MapboxMap: Plateforme: ${kIsWeb ? "Web" : "Mobile"}');
debugPrint('MapboxMap: Environnement: $environment');
debugPrint('MapboxMap: Token: ${mapboxToken.substring(0, 10)}...'); // Afficher seulement le début du token
debugPrint('MapboxMap: URL Template: ${urlTemplate.substring(0, 50)}...');
}
// Afficher un indicateur pendant l'initialisation du cache
if (!_cacheInitialized) {
return Stack(
children: [
// Carte sans cache en attendant
_buildMapContent(urlTemplate, mapboxToken),
_buildMapContent(urlTemplate),
// Indicateur discret
const Positioned(
top: 8,
@@ -194,10 +226,10 @@ class _MapboxMapState extends State<MapboxMap> {
);
}
return _buildMapContent(urlTemplate, mapboxToken);
return _buildMapContent(urlTemplate);
}
Widget _buildMapContent(String urlTemplate, String mapboxToken) {
Widget _buildMapContent(String urlTemplate) {
return Stack(
children: [
// Carte principale
@@ -232,13 +264,24 @@ class _MapboxMapState extends State<MapboxMap> {
urlTemplate: urlTemplate,
userAgentPackageName: 'app.geosector.fr',
maxNativeZoom: 19,
additionalOptions: {
'accessToken': mapboxToken,
},
// Utilise le cache si disponible
maxZoom: 20,
minZoom: 1,
// Retirer tileSize pour utiliser la valeur par défaut
// Les additionalOptions ne sont pas nécessaires car le token est dans l'URL
// Utilise le cache si disponible sur web, NetworkTileProvider sur mobile
tileProvider: _cacheInitialized && _tileProvider != null
? _tileProvider!
: NetworkTileProvider(),
: NetworkTileProvider(
headers: {
'User-Agent': 'geosector_app/3.1.3',
'Accept': '*/*',
},
),
errorTileCallback: (tile, error, stackTrace) {
debugPrint('MapboxMap: Erreur de chargement de tuile: $error');
debugPrint('MapboxMap: Coordonnées de la tuile: ${tile.coordinates}');
debugPrint('MapboxMap: Stack trace: $stackTrace');
},
),
// Polygones