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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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