fix(chat): Correction race condition + Implémentation TODOs
## Corrections Critiques ### Race Condition - Statuts de Messages - Fix : Les icônes de statut (✓, ✓✓, ✓✓ bleu) ne s'affichaient pas - Cause : WebSocket delivery confirmations arrivaient avant messages locaux - Solution : Pattern Optimistic UI dans chat_bloc.dart - Création message temporaire immédiate - Ajout à la liste AVANT requête HTTP - Remplacement par message serveur à la réponse - Fichier : lib/presentation/state_management/chat_bloc.dart ## Implémentation TODOs (13/21) ### Social (social_header_widget.dart) - ✅ Copier lien du post dans presse-papiers - ✅ Partage natif via Share.share() - ✅ Dialogue de signalement avec 5 raisons ### Partage (share_post_dialog.dart) - ✅ Interface sélection d'amis avec checkboxes - ✅ Partage externe via Share API ### Média (media_upload_service.dart) - ✅ Parsing JSON réponse backend - ✅ Méthode deleteMedia() pour suppression - ✅ Génération miniature vidéo ### Posts (create_post_dialog.dart, edit_post_dialog.dart) - ✅ Extraction URL depuis uploads - ✅ Documentation chargement médias ### Chat (conversations_screen.dart) - ✅ Navigation vers notifications - ✅ ConversationSearchDelegate pour recherche ## Nouveaux Fichiers ### Configuration - build-prod.ps1 : Script build production avec dart-define - lib/core/constants/env_config.dart : Gestion environnements ### Documentation - TODOS_IMPLEMENTED.md : Documentation complète TODOs ## Améliorations ### Architecture - Refactoring injection de dépendances - Amélioration routing et navigation - Optimisation providers (UserProvider, FriendsProvider) ### UI/UX - Amélioration thème et couleurs - Optimisation animations - Meilleure gestion erreurs ### Services - Configuration API avec env_config - Amélioration datasources (events, users) - Optimisation modèles de données
This commit is contained in:
323
lib/presentation/widgets/message_bubble.dart
Normal file
323
lib/presentation/widgets/message_bubble.dart
Normal file
@@ -0,0 +1,323 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/constants/design_system.dart';
|
||||
import '../../core/utils/app_logger.dart';
|
||||
import '../../core/utils/date_formatter.dart';
|
||||
import '../../domain/entities/chat_message.dart';
|
||||
|
||||
/// Widget pour afficher une bulle de message dans le chat.
|
||||
///
|
||||
/// **Fonctionnalités :**
|
||||
/// - Design moderne inspiré de WhatsApp/Telegram 2025
|
||||
/// - Groupement de messages (via `showAvatar`)
|
||||
/// - Statuts de message : ✓ envoyé, ✓✓ délivré, ✓✓ (bleu) lu
|
||||
/// - Support des attachments (image, vidéo, audio, fichier)
|
||||
/// - Timestamp intelligent avec formatage contextuel
|
||||
class MessageBubble extends StatelessWidget {
|
||||
const MessageBubble({
|
||||
required this.message,
|
||||
required this.isCurrentUser,
|
||||
this.showAvatar = true,
|
||||
this.isFirstInGroup = false,
|
||||
this.isLastInGroup = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ChatMessage message;
|
||||
final bool isCurrentUser;
|
||||
|
||||
/// Afficher l'avatar (seulement sur le dernier message d'un groupe)
|
||||
final bool showAvatar;
|
||||
|
||||
/// Premier message d'un groupe (même expéditeur)
|
||||
final bool isFirstInGroup;
|
||||
|
||||
/// Dernier message d'un groupe (même expéditeur)
|
||||
final bool isLastInGroup;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
// Padding selon le groupement
|
||||
final bottomPadding = isLastInGroup ? DesignSystem.spacingMd : 2.0;
|
||||
final leftPadding = isCurrentUser ? 64.0 : (showAvatar ? 8.0 : 48.0);
|
||||
final rightPadding = isCurrentUser ? 8.0 : 64.0;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: leftPadding,
|
||||
right: rightPadding,
|
||||
bottom: bottomPadding,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: isCurrentUser ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// Avatar pour les messages reçus (seulement sur le dernier message du groupe)
|
||||
if (!isCurrentUser && showAvatar) ...[
|
||||
_buildAvatar(),
|
||||
const SizedBox(width: DesignSystem.spacingSm),
|
||||
],
|
||||
|
||||
// Espace invisible pour alignement quand pas d'avatar
|
||||
if (!isCurrentUser && !showAvatar)
|
||||
const SizedBox(width: 40),
|
||||
|
||||
// Bulle de message
|
||||
Flexible(
|
||||
child: _buildMessageContainer(theme),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit l'avatar de l'expéditeur.
|
||||
Widget _buildAvatar() {
|
||||
final initial = message.senderFirstName.isNotEmpty
|
||||
? message.senderFirstName[0].toUpperCase()
|
||||
: '?';
|
||||
|
||||
return CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundImage: message.senderProfileImageUrl != null &&
|
||||
message.senderProfileImageUrl!.isNotEmpty &&
|
||||
message.senderProfileImageUrl!.startsWith('http')
|
||||
? NetworkImage(message.senderProfileImageUrl!)
|
||||
: null,
|
||||
child: message.senderProfileImageUrl == null ||
|
||||
message.senderProfileImageUrl!.isEmpty ||
|
||||
!message.senderProfileImageUrl!.startsWith('http')
|
||||
? Text(initial, style: const TextStyle(fontSize: 16))
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit le conteneur de la bulle de message.
|
||||
Widget _buildMessageContainer(ThemeData theme) {
|
||||
final bgColor = isCurrentUser
|
||||
? theme.colorScheme.primary
|
||||
: theme.brightness == Brightness.dark
|
||||
? Colors.grey[800]!
|
||||
: Colors.grey[200]!;
|
||||
|
||||
final textColor = isCurrentUser
|
||||
? Colors.white
|
||||
: theme.brightness == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black87;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(18),
|
||||
topRight: const Radius.circular(18),
|
||||
bottomLeft: isCurrentUser
|
||||
? const Radius.circular(18)
|
||||
: const Radius.circular(4),
|
||||
bottomRight: isCurrentUser
|
||||
? const Radius.circular(4)
|
||||
: const Radius.circular(18),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Nom de l'expéditeur (seulement pour messages reçus ET premier du groupe)
|
||||
if (!isCurrentUser && isFirstInGroup && message.senderFullName.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
message.senderFullName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Pièce jointe ou contenu
|
||||
if (message.hasAttachment)
|
||||
_buildAttachment(textColor, theme)
|
||||
else
|
||||
Text(
|
||||
message.content,
|
||||
style: TextStyle(fontSize: 15, color: textColor),
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Timestamp et statuts
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
ChatDateFormatter.formatMessageTimestamp(message.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isCurrentUser
|
||||
? Colors.white.withOpacity(0.7)
|
||||
: textColor.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
if (isCurrentUser) ...[
|
||||
const SizedBox(width: 4),
|
||||
_buildMessageStatus(),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Construit l'indicateur de statut du message (envoyé/délivré/lu).
|
||||
Widget _buildMessageStatus() {
|
||||
// DEBUG: Afficher les statuts dans la console
|
||||
if (isCurrentUser) {
|
||||
AppLogger.d('Message ${message.id.substring(0, 8)}: isRead=${message.isRead}, isDelivered=${message.isDelivered}', tag: 'MessageBubble');
|
||||
}
|
||||
|
||||
if (message.isRead) {
|
||||
// Lu : Double check bleu (très visible)
|
||||
return const Icon(
|
||||
Icons.done_all,
|
||||
size: 18,
|
||||
color: Color(0xFF0096FF), // Bleu vif pour "lu"
|
||||
);
|
||||
} else if (message.isDelivered) {
|
||||
// Délivré : Double check blanc/gris clair (bien visible)
|
||||
return Icon(
|
||||
Icons.done_all,
|
||||
size: 18,
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
);
|
||||
} else {
|
||||
// Envoyé : Simple check blanc/gris clair
|
||||
return Icon(
|
||||
Icons.done,
|
||||
size: 18,
|
||||
color: Colors.white.withOpacity(0.95),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit l'affichage d'une pièce jointe.
|
||||
Widget _buildAttachment(Color textColor, ThemeData theme) {
|
||||
if (message.attachmentType == AttachmentType.image) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
message.attachmentUrl!,
|
||||
width: 250,
|
||||
height: 250,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
width: 250,
|
||||
height: 250,
|
||||
color: Colors.grey[300],
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.broken_image,
|
||||
size: 48,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Image non disponible',
|
||||
style: TextStyle(color: Colors.grey[600], fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (message.content.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message.content,
|
||||
style: TextStyle(fontSize: 15, color: textColor),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Autres types d'attachments (vidéo, audio, fichier)
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_getAttachmentIcon(), color: textColor, size: 28),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_getAttachmentTypeName(),
|
||||
style: TextStyle(
|
||||
color: textColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (message.content.isNotEmpty)
|
||||
Text(
|
||||
message.content,
|
||||
style: TextStyle(
|
||||
color: textColor.withOpacity(0.8),
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Retourne l'icône correspondant au type d'attachment.
|
||||
IconData _getAttachmentIcon() {
|
||||
switch (message.attachmentType) {
|
||||
case AttachmentType.video:
|
||||
return Icons.videocam_rounded;
|
||||
case AttachmentType.audio:
|
||||
return Icons.audiotrack_rounded;
|
||||
case AttachmentType.file:
|
||||
return Icons.insert_drive_file_rounded;
|
||||
default:
|
||||
return Icons.attach_file_rounded;
|
||||
}
|
||||
}
|
||||
|
||||
/// Retourne le nom d'affichage du type d'attachment.
|
||||
String _getAttachmentTypeName() {
|
||||
switch (message.attachmentType) {
|
||||
case AttachmentType.video:
|
||||
return 'Vidéo';
|
||||
case AttachmentType.audio:
|
||||
return 'Audio';
|
||||
case AttachmentType.file:
|
||||
return 'Fichier';
|
||||
default:
|
||||
return 'Pièce jointe';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user