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