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