Files
TravelMate/lib/services/notification_service.dart
Van Leemput Dayron 3215a990d1 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.
2026-03-13 13:54:47 +01:00

415 lines
14 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/material.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 {
await Firebase.initializeApp();
LoggerService.info('Handling a background message: ${message.messageId}');
}
class NotificationService {
static final NotificationService _instance = NotificationService._internal();
factory NotificationService() => _instance;
NotificationService._internal();
late final FirebaseMessaging _firebaseMessaging = FirebaseMessaging.instance;
late final FlutterLocalNotificationsPlugin _localNotifications =
FlutterLocalNotificationsPlugin();
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;
await _requestPermissions();
const androidSettings = AndroidInitializationSettings(
'@mipmap/ic_launcher',
);
const iosSettings = DarwinInitializationSettings();
const initSettings = InitializationSettings(
android: androidSettings,
iOS: iosSettings,
);
await _localNotifications.initialize(
initSettings,
onDidReceiveNotificationResponse: (details) {
LoggerService.info('Notification tapped: ${details.payload}');
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);
}
},
);
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
FirebaseMessaging.instance.onTokenRefresh.listen(_onTokenRefresh);
_isInitialized = true;
LoggerService.info('NotificationService initialized');
final token = await getFCMToken();
LoggerService.info('Current FCM Token: $token');
}
/// Démarre l'écoute des interactions de notifications en arrière-plan.
///
/// À appeler quand l'arbre de navigation est prêt.
void startListening() {
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
_handleNotificationTap(message.data);
});
}
/// Traite une notification ayant ouvert l'app depuis l'état terminé.
Future<void> handleInitialMessage() async {
final initialMessage = await _firebaseMessaging.getInitialMessage();
if (initialMessage != null) {
LoggerService.info('Found initial message: ${initialMessage.data}');
_handleNotificationTap(initialMessage.data);
}
}
/// 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');
final action = NotificationPayloadParser.parse(data);
try {
switch (action.type) {
case 'message':
if (action.groupId != null) {
await _openGroupChat(action.groupId!);
}
return;
case 'expense':
if (action.tripId != null) {
await _openExpenses(action.tripId!);
return;
}
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');
ErrorService().showError(message: 'Erreur navigation: $e');
}
}
/// 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 {
final settings = await _firebaseMessaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
LoggerService.info(
'User granted permission: ${settings.authorizationStatus}',
);
}
/// 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) {
String? apnsToken = await _firebaseMessaging.getAPNSToken();
int retries = 0;
while (apnsToken == null && retries < 10) {
LoggerService.info(
'Waiting for APNS token... (Attempt ${retries + 1}/10)',
);
await Future.delayed(const Duration(seconds: 2));
apnsToken = await _firebaseMessaging.getAPNSToken();
retries++;
}
if (apnsToken == null) {
LoggerService.error('APNS token not available after retries');
return null;
}
}
final token = await _firebaseMessaging.getToken();
LoggerService.info('NotificationService - FCM Token: $token');
return token;
} catch (e) {
LoggerService.error('Error getting FCM token: $e');
return null;
}
}
/// 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');
}
} catch (e) {
LoggerService.error('Error saving FCM token to Firestore: $e');
}
}
/// 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}');
final title = message.notification?.title ?? message.data['title'];
final body = message.notification?.body ?? message.data['body'];
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),
);
}
}