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

@@ -111,7 +111,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
if (user != null) {
// Save FCM Token
await NotificationService().saveTokenToFirestore(user.id!);
await _notificationService.saveTokenToFirestore(user.id!);
await _analyticsService.setUserId(user.id);
await _analyticsService.logEvent(
name: 'login',
@@ -147,7 +147,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
if (user != null) {
// Save FCM Token
await NotificationService().saveTokenToFirestore(user.id!);
await _notificationService.saveTokenToFirestore(user.id!);
await _analyticsService.setUserId(user.id);
await _analyticsService.logEvent(
name: 'sign_up',
@@ -177,7 +177,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
if (user != null) {
// Save FCM Token
await NotificationService().saveTokenToFirestore(user.id!);
await _notificationService.saveTokenToFirestore(user.id!);
await _analyticsService.setUserId(user.id);
await _analyticsService.logEvent(
name: 'login',
@@ -268,7 +268,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
if (user != null) {
// Save FCM Token
await NotificationService().saveTokenToFirestore(user.id!);
await _notificationService.saveTokenToFirestore(user.id!);
await _analyticsService.setUserId(user.id);
await _analyticsService.logEvent(
name: 'login',

View File

@@ -92,6 +92,7 @@ class UserBloc extends Bloc<event.UserEvent, state.UserState> {
LoggerService.info('UserBloc - Updating FCM token in Firestore');
await _firestore.collection('users').doc(currentUser.uid).set({
'fcmToken': fcmToken,
'fcmTokens': FieldValue.arrayUnion([fcmToken]),
}, SetOptions(merge: true));
LoggerService.info('UserBloc - FCM token updated');
} else {

View File

@@ -926,7 +926,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
const SizedBox(width: 8),
Expanded(
child: Text(
'Activité éloignée : ${distanceInKm!.toStringAsFixed(0)} km du lieu du voyage',
'Activité éloignée : ${distanceInKm.toStringAsFixed(0)} km du lieu du voyage',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontWeight: FontWeight.w600,

View File

@@ -1,4 +1,3 @@
import 'dart:io';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -20,7 +19,6 @@ import '../../services/user_service.dart';
import '../../repositories/group_repository.dart';
import '../../repositories/account_repository.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../../services/place_image_service.dart';
import '../../services/trip_geocoding_service.dart';
import '../../services/logger_service.dart';

View File

@@ -34,6 +34,8 @@ import 'package:travel_mate/components/account/group_expenses_page.dart';
import 'package:travel_mate/models/group.dart';
import 'package:travel_mate/models/account.dart';
import 'package:travel_mate/models/user_balance.dart';
import 'package:travel_mate/models/user.dart';
import 'package:travel_mate/repositories/trip_invitation_repository.dart';
class ShowTripDetailsContent extends StatefulWidget {
final Trip trip;
@@ -48,6 +50,8 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
final GroupRepository _groupRepository = GroupRepository();
final UserRepository _userRepository = UserRepository();
final AccountRepository _accountRepository = AccountRepository();
final TripInvitationRepository _tripInvitationRepository =
TripInvitationRepository();
Group? _group;
Account? _account;
@@ -954,88 +958,193 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
void _showAddParticipantDialog() {
final theme = Theme.of(context);
final TextEditingController emailController = TextEditingController();
List<User> suggestions = [];
User? selectedUser;
bool isSearching = false;
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
backgroundColor:
theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface,
title: Text(
'Ajouter un participant',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurface,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Entrez l\'email du participant à ajouter :',
style: theme.textTheme.bodyMedium?.copyWith(
return StatefulBuilder(
builder: (BuildContext context, StateSetter setDialogState) {
/// Recherche des utilisateurs inscrits pour les suggestions.
///
/// Les participants déjà dans le voyage et l'utilisateur courant
/// sont exclus pour éviter les invitations invalides.
Future<void> searchSuggestions(String query) async {
final normalizedQuery = query.trim();
if (normalizedQuery.length < 2) {
setDialogState(() {
suggestions = [];
selectedUser = null;
isSearching = false;
});
return;
}
setDialogState(() {
isSearching = true;
});
final users = await _userRepository.searchUsers(normalizedQuery);
final participantIds = {
...widget.trip.participants,
widget.trip.createdBy,
};
final filteredUsers = users
.where((user) {
if (user.id == null) {
return false;
}
return !participantIds.contains(user.id);
})
.toList(growable: false);
if (!mounted) {
return;
}
setDialogState(() {
suggestions = filteredUsers;
isSearching = false;
});
}
return AlertDialog(
backgroundColor:
theme.dialogTheme.backgroundColor ??
theme.colorScheme.surface,
title: Text(
'Ajouter un participant',
style: theme.textTheme.titleLarge?.copyWith(
color: theme.colorScheme.onSurface,
),
),
const SizedBox(height: 16),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
hintText: 'participant@example.com',
hintStyle: TextStyle(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Recherchez un utilisateur déjà inscrit (email, prénom ou nom).',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface,
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
const SizedBox(height: 16),
TextField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
onChanged: searchSuggestions,
decoration: InputDecoration(
hintText: 'participant@example.com',
hintStyle: TextStyle(
color: theme.colorScheme.onSurface.withValues(
alpha: 0.5,
),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
style: TextStyle(color: theme.colorScheme.onSurface),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
if (isSearching) ...[
const SizedBox(height: 12),
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
if (!isSearching && suggestions.isNotEmpty) ...[
const SizedBox(height: 12),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 180),
child: ListView.builder(
shrinkWrap: true,
itemCount: suggestions.length,
itemBuilder: (context, index) {
final user = suggestions[index];
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text('${user.prenom} ${user.nom}'),
subtitle: Text(user.email),
onTap: () {
setDialogState(() {
selectedUser = user;
emailController.text = user.email;
suggestions = [];
});
},
);
},
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Annuler',
style: TextStyle(color: theme.colorScheme.primary),
),
),
style: TextStyle(color: theme.colorScheme.onSurface),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Annuler',
style: TextStyle(color: theme.colorScheme.primary),
),
),
TextButton(
onPressed: () {
if (emailController.text.isNotEmpty) {
_addParticipantByEmail(emailController.text);
Navigator.pop(context);
} else {
_errorService.showError(
message: 'Veuillez entrer un email valide',
);
}
},
child: Text(
'Ajouter',
style: TextStyle(color: theme.colorScheme.primary),
),
),
],
TextButton(
onPressed: () {
if (emailController.text.trim().isEmpty) {
_errorService.showError(
message: 'Veuillez entrer un email valide',
);
return;
}
_inviteParticipantByEmail(
email: emailController.text.trim(),
selectedUser: selectedUser,
);
Navigator.pop(context);
},
child: Text(
'Inviter',
style: TextStyle(color: theme.colorScheme.primary),
),
),
],
);
},
);
},
);
}
/// Ajouter un participant par email
Future<void> _addParticipantByEmail(String email) async {
/// Envoie une invitation de participation à partir d'un email.
///
/// Si [selectedUser] est fourni, il est utilisé directement. Sinon, la méthode
/// recherche un compte via l'email. L'invitation est refusée si l'utilisateur
/// est déjà membre du voyage, s'invite lui-même, ou si une invitation est déjà
/// en attente.
Future<void> _inviteParticipantByEmail({
required String email,
User? selectedUser,
}) async {
try {
// Chercher l'utilisateur par email
final user = await _userRepository.getUserByEmail(email);
final currentUserState = context.read<UserBloc>().state;
if (currentUserState is! user_state.UserLoaded) {
_errorService.showError(message: 'Utilisateur courant introuvable');
return;
}
final user = selectedUser ?? await _userRepository.getUserByEmail(email);
if (user == null) {
_errorService.showError(
message: 'Utilisateur non trouvé avec cet email',
message: 'Aucun compte inscrit trouvé avec cet email',
);
return;
}
@@ -1045,55 +1154,56 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
return;
}
// Ajouter l'utilisateur au groupe
if (widget.trip.id != null) {
final group = await _groupRepository.getGroupByTripId(widget.trip.id!);
if (group != null) {
// Créer un GroupMember à partir du User
final newMember = GroupMember(
userId: user.id!,
firstName: user.prenom,
lastName: user.nom,
pseudo: user.prenom,
profilePictureUrl: user.profilePictureUrl,
);
// Ajouter le membre au groupe
await _groupRepository.addMember(group.id, newMember);
// Ajouter le membre au compte
final account = await _accountRepository.getAccountByTripId(
widget.trip.id!,
);
if (account != null) {
await _accountRepository.addMemberToAccount(account.id, newMember);
}
// Mettre à jour la liste des participants du voyage
final newParticipants = [...widget.trip.participants, user.id!];
final updatedTrip = widget.trip.copyWith(
participants: newParticipants,
);
if (mounted) {
context.read<TripBloc>().add(
TripUpdateRequested(trip: updatedTrip),
);
_errorService.showSnackbar(
message: '${user.prenom} a été ajouté au voyage',
isError: false,
);
// Rafraîchir la page
setState(() {});
}
}
if (user.id == currentUserState.user.id) {
_errorService.showError(message: 'Vous êtes déjà dans ce voyage');
return;
}
} catch (e) {
_errorService.showError(
message: 'Erreur lors de l\'ajout du participant: $e',
final participantIds = {
...widget.trip.participants,
widget.trip.createdBy,
};
if (participantIds.contains(user.id)) {
_errorService.showError(
message: '${user.prenom} participe déjà à ce voyage',
);
return;
}
final tripId = widget.trip.id;
if (tripId == null) {
_errorService.showError(message: 'Voyage introuvable');
return;
}
final existingInvite = await _tripInvitationRepository
.getPendingInvitation(tripId: tripId, inviteeId: user.id!);
if (existingInvite != null) {
_errorService.showError(
message: 'Une invitation est déjà en attente pour cet utilisateur',
);
return;
}
await _tripInvitationRepository.createInvitation(
tripId: tripId,
tripTitle: widget.trip.title,
inviterId: currentUserState.user.id,
inviterName: currentUserState.user.prenom,
inviteeId: user.id!,
inviteeEmail: user.email,
);
if (!mounted) {
return;
}
_errorService.showSnackbar(
message: 'Invitation envoyée à ${user.prenom}',
isError: false,
);
} catch (e) {
_errorService.showError(message: 'Erreur lors de l\'invitation: $e');
}
}

View File

@@ -1,11 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:geolocator/geolocator.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'dart:ui' as ui;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../../firebase_options.dart';
import '../../services/error_service.dart';
import '../../services/map_navigation_service.dart';

View File

@@ -0,0 +1,146 @@
import 'package:cloud_firestore/cloud_firestore.dart';
/// Représente une invitation d'un utilisateur à rejoindre un voyage.
///
/// Une invitation passe par les statuts `pending`, `accepted` ou `rejected`.
/// Elle contient le contexte minimum nécessaire pour envoyer les notifications
/// et appliquer la réponse (trip, expéditeur, destinataire).
class TripInvitation {
/// Identifiant Firestore de l'invitation.
final String id;
/// Identifiant du voyage concerné.
final String tripId;
/// Titre du voyage au moment de l'invitation.
final String tripTitle;
/// Identifiant de l'utilisateur qui invite.
final String inviterId;
/// Nom affiché de l'utilisateur qui invite.
final String inviterName;
/// Identifiant de l'utilisateur invité.
final String inviteeId;
/// Email de l'utilisateur invité (utile pour affichage et debug).
final String inviteeEmail;
/// Statut courant de l'invitation: `pending`, `accepted`, `rejected`.
final String status;
/// Date de création de l'invitation.
final DateTime createdAt;
/// Date de réponse (acceptation/refus), null si encore en attente.
final DateTime? respondedAt;
/// Crée une instance de [TripInvitation].
///
/// [status] vaut `pending` par défaut pour une nouvelle invitation.
TripInvitation({
required this.id,
required this.tripId,
required this.tripTitle,
required this.inviterId,
required this.inviterName,
required this.inviteeId,
required this.inviteeEmail,
this.status = 'pending',
required this.createdAt,
this.respondedAt,
});
/// Crée une invitation à partir d'un document Firestore.
///
/// Gère les formats `Timestamp`, `int` et `DateTime` pour les dates.
factory TripInvitation.fromFirestore(
DocumentSnapshot<Map<String, dynamic>> doc,
) {
final data = doc.data() ?? <String, dynamic>{};
return TripInvitation(
id: doc.id,
tripId: data['tripId'] as String? ?? '',
tripTitle: data['tripTitle'] as String? ?? '',
inviterId: data['inviterId'] as String? ?? '',
inviterName: data['inviterName'] as String? ?? 'Quelqu\'un',
inviteeId: data['inviteeId'] as String? ?? '',
inviteeEmail: data['inviteeEmail'] as String? ?? '',
status: data['status'] as String? ?? 'pending',
createdAt: _parseDate(data['createdAt']) ?? DateTime.now(),
respondedAt: _parseDate(data['respondedAt']),
);
}
/// Convertit l'invitation en map Firestore.
///
/// [respondedAt] est omis si null pour éviter d'écraser inutilement la donnée.
Map<String, dynamic> toMap() {
final map = <String, dynamic>{
'tripId': tripId,
'tripTitle': tripTitle,
'inviterId': inviterId,
'inviterName': inviterName,
'inviteeId': inviteeId,
'inviteeEmail': inviteeEmail,
'status': status,
'createdAt': Timestamp.fromDate(createdAt),
};
if (respondedAt != null) {
map['respondedAt'] = Timestamp.fromDate(respondedAt!);
}
return map;
}
/// Retourne une copie avec les champs fournis.
///
/// Utile pour mettre à jour un statut localement sans muter l'instance initiale.
TripInvitation copyWith({
String? id,
String? tripId,
String? tripTitle,
String? inviterId,
String? inviterName,
String? inviteeId,
String? inviteeEmail,
String? status,
DateTime? createdAt,
DateTime? respondedAt,
}) {
return TripInvitation(
id: id ?? this.id,
tripId: tripId ?? this.tripId,
tripTitle: tripTitle ?? this.tripTitle,
inviterId: inviterId ?? this.inviterId,
inviterName: inviterName ?? this.inviterName,
inviteeId: inviteeId ?? this.inviteeId,
inviteeEmail: inviteeEmail ?? this.inviteeEmail,
status: status ?? this.status,
createdAt: createdAt ?? this.createdAt,
respondedAt: respondedAt ?? this.respondedAt,
);
}
/// Convertit une valeur de date Firestore vers [DateTime].
///
/// Retourne `null` si la valeur est absente ou non reconnue.
static DateTime? _parseDate(dynamic value) {
if (value == null) {
return null;
}
if (value is Timestamp) {
return value.toDate();
}
if (value is int) {
return DateTime.fromMillisecondsSinceEpoch(value);
}
if (value is DateTime) {
return value;
}
return null;
}
}

View File

@@ -16,6 +16,7 @@ import '../services/notification_service.dart';
import '../services/map_navigation_service.dart';
import '../services/whats_new_service.dart';
import '../components/whats_new_dialog.dart';
import 'trip_invitations_page.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@@ -227,6 +228,19 @@ class _HomePageState extends State<HomePage> {
title: "Comptes",
index: 4,
),
ListTile(
leading: const Icon(Icons.mail_outline),
title: const Text('Invitations'),
onTap: () {
Navigator.pop(context);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => TripInvitationsPage(),
),
);
},
),
const Divider(),
ListTile(
leading: const Icon(Icons.logout, color: Colors.red),

View File

@@ -0,0 +1,274 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:travel_mate/blocs/user/user_bloc.dart';
import 'package:travel_mate/blocs/user/user_state.dart' as user_state;
import 'package:travel_mate/models/trip_invitation.dart';
import 'package:travel_mate/repositories/trip_invitation_repository.dart';
import 'package:travel_mate/services/error_service.dart';
/// Affiche la boîte de réception des invitations de voyage.
///
/// Cette page permet de consulter toutes les invitations reçues et de répondre
/// aux invitations en attente (`pending`) via les actions accepter/refuser.
class TripInvitationsPage extends StatelessWidget {
/// Repository utilisé pour charger et mettre à jour les invitations.
final TripInvitationDataSource repository;
/// Identifiant utilisateur injecté pour les tests ou contextes spécifiques.
final String? userIdOverride;
/// Crée la page des invitations.
///
/// [repository] peut être injecté pour les tests; sinon un repository réel est utilisé.
TripInvitationsPage({
super.key,
TripInvitationDataSource? repository,
this.userIdOverride,
}) : repository = repository ?? TripInvitationRepository();
/// Retourne les invitations correspondant au [status] demandé.
///
/// [status] peut valoir `pending`, `accepted` ou `rejected`.
List<TripInvitation> _filterByStatus(
List<TripInvitation> invitations,
String status,
) {
return invitations
.where((invitation) => invitation.status == status)
.toList(growable: false);
}
/// Envoie la réponse utilisateur pour une invitation.
///
/// [isAccepted] à `true` accepte l'invitation, sinon la refuse.
/// Un feedback visuel est affiché à l'utilisateur.
Future<void> _respondToInvitation({
required BuildContext context,
required String invitationId,
required bool isAccepted,
}) async {
try {
await repository.respondToInvitation(
invitationId: invitationId,
isAccepted: isAccepted,
);
if (!context.mounted) {
return;
}
ErrorService().showSnackbar(
message: isAccepted ? 'Invitation acceptée' : 'Invitation refusée',
isError: false,
);
} catch (e) {
if (!context.mounted) {
return;
}
ErrorService().showError(message: 'Erreur lors de la réponse: $e');
}
}
@override
Widget build(BuildContext context) {
if (userIdOverride != null && userIdOverride!.isNotEmpty) {
return _InvitationsScaffold(
userId: userIdOverride!,
repository: repository,
onRespond: ({required String invitationId, required bool isAccepted}) {
return _respondToInvitation(
context: context,
invitationId: invitationId,
isAccepted: isAccepted,
);
},
filterByStatus: _filterByStatus,
);
}
return BlocBuilder<UserBloc, user_state.UserState>(
builder: (context, state) {
if (state is! user_state.UserLoaded) {
return Scaffold(
appBar: AppBar(title: const Text('Invitations')),
body: const Center(child: Text('Utilisateur non chargé')),
);
}
return _InvitationsScaffold(
userId: state.user.id,
repository: repository,
onRespond:
({required String invitationId, required bool isAccepted}) {
return _respondToInvitation(
context: context,
invitationId: invitationId,
isAccepted: isAccepted,
);
},
filterByStatus: _filterByStatus,
);
},
);
}
}
/// Échafaudage principal des invitations avec onglets par statut.
class _InvitationsScaffold extends StatelessWidget {
/// Identifiant utilisateur des invitations à afficher.
final String userId;
/// Source de données des invitations.
final TripInvitationDataSource repository;
/// Callback appelé lors d'une réponse utilisateur.
final Future<void> Function({
required String invitationId,
required bool isAccepted,
})
onRespond;
/// Fonction de filtrage par statut.
final List<TripInvitation> Function(
List<TripInvitation> invitations,
String status,
)
filterByStatus;
/// Crée l'échafaudage d'affichage des invitations.
const _InvitationsScaffold({
required this.userId,
required this.repository,
required this.onRespond,
required this.filterByStatus,
});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('Invitations'),
bottom: const TabBar(
tabs: [
Tab(text: 'En attente'),
Tab(text: 'Acceptées'),
Tab(text: 'Refusées'),
],
),
),
body: StreamBuilder<List<TripInvitation>>(
stream: repository.watchInvitationsForUser(userId),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (snapshot.hasError) {
return Center(
child: Text('Erreur de chargement: ${snapshot.error}'),
);
}
final invitations = snapshot.data ?? const <TripInvitation>[];
final pending = filterByStatus(invitations, 'pending');
final accepted = filterByStatus(invitations, 'accepted');
final rejected = filterByStatus(invitations, 'rejected');
return TabBarView(
children: [
_InvitationsList(
invitations: pending,
onAccept: (id) =>
onRespond(invitationId: id, isAccepted: true),
onReject: (id) =>
onRespond(invitationId: id, isAccepted: false),
),
_InvitationsList(invitations: accepted),
_InvitationsList(invitations: rejected),
],
);
},
),
),
);
}
}
/// Affiche une liste d'invitations avec actions éventuelles.
class _InvitationsList extends StatelessWidget {
/// Invitations à afficher.
final List<TripInvitation> invitations;
/// Callback appelé lors d'une acceptation.
final Future<void> Function(String invitationId)? onAccept;
/// Callback appelé lors d'un refus.
final Future<void> Function(String invitationId)? onReject;
/// Crée une liste d'invitations.
const _InvitationsList({
required this.invitations,
this.onAccept,
this.onReject,
});
@override
Widget build(BuildContext context) {
if (invitations.isEmpty) {
return const Center(child: Text('Aucune invitation'));
}
return ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: invitations.length,
separatorBuilder: (context, _) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final invitation = invitations[index];
final isPending = invitation.status == 'pending';
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
invitation.tripTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
Text('Invité par: ${invitation.inviterName}'),
Text(
'Reçu: ${invitation.createdAt.day}/${invitation.createdAt.month}/${invitation.createdAt.year}',
),
if (isPending) ...[
const SizedBox(height: 12),
Row(
children: [
OutlinedButton(
onPressed: onReject == null
? null
: () => onReject!(invitation.id),
child: const Text('Refuser'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: onAccept == null
? null
: () => onAccept!(invitation.id),
child: const Text('Accepter'),
),
],
),
],
],
),
),
);
},
);
}
}

