diff --git a/.gitignore b/.gitignore index 200f301..e567dc6 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,4 @@ storage.rules .vscode .VSCodeCounter +AGENTS.md \ No newline at end of file diff --git a/firebase.json b/firebase.json index 93b00ea..cc9b4c1 100644 --- a/firebase.json +++ b/firebase.json @@ -33,5 +33,8 @@ "*.local" ] } - ] + ], + "firestore": { + "rules": "firestore.rules" + } } diff --git a/functions/index.js b/functions/index.js index bb8b232..d2bd33a 100644 --- a/functions/index.js +++ b/functions/index.js @@ -1,87 +1,252 @@ +const crypto = require("crypto"); const functions = require("firebase-functions/v1"); const admin = require("firebase-admin"); +const nodemailer = require("nodemailer"); +const { extractUserFcmTokens } = require("./notification_tokens"); admin.initializeApp(); -// Helper function to send notifications to a list of users +let mailTransporter; + +/** + * Retourne un transporteur SMTP nodemailer initialisé avec les variables d'environnement. + * + * La configuration repose sur les variables du fichier `.env` dans le dossier `functions`. + */ +function getMailTransporter() { + if (mailTransporter) { + return mailTransporter; + } + + mailTransporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT || 587), + secure: String(process.env.SMTP_SECURE || "false") === "true", + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + requireTLS: String(process.env.SMTP_REQUIRE_TLS || "false") === "true", + tls: { + rejectUnauthorized: + String(process.env.SMTP_TLS_REJECT_UNAUTHORIZED || "true") === "true", + }, + }); + + return mailTransporter; +} + +/** + * Normalise une adresse email pour les comparaisons et le stockage. + */ +function normalizeEmail(email) { + return String(email || "").trim().toLowerCase(); +} + +/** + * Génère un code numérique de vérification sur 6 chiffres. + */ +function generateAuthCode() { + return String(Math.floor(100000 + Math.random() * 900000)); +} + +/** + * Hash un code d'authentification avec SHA-256. + */ +function hashCode(code) { + return crypto.createHash("sha256").update(String(code)).digest("hex"); +} + +/** + * Envoie une notification à une liste d'utilisateurs. + */ async function sendNotificationToUsers(userIds, title, body, excludeUserId, data = {}) { - console.log(`Starting sendNotificationToUsers. Total users: ${userIds.length}, Exclude: ${excludeUserId}`); - try { - const tokens = []; + const targets = (userIds || []).filter((id) => id && id !== excludeUserId); + if (targets.length === 0) { + return; + } - for (const userId of userIds) { - if (userId === excludeUserId) { - console.log(`Skipping user ${userId} (sender)`); - continue; - } + const userDocs = await Promise.all( + targets.map((userId) => admin.firestore().collection("users").doc(userId).get()) + ); - const userDoc = await admin.firestore().collection("users").doc(userId).get(); - if (userDoc.exists) { - const userData = userDoc.data(); - if (userData.fcmToken) { - console.log(`Found token for user ${userId}`); - tokens.push(userData.fcmToken); - } else { - console.log(`No FCM token found for user ${userId}`); - } - } else { - console.log(`User document not found for ${userId}`); - } - } + const tokens = userDocs + .filter((doc) => doc.exists) + .flatMap((doc) => extractUserFcmTokens(doc.data() || {})); - // De-duplicate tokens - const uniqueTokens = [...new Set(tokens)]; - console.log(`Total unique tokens to send: ${uniqueTokens.length} (from ${tokens.length} found)`); + await sendPushToTokens(tokens, title, body, data); +} - if (uniqueTokens.length > 0) { - const message = { - notification: { - title: title, - body: body, - }, - tokens: uniqueTokens, - data: { - click_action: "FLUTTER_NOTIFICATION_CLICK", - ...data - }, - android: { - priority: "high", - notification: { - channelId: "high_importance_channel", - } - } - }; +/** + * Envoie une notification à un utilisateur unique. + */ +async function sendNotificationToUser(userId, title, body, data = {}) { + if (!userId) { + return; + } - const response = await admin.messaging().sendEachForMulticast(message); - console.log(`${response.successCount} messages were sent successfully`); - if (response.failureCount > 0) { - console.log('Failed notifications:', response.responses.filter(r => !r.success)); - } - } else { - console.log("No tokens found, skipping notification send."); - } - } catch (error) { - console.error("Error sending notification:", error); + const userDoc = await admin.firestore().collection("users").doc(userId).get(); + if (!userDoc.exists) { + return; + } + + const tokens = extractUserFcmTokens(userDoc.data() || {}); + await sendPushToTokens(tokens, title, body, data); +} + +/** + * Envoie un push multicast à une liste de tokens FCM. + */ +async function sendPushToTokens(tokens, title, body, data = {}) { + const uniqueTokens = [...new Set((tokens || []).filter(Boolean))]; + if (uniqueTokens.length === 0) { + console.log("No tokens found, skipping notification send."); + return; + } + + const normalizedData = Object.fromEntries( + Object.entries(data).map(([key, value]) => [key, value == null ? "" : String(value)]) + ); + + const message = { + notification: { title, body }, + tokens: uniqueTokens, + data: { + click_action: "FLUTTER_NOTIFICATION_CLICK", + ...normalizedData, + }, + android: { + priority: "high", + notification: { + channelId: "high_importance_channel", + }, + }, + }; + + const response = await admin.messaging().sendEachForMulticast(message); + console.log(`${response.successCount} messages were sent successfully`); + if (response.failureCount > 0) { + console.log("Failed notifications:", response.responses.filter((r) => !r.success)); } } +/** + * Envoie un code d'authentification par email via SMTP. + */ +exports.sendEmailAuthCode = functions.https.onCall(async (data) => { + const email = normalizeEmail(data?.email); + if (!email || !email.includes("@")) { + throw new functions.https.HttpsError("invalid-argument", "Adresse email invalide."); + } + + const now = Date.now(); + const oneMinuteAgo = admin.firestore.Timestamp.fromMillis(now - 60 * 1000); + + const recentRequest = await admin + .firestore() + .collection("emailAuthCodes") + .where("email", "==", email) + .where("createdAt", ">=", oneMinuteAgo) + .limit(1) + .get(); + + if (!recentRequest.empty) { + throw new functions.https.HttpsError( + "resource-exhausted", + "Un code a déjà été envoyé récemment. Réessayez dans une minute." + ); + } + + const code = generateAuthCode(); + const codeHash = hashCode(code); + const expiresAt = admin.firestore.Timestamp.fromMillis(now + 10 * 60 * 1000); + + await admin.firestore().collection("emailAuthCodes").add({ + email, + codeHash, + createdAt: admin.firestore.FieldValue.serverTimestamp(), + expiresAt, + used: false, + attempts: 0, + }); + + const transporter = getMailTransporter(); + await transporter.sendMail({ + from: process.env.SMTP_FROM || process.env.SMTP_USER, + to: email, + subject: "Votre code de connexion Travel Mate", + text: `Votre code d'authentification est: ${code}. Il expire dans 10 minutes.`, + html: `

