Files
TravelMate/lib/components/group/chat_group_content.dart

884 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'dart:async';
import '../../blocs/user/user_bloc.dart';
import '../../blocs/user/user_state.dart' as user_state;
import '../../blocs/message/message_bloc.dart';
import '../../blocs/message/message_event.dart';
import '../../blocs/message/message_state.dart';
import '../../models/group.dart';
import '../../models/group_member.dart';
import '../../models/message.dart';
import '../../repositories/group_repository.dart';
import '../../services/error_service.dart';
/// Chat group content widget for group messaging functionality.
///
/// This widget provides a complete chat interface for group members to
/// communicate within a travel group. Features include:
/// - Real-time message loading and sending
/// - Message editing and deletion
/// - Message reactions (like/unlike)
/// - Scroll-to-bottom functionality
/// - Message status indicators
///
/// The widget integrates with MessageBloc for state management and
/// handles various message operations through the bloc pattern.
class ChatGroupContent extends StatefulWidget {
/// The group for which to display the chat interface
final Group group;
/// Creates a chat group content widget.
///
/// Args:
/// [group]: The group object containing group details and ID
const ChatGroupContent({super.key, required this.group});
@override
State<ChatGroupContent> createState() => _ChatGroupContentState();
}
class _ChatGroupContentState extends State<ChatGroupContent> {
/// Controller for the message input field
final _messageController = TextEditingController();
/// Controller for managing scroll position in the message list
final _scrollController = ScrollController();
/// Currently selected message for editing (null if not editing)
Message? _editingMessage;
/// Repository pour gérer les groupes
final _groupRepository = GroupRepository();
/// Subscription pour écouter les changements des membres du groupe
late StreamSubscription<List<GroupMember>> _membersSubscription;
@override
void initState() {
super.initState();
// Load messages when the widget initializes
context.read<MessageBloc>().add(LoadMessages(widget.group.id));
// Écouter les changements des membres du groupe
_membersSubscription = _groupRepository
.watchGroupMembers(widget.group.id)
.listen((updatedMembers) {
if (mounted) {
setState(() {
widget.group.members.clear();
widget.group.members.addAll(updatedMembers);
});
}
});
}
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
_membersSubscription.cancel();
super.dispose();
}
/// Sends a new message or updates an existing message.
///
/// Handles both sending new messages and editing existing ones based
/// on the current editing state. Validates input and clears the input
/// field after successful submission.
///
/// Args:
/// [currentUser]: The user sending or editing the message
void _sendMessage(user_state.UserModel currentUser) {
final messageText = _messageController.text.trim();
if (messageText.isEmpty) return;
if (_editingMessage != null) {
// Edit mode - update existing message
context.read<MessageBloc>().add(
UpdateMessage(
groupId: widget.group.id,
messageId: _editingMessage!.id,
newText: messageText,
),
);
_cancelEdit();
} else {
// Send mode - create new message
context.read<MessageBloc>().add(
SendMessage(
groupId: widget.group.id,
text: messageText,
senderId: currentUser.id,
senderName: currentUser.prenom,
),
);
}
_messageController.clear();
}
/// Initiates editing mode for a selected message.
///
/// Sets the message as the currently editing message and populates
/// the input field with the message text for modification.
///
/// Args:
/// [message]: The message to edit
void _editMessage(Message message) {
setState(() {
_editingMessage = message;
_messageController.text = message.text;
});
}
/// Cancels the current editing operation.
///
/// Resets the editing state and clears the input field,
/// returning to normal message sending mode.
void _cancelEdit() {
setState(() {
_editingMessage = null;
_messageController.clear();
});
}
/// Deletes a message from the group chat.
///
/// Sends a delete event to the MessageBloc to remove the specified
/// message from the group's message history.
///
/// Args:
/// [messageId]: The ID of the message to delete
void _deleteMessage(String messageId) {
context.read<MessageBloc>().add(
DeleteMessage(groupId: widget.group.id, messageId: messageId),
);
}
void _reactToMessage(String messageId, String userId, String reaction) {
context.read<MessageBloc>().add(
ReactToMessage(
groupId: widget.group.id,
messageId: messageId,
userId: userId,
reaction: reaction,
),
);
}
void _removeReaction(String messageId, String userId) {
context.read<MessageBloc>().add(
RemoveReaction(
groupId: widget.group.id,
messageId: messageId,
userId: userId,
),
);
}
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return BlocBuilder<UserBloc, user_state.UserState>(
builder: (context, userState) {
if (userState is! user_state.UserLoaded) {
return Scaffold(
appBar: AppBar(title: Text(widget.group.name)),
body: const Center(child: Text('Utilisateur non connecté')),
);
}
final currentUser = userState.user;
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.group.name, style: const TextStyle(fontSize: 18)),
Text(
'${widget.group.members.length} membre${widget.group.members.length > 1 ? 's' : ''}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
),
),
],
),
actions: [
IconButton(
icon: const Icon(Icons.people),
onPressed: () => _showMembersDialog(context),
),
],
),
body: Column(
children: [
// Liste des messages
Expanded(
child: BlocConsumer<MessageBloc, MessageState>(
listener: (context, state) {
if (state is MessageError) {
ErrorService().showError(message: state.message);
}
},
builder: (context, state) {
if (state is MessageLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is MessagesLoaded) {
if (state.messages.isEmpty) {
return _buildEmptyState();
}
// Scroller automatiquement vers le bas au chargement
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.jumpTo(
_scrollController.position.maxScrollExtent,
);
}
});
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.all(16),
itemCount: state.messages.length,
itemBuilder: (context, index) {
final message = state.messages[index];
final isMe = message.senderId == currentUser.id;
final showDate =
index == 0 ||
!_isSameDay(
state.messages[index - 1].timestamp,
message.timestamp,
);
return Column(
children: [
if (showDate)
_buildDateSeparator(message.timestamp),
_buildMessageBubble(
message,
isMe,
isDark,
currentUser.id,
),
],
);
},
);
}
return _buildEmptyState();
},
),
),
// Barre d'édition si un message est en cours de modification
if (_editingMessage != null)
Container(
color: isDark ? Colors.blue[900] : Colors.blue[100],
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row(
children: [
const Icon(Icons.edit, size: 20),
const SizedBox(width: 8),
const Expanded(child: Text('Modification du message')),
IconButton(
icon: const Icon(Icons.close),
onPressed: _cancelEdit,
),
],
),
),
// Zone de saisie
Container(
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
border: Border(
top: BorderSide(
color: isDark ? Colors.grey[800]! : Colors.grey[300]!,
width: 1,
),
),
),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
child: SafeArea(
child: Row(
children: [
Expanded(
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: _editingMessage != null
? 'Modifier le message...'
: 'Écrire un message...',
filled: true,
fillColor: isDark
? Colors.grey[850]
: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
maxLines: null,
textCapitalization: TextCapitalization.sentences,
),
),
const SizedBox(width: 8),
IconButton(
onPressed: () => _sendMessage(currentUser),
icon: Icon(
_editingMessage != null ? Icons.check : Icons.send,
),
style: IconButton.styleFrom(
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(12),
),
),
],
),
),
),
],
),
);
},
);
}
Widget _buildEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.chat_bubble_outline, size: 80, color: Colors.grey[400]),
const SizedBox(height: 16),
const Text(
'Aucun message',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Commencez la conversation !',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
],
),
),
);
}
Widget _buildMessageBubble(
Message message,
bool isMe,
bool isDark,
String currentUserId,
) {
final Color bubbleColor;
final Color textColor;
if (isMe) {
bubbleColor = isDark ? const Color(0xFF1E3A5F) : const Color(0xFF90CAF9);
textColor = isDark ? Colors.white : Colors.black87;
} else {
bubbleColor = isDark ? Colors.grey[800]! : Colors.grey[200]!;
textColor = isDark ? Colors.white : Colors.black87;
}
// Trouver le membre qui a envoyé le message pour récupérer son pseudo actuel
final senderMember =
widget.group.members.firstWhere(
(m) => m.userId == message.senderId,
orElse: () => null as dynamic,
)
as dynamic;
// Utiliser le pseudo actuel du membre, ou le senderName en fallback
final displayName = senderMember != null
? senderMember.pseudo
: message.senderName;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: GestureDetector(
onLongPress: () =>
_showMessageOptions(context, message, isMe, currentUserId),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisAlignment: isMe
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Avatar du sender (seulement pour les autres messages)
if (!isMe) ...[
CircleAvatar(
radius: 16,
backgroundImage:
(senderMember != null &&
senderMember.profilePictureUrl != null &&
senderMember.profilePictureUrl!.isNotEmpty)
? NetworkImage(senderMember.profilePictureUrl!)
: null,
child:
(senderMember == null ||
senderMember.profilePictureUrl == null ||
senderMember.profilePictureUrl!.isEmpty)
? Text(
displayName.isNotEmpty
? displayName[0].toUpperCase()
: '?',
style: const TextStyle(fontSize: 12),
)
: null,
),
const SizedBox(width: 8),
],
Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: Radius.circular(isMe ? 16 : 4),
bottomRight: Radius.circular(isMe ? 4 : 16),
),
),
child: Column(
crossAxisAlignment: isMe
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
if (!isMe) ...[
Text(
displayName,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isDark ? Colors.grey[400] : Colors.grey[700],
),
),
const SizedBox(height: 4),
],
Text(
message.isDeleted
? 'a supprimé un message'
: message.text,
style: TextStyle(
fontSize: 15,
color: message.isDeleted
? textColor.withValues(alpha: 0.5)
: textColor,
fontStyle: message.isDeleted
? FontStyle.italic
: FontStyle.normal,
),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatTime(message.timestamp),
style: TextStyle(
fontSize: 11,
color: textColor.withValues(alpha: 0.7),
),
),
if (message.isEdited) ...[
const SizedBox(width: 4),
Text(
'(modifié)',
style: TextStyle(
fontSize: 10,
fontStyle: FontStyle.italic,
color: textColor.withValues(alpha: 0.6),
),
),
],
],
),
// Afficher les réactions
if (message.reactions.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Wrap(
spacing: 4,
children: _buildReactionChips(message, currentUserId),
),
),
],
),
),
],
),
),
),
);
}
List<Widget> _buildReactionChips(Message message, String currentUserId) {
final reactionCounts = <String, List<String>>{};
// Grouper les réactions par emoji
message.reactions.forEach((userId, emoji) {
reactionCounts.putIfAbsent(emoji, () => []).add(userId);
});
return reactionCounts.entries.map((entry) {
final emoji = entry.key;
final userIds = entry.value;
final hasReacted = userIds.contains(currentUserId);
return GestureDetector(
onTap: () {
if (hasReacted) {
_removeReaction(message.id, currentUserId);
} else {
_reactToMessage(message.id, currentUserId, emoji);
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: hasReacted
? Colors.blue.withValues(alpha: 0.3)
: Colors.grey.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
border: hasReacted
? Border.all(color: Colors.blue, width: 1)
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(emoji, style: const TextStyle(fontSize: 12)),
const SizedBox(width: 2),
Text(
'${userIds.length}',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
),
);
}).toList();
}
void _showMessageOptions(
BuildContext context,
Message message,
bool isMe,
String currentUserId,
) {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Réactions rapides
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: ['👍', '❤️', '😂', '😮', '😢', '🔥'].map((emoji) {
return GestureDetector(
onTap: () {
Navigator.pop(context);
_reactToMessage(message.id, currentUserId, emoji);
},
child: Text(emoji, style: const TextStyle(fontSize: 32)),
);
}).toList(),
),
),
const Divider(height: 1),
if (isMe) ...[
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Modifier'),
onTap: () {
Navigator.pop(context);
_editMessage(message);
},
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text(
'Supprimer',
style: TextStyle(color: Colors.red),
),
onTap: () {
Navigator.pop(context);
_showDeleteConfirmation(context, message.id);
},
),
],
ListTile(
leading: const Icon(Icons.close),
title: const Text('Annuler'),
onTap: () => Navigator.pop(context),
),
],
),
),
);
}
void _showDeleteConfirmation(BuildContext context, String messageId) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Supprimer le message'),
content: const Text('Êtes-vous sûr de vouloir supprimer ce message ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
TextButton(
onPressed: () {
Navigator.pop(context);
_deleteMessage(messageId);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Supprimer'),
),
],
),
);
}
// Helpers pour les dates
bool _isSameDay(DateTime date1, DateTime date2) {
return date1.year == date2.year &&
date1.month == date2.month &&
date1.day == date2.day;
}
String _formatTime(DateTime date) {
final hour = date.hour.toString().padLeft(2, '0');
final minute = date.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final yesterday = today.subtract(const Duration(days: 1));
final messageDate = DateTime(date.year, date.month, date.day);
if (messageDate == today) {
return 'Aujourd\'hui';
} else if (messageDate == yesterday) {
return 'Hier';
} else {
return '${date.day}/${date.month}/${date.year}';
}
}
Widget _buildDateSeparator(DateTime date) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_formatDate(date),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
),
),
);
}
void _showMembersDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text('Membres (${widget.group.members.length})'),
content: SizedBox(
width: double.maxFinite,
child: ListView.builder(
shrinkWrap: true,
itemCount: widget.group.members.length,
itemBuilder: (context, index) {
final member = widget.group.members[index];
final initials = member.pseudo.isNotEmpty
? member.pseudo[0].toUpperCase()
: (member.firstName.isNotEmpty
? member.firstName[0].toUpperCase()
: '?');
// Construire le nom complet
final fullName = '${member.firstName} ${member.lastName}'.trim();
return ListTile(
leading: CircleAvatar(
backgroundImage:
(member.profilePictureUrl != null &&
member.profilePictureUrl!.isNotEmpty)
? NetworkImage(member.profilePictureUrl!)
: null,
child:
(member.profilePictureUrl == null ||
member.profilePictureUrl!.isEmpty)
? Text(initials)
: null,
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(member.pseudo),
if (fullName.isNotEmpty)
Text(
fullName,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
),
),
],
),
subtitle: member.role == 'admin'
? const Text(
'Administrateur',
style: TextStyle(fontSize: 12),
)
: null,
trailing: IconButton(
icon: const Icon(Icons.edit, size: 18),
onPressed: () {
Navigator.pop(context);
_showChangePseudoDialog(member);
},
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
],
),
);
}
void _showChangePseudoDialog(dynamic member) {
final pseudoController = TextEditingController(text: member.pseudo);
final theme = Theme.of(context);
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor:
theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface,
title: Text(
'Changer le pseudo',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurface,
),
),
content: TextField(
controller: pseudoController,
decoration: InputDecoration(
hintText: 'Nouveau pseudo',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
style: TextStyle(color: theme.colorScheme.onSurface),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Annuler',
style: TextStyle(color: theme.colorScheme.primary),
),
),
TextButton(
onPressed: () {
final newPseudo = pseudoController.text.trim();
if (newPseudo.isNotEmpty) {
_updateMemberPseudo(member, newPseudo);
Navigator.pop(context);
}
},
child: Text(
'Valider',
style: TextStyle(color: theme.colorScheme.primary),
),
),
],
),
);
}
Future<void> _updateMemberPseudo(dynamic member, String newPseudo) async {
try {
final updatedMember = member.copyWith(pseudo: newPseudo);
await _groupRepository.addMember(widget.group.id, updatedMember);
if (mounted) {
// Le stream listener va automatiquement mettre à jour les membres
// Pas besoin de fermer le dialog ou de faire un refresh manuel
ErrorService().showSnackbar(
message: 'Pseudo modifié en "$newPseudo"',
isError: false,
);
}
} catch (e) {
if (mounted) {
ErrorService().showError(
message: 'Erreur lors de la modification du pseudo: $e',
);
}
}
}
}