- Implement EmergencyService for handling emergency contacts per trip. - Create GuestFlagService to manage guest mode flags for trips. - Introduce NotificationService with local notification capabilities. - Add OfflineFlagService for managing offline caching flags. - Develop PackingService for shared packing lists per trip. - Implement ReminderService for managing reminders/to-dos per trip. - Create SosService for dispatching SOS events to a backend. - Add StorageService with album image upload functionality. - Implement TransportService for managing transport segments per trip. - Create TripChecklistService for storing and retrieving trip checklists. - Add TripDocumentService for persisting trip documents metadata. test: Add unit tests for new services - Implement tests for AlbumService, BudgetService, EmergencyService, GuestFlagService, PackingService, ReminderService, SosService, TransportService, TripChecklistService, and TripDocumentService. - Ensure tests cover adding, loading, deleting, and handling corrupted payloads for each service.
490 lines
16 KiB
Dart
490 lines
16 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:timezone/data/latest_all.dart' as tz;
|
|
import 'package:timezone/timezone.dart' as tz;
|
|
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();
|
|
tz.initializeTimeZones();
|
|
tz.setLocalLocation(tz.getLocation('UTC'));
|
|
|
|
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),
|
|
);
|
|
}
|
|
|
|
/// Shows a local push notification with a custom [title] and [body].
|
|
///
|
|
/// Used for recap/reminder notifications when on-device scheduling is desired.
|
|
Future<void> showLocalRecap({
|
|
required String title,
|
|
required String body,
|
|
}) async {
|
|
const androidDetails = AndroidNotificationDetails(
|
|
'recap_channel',
|
|
'Recap quotidien',
|
|
channelDescription: 'Notifications de récap voyage',
|
|
importance: Importance.max,
|
|
priority: Priority.high,
|
|
);
|
|
const iosDetails = DarwinNotificationDetails();
|
|
const details = NotificationDetails(
|
|
android: androidDetails,
|
|
iOS: iosDetails,
|
|
);
|
|
|
|
await _localNotifications.show(
|
|
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
|
title,
|
|
body,
|
|
details,
|
|
);
|
|
}
|
|
|
|
/// Schedules a local reminder notification at [dueAt] with [title]/[body].
|
|
Future<void> scheduleReminder({
|
|
required String id,
|
|
required String title,
|
|
required String body,
|
|
required DateTime dueAt,
|
|
}) async {
|
|
try {
|
|
final int notifId = id.hashCode & 0x7fffffff;
|
|
final scheduled = tz.TZDateTime.from(dueAt, tz.local);
|
|
|
|
const androidDetails = AndroidNotificationDetails(
|
|
'reminder_channel',
|
|
'Rappels voyage',
|
|
channelDescription: 'Notifications des rappels/to-dos',
|
|
importance: Importance.high,
|
|
priority: Priority.high,
|
|
);
|
|
const iosDetails = DarwinNotificationDetails();
|
|
const details = NotificationDetails(
|
|
android: androidDetails,
|
|
iOS: iosDetails,
|
|
);
|
|
|
|
await _localNotifications.zonedSchedule(
|
|
notifId,
|
|
title,
|
|
body,
|
|
scheduled,
|
|
details,
|
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
|
);
|
|
} catch (e) {
|
|
LoggerService.error('Failed to schedule reminder', error: e);
|
|
}
|
|
}
|
|
|
|
/// Cancels a scheduled reminder notification by [id].
|
|
Future<void> cancelReminder(String id) async {
|
|
final notifId = id.hashCode & 0x7fffffff;
|
|
await _localNotifications.cancel(notifId);
|
|
}
|
|
}
|