View File

@@ -312,6 +312,7 @@ class AuthRepository {
if (token != null) {
await _firestore.collection('users').doc(userId).set({
'fcmToken': token,
'fcmTokens': FieldValue.arrayUnion([token]),
}, SetOptions(merge: true));
}
} catch (e, stackTrace) {

View File

@@ -0,0 +1,225 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:travel_mate/models/trip_invitation.dart';
import 'package:travel_mate/services/error_service.dart';
/// Contrat de données pour la gestion des invitations de voyage.
///
/// Cette interface permet d'injecter une implémentation réelle ou fake
/// (tests) dans les écrans qui consomment les invitations.
abstract class TripInvitationDataSource {
/// Crée une invitation de voyage.
Future<void> createInvitation({
required String tripId,
required String tripTitle,
required String inviterId,
required String inviterName,
required String inviteeId,
required String inviteeEmail,
});
/// Retourne une invitation en attente pour le couple voyage/invité.
Future<TripInvitation?> getPendingInvitation({
required String tripId,
required String inviteeId,
});
/// Retourne une invitation par son identifiant.
Future<TripInvitation?> getInvitationById(String invitationId);
/// Met à jour une invitation selon la réponse utilisateur.
Future<void> respondToInvitation({
required String invitationId,
required bool isAccepted,
});
/// Retourne les invitations en attente d'un utilisateur.
Stream<List<TripInvitation>> watchPendingInvitationsForUser(String userId);
/// Retourne toutes les invitations d'un utilisateur.
Stream<List<TripInvitation>> watchInvitationsForUser(String userId);
}
/// Repository dédié à la gestion des invitations de voyage.
///
/// Ce repository centralise la création d'invitation, la lecture des invitations
/// en attente et la réponse (acceptation/refus) depuis l'application.
class TripInvitationRepository implements TripInvitationDataSource {
/// Instance Firestore injectée ou par défaut.
final FirebaseFirestore _firestore;
final ErrorService _errorService = ErrorService();
/// Crée une instance du repository.
TripInvitationRepository({FirebaseFirestore? firestore})
: _firestore = firestore ?? FirebaseFirestore.instance;
CollectionReference<Map<String, dynamic>> get _collection =>
_firestore.collection('tripInvitations');
/// Cherche une invitation en attente pour un voyage et un utilisateur donné.
///
/// Retourne `null` si aucune invitation `pending` n'existe.
@override
Future<TripInvitation?> getPendingInvitation({
required String tripId,
required String inviteeId,
}) async {
try {
final query = await _collection
.where('tripId', isEqualTo: tripId)
.where('inviteeId', isEqualTo: inviteeId)
.where('status', isEqualTo: 'pending')
.limit(1)
.get();
if (query.docs.isEmpty) {
return null;
}
return TripInvitation.fromFirestore(query.docs.first);
} catch (e, stackTrace) {
_errorService.logError(
'TripInvitationRepository',
'Erreur getPendingInvitation: $e',
stackTrace,
);
throw Exception('Impossible de vérifier les invitations en attente.');
}
}
/// Crée une invitation de voyage en statut `pending`.
///
/// La méthode bloque les doublons d'invitation active pour éviter les spams.
/// Lance une exception si une invitation en attente existe déjà.
@override
Future<void> createInvitation({
required String tripId,
required String tripTitle,
required String inviterId,
required String inviterName,
required String inviteeId,
required String inviteeEmail,
}) async {
try {
final pending = await getPendingInvitation(
tripId: tripId,
inviteeId: inviteeId,
);
if (pending != null) {
throw Exception(
'Une invitation est déjà en attente pour cet utilisateur.',
);
}
await _collection.add({
'tripId': tripId,
'tripTitle': tripTitle,
'inviterId': inviterId,
'inviterName': inviterName,
'inviteeId': inviteeId,
'inviteeEmail': inviteeEmail,
'status': 'pending',
'createdAt': FieldValue.serverTimestamp(),
});
} catch (e, stackTrace) {
_errorService.logError(
'TripInvitationRepository',
'Erreur createInvitation: $e',
stackTrace,
);
rethrow;
}
}
/// Récupère une invitation précise par son identifiant.
///
/// Retourne `null` si le document n'existe pas.
@override
Future<TripInvitation?> getInvitationById(String invitationId) async {
try {
final doc = await _collection.doc(invitationId).get();
if (!doc.exists) {
return null;
}
return TripInvitation.fromFirestore(doc);
} catch (e, stackTrace) {
_errorService.logError(
'TripInvitationRepository',
'Erreur getInvitationById: $e',
stackTrace,
);
throw Exception('Impossible de charger l\'invitation.');
}
}
/// Accepte ou refuse une invitation en attente.
///
/// [isAccepted] à `true` passe le statut à `accepted`, sinon `rejected`.
/// Si l'invitation n'est plus en attente, aucune modification n'est appliquée.
@override
Future<void> respondToInvitation({
required String invitationId,
required bool isAccepted,
}) async {
try {
await _firestore.runTransaction((transaction) async {
final docRef = _collection.doc(invitationId);
final snapshot = await transaction.get(docRef);
if (!snapshot.exists) {
throw Exception('Invitation introuvable.');
}
final data = snapshot.data() ?? <String, dynamic>{};
if ((data['status'] as String? ?? 'pending') != 'pending') {
return;
}
transaction.update(docRef, {
'status': isAccepted ? 'accepted' : 'rejected',
'respondedAt': FieldValue.serverTimestamp(),
});
});
} catch (e, stackTrace) {
_errorService.logError(
'TripInvitationRepository',
'Erreur respondToInvitation: $e',
stackTrace,
);
rethrow;
}
}
/// Écoute les invitations en attente d'un utilisateur.
///
/// Le flux est trié de la plus récente à la plus ancienne.
@override
Stream<List<TripInvitation>> watchPendingInvitationsForUser(String userId) {
return _collection
.where('inviteeId', isEqualTo: userId)
.where('status', isEqualTo: 'pending')
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) {
return snapshot.docs
.map(TripInvitation.fromFirestore)
.toList(growable: false);
});
}
/// Écoute toutes les invitations d'un utilisateur (tous statuts confondus).
///
/// Le flux est trié de la plus récente à la plus ancienne.
@override
Stream<List<TripInvitation>> watchInvitationsForUser(String userId) {
return _collection
.where('inviteeId', isEqualTo: userId)
.orderBy('createdAt', descending: true)
.snapshots()
.map((snapshot) {
return snapshot.docs
.map(TripInvitation.fromFirestore)
.toList(growable: false);
});
}
}

