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:
82
lib/services/notification_payload_parser.dart
Normal file
82
lib/services/notification_payload_parser.dart
Normal file
@@ -0,0 +1,82 @@
|
||||
/// Normalise et interprète les données reçues depuis une notification push.
|
||||
///
|
||||
/// Le parseur convertit les payloads FCM en une action unique utilisée
|
||||
/// par [NotificationService] pour effectuer la bonne navigation.
|
||||
class NotificationPayloadParser {
|
||||
/// Construit une action de navigation à partir d'un payload FCM.
|
||||
///
|
||||
/// [data] peut contenir des valeurs hétérogènes; elles sont converties en
|
||||
/// `String` de manière défensive. Si `type` est absent, des règles de
|
||||
/// fallback basées sur les clés présentes (`invitationId`, `groupId`, `tripId`)
|
||||
/// sont appliquées.
|
||||
static NotificationAction parse(Map<String, dynamic> data) {
|
||||
final normalized = data.map(
|
||||
(key, value) => MapEntry(key, value?.toString()),
|
||||
);
|
||||
|
||||
final explicitType = normalized['type'];
|
||||
final inferredType = _inferType(normalized);
|
||||
final type = explicitType ?? inferredType;
|
||||
|
||||
return NotificationAction(
|
||||
type: type,
|
||||
tripId: normalized['tripId'],
|
||||
groupId: normalized['groupId'],
|
||||
activityId: normalized['activityId'],
|
||||
invitationId: normalized['invitationId'],
|
||||
inviterName: normalized['inviterName'],
|
||||
tripTitle: normalized['tripTitle'],
|
||||
);
|
||||
}
|
||||
|
||||
/// Déduit un type de notification quand `type` est absent du payload.
|
||||
///
|
||||
/// Priorité: invitation > message > voyage.
|
||||
static String _inferType(Map<String, String?> data) {
|
||||
if (data['invitationId'] != null) {
|
||||
return 'trip_invitation';
|
||||
}
|
||||
if (data['groupId'] != null) {
|
||||
return 'message';
|
||||
}
|
||||
if (data['tripId'] != null) {
|
||||
return 'trip';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/// Représente une action de navigation dérivée d'une notification.
|
||||
class NotificationAction {
|
||||
/// Type normalisé de la notification (message, expense, activity, etc.).
|
||||
final String type;
|
||||
|
||||
/// Identifiant de voyage éventuellement présent dans le payload.
|
||||
final String? tripId;
|
||||
|
||||
/// Identifiant de groupe éventuellement présent dans le payload.
|
||||
final String? groupId;
|
||||
|
||||
/// Identifiant d'activité éventuellement présent dans le payload.
|
||||
final String? activityId;
|
||||
|
||||
/// Identifiant d'invitation de voyage éventuellement présent.
|
||||
final String? invitationId;
|
||||
|
||||
/// Nom de l'invitant, utilisé pour le texte du popup d'invitation.
|
||||
final String? inviterName;
|
||||
|
||||
/// Titre du voyage, utilisé pour enrichir le popup d'invitation.
|
||||
final String? tripTitle;
|
||||
|
||||
/// Crée une action de notification.
|
||||
const NotificationAction({
|
||||
required this.type,
|
||||
this.tripId,
|
||||
this.groupId,
|
||||
this.activityId,
|
||||
this.invitationId,
|
||||
this.inviterName,
|
||||
this.tripTitle,
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user