diff --git a/lib/blocs/message/message_bloc.dart b/lib/blocs/message/message_bloc.dart new file mode 100644 index 0000000..a619d5c --- /dev/null +++ b/lib/blocs/message/message_bloc.dart @@ -0,0 +1,238 @@ +import 'dart:async'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../data/models/message.dart'; +import '../../services/message_service.dart'; +import '../../repositories/message_repository.dart'; +import 'message_event.dart'; +import 'message_state.dart'; + +class MessageBloc extends Bloc { + final MessageService _messageService; + StreamSubscription>? _messagesSubscription; + + MessageBloc({MessageService? messageService}) + : _messageService = messageService ?? MessageService( + messageRepository: MessageRepository(), + ), + super(MessageInitial()) { + on(_onLoadMessages); + on(_onSendMessage); + on(_onDeleteMessage); + on(_onUpdateMessage); + on(_onReactToMessage); + on(_onRemoveReaction); + on<_MessagesUpdated>(_onMessagesUpdated); + } + + Future _onLoadMessages( + LoadMessages event, + Emitter emit, + ) async { + emit(MessageLoading()); + + await _messagesSubscription?.cancel(); + + _messagesSubscription = _messageService + .getMessagesStream(event.groupId) + .listen( + (messages) { + add(_MessagesUpdated(messages: messages, groupId: event.groupId)); + }, + onError: (error) { + add(_MessagesError('Erreur lors du chargement des messages: $error')); + }, + ); + } + + void _onMessagesUpdated( + _MessagesUpdated event, + Emitter emit, + ) { + emit(MessagesLoaded(messages: event.messages, groupId: event.groupId)); + } + + Future _onSendMessage( + SendMessage event, + Emitter emit, + ) async { + final currentState = state; + + try { + emit(MessageSending()); + + await _messageService.sendMessage( + groupId: event.groupId, + text: event.text, + senderId: event.senderId, + senderName: event.senderName, + ); + + emit(MessageSent()); + + // Retourner à l'état précédent si c'était MessagesLoaded + if (currentState is MessagesLoaded) { + emit(currentState); + } + } catch (e) { + emit(MessageError('Erreur lors de l\'envoi du message: $e')); + + // Retourner à l'état précédent + if (currentState is MessagesLoaded) { + emit(currentState); + } + } + } + + Future _onDeleteMessage( + DeleteMessage event, + Emitter emit, + ) async { + final currentState = state; + + try { + emit(MessageDeleting()); + + await _messageService.deleteMessage( + groupId: event.groupId, + messageId: event.messageId, + ); + + emit(MessageDeleted()); + + // Retourner à l'état précédent si c'était MessagesLoaded + if (currentState is MessagesLoaded) { + emit(currentState); + } + } catch (e) { + emit(MessageError('Erreur lors de la suppression du message: $e')); + + // Retourner à l'état précédent + if (currentState is MessagesLoaded) { + emit(currentState); + } + } + } + + Future _onUpdateMessage( + UpdateMessage event, + Emitter emit, + ) async { + final currentState = state; + + try { + emit(MessageUpdating()); + + await _messageService.updateMessage( + groupId: event.groupId, + messageId: event.messageId, + newText: event.newText, + ); + + emit(MessageUpdated()); + + // Retourner à l'état précédent si c'était MessagesLoaded + if (currentState is MessagesLoaded) { + emit(currentState); + } + } catch (e) { + emit(MessageError('Erreur lors de la modification du message: $e')); + + // Retourner à l'état précédent + if (currentState is MessagesLoaded) { + emit(currentState); + } + } + } + + Future _onReactToMessage( + ReactToMessage event, + Emitter emit, + ) async { + final currentState = state; + + try { + emit(MessageReacting()); + + await _messageService.reactToMessage( + groupId: event.groupId, + messageId: event.messageId, + userId: event.userId, + reaction: event.reaction, + ); + + emit(MessageReacted()); + + // Retourner à l'état précédent si c'était MessagesLoaded + if (currentState is MessagesLoaded) { + emit(currentState); + } + } catch (e) { + emit(MessageError('Erreur lors de l\'ajout de la réaction: $e')); + + // Retourner à l'état précédent + if (currentState is MessagesLoaded) { + emit(currentState); + } + } + } + + Future _onRemoveReaction( + RemoveReaction event, + Emitter emit, + ) async { + final currentState = state; + + try { + emit(MessageReacting()); + + await _messageService.removeReaction( + groupId: event.groupId, + messageId: event.messageId, + userId: event.userId, + ); + + emit(MessageReacted()); + + // Retourner à l'état précédent si c'était MessagesLoaded + if (currentState is MessagesLoaded) { + emit(currentState); + } + } catch (e) { + emit(MessageError('Erreur lors de la suppression de la réaction: $e')); + + // Retourner à l'état précédent + if (currentState is MessagesLoaded) { + emit(currentState); + } + } + } + + @override + Future close() { + _messagesSubscription?.cancel(); + return super.close(); + } +} + +// Events internes +class _MessagesUpdated extends MessageEvent { + final List messages; + final String groupId; + + const _MessagesUpdated({ + required this.messages, + required this.groupId, + }); + + @override + List get props => [messages, groupId]; +} + +class _MessagesError extends MessageEvent { + final String error; + + const _MessagesError(this.error); + + @override + List get props => [error]; +} diff --git a/lib/blocs/message/message_event.dart b/lib/blocs/message/message_event.dart new file mode 100644 index 0000000..01c031b --- /dev/null +++ b/lib/blocs/message/message_event.dart @@ -0,0 +1,94 @@ +import 'package:equatable/equatable.dart'; + +abstract class MessageEvent extends Equatable { + const MessageEvent(); + + @override + List get props => []; +} + +class LoadMessages extends MessageEvent { + final String groupId; + + const LoadMessages(this.groupId); + + @override + List get props => [groupId]; +} + +class SendMessage extends MessageEvent { + final String groupId; + final String text; + final String senderId; + final String senderName; + + const SendMessage({ + required this.groupId, + required this.text, + required this.senderId, + required this.senderName, + }); + + @override + List get props => [groupId, text, senderId, senderName]; +} + +class DeleteMessage extends MessageEvent { + final String groupId; + final String messageId; + + const DeleteMessage({ + required this.groupId, + required this.messageId, + }); + + @override + List get props => [groupId, messageId]; +} + +class UpdateMessage extends MessageEvent { + final String groupId; + final String messageId; + final String newText; + + const UpdateMessage({ + required this.groupId, + required this.messageId, + required this.newText, + }); + + @override + List get props => [groupId, messageId, newText]; +} + +class ReactToMessage extends MessageEvent { + final String groupId; + final String messageId; + final String userId; + final String reaction; + + const ReactToMessage({ + required this.groupId, + required this.messageId, + required this.userId, + required this.reaction, + }); + + @override + List get props => [groupId, messageId, userId, reaction]; +} + +class RemoveReaction extends MessageEvent { + final String groupId; + final String messageId; + final String userId; + + const RemoveReaction({ + required this.groupId, + required this.messageId, + required this.userId, + }); + + @override + List get props => [groupId, messageId, userId]; +} diff --git a/lib/blocs/message/message_state.dart b/lib/blocs/message/message_state.dart new file mode 100644 index 0000000..560fa07 --- /dev/null +++ b/lib/blocs/message/message_state.dart @@ -0,0 +1,51 @@ +import 'package:equatable/equatable.dart'; +import '../../data/models/message.dart'; + +abstract class MessageState extends Equatable { + const MessageState(); + + @override + List get props => []; +} + +class MessageInitial extends MessageState {} + +class MessageLoading extends MessageState {} + +class MessagesLoaded extends MessageState { + final List messages; + final String groupId; + + const MessagesLoaded({ + required this.messages, + required this.groupId, + }); + + @override + List get props => [messages, groupId]; +} + +class MessageSending extends MessageState {} + +class MessageSent extends MessageState {} + +class MessageDeleting extends MessageState {} + +class MessageDeleted extends MessageState {} + +class MessageUpdating extends MessageState {} + +class MessageUpdated extends MessageState {} + +class MessageReacting extends MessageState {} + +class MessageReacted extends MessageState {} + +class MessageError extends MessageState { + final String message; + + const MessageError(this.message); + + @override + List get props => [message]; +} diff --git a/lib/components/group/chat_group_content.dart b/lib/components/group/chat_group_content.dart new file mode 100644 index 0000000..09ec5f6 --- /dev/null +++ b/lib/components/group/chat_group_content.dart @@ -0,0 +1,638 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +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 '../../data/models/group.dart'; +import '../../data/models/message.dart'; + +class ChatGroupContent extends StatefulWidget { + final Group group; + + const ChatGroupContent({ + super.key, + required this.group, + }); + + @override + State createState() => _ChatGroupContentState(); +} + +class _ChatGroupContentState extends State { + final _messageController = TextEditingController(); + final _scrollController = ScrollController(); + Message? _editingMessage; + + @override + void initState() { + super.initState(); + // Charger les messages au démarrage + context.read().add(LoadMessages(widget.group.id)); + } + + @override + void dispose() { + _messageController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _sendMessage(user_state.UserModel currentUser) { + final messageText = _messageController.text.trim(); + if (messageText.isEmpty) return; + + if (_editingMessage != null) { + // Mode édition + context.read().add( + UpdateMessage( + groupId: widget.group.id, + messageId: _editingMessage!.id, + newText: messageText, + ), + ); + _cancelEdit(); + } else { + // Mode envoi + context.read().add( + SendMessage( + groupId: widget.group.id, + text: messageText, + senderId: currentUser.id, + senderName: currentUser.prenom, + ), + ); + } + + _messageController.clear(); + } + + void _editMessage(Message message) { + setState(() { + _editingMessage = message; + _messageController.text = message.text; + }); + } + + void _cancelEdit() { + setState(() { + _editingMessage = null; + _messageController.clear(); + }); + } + + void _deleteMessage(String messageId) { + context.read().add( + DeleteMessage( + groupId: widget.group.id, + messageId: messageId, + ), + ); + } + + void _reactToMessage(String messageId, String userId, String reaction) { + context.read().add( + ReactToMessage( + groupId: widget.group.id, + messageId: messageId, + userId: userId, + reaction: reaction, + ), + ); + } + + void _removeReaction(String messageId, String userId) { + context.read().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( + 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( + listener: (context, state) { + if (state is MessageError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.message), + backgroundColor: Colors.red, + ), + ); + } else if (state is MessageSent || state is MessageUpdated) { + // Scroller vers le bas après l'envoi + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + }, + 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), + BlocBuilder( + builder: (context, state) { + if (state is MessageSending || state is MessageUpdating) { + return Container( + width: 48, + height: 48, + padding: const EdgeInsets.all(12), + child: const CircularProgressIndicator(strokeWidth: 2), + ); + } + return 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; + } + + return Align( + alignment: isMe ? Alignment.centerRight : Alignment.centerLeft, + child: GestureDetector( + onLongPress: () => _showMessageOptions(context, message, isMe, currentUserId), + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.7, + ), + 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( + message.senderName, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: isDark ? Colors.grey[400] : Colors.grey[700], + ), + ), + const SizedBox(height: 4), + ], + Text( + message.text, + style: TextStyle(fontSize: 15, color: textColor), + ), + 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 _buildReactionChips(Message message, String currentUserId) { + final reactionCounts = >{}; + + // 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]; + return ListTile( + leading: CircleAvatar( + child: Text(member.pseudo.substring(0, 1).toUpperCase()), + ), + title: Text(member.pseudo), + ); + }, + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Fermer'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/components/group/group_content.dart b/lib/components/group/group_content.dart index 4e49e28..dc77fb9 100644 --- a/lib/components/group/group_content.dart +++ b/lib/components/group/group_content.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:travel_mate/components/error/error_content.dart'; +import 'package:travel_mate/components/group/chat_group_content.dart'; import '../../blocs/user/user_bloc.dart'; import '../../blocs/user/user_state.dart' as user_state; import '../../blocs/group/group_bloc.dart'; @@ -167,7 +168,6 @@ class _GroupContentState extends State { ), const SizedBox(height: 24), - // IMPORTANT: Utiliser un simple Column au lieu de GridView pour tester ...groups.map((group) { return Padding( padding: const EdgeInsets.only(bottom: 12), @@ -289,24 +289,13 @@ class _GroupContentState extends State { void _openGroupChat(Group group) { try { - // Afficher juste un message, pas de navigation pour l'instant - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Chat: ${group.name} (${group.members.length} membres)'), - duration: const Duration(seconds: 2), - ), - ); - } - - // TODO: Navigation vers la page de chat - // Navigator.push( - // context, - // MaterialPageRoute( - // builder: (context) => GroupChatPage(group: group), - // ), - // ); - + // Navigation vers la page de chat + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ChatGroupContent(group: group), + ), + ); } catch (e) { _buildErrorState(e.toString(), '', false); } diff --git a/lib/data/models/message.dart b/lib/data/models/message.dart index d8f48f1..4c3a099 100644 --- a/lib/data/models/message.dart +++ b/lib/data/models/message.dart @@ -1,15 +1,81 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + class Message { - final String text; + final String id; + final String text; final DateTime timestamp; final String senderId; final String senderName; final String groupId; + final Map reactions; // userId -> emoji + final DateTime? editedAt; + final bool isEdited; Message({ + this.id = '', required this.text, required this.timestamp, required this.senderId, required this.senderName, required this.groupId, + this.reactions = const {}, + this.editedAt, + this.isEdited = false, }); + + factory Message.fromFirestore(DocumentSnapshot doc) { + final data = doc.data() as Map; + final timestamp = data['timestamp'] as Timestamp?; + final editedAtTimestamp = data['editedAt'] as Timestamp?; + final reactionsData = data['reactions'] as Map?; + + return Message( + id: doc.id, + text: data['text'] ?? '', + timestamp: timestamp?.toDate() ?? DateTime.now(), + senderId: data['senderId'] ?? '', + senderName: data['senderName'] ?? 'Anonyme', + groupId: data['groupId'] ?? '', + reactions: reactionsData?.map((key, value) => MapEntry(key, value.toString())) ?? {}, + editedAt: editedAtTimestamp?.toDate(), + isEdited: data['isEdited'] ?? false, + ); + } + + Map toFirestore() { + return { + 'text': text, + 'senderId': senderId, + 'senderName': senderName, + 'timestamp': Timestamp.fromDate(timestamp), + 'groupId': groupId, + 'reactions': reactions, + 'editedAt': editedAt != null ? Timestamp.fromDate(editedAt!) : null, + 'isEdited': isEdited, + }; + } + + Message copyWith({ + String? id, + String? text, + DateTime? timestamp, + String? senderId, + String? senderName, + String? groupId, + Map? reactions, + DateTime? editedAt, + bool? isEdited, + }) { + return Message( + id: id ?? this.id, + text: text ?? this.text, + timestamp: timestamp ?? this.timestamp, + senderId: senderId ?? this.senderId, + senderName: senderName ?? this.senderName, + groupId: groupId ?? this.groupId, + reactions: reactions ?? this.reactions, + editedAt: editedAt ?? this.editedAt, + isEdited: isEdited ?? this.isEdited, + ); + } } \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index 87c0783..6ba1216 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:travel_mate/blocs/message/message_bloc.dart'; import 'package:travel_mate/services/error_service.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'blocs/auth/auth_bloc.dart'; @@ -15,6 +16,7 @@ import 'repositories/auth_repository.dart'; import 'repositories/trip_repository.dart'; import 'repositories/user_repository.dart'; import 'repositories/group_repository.dart'; +import 'repositories/message_repository.dart'; import 'pages/login.dart'; import 'pages/home.dart'; import 'pages/signup.dart'; @@ -47,6 +49,9 @@ class MyApp extends StatelessWidget { RepositoryProvider( create: (context) => GroupRepository(), ), + RepositoryProvider( + create: (context) => MessageRepository(), + ), ], child: MultiBlocProvider( providers: [ @@ -66,6 +71,9 @@ class MyApp extends StatelessWidget { TripBloc(context.read()), ), BlocProvider(create: (context) => UserBloc()), + BlocProvider( + create: (context) => MessageBloc(), + ), ], child: BlocBuilder( builder: (context, themeState) { diff --git a/lib/repositories/message_repository.dart b/lib/repositories/message_repository.dart new file mode 100644 index 0000000..832640e --- /dev/null +++ b/lib/repositories/message_repository.dart @@ -0,0 +1,108 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import '../data/models/message.dart'; + +class MessageRepository { + final FirebaseFirestore _firestore; + + MessageRepository({FirebaseFirestore? firestore}) + : _firestore = firestore ?? FirebaseFirestore.instance; + + // Envoyer un message + Future sendMessage({ + required String groupId, + required String text, + required String senderId, + required String senderName, + }) async { + final message = { + 'text': text, + 'senderId': senderId, + 'senderName': senderName, + 'timestamp': FieldValue.serverTimestamp(), + 'groupId': groupId, + }; + + await _firestore + .collection('groups') + .doc(groupId) + .collection('messages') + .add(message); + } + + // Stream des messages + Stream> getMessagesStream(String groupId) { + return _firestore + .collection('groups') + .doc(groupId) + .collection('messages') + .orderBy('timestamp', descending: false) + .snapshots() + .map((snapshot) { + return snapshot.docs.map((doc) => Message.fromFirestore(doc)).toList(); + }); + } + + // Supprimer un message + Future deleteMessage({ + required String groupId, + required String messageId, + }) async { + await _firestore + .collection('groups') + .doc(groupId) + .collection('messages') + .doc(messageId) + .delete(); + } + + // Modifier un message + Future updateMessage({ + required String groupId, + required String messageId, + required String newText, + }) async { + await _firestore + .collection('groups') + .doc(groupId) + .collection('messages') + .doc(messageId) + .update({ + 'text': newText, + 'editedAt': FieldValue.serverTimestamp(), + 'isEdited': true, + }); + } + + // Ajouter une réaction + Future reactToMessage({ + required String groupId, + required String messageId, + required String userId, + required String reaction, + }) async { + await _firestore + .collection('groups') + .doc(groupId) + .collection('messages') + .doc(messageId) + .update({ + 'reactions.$userId': reaction, + }); + } + + // Supprimer une réaction + Future removeReaction({ + required String groupId, + required String messageId, + required String userId, + }) async { + await _firestore + .collection('groups') + .doc(groupId) + .collection('messages') + .doc(messageId) + .update({ + 'reactions.$userId': FieldValue.delete(), + }); + } +} diff --git a/lib/services/message_service.dart b/lib/services/message_service.dart index e69de29..35c107e 100644 --- a/lib/services/message_service.dart +++ b/lib/services/message_service.dart @@ -0,0 +1,142 @@ +import '../data/models/message.dart'; +import '../repositories/message_repository.dart'; +import 'error_service.dart'; + +class MessageService { + final MessageRepository _messageRepository; + final ErrorService _errorService; + + MessageService({ + required MessageRepository messageRepository, + ErrorService? errorService, + }) : _messageRepository = messageRepository, + _errorService = errorService ?? ErrorService(); + + // Envoyer un message + Future sendMessage({ + required String groupId, + required String text, + required String senderId, + required String senderName, + }) async { + try { + if (text.trim().isEmpty) { + throw Exception('Le message ne peut pas être vide'); + } + + await _messageRepository.sendMessage( + groupId: groupId, + text: text.trim(), + senderId: senderId, + senderName: senderName, + ); + } catch (e) { + _errorService.logError( + 'message_service.dart', + 'Erreur lors de l\'envoi du message: $e', + ); + rethrow; + } + } + + // Stream des messages + Stream> getMessagesStream(String groupId) { + try { + return _messageRepository.getMessagesStream(groupId); + } catch (e) { + _errorService.logError( + 'message_service.dart', + 'Erreur lors de la récupération des messages: $e', + ); + rethrow; + } + } + + // Supprimer un message + Future deleteMessage({ + required String groupId, + required String messageId, + }) async { + try { + await _messageRepository.deleteMessage( + groupId: groupId, + messageId: messageId, + ); + } catch (e) { + _errorService.logError( + 'message_service.dart', + 'Erreur lors de la suppression du message: $e', + ); + rethrow; + } + } + + // Modifier un message + Future updateMessage({ + required String groupId, + required String messageId, + required String newText, + }) async { + try { + if (newText.trim().isEmpty) { + throw Exception('Le message ne peut pas être vide'); + } + + await _messageRepository.updateMessage( + groupId: groupId, + messageId: messageId, + newText: newText.trim(), + ); + } catch (e) { + _errorService.logError( + 'message_service.dart', + 'Erreur lors de la modification du message: $e', + ); + rethrow; + } + } + + // Ajouter une réaction + Future reactToMessage({ + required String groupId, + required String messageId, + required String userId, + required String reaction, + }) async { + try { + await _messageRepository.reactToMessage( + groupId: groupId, + messageId: messageId, + userId: userId, + reaction: reaction, + ); + } catch (e) { + _errorService.logError( + 'message_service.dart', + 'Erreur lors de l\'ajout de la réaction: $e', + ); + rethrow; + } + } + + // Supprimer une réaction + Future removeReaction({ + required String groupId, + required String messageId, + required String userId, + }) async { + try { + await _messageRepository.removeReaction( + groupId: groupId, + messageId: messageId, + userId: userId, + ); + } catch (e) { + _errorService.logError( + 'message_service.dart', + 'Erreur lors de la suppression de la réaction: $e', + ); + rethrow; + } + } +}