feat: Implement message management features including sending, deleting, updating, and reacting to messages

This commit is contained in:
Dayron
2025-10-20 17:34:09 +02:00
parent 3d902ffa74
commit a2e366e1ce
9 changed files with 1354 additions and 20 deletions

View File

@@ -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<MessageEvent, MessageState> {
final MessageService _messageService;
StreamSubscription<List<Message>>? _messagesSubscription;
MessageBloc({MessageService? messageService})
: _messageService = messageService ?? MessageService(
messageRepository: MessageRepository(),
),
super(MessageInitial()) {
on<LoadMessages>(_onLoadMessages);
on<SendMessage>(_onSendMessage);
on<DeleteMessage>(_onDeleteMessage);
on<UpdateMessage>(_onUpdateMessage);
on<ReactToMessage>(_onReactToMessage);
on<RemoveReaction>(_onRemoveReaction);
on<_MessagesUpdated>(_onMessagesUpdated);
}
Future<void> _onLoadMessages(
LoadMessages event,
Emitter<MessageState> 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<MessageState> emit,
) {
emit(MessagesLoaded(messages: event.messages, groupId: event.groupId));
}
Future<void> _onSendMessage(
SendMessage event,
Emitter<MessageState> 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<void> _onDeleteMessage(
DeleteMessage event,
Emitter<MessageState> 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<void> _onUpdateMessage(
UpdateMessage event,
Emitter<MessageState> 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<void> _onReactToMessage(
ReactToMessage event,
Emitter<MessageState> 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<void> _onRemoveReaction(
RemoveReaction event,
Emitter<MessageState> 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<void> close() {
_messagesSubscription?.cancel();
return super.close();
}
}
// Events internes
class _MessagesUpdated extends MessageEvent {
final List<Message> messages;
final String groupId;
const _MessagesUpdated({
required this.messages,
required this.groupId,
});
@override
List<Object?> get props => [messages, groupId];
}
class _MessagesError extends MessageEvent {
final String error;
const _MessagesError(this.error);
@override
List<Object?> get props => [error];
}

View File

@@ -0,0 +1,94 @@
import 'package:equatable/equatable.dart';
abstract class MessageEvent extends Equatable {
const MessageEvent();
@override
List<Object?> get props => [];
}
class LoadMessages extends MessageEvent {
final String groupId;
const LoadMessages(this.groupId);
@override
List<Object?> 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<Object?> 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<Object?> 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<Object?> 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<Object?> 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<Object?> get props => [groupId, messageId, userId];
}

View File

@@ -0,0 +1,51 @@
import 'package:equatable/equatable.dart';
import '../../data/models/message.dart';
abstract class MessageState extends Equatable {
const MessageState();
@override
List<Object?> get props => [];
}
class MessageInitial extends MessageState {}
class MessageLoading extends MessageState {}
class MessagesLoaded extends MessageState {
final List<Message> messages;
final String groupId;
const MessagesLoaded({
required this.messages,
required this.groupId,
});
@override
List<Object?> 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<Object?> get props => [message];
}

View File

@@ -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<ChatGroupContent> createState() => _ChatGroupContentState();
}
class _ChatGroupContentState extends State<ChatGroupContent> {
final _messageController = TextEditingController();
final _scrollController = ScrollController();
Message? _editingMessage;
@override
void initState() {
super.initState();
// Charger les messages au démarrage
context.read<MessageBloc>().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<MessageBloc>().add(
UpdateMessage(
groupId: widget.group.id,
messageId: _editingMessage!.id,
newText: messageText,
),
);
_cancelEdit();
} else {
// Mode envoi
context.read<MessageBloc>().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<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) {
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<MessageBloc, MessageState>(
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<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];
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'),
),
],
),
);
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:travel_mate/components/error/error_content.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_bloc.dart';
import '../../blocs/user/user_state.dart' as user_state; import '../../blocs/user/user_state.dart' as user_state;
import '../../blocs/group/group_bloc.dart'; import '../../blocs/group/group_bloc.dart';
@@ -167,7 +168,6 @@ class _GroupContentState extends State<GroupContent> {
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// IMPORTANT: Utiliser un simple Column au lieu de GridView pour tester
...groups.map((group) { ...groups.map((group) {
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
@@ -289,24 +289,13 @@ class _GroupContentState extends State<GroupContent> {
void _openGroupChat(Group group) { void _openGroupChat(Group group) {
try { try {
// Afficher juste un message, pas de navigation pour l'instant // Navigation vers la page de chat
if (mounted) { Navigator.push(
ScaffoldMessenger.of(context).showSnackBar( context,
SnackBar( MaterialPageRoute(
content: Text('Chat: ${group.name} (${group.members.length} membres)'), builder: (context) => ChatGroupContent(group: group),
duration: const Duration(seconds: 2),
), ),
); );
}
// TODO: Navigation vers la page de chat
// Navigator.push(
// context,
// MaterialPageRoute(
// builder: (context) => GroupChatPage(group: group),
// ),
// );
} catch (e) { } catch (e) {
_buildErrorState(e.toString(), '', false); _buildErrorState(e.toString(), '', false);
} }

View File

@@ -1,15 +1,81 @@
import 'package:cloud_firestore/cloud_firestore.dart';
class Message { class Message {
final String id;
final String text; final String text;
final DateTime timestamp; final DateTime timestamp;
final String senderId; final String senderId;
final String senderName; final String senderName;
final String groupId; final String groupId;
final Map<String, String> reactions; // userId -> emoji
final DateTime? editedAt;
final bool isEdited;
Message({ Message({
this.id = '',
required this.text, required this.text,
required this.timestamp, required this.timestamp,
required this.senderId, required this.senderId,
required this.senderName, required this.senderName,
required this.groupId, required this.groupId,
this.reactions = const {},
this.editedAt,
this.isEdited = false,
}); });
factory Message.fromFirestore(DocumentSnapshot doc) {
final data = doc.data() as Map<String, dynamic>;
final timestamp = data['timestamp'] as Timestamp?;
final editedAtTimestamp = data['editedAt'] as Timestamp?;
final reactionsData = data['reactions'] as Map<String, dynamic>?;
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<String, dynamic> 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<String, String>? 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,
);
}
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:firebase_core/firebase_core.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:travel_mate/services/error_service.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'blocs/auth/auth_bloc.dart'; import 'blocs/auth/auth_bloc.dart';
@@ -15,6 +16,7 @@ import 'repositories/auth_repository.dart';
import 'repositories/trip_repository.dart'; import 'repositories/trip_repository.dart';
import 'repositories/user_repository.dart'; import 'repositories/user_repository.dart';
import 'repositories/group_repository.dart'; import 'repositories/group_repository.dart';
import 'repositories/message_repository.dart';
import 'pages/login.dart'; import 'pages/login.dart';
import 'pages/home.dart'; import 'pages/home.dart';
import 'pages/signup.dart'; import 'pages/signup.dart';
@@ -47,6 +49,9 @@ class MyApp extends StatelessWidget {
RepositoryProvider<GroupRepository>( RepositoryProvider<GroupRepository>(
create: (context) => GroupRepository(), create: (context) => GroupRepository(),
), ),
RepositoryProvider<MessageRepository>(
create: (context) => MessageRepository(),
),
], ],
child: MultiBlocProvider( child: MultiBlocProvider(
providers: [ providers: [
@@ -66,6 +71,9 @@ class MyApp extends StatelessWidget {
TripBloc(context.read<TripRepository>()), TripBloc(context.read<TripRepository>()),
), ),
BlocProvider(create: (context) => UserBloc()), BlocProvider(create: (context) => UserBloc()),
BlocProvider(
create: (context) => MessageBloc(),
),
], ],
child: BlocBuilder<ThemeBloc, ThemeState>( child: BlocBuilder<ThemeBloc, ThemeState>(
builder: (context, themeState) { builder: (context, themeState) {

View File

@@ -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<void> 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<List<Message>> 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<void> deleteMessage({
required String groupId,
required String messageId,
}) async {
await _firestore
.collection('groups')
.doc(groupId)
.collection('messages')
.doc(messageId)
.delete();
}
// Modifier un message
Future<void> 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<void> 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<void> 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(),
});
}
}

View File

@@ -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<void> 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<List<Message>> 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<void> 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<void> 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<void> 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<void> 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;
}
}
}