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 4f7247eb2d
commit 3443277d4a
185 changed files with 109354 additions and 102937 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();
}
}