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:
@@ -926,7 +926,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Activité éloignée : ${distanceInKm!.toStringAsFixed(0)} km du lieu du voyage',
|
||||
'Activité éloignée : ${distanceInKm.toStringAsFixed(0)} km du lieu du voyage',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -20,7 +19,6 @@ import '../../services/user_service.dart';
|
||||
import '../../repositories/group_repository.dart';
|
||||
import '../../repositories/account_repository.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../../services/place_image_service.dart';
|
||||
import '../../services/trip_geocoding_service.dart';
|
||||
import '../../services/logger_service.dart';
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../../firebase_options.dart';
|
||||
import '../../services/error_service.dart';
|
||||
import '../../services/map_navigation_service.dart';
|
||||
|
||||
Reference in New Issue
Block a user