Votre code d'authentification est: ${code}.

Il expire dans 10 minutes.

`, + }); + + return { success: true }; +}); + +/** + * Vérifie un code d'authentification reçu par email. + */ +exports.verifyEmailAuthCode = functions.https.onCall(async (data) => { + const email = normalizeEmail(data?.email); + const code = String(data?.code || "").trim(); + + if (!email || !email.includes("@") || code.length !== 6) { + throw new functions.https.HttpsError("invalid-argument", "Email ou code invalide."); + } + + const now = admin.firestore.Timestamp.now(); + + const snapshot = await admin + .firestore() + .collection("emailAuthCodes") + .where("email", "==", email) + .where("used", "==", false) + .orderBy("createdAt", "desc") + .limit(1) + .get(); + + if (snapshot.empty) { + throw new functions.https.HttpsError("not-found", "Aucun code actif trouvé."); + } + + const doc = snapshot.docs[0]; + const payload = doc.data(); + + if (payload.expiresAt && payload.expiresAt.toMillis() < now.toMillis()) { + throw new functions.https.HttpsError("deadline-exceeded", "Le code a expiré."); + } + + if ((payload.attempts || 0) >= 5) { + throw new functions.https.HttpsError("permission-denied", "Trop de tentatives."); + } + + const providedHash = hashCode(code); + if (providedHash !== payload.codeHash) { + await doc.ref.update({ attempts: admin.firestore.FieldValue.increment(1) }); + throw new functions.https.HttpsError("permission-denied", "Code incorrect."); + } + + await doc.ref.update({ + used: true, + verifiedAt: admin.firestore.FieldValue.serverTimestamp(), + }); + + return { verified: true }; +}); + exports.onActivityCreated = functions.firestore .document("activities/{activityId}") .onCreate(async (snapshot, context) => { - console.log(`onActivityCreated triggered for ${context.params.activityId}`); const activity = snapshot.data(); + const activityId = context.params.activityId; const tripId = activity.tripId; const createdBy = activity.createdBy || "Unknown"; if (!tripId) { - console.log("No tripId found in activity"); return; } - // Fetch trip to get participants const tripDoc = await admin.firestore().collection("trips").doc(tripId).get(); if (!tripDoc.exists) { - console.log(`Trip ${tripId} not found`); return; } @@ -91,9 +256,6 @@ exports.onActivityCreated = functions.firestore participants.push(trip.createdBy); } - console.log(`Found trip participants: ${JSON.stringify(participants)}`); - - // Fetch creator name let creatorName = "Quelqu'un"; if (createdBy !== "Unknown") { const userDoc = await admin.firestore().collection("users").doc(createdBy).get(); @@ -104,78 +266,200 @@ exports.onActivityCreated = functions.firestore await sendNotificationToUsers( participants, - "Nouvelle activité !", - `${creatorName} a ajouté une nouvelle activité : ${activity.name || activity.title}`, + "Nouvelle activité", + `${creatorName} a ajouté : ${activity.name || activity.title || "Activité"}`, createdBy, - { tripId: tripId } + { type: "activity", tripId, activityId } ); }); exports.onMessageCreated = functions.firestore .document("groups/{groupId}/messages/{messageId}") .onCreate(async (snapshot, context) => { - console.log(`onMessageCreated triggered for ${context.params.messageId} in group ${context.params.groupId}`); const message = snapshot.data(); const groupId = context.params.groupId; const senderId = message.senderId; - // Fetch group to get members const groupDoc = await admin.firestore().collection("groups").doc(groupId).get(); if (!groupDoc.exists) { - console.log(`Group ${groupId} not found`); return; } const group = groupDoc.data(); const memberIds = group.memberIds || []; - console.log(`Found group members: ${JSON.stringify(memberIds)}`); - - let senderName = message.senderName || "Quelqu'un"; + const tripId = group.tripId || ""; + const senderName = message.senderName || "Quelqu'un"; await sendNotificationToUsers( memberIds, "Nouveau message", - `${senderName} : ${message.text}`, + `${senderName}: ${message.text}`, senderId, - { groupId: groupId } + { type: "message", groupId, tripId } ); }); exports.onExpenseCreated = functions.firestore .document("expenses/{expenseId}") - .onCreate(async (snapshot, context) => { - console.log(`onExpenseCreated triggered for ${context.params.expenseId}`); + .onCreate(async (snapshot) => { const expense = snapshot.data(); const groupId = expense.groupId; const paidBy = expense.paidById || expense.paidBy; if (!groupId) { - console.log("No groupId found in expense"); return; } - // Fetch group to get members const groupDoc = await admin.firestore().collection("groups").doc(groupId).get(); if (!groupDoc.exists) { - console.log(`Group ${groupId} not found`); return; } const group = groupDoc.data(); const memberIds = group.memberIds || []; - console.log(`Found group members: ${JSON.stringify(memberIds)}`); - - let payerName = expense.paidByName || "Quelqu'un"; + const tripId = group.tripId || ""; + const payerName = expense.paidByName || "Quelqu'un"; await sendNotificationToUsers( memberIds, "Nouvelle dépense", - `${payerName} a ajouté une dépense : ${expense.amount} ${expense.currency || '€'}`, + `${payerName} a ajouté une dépense : ${expense.amount} ${expense.currency || "€"}`, paidBy, - { groupId: groupId } + { type: "expense", groupId, tripId } ); }); +exports.onTripInvitationCreated = functions.firestore + .document("tripInvitations/{invitationId}") + .onCreate(async (snapshot, context) => { + const invitationId = context.params.invitationId; + const invitation = snapshot.data(); + + if (!invitation?.inviteeId) { + return; + } + + await sendNotificationToUser( + invitation.inviteeId, + "Invitation de voyage", + `${invitation.inviterName || "Quelqu'un"} vous invite à rejoindre ${invitation.tripTitle || "un voyage"}`, + { + type: "trip_invitation", + invitationId, + tripId: invitation.tripId, + inviterName: invitation.inviterName || "Quelqu'un", + tripTitle: invitation.tripTitle || "Voyage", + } + ); + }); + +exports.onTripInvitationUpdated = functions.firestore + .document("tripInvitations/{invitationId}") + .onUpdate(async (change) => { + const before = change.before.data(); + const after = change.after.data(); + + if (before.status === after.status || before.status !== "pending") { + return; + } + + const status = after.status; + const tripId = after.tripId; + const inviteeId = after.inviteeId; + + if (!tripId || !inviteeId) { + return; + } + + if (status === "accepted") { + await admin.firestore().runTransaction(async (transaction) => { + const userRef = admin.firestore().collection("users").doc(inviteeId); + const userDoc = await transaction.get(userRef); + if (!userDoc.exists) { + throw new Error("Utilisateur invité introuvable"); + } + const user = userDoc.data(); + + const tripRef = admin.firestore().collection("trips").doc(tripId); + transaction.update(tripRef, { + participants: admin.firestore.FieldValue.arrayUnion(inviteeId), + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }); + + const groupSnapshot = await admin + .firestore() + .collection("groups") + .where("tripId", "==", tripId) + .limit(1) + .get(); + + if (!groupSnapshot.empty) { + const groupRef = groupSnapshot.docs[0].ref; + const memberRef = groupRef.collection("members").doc(inviteeId); + + transaction.set( + memberRef, + { + userId: inviteeId, + firstName: user.prenom || "", + lastName: user.nom || "", + pseudo: user.prenom || "Voyageur", + profilePictureUrl: user.profilePictureUrl || null, + joinedAt: Date.now(), + }, + { merge: true } + ); + + transaction.update(groupRef, { + memberIds: admin.firestore.FieldValue.arrayUnion(inviteeId), + updatedAt: Date.now(), + }); + } + + const accountSnapshot = await admin + .firestore() + .collection("accounts") + .where("tripId", "==", tripId) + .limit(1) + .get(); + + if (!accountSnapshot.empty) { + const accountRef = accountSnapshot.docs[0].ref; + const accountMemberRef = accountRef.collection("members").doc(inviteeId); + transaction.set( + accountMemberRef, + { + userId: inviteeId, + firstName: user.prenom || "", + lastName: user.nom || "", + pseudo: user.prenom || "Voyageur", + profilePictureUrl: user.profilePictureUrl || null, + joinedAt: Date.now(), + }, + { merge: true } + ); + } + }); + + await sendNotificationToUser( + after.inviterId, + "Invitation acceptée", + `${after.inviteeEmail || "L'utilisateur"} a accepté votre invitation.`, + { type: "trip_invitation_response", tripId } + ); + return; + } + + if (status === "rejected") { + await sendNotificationToUser( + after.inviterId, + "Invitation refusée", + `${after.inviteeEmail || "L'utilisateur"} a refusé votre invitation.`, + { type: "trip_invitation_response", tripId } + ); + } + }); + exports.callbacks_signInWithApple = functions.https.onRequest((req, res) => { const code = req.body.code; const state = req.body.state; @@ -183,13 +467,13 @@ exports.callbacks_signInWithApple = functions.https.onRequest((req, res) => { const user = req.body.user; const params = new URLSearchParams(); - if (code) params.append('code', code); - if (state) params.append('state', state); - if (id_token) params.append('id_token', id_token); - if (user) params.append('user', user); + if (code) params.append("code", code); + if (state) params.append("state", state); + if (id_token) params.append("id_token", id_token); + if (user) params.append("user", user); const qs = params.toString(); - const packageName = 'be.devdayronvl.travel_mate'; + const packageName = "be.devdayronvl.travel_mate"; const redirectUrl = `intent://callback?${qs}#Intent;package=${packageName};scheme=signinwithapple;end`; res.redirect(302, redirectUrl); diff --git a/functions/notification_tokens.js b/functions/notification_tokens.js new file mode 100644 index 0000000..a65b6b4 --- /dev/null +++ b/functions/notification_tokens.js @@ -0,0 +1,27 @@ +/** + * Retourne tous les tokens FCM d'un profil utilisateur. + * + * La méthode supporte le format historique `fcmToken` (string) et le nouveau + * format `fcmTokens` (array de strings) pour gérer le multi-appareils. + */ +function extractUserFcmTokens(userData) { + const tokens = []; + + if (typeof userData.fcmToken === "string" && userData.fcmToken.length > 0) { + tokens.push(userData.fcmToken); + } + + if (Array.isArray(userData.fcmTokens)) { + for (const token of userData.fcmTokens) { + if (typeof token === "string" && token.length > 0) { + tokens.push(token); + } + } + } + + return tokens; +} + +module.exports = { + extractUserFcmTokens, +}; diff --git a/functions/notification_tokens.test.js b/functions/notification_tokens.test.js new file mode 100644 index 0000000..fec9eb0 --- /dev/null +++ b/functions/notification_tokens.test.js @@ -0,0 +1,24 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { extractUserFcmTokens } = require("./notification_tokens"); + +test("extractUserFcmTokens supports legacy single token", () => { + const tokens = extractUserFcmTokens({ fcmToken: "token_1" }); + assert.deepEqual(tokens, ["token_1"]); +}); + +test("extractUserFcmTokens merges single and multi token formats", () => { + const tokens = extractUserFcmTokens({ + fcmToken: "token_legacy", + fcmTokens: ["token_a", "token_b"], + }); + assert.deepEqual(tokens, ["token_legacy", "token_a", "token_b"]); +}); + +test("extractUserFcmTokens filters invalid values", () => { + const tokens = extractUserFcmTokens({ + fcmToken: "", + fcmTokens: ["valid", null, 42, ""], + }); + assert.deepEqual(tokens, ["valid"]); +}); diff --git a/functions/package-lock.json b/functions/package-lock.json index 9e5db31..f1c53e1 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -7,13 +7,14 @@ "name": "functions", "dependencies": { "firebase-admin": "^13.6.0", - "firebase-functions": "^7.0.0" + "firebase-functions": "^7.0.0", + "nodemailer": "^6.10.1" }, "devDependencies": { "firebase-functions-test": "^3.4.1" }, "engines": { - "node": "24" + "node": "20" } }, "node_modules/@babel/code-frame": { @@ -5209,6 +5210,15 @@ "dev": true, "license": "MIT" }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", diff --git a/functions/package.json b/functions/package.json index f19305f..e1d5d09 100644 --- a/functions/package.json +++ b/functions/package.json @@ -14,10 +14,11 @@ "main": "index.js", "dependencies": { "firebase-admin": "^13.6.0", - "firebase-functions": "^7.0.0" + "firebase-functions": "^7.0.0", + "nodemailer": "^6.10.1" }, "devDependencies": { "firebase-functions-test": "^3.4.1" }, "private": true -} \ No newline at end of file +} diff --git a/lib/blocs/auth/auth_bloc.dart b/lib/blocs/auth/auth_bloc.dart index a65f00e..53c8fe5 100644 --- a/lib/blocs/auth/auth_bloc.dart +++ b/lib/blocs/auth/auth_bloc.dart @@ -111,7 +111,7 @@ class AuthBloc extends Bloc { 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 { 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 { 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 { 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', diff --git a/lib/blocs/user/user_bloc.dart b/lib/blocs/user/user_bloc.dart index 472b53e..ec16147 100644 --- a/lib/blocs/user/user_bloc.dart +++ b/lib/blocs/user/user_bloc.dart @@ -92,6 +92,7 @@ class UserBloc extends Bloc { 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 { diff --git a/lib/components/activities/activities_page.dart b/lib/components/activities/activities_page.dart index 3ba2f4b..33d2951 100644 --- a/lib/components/activities/activities_page.dart +++ b/lib/components/activities/activities_page.dart @@ -926,7 +926,7 @@ class _ActivitiesPageState extends State 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, diff --git a/lib/components/home/create_trip_content.dart b/lib/components/home/create_trip_content.dart index a67434f..a80bfd2 100644 --- a/lib/components/home/create_trip_content.dart +++ b/lib/components/home/create_trip_content.dart @@ -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'; diff --git a/lib/components/home/show_trip_details_content.dart b/lib/components/home/show_trip_details_content.dart index fcae30e..90c0e21 100644 --- a/lib/components/home/show_trip_details_content.dart +++ b/lib/components/home/show_trip_details_content.dart @@ -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 { 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 { void _showAddParticipantDialog() { final theme = Theme.of(context); final TextEditingController emailController = TextEditingController(); + List 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 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 _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 _inviteParticipantByEmail({ + required String email, + User? selectedUser, + }) async { try { - // Chercher l'utilisateur par email - final user = await _userRepository.getUserByEmail(email); + final currentUserState = context.read().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 { 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().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'); } } diff --git a/lib/components/map/map_content.dart b/lib/components/map/map_content.dart index c8af78a..20da01c 100644 --- a/lib/components/map/map_content.dart +++ b/lib/components/map/map_content.dart @@ -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'; diff --git a/lib/models/trip_invitation.dart b/lib/models/trip_invitation.dart new file mode 100644 index 0000000..365e193 --- /dev/null +++ b/lib/models/trip_invitation.dart @@ -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> doc, + ) { + final data = doc.data() ?? {}; + + 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 toMap() { + final map = { + '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; + } +} diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 0d5d71b..8b1f1d0 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -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 { 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), diff --git a/lib/pages/trip_invitations_page.dart b/lib/pages/trip_invitations_page.dart new file mode 100644 index 0000000..c28ff5b --- /dev/null +++ b/lib/pages/trip_invitations_page.dart @@ -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 _filterByStatus( + List 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 _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( + 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 Function({ + required String invitationId, + required bool isAccepted, + }) + onRespond; + + /// Fonction de filtrage par statut. + final List Function( + List 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>( + 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 []; + 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 invitations; + + /// Callback appelé lors d'une acceptation. + final Future Function(String invitationId)? onAccept; + + /// Callback appelé lors d'un refus. + final Future 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'), + ), + ], + ), + ], + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/repositories/auth_repository.dart b/lib/repositories/auth_repository.dart index 0444762..09ffe9e 100644 --- a/lib/repositories/auth_repository.dart +++ b/lib/repositories/auth_repository.dart @@ -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) { diff --git a/lib/repositories/trip_invitation_repository.dart b/lib/repositories/trip_invitation_repository.dart new file mode 100644 index 0000000..69ff53f --- /dev/null +++ b/lib/repositories/trip_invitation_repository.dart @@ -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 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 getPendingInvitation({ + required String tripId, + required String inviteeId, + }); + + /// Retourne une invitation par son identifiant. + Future getInvitationById(String invitationId); + + /// Met à jour une invitation selon la réponse utilisateur. + Future respondToInvitation({ + required String invitationId, + required bool isAccepted, + }); + + /// Retourne les invitations en attente d'un utilisateur. + Stream> watchPendingInvitationsForUser(String userId); + + /// Retourne toutes les invitations d'un utilisateur. + Stream> 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> 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 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 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 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 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() ?? {}; + 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> 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> 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); + }); + } +} diff --git a/lib/repositories/user_repository.dart b/lib/repositories/user_repository.dart index d91b941..d4f6b7d 100644 --- a/lib/repositories/user_repository.dart +++ b/lib/repositories/user_repository.dart @@ -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> 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 []; + } + } } diff --git a/lib/services/notification_payload_parser.dart b/lib/services/notification_payload_parser.dart new file mode 100644 index 0000000..1ccf955 --- /dev/null +++ b/lib/services/notification_payload_parser.dart @@ -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 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 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, + }); +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 5e4c810..ffd71c8 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -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 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 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; - _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; + _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 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 _handleNotificationTap(Map 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 _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 _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 { - 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 getFCMToken() async { try { if (Platform.isIOS) { @@ -209,12 +361,14 @@ class NotificationService { } } + /// 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'); } @@ -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 _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), + ); } } diff --git a/pubspec.lock b/pubspec.lock index 87f1ac1..957490b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -458,7 +458,7 @@ packages: source: hosted version: "4.3.0" firebase_core_platform_interface: - dependency: transitive + dependency: "direct dev" description: name: firebase_core_platform_interface sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 diff --git a/pubspec.yaml b/pubspec.yaml index f7885c8..c975f67 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.1.3+1 +version: 2026.3.2+3 environment: sdk: ^3.9.2 @@ -78,6 +78,7 @@ dev_dependencies: mockito: ^5.4.4 build_runner: ^2.4.8 bloc_test: ^10.0.0 + firebase_core_platform_interface: ^6.0.2 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/blocs/auth_bloc_test.dart b/test/blocs/auth_bloc_test.dart index 8fb98fd..e843fee 100644 --- a/test/blocs/auth_bloc_test.dart +++ b/test/blocs/auth_bloc_test.dart @@ -8,16 +8,22 @@ import 'package:travel_mate/blocs/auth/auth_state.dart'; import 'package:travel_mate/repositories/auth_repository.dart'; import 'package:travel_mate/models/user.dart'; import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_core_platform_interface/test.dart'; import 'package:travel_mate/services/notification_service.dart'; +import 'package:travel_mate/services/analytics_service.dart'; import 'auth_bloc_test.mocks.dart'; @GenerateMocks([AuthRepository, NotificationService]) void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + group('AuthBloc', () { late MockAuthRepository mockAuthRepository; late MockNotificationService mockNotificationService; + late FakeAnalyticsService fakeAnalyticsService; late AuthBloc authBloc; final user = User( @@ -28,12 +34,19 @@ void main() { platform: 'email', ); + setUpAll(() async { + setupFirebaseCoreMocks(); + await Firebase.initializeApp(); + }); + setUp(() { mockAuthRepository = MockAuthRepository(); mockNotificationService = MockNotificationService(); + fakeAnalyticsService = FakeAnalyticsService(); authBloc = AuthBloc( authRepository: mockAuthRepository, notificationService: mockNotificationService, + analyticsService: fakeAnalyticsService, ); // Default stub for saveTokenToFirestore to avoid strict mock errors @@ -128,3 +141,14 @@ class MockFirebaseUser extends Mock implements firebase_auth.User { MockFirebaseUser({required this.uid, this.email}); } + +class FakeAnalyticsService extends AnalyticsService { + @override + Future logEvent({ + required String name, + Map? parameters, + }) async {} + + @override + Future setUserId(String? id) async {} +} diff --git a/test/models/trip_invitation_test.dart b/test/models/trip_invitation_test.dart new file mode 100644 index 0000000..bb09737 --- /dev/null +++ b/test/models/trip_invitation_test.dart @@ -0,0 +1,49 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:travel_mate/models/trip_invitation.dart'; + +void main() { + group('TripInvitation', () { + test('toMap serializes required fields', () { + final invitation = TripInvitation( + id: 'id_1', + tripId: 'trip_1', + tripTitle: 'Weekend Rome', + inviterId: 'user_a', + inviterName: 'Alice', + inviteeId: 'user_b', + inviteeEmail: 'bob@example.com', + createdAt: DateTime(2026, 3, 3), + ); + + final map = invitation.toMap(); + + expect(map['tripId'], 'trip_1'); + expect(map['status'], 'pending'); + expect(map['respondedAt'], isNull); + expect(map['createdAt'], isA()); + }); + + test('copyWith updates selected values', () { + final invitation = TripInvitation( + id: 'id_1', + tripId: 'trip_1', + tripTitle: 'Trip', + inviterId: 'user_a', + inviterName: 'Alice', + inviteeId: 'user_b', + inviteeEmail: 'bob@example.com', + createdAt: DateTime(2026, 3, 3), + ); + + final updated = invitation.copyWith( + status: 'accepted', + respondedAt: DateTime(2026, 3, 4), + ); + + expect(updated.status, 'accepted'); + expect(updated.respondedAt, DateTime(2026, 3, 4)); + expect(updated.tripId, invitation.tripId); + }); + }); +} diff --git a/test/pages/trip_invitations_page_test.dart b/test/pages/trip_invitations_page_test.dart new file mode 100644 index 0000000..bf0ae3f --- /dev/null +++ b/test/pages/trip_invitations_page_test.dart @@ -0,0 +1,124 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:travel_mate/models/trip_invitation.dart'; +import 'package:travel_mate/pages/trip_invitations_page.dart'; +import 'package:travel_mate/repositories/trip_invitation_repository.dart'; + +class _FakeTripInvitationDataSource implements TripInvitationDataSource { + final StreamController> _controller = + StreamController>(); + + bool acceptedCalled = false; + bool rejectedCalled = false; + + void emitInvitations(List invitations) { + _controller.add(invitations); + } + + @override + Future respondToInvitation({ + required String invitationId, + required bool isAccepted, + }) async { + if (isAccepted) { + acceptedCalled = true; + return; + } + rejectedCalled = true; + } + + @override + Stream> watchInvitationsForUser(String userId) { + return _controller.stream; + } + + @override + Future createInvitation({ + required String tripId, + required String tripTitle, + required String inviterId, + required String inviterName, + required String inviteeId, + required String inviteeEmail, + }) async {} + + @override + Future getInvitationById(String invitationId) async { + return null; + } + + @override + Future getPendingInvitation({ + required String tripId, + required String inviteeId, + }) async { + return null; + } + + @override + Stream> watchPendingInvitationsForUser(String userId) { + return _controller.stream; + } + + Future dispose() async { + await _controller.close(); + } +} + +void main() { + group('TripInvitationsPage', () { + testWidgets('shows invitations in correct tabs', (tester) async { + final fakeRepository = _FakeTripInvitationDataSource(); + + await tester.pumpWidget( + MaterialApp( + home: TripInvitationsPage( + repository: fakeRepository, + userIdOverride: 'user_1', + ), + ), + ); + + final invitations = [ + TripInvitation( + id: 'pending_1', + tripId: 'trip_1', + tripTitle: 'Road Trip', + inviterId: 'u2', + inviterName: 'Alice', + inviteeId: 'user_1', + inviteeEmail: 'user@example.com', + status: 'pending', + createdAt: DateTime(2026, 3, 6), + ), + TripInvitation( + id: 'accepted_1', + tripId: 'trip_2', + tripTitle: 'Tokyo', + inviterId: 'u3', + inviterName: 'Bob', + inviteeId: 'user_1', + inviteeEmail: 'user@example.com', + status: 'accepted', + createdAt: DateTime(2026, 3, 6), + ), + ]; + + fakeRepository.emitInvitations(invitations); + await tester.pump(); + + expect(find.text('Road Trip'), findsOneWidget); + expect(find.text('Accepter'), findsOneWidget); + + await tester.tap(find.text('Acceptées')); + await tester.pumpAndSettle(); + + expect(find.text('Tokyo'), findsOneWidget); + expect(find.text('Accepter'), findsNothing); + + await fakeRepository.dispose(); + }); + }); +} diff --git a/test/services/notification_payload_parser_test.dart b/test/services/notification_payload_parser_test.dart new file mode 100644 index 0000000..1d673bc --- /dev/null +++ b/test/services/notification_payload_parser_test.dart @@ -0,0 +1,42 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:travel_mate/services/notification_payload_parser.dart'; + +void main() { + group('NotificationPayloadParser', () { + test('uses explicit type when present', () { + final action = NotificationPayloadParser.parse({ + 'type': 'activity', + 'tripId': 'trip_1', + 'activityId': 'activity_42', + }); + + expect(action.type, 'activity'); + expect(action.tripId, 'trip_1'); + expect(action.activityId, 'activity_42'); + }); + + test('infers invitation type when invitationId exists', () { + final action = NotificationPayloadParser.parse({ + 'invitationId': 'inv_1', + 'tripId': 'trip_1', + }); + + expect(action.type, 'trip_invitation'); + expect(action.invitationId, 'inv_1'); + }); + + test('infers message type when groupId exists', () { + final action = NotificationPayloadParser.parse({'groupId': 'group_1'}); + + expect(action.type, 'message'); + expect(action.groupId, 'group_1'); + }); + + test('falls back to trip type when only tripId exists', () { + final action = NotificationPayloadParser.parse({'tripId': 'trip_1'}); + + expect(action.type, 'trip'); + expect(action.tripId, 'trip_1'); + }); + }); +}