feat: synchronisation mode deconnecte fin chat et stats
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user