Files
TravelMate/lib/components/group/chat_group_content.dart
Dayron 2faf37f145 Enhance model and service documentation with detailed comments and descriptions
- Updated Group, Trip, User, and other model classes to include comprehensive documentation for better understanding and maintainability.
- Improved error handling and logging in services, including AuthService, ErrorService, and StorageService.
- Added validation and business logic explanations in ExpenseService and TripService.
- Refactored method comments to follow a consistent format across the codebase.
- Translated error messages and comments from French to English for consistency.
2025-10-30 15:56:17 +01:00

663 lines
22 KiB
Dart

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 '../../models/group.dart';
import '../../models/message.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;
@override
void initState() {
super.initState();
// Load messages when the widget initializes
context.read<MessageBloc>().add(LoadMessages(widget.group.id));
}
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
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) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
}
},
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;
}
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'),
),
],
),
);
}
}