feat: Add logger service and improve expense dialog with enhanced receipt management and calculation logic.

This commit is contained in:
Van Leemput Dayron
2025-11-28 12:54:54 +01:00
parent cad9d42128
commit fd710b8cb8
35 changed files with 2148 additions and 1296 deletions

View File

@@ -12,7 +12,7 @@ import '../../models/message.dart';
import '../../repositories/group_repository.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
@@ -20,7 +20,7 @@ import '../../repositories/group_repository.dart';
/// - 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 {
@@ -28,13 +28,10 @@ class ChatGroupContent extends StatefulWidget {
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,
});
const ChatGroupContent({super.key, required this.group});
@override
State<ChatGroupContent> createState() => _ChatGroupContentState();
@@ -43,16 +40,16 @@ class ChatGroupContent extends StatefulWidget {
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;
@@ -61,18 +58,20 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
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);
_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();
@@ -82,11 +81,11 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
}
/// 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) {
@@ -96,33 +95,33 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
if (_editingMessage != null) {
// Edit mode - update existing message
context.read<MessageBloc>().add(
UpdateMessage(
groupId: widget.group.id,
messageId: _editingMessage!.id,
newText: messageText,
),
);
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,
),
);
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) {
@@ -133,7 +132,7 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
}
/// Cancels the current editing operation.
///
///
/// Resets the editing state and clears the input field,
/// returning to normal message sending mode.
void _cancelEdit() {
@@ -144,46 +143,43 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
}
/// 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,
),
);
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,
),
);
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,
),
);
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) {
@@ -203,7 +199,10 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
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),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
),
),
],
),
@@ -255,7 +254,8 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
itemBuilder: (context, index) {
final message = state.messages[index];
final isMe = message.senderId == currentUser.id;
final showDate = index == 0 ||
final showDate =
index == 0 ||
!_isSameDay(
state.messages[index - 1].timestamp,
message.timestamp,
@@ -263,8 +263,14 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
return Column(
children: [
if (showDate) _buildDateSeparator(message.timestamp),
_buildMessageBubble(message, isMe, isDark, currentUser.id),
if (showDate)
_buildDateSeparator(message.timestamp),
_buildMessageBubble(
message,
isMe,
isDark,
currentUser.id,
),
],
);
},
@@ -280,14 +286,15 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
if (_editingMessage != null)
Container(
color: isDark ? Colors.blue[900] : Colors.blue[100],
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
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'),
),
const Expanded(child: Text('Modification du message')),
IconButton(
icon: const Icon(Icons.close),
onPressed: _cancelEdit,
@@ -315,11 +322,13 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
child: TextField(
controller: _messageController,
decoration: InputDecoration(
hintText: _editingMessage != null
? 'Modifier le message...'
hintText: _editingMessage != null
? 'Modifier le message...'
: 'Écrire un message...',
filled: true,
fillColor: isDark ? Colors.grey[850] : Colors.grey[100],
fillColor: isDark
? Colors.grey[850]
: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
@@ -336,9 +345,13 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
const SizedBox(width: 8),
IconButton(
onPressed: () => _sendMessage(currentUser),
icon: Icon(_editingMessage != null ? Icons.check : Icons.send),
icon: Icon(
_editingMessage != null ? Icons.check : Icons.send,
),
style: IconButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(12),
),
@@ -361,27 +374,17 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.chat_bubble_outline,
size: 80,
color: Colors.grey[400],
),
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,
),
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],
),
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
],
),
@@ -389,10 +392,15 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
);
}
Widget _buildMessageBubble(Message message, bool isMe, bool isDark, String currentUserId) {
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;
@@ -402,42 +410,48 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
}
// 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;
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;
final displayName = senderMember != null
? senderMember.pseudo
: message.senderName;
return Align(
alignment: isMe ? Alignment.centerRight : Alignment.centerLeft,
child: GestureDetector(
onLongPress: () => _showMessageOptions(context, message, isMe, currentUserId),
onLongPress: () =>
_showMessageOptions(context, message, isMe, currentUserId),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
mainAxisAlignment: isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
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)
backgroundImage:
(senderMember != null &&
senderMember.profilePictureUrl != null &&
senderMember.profilePictureUrl!.isNotEmpty)
? NetworkImage(senderMember.profilePictureUrl!)
: null,
child: (senderMember == null ||
senderMember.profilePictureUrl == null ||
senderMember.profilePictureUrl!.isEmpty)
child:
(senderMember == null ||
senderMember.profilePictureUrl == null ||
senderMember.profilePictureUrl!.isEmpty)
? Text(
displayName.isNotEmpty
? displayName[0].toUpperCase()
displayName.isNotEmpty
? displayName[0].toUpperCase()
: '?',
style: const TextStyle(fontSize: 12),
)
@@ -445,10 +459,13 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
),
const SizedBox(width: 8),
],
Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.65,
),
@@ -462,7 +479,9 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
),
),
child: Column(
crossAxisAlignment: isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
crossAxisAlignment: isMe
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
if (!isMe) ...[
Text(
@@ -476,11 +495,17 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
const SizedBox(height: 4),
],
Text(
message.isDeleted ? 'a supprimé un message' : message.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,
color: message.isDeleted
? textColor.withValues(alpha: 0.5)
: textColor,
fontStyle: message.isDeleted
? FontStyle.italic
: FontStyle.normal,
),
),
const SizedBox(height: 4),
@@ -528,7 +553,7 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
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);
@@ -550,7 +575,7 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: hasReacted
color: hasReacted
? Colors.blue.withValues(alpha: 0.3)
: Colors.grey.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
@@ -565,7 +590,10 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
const SizedBox(width: 2),
Text(
'${userIds.length}',
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold),
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
],
),
@@ -574,7 +602,12 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
}).toList();
}
void _showMessageOptions(BuildContext context, Message message, bool isMe, String currentUserId) {
void _showMessageOptions(
BuildContext context,
Message message,
bool isMe,
String currentUserId,
) {
showModalBottomSheet(
context: context,
builder: (context) => SafeArea(
@@ -609,7 +642,10 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
),
ListTile(
leading: const Icon(Icons.delete, color: Colors.red),
title: const Text('Supprimer', style: TextStyle(color: Colors.red)),
title: const Text(
'Supprimer',
style: TextStyle(color: Colors.red),
),
onTap: () {
Navigator.pop(context);
_showDeleteConfirmation(context, message.id);
@@ -712,20 +748,23 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
final member = widget.group.members[index];
final initials = member.pseudo.isNotEmpty
? member.pseudo[0].toUpperCase()
: (member.firstName.isNotEmpty
? member.firstName[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)
backgroundImage:
(member.profilePictureUrl != null &&
member.profilePictureUrl!.isNotEmpty)
? NetworkImage(member.profilePictureUrl!)
: null,
child: (member.profilePictureUrl == null || member.profilePictureUrl!.isEmpty)
child:
(member.profilePictureUrl == null ||
member.profilePictureUrl!.isEmpty)
? Text(initials)
: null,
),
@@ -743,8 +782,11 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
),
],
),
subtitle: member.role == 'admin'
? const Text('Administrateur', style: TextStyle(fontSize: 12))
subtitle: member.role == 'admin'
? const Text(
'Administrateur',
style: TextStyle(fontSize: 12),
)
: null,
trailing: IconButton(
icon: const Icon(Icons.edit, size: 18),
@@ -774,7 +816,8 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: theme.dialogBackgroundColor,
backgroundColor:
theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface,
title: Text(
'Changer le pseudo',
style: theme.textTheme.titleLarge?.copyWith(
@@ -785,9 +828,7 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
controller: pseudoController,
decoration: InputDecoration(
hintText: 'Nouveau pseudo',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
@@ -825,11 +866,11 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
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
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Pseudo modifié en "$newPseudo"'),
@@ -848,4 +889,4 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
}
}
}
}
}