## 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
324 lines
9.9 KiB
Dart
324 lines
9.9 KiB
Dart
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';
|
|
}
|
|
}
|
|
}
|