Initialisation du projet geosector complet (web + flutter)
This commit is contained in:
82
flutt/lib/chat/README.md
Normal file
82
flutt/lib/chat/README.md
Normal 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
flutt/lib/chat/chat.dart
Normal file
35
flutt/lib/chat/chat.dart
Normal 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';
|
||||
510
flutt/lib/chat/chat_updated.md
Normal file
510
flutt/lib/chat/chat_updated.md
Normal 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.
|
||||
50
flutt/lib/chat/constants/chat_constants.dart
Normal file
50
flutt/lib/chat/constants/chat_constants.dart
Normal 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);
|
||||
}
|
||||
166
flutt/lib/chat/example_integration/mqtt_integration_example.dart
Normal file
166
flutt/lib/chat/example_integration/mqtt_integration_example.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
104
flutt/lib/chat/models/anonymous_user_model.dart
Normal file
104
flutt/lib/chat/models/anonymous_user_model.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
59
flutt/lib/chat/models/anonymous_user_model.g.dart
Normal file
59
flutt/lib/chat/models/anonymous_user_model.g.dart
Normal 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;
|
||||
}
|
||||
138
flutt/lib/chat/models/audience_target_model.dart
Normal file
138
flutt/lib/chat/models/audience_target_model.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
59
flutt/lib/chat/models/audience_target_model.g.dart
Normal file
59
flutt/lib/chat/models/audience_target_model.g.dart
Normal 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;
|
||||
}
|
||||
15
flutt/lib/chat/models/chat_adapters.dart
Normal file
15
flutt/lib/chat/models/chat_adapters.dart
Normal 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
|
||||
}
|
||||
104
flutt/lib/chat/models/chat_config.dart
Normal file
104
flutt/lib/chat/models/chat_config.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
139
flutt/lib/chat/models/conversation_model.dart
Normal file
139
flutt/lib/chat/models/conversation_model.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
68
flutt/lib/chat/models/conversation_model.g.dart
Normal file
68
flutt/lib/chat/models/conversation_model.g.dart
Normal 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;
|
||||
}
|
||||
140
flutt/lib/chat/models/message_model.dart
Normal file
140
flutt/lib/chat/models/message_model.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
71
flutt/lib/chat/models/message_model.g.dart
Normal file
71
flutt/lib/chat/models/message_model.g.dart
Normal 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;
|
||||
}
|
||||
160
flutt/lib/chat/models/notification_settings.dart
Normal file
160
flutt/lib/chat/models/notification_settings.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
68
flutt/lib/chat/models/notification_settings.g.dart
Normal file
68
flutt/lib/chat/models/notification_settings.g.dart
Normal 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;
|
||||
}
|
||||
118
flutt/lib/chat/models/participant_model.dart
Normal file
118
flutt/lib/chat/models/participant_model.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
65
flutt/lib/chat/models/participant_model.g.dart
Normal file
65
flutt/lib/chat/models/participant_model.g.dart
Normal 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;
|
||||
}
|
||||
79
flutt/lib/chat/pages/chat_page.dart
Normal file
79
flutt/lib/chat/pages/chat_page.dart
Normal 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
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
364
flutt/lib/chat/repositories/chat_repository.dart
Normal file
364
flutt/lib/chat/repositories/chat_repository.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
213
flutt/lib/chat/scripts/chat_tables.sql
Normal file
213
flutt/lib/chat/scripts/chat_tables.sql
Normal 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);
|
||||
323
flutt/lib/chat/scripts/mqtt_notification_sender.php
Normal file
323
flutt/lib/chat/scripts/mqtt_notification_sender.php
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
263
flutt/lib/chat/scripts/send_notification.php
Normal file
263
flutt/lib/chat/scripts/send_notification.php
Normal 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);
|
||||
}
|
||||
}
|
||||
97
flutt/lib/chat/services/chat_api_service.dart
Normal file
97
flutt/lib/chat/services/chat_api_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
214
flutt/lib/chat/services/notifications/README_MQTT.md
Normal file
214
flutt/lib/chat/services/notifications/README_MQTT.md
Normal 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
|
||||
@@ -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}');
|
||||
}
|
||||
74
flutt/lib/chat/services/notifications/mqtt_config.dart
Normal file
74
flutt/lib/chat/services/notifications/mqtt_config.dart
Normal 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
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
46
flutt/lib/chat/services/offline_queue_service.dart
Normal file
46
flutt/lib/chat/services/offline_queue_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
98
flutt/lib/chat/widgets/chat_input.dart
Normal file
98
flutt/lib/chat/widgets/chat_input.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
80
flutt/lib/chat/widgets/chat_screen.dart
Normal file
80
flutt/lib/chat/widgets/chat_screen.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
78
flutt/lib/chat/widgets/conversations_list.dart
Normal file
78
flutt/lib/chat/widgets/conversations_list.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
69
flutt/lib/chat/widgets/message_bubble.dart
Normal file
69
flutt/lib/chat/widgets/message_bubble.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
159
flutt/lib/chat/widgets/notification_settings_widget.dart
Normal file
159
flutt/lib/chat/widgets/notification_settings_widget.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user