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

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