Files
geo/app/lib/chat/services/chat_service.dart
Pierre 5ab03751e1 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>
2025-08-19 19:38:03 +02:00

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 '';
}
}
}