import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import '../../../config/injection/injection.dart'; import '../../../core/constants/design_system.dart'; import '../../../core/utils/app_logger.dart'; import '../../../data/providers/presence_provider.dart'; import '../../../data/services/chat_websocket_service.dart'; import '../../../data/services/secure_storage.dart'; import '../../../domain/entities/chat_message.dart'; import '../../../domain/entities/conversation.dart'; import '../../state_management/chat_bloc.dart'; import '../../widgets/custom_snackbar.dart'; import '../../widgets/date_separator.dart'; import '../../widgets/message_bubble.dart'; import '../../widgets/shimmer_loading.dart'; import '../../widgets/typing_indicator_widget.dart'; /// Écran de chat pour une conversation individuelle. class ChatScreen extends StatelessWidget { const ChatScreen({ required this.conversation, super.key, }); final Conversation conversation; @override Widget build(BuildContext context) { return BlocProvider( create: (context) => sl(), child: _ChatScreenContent(conversation: conversation), ); } } class _ChatScreenContent extends StatefulWidget { const _ChatScreenContent({required this.conversation}); final Conversation conversation; @override State<_ChatScreenContent> createState() => _ChatScreenContentState(); } class _ChatScreenContentState extends State<_ChatScreenContent> { final SecureStorage _storage = SecureStorage(); late ChatWebSocketService _wsService; final TextEditingController _messageController = TextEditingController(); final ScrollController _scrollController = ScrollController(); String? _currentUserId; bool _isTyping = false; Timer? _typingTimer; StreamSubscription? _messageSubscription; StreamSubscription? _typingSubscription; StreamSubscription? _deliverySubscription; @override void initState() { super.initState(); _initializeChat(); } @override void dispose() { _messageController.dispose(); _scrollController.dispose(); _typingTimer?.cancel(); _messageSubscription?.cancel(); _typingSubscription?.cancel(); _deliverySubscription?.cancel(); _wsService.dispose(); super.dispose(); } Future _initializeChat() async { _currentUserId = await _storage.getUserId(); if (_currentUserId == null) return; // Créer une nouvelle instance du WebSocket service _wsService = ChatWebSocketService(_currentUserId!); // Se connecter au WebSocket await _wsService.connect(); // S'abonner aux nouveaux messages _messageSubscription = _wsService.messageStream.listen((message) { if (message.conversationId == widget.conversation.id) { // Ajouter le message au Bloc if (mounted) { context.read().add(AddMessageToConversation(message)); } _scrollToBottom(); // Marquer comme lu si ce n'est pas notre message if (message.senderId != _currentUserId) { _markAsRead(message.id); } } }); // S'abonner aux indicateurs de frappe _typingSubscription = _wsService.typingStream.listen((indicator) { if (indicator.conversationId == widget.conversation.id && indicator.userId != _currentUserId) { if (mounted) { setState(() { _isTyping = indicator.isTyping; }); } } }); // S'abonner aux confirmations de délivrance _deliverySubscription = _wsService.deliveryStream.listen((confirmation) { AppLogger.d('Delivery confirmation reçue: ${confirmation.messageId}', tag: 'ChatScreen'); if (mounted) { AppLogger.d('Envoi MarkMessageAsDelivered au ChatBloc', tag: 'ChatScreen'); context.read().add(MarkMessageAsDelivered(confirmation.messageId)); } }); // Charger les messages via le Bloc if (mounted) { context.read().add(LoadMessages(conversationId: widget.conversation.id)); } } void _sendMessage() { final content = _messageController.text.trim(); if (content.isEmpty || _currentUserId == null) return; _messageController.clear(); // Arrêter l'indicateur de frappe _wsService.sendTypingIndicator(widget.conversation.id, false); // Envoyer via le Bloc context.read().add(SendMessage( senderId: _currentUserId!, recipientId: widget.conversation.participantId, content: content, )); _scrollToBottom(); } void _markAsRead(String messageId) { context.read().add(MarkMessageAsRead(messageId)); _wsService.sendReadReceipt(messageId); } void _onTextChanged(String text) { // Envoyer l'indicateur de frappe après 500ms d'inactivité _typingTimer?.cancel(); if (text.isNotEmpty) { _wsService.sendTypingIndicator(widget.conversation.id, true); _typingTimer = Timer(const Duration(milliseconds: 1500), () { _wsService.sendTypingIndicator(widget.conversation.id, false); }); } else { _wsService.sendTypingIndicator(widget.conversation.id, false); } } void _scrollToBottom() { if (_scrollController.hasClients) { Future.delayed(const Duration(milliseconds: 100), () { if (_scrollController.hasClients) { _scrollController.animateTo( 0, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); } } /// Vérifie si deux dates sont le même jour. bool _isSameDay(DateTime date1, DateTime date2) { return date1.year == date2.year && date1.month == date2.month && date1.day == date2.day; } /// Calcule le nombre total d'items (messages + séparateurs). int _getTotalItemCount(List messages) { if (messages.isEmpty) return 0; int separatorCount = 0; DateTime? lastDate; for (var message in messages) { final messageDate = DateTime( message.timestamp.year, message.timestamp.month, message.timestamp.day, ); if (lastDate == null || !_isSameDay(lastDate, messageDate)) { separatorCount++; lastDate = messageDate; } } return messages.length + separatorCount; } /// Détermine si l'index donné correspond à un séparateur de date. bool _isDateSeparatorAtIndex(int displayIndex, List messages) { if (messages.isEmpty) return false; int itemsProcessed = 0; DateTime? lastDate; for (int i = 0; i < messages.length; i++) { final messageDate = DateTime( messages[i].timestamp.year, messages[i].timestamp.month, messages[i].timestamp.day, ); if (lastDate == null || !_isSameDay(lastDate, messageDate)) { if (itemsProcessed == displayIndex) { return true; // C'est un séparateur } itemsProcessed++; lastDate = messageDate; } if (itemsProcessed == displayIndex) { return false; // C'est un message } itemsProcessed++; } return false; } /// Obtient l'index réel du message en tenant compte des séparateurs. int _getMessageIndex(int displayIndex, List messages) { if (messages.isEmpty) return 0; int itemsProcessed = 0; int messageIndex = 0; DateTime? lastDate; for (int i = 0; i < messages.length; i++) { final messageDate = DateTime( messages[i].timestamp.year, messages[i].timestamp.month, messages[i].timestamp.day, ); if (lastDate == null || !_isSameDay(lastDate, messageDate)) { if (itemsProcessed == displayIndex) { return i; // On retourne l'index du premier message de ce groupe } itemsProcessed++; lastDate = messageDate; } if (itemsProcessed == displayIndex) { return i; } itemsProcessed++; } return messageIndex; } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( appBar: AppBar( title: Row( children: [ Hero( tag: 'chat_avatar_${widget.conversation.participantId}', child: CircleAvatar( radius: 18, backgroundImage: widget.conversation.participantProfileImageUrl != null ? NetworkImage(widget.conversation.participantProfileImageUrl!) : null, child: widget.conversation.participantProfileImageUrl == null ? Text( widget.conversation.participantFirstName[0].toUpperCase(), style: const TextStyle(fontSize: 16), ) : null, ), ), const SizedBox(width: DesignSystem.spacingSm), Expanded( child: Consumer( builder: (context, presenceProvider, child) { final isOnline = presenceProvider.isUserOnline(widget.conversation.participantId); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( widget.conversation.participantFullName, style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), ), if (_isTyping) Text( 'En train d\'écrire...', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.primary, fontStyle: FontStyle.italic, ), ) else Text( isOnline ? 'En ligne' : 'Hors ligne', style: theme.textTheme.bodySmall?.copyWith( color: isOnline ? Colors.green : Colors.grey, ), ), ], ); }, ), ), ], ), ), body: BlocConsumer( listener: (context, state) { if (state.messagesStatus == MessagesStatus.error) { context.showError(state.errorMessage ?? 'Erreur de chargement'); } if (state.sendMessageStatus == SendMessageStatus.error) { context.showError(state.errorMessage ?? 'Erreur d\'envoi'); } if (state.sendMessageStatus == SendMessageStatus.success) { _scrollToBottom(); } }, builder: (context, state) { return Column( children: [ // Liste des messages Expanded( child: state.messagesStatus == MessagesStatus.loading ? const SkeletonList( itemCount: 10, skeletonWidget: ListItemSkeleton(), ) : state.messagesStatus == MessagesStatus.error ? _buildErrorState(theme, state.errorMessage) : state.messages.isEmpty ? _buildEmptyState(theme) : ListView.builder( controller: _scrollController, reverse: true, padding: const EdgeInsets.all(DesignSystem.spacingLg), itemCount: _getTotalItemCount(state.messages) + (_isTyping ? 1 : 0), itemBuilder: (context, index) { // Afficher l'indicateur de frappe en premier (index 0) if (_isTyping && index == 0) { return const Padding( padding: EdgeInsets.only(bottom: DesignSystem.spacingSm), child: TypingIndicatorWidget(), ); } final actualIndex = _isTyping ? index - 1 : index; // Vérifier si c'est un séparateur de date if (_isDateSeparatorAtIndex(actualIndex, state.messages)) { final messageIndex = _getMessageIndex(actualIndex, state.messages); final message = state.messages[messageIndex]; return DateSeparator(date: message.timestamp); } // C'est un message normal final messageIndex = _getMessageIndex(actualIndex, state.messages); final message = state.messages[messageIndex]; final isCurrentUser = message.senderId == _currentUserId; return MessageBubble( message: message, isCurrentUser: isCurrentUser, ); }, ), ), // Barre de saisie Container( padding: EdgeInsets.only( left: DesignSystem.spacingLg, right: DesignSystem.spacingLg, top: DesignSystem.spacingSm, bottom: MediaQuery.of(context).viewInsets.bottom + DesignSystem.spacingSm, ), decoration: BoxDecoration( color: theme.scaffoldBackgroundColor, border: Border( top: BorderSide(color: theme.dividerColor), ), ), child: Row( children: [ Expanded( child: TextField( controller: _messageController, onChanged: _onTextChanged, decoration: InputDecoration( hintText: 'Écrivez un message...', border: OutlineInputBorder( borderRadius: BorderRadius.circular(DesignSystem.radiusLg), ), contentPadding: const EdgeInsets.symmetric( horizontal: DesignSystem.spacingLg, vertical: DesignSystem.spacingSm, ), ), maxLines: null, textInputAction: TextInputAction.send, onSubmitted: (_) => _sendMessage(), ), ), const SizedBox(width: DesignSystem.spacingSm), state.sendMessageStatus == SendMessageStatus.sending ? const Padding( padding: EdgeInsets.all(8.0), child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2), ), ) : IconButton( onPressed: _sendMessage, icon: const Icon(Icons.send), color: theme.colorScheme.primary, iconSize: 28, ), ], ), ), ], ); }, ), ); } Widget _buildErrorState(ThemeData theme, String? errorMessage) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.error_outline, size: 64, color: Colors.grey[400]), const SizedBox(height: DesignSystem.spacingLg), Text('Erreur de chargement', style: theme.textTheme.titleLarge), const SizedBox(height: DesignSystem.spacingSm), Text( errorMessage ?? 'Une erreur est survenue', style: theme.textTheme.bodyMedium, textAlign: TextAlign.center, ), const SizedBox(height: DesignSystem.spacingXl), ElevatedButton.icon( onPressed: () { context.read().add(LoadMessages(conversationId: widget.conversation.id)); }, icon: const Icon(Icons.refresh), label: const Text('Réessayer'), ), ], ), ); } Widget _buildEmptyState(ThemeData theme) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.chat_bubble_outline, size: 64, color: Colors.grey[400]), const SizedBox(height: DesignSystem.spacingLg), Text( 'Aucun message', style: theme.textTheme.titleLarge, ), const SizedBox(height: DesignSystem.spacingSm), Text( 'Envoyez le premier message !', style: theme.textTheme.bodyMedium?.copyWith( color: Colors.grey[600], ), ), ], ), ); } }