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

@@ -1,16 +1,22 @@
import 'dart:convert';
import 'dart:io';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:travel_mate/services/logger_service.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.dart';
import 'package:travel_mate/services/error_service.dart';
import 'package:travel_mate/repositories/group_repository.dart';
import 'package:travel_mate/repositories/account_repository.dart';
import 'package:travel_mate/components/group/chat_group_content.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:travel_mate/components/account/group_expenses_page.dart';
import 'package:travel_mate/components/activities/activities_page.dart';
import 'package:travel_mate/components/group/chat_group_content.dart';
import 'package:travel_mate/components/home/show_trip_details_content.dart';
import 'package:travel_mate/repositories/account_repository.dart';
import 'package:travel_mate/repositories/group_repository.dart';
import 'package:travel_mate/repositories/trip_invitation_repository.dart';
import 'package:travel_mate/repositories/trip_repository.dart';
import 'package:travel_mate/services/error_service.dart';
import 'package:travel_mate/services/logger_service.dart';
import 'package:travel_mate/services/notification_payload_parser.dart';
@pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
@@ -27,15 +33,24 @@ class NotificationService {
late final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
bool _isInitialized = false;
final TripRepository _tripRepository = TripRepository();
final GroupRepository _groupRepository = GroupRepository();
final AccountRepository _accountRepository = AccountRepository();
final TripInvitationRepository _tripInvitationRepository =
TripInvitationRepository();
bool _isInitialized = false;
bool _isInvitationDialogOpen = false;
/// Initialise les permissions et listeners de notifications.
///
/// Cette méthode est idempotente: les initialisations déjà faites ne sont
/// pas rejouées.
Future<void> initialize() async {
if (_isInitialized) return;
// Request permissions
await _requestPermissions();
// Initialize local notifications
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
@@ -48,54 +63,42 @@ class NotificationService {
await _localNotifications.initialize(
initSettings,
onDidReceiveNotificationResponse: (details) {
// Handle notification tap
LoggerService.info('Notification tapped: ${details.payload}');
if (details.payload != null) {
try {
final data = json.decode(details.payload!) as Map<String, dynamic>;
_handleNotificationTap(data);
} catch (e) {
LoggerService.error('Error parsing notification payload', error: e);
}
if (details.payload == null || details.payload!.isEmpty) {
return;
}
try {
final data = json.decode(details.payload!) as Map<String, dynamic>;
_handleNotificationTap(data);
} catch (e) {
LoggerService.error('Error parsing notification payload', error: e);
}
},
);
// Handle foreground messages
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
// Handle token refresh
FirebaseMessaging.instance.onTokenRefresh.listen(_onTokenRefresh);
// Setup interacted message (Deep Linking)
// We don't call this here anymore, it will be called from HomePage
// await setupInteractedMessage();
_isInitialized = true;
LoggerService.info('NotificationService initialized');
// Print current token for debugging
final token = await getFCMToken();
LoggerService.info('Current FCM Token: $token');
}
/// Sets up the background message listener.
/// Should be called when the app is ready to handle navigation.
/// Démarre l'écoute des interactions de notifications en arrière-plan.
///
/// À appeler quand l'arbre de navigation est prêt.
void startListening() {
// Handle any interaction when the app is in the background via a
// Stream listener
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
_handleNotificationTap(message.data);
});
}
/// Checks for an initial message (app opened from terminated state)
/// and handles it if present.
/// Traite une notification ayant ouvert l'app depuis l'état terminé.
Future<void> handleInitialMessage() async {
// Get any messages which caused the application to open from
// a terminated state.
RemoteMessage? initialMessage = await _firebaseMessaging
.getInitialMessage();
final initialMessage = await _firebaseMessaging.getInitialMessage();
if (initialMessage != null) {
LoggerService.info('Found initial message: ${initialMessage.data}');
@@ -103,56 +106,57 @@ class NotificationService {
}
}
/// Exécute l'action métier/navigation associée à un payload push.
///
/// Le routage se base en priorité sur `type`, puis sur un fallback par clés.
Future<void> _handleNotificationTap(Map<String, dynamic> data) async {
LoggerService.info('Handling notification tap with data: $data');
// DEBUG: Show snackbar to verify payload
// ErrorService().showSnackbar(message: 'Debug: Payload $data', isError: false);
final type = data['type'];
final action = NotificationPayloadParser.parse(data);
try {
if (type == 'message') {
final groupId = data['groupId'];
if (groupId != null) {
final groupRepository = GroupRepository();
final group = await groupRepository.getGroupById(groupId);
if (group != null) {
ErrorService.navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (context) => ChatGroupContent(group: group),
),
);
} else {
LoggerService.error('Group not found: $groupId');
// ErrorService().showError(message: 'Groupe introuvable: $groupId');
switch (action.type) {
case 'message':
if (action.groupId != null) {
await _openGroupChat(action.groupId!);
}
} else {
LoggerService.error('Missing groupId in payload');
// ErrorService().showError(message: 'Payload invalide: groupId manquant');
}
} else if (type == 'expense') {
final tripId = data['tripId'];
if (tripId != null) {
final accountRepository = AccountRepository();
final groupRepository = GroupRepository();
final account = await accountRepository.getAccountByTripId(tripId);
final group = await groupRepository.getGroupByTripId(tripId);
if (account != null && group != null) {
ErrorService.navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (context) =>
GroupExpensesPage(account: account, group: group),
),
);
} else {
LoggerService.error('Account or Group not found for trip: $tripId');
// ErrorService().showError(message: 'Compte ou Groupe introuvable');
return;
case 'expense':
if (action.tripId != null) {
await _openExpenses(action.tripId!);
return;
}
}
} else {
LoggerService.info('Unknown notification type: $type');
if (action.groupId != null) {
final tripId = await _resolveTripIdFromGroup(action.groupId!);
if (tripId != null) {
await _openExpenses(tripId);
}
}
return;
case 'activity':
if (action.tripId != null) {
await _openActivities(action.tripId!);
}
return;
case 'trip_invitation':
await _showTripInvitationDialog(action);
return;
case 'trip_invitation_response':
case 'trip':
if (action.tripId != null) {
await _openTripDetails(action.tripId!);
}
return;
default:
if (action.groupId != null) {
await _openGroupChat(action.groupId!);
return;
}
if (action.tripId != null) {
await _openTripDetails(action.tripId!);
return;
}
LoggerService.info('Unknown notification payload: $data');
}
} catch (e) {
LoggerService.error('Error handling notification tap: $e');
@@ -160,17 +164,162 @@ class NotificationService {
}
}
Future<void> _onTokenRefresh(String newToken) async {
LoggerService.info('FCM Token refreshed: $newToken');
// We need the user ID to save the token.
// Since this service is a singleton, we might not have direct access to the user ID here
// without injecting the repository or bloc.
// For now, we rely on the AuthBloc to update the token on login/start.
// Ideally, we should save it here if we have the user ID.
/// Ouvre la discussion du groupe ciblé.
///
/// Ne fait rien si le groupe n'existe plus ou n'est pas accessible.
Future<void> _openGroupChat(String groupId) async {
final group = await _groupRepository.getGroupById(groupId);
if (group == null) {
LoggerService.error('Group not found: $groupId');
return;
}
ErrorService.navigatorKey.currentState?.push(
MaterialPageRoute(builder: (context) => ChatGroupContent(group: group)),
);
}
/// Ouvre la page des dépenses pour un voyage.
///
/// Nécessite que le groupe et le compte du voyage soient disponibles.
Future<void> _openExpenses(String tripId) async {
final account = await _accountRepository.getAccountByTripId(tripId);
final group = await _groupRepository.getGroupByTripId(tripId);
if (account == null || group == null) {
LoggerService.error('Account or Group not found for trip: $tripId');
return;
}
ErrorService.navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (context) => GroupExpensesPage(account: account, group: group),
),
);
}
/// Ouvre la page de détail d'un voyage.
///
/// Retourne silencieusement si le voyage n'existe plus.
Future<void> _openTripDetails(String tripId) async {
final trip = await _tripRepository.getTripById(tripId);
if (trip == null) {
LoggerService.error('Trip not found: $tripId');
return;
}
ErrorService.navigatorKey.currentState?.push(
MaterialPageRoute(
builder: (context) => ShowTripDetailsContent(trip: trip),
),
);
}
/// Ouvre la page des activités du voyage ciblé.
///
/// Cette navigation est utilisée pour les notifications d'ajout d'activité.
Future<void> _openActivities(String tripId) async {
final trip = await _tripRepository.getTripById(tripId);
if (trip == null) {
LoggerService.error('Trip not found for activities: $tripId');
return;
}
ErrorService.navigatorKey.currentState?.push(
MaterialPageRoute(builder: (context) => ActivitiesPage(trip: trip)),
);
}
/// Affiche un popup d'invitation de voyage et traite la réponse utilisateur.
///
/// Si [action.invitationId] est absent, aucun popup n'est affiché.
Future<void> _showTripInvitationDialog(NotificationAction action) async {
if (_isInvitationDialogOpen || action.invitationId == null) {
return;
}
final context = ErrorService.navigatorKey.currentContext;
if (context == null) {
LoggerService.error('Cannot show invitation dialog: missing context');
return;
}
_isInvitationDialogOpen = true;
final inviterName = action.inviterName ?? 'Quelqu\'un';
final tripTitle = action.tripTitle ?? 'ce voyage';
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Invitation de voyage'),
content: Text('$inviterName vous invite à rejoindre "$tripTitle".'),
actions: [
TextButton(
onPressed: () async {
Navigator.of(dialogContext).pop();
await _tripInvitationRepository.respondToInvitation(
invitationId: action.invitationId!,
isAccepted: false,
);
ErrorService().showSnackbar(
message: 'Invitation refusée',
isError: false,
);
},
child: const Text('Refuser'),
),
ElevatedButton(
onPressed: () async {
Navigator.of(dialogContext).pop();
await _tripInvitationRepository.respondToInvitation(
invitationId: action.invitationId!,
isAccepted: true,
);
ErrorService().showSnackbar(
message: 'Invitation acceptée',
isError: false,
);
if (action.tripId != null) {
await _openTripDetails(action.tripId!);
}
},
child: const Text('Rejoindre'),
),
],
);
},
);
_isInvitationDialogOpen = false;
}
/// Résout un `tripId` à partir d'un `groupId`.
///
/// Retourne `null` si le groupe est introuvable.
Future<String?> _resolveTripIdFromGroup(String groupId) async {
final group = await _groupRepository.getGroupById(groupId);
return group?.tripId;
}
/// Callback lors du refresh d'un token FCM.
///
/// Quand un utilisateur est connecté, le nouveau token est persisté
/// immédiatement pour éviter toute perte de notifications.
Future<void> _onTokenRefresh(String newToken) async {
LoggerService.info('FCM Token refreshed: $newToken');
final userId = FirebaseAuth.instance.currentUser?.uid;
if (userId == null) {
return;
}
await saveTokenToFirestore(userId);
}
/// Demande les permissions système de notification.
Future<void> _requestPermissions() async {
NotificationSettings settings = await _firebaseMessaging.requestPermission(
final settings = await _firebaseMessaging.requestPermission(
alert: true,
badge: true,
sound: true,
@@ -181,6 +330,9 @@ class NotificationService {
);
}
/// Retourne le token FCM courant de l'appareil.
///
/// Sur iOS, un token APNS valide est attendu avant de récupérer le token FCM.
Future<String?> getFCMToken() async {
try {
if (Platform.isIOS) {
@@ -209,12 +361,14 @@ class NotificationService {
}
}
/// Sauvegarde le token FCM de l'utilisateur courant dans Firestore.
Future<void> saveTokenToFirestore(String userId) async {
try {
final token = await getFCMToken();
if (token != null) {
await FirebaseFirestore.instance.collection('users').doc(userId).set({
'fcmToken': token,
'fcmTokens': FieldValue.arrayUnion([token]),
}, SetOptions(merge: true));
LoggerService.info('FCM Token saved to Firestore for user: $userId');
}
@@ -223,34 +377,38 @@ class NotificationService {
}
}
/// Affiche une notification locale lorsque le message arrive en foreground.
///
/// Le payload FCM est conservé pour permettre le deep link au clic local.
Future<void> _handleForegroundMessage(RemoteMessage message) async {
LoggerService.info('Got a message whilst in the foreground!');
LoggerService.info('Message data: ${message.data}');
if (message.notification != null) {
LoggerService.info(
'Message also contained a notification: ${message.notification}',
);
final title = message.notification?.title ?? message.data['title'];
final body = message.notification?.body ?? message.data['body'];
// Show local notification
const androidDetails = AndroidNotificationDetails(
'high_importance_channel',
'High Importance Notifications',
importance: Importance.max,
priority: Priority.high,
);
const iosDetails = DarwinNotificationDetails();
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _localNotifications.show(
message.hashCode,
message.notification?.title,
message.notification?.body,
details,
);
if (title == null && body == null) {
return;
}
const androidDetails = AndroidNotificationDetails(
'high_importance_channel',
'High Importance Notifications',
importance: Importance.max,
priority: Priority.high,
);
const iosDetails = DarwinNotificationDetails();
const details = NotificationDetails(
android: androidDetails,
iOS: iosDetails,
);
await _localNotifications.show(
message.hashCode,
title,
body,
details,
payload: json.encode(message.data),
);
}
}