✨ 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>
468 lines
15 KiB
Dart
468 lines
15 KiB
Dart
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 '';
|
|
}
|
|
}
|
|
} |