View File

@@ -189,4 +189,47 @@ class UserRepository {
return [];
}
}
/// Recherche des utilisateurs inscrits par email, prénom ou nom.
///
/// Cette méthode est utilisée pour proposer des suggestions lors de
/// l'invitation à un voyage. La recherche est insensible à la casse
/// et retourne au maximum [limit] résultats.
///
/// [query] - Texte saisi par l'utilisateur
/// [limit] - Nombre maximum de résultats retournés
///
/// Retourne une liste vide si [query] est trop court ou en cas d'erreur.
Future<List<User>> searchUsers(String query, {int limit = 8}) async {
final normalizedQuery = query.trim().toLowerCase();
if (normalizedQuery.length < 2) {
return [];
}
try {
final snapshot = await _firestore.collection('users').limit(80).get();
final results = snapshot.docs
.map((doc) => User.fromMap({...doc.data(), 'id': doc.id}))
.where((user) {
final email = user.email.toLowerCase();
final firstName = user.prenom.toLowerCase();
final lastName = user.nom.toLowerCase();
final fullName = '${user.prenom} ${user.nom}'.toLowerCase();
return email.contains(normalizedQuery) ||
firstName.contains(normalizedQuery) ||
lastName.contains(normalizedQuery) ||
fullName.contains(normalizedQuery);
})
.take(limit)
.toList(growable: false);
return results;
} catch (e, stackTrace) {
_errorService.logError(
'UserRepository',
'Error searching users: $e',
stackTrace,
);
return [];
}
}
}

View 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,
});
}

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),
);
}
}