feat: synchronisation mode deconnecte fin chat et stats

This commit is contained in:
2025-08-31 18:21:20 +02:00
parent f5bef999df
commit 96af94ad13
129 changed files with 125731 additions and 110375 deletions

View File

@@ -7,6 +7,7 @@ import 'pages/chat_page.dart';
///
/// Les permissions sont gérées via le fichier chat_config.yaml
class ChatModule {
static bool _isInitialized = false;
/// Initialiser le module chat avec support des rôles
///
@@ -24,6 +25,12 @@ class ChatModule {
int? userEntite,
String? authToken,
}) async {
// Si déjà initialisé, on ne fait rien (évite les réinitialisations)
if (_isInitialized) {
debugPrint('ChatModule already initialized, skipping...');
return;
}
await ChatService.init(
apiUrl: apiUrl,
userId: userId,
@@ -32,6 +39,7 @@ class ChatModule {
userEntite: userEntite,
authToken: authToken,
);
_isInitialized = true;
}
/// Obtenir la page de liste des conversations
@@ -52,8 +60,24 @@ class ChatModule {
);
}
/// Nettoyer les ressources
/// Nettoyer les ressources (alias pour cleanup)
static void dispose() {
ChatService.instance.dispose();
cleanup();
}
/// Nettoyer les ressources du module chat (à appeler lors du logout)
static void cleanup() {
if (_isInitialized) {
try {
ChatService.instance.dispose();
} catch (e) {
// Ignorer les erreurs si le service n'est pas initialisé
debugPrint('⚠️ Erreur lors du cleanup du chat: $e');
}
_isInitialized = false;
}
}
/// Vérifier si le module est initialisé
static bool get isInitialized => _isInitialized;
}

View File

