feat: Release version 3.1.4 - Mode terrain et génération PDF
✨ Nouvelles fonctionnalités: - Ajout du mode terrain pour utilisation mobile hors connexion - Génération automatique de reçus PDF avec template personnalisé - Révision complète du système de cartes avec amélioration des performances 🔧 Améliorations techniques: - Refactoring du module chat avec architecture simplifiée - Optimisation du système de sécurité NIST SP 800-63B - Amélioration de la gestion des secteurs géographiques - Support UTF-8 étendu pour les noms d'utilisateurs 📱 Application mobile: - Nouveau mode terrain dans user_field_mode_page - Interface utilisateur adaptative pour conditions difficiles - Synchronisation offline améliorée 🗺️ Cartographie: - Optimisation des performances MapBox - Meilleure gestion des tuiles hors ligne - Amélioration de l'affichage des secteurs 📄 Documentation: - Ajout guide Android (ANDROID-GUIDE.md) - Documentation sécurité API (API-SECURITY.md) - Guide module chat (CHAT_MODULE.md) 🐛 Corrections: - Résolution des erreurs 400 lors de la création d'utilisateurs - Correction de la validation des noms d'utilisateurs - Fix des problèmes de synchronisation chat 🤖 Generated with Claude Code (https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,107 +0,0 @@
|
||||
/// Service API pour la communication avec le backend du chat
|
||||
///
|
||||
/// Ce service gère toutes les requêtes HTTP vers l'API chat
|
||||
library;
|
||||
|
||||
class ChatApiService {
|
||||
final String baseUrl;
|
||||
final String? authToken;
|
||||
|
||||
ChatApiService({
|
||||
required this.baseUrl,
|
||||
this.authToken,
|
||||
});
|
||||
|
||||
/// Récupère les conversations
|
||||
Future<Map<String, dynamic>> fetchConversations() async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Récupère les messages d'une conversation
|
||||
Future<Map<String, dynamic>> fetchMessages(String conversationId,
|
||||
{int page = 1, int limit = 50}) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Crée une nouvelle conversation
|
||||
Future<Map<String, dynamic>> createConversation(
|
||||
Map<String, dynamic> data) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Envoie un message
|
||||
Future<Map<String, dynamic>> sendMessage(
|
||||
String conversationId, Map<String, dynamic> messageData) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Marque un message comme lu
|
||||
Future<Map<String, dynamic>> markMessageAsRead(String messageId) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Ajoute un participant
|
||||
Future<Map<String, dynamic>> addParticipant(
|
||||
String conversationId, Map<String, dynamic> participantData) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Retire un participant
|
||||
Future<Map<String, dynamic>> removeParticipant(
|
||||
String conversationId, String participantId) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Crée un utilisateur anonyme
|
||||
Future<Map<String, dynamic>> createAnonymousUser(
|
||||
{String? name, String? email}) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Récupère les annonces
|
||||
Future<Map<String, dynamic>> fetchAnnouncements() async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Crée une annonce
|
||||
Future<Map<String, dynamic>> createAnnouncement(
|
||||
Map<String, dynamic> data) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Récupère les statistiques d'une annonce
|
||||
Future<Map<String, dynamic>> fetchAnnouncementStats(
|
||||
String conversationId) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Récupère les cibles d'audience disponibles
|
||||
Future<Map<String, dynamic>> fetchAvailableAudienceTargets() async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Met à jour une conversation
|
||||
Future<Map<String, dynamic>> updateConversation(
|
||||
String id, Map<String, dynamic> data) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Supprime une conversation
|
||||
Future<void> deleteConversation(String id) async {
|
||||
// TODO: Implémenter la requête HTTP
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
238
app/lib/chat/services/chat_config_loader.dart
Normal file
238
app/lib/chat/services/chat_config_loader.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:yaml/yaml.dart';
|
||||
|
||||
/// Classe pour charger et gérer la configuration du chat depuis chat_config.yaml
|
||||
class ChatConfigLoader {
|
||||
static ChatConfigLoader? _instance;
|
||||
static ChatConfigLoader get instance => _instance ??= ChatConfigLoader._();
|
||||
|
||||
Map<String, dynamic>? _config;
|
||||
|
||||
ChatConfigLoader._();
|
||||
|
||||
/// Charger la configuration depuis le fichier YAML
|
||||
Future<void> loadConfig() async {
|
||||
try {
|
||||
// Charger le fichier YAML
|
||||
final yamlString = await rootBundle.loadString('lib/chat/chat_config.yaml');
|
||||
|
||||
// Vérifier que le contenu n'est pas vide
|
||||
if (yamlString.isEmpty) {
|
||||
print('Fichier de configuration chat vide, utilisation de la configuration par défaut');
|
||||
_config = _getDefaultConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parser le YAML
|
||||
dynamic yamlMap;
|
||||
try {
|
||||
yamlMap = loadYaml(yamlString);
|
||||
} catch (parseError) {
|
||||
print('Erreur de parsing YAML (utilisation de la config par défaut): $parseError');
|
||||
print('Contenu YAML problématique (premiers 500 caractères): ${yamlString.substring(0, yamlString.length > 500 ? 500 : yamlString.length)}');
|
||||
_config = _getDefaultConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
// Convertir en Map<String, dynamic>
|
||||
_config = _convertYamlToMap(yamlMap);
|
||||
print('Configuration chat chargée avec succès');
|
||||
} catch (e) {
|
||||
print('Erreur lors du chargement de la configuration chat: $e');
|
||||
// Utiliser une configuration par défaut en cas d'erreur
|
||||
_config = _getDefaultConfig();
|
||||
}
|
||||
}
|
||||
|
||||
/// Convertir YamlMap en Map standard
|
||||
dynamic _convertYamlToMap(dynamic yamlData) {
|
||||
if (yamlData is YamlMap) {
|
||||
final map = <String, dynamic>{};
|
||||
yamlData.forEach((key, value) {
|
||||
map[key.toString()] = _convertYamlToMap(value);
|
||||
});
|
||||
return map;
|
||||
} else if (yamlData is YamlList) {
|
||||
return yamlData.map((item) => _convertYamlToMap(item)).toList();
|
||||
} else {
|
||||
return yamlData;
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtenir les permissions pour un rôle
|
||||
Map<String, dynamic> getPermissionsForRole(int role) {
|
||||
if (_config == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
final permissions = _config!['chat_permissions'] as Map<String, dynamic>?;
|
||||
if (permissions == null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return permissions['role_$role'] as Map<String, dynamic>? ?? {};
|
||||
}
|
||||
|
||||
/// Vérifier si un utilisateur peut envoyer un message à un autre
|
||||
bool canSendMessageTo({
|
||||
required int senderRole,
|
||||
required int recipientRole,
|
||||
int? senderEntite,
|
||||
int? recipientEntite,
|
||||
}) {
|
||||
final permissions = getPermissionsForRole(senderRole);
|
||||
final canMessageWith = permissions['can_message_with'] as List<dynamic>?;
|
||||
|
||||
if (canMessageWith == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (final rule in canMessageWith) {
|
||||
if (rule['role'] == recipientRole) {
|
||||
final condition = rule['condition'] as String?;
|
||||
|
||||
switch (condition) {
|
||||
case 'same_entite':
|
||||
return senderEntite != null &&
|
||||
recipientEntite != null &&
|
||||
senderEntite == recipientEntite;
|
||||
case 'all':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Obtenir les destinataires possibles pour un rôle
|
||||
List<Map<String, dynamic>> getPossibleRecipientsConfig(int role) {
|
||||
final permissions = getPermissionsForRole(role);
|
||||
final canMessageWith = permissions['can_message_with'] as List<dynamic>?;
|
||||
|
||||
if (canMessageWith == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return canMessageWith.map((rule) {
|
||||
return {
|
||||
'role': rule['role'],
|
||||
'condition': rule['condition'],
|
||||
'description': rule['description'],
|
||||
'allow_selection': rule['allow_selection'] ?? false,
|
||||
'allow_broadcast': rule['allow_broadcast'] ?? false,
|
||||
};
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Obtenir le nom d'un rôle
|
||||
String getRoleName(int role) {
|
||||
final permissions = getPermissionsForRole(role);
|
||||
return permissions['name'] as String? ?? 'Utilisateur';
|
||||
}
|
||||
|
||||
/// Obtenir la description d'un rôle
|
||||
String getRoleDescription(int role) {
|
||||
final permissions = getPermissionsForRole(role);
|
||||
return permissions['description'] as String? ?? '';
|
||||
}
|
||||
|
||||
/// Obtenir le texte d'aide pour un rôle
|
||||
String getHelpText(int role) {
|
||||
final permissions = getPermissionsForRole(role);
|
||||
return permissions['help_text'] as String? ?? '';
|
||||
}
|
||||
|
||||
/// Vérifier si un rôle peut créer des groupes
|
||||
bool canCreateGroup(int role) {
|
||||
final permissions = getPermissionsForRole(role);
|
||||
return permissions['can_create_group'] as bool? ?? false;
|
||||
}
|
||||
|
||||
/// Vérifier si un rôle peut faire du broadcast
|
||||
bool canBroadcast(int role) {
|
||||
final permissions = getPermissionsForRole(role);
|
||||
return permissions['can_broadcast'] as bool? ?? false;
|
||||
}
|
||||
|
||||
/// Obtenir la configuration UI
|
||||
Map<String, dynamic> getUIConfig() {
|
||||
return _config?['ui_config'] as Map<String, dynamic>? ?? {};
|
||||
}
|
||||
|
||||
/// Obtenir les messages de l'interface
|
||||
Map<String, dynamic> getUIMessages() {
|
||||
final uiConfig = getUIConfig();
|
||||
return uiConfig['messages'] as Map<String, dynamic>? ?? {};
|
||||
}
|
||||
|
||||
/// Obtenir la couleur d'un rôle
|
||||
String getRoleColor(int role) {
|
||||
final uiConfig = getUIConfig();
|
||||
final roleColors = uiConfig['role_colors'] as Map<String, dynamic>?;
|
||||
return roleColors?[role.toString()] as String? ?? '#64748B';
|
||||
}
|
||||
|
||||
/// Obtenir les informations du module
|
||||
Map<String, dynamic> getModuleInfo() {
|
||||
return _config?['module_info'] as Map<String, dynamic>? ?? {
|
||||
'version': '1.0.0',
|
||||
'name': 'Chat Module Light',
|
||||
'description': 'Module de chat autonome et portable pour GEOSECTOR'
|
||||
};
|
||||
}
|
||||
|
||||
/// Obtenir la version du module
|
||||
String getModuleVersion() {
|
||||
final moduleInfo = getModuleInfo();
|
||||
return moduleInfo['version'] as String? ?? '1.0.0';
|
||||
}
|
||||
|
||||
/// Configuration par défaut si le fichier YAML n'est pas trouvé
|
||||
Map<String, dynamic> _getDefaultConfig() {
|
||||
return {
|
||||
'chat_permissions': {
|
||||
'role_1': {
|
||||
'name': 'Membre',
|
||||
'can_message_with': [
|
||||
{'role': 1, 'condition': 'same_entite'},
|
||||
{'role': 2, 'condition': 'same_entite'},
|
||||
],
|
||||
'can_create_group': false,
|
||||
'can_broadcast': false,
|
||||
'help_text': 'Vous pouvez discuter avec les membres de votre amicale',
|
||||
},
|
||||
'role_2': {
|
||||
'name': 'Admin Amicale',
|
||||
'can_message_with': [
|
||||
{'role': 1, 'condition': 'same_entite'},
|
||||
{'role': 2, 'condition': 'same_entite'},
|
||||
{'role': 9, 'condition': 'all'},
|
||||
],
|
||||
'can_create_group': true,
|
||||
'can_broadcast': false,
|
||||
'help_text': 'Vous pouvez discuter avec les membres et les super admins',
|
||||
},
|
||||
'role_9': {
|
||||
'name': 'Super Admin',
|
||||
'can_message_with': [
|
||||
{'role': 2, 'condition': 'all', 'allow_selection': true, 'allow_broadcast': true},
|
||||
],
|
||||
'can_create_group': true,
|
||||
'can_broadcast': true,
|
||||
'help_text': 'Vous pouvez envoyer des messages aux administrateurs d\'amicale',
|
||||
},
|
||||
},
|
||||
'ui_config': {
|
||||
'show_role_badge': true,
|
||||
'enable_autocomplete': true,
|
||||
'messages': {
|
||||
'no_permission': 'Vous n\'avez pas la permission',
|
||||
'search_placeholder': 'Rechercher...',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
112
app/lib/chat/services/chat_info_service.dart
Normal file
112
app/lib/chat/services/chat_info_service.dart
Normal file
@@ -0,0 +1,112 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Service pour gérer les informations globales du chat (badges, stats)
|
||||
/// Récupère les infos depuis l'API au login et les maintient à jour
|
||||
class ChatInfoService extends ChangeNotifier {
|
||||
static ChatInfoService? _instance;
|
||||
static ChatInfoService get instance => _instance ??= ChatInfoService._();
|
||||
|
||||
ChatInfoService._();
|
||||
|
||||
// Stats du chat
|
||||
int _totalRooms = 0;
|
||||
int _unreadMessages = 0;
|
||||
DateTime? _lastUpdate;
|
||||
|
||||
// Getters
|
||||
int get totalRooms => _totalRooms;
|
||||
int get unreadMessages => _unreadMessages;
|
||||
DateTime? get lastUpdate => _lastUpdate;
|
||||
bool get hasUnread => _unreadMessages > 0;
|
||||
|
||||
/// Met à jour les infos depuis la réponse de login
|
||||
/// Attend une structure : { "chat": { "total_rooms": 5, "unread_messages": 12 } }
|
||||
void updateFromLogin(Map<String, dynamic> loginData) {
|
||||
debugPrint('📊 ChatInfoService: Mise à jour depuis login');
|
||||
|
||||
final chatData = loginData['chat'];
|
||||
if (chatData != null && chatData is Map<String, dynamic>) {
|
||||
_totalRooms = chatData['total_rooms'] ?? 0;
|
||||
_unreadMessages = chatData['unread_messages'] ?? 0;
|
||||
_lastUpdate = DateTime.now();
|
||||
|
||||
debugPrint('💬 Chat stats - Rooms: $_totalRooms, Non lus: $_unreadMessages');
|
||||
notifyListeners();
|
||||
} else {
|
||||
debugPrint('⚠️ Pas de données chat dans la réponse login');
|
||||
}
|
||||
}
|
||||
|
||||
/// Met à jour directement les stats
|
||||
void updateStats({int? totalRooms, int? unreadMessages}) {
|
||||
bool hasChanged = false;
|
||||
|
||||
if (totalRooms != null && totalRooms != _totalRooms) {
|
||||
_totalRooms = totalRooms;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
if (unreadMessages != null && unreadMessages != _unreadMessages) {
|
||||
_unreadMessages = unreadMessages;
|
||||
hasChanged = true;
|
||||
}
|
||||
|
||||
if (hasChanged) {
|
||||
_lastUpdate = DateTime.now();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Décrémente le nombre de messages non lus
|
||||
void decrementUnread(int count) {
|
||||
if (count > 0) {
|
||||
_unreadMessages = (_unreadMessages - count).clamp(0, 999);
|
||||
_lastUpdate = DateTime.now();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Marque tous les messages d'une room comme lus
|
||||
void markRoomAsRead(int messagesCount) {
|
||||
if (messagesCount > 0) {
|
||||
decrementUnread(messagesCount);
|
||||
}
|
||||
}
|
||||
|
||||
/// Incrémente le nombre de messages non lus (nouveau message reçu)
|
||||
void incrementUnread(int count) {
|
||||
if (count > 0) {
|
||||
_unreadMessages = (_unreadMessages + count).clamp(0, 999);
|
||||
_lastUpdate = DateTime.now();
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Réinitialise les stats (au logout)
|
||||
void reset() {
|
||||
_totalRooms = 0;
|
||||
_unreadMessages = 0;
|
||||
_lastUpdate = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Force un refresh des stats depuis l'API
|
||||
Future<void> refreshFromApi() async {
|
||||
// Cette méthode pourrait appeler directement l'API
|
||||
// Pour l'instant on la laisse vide, elle sera utile plus tard
|
||||
debugPrint('📊 ChatInfoService: Refresh depuis API demandé');
|
||||
}
|
||||
|
||||
/// Retourne un label formaté pour le badge
|
||||
String get badgeLabel {
|
||||
if (_unreadMessages == 0) return '';
|
||||
if (_unreadMessages > 99) return '99+';
|
||||
return _unreadMessages.toString();
|
||||
}
|
||||
|
||||
/// Debug info
|
||||
@override
|
||||
String toString() {
|
||||
return 'ChatInfoService(rooms: $_totalRooms, unread: $_unreadMessages, lastUpdate: $_lastUpdate)';
|
||||
}
|
||||
}
|
||||
468
app/lib/chat/services/chat_service.dart
Normal file
468
app/lib/chat/services/chat_service.dart
Normal file
@@ -0,0 +1,468 @@
|
||||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import '../models/room.dart';
|
||||
import '../models/message.dart';
|
||||
import 'chat_config_loader.dart';
|
||||
import 'chat_info_service.dart';
|
||||
import 'package:geosector_app/core/constants/app_keys.dart';
|
||||
|
||||
/// Service de chat avec règles métier configurables via YAML
|
||||
/// Les permissions sont définies dans chat_config.yaml
|
||||
class ChatService {
|
||||
static ChatService? _instance;
|
||||
static ChatService get instance => _instance!;
|
||||
|
||||
late final Dio _dio;
|
||||
late final Box<Room> _roomsBox;
|
||||
late final Box<Message> _messagesBox;
|
||||
|
||||
late int _currentUserId;
|
||||
late String _currentUserName;
|
||||
late int _currentUserRole;
|
||||
late int? _currentUserEntite;
|
||||
String? _authToken;
|
||||
|
||||
Timer? _syncTimer;
|
||||
|
||||
/// Initialisation avec gestion des rôles et configuration YAML
|
||||
static Future<void> init({
|
||||
required String apiUrl,
|
||||
required int userId,
|
||||
required String userName,
|
||||
required int userRole,
|
||||
int? userEntite,
|
||||
String? authToken,
|
||||
}) async {
|
||||
_instance = ChatService._();
|
||||
|
||||
// Charger la configuration depuis le YAML
|
||||
await ChatConfigLoader.instance.loadConfig();
|
||||
|
||||
// Initialiser Hive
|
||||
await Hive.initFlutter();
|
||||
Hive.registerAdapter(RoomAdapter());
|
||||
Hive.registerAdapter(MessageAdapter());
|
||||
|
||||
// Ouvrir les boxes en utilisant les constantes centralisées
|
||||
_instance!._roomsBox = await Hive.openBox<Room>(AppKeys.chatRoomsBoxName);
|
||||
_instance!._messagesBox = await Hive.openBox<Message>(AppKeys.chatMessagesBoxName);
|
||||
|
||||
// Configurer l'utilisateur
|
||||
_instance!._currentUserId = userId;
|
||||
_instance!._currentUserName = userName;
|
||||
_instance!._currentUserRole = userRole;
|
||||
_instance!._currentUserEntite = userEntite;
|
||||
_instance!._authToken = authToken;
|
||||
|
||||
// Configurer Dio
|
||||
_instance!._dio = Dio(BaseOptions(
|
||||
baseUrl: apiUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
headers: authToken != null ? {'Authorization': 'Bearer $authToken'} : {},
|
||||
));
|
||||
|
||||
// Démarrer la synchronisation
|
||||
_instance!._startSync();
|
||||
}
|
||||
|
||||
ChatService._();
|
||||
|
||||
/// Obtenir les destinataires possibles selon le rôle
|
||||
Future<List<Map<String, dynamic>>> getPossibleRecipients({String? search}) async {
|
||||
try {
|
||||
// L'API utilise le token pour identifier l'utilisateur et son rôle
|
||||
String endpoint = '/chat/recipients';
|
||||
final params = <String, dynamic>{};
|
||||
|
||||
if (search != null && search.isNotEmpty) {
|
||||
params['search'] = search;
|
||||
}
|
||||
|
||||
final response = await _dio.get(endpoint, queryParameters: params);
|
||||
|
||||
// Gérer différents formats de réponse
|
||||
if (response.data is List) {
|
||||
return List<Map<String, dynamic>>.from(response.data);
|
||||
} else if (response.data is Map && response.data['recipients'] != null) {
|
||||
return List<Map<String, dynamic>>.from(response.data['recipients']);
|
||||
} else if (response.data is Map && response.data['data'] != null) {
|
||||
return List<Map<String, dynamic>>.from(response.data['data']);
|
||||
} else {
|
||||
print('⚠️ Format inattendu pour /chat/recipients: ${response.data.runtimeType}');
|
||||
return [];
|
||||
}
|
||||
} catch (e) {
|
||||
print('⚠️ Erreur getPossibleRecipients: $e');
|
||||
// Fallback sur logique locale selon le rôle
|
||||
return _getLocalRecipients();
|
||||
}
|
||||
}
|
||||
|
||||
/// Logique locale de récupération des destinataires
|
||||
List<Map<String, dynamic>> _getLocalRecipients() {
|
||||
// Cette méthode devrait accéder à la box membres de l'app principale
|
||||
// Pour l'instant on retourne une liste vide
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Vérifier si l'utilisateur peut créer une conversation avec un destinataire
|
||||
bool canCreateConversationWith(int recipientRole, {int? recipientEntite}) {
|
||||
return ChatConfigLoader.instance.canSendMessageTo(
|
||||
senderRole: _currentUserRole,
|
||||
recipientRole: recipientRole,
|
||||
senderEntite: _currentUserEntite,
|
||||
recipientEntite: recipientEntite,
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtenir les rooms filtrées selon les permissions
|
||||
Future<List<Room>> getRooms() async {
|
||||
try {
|
||||
// L'API filtre automatiquement selon le token Bearer
|
||||
final response = await _dio.get('/chat/rooms');
|
||||
|
||||
// Debug : afficher le type et le contenu de la réponse
|
||||
print('📊 Type de réponse /chat/rooms: ${response.data.runtimeType}');
|
||||
if (response.data is Map) {
|
||||
print('📊 Clés de la réponse: ${(response.data as Map).keys.toList()}');
|
||||
}
|
||||
|
||||
// Gérer différents formats de réponse API
|
||||
List<dynamic> roomsData;
|
||||
if (response.data is Map) {
|
||||
// La plupart du temps, l'API retourne un objet avec status et rooms
|
||||
if (response.data['rooms'] != null) {
|
||||
roomsData = response.data['rooms'] as List;
|
||||
print('✅ Réponse API: status=${response.data['status']}, ${roomsData.length} rooms');
|
||||
} else if (response.data['data'] != null) {
|
||||
roomsData = response.data['data'] as List;
|
||||
print('✅ Réponse avec propriété "data" (${roomsData.length} rooms)');
|
||||
} else {
|
||||
// Pas de propriété rooms ou data, liste vide
|
||||
print('⚠️ Réponse sans rooms ni data: ${response.data}');
|
||||
roomsData = [];
|
||||
}
|
||||
} else if (response.data is List) {
|
||||
// Si c'est directement une liste (moins courant)
|
||||
roomsData = response.data as List;
|
||||
print('✅ Réponse est directement une liste avec ${roomsData.length} rooms');
|
||||
} else {
|
||||
// Format complètement inattendu
|
||||
print('⚠️ Format de réponse inattendu pour /chat/rooms: ${response.data.runtimeType}');
|
||||
roomsData = [];
|
||||
}
|
||||
|
||||
final rooms = roomsData
|
||||
.map((json) => Room.fromJson(json))
|
||||
.toList();
|
||||
|
||||
// Sauvegarder dans Hive
|
||||
await _roomsBox.clear();
|
||||
for (final room in rooms) {
|
||||
await _roomsBox.put(room.id, room);
|
||||
}
|
||||
|
||||
// Mettre à jour les stats globales
|
||||
final totalUnread = rooms.fold<int>(0, (sum, room) => sum + room.unreadCount);
|
||||
ChatInfoService.instance.updateStats(
|
||||
totalRooms: rooms.length,
|
||||
unreadMessages: totalUnread,
|
||||
);
|
||||
|
||||
return rooms;
|
||||
} catch (e) {
|
||||
print('Erreur lors de la récupération des rooms (utilisation du cache): $e');
|
||||
// Fallback sur le cache local en cas d'erreur API (404, etc.)
|
||||
return _roomsBox.values.toList()
|
||||
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
|
||||
.compareTo(a.lastMessageAt ?? a.createdAt));
|
||||
}
|
||||
}
|
||||
|
||||
/// Créer une room avec vérification des permissions
|
||||
Future<Room?> createRoom({
|
||||
required String title,
|
||||
required List<int> participantIds,
|
||||
String? type,
|
||||
String? initialMessage,
|
||||
}) async {
|
||||
try {
|
||||
// Vérifier les permissions localement d'abord
|
||||
// L'API fera aussi une vérification
|
||||
|
||||
final data = {
|
||||
'title': title,
|
||||
'type': type ?? (_currentUserRole == 9 ? 'broadcast' : 'private'),
|
||||
'participants': participantIds,
|
||||
// L'API récupère le rôle et l'entité depuis le token
|
||||
};
|
||||
|
||||
// Ajouter le message initial s'il est fourni
|
||||
if (initialMessage != null && initialMessage.isNotEmpty) {
|
||||
data['initial_message'] = initialMessage;
|
||||
}
|
||||
|
||||
final response = await _dio.post('/chat/rooms', data: data);
|
||||
|
||||
final room = Room.fromJson(response.data);
|
||||
await _roomsBox.put(room.id, room);
|
||||
|
||||
return room;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Créer une conversation one-to-one
|
||||
Future<Room?> createPrivateRoom({
|
||||
required int recipientId,
|
||||
required String recipientName,
|
||||
required int recipientRole,
|
||||
int? recipientEntite,
|
||||
String? initialMessage,
|
||||
}) async {
|
||||
// Vérifier les permissions via la configuration
|
||||
if (!canCreateConversationWith(recipientRole, recipientEntite: recipientEntite)) {
|
||||
final messages = ChatConfigLoader.instance.getUIMessages();
|
||||
throw Exception(messages['no_permission'] ?? 'Permission refusée');
|
||||
}
|
||||
|
||||
return createRoom(
|
||||
title: recipientName,
|
||||
participantIds: [recipientId],
|
||||
type: 'private',
|
||||
initialMessage: initialMessage,
|
||||
);
|
||||
}
|
||||
|
||||
/// Créer une conversation de groupe (pour superadmin)
|
||||
Future<Room?> createGroupRoom({
|
||||
required String title,
|
||||
required List<int> adminIds,
|
||||
String? initialMessage,
|
||||
}) async {
|
||||
if (_currentUserRole != 9) {
|
||||
throw Exception('Seuls les superadmins peuvent créer des groupes');
|
||||
}
|
||||
|
||||
return createRoom(
|
||||
title: title,
|
||||
participantIds: adminIds,
|
||||
type: 'broadcast',
|
||||
initialMessage: initialMessage,
|
||||
);
|
||||
}
|
||||
|
||||
/// Obtenir les messages d'une room avec pagination
|
||||
Future<Map<String, dynamic>> getMessages(String roomId, {String? beforeMessageId}) async {
|
||||
try {
|
||||
final params = <String, dynamic>{
|
||||
'limit': 50,
|
||||
};
|
||||
|
||||
if (beforeMessageId != null) {
|
||||
params['before'] = beforeMessageId;
|
||||
}
|
||||
|
||||
final response = await _dio.get('/chat/rooms/$roomId/messages', queryParameters: params);
|
||||
|
||||
// Gérer différents formats de réponse
|
||||
List<dynamic> messagesData;
|
||||
bool hasMore = false;
|
||||
|
||||
if (response.data is List) {
|
||||
// Si c'est directement une liste de messages
|
||||
messagesData = response.data as List;
|
||||
} else if (response.data is Map) {
|
||||
// Si c'est un objet avec messages et has_more
|
||||
messagesData = response.data['messages'] ?? response.data['data'] ?? [];
|
||||
hasMore = response.data['has_more'] ?? false;
|
||||
} else {
|
||||
print('⚠️ Format inattendu pour les messages: ${response.data.runtimeType}');
|
||||
messagesData = [];
|
||||
}
|
||||
|
||||
final messages = messagesData
|
||||
.map((json) => Message.fromJson(json, _currentUserId))
|
||||
.toList();
|
||||
|
||||
// Sauvegarder dans Hive (en limitant à 100 messages par room)
|
||||
await _saveMessagesToCache(roomId, messages);
|
||||
|
||||
return {
|
||||
'messages': messages,
|
||||
'has_more': hasMore,
|
||||
};
|
||||
} catch (e) {
|
||||
print('Erreur getMessages: $e');
|
||||
// Fallback sur le cache local
|
||||
final cachedMessages = _messagesBox.values
|
||||
.where((m) => m.roomId == roomId)
|
||||
.toList()
|
||||
..sort((a, b) => a.sentAt.compareTo(b.sentAt));
|
||||
|
||||
return {
|
||||
'messages': cachedMessages,
|
||||
'has_more': false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauvegarder les messages dans le cache en limitant à 100 par room
|
||||
Future<void> _saveMessagesToCache(String roomId, List<Message> newMessages) async {
|
||||
// Obtenir tous les messages existants pour cette room
|
||||
final existingMessages = _messagesBox.values
|
||||
.where((m) => m.roomId == roomId)
|
||||
.toList()
|
||||
..sort((a, b) => b.sentAt.compareTo(a.sentAt)); // Plus récent en premier
|
||||
|
||||
// Ajouter les nouveaux messages
|
||||
for (final message in newMessages) {
|
||||
await _messagesBox.put(message.id, message);
|
||||
}
|
||||
|
||||
// Si on dépasse 100 messages, supprimer les plus anciens
|
||||
final allMessages = [...existingMessages, ...newMessages]
|
||||
..sort((a, b) => b.sentAt.compareTo(a.sentAt));
|
||||
|
||||
if (allMessages.length > 100) {
|
||||
final messagesToDelete = allMessages.skip(100).toList();
|
||||
for (final message in messagesToDelete) {
|
||||
await _messagesBox.delete(message.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoyer un message
|
||||
Future<Message?> sendMessage(String roomId, String content) async {
|
||||
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
final tempMessage = Message(
|
||||
id: tempId,
|
||||
roomId: roomId,
|
||||
content: content,
|
||||
senderId: _currentUserId,
|
||||
senderName: _currentUserName,
|
||||
sentAt: DateTime.now(),
|
||||
isMe: true,
|
||||
);
|
||||
|
||||
await _messagesBox.put(tempId, tempMessage);
|
||||
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/chat/rooms/$roomId/messages',
|
||||
data: {
|
||||
'content': content,
|
||||
// L'API récupère le sender depuis le token
|
||||
},
|
||||
);
|
||||
|
||||
final finalMessage = Message.fromJson(response.data, _currentUserId);
|
||||
|
||||
await _messagesBox.delete(tempId);
|
||||
await _messagesBox.put(finalMessage.id, finalMessage);
|
||||
|
||||
// Mettre à jour la room
|
||||
final room = _roomsBox.get(roomId);
|
||||
if (room != null) {
|
||||
final updatedRoom = Room(
|
||||
id: room.id,
|
||||
title: room.title,
|
||||
type: room.type,
|
||||
createdAt: room.createdAt,
|
||||
lastMessage: content,
|
||||
lastMessageAt: DateTime.now(),
|
||||
unreadCount: 0,
|
||||
);
|
||||
await _roomsBox.put(roomId, updatedRoom);
|
||||
}
|
||||
|
||||
return finalMessage;
|
||||
} catch (e) {
|
||||
return tempMessage;
|
||||
}
|
||||
}
|
||||
|
||||
/// Marquer comme lu
|
||||
Future<void> markAsRead(String roomId) async {
|
||||
try {
|
||||
await _dio.post('/chat/rooms/$roomId/read');
|
||||
|
||||
final room = _roomsBox.get(roomId);
|
||||
if (room != null) {
|
||||
// Décrémenter les messages non lus dans ChatInfoService
|
||||
if (room.unreadCount > 0) {
|
||||
ChatInfoService.instance.decrementUnread(room.unreadCount);
|
||||
}
|
||||
|
||||
final updatedRoom = Room(
|
||||
id: room.id,
|
||||
title: room.title,
|
||||
type: room.type,
|
||||
createdAt: room.createdAt,
|
||||
lastMessage: room.lastMessage,
|
||||
lastMessageAt: room.lastMessageAt,
|
||||
unreadCount: 0,
|
||||
);
|
||||
await _roomsBox.put(roomId, updatedRoom);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignorer
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronisation périodique
|
||||
void _startSync() {
|
||||
_syncTimer?.cancel();
|
||||
_syncTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
getRooms();
|
||||
});
|
||||
}
|
||||
|
||||
/// Nettoyer les ressources
|
||||
void dispose() {
|
||||
_syncTimer?.cancel();
|
||||
_dio.close();
|
||||
}
|
||||
|
||||
// Getters
|
||||
Box<Room> get roomsBox => _roomsBox;
|
||||
Box<Message> get messagesBox => _messagesBox;
|
||||
int get currentUserId => _currentUserId;
|
||||
|
||||
/// Obtenir le rôle de l'utilisateur actuel
|
||||
int getUserRole() => _currentUserRole;
|
||||
int get currentUserRole => _currentUserRole;
|
||||
String get currentUserName => _currentUserName;
|
||||
|
||||
/// Obtenir le label du rôle
|
||||
String getRoleLabel(int role) {
|
||||
switch (role) {
|
||||
case 1:
|
||||
return 'Membre';
|
||||
case 2:
|
||||
return 'Admin Amicale';
|
||||
case 9:
|
||||
return 'Super Admin';
|
||||
default:
|
||||
return 'Utilisateur';
|
||||
}
|
||||
}
|
||||
|
||||
/// Obtenir la description des permissions
|
||||
String getPermissionsDescription() {
|
||||
switch (_currentUserRole) {
|
||||
case 1:
|
||||
return 'Vous pouvez discuter avec les membres de votre amicale';
|
||||
case 2:
|
||||
return 'Vous pouvez discuter avec les membres et les super admins';
|
||||
case 9:
|
||||
return 'Vous pouvez envoyer des messages aux administrateurs d\'amicale';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
# Notifications MQTT pour le Chat GEOSECTOR
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Ce système de notifications utilise MQTT pour fournir des notifications push en temps réel pour le module chat. Il offre une alternative légère à Firebase Cloud Messaging (FCM) et peut être auto-hébergé dans votre infrastructure.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Composants principaux
|
||||
|
||||
1. **MqttNotificationService** (Flutter)
|
||||
- Service de notification côté client
|
||||
- Gère la connexion au broker MQTT
|
||||
- Traite les messages entrants
|
||||
- Affiche les notifications locales
|
||||
|
||||
2. **MqttConfig** (Flutter)
|
||||
- Configuration centralisée pour MQTT
|
||||
- Gestion des topics
|
||||
- Paramètres de connexion
|
||||
|
||||
3. **MqttNotificationSender** (PHP)
|
||||
- Service backend pour envoyer les notifications
|
||||
- Interface avec la base de données
|
||||
- Gestion des cibles d'audience
|
||||
|
||||
## Configuration du broker MQTT
|
||||
|
||||
### Container Incus
|
||||
|
||||
Le broker MQTT (Eclipse Mosquitto recommandé) doit être installé dans votre container Incus :
|
||||
|
||||
```bash
|
||||
# Installer Mosquitto
|
||||
apt-get update
|
||||
apt-get install mosquitto mosquitto-clients
|
||||
|
||||
# Configurer Mosquitto
|
||||
vi /etc/mosquitto/mosquitto.conf
|
||||
```
|
||||
|
||||
Configuration recommandée :
|
||||
```
|
||||
listener 1883
|
||||
allow_anonymous false
|
||||
password_file /etc/mosquitto/passwd
|
||||
|
||||
# Pour SSL/TLS
|
||||
listener 8883
|
||||
cafile /etc/mosquitto/ca.crt
|
||||
certfile /etc/mosquitto/server.crt
|
||||
keyfile /etc/mosquitto/server.key
|
||||
```
|
||||
|
||||
### Sécurité
|
||||
|
||||
Pour un environnement de production, il est fortement recommandé :
|
||||
|
||||
1. D'utiliser SSL/TLS (port 8883)
|
||||
2. De configurer l'authentification par mot de passe
|
||||
3. De limiter les IPs pouvant se connecter
|
||||
4. De configurer des ACLs pour restreindre l'accès aux topics
|
||||
|
||||
## Structure des topics MQTT
|
||||
|
||||
### Topics utilisateur
|
||||
- `chat/user/{userId}/messages` - Messages personnels pour l'utilisateur
|
||||
- `chat/user/{userId}/groups/{groupId}` - Messages des groupes de l'utilisateur
|
||||
|
||||
### Topics globaux
|
||||
- `chat/announcement` - Annonces générales
|
||||
- `chat/broadcast` - Diffusions à grande échelle
|
||||
|
||||
### Topics conversation
|
||||
- `chat/conversation/{conversationId}` - Messages spécifiques à une conversation
|
||||
|
||||
## Intégration Flutter
|
||||
|
||||
### Dépendances requises
|
||||
|
||||
Ajoutez ces dépendances à votre `pubspec.yaml` :
|
||||
|
||||
```yaml
|
||||
dependencies:
|
||||
mqtt5_client: ^4.0.0 # ou mqtt_client selon votre préférence
|
||||
flutter_local_notifications: ^17.0.0
|
||||
```
|
||||
|
||||
### Initialisation
|
||||
|
||||
```dart
|
||||
// Dans main.dart
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final notificationService = MqttNotificationService();
|
||||
await notificationService.initialize(userId: currentUserId);
|
||||
|
||||
runApp(const GeoSectorApp());
|
||||
}
|
||||
```
|
||||
|
||||
### Utilisation
|
||||
|
||||
```dart
|
||||
// Écouter les messages
|
||||
notificationService.onMessageTap = (messageId) {
|
||||
// Naviguer vers le message
|
||||
Navigator.pushNamed(context, '/chat/$messageId');
|
||||
};
|
||||
|
||||
// Publier un message
|
||||
await notificationService.publishMessage(
|
||||
'chat/user/$userId/messages',
|
||||
{'content': 'Test message'},
|
||||
);
|
||||
```
|
||||
|
||||
## Gestion des notifications
|
||||
|
||||
### Paramètres utilisateur
|
||||
|
||||
Les utilisateurs peuvent configurer :
|
||||
- Activation/désactivation des notifications
|
||||
- Conversations en silencieux
|
||||
- Mode "Ne pas déranger"
|
||||
- Aperçu du contenu
|
||||
|
||||
### Persistance des notifications
|
||||
|
||||
Les notifications sont enregistrées dans la table `chat_notifications` pour :
|
||||
- Traçabilité
|
||||
- Statistiques
|
||||
- Synchronisation
|
||||
|
||||
## Tests
|
||||
|
||||
### Test de connexion
|
||||
|
||||
```dart
|
||||
final service = MqttNotificationService();
|
||||
await service.initialize(userId: 'test_user');
|
||||
// Vérifie les logs pour confirmer la connexion
|
||||
```
|
||||
|
||||
### Test d'envoi
|
||||
|
||||
```php
|
||||
$sender = new MqttNotificationSender($db, $mqttConfig);
|
||||
$result = $sender->sendMessageNotification(
|
||||
'receiver_id',
|
||||
'sender_id',
|
||||
'message_id',
|
||||
'Test message',
|
||||
'conversation_id'
|
||||
);
|
||||
```
|
||||
|
||||
## Surveillance et maintenance
|
||||
|
||||
### Logs
|
||||
|
||||
Les logs sont disponibles dans :
|
||||
- Logs Flutter (console debug)
|
||||
- Logs Mosquitto (`/var/log/mosquitto/mosquitto.log`)
|
||||
- Logs PHP (selon configuration)
|
||||
|
||||
### Métriques à surveiller
|
||||
|
||||
- Nombre de connexions actives
|
||||
- Latence des messages
|
||||
- Taux d'échec des notifications
|
||||
- Consommation mémoire/CPU du broker
|
||||
|
||||
## Comparaison avec Firebase
|
||||
|
||||
### Avantages MQTT
|
||||
|
||||
1. **Auto-hébergé** : Contrôle total de l'infrastructure
|
||||
2. **Léger** : Moins de ressources que Firebase
|
||||
3. **Coût** : Gratuit (uniquement coûts d'infrastructure)
|
||||
4. **Personnalisable** : Configuration fine du broker
|
||||
|
||||
### Inconvénients
|
||||
|
||||
1. **Maintenance** : Nécessite une gestion du broker
|
||||
2. **Évolutivité** : Requiert dimensionnement et clustering
|
||||
3. **Fonctionnalités** : Moins de services intégrés que Firebase
|
||||
|
||||
## Évolutions futures
|
||||
|
||||
1. **WebSocket** : Ajout optionnel pour temps réel strict
|
||||
2. **Clustering** : Pour haute disponibilité
|
||||
3. **Analytics** : Dashboard de monitoring
|
||||
4. **Webhooks** : Intégration avec d'autres services
|
||||
|
||||
## Dépannage
|
||||
|
||||
### Problèmes courants
|
||||
|
||||
1. **Connexion échouée**
|
||||
- Vérifier username/password
|
||||
- Vérifier port/hostname
|
||||
- Vérifier firewall
|
||||
|
||||
2. **Messages non reçus**
|
||||
- Vérifier abonnement aux topics
|
||||
- Vérifier QoS
|
||||
- Vérifier paramètres notifications
|
||||
|
||||
3. **Performance dégradée**
|
||||
- Augmenter keepAlive
|
||||
- Ajuster reconnectInterval
|
||||
- Vérifier charge serveur
|
||||
@@ -1,205 +0,0 @@
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Service de gestion des notifications chat
|
||||
///
|
||||
/// Gère l'envoi et la réception des notifications pour le module chat
|
||||
|
||||
class ChatNotificationService {
|
||||
static final ChatNotificationService _instance =
|
||||
ChatNotificationService._internal();
|
||||
factory ChatNotificationService() => _instance;
|
||||
ChatNotificationService._internal();
|
||||
|
||||
final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
|
||||
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
// Callback pour les actions sur les notifications
|
||||
Function(String messageId)? onMessageTap;
|
||||
Function(Map<String, dynamic>)? onBackgroundMessage;
|
||||
|
||||
/// Initialise le service de notifications
|
||||
Future<void> initialize() async {
|
||||
// Demander les permissions
|
||||
await _requestPermissions();
|
||||
|
||||
// Initialiser les notifications locales
|
||||
await _initializeLocalNotifications();
|
||||
|
||||
// Configurer les handlers de messages
|
||||
_configureFirebaseHandlers();
|
||||
|
||||
// Obtenir le token du device
|
||||
await _initializeDeviceToken();
|
||||
}
|
||||
|
||||
/// Demande les permissions pour les notifications
|
||||
Future<bool> _requestPermissions() async {
|
||||
NotificationSettings settings = await _firebaseMessaging.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
provisional: false,
|
||||
);
|
||||
|
||||
return settings.authorizationStatus == AuthorizationStatus.authorized;
|
||||
}
|
||||
|
||||
/// Initialise les notifications locales
|
||||
Future<void> _initializeLocalNotifications() async {
|
||||
const AndroidInitializationSettings androidSettings =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
|
||||
final DarwinInitializationSettings iosSettings =
|
||||
DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
onDidReceiveLocalNotification: _onDidReceiveLocalNotification,
|
||||
);
|
||||
|
||||
final InitializationSettings initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
await _localNotifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||
);
|
||||
}
|
||||
|
||||
/// Configure les handlers Firebase
|
||||
void _configureFirebaseHandlers() {
|
||||
// Message reçu quand l'app est au premier plan
|
||||
FirebaseMessaging.onMessage.listen(_onForegroundMessage);
|
||||
|
||||
// Message reçu quand l'app est en arrière-plan
|
||||
FirebaseMessaging.onMessageOpenedApp.listen(_onBackgroundMessageOpened);
|
||||
|
||||
// Handler pour les messages en arrière-plan terminé
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseBackgroundHandler);
|
||||
}
|
||||
|
||||
/// Handler pour les messages reçus au premier plan
|
||||
Future<void> _onForegroundMessage(RemoteMessage message) async {
|
||||
if (message.notification != null) {
|
||||
// Afficher une notification locale
|
||||
await _showLocalNotification(
|
||||
title: message.notification!.title ?? 'Nouveau message',
|
||||
body: message.notification!.body ?? '',
|
||||
payload: message.data['messageId'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour les messages ouverts depuis l'arrière-plan
|
||||
void _onBackgroundMessageOpened(RemoteMessage message) {
|
||||
final messageId = message.data['messageId'];
|
||||
if (messageId != null) {
|
||||
onMessageTap?.call(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche une notification locale
|
||||
Future<void> _showLocalNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
required String payload,
|
||||
}) async {
|
||||
const AndroidNotificationDetails androidDetails =
|
||||
AndroidNotificationDetails(
|
||||
'chat_messages',
|
||||
'Messages de chat',
|
||||
channelDescription: 'Notifications pour les nouveaux messages de chat',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const DarwinNotificationDetails iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const NotificationDetails notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _localNotifications.show(
|
||||
DateTime.now().microsecondsSinceEpoch,
|
||||
title,
|
||||
body,
|
||||
notificationDetails,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
/// Handler pour le clic sur une notification
|
||||
void _onNotificationTap(NotificationResponse response) {
|
||||
final payload = response.payload;
|
||||
if (payload != null) {
|
||||
onMessageTap?.call(payload);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour les notifications iOS reçues au premier plan
|
||||
void _onDidReceiveLocalNotification(
|
||||
int id, String? title, String? body, String? payload) {
|
||||
// Traitement spécifique iOS si nécessaire
|
||||
}
|
||||
|
||||
/// Obtient et stocke le token du device
|
||||
Future<String?> _initializeDeviceToken() async {
|
||||
String? token = await _firebaseMessaging.getToken();
|
||||
// Envoyer le token au serveur pour stocker
|
||||
await _sendTokenToServer(token);
|
||||
|
||||
// Écouter les changements de token
|
||||
_firebaseMessaging.onTokenRefresh.listen(_sendTokenToServer);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/// Envoie le token FCM au serveur
|
||||
Future<void> _sendTokenToServer(String token) async {
|
||||
try {
|
||||
// Appel API pour enregistrer le token
|
||||
// await chatApiService.registerDeviceToken(token);
|
||||
debugPrint('Device token enregistré : $token');
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'enregistrement du token : $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// S'abonner aux notifications pour une conversation
|
||||
Future<void> subscribeToConversation(String conversationId) async {
|
||||
await _firebaseMessaging.subscribeToTopic('chat_$conversationId');
|
||||
}
|
||||
|
||||
/// Se désabonner des notifications pour une conversation
|
||||
Future<void> unsubscribeFromConversation(String conversationId) async {
|
||||
await _firebaseMessaging.unsubscribeFromTopic('chat_$conversationId');
|
||||
}
|
||||
|
||||
/// Désactive temporairement les notifications
|
||||
Future<void> pauseNotifications() async {
|
||||
await _firebaseMessaging.setAutoInitEnabled(false);
|
||||
}
|
||||
|
||||
/// Réactive les notifications
|
||||
Future<void> resumeNotifications() async {
|
||||
await _firebaseMessaging.setAutoInitEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler pour les messages en arrière-plan
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _firebaseBackgroundHandler(RemoteMessage message) async {
|
||||
// Traitement des messages en arrière-plan
|
||||
debugPrint('Message reçu en arrière-plan : ${message.messageId}');
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/// Configuration pour le broker MQTT
|
||||
///
|
||||
/// Centralise les paramètres de connexion au broker MQTT
|
||||
library;
|
||||
|
||||
class MqttConfig {
|
||||
// Configuration du serveur MQTT
|
||||
static const String host = 'mqtt.geosector.fr';
|
||||
static const int port = 1883;
|
||||
static const int securePort = 8883;
|
||||
static const bool useSsl = false;
|
||||
|
||||
// Configuration d'authentification
|
||||
static const String username = 'geosector_chat';
|
||||
static const String password = 'secure_password_here';
|
||||
|
||||
// Préfixes des topics MQTT
|
||||
static const String topicBase = 'chat';
|
||||
static const String topicUserMessages = '$topicBase/user';
|
||||
static const String topicAnnouncements = '$topicBase/announcement';
|
||||
static const String topicGroups = '$topicBase/groups';
|
||||
static const String topicConversations = '$topicBase/conversation';
|
||||
|
||||
// Configuration des sessions
|
||||
static const int keepAliveInterval = 60;
|
||||
static const int reconnectInterval = 5;
|
||||
static const bool cleanSession = true;
|
||||
|
||||
// Configuration des notifications
|
||||
static const int notificationRetryCount = 3;
|
||||
static const Duration notificationTimeout = Duration(seconds: 30);
|
||||
|
||||
/// Génère un client ID unique pour chaque session
|
||||
static String generateClientId(String userId) {
|
||||
return 'chat_${userId}_${DateTime.now().millisecondsSinceEpoch}';
|
||||
}
|
||||
|
||||
/// Retourne l'URL complète du broker selon la configuration SSL
|
||||
static String get brokerUrl {
|
||||
if (useSsl) {
|
||||
return '$host:$securePort';
|
||||
} else {
|
||||
return '$host:$port';
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le topic pour les messages d'un utilisateur
|
||||
static String getUserMessageTopic(String userId) {
|
||||
return '$topicUserMessages/$userId/messages';
|
||||
}
|
||||
|
||||
/// Retourne le topic pour les annonces globales
|
||||
static String getAnnouncementTopic() {
|
||||
return topicAnnouncements;
|
||||
}
|
||||
|
||||
/// Retourne le topic pour une conversation spécifique
|
||||
static String getConversationTopic(String conversationId) {
|
||||
return '$topicConversations/$conversationId';
|
||||
}
|
||||
|
||||
/// Retourne le topic pour un groupe spécifique
|
||||
static String getGroupTopic(String groupId) {
|
||||
return '$topicGroups/$groupId';
|
||||
}
|
||||
|
||||
/// Retourne les topics auxquels un utilisateur doit s'abonner
|
||||
static List<String> getUserSubscriptionTopics(String userId) {
|
||||
return [
|
||||
getUserMessageTopic(userId),
|
||||
getAnnouncementTopic(),
|
||||
// Ajoutez d'autres topics selon les besoins
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:mqtt5_client/mqtt5_client.dart';
|
||||
import 'package:mqtt5_client/mqtt5_server_client.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
/// Service de gestion des notifications chat via MQTT
|
||||
///
|
||||
/// Utilise MQTT pour recevoir des notifications en temps réel
|
||||
/// et afficher des notifications locales
|
||||
|
||||
class MqttNotificationService {
|
||||
static final MqttNotificationService _instance =
|
||||
MqttNotificationService._internal();
|
||||
factory MqttNotificationService() => _instance;
|
||||
MqttNotificationService._internal();
|
||||
|
||||
late MqttServerClient _client;
|
||||
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
// Configuration
|
||||
final String mqttHost;
|
||||
final int mqttPort;
|
||||
final String mqttUsername;
|
||||
final String mqttPassword;
|
||||
final String clientId;
|
||||
|
||||
// État
|
||||
bool _initialized = false;
|
||||
String? _userId;
|
||||
StreamSubscription? _messageSubscription;
|
||||
|
||||
// Callbacks
|
||||
Function(String messageId)? onMessageTap;
|
||||
Function(Map<String, dynamic>)? onNotificationReceived;
|
||||
|
||||
MqttNotificationService({
|
||||
this.mqttHost = 'mqtt.geosector.fr',
|
||||
this.mqttPort = 1883,
|
||||
this.mqttUsername = '',
|
||||
this.mqttPassword = '',
|
||||
String? clientId,
|
||||
}) : clientId = clientId ??
|
||||
'geosector_chat_${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
/// Initialise le service de notifications
|
||||
Future<void> initialize({required String userId}) async {
|
||||
if (_initialized) return;
|
||||
|
||||
_userId = userId;
|
||||
|
||||
// Initialiser les notifications locales
|
||||
await _initializeLocalNotifications();
|
||||
|
||||
// Initialiser le client MQTT
|
||||
await _initializeMqttClient();
|
||||
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Initialise le client MQTT
|
||||
Future<void> _initializeMqttClient() async {
|
||||
try {
|
||||
_client = MqttServerClient.withPort(mqttHost, clientId, mqttPort);
|
||||
|
||||
_client.logging(on: kDebugMode);
|
||||
_client.keepAlivePeriod = 60;
|
||||
_client.onConnected = _onConnected;
|
||||
_client.onDisconnected = _onDisconnected;
|
||||
_client.onSubscribed = _onSubscribed;
|
||||
_client.autoReconnect = true;
|
||||
|
||||
// Configurer les options de connexion
|
||||
final connMessage = MqttConnectMessage()
|
||||
.authenticateAs(mqttUsername, mqttPassword)
|
||||
.withClientIdentifier(clientId)
|
||||
.startClean()
|
||||
.keepAliveFor(60);
|
||||
|
||||
_client.connectionMessage = connMessage;
|
||||
|
||||
// Se connecter
|
||||
await _connect();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors de l\'initialisation MQTT : $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Se connecte au broker MQTT
|
||||
Future<void> _connect() async {
|
||||
try {
|
||||
await _client.connect();
|
||||
} catch (e) {
|
||||
debugPrint('Erreur de connexion MQTT : $e');
|
||||
_client.disconnect();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback lors de la connexion
|
||||
void _onConnected() {
|
||||
debugPrint('Connecté au broker MQTT');
|
||||
|
||||
// S'abonner aux topics de l'utilisateur
|
||||
if (_userId != null) {
|
||||
_subscribeToUserTopics(_userId!);
|
||||
}
|
||||
|
||||
// Écouter les messages
|
||||
_messageSubscription = _client.updates.listen(_onMessageReceived);
|
||||
}
|
||||
|
||||
/// Callback lors de la déconnexion
|
||||
void _onDisconnected() {
|
||||
debugPrint('Déconnecté du broker MQTT');
|
||||
|
||||
// Tenter une reconnexion
|
||||
if (_client.autoReconnect) {
|
||||
Future.delayed(const Duration(seconds: 5), () {
|
||||
_connect();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Callback lors de l'abonnement
|
||||
void _onSubscribed(MqttSubscription subscription) {
|
||||
debugPrint('Abonné au topic : ${subscription.topic.rawTopic}');
|
||||
}
|
||||
|
||||
/// S'abonner aux topics de l'utilisateur
|
||||
void _subscribeToUserTopics(String userId) {
|
||||
// Topic pour les messages personnels
|
||||
_client.subscribe('chat/user/$userId/messages', MqttQos.atLeastOnce);
|
||||
|
||||
// Topic pour les annonces
|
||||
_client.subscribe('chat/announcement', MqttQos.atLeastOnce);
|
||||
|
||||
// Topic pour les groupes de l'utilisateur (si disponibles)
|
||||
_client.subscribe('chat/user/$userId/groups/+', MqttQos.atLeastOnce);
|
||||
}
|
||||
|
||||
/// Gère les messages reçus
|
||||
void _onMessageReceived(List<MqttReceivedMessage<MqttMessage>> messages) {
|
||||
for (var message in messages) {
|
||||
final topic = message.topic;
|
||||
final payload = message.payload as MqttPublishMessage;
|
||||
final messageText =
|
||||
MqttUtilities.bytesToStringAsString(payload.payload.message!);
|
||||
|
||||
try {
|
||||
final data = jsonDecode(messageText) as Map<String, dynamic>;
|
||||
_handleNotification(topic, data);
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du décodage du message : $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Traite la notification reçue
|
||||
Future<void> _handleNotification(
|
||||
String topic, Map<String, dynamic> data) async {
|
||||
// Vérifier les paramètres de notification de l'utilisateur
|
||||
if (!await _shouldShowNotification(data)) {
|
||||
return;
|
||||
}
|
||||
|
||||
String title = '';
|
||||
String body = '';
|
||||
String messageId = '';
|
||||
String conversationId = '';
|
||||
|
||||
if (topic.startsWith('chat/user/')) {
|
||||
// Message personnel
|
||||
title = data['senderName'] ?? 'Nouveau message';
|
||||
body = data['content'] ?? '';
|
||||
messageId = data['messageId'] ?? '';
|
||||
conversationId = data['conversationId'] ?? '';
|
||||
} else if (topic.startsWith('chat/announcement')) {
|
||||
// Annonce
|
||||
title = data['title'] ?? 'Annonce';
|
||||
body = data['content'] ?? '';
|
||||
messageId = data['messageId'] ?? '';
|
||||
conversationId = data['conversationId'] ?? '';
|
||||
}
|
||||
|
||||
// Afficher la notification locale
|
||||
await _showLocalNotification(
|
||||
title: title,
|
||||
body: body,
|
||||
payload: jsonEncode({
|
||||
'messageId': messageId,
|
||||
'conversationId': conversationId,
|
||||
}),
|
||||
);
|
||||
|
||||
// Appeler le callback si défini
|
||||
onNotificationReceived?.call(data);
|
||||
}
|
||||
|
||||
/// Vérifie si la notification doit être affichée
|
||||
Future<bool> _shouldShowNotification(Map<String, dynamic> data) async {
|
||||
// TODO: Vérifier les paramètres de notification de l'utilisateur
|
||||
// - Notifications désactivées
|
||||
// - Conversation en silencieux
|
||||
// - Mode Ne pas déranger
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Initialise les notifications locales
|
||||
Future<void> _initializeLocalNotifications() async {
|
||||
const androidSettings =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: true,
|
||||
requestBadgePermission: true,
|
||||
requestSoundPermission: true,
|
||||
);
|
||||
|
||||
const initSettings = InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
);
|
||||
|
||||
await _localNotifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: _onNotificationTap,
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche une notification locale
|
||||
Future<void> _showLocalNotification({
|
||||
required String title,
|
||||
required String body,
|
||||
required String payload,
|
||||
}) async {
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'chat_messages',
|
||||
'Messages de chat',
|
||||
channelDescription: 'Notifications pour les nouveaux messages de chat',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
);
|
||||
|
||||
const iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
const notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _localNotifications.show(
|
||||
DateTime.now().microsecondsSinceEpoch,
|
||||
title,
|
||||
body,
|
||||
notificationDetails,
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
/// Handler pour le clic sur une notification
|
||||
void _onNotificationTap(NotificationResponse response) {
|
||||
final payload = response.payload;
|
||||
if (payload != null) {
|
||||
try {
|
||||
final data = jsonDecode(payload) as Map<String, dynamic>;
|
||||
final messageId = data['messageId'] as String?;
|
||||
if (messageId != null) {
|
||||
onMessageTap?.call(messageId);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('Erreur lors du traitement du clic sur notification : $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Publie un message MQTT
|
||||
Future<void> publishMessage(
|
||||
String topic, Map<String, dynamic> message) async {
|
||||
if (_client.connectionStatus?.state != MqttConnectionState.connected) {
|
||||
await _connect();
|
||||
}
|
||||
|
||||
final messagePayload = jsonEncode(message);
|
||||
final builder = MqttPayloadBuilder();
|
||||
builder.addString(messagePayload);
|
||||
|
||||
_client.publishMessage(topic, MqttQos.atLeastOnce, builder.payload!);
|
||||
}
|
||||
|
||||
/// S'abonner à une conversation spécifique
|
||||
Future<void> subscribeToConversation(String conversationId) async {
|
||||
if (_client.connectionStatus?.state == MqttConnectionState.connected) {
|
||||
_client.subscribe(
|
||||
'chat/conversation/$conversationId', MqttQos.atLeastOnce);
|
||||
}
|
||||
}
|
||||
|
||||
/// Se désabonner d'une conversation
|
||||
Future<void> unsubscribeFromConversation(String conversationId) async {
|
||||
if (_client.connectionStatus?.state == MqttConnectionState.connected) {
|
||||
_client.unsubscribeStringTopic('chat/conversation/$conversationId');
|
||||
}
|
||||
}
|
||||
|
||||
/// Désactive temporairement les notifications
|
||||
void pauseNotifications() {
|
||||
_client.pause();
|
||||
}
|
||||
|
||||
/// Réactive les notifications
|
||||
void resumeNotifications() {
|
||||
_client.resume();
|
||||
}
|
||||
|
||||
/// Libère les ressources
|
||||
void dispose() {
|
||||
_messageSubscription?.cancel();
|
||||
_client.disconnect();
|
||||
_initialized = false;
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
/// Service de gestion de la file d'attente hors ligne
|
||||
///
|
||||
/// Ce service gère les opérations chat en mode hors ligne
|
||||
/// et les synchronise lorsque la connexion revient
|
||||
library;
|
||||
|
||||
class OfflineQueueService {
|
||||
// TODO: Ajouter le service de connectivité
|
||||
|
||||
OfflineQueueService();
|
||||
|
||||
/// Ajoute une opération en attente
|
||||
Future<void> addPendingOperation(
|
||||
String operationType, Map<String, dynamic> data) async {
|
||||
// TODO: Implémenter l'ajout à la file d'attente
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Traite les opérations en attente
|
||||
Future<void> processPendingOperations() async {
|
||||
// TODO: Implémenter le traitement des opérations
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Écoute les changements de connectivité
|
||||
void listenToConnectivityChanges() {
|
||||
// TODO: Implémenter l'écoute des changements
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Vérifie si une opération est en file d'attente
|
||||
bool hasOperationInQueue(String operationType, String id) {
|
||||
// TODO: Implémenter la vérification
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Supprime une opération de la file d'attente
|
||||
Future<void> removeOperationFromQueue(String operationType, String id) async {
|
||||
// TODO: Implémenter la suppression
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
/// Dispose des ressources
|
||||
void dispose() {
|
||||
// TODO: Implémenter le dispose
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user