feat: Implement trip invitation functionality and notification handling

- Added TripInvitationRepository for managing trip invitations.
- Created TripInvitation model with serialization methods.
- Implemented notification payload parser for handling FCM notifications.
- Enhanced NotificationService to manage trip invitations and related actions.
- Updated UserRepository to include user search functionality.
- Modified AuthRepository to store multiple FCM tokens.
- Added tests for trip invitation logic and notification payload parsing.
- Updated pubspec.yaml and pubspec.lock for dependency management.
This commit is contained in:
Van Leemput Dayron
2026-03-13 13:54:47 +01:00
parent e665dea82a
commit 3215a990d1
27 changed files with 1961 additions and 321 deletions

View File

@@ -34,6 +34,8 @@ import 'package:travel_mate/components/account/group_expenses_page.dart';
import 'package:travel_mate/models/group.dart';
import 'package:travel_mate/models/account.dart';
import 'package:travel_mate/models/user_balance.dart';
import 'package:travel_mate/models/user.dart';
import 'package:travel_mate/repositories/trip_invitation_repository.dart';
class ShowTripDetailsContent extends StatefulWidget {
final Trip trip;
@@ -48,6 +50,8 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
final GroupRepository _groupRepository = GroupRepository();
final UserRepository _userRepository = UserRepository();
final AccountRepository _accountRepository = AccountRepository();
final TripInvitationRepository _tripInvitationRepository =
TripInvitationRepository();
Group? _group;
Account? _account;
@@ -954,88 +958,193 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
void _showAddParticipantDialog() {
final theme = Theme.of(context);
final TextEditingController emailController = TextEditingController();
List<User> suggestions = [];
User? selectedUser;
bool isSearching = false;
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
backgroundColor:
theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface,
title: Text(
'Ajouter un participant',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurface,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Entrez l\'email du participant à ajouter :',
style: theme.textTheme.bodyMedium?.copyWith(
return StatefulBuilder(
builder: (BuildContext context, StateSetter setDialogState) {
/// Recherche des utilisateurs inscrits pour les suggestions.
///
/// Les participants déjà dans le voyage et l'utilisateur courant
/// sont exclus pour éviter les invitations invalides.
Future<void> searchSuggestions(String query) async {
final normalizedQuery = query.trim();
if (normalizedQuery.length < 2) {
setDialogState(() {
suggestions = [];
selectedUser = null;
isSearching = false;
});
return;
}
setDialogState(() {
isSearching = true;
});
final users = await _userRepository.searchUsers(normalizedQuery);
final participantIds = {
...widget.trip.participants,
widget.trip.createdBy,
};
final filteredUsers = users
.where((user) {
if (user.id == null) {
return false;
}
return !participantIds.contains(user.id);
})
.toList(growable: false);
if (!mounted) {
return;
}
setDialogState(() {
suggestions = filteredUsers;
isSearching = false;
});
}
return AlertDialog(
backgroundColor:
theme.dialogTheme.backgroundColor ??
theme.colorScheme.surface,
title: Text(
'Ajouter un participant',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 16),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
hintText: 'participant@example.com',
hintStyle: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Recherchez un utilisateur déjà inscrit (email, prénom ou nom).',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
const SizedBox(height: 16),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
onChanged: searchSuggestions,
decoration: InputDecoration(
hintText: 'participant@example.com',
hintStyle: TextStyle(
color: theme.colorScheme.onSurface.withValues(
alpha: 0.5,
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
style: TextStyle(color: theme.colorScheme.onSurface),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
if (isSearching) ...[
const SizedBox(height: 12),
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
if (!isSearching && suggestions.isNotEmpty) ...[
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 180),
child: ListView.builder(
shrinkWrap: true,
itemCount: suggestions.length,
itemBuilder: (context, index) {
final user = suggestions[index];
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text('${user.prenom} ${user.nom}'),
subtitle: Text(user.email),
onTap: () {
setDialogState(() {
selectedUser = user;
emailController.text = user.email;
suggestions = [];
});
},
);
},
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Annuler',
style: TextStyle(color: theme.colorScheme.primary),
),
),
style: TextStyle(color: theme.colorScheme.onSurface),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Annuler',
style: TextStyle(color: theme.colorScheme.primary),
),
),
TextButton(
onPressed: () {
if (emailController.text.isNotEmpty) {
_addParticipantByEmail(emailController.text);
Navigator.pop(context);
} else {
_errorService.showError(
message: 'Veuillez entrer un email valide',
);
}
},
child: Text(
'Ajouter',
style: TextStyle(color: theme.colorScheme.primary),
),
),
],
TextButton(
onPressed: () {
if (emailController.text.trim().isEmpty) {
_errorService.showError(
message: 'Veuillez entrer un email valide',
);
return;
}
_inviteParticipantByEmail(
email: emailController.text.trim(),
selectedUser: selectedUser,
);
Navigator.pop(context);
},
child: Text(
'Inviter',
style: TextStyle(color: theme.colorScheme.primary),
),
),
],
);
},
);
},
);
}
/// Ajouter un participant par email
Future<void> _addParticipantByEmail(String email) async {
/// Envoie une invitation de participation à partir d'un email.
///
/// Si [selectedUser] est fourni, il est utilisé directement. Sinon, la méthode
/// recherche un compte via l'email. L'invitation est refusée si l'utilisateur
/// est déjà membre du voyage, s'invite lui-même, ou si une invitation est déjà
/// en attente.
Future<void> _inviteParticipantByEmail({
required String email,
User? selectedUser,
}) async {
try {
// Chercher l'utilisateur par email
final user = await _userRepository.getUserByEmail(email);
final currentUserState = context.read<UserBloc>().state;
if (currentUserState is! user_state.UserLoaded) {
_errorService.showError(message: 'Utilisateur courant introuvable');
return;
}
final user = selectedUser ?? await _userRepository.getUserByEmail(email);
if (user == null) {
_errorService.showError(
message: 'Utilisateur non trouvé avec cet email',
message: 'Aucun compte inscrit trouvé avec cet email',
);
return;
}
@@ -1045,55 +1154,56 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
return;
}
// Ajouter l'utilisateur au groupe
if (widget.trip.id != null) {
final group = await _groupRepository.getGroupByTripId(widget.trip.id!);
if (group != null) {
// Créer un GroupMember à partir du User
final newMember = GroupMember(
userId: user.id!,
firstName: user.prenom,
lastName: user.nom,
pseudo: user.prenom,
profilePictureUrl: user.profilePictureUrl,
);
// Ajouter le membre au groupe
await _groupRepository.addMember(group.id, newMember);
// Ajouter le membre au compte
final account = await _accountRepository.getAccountByTripId(
widget.trip.id!,
);
if (account != null) {
await _accountRepository.addMemberToAccount(account.id, newMember);
}
// Mettre à jour la liste des participants du voyage
final newParticipants = [...widget.trip.participants, user.id!];
final updatedTrip = widget.trip.copyWith(
participants: newParticipants,
);
if (mounted) {
context.read<TripBloc>().add(
TripUpdateRequested(trip: updatedTrip),
);
_errorService.showSnackbar(
message: '${user.prenom} a été ajouté au voyage',
isError: false,
);
// Rafraîchir la page
setState(() {});
}
}
if (user.id == currentUserState.user.id) {
_errorService.showError(message: 'Vous êtes déjà dans ce voyage');
return;
}
} catch (e) {
_errorService.showError(
message: 'Erreur lors de l\'ajout du participant: $e',
final participantIds = {
...widget.trip.participants,
widget.trip.createdBy,
};
if (participantIds.contains(user.id)) {
_errorService.showError(
message: '${user.prenom} participe déjà à ce voyage',
);
return;
}
final tripId = widget.trip.id;
if (tripId == null) {
_errorService.showError(message: 'Voyage introuvable');
return;
}
final existingInvite = await _tripInvitationRepository
.getPendingInvitation(tripId: tripId, inviteeId: user.id!);
if (existingInvite != null) {
_errorService.showError(
message: 'Une invitation est déjà en attente pour cet utilisateur',
);
return;
}
await _tripInvitationRepository.createInvitation(
tripId: tripId,
tripTitle: widget.trip.title,
inviterId: currentUserState.user.id,
inviterName: currentUserState.user.prenom,
inviteeId: user.id!,
inviteeEmail: user.email,
);
if (!mounted) {
return;
}
_errorService.showSnackbar(
message: 'Invitation envoyée à ${user.prenom}',
isError: false,
);
} catch (e) {
_errorService.showError(message: 'Erreur lors de l\'invitation: $e');
}
}