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 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 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; _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 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 _handleNotificationTap(Map 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 _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 _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 _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 _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 _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( 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 _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 _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 _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 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 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 _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), ); } }