@@ -29,6 +29,15 @@ class Message extends HiveObject {
@HiveField(7)
final bool isRead;
@HiveField(8)
final String? senderFirstName;
@HiveField(9)
final int? readCount;
@HiveField(10)
final bool isSynced;
Message({
required this.id,
required this.roomId,
@@ -38,19 +47,25 @@ class Message extends HiveObject {
required this.sentAt,
this.isMe = false,
this.isRead = false,
this.senderFirstName,
this.readCount,
this.isSynced = true,
});
// Simple factory depuis JSON
factory Message.fromJson(Map<String, dynamic> json, int currentUserId) {
factory Message.fromJson(Map<String, dynamic> json, int currentUserId, {String? roomId}) {
return Message(
id: json['id'],
roomId: json['fk_room'],
id: json['id'] ?? '',
roomId: roomId ?? json['room_id'] ?? json['fk_room'] ?? '',
content: json['content'] ?? '',
senderId: json['fk_user'] ?? 0,
senderId: json['sender_id'] ?? json['fk_user'] ?? 0,
senderName: json['sender_name'] ?? 'Anonyme',
sentAt: DateTime.parse(json['date_sent']),
isMe: json['fk_user'] == currentUserId,
isRead: json['statut'] == 'lu',
sentAt: DateTime.parse(json['sent_at'] ?? json['date_sent']),
isMe: json['is_mine'] ?? (json['sender_id'] == currentUserId || json['fk_user'] == currentUserId),
isRead: json['is_read'] ?? json['statut'] == 'lu' ?? false,
senderFirstName: json['sender_first_name'],
readCount: json['read_count'],
isSynced: json['is_synced'] ?? true,
);
}
@@ -60,4 +75,33 @@ class Message extends HiveObject {
'content': content,
'fk_user': senderId,
};
// Méthode copyWith pour faciliter les mises à jour
Message copyWith({
String? id,
String? roomId,
String? content,
int? senderId,
String? senderName,
DateTime? sentAt,
bool? isMe,
bool? isRead,
String? senderFirstName,
int? readCount,
bool? isSynced,
}) {
return Message(
id: id ?? this.id,
roomId: roomId ?? this.roomId,
content: content ?? this.content,
senderId: senderId ?? this.senderId,
senderName: senderName ?? this.senderName,
sentAt: sentAt ?? this.sentAt,
isMe: isMe ?? this.isMe,
isRead: isRead ?? this.isRead,
senderFirstName: senderFirstName ?? this.senderFirstName,
readCount: readCount ?? this.readCount,
isSynced: isSynced ?? this.isSynced,
);
}
}

View File

@@ -25,13 +25,16 @@ class MessageAdapter extends TypeAdapter<Message> {
sentAt: fields[5] as DateTime,
isMe: fields[6] as bool,
isRead: fields[7] as bool,
senderFirstName: fields[8] as String?,
readCount: fields[9] as int?,
isSynced: fields[10] as bool,
);
}
@override
void write(BinaryWriter writer, Message obj) {
writer
..writeByte(8)
..writeByte(11)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -47,7 +50,13 @@ class MessageAdapter extends TypeAdapter<Message> {
..writeByte(6)
..write(obj.isMe)
..writeByte(7)
..write(obj.isRead);
..write(obj.isRead)
..writeByte(8)
..write(obj.senderFirstName)
..writeByte(9)
..write(obj.readCount)
..writeByte(10)
..write(obj.isSynced);
}
@override

View File

@@ -26,6 +26,18 @@ class Room extends HiveObject {
@HiveField(6)
final int unreadCount;
@HiveField(7)
final List<Map<String, dynamic>>? recentMessages;
@HiveField(8)
final DateTime? updatedAt;
@HiveField(9)
final int? createdBy;
@HiveField(10)
final bool isSynced;
Room({
required this.id,
required this.title,
@@ -34,6 +46,10 @@ class Room extends HiveObject {
this.lastMessage,
this.lastMessageAt,
this.unreadCount = 0,
this.recentMessages,
this.updatedAt,
this.createdBy,
this.isSynced = true,
});
// Simple factory depuis JSON
@@ -42,12 +58,20 @@ class Room extends HiveObject {
id: json['id'],
title: json['title'] ?? 'Sans titre',
type: json['type'] ?? 'private',
createdAt: DateTime.parse(json['date_creation']),
createdAt: DateTime.parse(json['created_at'] ?? json['date_creation']),
lastMessage: json['last_message'],
lastMessageAt: json['last_message_at'] != null
? DateTime.parse(json['last_message_at'])
: null,
unreadCount: json['unread_count'] ?? 0,
recentMessages: json['recent_messages'] != null
? List<Map<String, dynamic>>.from(json['recent_messages'])
: null,
updatedAt: json['updated_at'] != null
? DateTime.parse(json['updated_at'])
: null,
createdBy: json['created_by'],
isSynced: json['is_synced'] ?? true,
);
}
@@ -58,4 +82,33 @@ class Room extends HiveObject {
'type': type,
'date_creation': createdAt.toIso8601String(),
};
// Méthode copyWith pour faciliter les mises à jour
Room copyWith({
String? id,
String? title,
String? type,
DateTime? createdAt,
String? lastMessage,
DateTime? lastMessageAt,
int? unreadCount,
List<Map<String, dynamic>>? recentMessages,
DateTime? updatedAt,
int? createdBy,
bool? isSynced,
}) {
return Room(
id: id ?? this.id,
title: title ?? this.title,
type: type ?? this.type,
createdAt: createdAt ?? this.createdAt,
lastMessage: lastMessage ?? this.lastMessage,
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
unreadCount: unreadCount ?? this.unreadCount,
recentMessages: recentMessages ?? this.recentMessages,
updatedAt: updatedAt ?? this.updatedAt,
createdBy: createdBy ?? this.createdBy,
isSynced: isSynced ?? this.isSynced,
);
}
}

View File

@@ -24,13 +24,19 @@ class RoomAdapter extends TypeAdapter<Room> {
lastMessage: fields[4] as String?,
lastMessageAt: fields[5] as DateTime?,
unreadCount: fields[6] as int,
recentMessages: (fields[7] as List?)
?.map((dynamic e) => (e as Map).cast<String, dynamic>())
?.toList(),
updatedAt: fields[8] as DateTime?,
createdBy: fields[9] as int?,
isSynced: fields[10] as bool,
);
}
@override
void write(BinaryWriter writer, Room obj) {
writer
..writeByte(7)
..writeByte(11)
..writeByte(0)
..write(obj.id)
..writeByte(1)
@@ -44,7 +50,15 @@ class RoomAdapter extends TypeAdapter<Room> {
..writeByte(5)
..write(obj.lastMessageAt)
..writeByte(6)
..write(obj.unreadCount);
..write(obj.unreadCount)
..writeByte(7)
..write(obj.recentMessages)
..writeByte(8)
..write(obj.updatedAt)
..writeByte(9)
..write(obj.createdBy)
..writeByte(10)
..write(obj.isSynced);
}
@override

View File

@@ -2,52 +2,56 @@ import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import '../models/message.dart';
import '../services/chat_service.dart';
import '../services/chat_config_loader.dart';
/// Page simple de chat
class ChatPage extends StatefulWidget {
final String roomId;
final String roomTitle;
final String? roomType; // Type de room (private, group, broadcast)
final int? roomCreatorId; // ID du créateur pour les rooms broadcast
final bool isEmbedded; // Pour indiquer si on est en mode split-view web
const ChatPage({
super.key,
required this.roomId,
required this.roomTitle,
this.roomType,
this.roomCreatorId,
this.isEmbedded = false,
});
@override
State<ChatPage> createState() => _ChatPageState();
State<ChatPage> createState() => ChatPageState();
}
class _ChatPageState extends State<ChatPage> {
class ChatPageState extends State<ChatPage> {
final _service = ChatService.instance;
final _messageController = TextEditingController();
final _scrollController = ScrollController();
bool _isLoading = true;
bool _isLoadingMore = false;
bool _hasMore = true;
List<Message> _messages = [];
String? _oldestMessageId;
int _lastMessageCount = 0;
// Vérifier si c'est une room broadcast et si l'utilisateur peut poster
bool get _isBroadcast => widget.roomType == 'broadcast';
bool get _canSendMessage => !_isBroadcast ||
(widget.roomCreatorId != null && widget.roomCreatorId == _service.currentUserId);
@override
void initState() {
super.initState();
_loadInitialMessages();
_service.markAsRead(widget.roomId);
_loadInitialMessages(); // L'API marque automatiquement comme lu lors du chargement des messages
}
Future<void> _loadInitialMessages() async {
setState(() => _isLoading = true);
final result = await _service.getMessages(widget.roomId);
final messages = result['messages'] as List<Message>;
print('🚀 ChatPage: Chargement initial des messages pour room ${widget.roomId}');
final result = await _service.getMessages(widget.roomId, isInitialLoad: true);
setState(() {
_messages = messages;
_hasMore = result['has_more'] as bool;
if (messages.isNotEmpty) {
_oldestMessageId = messages.first.id;
}
_isLoading = false;
});
@@ -56,22 +60,23 @@ class _ChatPageState extends State<ChatPage> {
}
Future<void> _loadMoreMessages() async {
if (_isLoadingMore || !_hasMore || _oldestMessageId == null) return;
if (_isLoadingMore || !_hasMore) return;
// Récupérer le message le plus ancien dans Hive pour cette room
final currentMessages = _service.messagesBox.values
.where((m) => m.roomId == widget.roomId)
.toList()
..sort((a, b) => a.sentAt.compareTo(b.sentAt));
if (currentMessages.isEmpty) return;
setState(() => _isLoadingMore = true);
final result = await _service.getMessages(widget.roomId, beforeMessageId: _oldestMessageId);
final newMessages = result['messages'] as List<Message>;
final oldestMessageId = currentMessages.first.id;
final result = await _service.getMessages(widget.roomId, beforeMessageId: oldestMessageId);
setState(() {
// Insérer les messages plus anciens au début
_messages = [...newMessages, ..._messages];
_hasMore = result['has_more'] as bool;
if (newMessages.isNotEmpty) {
_oldestMessageId = newMessages.first.id;
}
_isLoadingMore = false;
});
}
@@ -97,37 +102,113 @@ class _ChatPageState extends State<ChatPage> {
@override
Widget build(BuildContext context) {
// Obtenir le rôle de l'utilisateur pour la colorisation
final userRole = _service.getUserRole();
// Déterminer la couleur du badge selon le rôle
Color badgeColor;
switch (userRole) {
case 1:
badgeColor = Colors.green; // Vert pour les membres
break;
case 2:
badgeColor = Colors.blue; // Bleu pour les admins amicale
break;
case 9:
badgeColor = Colors.red; // Rouge pour les super admins
break;
default:
badgeColor = Colors.grey; // Gris par défaut
// Si on est en mode embedded (web split-view), pas de Scaffold/AppBar
if (widget.isEmbedded) {
return Container(
color: const Color(0xFFFAFAFA),
child: Column(
children: [
// En-tête simple pour le mode embedded
Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
bottom: BorderSide(color: Colors.grey[200]!),
),
),
child: Row(
children: [
if (_isBroadcast) ...[
Icon(Icons.campaign, size: 20, color: Colors.amber.shade600),
const SizedBox(width: 8),
],
Expanded(
child: Text(
widget.roomTitle,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
overflow: TextOverflow.ellipsis,
),
),
if (_isBroadcast)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.amber.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'ANNONCE',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.amber.shade800,
),
),
),
],
),
),
// Contenu du chat
Expanded(
child: _buildChatContent(context),
),
],
),
);
}
// Obtenir la version du module
final moduleVersion = ChatConfigLoader.instance.getModuleVersion();
// Mode normal avec Scaffold pour mobile
return Scaffold(
backgroundColor: const Color(0xFFFAFAFA),
appBar: AppBar(
title: Text(widget.roomTitle),
title: Row(
children: [
if (_isBroadcast) ...[
Icon(Icons.campaign, size: 20, color: Colors.amber.shade600),
const SizedBox(width: 8),
],
Expanded(
child: Text(
widget.roomTitle,
overflow: TextOverflow.ellipsis,
),
),
if (_isBroadcast)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.amber.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'ANNONCE',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.amber.shade800,
),
),
),
],
),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1E293B),
elevation: 0,
),
body: Stack(
body: _buildChatContent(context),
);
}
Widget _buildChatContent(BuildContext context) {
return Stack(
children: [
Column(
children: [
@@ -138,16 +219,49 @@ class _ChatPageState extends State<ChatPage> {
: ValueListenableBuilder<Box<Message>>(
valueListenable: _service.messagesBox.listenable(),
builder: (context, box, _) {
// Mettre à jour la liste avec les nouveaux messages envoyés
final recentMessages = box.values
.where((m) => m.roomId == widget.roomId &&
!_messages.any((msg) => msg.id == m.id))
.toList();
// Combiner les messages chargés et les nouveaux
final allMessages = [..._messages, ...recentMessages]
// Récupérer tous les messages de cette room depuis Hive
final allMessages = box.values
.where((m) => m.roomId == widget.roomId)
.toList()
..sort((a, b) => a.sentAt.compareTo(b.sentAt));
print('🔍 ChatPage: ${allMessages.length} messages trouvés pour room ${widget.roomId}');
if (allMessages.isEmpty) {
print('📭 Aucun message dans Hive pour cette room');
print('📦 Total messages dans Hive: ${box.length}');
final roomIds = box.values.map((m) => m.roomId).toSet();
print('🏠 Rooms dans Hive: $roomIds');
} else {
// Détecter les doublons potentiels
final messageIds = <String>{};
final duplicates = <String>[];
for (final msg in allMessages) {
if (messageIds.contains(msg.id)) {
duplicates.add(msg.id);
}
messageIds.add(msg.id);
}
if (duplicates.isNotEmpty) {
print('⚠️ DOUBLONS DÉTECTÉS: $duplicates');
}
// Afficher les IDs des messages pour débugger
print('📝 Liste des messages:');
for (final msg in allMessages) {
print(' - ${msg.id}: "${msg.content.substring(0, msg.content.length > 20 ? 20 : msg.content.length)}..." (isMe: ${msg.isMe})');
}
}
// Détecter les nouveaux messages et scroller vers le bas
if (allMessages.length > _lastMessageCount) {
_lastMessageCount = allMessages.length;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollToBottom();
}
});
}
if (allMessages.isEmpty) {
return Center(
child: Text(
@@ -197,7 +311,10 @@ class _ChatPageState extends State<ChatPage> {
itemCount: allMessages.length,
itemBuilder: (context, index) {
final message = allMessages[index];
return _MessageBubble(message: message);
return _MessageBubble(
message: message,
isBroadcast: _isBroadcast,
);
},
),
),
@@ -208,85 +325,72 @@ class _ChatPageState extends State<ChatPage> {
),
),
// Input
Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Colors.grey[200]!),
// Input ou message broadcast
if (_canSendMessage)
Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Colors.grey[200]!),
),
),
),
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: 'Message...',
hintStyle: TextStyle(color: Colors.grey[400]),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: _isBroadcast ? 'Nouvelle annonce...' : 'Message...',
hintStyle: TextStyle(color: Colors.grey[400]),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
),
IconButton(
icon: Icon(_isBroadcast ? Icons.campaign : Icons.send),
color: _isBroadcast ? Colors.amber.shade600 : const Color(0xFF2563EB),
onPressed: _sendMessage,
),
],
),
)
else
Container(
decoration: BoxDecoration(
color: Colors.amber.shade50,
border: Border(
top: BorderSide(color: Colors.amber.shade200),
),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(Icons.campaign, size: 20, color: Colors.amber.shade700),
const SizedBox(width: 12),
Expanded(
child: Text(
'Ceci est une annonce officielle. Seul l\'administrateur peut poster des messages.',
style: TextStyle(
fontSize: 13,
color: Colors.amber.shade800,
fontStyle: FontStyle.italic,
),
),
maxLines: null,
textInputAction: TextInputAction.send,
onSubmitted: (_) => _sendMessage(),
),
),
IconButton(
icon: const Icon(Icons.send),
color: const Color(0xFF2563EB),
onPressed: _sendMessage,
),
],
],
),
),
),
],
),
// Badge de version en bas à droite
Positioned(
bottom: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: badgeColor.withOpacity(0.9),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: badgeColor.withOpacity(0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.chat_bubble_outline,
size: 14,
color: Colors.white.withOpacity(0.9),
),
const SizedBox(width: 4),
Text(
'v$moduleVersion',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
),
);
}
@@ -301,13 +405,100 @@ class _ChatPageState extends State<ChatPage> {
/// Widget simple pour une bulle de message
class _MessageBubble extends StatelessWidget {
final Message message;
final bool isBroadcast;
const _MessageBubble({required this.message});
const _MessageBubble({
required this.message,
this.isBroadcast = false,
});
@override
Widget build(BuildContext context) {
final isMe = message.isMe;
// Style spécial pour les messages broadcast
if (isBroadcast) {
return Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
decoration: BoxDecoration(
color: Colors.amber.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.amber.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// En-tête broadcast
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.amber.shade100,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(11),
topRight: Radius.circular(11),
),
),
child: Row(
children: [
Icon(Icons.campaign, size: 18, color: Colors.amber.shade700),
const SizedBox(width: 8),
Text(
'ANNONCE OFFICIELLE',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: Colors.amber.shade800,
letterSpacing: 0.5,
),
),
const Spacer(),
Text(
_formatTime(message.sentAt),
style: TextStyle(
fontSize: 11,
color: Colors.amber.shade700,
),
),
],
),
),
// Contenu du message
Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isMe)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
'De: ${_getFullName(message)}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.amber.shade900,
),
),
),
Text(
message.content,
style: const TextStyle(
fontSize: 15,
height: 1.4,
),
),
],
),
),
],
),
);
}
// Style normal pour les messages privés/groupe
// Vérifier si le message est synchronisé
final isSynced = message.isSynced;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
@@ -327,7 +518,7 @@ class _MessageBubble extends StatelessWidget {
children: [
if (!isMe)
Text(
message.senderName,
_getFullName(message),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
@@ -337,15 +528,32 @@ class _MessageBubble extends StatelessWidget {
const SizedBox(height: 2),
Text(
message.content,
style: const TextStyle(fontSize: 14),
style: TextStyle(
fontSize: 14,
fontStyle: isSynced ? FontStyle.normal : FontStyle.italic,
color: isSynced ? null : Colors.grey[600],
),
),
const SizedBox(height: 2),
Text(
_formatTime(message.sentAt),
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.sentAt),
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
),
),
if (!isSynced) ...[
const SizedBox(width: 4),
Icon(
Icons.schedule,
size: 12,
color: Colors.orange[400],
),
],
],
),
],
),
@@ -356,4 +564,17 @@ class _MessageBubble extends StatelessWidget {
String _formatTime(DateTime date) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
String _getFullName(Message message) {
// Construire le nom complet avec prénom + nom
// sender_first_name contient le prénom (ex: "Pierre")
// sender_name contient le nom de famille (ex: "VALERY ADM")
final firstName = message.senderFirstName ?? '';
final lastName = message.senderName;
if (firstName.isNotEmpty) {
return '$firstName $lastName';
}
return lastName;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,14 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:uuid/uuid.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';
import 'package:geosector_app/core/services/api_service.dart';
import 'package:geosector_app/app.dart'; // Pour accéder à connectivityService
/// Service de chat avec règles métier configurables via YAML
/// Les permissions sont définies dans chat_config.yaml
@@ -21,9 +24,12 @@ class ChatService {
late String _currentUserName;
late int _currentUserRole;
late int? _currentUserEntite;
String? _authToken;
Timer? _syncTimer;
DateTime? _lastSyncTimestamp;
DateTime? _lastFullSync;
static const Duration _syncInterval = Duration(seconds: 15); // Changé à 15 secondes comme suggéré par l'API
static const Duration _fullSyncInterval = Duration(minutes: 5);
/// Initialisation avec gestion des rôles et configuration YAML
static Future<void> init({
@@ -39,21 +45,24 @@ class ChatService {
// Charger la configuration depuis le YAML
await ChatConfigLoader.instance.loadConfig();
// Initialiser Hive
await Hive.initFlutter();
Hive.registerAdapter(RoomAdapter());
Hive.registerAdapter(MessageAdapter());
// Les boxes sont déjà ouvertes par HiveService dans splash_page
// On vérifie juste qu'elles sont disponibles et on les récupère
if (!Hive.isBoxOpen(AppKeys.chatRoomsBoxName)) {
throw Exception('Chat rooms box not open. Please ensure HiveService is initialized.');
}
if (!Hive.isBoxOpen(AppKeys.chatMessagesBoxName)) {
throw Exception('Chat messages box not open. Please ensure HiveService is initialized.');
}
// 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);
// Récupérer les boxes déjà ouvertes
_instance!._roomsBox = Hive.box<Room>(AppKeys.chatRoomsBoxName);
_instance!._messagesBox = Hive.box<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(
@@ -63,7 +72,14 @@ class ChatService {
headers: authToken != null ? {'Authorization': 'Bearer $authToken'} : {},
));
// Démarrer la synchronisation
// Charger le dernier timestamp de sync depuis Hive
await _instance!._loadSyncTimestamp();
// Faire la sync initiale complète au login
await _instance!.getRooms(forceFullSync: true);
print('✅ Sync initiale complète effectuée au login');
// Démarrer la synchronisation incrémentale périodique
_instance!._startSync();
}
@@ -117,64 +133,216 @@ class ChatService {
);
}
/// Obtenir les rooms filtrées selon les permissions
Future<List<Room>> getRooms() async {
/// Obtenir les rooms avec synchronisation incrémentale
Future<List<Room>> getRooms({bool forceFullSync = false}) async {
// Vérifier la connectivité
if (!connectivityService.isConnected) {
print('📵 Pas de connexion réseau - utilisation du cache');
return _roomsBox.values.toList()
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
}
try {
// L'API filtre automatiquement selon le token Bearer
final response = await _dio.get('/chat/rooms');
// Déterminer si on fait une sync complète ou incrémentale
final now = DateTime.now();
final needsFullSync = forceFullSync ||
_lastFullSync == null ||
now.difference(_lastFullSync!).compareTo(_fullSyncInterval) > 0;
// 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()}');
Response response;
if (needsFullSync || _lastSyncTimestamp == null) {
// Synchronisation complète
print('🔄 Synchronisation complète des rooms...');
response = await _dio.get('/chat/rooms');
_lastFullSync = now;
} else {
// Synchronisation incrémentale
final isoTimestamp = _lastSyncTimestamp!.toUtc().toIso8601String();
print('🔄 Synchronisation incrémentale depuis $isoTimestamp');
response = await _dio.get('/chat/rooms', queryParameters: {
'updated_after': isoTimestamp,
});
}
// Extraire le timestamp de synchronisation fourni par l'API
if (response.data is Map && response.data['sync_timestamp'] != null) {
_lastSyncTimestamp = DateTime.parse(response.data['sync_timestamp']);
print('⏰ Timestamp de sync reçu de l\'API: $_lastSyncTimestamp');
// Sauvegarder le timestamp pour la prochaine session
await _saveSyncTimestamp();
} else {
// L'API doit toujours retourner un sync_timestamp
print('⚠️ Attention: L\'API n\'a pas retourné de sync_timestamp');
// On utilise le timestamp actuel comme fallback mais ce n'est pas idéal
_lastSyncTimestamp = now;
}
// Vérifier s'il y a des changements (pour sync incrémentale)
if (!needsFullSync && response.data is Map && response.data['has_changes'] == false) {
print('✅ Aucun changement depuis la dernière sync');
return _roomsBox.values.toList()
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
}
// 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');
final hasChanges = response.data['has_changes'] ?? true;
print('✅ Réponse API: ${roomsData.length} rooms, has_changes: $hasChanges');
} 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();
// Parser les rooms
final rooms = <Room>[];
final deletedRoomIds = <String>[];
// Sauvegarder dans Hive
await _roomsBox.clear();
for (final room in rooms) {
await _roomsBox.put(room.id, room);
for (final json in roomsData) {
try {
// Vérifier si la room est marquée comme supprimée
if (json['deleted'] == true) {
deletedRoomIds.add(json['id']);
continue;
}
final room = Room.fromJson(json);
rooms.add(room);
} catch (e) {
print('❌ Erreur parsing room: $e');
}
}
// Appliquer les modifications à Hive
if (needsFullSync) {
// Sync complète : remplacer tout mais préserver certaines données locales
final existingRooms = Map.fromEntries(
_roomsBox.values.map((r) => MapEntry(r.id, r))
);
await _roomsBox.clear();
for (final room in rooms) {
final existingRoom = existingRooms[room.id];
final roomToSave = Room(
id: room.id,
title: room.title,
type: room.type,
createdAt: room.createdAt,
lastMessage: room.lastMessage,
lastMessageAt: room.lastMessageAt,
unreadCount: room.unreadCount,
recentMessages: room.recentMessages,
updatedAt: room.updatedAt,
// Préserver createdBy existant si la nouvelle room n'en a pas
createdBy: room.createdBy ?? existingRoom?.createdBy,
);
await _roomsBox.put(roomToSave.id, roomToSave);
// Traiter les messages récents de la room
if (room.recentMessages != null && room.recentMessages!.isNotEmpty) {
for (final msgData in room.recentMessages!) {
try {
final message = Message.fromJson(msgData, _currentUserId, roomId: room.id);
// Sauvegarder uniquement si le message n'existe pas déjà
if (!_messagesBox.containsKey(message.id) && message.id.isNotEmpty) {
await _messagesBox.put(message.id, message);
print('📩 Nouveau message ajouté depuis sync: ${message.id} dans room ${room.id}');
} else if (message.id.isEmpty) {
print('⚠️ Message avec ID vide ignoré');
}
} catch (e) {
print('⚠️ Erreur lors du traitement d\'un message récent: $e');
}
}
}
}
print('💾 Sync complète: ${rooms.length} rooms sauvegardées');
} else {
// Sync incrémentale : mettre à jour uniquement les changements
for (final room in rooms) {
// Préserver le createdBy existant si non fourni par l'API
final existingRoom = _roomsBox.get(room.id);
final roomToSave = Room(
id: room.id,
title: room.title,
type: room.type,
createdAt: room.createdAt,
lastMessage: room.lastMessage,
lastMessageAt: room.lastMessageAt,
unreadCount: room.unreadCount,
recentMessages: room.recentMessages,
updatedAt: room.updatedAt,
// Préserver createdBy existant si la nouvelle room n'en a pas
createdBy: room.createdBy ?? existingRoom?.createdBy,
);
print('💾 Sauvegarde room ${roomToSave.title} (${roomToSave.id}): createdBy=${roomToSave.createdBy}');
await _roomsBox.put(roomToSave.id, roomToSave);
// Traiter les messages récents de la room
if (room.recentMessages != null && room.recentMessages!.isNotEmpty) {
for (final msgData in room.recentMessages!) {
try {
final message = Message.fromJson(msgData, _currentUserId, roomId: room.id);
// Sauvegarder uniquement si le message n'existe pas déjà
if (!_messagesBox.containsKey(message.id) && message.id.isNotEmpty) {
await _messagesBox.put(message.id, message);
print('📩 Nouveau message ajouté depuis sync: ${message.id} dans room ${room.id}');
} else if (message.id.isEmpty) {
print('⚠️ Message avec ID vide ignoré');
}
} catch (e) {
print('⚠️ Erreur lors du traitement d\'un message récent: $e');
}
}
}
}
// Supprimer les rooms marquées comme supprimées
for (final roomId in deletedRoomIds) {
// Supprimer la room
await _roomsBox.delete(roomId);
// Supprimer tous les messages de cette room
final messagesToDelete = _messagesBox.values
.where((msg) => msg.roomId == roomId)
.map((msg) => msg.id)
.toList();
for (final msgId in messagesToDelete) {
await _messagesBox.delete(msgId);
}
print('🗑️ Room $roomId supprimée avec ${messagesToDelete.length} messages');
}
print('💾 Sync incrémentale: ${rooms.length} rooms mises à jour, ${deletedRoomIds.length} supprimées');
}
// Mettre à jour les stats globales
final totalUnread = rooms.fold<int>(0, (sum, room) => sum + room.unreadCount);
final allRooms = _roomsBox.values.toList();
final totalUnread = allRooms.fold<int>(0, (sum, room) => sum + room.unreadCount);
ChatInfoService.instance.updateStats(
totalRooms: rooms.length,
totalRooms: allRooms.length,
unreadMessages: totalUnread,
);
return rooms;
return allRooms
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
} 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.)
print('Erreur sync rooms: $e');
// Fallback sur le cache local
return _roomsBox.values.toList()
..sort((a, b) => (b.lastMessageAt ?? b.createdAt)
.compareTo(a.lastMessageAt ?? a.createdAt));
@@ -188,6 +356,27 @@ class ChatService {
String? type,
String? initialMessage,
}) async {
// Générer un ID temporaire pour la room
final tempId = 'temp_room_${const Uuid().v4()}';
final now = DateTime.now();
// Créer la room locale immédiatement
final tempRoom = Room(
id: tempId,
title: title,
type: type ?? (_currentUserRole == 9 ? 'broadcast' : 'private'),
createdAt: now,
lastMessage: initialMessage,
lastMessageAt: initialMessage != null ? now : null,
unreadCount: 0,
createdBy: _currentUserId,
isSynced: false, // Room non synchronisée
);
// Sauvegarder immédiatement dans Hive
await _roomsBox.put(tempId, tempRoom);
print('💾 Room temporaire sauvée: $tempId');
try {
// Vérifier les permissions localement d'abord
// L'API fera aussi une vérification
@@ -204,14 +393,37 @@ class ChatService {
data['initial_message'] = initialMessage;
}
final response = await _dio.post('/chat/rooms', data: data);
// Utiliser ApiService qui gère automatiquement la queue offline
final response = await ApiService.instance.post(
'/chat/rooms',
data: data,
tempId: tempId, // Passer le tempId pour le mapping après sync
);
final room = Room.fromJson(response.data);
await _roomsBox.put(room.id, room);
// Vérifier si la room a été mise en queue (offline)
if (response.data != null && response.data['queued'] == true) {
print('📵 Room mise en file d\'attente pour synchronisation: $tempId');
return tempRoom; // Retourner la room temporaire
}
// Si online et succès immédiat
if (response.statusCode == 200 || response.statusCode == 201) {
final realRoom = Room.fromJson(response.data);
// Remplacer la room temporaire par la room réelle
await _roomsBox.delete(tempId);
await _roomsBox.put(realRoom.id, realRoom);
print('✅ Room temporaire $tempId remplacée par ${realRoom.id}');
return realRoom;
}
return tempRoom;
return room;
} catch (e) {
return null;
print('⚠️ Erreur création room: $e - La room sera synchronisée plus tard');
// La room reste en local avec isSynced = false
return tempRoom;
}
}
@@ -255,8 +467,8 @@ class ChatService {
);
}
/// Obtenir les messages d'une room avec pagination
Future<Map<String, dynamic>> getMessages(String roomId, {String? beforeMessageId}) async {
/// Obtenir les messages d'une room (marque automatiquement comme lu)
Future<Map<String, dynamic>> getMessages(String roomId, {String? beforeMessageId, bool isInitialLoad = false}) async {
try {
final params = <String, dynamic>{
'limit': 50,
@@ -271,29 +483,64 @@ class ChatService {
// Gérer différents formats de réponse
List<dynamic> messagesData;
bool hasMore = false;
int markedAsRead = 0;
int unreadRemaining = 0;
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
// Format avec métadonnées
messagesData = response.data['messages'] ?? response.data['data'] ?? [];
hasMore = response.data['has_more'] ?? false;
markedAsRead = response.data['marked_as_read'] ?? 0;
unreadRemaining = response.data['unread_count'] ?? 0;
if (markedAsRead > 0) {
print('$markedAsRead messages marqués comme lus automatiquement');
}
} else {
print('⚠️ Format inattendu pour les messages: ${response.data.runtimeType}');
messagesData = [];
}
final messages = messagesData
.map((json) => Message.fromJson(json, _currentUserId))
.map((json) => Message.fromJson(json, _currentUserId, roomId: roomId))
.toList();
print('📨 Messages reçus pour room $roomId: ${messages.length}');
for (final msg in messages) {
print(' - ${msg.id}: "${msg.content}" de ${msg.senderName} (${msg.senderId}) isMe: ${msg.isMe}');
}
// Sauvegarder dans Hive (en limitant à 100 messages par room)
await _saveMessagesToCache(roomId, messages);
// Si c'est le chargement initial, on remplace tous les messages
await _saveMessagesToCache(roomId, messages, replaceAll: isInitialLoad && beforeMessageId == null);
// Mettre à jour le unreadCount de la room localement
final room = _roomsBox.get(roomId);
if (room != null && unreadRemaining == 0) {
final updatedRoom = Room(
id: room.id,
title: room.title,
type: room.type,
createdAt: room.createdAt,
lastMessage: room.lastMessage,
lastMessageAt: room.lastMessageAt,
unreadCount: 0, // Mis à 0 car tout est lu
recentMessages: room.recentMessages,
updatedAt: room.updatedAt,
);
await _roomsBox.put(roomId, updatedRoom);
// Mettre à jour les stats globales
ChatInfoService.instance.decrementUnread(markedAsRead);
}
return {
'messages': messages,
'has_more': hasMore,
'marked_as_read': markedAsRead,
};
} catch (e) {
print('Erreur getMessages: $e');
@@ -306,120 +553,231 @@ class ChatService {
return {
'messages': cachedMessages,
'has_more': false,
'marked_as_read': 0,
};
}
}
/// 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
Future<void> _saveMessagesToCache(String roomId, List<Message> newMessages, {bool replaceAll = false}) async {
// Ajouter les nouveaux messages (en évitant les doublons)
int addedCount = 0;
for (final message in newMessages) {
// Vérifier si le message n'existe pas déjà
if (!_messagesBox.containsKey(message.id) && message.id.isNotEmpty) {
await _messagesBox.put(message.id, message);
print('💾 Message sauvé: ${message.id} dans room ${message.roomId}');
addedCount++;
} else if (_messagesBox.containsKey(message.id)) {
print('⚠️ Message ${message.id} existe déjà, ignoré pour éviter duplication');
}
}
print('📊 Résumé: ${addedCount} nouveaux messages ajoutés sur ${newMessages.length} reçus');
// Après l'ajout, récupérer TOUS les messages de la room pour le nettoyage
final allRoomMessages = _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();
if (allRoomMessages.length > 100) {
final messagesToDelete = allRoomMessages.skip(100).toList();
print('🗑️ Suppression de ${messagesToDelete.length} anciens messages');
for (final message in messagesToDelete) {
await _messagesBox.delete(message.id);
}
}
}
/// Supprimer une room (seulement pour le créateur)
Future<bool> deleteRoom(String roomId) async {
try {
// Appeler l'API pour supprimer la room
await _dio.delete('/chat/rooms/$roomId');
// Supprimer la room localement
await _roomsBox.delete(roomId);
// Supprimer tous les messages de cette room
final messagesToDelete = _messagesBox.values
.where((msg) => msg.roomId == roomId)
.map((msg) => msg.id)
.toList();
for (final msgId in messagesToDelete) {
await _messagesBox.delete(msgId);
}
print('✅ Room $roomId supprimée avec succès');
return true;
} catch (e) {
print('❌ Erreur suppression room: $e');
throw Exception('Impossible de supprimer la conversation');
}
}
/// Envoyer un message
Future<Message?> sendMessage(String roomId, String content) async {
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
// Générer un ID temporaire pour le message
final tempId = 'temp_msg_${const Uuid().v4()}';
final now = DateTime.now();
// Créer le message local immédiatement (optimistic UI)
final tempMessage = Message(
id: tempId,
roomId: roomId,
content: content,
senderId: _currentUserId,
senderName: _currentUserName,
sentAt: DateTime.now(),
sentAt: now,
isMe: true,
isRead: false,
isSynced: false, // Message non synchronisé
);
// Sauvegarder immédiatement dans Hive pour affichage instantané
await _messagesBox.put(tempId, tempMessage);
print('💾 Message temporaire sauvé: $tempId');
// Mettre à jour la room localement pour affichage immédiat
final room = _roomsBox.get(roomId);
if (room != null) {
final updatedRoom = room.copyWith(
lastMessage: content,
lastMessageAt: now,
unreadCount: 0,
updatedAt: now,
);
await _roomsBox.put(roomId, updatedRoom);
}
try {
final response = await _dio.post(
// Utiliser ApiService qui gère automatiquement la queue offline
final response = await ApiService.instance.post(
'/chat/rooms/$roomId/messages',
data: {
'content': content,
// L'API récupère le sender depuis le token
},
tempId: tempId, // Passer le tempId pour le mapping après sync
);
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);
// Vérifier si le message a été mis en queue (offline)
if (response.data != null && response.data['queued'] == true) {
print('📵 Message mis en file d\'attente pour synchronisation: $tempId');
return tempMessage; // Retourner le message temporaire
}
return finalMessage;
// Si online et succès immédiat
if (response.statusCode == 200 || response.statusCode == 201) {
// Récupérer le message complet depuis la réponse API
final realMessage = Message.fromJson(
response.data['message'] ?? response.data,
_currentUserId,
roomId: roomId
);
print('📨 Message envoyé avec ID réel: ${realMessage.id}');
// Remplacer le message temporaire par le message réel
await _messagesBox.delete(tempId);
await _messagesBox.put(realMessage.id, realMessage);
print('✅ Message temporaire $tempId remplacé par ${realMessage.id}');
return realMessage;
}
// Cas par défaut : retourner le message temporaire
return tempMessage;
} catch (e) {
print('⚠️ Erreur envoi message: $e - Le message sera synchronisé plus tard');
// Le message reste en local avec isSynced = false
return tempMessage;
}
}
/// Marquer comme lu
Future<void> markAsRead(String roomId) async {
// La méthode markAsRead n'est plus nécessaire car l'API marque automatiquement
// les messages comme lus lors de l'appel GET /api/chat/rooms/{roomId}/messages
/// Charger le timestamp de dernière sync depuis Hive
Future<void> _loadSyncTimestamp() 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);
// Utiliser une box générique pour stocker les métadonnées
if (Hive.isBoxOpen('chat_metadata')) {
final metaBox = Hive.box('chat_metadata');
final timestamp = metaBox.get('last_sync_timestamp');
if (timestamp != null) {
_lastSyncTimestamp = DateTime.parse(timestamp);
print('📅 Dernier timestamp de sync restauré: $_lastSyncTimestamp');
}
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
print('⚠️ Impossible de charger le timestamp de sync: $e');
}
}
/// Synchronisation périodique
/// Sauvegarder le timestamp de dernière sync dans Hive
Future<void> _saveSyncTimestamp() async {
if (_lastSyncTimestamp == null) return;
try {
// Ouvrir ou créer la box de métadonnées si nécessaire
Box metaBox;
if (!Hive.isBoxOpen('chat_metadata')) {
metaBox = await Hive.openBox('chat_metadata');
} else {
metaBox = Hive.box('chat_metadata');
}
await metaBox.put('last_sync_timestamp', _lastSyncTimestamp!.toIso8601String());
} catch (e) {
print('⚠️ Impossible de sauvegarder le timestamp de sync: $e');
}
}
/// Synchronisation périodique avec vérification de connectivité
void _startSync() {
_syncTimer?.cancel();
_syncTimer = Timer.periodic(const Duration(seconds: 30), (_) {
getRooms();
_syncTimer = Timer.periodic(_syncInterval, (_) async {
// Vérifier la connectivité avant de synchroniser
if (!connectivityService.isConnected) {
print('📵 Pas de connexion - sync ignorée');
return;
}
// Synchroniser les rooms (incrémentale)
await getRooms();
});
// Pas de sync immédiate ici car déjà faite dans init()
print('⏰ Timer de sync incrémentale démarré (toutes les 15 secondes)');
}
/// Mettre en pause les synchronisations (app en arrière-plan)
void pauseSyncs() {
_syncTimer?.cancel();
print('⏸️ Timer de sync arrêté (app en arrière-plan)');
}
/// Reprendre les synchronisations (app au premier plan)
void resumeSyncs() {
if (_syncTimer == null || !_syncTimer!.isActive) {
_startSync();
print('▶️ Timer de sync redémarré (app au premier plan)');
// Faire une sync immédiate au retour au premier plan
// pour rattraper les messages manqués
if (connectivityService.isConnected) {
getRooms().then((_) {
print('✅ Sync de rattrapage effectuée');
}).catchError((e) {
print('⚠️ Erreur sync de rattrapage: $e');
});
}
}
}
/// Nettoyer les ressources

View File

@@ -24,6 +24,7 @@ class _RecipientSelectorState extends State<RecipientSelector> {
final _selectedRecipients = <Map<String, dynamic>>[];
List<Map<String, dynamic>> _suggestions = [];
List<Map<String, dynamic>> _allRecipients = []; // Liste complète pour la recherche locale
bool _isLoading = false;
@override
@@ -38,7 +39,20 @@ class _RecipientSelectorState extends State<RecipientSelector> {
try {
final recipients = await _service.getPossibleRecipients();
setState(() {
_suggestions = recipients;
// Pour un admin (rôle 2), on filtre les super admins de la liste de sélection individuelle
// et on exclut aussi l'utilisateur lui-même
if (_service.currentUserRole == 2) {
_allRecipients = recipients.where((r) =>
r['role'] != 9 && // Pas de super admins
r['id'] != _service.currentUserId // Pas l'utilisateur lui-même
).toList();
} else {
// Pour les autres rôles, on exclut juste l'utilisateur lui-même
_allRecipients = recipients.where((r) =>
r['id'] != _service.currentUserId
).toList();
}
_suggestions = _allRecipients; // Afficher tous les destinataires initialement
_isLoading = false;
});
} catch (e) {
@@ -47,22 +61,29 @@ class _RecipientSelectorState extends State<RecipientSelector> {
}
Future<void> _searchRecipients(String query) async {
if (query.length < 2) {
_loadInitialRecipients();
if (query.isEmpty) {
setState(() {
_suggestions = _allRecipients;
});
return;
}
setState(() => _isLoading = true);
// Recherche locale sur les trois champs
final searchQuery = query.toLowerCase().trim();
try {
final recipients = await _service.getPossibleRecipients(search: query);
setState(() {
_suggestions = recipients;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
}
setState(() {
_suggestions = _allRecipients.where((recipient) {
// Récupérer les trois champs à rechercher
final firstName = (recipient['first_name'] ?? '').toString().toLowerCase();
final lastName = (recipient['name'] ?? '').toString().toLowerCase();
final sectName = (recipient['sect_name'] ?? '').toString().toLowerCase();
// Rechercher dans les trois champs
return firstName.contains(searchQuery) ||
lastName.contains(searchQuery) ||
sectName.contains(searchQuery);
}).toList();
});
}
void _toggleRecipient(Map<String, dynamic> recipient) {
@@ -116,7 +137,6 @@ class _RecipientSelectorState extends State<RecipientSelector> {
Widget build(BuildContext context) {
final currentRole = _service.currentUserRole;
final config = ChatConfigLoader.instance.getPossibleRecipientsConfig(currentRole);
final canBroadcast = config.any((c) => c['allow_broadcast'] == true);
final canSelect = config.any((c) => c['allow_selection'] == true);
return Column(
@@ -227,10 +247,23 @@ class _RecipientSelectorState extends State<RecipientSelector> {
final allRecipients = await _service.getPossibleRecipients();
setState(() {
_selectedRecipients.clear();
// Sélectionner tous les membres de l'amicale (role 1)
// Sélectionner tous les membres actifs de l'amicale (pas les super admins)
// On inclut l'utilisateur lui-même pour qu'il reçoive aussi le message
// L'API devrait déjà retourner tous les membres actifs de l'amicale (rôle 1 et 2)
_selectedRecipients.addAll(
allRecipients.where((r) => r['role'] == 1)
allRecipients.where((r) => r['role'] != 9)
);
// Si l'utilisateur n'est pas dans la liste, on l'ajoute
if (!_selectedRecipients.any((r) => r['id'] == _service.currentUserId)) {
// On utilise le nom complet fourni au service
// TODO: Il faudrait passer prénom et nom séparément lors de l'init du ChatService
_selectedRecipients.add({
'id': _service.currentUserId,
'name': _service.currentUserName,
'first_name': '',
'role': _service.currentUserRole,
});
}
});
widget.onRecipientsSelected(_selectedRecipients);
},
@@ -277,21 +310,7 @@ class _RecipientSelectorState extends State<RecipientSelector> {
Wrap(
spacing: 8,
children: [
if (canBroadcast)
ActionChip(
label: const Text('Tous les admins'),
avatar: const Icon(Icons.groups, size: 18),
onPressed: () async {
final allAdmins = await _service.getPossibleRecipients();
setState(() {
_selectedRecipients.clear();
_selectedRecipients.addAll(
allAdmins.where((r) => r['role'] == 2)
);
});
widget.onRecipientsSelected(_selectedRecipients);
},
),
// Bouton "Tous les admins" retiré - maintenant disponible via le bouton d'action rapide dans la liste des rooms
if (_selectedRecipients.isNotEmpty)
ActionChip(
label: Text('${_selectedRecipients.length} sélectionné(s)'),
@@ -316,8 +335,7 @@ class _RecipientSelectorState extends State<RecipientSelector> {
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: ChatConfigLoader.instance.getUIMessages()['search_placeholder']
?? 'Rechercher...',
hintText: 'Rechercher un destinataire...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
@@ -349,29 +367,57 @@ class _RecipientSelectorState extends State<RecipientSelector> {
(r) => r['id'] == recipient['id']
);
// Construire le nom complet (prénom + nom)
final String firstName = recipient['first_name'] ?? '';
final String lastName = recipient['name'] ?? '';
final String fullName = '${firstName.trim()} ${lastName.trim()}'.trim();
final String displayName = fullName.isNotEmpty ? fullName : 'Sans nom';
// Utiliser sect_name s'il existe, sinon rien
final String? sectName = recipient['sect_name'];
final String? subtitle = sectName?.isNotEmpty == true
? sectName
: null;
// Initiales pour l'avatar (première lettre prénom + première lettre nom)
String avatarLetters = '';
if (firstName.isNotEmpty) {
avatarLetters += firstName.substring(0, 1).toUpperCase();
}
if (lastName.isNotEmpty) {
avatarLetters += lastName.substring(0, 1).toUpperCase();
}
if (avatarLetters.isEmpty) {
avatarLetters = '?';
}
return ListTile(
leading: CircleAvatar(
backgroundColor: isSelected
? Theme.of(context).primaryColor
: Colors.grey.shade200,
child: Text(
recipient['name']?.substring(0, 1).toUpperCase() ?? '?',
avatarLetters,
style: TextStyle(
color: isSelected ? Colors.white : Colors.grey[700],
fontWeight: FontWeight.w600,
fontSize: avatarLetters.length > 1 ? 14 : 16,
),
),
),
title: Text(
recipient['name'] ?? 'Sans nom',
displayName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: recipient['entite_name'] != null
subtitle: subtitle != null
? Text(
recipient['entite_name'],
subtitle,
style: TextStyle(
fontSize: 13,
color: Colors.grey[600],
fontStyle: sectName?.isNotEmpty == true
? FontStyle.italic
: FontStyle.normal,
),
)
: null,
@@ -489,6 +535,7 @@ class _RecipientSelectorWithMessage extends StatefulWidget {
class _RecipientSelectorWithMessageState extends State<_RecipientSelectorWithMessage> {
List<Map<String, dynamic>> _selectedRecipients = [];
final _messageController = TextEditingController();
bool _isBroadcast = false; // Option broadcast pour super admins
@override
Widget build(BuildContext context) {
@@ -508,7 +555,59 @@ class _RecipientSelectorWithMessageState extends State<_RecipientSelectorWithMes
),
// Champ de message initial si des destinataires sont sélectionnés
if (_selectedRecipients.isNotEmpty) ...[
if (_selectedRecipients.isNotEmpty) ...[
// Option broadcast pour super admins uniquement
if (ChatService.instance.currentUserRole == 9)
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.amber.shade50,
border: Border(
top: BorderSide(color: Colors.grey.shade200),
),
),
child: Row(
children: [
Expanded(
child: Row(
children: [
Icon(Icons.campaign, size: 20, color: Colors.amber.shade700),
const SizedBox(width: 8),
Text(
'Mode Annonce (Broadcast)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.amber.shade900,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Les destinataires ne pourront pas répondre',
style: TextStyle(
fontSize: 12,
color: Colors.amber.shade700,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
Switch(
value: _isBroadcast,
onChanged: (value) {
setState(() {
_isBroadcast = value;
});
},
activeColor: Colors.amber.shade600,
),
],
),
),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@@ -520,33 +619,50 @@ class _RecipientSelectorWithMessageState extends State<_RecipientSelectorWithMes
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Message initial (optionnel)',
Text(
_isBroadcast ? 'Message de l\'annonce' : 'Message initial (optionnel)',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
color: _isBroadcast ? Colors.amber.shade900 : const Color(0xFF1E293B),
),
),
const SizedBox(height: 8),
TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: 'Écrivez votre premier message...',
hintText: _isBroadcast
? 'Écrivez votre annonce officielle...'
: 'Écrivez votre premier message...',
hintStyle: TextStyle(color: Colors.grey[400]),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
borderSide: BorderSide(
color: _isBroadcast ? Colors.amber.shade300 : Colors.grey.shade300,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: _isBroadcast ? Colors.amber.shade300 : Colors.grey.shade300,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: _isBroadcast ? Colors.amber.shade600 : Theme.of(context).primaryColor,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
maxLines: 3,
minLines: 2,
maxLines: _isBroadcast ? 5 : 3,
minLines: _isBroadcast ? 3 : 2,
),
],
),
@@ -568,20 +684,34 @@ class _RecipientSelectorWithMessageState extends State<_RecipientSelectorWithMes
Navigator.of(context).pop({
'recipients': _selectedRecipients,
'initial_message': _messageController.text.trim(),
'is_broadcast': _isBroadcast, // Ajouter le flag broadcast
});
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
backgroundColor: Theme.of(context).primaryColor,
backgroundColor: _isBroadcast
? Colors.amber.shade600
: Theme.of(context).primaryColor,
),
child: Text(
widget.allowMultiple
? 'Créer conversation avec ${_selectedRecipients.length} personne(s)'
: 'Créer conversation',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.white,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isBroadcast) ...[
const Icon(Icons.campaign, color: Colors.white, size: 20),
const SizedBox(width: 8),
],
Text(
_isBroadcast
? 'Envoyer l\'annonce à ${_selectedRecipients.length} admin(s)'
: widget.allowMultiple
? 'Créer conversation avec ${_selectedRecipients.length} personne(s)'
: 'Créer conversation',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),