feat: Implement trip invitation functionality and notification handling
- Added TripInvitationRepository for managing trip invitations. - Created TripInvitation model with serialization methods. - Implemented notification payload parser for handling FCM notifications. - Enhanced NotificationService to manage trip invitations and related actions. - Updated UserRepository to include user search functionality. - Modified AuthRepository to store multiple FCM tokens. - Added tests for trip invitation logic and notification payload parsing. - Updated pubspec.yaml and pubspec.lock for dependency management.
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,3 +56,4 @@ storage.rules
|
|||||||
.vscode
|
.vscode
|
||||||
.VSCodeCounter
|
.VSCodeCounter
|
||||||
|
|
||||||
|
AGENTS.md
|
||||||
@@ -33,5 +33,8 @@
|
|||||||
"*.local"
|
"*.local"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"firestore": {
|
||||||
|
"rules": "firestore.rules"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +1,252 @@
|
|||||||
|
const crypto = require("crypto");
|
||||||
const functions = require("firebase-functions/v1");
|
const functions = require("firebase-functions/v1");
|
||||||
const admin = require("firebase-admin");
|
const admin = require("firebase-admin");
|
||||||
|
const nodemailer = require("nodemailer");
|
||||||
|
const { extractUserFcmTokens } = require("./notification_tokens");
|
||||||
|
|
||||||
admin.initializeApp();
|
admin.initializeApp();
|
||||||
|
|
||||||
// Helper function to send notifications to a list of users
|
let mailTransporter;
|
||||||
async function sendNotificationToUsers(userIds, title, body, excludeUserId, data = {}) {
|
|
||||||
console.log(`Starting sendNotificationToUsers. Total users: ${userIds.length}, Exclude: ${excludeUserId}`);
|
|
||||||
try {
|
|
||||||
const tokens = [];
|
|
||||||
|
|
||||||
for (const userId of userIds) {
|
/**
|
||||||
if (userId === excludeUserId) {
|
* Retourne un transporteur SMTP nodemailer initialisé avec les variables d'environnement.
|
||||||
console.log(`Skipping user ${userId} (sender)`);
|
*
|
||||||
continue;
|
* 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 = {}) {
|
||||||
|
const targets = (userIds || []).filter((id) => id && id !== excludeUserId);
|
||||||
|
if (targets.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userDocs = await Promise.all(
|
||||||
|
targets.map((userId) => admin.firestore().collection("users").doc(userId).get())
|
||||||
|
);
|
||||||
|
|
||||||
|
const tokens = userDocs
|
||||||
|
.filter((doc) => doc.exists)
|
||||||
|
.flatMap((doc) => extractUserFcmTokens(doc.data() || {}));
|
||||||
|
|
||||||
|
await sendPushToTokens(tokens, title, body, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie une notification à un utilisateur unique.
|
||||||
|
*/
|
||||||
|
async function sendNotificationToUser(userId, title, body, data = {}) {
|
||||||
|
if (!userId) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const userDoc = await admin.firestore().collection("users").doc(userId).get();
|
const userDoc = await admin.firestore().collection("users").doc(userId).get();
|
||||||
if (userDoc.exists) {
|
if (!userDoc.exists) {
|
||||||
const userData = userDoc.data();
|
return;
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// De-duplicate tokens
|
const tokens = extractUserFcmTokens(userDoc.data() || {});
|
||||||
const uniqueTokens = [...new Set(tokens)];
|
await sendPushToTokens(tokens, title, body, data);
|
||||||
console.log(`Total unique tokens to send: ${uniqueTokens.length} (from ${tokens.length} found)`);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)])
|
||||||
|
);
|
||||||
|
|
||||||
if (uniqueTokens.length > 0) {
|
|
||||||
const message = {
|
const message = {
|
||||||
notification: {
|
notification: { title, body },
|
||||||
title: title,
|
|
||||||
body: body,
|
|
||||||
},
|
|
||||||
tokens: uniqueTokens,
|
tokens: uniqueTokens,
|
||||||
data: {
|
data: {
|
||||||
click_action: "FLUTTER_NOTIFICATION_CLICK",
|
click_action: "FLUTTER_NOTIFICATION_CLICK",
|
||||||
...data
|
...normalizedData,
|
||||||
},
|
},
|
||||||
android: {
|
android: {
|
||||||
priority: "high",
|
priority: "high",
|
||||||
notification: {
|
notification: {
|
||||||
channelId: "high_importance_channel",
|
channelId: "high_importance_channel",
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await admin.messaging().sendEachForMulticast(message);
|
const response = await admin.messaging().sendEachForMulticast(message);
|
||||||
console.log(`${response.successCount} messages were sent successfully`);
|
console.log(`${response.successCount} messages were sent successfully`);
|
||||||
if (response.failureCount > 0) {
|
if (response.failureCount > 0) {
|
||||||
console.log('Failed notifications:', response.responses.filter(r => !r.success));
|
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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: `<p>Votre code d'authentification est: <strong>${code}</strong>.</p><p>Il expire dans 10 minutes.</p>`,
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
exports.onActivityCreated = functions.firestore
|
||||||
.document("activities/{activityId}")
|
.document("activities/{activityId}")
|
||||||
.onCreate(async (snapshot, context) => {
|
.onCreate(async (snapshot, context) => {
|
||||||
console.log(`onActivityCreated triggered for ${context.params.activityId}`);
|
|
||||||
const activity = snapshot.data();
|
const activity = snapshot.data();
|
||||||
|
const activityId = context.params.activityId;
|
||||||
const tripId = activity.tripId;
|
const tripId = activity.tripId;
|
||||||
const createdBy = activity.createdBy || "Unknown";
|
const createdBy = activity.createdBy || "Unknown";
|
||||||
|
|
||||||
if (!tripId) {
|
if (!tripId) {
|
||||||
console.log("No tripId found in activity");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch trip to get participants
|
|
||||||
const tripDoc = await admin.firestore().collection("trips").doc(tripId).get();
|
const tripDoc = await admin.firestore().collection("trips").doc(tripId).get();
|
||||||
if (!tripDoc.exists) {
|
if (!tripDoc.exists) {
|
||||||
console.log(`Trip ${tripId} not found`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,9 +256,6 @@ exports.onActivityCreated = functions.firestore
|
|||||||
participants.push(trip.createdBy);
|
participants.push(trip.createdBy);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Found trip participants: ${JSON.stringify(participants)}`);
|
|
||||||
|
|
||||||
// Fetch creator name
|
|
||||||
let creatorName = "Quelqu'un";
|
let creatorName = "Quelqu'un";
|
||||||
if (createdBy !== "Unknown") {
|
if (createdBy !== "Unknown") {
|
||||||
const userDoc = await admin.firestore().collection("users").doc(createdBy).get();
|
const userDoc = await admin.firestore().collection("users").doc(createdBy).get();
|
||||||
@@ -104,78 +266,200 @@ exports.onActivityCreated = functions.firestore
|
|||||||
|
|
||||||
await sendNotificationToUsers(
|
await sendNotificationToUsers(
|
||||||
participants,
|
participants,
|
||||||
"Nouvelle activité !",
|
"Nouvelle activité",
|
||||||
`${creatorName} a ajouté une nouvelle activité : ${activity.name || activity.title}`,
|
`${creatorName} a ajouté : ${activity.name || activity.title || "Activité"}`,
|
||||||
createdBy,
|
createdBy,
|
||||||
{ tripId: tripId }
|
{ type: "activity", tripId, activityId }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
exports.onMessageCreated = functions.firestore
|
exports.onMessageCreated = functions.firestore
|
||||||
.document("groups/{groupId}/messages/{messageId}")
|
.document("groups/{groupId}/messages/{messageId}")
|
||||||
.onCreate(async (snapshot, context) => {
|
.onCreate(async (snapshot, context) => {
|
||||||
console.log(`onMessageCreated triggered for ${context.params.messageId} in group ${context.params.groupId}`);
|
|
||||||
const message = snapshot.data();
|
const message = snapshot.data();
|
||||||
const groupId = context.params.groupId;
|
const groupId = context.params.groupId;
|
||||||
const senderId = message.senderId;
|
const senderId = message.senderId;
|
||||||
|
|
||||||
// Fetch group to get members
|
|
||||||
const groupDoc = await admin.firestore().collection("groups").doc(groupId).get();
|
const groupDoc = await admin.firestore().collection("groups").doc(groupId).get();
|
||||||
if (!groupDoc.exists) {
|
if (!groupDoc.exists) {
|
||||||
console.log(`Group ${groupId} not found`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = groupDoc.data();
|
const group = groupDoc.data();
|
||||||
const memberIds = group.memberIds || [];
|
const memberIds = group.memberIds || [];
|
||||||
console.log(`Found group members: ${JSON.stringify(memberIds)}`);
|
const tripId = group.tripId || "";
|
||||||
|
const senderName = message.senderName || "Quelqu'un";
|
||||||
let senderName = message.senderName || "Quelqu'un";
|
|
||||||
|
|
||||||
await sendNotificationToUsers(
|
await sendNotificationToUsers(
|
||||||
memberIds,
|
memberIds,
|
||||||
"Nouveau message",
|
"Nouveau message",
|
||||||
`${senderName}: ${message.text}`,
|
`${senderName}: ${message.text}`,
|
||||||
senderId,
|
senderId,
|
||||||
{ groupId: groupId }
|
{ type: "message", groupId, tripId }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
exports.onExpenseCreated = functions.firestore
|
exports.onExpenseCreated = functions.firestore
|
||||||
.document("expenses/{expenseId}")
|
.document("expenses/{expenseId}")
|
||||||
.onCreate(async (snapshot, context) => {
|
.onCreate(async (snapshot) => {
|
||||||
console.log(`onExpenseCreated triggered for ${context.params.expenseId}`);
|
|
||||||
const expense = snapshot.data();
|
const expense = snapshot.data();
|
||||||
const groupId = expense.groupId;
|
const groupId = expense.groupId;
|
||||||
const paidBy = expense.paidById || expense.paidBy;
|
const paidBy = expense.paidById || expense.paidBy;
|
||||||
|
|
||||||
if (!groupId) {
|
if (!groupId) {
|
||||||
console.log("No groupId found in expense");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch group to get members
|
|
||||||
const groupDoc = await admin.firestore().collection("groups").doc(groupId).get();
|
const groupDoc = await admin.firestore().collection("groups").doc(groupId).get();
|
||||||
if (!groupDoc.exists) {
|
if (!groupDoc.exists) {
|
||||||
console.log(`Group ${groupId} not found`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = groupDoc.data();
|
const group = groupDoc.data();
|
||||||
const memberIds = group.memberIds || [];
|
const memberIds = group.memberIds || [];
|
||||||
console.log(`Found group members: ${JSON.stringify(memberIds)}`);
|
const tripId = group.tripId || "";
|
||||||
|
const payerName = expense.paidByName || "Quelqu'un";
|
||||||
let payerName = expense.paidByName || "Quelqu'un";
|
|
||||||
|
|
||||||
await sendNotificationToUsers(
|
await sendNotificationToUsers(
|
||||||
memberIds,
|
memberIds,
|
||||||
"Nouvelle dépense",
|
"Nouvelle dépense",
|
||||||
`${payerName} a ajouté une dépense : ${expense.amount} ${expense.currency || '€'}`,
|
`${payerName} a ajouté une dépense : ${expense.amount} ${expense.currency || "€"}`,
|
||||||
paidBy,
|
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) => {
|
exports.callbacks_signInWithApple = functions.https.onRequest((req, res) => {
|
||||||
const code = req.body.code;
|
const code = req.body.code;
|
||||||
const state = req.body.state;
|
const state = req.body.state;
|
||||||
@@ -183,13 +467,13 @@ exports.callbacks_signInWithApple = functions.https.onRequest((req, res) => {
|
|||||||
const user = req.body.user;
|
const user = req.body.user;
|
||||||
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (code) params.append('code', code);
|
if (code) params.append("code", code);
|
||||||
if (state) params.append('state', state);
|
if (state) params.append("state", state);
|
||||||
if (id_token) params.append('id_token', id_token);
|
if (id_token) params.append("id_token", id_token);
|
||||||
if (user) params.append('user', user);
|
if (user) params.append("user", user);
|
||||||
|
|
||||||
const qs = params.toString();
|
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`;
|
const redirectUrl = `intent://callback?${qs}#Intent;package=${packageName};scheme=signinwithapple;end`;
|
||||||
|
|
||||||
res.redirect(302, redirectUrl);
|
res.redirect(302, redirectUrl);
|
||||||
|
|||||||
27
functions/notification_tokens.js
Normal file
27
functions/notification_tokens.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
24
functions/notification_tokens.test.js
Normal file
24
functions/notification_tokens.test.js
Normal file
@@ -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"]);
|
||||||
|
});
|
||||||
14
functions/package-lock.json
generated
14
functions/package-lock.json
generated
@@ -7,13 +7,14 @@
|
|||||||
"name": "functions",
|
"name": "functions",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"firebase-admin": "^13.6.0",
|
"firebase-admin": "^13.6.0",
|
||||||
"firebase-functions": "^7.0.0"
|
"firebase-functions": "^7.0.0",
|
||||||
|
"nodemailer": "^6.10.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"firebase-functions-test": "^3.4.1"
|
"firebase-functions-test": "^3.4.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "24"
|
"node": "20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
@@ -5209,6 +5210,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"firebase-admin": "^13.6.0",
|
"firebase-admin": "^13.6.0",
|
||||||
"firebase-functions": "^7.0.0"
|
"firebase-functions": "^7.0.0",
|
||||||
|
"nodemailer": "^6.10.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"firebase-functions-test": "^3.4.1"
|
"firebase-functions-test": "^3.4.1"
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
|
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
// Save FCM Token
|
// Save FCM Token
|
||||||
await NotificationService().saveTokenToFirestore(user.id!);
|
await _notificationService.saveTokenToFirestore(user.id!);
|
||||||
await _analyticsService.setUserId(user.id);
|
await _analyticsService.setUserId(user.id);
|
||||||
await _analyticsService.logEvent(
|
await _analyticsService.logEvent(
|
||||||
name: 'login',
|
name: 'login',
|
||||||
@@ -147,7 +147,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
|
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
// Save FCM Token
|
// Save FCM Token
|
||||||
await NotificationService().saveTokenToFirestore(user.id!);
|
await _notificationService.saveTokenToFirestore(user.id!);
|
||||||
await _analyticsService.setUserId(user.id);
|
await _analyticsService.setUserId(user.id);
|
||||||
await _analyticsService.logEvent(
|
await _analyticsService.logEvent(
|
||||||
name: 'sign_up',
|
name: 'sign_up',
|
||||||
@@ -177,7 +177,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
|
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
// Save FCM Token
|
// Save FCM Token
|
||||||
await NotificationService().saveTokenToFirestore(user.id!);
|
await _notificationService.saveTokenToFirestore(user.id!);
|
||||||
await _analyticsService.setUserId(user.id);
|
await _analyticsService.setUserId(user.id);
|
||||||
await _analyticsService.logEvent(
|
await _analyticsService.logEvent(
|
||||||
name: 'login',
|
name: 'login',
|
||||||
@@ -268,7 +268,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
|
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
// Save FCM Token
|
// Save FCM Token
|
||||||
await NotificationService().saveTokenToFirestore(user.id!);
|
await _notificationService.saveTokenToFirestore(user.id!);
|
||||||
await _analyticsService.setUserId(user.id);
|
await _analyticsService.setUserId(user.id);
|
||||||
await _analyticsService.logEvent(
|
await _analyticsService.logEvent(
|
||||||
name: 'login',
|
name: 'login',
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ class UserBloc extends Bloc<event.UserEvent, state.UserState> {
|
|||||||
LoggerService.info('UserBloc - Updating FCM token in Firestore');
|
LoggerService.info('UserBloc - Updating FCM token in Firestore');
|
||||||
await _firestore.collection('users').doc(currentUser.uid).set({
|
await _firestore.collection('users').doc(currentUser.uid).set({
|
||||||
'fcmToken': fcmToken,
|
'fcmToken': fcmToken,
|
||||||
|
'fcmTokens': FieldValue.arrayUnion([fcmToken]),
|
||||||
}, SetOptions(merge: true));
|
}, SetOptions(merge: true));
|
||||||
LoggerService.info('UserBloc - FCM token updated');
|
LoggerService.info('UserBloc - FCM token updated');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -926,7 +926,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
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(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onErrorContainer,
|
color: theme.colorScheme.onErrorContainer,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
@@ -20,7 +19,6 @@ import '../../services/user_service.dart';
|
|||||||
import '../../repositories/group_repository.dart';
|
import '../../repositories/group_repository.dart';
|
||||||
import '../../repositories/account_repository.dart';
|
import '../../repositories/account_repository.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
||||||
import '../../services/place_image_service.dart';
|
import '../../services/place_image_service.dart';
|
||||||
import '../../services/trip_geocoding_service.dart';
|
import '../../services/trip_geocoding_service.dart';
|
||||||
import '../../services/logger_service.dart';
|
import '../../services/logger_service.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/group.dart';
|
||||||
import 'package:travel_mate/models/account.dart';
|
import 'package:travel_mate/models/account.dart';
|
||||||
import 'package:travel_mate/models/user_balance.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 {
|
class ShowTripDetailsContent extends StatefulWidget {
|
||||||
final Trip trip;
|
final Trip trip;
|
||||||
@@ -48,6 +50,8 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
|
|||||||
final GroupRepository _groupRepository = GroupRepository();
|
final GroupRepository _groupRepository = GroupRepository();
|
||||||
final UserRepository _userRepository = UserRepository();
|
final UserRepository _userRepository = UserRepository();
|
||||||
final AccountRepository _accountRepository = AccountRepository();
|
final AccountRepository _accountRepository = AccountRepository();
|
||||||
|
final TripInvitationRepository _tripInvitationRepository =
|
||||||
|
TripInvitationRepository();
|
||||||
|
|
||||||
Group? _group;
|
Group? _group;
|
||||||
Account? _account;
|
Account? _account;
|
||||||
@@ -954,13 +958,62 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
|
|||||||
void _showAddParticipantDialog() {
|
void _showAddParticipantDialog() {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final TextEditingController emailController = TextEditingController();
|
final TextEditingController emailController = TextEditingController();
|
||||||
|
List<User> suggestions = [];
|
||||||
|
User? selectedUser;
|
||||||
|
bool isSearching = false;
|
||||||
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (BuildContext context, StateSetter setDialogState) {
|
||||||
|
/// Recherche des utilisateurs inscrits pour les suggestions.
|
||||||
|
///
|
||||||
|
/// Les participants déjà dans le voyage et l'utilisateur courant
|
||||||
|
/// sont exclus pour éviter les invitations invalides.
|
||||||
|
Future<void> searchSuggestions(String query) async {
|
||||||
|
final normalizedQuery = query.trim();
|
||||||
|
if (normalizedQuery.length < 2) {
|
||||||
|
setDialogState(() {
|
||||||
|
suggestions = [];
|
||||||
|
selectedUser = null;
|
||||||
|
isSearching = false;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogState(() {
|
||||||
|
isSearching = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
final users = await _userRepository.searchUsers(normalizedQuery);
|
||||||
|
final participantIds = {
|
||||||
|
...widget.trip.participants,
|
||||||
|
widget.trip.createdBy,
|
||||||
|
};
|
||||||
|
final filteredUsers = users
|
||||||
|
.where((user) {
|
||||||
|
if (user.id == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return !participantIds.contains(user.id);
|
||||||
|
})
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDialogState(() {
|
||||||
|
suggestions = filteredUsers;
|
||||||
|
isSearching = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface,
|
theme.dialogTheme.backgroundColor ??
|
||||||
|
theme.colorScheme.surface,
|
||||||
title: Text(
|
title: Text(
|
||||||
'Ajouter un participant',
|
'Ajouter un participant',
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
@@ -971,7 +1024,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Entrez l\'email du participant à ajouter :',
|
'Recherchez un utilisateur déjà inscrit (email, prénom ou nom).',
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: theme.colorScheme.onSurface,
|
color: theme.colorScheme.onSurface,
|
||||||
),
|
),
|
||||||
@@ -980,10 +1033,13 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
|
|||||||
TextField(
|
TextField(
|
||||||
controller: emailController,
|
controller: emailController,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
onChanged: searchSuggestions,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'participant@example.com',
|
hintText: 'participant@example.com',
|
||||||
hintStyle: TextStyle(
|
hintStyle: TextStyle(
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
color: theme.colorScheme.onSurface.withValues(
|
||||||
|
alpha: 0.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -995,6 +1051,40 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
|
|||||||
),
|
),
|
||||||
style: TextStyle(color: theme.colorScheme.onSurface),
|
style: TextStyle(color: theme.colorScheme.onSurface),
|
||||||
),
|
),
|
||||||
|
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: [
|
actions: [
|
||||||
@@ -1007,17 +1097,21 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
|
|||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (emailController.text.isNotEmpty) {
|
if (emailController.text.trim().isEmpty) {
|
||||||
_addParticipantByEmail(emailController.text);
|
|
||||||
Navigator.pop(context);
|
|
||||||
} else {
|
|
||||||
_errorService.showError(
|
_errorService.showError(
|
||||||
message: 'Veuillez entrer un email valide',
|
message: 'Veuillez entrer un email valide',
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_inviteParticipantByEmail(
|
||||||
|
email: emailController.text.trim(),
|
||||||
|
selectedUser: selectedUser,
|
||||||
|
);
|
||||||
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
'Ajouter',
|
'Inviter',
|
||||||
style: TextStyle(color: theme.colorScheme.primary),
|
style: TextStyle(color: theme.colorScheme.primary),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1025,17 +1119,32 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ajouter un participant par email
|
/// Envoie une invitation de participation à partir d'un email.
|
||||||
Future<void> _addParticipantByEmail(String email) async {
|
///
|
||||||
|
/// Si [selectedUser] est fourni, il est utilisé directement. Sinon, la méthode
|
||||||
|
/// recherche un compte via l'email. L'invitation est refusée si l'utilisateur
|
||||||
|
/// est déjà membre du voyage, s'invite lui-même, ou si une invitation est déjà
|
||||||
|
/// en attente.
|
||||||
|
Future<void> _inviteParticipantByEmail({
|
||||||
|
required String email,
|
||||||
|
User? selectedUser,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Chercher l'utilisateur par email
|
final currentUserState = context.read<UserBloc>().state;
|
||||||
final user = await _userRepository.getUserByEmail(email);
|
if (currentUserState is! user_state.UserLoaded) {
|
||||||
|
_errorService.showError(message: 'Utilisateur courant introuvable');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final user = selectedUser ?? await _userRepository.getUserByEmail(email);
|
||||||
|
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
_errorService.showError(
|
_errorService.showError(
|
||||||
message: 'Utilisateur non trouvé avec cet email',
|
message: 'Aucun compte inscrit trouvé avec cet email',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1045,55 +1154,56 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ajouter l'utilisateur au groupe
|
if (user.id == currentUserState.user.id) {
|
||||||
if (widget.trip.id != null) {
|
_errorService.showError(message: 'Vous êtes déjà dans ce voyage');
|
||||||
final group = await _groupRepository.getGroupByTripId(widget.trip.id!);
|
return;
|
||||||
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 participantIds = {
|
||||||
final newParticipants = [...widget.trip.participants, user.id!];
|
...widget.trip.participants,
|
||||||
final updatedTrip = widget.trip.copyWith(
|
widget.trip.createdBy,
|
||||||
participants: newParticipants,
|
};
|
||||||
|
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) {
|
if (!mounted) {
|
||||||
context.read<TripBloc>().add(
|
return;
|
||||||
TripUpdateRequested(trip: updatedTrip),
|
}
|
||||||
);
|
|
||||||
|
|
||||||
_errorService.showSnackbar(
|
_errorService.showSnackbar(
|
||||||
message: '${user.prenom} a été ajouté au voyage',
|
message: 'Invitation envoyée à ${user.prenom}',
|
||||||
isError: false,
|
isError: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Rafraîchir la page
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_errorService.showError(
|
_errorService.showError(message: 'Erreur lors de l\'invitation: $e');
|
||||||
message: 'Erreur lors de l\'ajout du participant: $e',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||||
import 'package:geolocator/geolocator.dart';
|
import 'package:geolocator/geolocator.dart';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
||||||
import '../../firebase_options.dart';
|
import '../../firebase_options.dart';
|
||||||
import '../../services/error_service.dart';
|
import '../../services/error_service.dart';
|
||||||
import '../../services/map_navigation_service.dart';
|
import '../../services/map_navigation_service.dart';
|
||||||
|
|||||||
146
lib/models/trip_invitation.dart
Normal file
146
lib/models/trip_invitation.dart
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
/// Représente une invitation d'un utilisateur à rejoindre un voyage.
|
||||||
|
///
|
||||||
|
/// Une invitation passe par les statuts `pending`, `accepted` ou `rejected`.
|
||||||
|
/// Elle contient le contexte minimum nécessaire pour envoyer les notifications
|
||||||
|
/// et appliquer la réponse (trip, expéditeur, destinataire).
|
||||||
|
class TripInvitation {
|
||||||
|
/// Identifiant Firestore de l'invitation.
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Identifiant du voyage concerné.
|
||||||
|
final String tripId;
|
||||||
|
|
||||||
|
/// Titre du voyage au moment de l'invitation.
|
||||||
|
final String tripTitle;
|
||||||
|
|
||||||
|
/// Identifiant de l'utilisateur qui invite.
|
||||||
|
final String inviterId;
|
||||||
|
|
||||||
|
/// Nom affiché de l'utilisateur qui invite.
|
||||||
|
final String inviterName;
|
||||||
|
|
||||||
|
/// Identifiant de l'utilisateur invité.
|
||||||
|
final String inviteeId;
|
||||||
|
|
||||||
|
/// Email de l'utilisateur invité (utile pour affichage et debug).
|
||||||
|
final String inviteeEmail;
|
||||||
|
|
||||||
|
/// Statut courant de l'invitation: `pending`, `accepted`, `rejected`.
|
||||||
|
final String status;
|
||||||
|
|
||||||
|
/// Date de création de l'invitation.
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
/// Date de réponse (acceptation/refus), null si encore en attente.
|
||||||
|
final DateTime? respondedAt;
|
||||||
|
|
||||||
|
/// Crée une instance de [TripInvitation].
|
||||||
|
///
|
||||||
|
/// [status] vaut `pending` par défaut pour une nouvelle invitation.
|
||||||
|
TripInvitation({
|
||||||
|
required this.id,
|
||||||
|
required this.tripId,
|
||||||
|
required this.tripTitle,
|
||||||
|
required this.inviterId,
|
||||||
|
required this.inviterName,
|
||||||
|
required this.inviteeId,
|
||||||
|
required this.inviteeEmail,
|
||||||
|
this.status = 'pending',
|
||||||
|
required this.createdAt,
|
||||||
|
this.respondedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Crée une invitation à partir d'un document Firestore.
|
||||||
|
///
|
||||||
|
/// Gère les formats `Timestamp`, `int` et `DateTime` pour les dates.
|
||||||
|
factory TripInvitation.fromFirestore(
|
||||||
|
DocumentSnapshot<Map<String, dynamic>> doc,
|
||||||
|
) {
|
||||||
|
final data = doc.data() ?? <String, dynamic>{};
|
||||||
|
|
||||||
|
return TripInvitation(
|
||||||
|
id: doc.id,
|
||||||
|
tripId: data['tripId'] as String? ?? '',
|
||||||
|
tripTitle: data['tripTitle'] as String? ?? '',
|
||||||
|
inviterId: data['inviterId'] as String? ?? '',
|
||||||
|
inviterName: data['inviterName'] as String? ?? 'Quelqu\'un',
|
||||||
|
inviteeId: data['inviteeId'] as String? ?? '',
|
||||||
|
inviteeEmail: data['inviteeEmail'] as String? ?? '',
|
||||||
|
status: data['status'] as String? ?? 'pending',
|
||||||
|
createdAt: _parseDate(data['createdAt']) ?? DateTime.now(),
|
||||||
|
respondedAt: _parseDate(data['respondedAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit l'invitation en map Firestore.
|
||||||
|
///
|
||||||
|
/// [respondedAt] est omis si null pour éviter d'écraser inutilement la donnée.
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
final map = <String, dynamic>{
|
||||||
|
'tripId': tripId,
|
||||||
|
'tripTitle': tripTitle,
|
||||||
|
'inviterId': inviterId,
|
||||||
|
'inviterName': inviterName,
|
||||||
|
'inviteeId': inviteeId,
|
||||||
|
'inviteeEmail': inviteeEmail,
|
||||||
|
'status': status,
|
||||||
|
'createdAt': Timestamp.fromDate(createdAt),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (respondedAt != null) {
|
||||||
|
map['respondedAt'] = Timestamp.fromDate(respondedAt!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne une copie avec les champs fournis.
|
||||||
|
///
|
||||||
|
/// Utile pour mettre à jour un statut localement sans muter l'instance initiale.
|
||||||
|
TripInvitation copyWith({
|
||||||
|
String? id,
|
||||||
|
String? tripId,
|
||||||
|
String? tripTitle,
|
||||||
|
String? inviterId,
|
||||||
|
String? inviterName,
|
||||||
|
String? inviteeId,
|
||||||
|
String? inviteeEmail,
|
||||||
|
String? status,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? respondedAt,
|
||||||
|
}) {
|
||||||
|
return TripInvitation(
|
||||||
|
id: id ?? this.id,
|
||||||
|
tripId: tripId ?? this.tripId,
|
||||||
|
tripTitle: tripTitle ?? this.tripTitle,
|
||||||
|
inviterId: inviterId ?? this.inviterId,
|
||||||
|
inviterName: inviterName ?? this.inviterName,
|
||||||
|
inviteeId: inviteeId ?? this.inviteeId,
|
||||||
|
inviteeEmail: inviteeEmail ?? this.inviteeEmail,
|
||||||
|
status: status ?? this.status,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
respondedAt: respondedAt ?? this.respondedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convertit une valeur de date Firestore vers [DateTime].
|
||||||
|
///
|
||||||
|
/// Retourne `null` si la valeur est absente ou non reconnue.
|
||||||
|
static DateTime? _parseDate(dynamic value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (value is Timestamp) {
|
||||||
|
return value.toDate();
|
||||||
|
}
|
||||||
|
if (value is int) {
|
||||||
|
return DateTime.fromMillisecondsSinceEpoch(value);
|
||||||
|
}
|
||||||
|
if (value is DateTime) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import '../services/notification_service.dart';
|
|||||||
import '../services/map_navigation_service.dart';
|
import '../services/map_navigation_service.dart';
|
||||||
import '../services/whats_new_service.dart';
|
import '../services/whats_new_service.dart';
|
||||||
import '../components/whats_new_dialog.dart';
|
import '../components/whats_new_dialog.dart';
|
||||||
|
import 'trip_invitations_page.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
const HomePage({super.key});
|
const HomePage({super.key});
|
||||||
@@ -227,6 +228,19 @@ class _HomePageState extends State<HomePage> {
|
|||||||
title: "Comptes",
|
title: "Comptes",
|
||||||
index: 4,
|
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(),
|
const Divider(),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.logout, color: Colors.red),
|
leading: const Icon(Icons.logout, color: Colors.red),
|
||||||
|
|||||||
274
lib/pages/trip_invitations_page.dart
Normal file
274
lib/pages/trip_invitations_page.dart
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:travel_mate/blocs/user/user_bloc.dart';
|
||||||
|
import 'package:travel_mate/blocs/user/user_state.dart' as user_state;
|
||||||
|
import 'package:travel_mate/models/trip_invitation.dart';
|
||||||
|
import 'package:travel_mate/repositories/trip_invitation_repository.dart';
|
||||||
|
import 'package:travel_mate/services/error_service.dart';
|
||||||
|
|
||||||
|
/// Affiche la boîte de réception des invitations de voyage.
|
||||||
|
///
|
||||||
|
/// Cette page permet de consulter toutes les invitations reçues et de répondre
|
||||||
|
/// aux invitations en attente (`pending`) via les actions accepter/refuser.
|
||||||
|
class TripInvitationsPage extends StatelessWidget {
|
||||||
|
/// Repository utilisé pour charger et mettre à jour les invitations.
|
||||||
|
final TripInvitationDataSource repository;
|
||||||
|
|
||||||
|
/// Identifiant utilisateur injecté pour les tests ou contextes spécifiques.
|
||||||
|
final String? userIdOverride;
|
||||||
|
|
||||||
|
/// Crée la page des invitations.
|
||||||
|
///
|
||||||
|
/// [repository] peut être injecté pour les tests; sinon un repository réel est utilisé.
|
||||||
|
TripInvitationsPage({
|
||||||
|
super.key,
|
||||||
|
TripInvitationDataSource? repository,
|
||||||
|
this.userIdOverride,
|
||||||
|
}) : repository = repository ?? TripInvitationRepository();
|
||||||
|
|
||||||
|
/// Retourne les invitations correspondant au [status] demandé.
|
||||||
|
///
|
||||||
|
/// [status] peut valoir `pending`, `accepted` ou `rejected`.
|
||||||
|
List<TripInvitation> _filterByStatus(
|
||||||
|
List<TripInvitation> invitations,
|
||||||
|
String status,
|
||||||
|
) {
|
||||||
|
return invitations
|
||||||
|
.where((invitation) => invitation.status == status)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Envoie la réponse utilisateur pour une invitation.
|
||||||
|
///
|
||||||
|
/// [isAccepted] à `true` accepte l'invitation, sinon la refuse.
|
||||||
|
/// Un feedback visuel est affiché à l'utilisateur.
|
||||||
|
Future<void> _respondToInvitation({
|
||||||
|
required BuildContext context,
|
||||||
|
required String invitationId,
|
||||||
|
required bool isAccepted,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await repository.respondToInvitation(
|
||||||
|
invitationId: invitationId,
|
||||||
|
isAccepted: isAccepted,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorService().showSnackbar(
|
||||||
|
message: isAccepted ? 'Invitation acceptée' : 'Invitation refusée',
|
||||||
|
isError: false,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorService().showError(message: 'Erreur lors de la réponse: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (userIdOverride != null && userIdOverride!.isNotEmpty) {
|
||||||
|
return _InvitationsScaffold(
|
||||||
|
userId: userIdOverride!,
|
||||||
|
repository: repository,
|
||||||
|
onRespond: ({required String invitationId, required bool isAccepted}) {
|
||||||
|
return _respondToInvitation(
|
||||||
|
context: context,
|
||||||
|
invitationId: invitationId,
|
||||||
|
isAccepted: isAccepted,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
filterByStatus: _filterByStatus,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BlocBuilder<UserBloc, user_state.UserState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is! user_state.UserLoaded) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Invitations')),
|
||||||
|
body: const Center(child: Text('Utilisateur non chargé')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _InvitationsScaffold(
|
||||||
|
userId: state.user.id,
|
||||||
|
repository: repository,
|
||||||
|
onRespond:
|
||||||
|
({required String invitationId, required bool isAccepted}) {
|
||||||
|
return _respondToInvitation(
|
||||||
|
context: context,
|
||||||
|
invitationId: invitationId,
|
||||||
|
isAccepted: isAccepted,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
filterByStatus: _filterByStatus,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Échafaudage principal des invitations avec onglets par statut.
|
||||||
|
class _InvitationsScaffold extends StatelessWidget {
|
||||||
|
/// Identifiant utilisateur des invitations à afficher.
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Source de données des invitations.
|
||||||
|
final TripInvitationDataSource repository;
|
||||||
|
|
||||||
|
/// Callback appelé lors d'une réponse utilisateur.
|
||||||
|
final Future<void> Function({
|
||||||
|
required String invitationId,
|
||||||
|
required bool isAccepted,
|
||||||
|
})
|
||||||
|
onRespond;
|
||||||
|
|
||||||
|
/// Fonction de filtrage par statut.
|
||||||
|
final List<TripInvitation> Function(
|
||||||
|
List<TripInvitation> invitations,
|
||||||
|
String status,
|
||||||
|
)
|
||||||
|
filterByStatus;
|
||||||
|
|
||||||
|
/// Crée l'échafaudage d'affichage des invitations.
|
||||||
|
const _InvitationsScaffold({
|
||||||
|
required this.userId,
|
||||||
|
required this.repository,
|
||||||
|
required this.onRespond,
|
||||||
|
required this.filterByStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DefaultTabController(
|
||||||
|
length: 3,
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Invitations'),
|
||||||
|
bottom: const TabBar(
|
||||||
|
tabs: [
|
||||||
|
Tab(text: 'En attente'),
|
||||||
|
Tab(text: 'Acceptées'),
|
||||||
|
Tab(text: 'Refusées'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: StreamBuilder<List<TripInvitation>>(
|
||||||
|
stream: repository.watchInvitationsForUser(userId),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(
|
||||||
|
child: Text('Erreur de chargement: ${snapshot.error}'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final invitations = snapshot.data ?? const <TripInvitation>[];
|
||||||
|
final pending = filterByStatus(invitations, 'pending');
|
||||||
|
final accepted = filterByStatus(invitations, 'accepted');
|
||||||
|
final rejected = filterByStatus(invitations, 'rejected');
|
||||||
|
|
||||||
|
return TabBarView(
|
||||||
|
children: [
|
||||||
|
_InvitationsList(
|
||||||
|
invitations: pending,
|
||||||
|
onAccept: (id) =>
|
||||||
|
onRespond(invitationId: id, isAccepted: true),
|
||||||
|
onReject: (id) =>
|
||||||
|
onRespond(invitationId: id, isAccepted: false),
|
||||||
|
),
|
||||||
|
_InvitationsList(invitations: accepted),
|
||||||
|
_InvitationsList(invitations: rejected),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Affiche une liste d'invitations avec actions éventuelles.
|
||||||
|
class _InvitationsList extends StatelessWidget {
|
||||||
|
/// Invitations à afficher.
|
||||||
|
final List<TripInvitation> invitations;
|
||||||
|
|
||||||
|
/// Callback appelé lors d'une acceptation.
|
||||||
|
final Future<void> Function(String invitationId)? onAccept;
|
||||||
|
|
||||||
|
/// Callback appelé lors d'un refus.
|
||||||
|
final Future<void> Function(String invitationId)? onReject;
|
||||||
|
|
||||||
|
/// Crée une liste d'invitations.
|
||||||
|
const _InvitationsList({
|
||||||
|
required this.invitations,
|
||||||
|
this.onAccept,
|
||||||
|
this.onReject,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (invitations.isEmpty) {
|
||||||
|
return const Center(child: Text('Aucune invitation'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: invitations.length,
|
||||||
|
separatorBuilder: (context, _) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final invitation = invitations[index];
|
||||||
|
final isPending = invitation.status == 'pending';
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
invitation.tripTitle,
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('Invité par: ${invitation.inviterName}'),
|
||||||
|
Text(
|
||||||
|
'Reçu: ${invitation.createdAt.day}/${invitation.createdAt.month}/${invitation.createdAt.year}',
|
||||||
|
),
|
||||||
|
if (isPending) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: onReject == null
|
||||||
|
? null
|
||||||
|
: () => onReject!(invitation.id),
|
||||||
|
child: const Text('Refuser'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: onAccept == null
|
||||||
|
? null
|
||||||
|
: () => onAccept!(invitation.id),
|
||||||
|
child: const Text('Accepter'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -312,6 +312,7 @@ class AuthRepository {
|
|||||||
if (token != null) {
|
if (token != null) {
|
||||||
await _firestore.collection('users').doc(userId).set({
|
await _firestore.collection('users').doc(userId).set({
|
||||||
'fcmToken': token,
|
'fcmToken': token,
|
||||||
|
'fcmTokens': FieldValue.arrayUnion([token]),
|
||||||
}, SetOptions(merge: true));
|
}, SetOptions(merge: true));
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
|
|||||||
225
lib/repositories/trip_invitation_repository.dart
Normal file
225
lib/repositories/trip_invitation_repository.dart
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:travel_mate/models/trip_invitation.dart';
|
||||||
|
import 'package:travel_mate/services/error_service.dart';
|
||||||
|
|
||||||
|
/// Contrat de données pour la gestion des invitations de voyage.
|
||||||
|
///
|
||||||
|
/// Cette interface permet d'injecter une implémentation réelle ou fake
|
||||||
|
/// (tests) dans les écrans qui consomment les invitations.
|
||||||
|
abstract class TripInvitationDataSource {
|
||||||
|
/// Crée une invitation de voyage.
|
||||||
|
Future<void> createInvitation({
|
||||||
|
required String tripId,
|
||||||
|
required String tripTitle,
|
||||||
|
required String inviterId,
|
||||||
|
required String inviterName,
|
||||||
|
required String inviteeId,
|
||||||
|
required String inviteeEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Retourne une invitation en attente pour le couple voyage/invité.
|
||||||
|
Future<TripInvitation?> getPendingInvitation({
|
||||||
|
required String tripId,
|
||||||
|
required String inviteeId,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Retourne une invitation par son identifiant.
|
||||||
|
Future<TripInvitation?> getInvitationById(String invitationId);
|
||||||
|
|
||||||
|
/// Met à jour une invitation selon la réponse utilisateur.
|
||||||
|
Future<void> respondToInvitation({
|
||||||
|
required String invitationId,
|
||||||
|
required bool isAccepted,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Retourne les invitations en attente d'un utilisateur.
|
||||||
|
Stream<List<TripInvitation>> watchPendingInvitationsForUser(String userId);
|
||||||
|
|
||||||
|
/// Retourne toutes les invitations d'un utilisateur.
|
||||||
|
Stream<List<TripInvitation>> watchInvitationsForUser(String userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Repository dédié à la gestion des invitations de voyage.
|
||||||
|
///
|
||||||
|
/// Ce repository centralise la création d'invitation, la lecture des invitations
|
||||||
|
/// en attente et la réponse (acceptation/refus) depuis l'application.
|
||||||
|
class TripInvitationRepository implements TripInvitationDataSource {
|
||||||
|
/// Instance Firestore injectée ou par défaut.
|
||||||
|
final FirebaseFirestore _firestore;
|
||||||
|
|
||||||
|
final ErrorService _errorService = ErrorService();
|
||||||
|
|
||||||
|
/// Crée une instance du repository.
|
||||||
|
TripInvitationRepository({FirebaseFirestore? firestore})
|
||||||
|
: _firestore = firestore ?? FirebaseFirestore.instance;
|
||||||
|
|
||||||
|
CollectionReference<Map<String, dynamic>> get _collection =>
|
||||||
|
_firestore.collection('tripInvitations');
|
||||||
|
|
||||||
|
/// Cherche une invitation en attente pour un voyage et un utilisateur donné.
|
||||||
|
///
|
||||||
|
/// Retourne `null` si aucune invitation `pending` n'existe.
|
||||||
|
@override
|
||||||
|
Future<TripInvitation?> getPendingInvitation({
|
||||||
|
required String tripId,
|
||||||
|
required String inviteeId,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final query = await _collection
|
||||||
|
.where('tripId', isEqualTo: tripId)
|
||||||
|
.where('inviteeId', isEqualTo: inviteeId)
|
||||||
|
.where('status', isEqualTo: 'pending')
|
||||||
|
.limit(1)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
if (query.docs.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return TripInvitation.fromFirestore(query.docs.first);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_errorService.logError(
|
||||||
|
'TripInvitationRepository',
|
||||||
|
'Erreur getPendingInvitation: $e',
|
||||||
|
stackTrace,
|
||||||
|
);
|
||||||
|
throw Exception('Impossible de vérifier les invitations en attente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Crée une invitation de voyage en statut `pending`.
|
||||||
|
///
|
||||||
|
/// La méthode bloque les doublons d'invitation active pour éviter les spams.
|
||||||
|
/// Lance une exception si une invitation en attente existe déjà.
|
||||||
|
@override
|
||||||
|
Future<void> createInvitation({
|
||||||
|
required String tripId,
|
||||||
|
required String tripTitle,
|
||||||
|
required String inviterId,
|
||||||
|
required String inviterName,
|
||||||
|
required String inviteeId,
|
||||||
|
required String inviteeEmail,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final pending = await getPendingInvitation(
|
||||||
|
tripId: tripId,
|
||||||
|
inviteeId: inviteeId,
|
||||||
|
);
|
||||||
|
if (pending != null) {
|
||||||
|
throw Exception(
|
||||||
|
'Une invitation est déjà en attente pour cet utilisateur.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _collection.add({
|
||||||
|
'tripId': tripId,
|
||||||
|
'tripTitle': tripTitle,
|
||||||
|
'inviterId': inviterId,
|
||||||
|
'inviterName': inviterName,
|
||||||
|
'inviteeId': inviteeId,
|
||||||
|
'inviteeEmail': inviteeEmail,
|
||||||
|
'status': 'pending',
|
||||||
|
'createdAt': FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_errorService.logError(
|
||||||
|
'TripInvitationRepository',
|
||||||
|
'Erreur createInvitation: $e',
|
||||||
|
stackTrace,
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Récupère une invitation précise par son identifiant.
|
||||||
|
///
|
||||||
|
/// Retourne `null` si le document n'existe pas.
|
||||||
|
@override
|
||||||
|
Future<TripInvitation?> getInvitationById(String invitationId) async {
|
||||||
|
try {
|
||||||
|
final doc = await _collection.doc(invitationId).get();
|
||||||
|
if (!doc.exists) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return TripInvitation.fromFirestore(doc);
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_errorService.logError(
|
||||||
|
'TripInvitationRepository',
|
||||||
|
'Erreur getInvitationById: $e',
|
||||||
|
stackTrace,
|
||||||
|
);
|
||||||
|
throw Exception('Impossible de charger l\'invitation.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepte ou refuse une invitation en attente.
|
||||||
|
///
|
||||||
|
/// [isAccepted] à `true` passe le statut à `accepted`, sinon `rejected`.
|
||||||
|
/// Si l'invitation n'est plus en attente, aucune modification n'est appliquée.
|
||||||
|
@override
|
||||||
|
Future<void> respondToInvitation({
|
||||||
|
required String invitationId,
|
||||||
|
required bool isAccepted,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await _firestore.runTransaction((transaction) async {
|
||||||
|
final docRef = _collection.doc(invitationId);
|
||||||
|
final snapshot = await transaction.get(docRef);
|
||||||
|
|
||||||
|
if (!snapshot.exists) {
|
||||||
|
throw Exception('Invitation introuvable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = snapshot.data() ?? <String, dynamic>{};
|
||||||
|
if ((data['status'] as String? ?? 'pending') != 'pending') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.update(docRef, {
|
||||||
|
'status': isAccepted ? 'accepted' : 'rejected',
|
||||||
|
'respondedAt': FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_errorService.logError(
|
||||||
|
'TripInvitationRepository',
|
||||||
|
'Erreur respondToInvitation: $e',
|
||||||
|
stackTrace,
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Écoute les invitations en attente d'un utilisateur.
|
||||||
|
///
|
||||||
|
/// Le flux est trié de la plus récente à la plus ancienne.
|
||||||
|
@override
|
||||||
|
Stream<List<TripInvitation>> watchPendingInvitationsForUser(String userId) {
|
||||||
|
return _collection
|
||||||
|
.where('inviteeId', isEqualTo: userId)
|
||||||
|
.where('status', isEqualTo: 'pending')
|
||||||
|
.orderBy('createdAt', descending: true)
|
||||||
|
.snapshots()
|
||||||
|
.map((snapshot) {
|
||||||
|
return snapshot.docs
|
||||||
|
.map(TripInvitation.fromFirestore)
|
||||||
|
.toList(growable: false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Écoute toutes les invitations d'un utilisateur (tous statuts confondus).
|
||||||
|
///
|
||||||
|
/// Le flux est trié de la plus récente à la plus ancienne.
|
||||||
|
@override
|
||||||
|
Stream<List<TripInvitation>> watchInvitationsForUser(String userId) {
|
||||||
|
return _collection
|
||||||
|
.where('inviteeId', isEqualTo: userId)
|
||||||
|
.orderBy('createdAt', descending: true)
|
||||||
|
.snapshots()
|
||||||
|
.map((snapshot) {
|
||||||
|
return snapshot.docs
|
||||||
|
.map(TripInvitation.fromFirestore)
|
||||||
|
.toList(growable: false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -189,4 +189,47 @@ class UserRepository {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recherche des utilisateurs inscrits par email, prénom ou nom.
|
||||||
|
///
|
||||||
|
/// Cette méthode est utilisée pour proposer des suggestions lors de
|
||||||
|
/// l'invitation à un voyage. La recherche est insensible à la casse
|
||||||
|
/// et retourne au maximum [limit] résultats.
|
||||||
|
///
|
||||||
|
/// [query] - Texte saisi par l'utilisateur
|
||||||
|
/// [limit] - Nombre maximum de résultats retournés
|
||||||
|
///
|
||||||
|
/// Retourne une liste vide si [query] est trop court ou en cas d'erreur.
|
||||||
|
Future<List<User>> searchUsers(String query, {int limit = 8}) async {
|
||||||
|
final normalizedQuery = query.trim().toLowerCase();
|
||||||
|
if (normalizedQuery.length < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final snapshot = await _firestore.collection('users').limit(80).get();
|
||||||
|
final results = snapshot.docs
|
||||||
|
.map((doc) => User.fromMap({...doc.data(), 'id': doc.id}))
|
||||||
|
.where((user) {
|
||||||
|
final email = user.email.toLowerCase();
|
||||||
|
final firstName = user.prenom.toLowerCase();
|
||||||
|
final lastName = user.nom.toLowerCase();
|
||||||
|
final fullName = '${user.prenom} ${user.nom}'.toLowerCase();
|
||||||
|
return email.contains(normalizedQuery) ||
|
||||||
|
firstName.contains(normalizedQuery) ||
|
||||||
|
lastName.contains(normalizedQuery) ||
|
||||||
|
fullName.contains(normalizedQuery);
|
||||||
|
})
|
||||||
|
.take(limit)
|
||||||
|
.toList(growable: false);
|
||||||
|
return results;
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_errorService.logError(
|
||||||
|
'UserRepository',
|
||||||
|
'Error searching users: $e',
|
||||||
|
stackTrace,
|
||||||
|
);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
lib/services/notification_payload_parser.dart
Normal file
82
lib/services/notification_payload_parser.dart
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
/// Normalise et interprète les données reçues depuis une notification push.
|
||||||
|
///
|
||||||
|
/// Le parseur convertit les payloads FCM en une action unique utilisée
|
||||||
|
/// par [NotificationService] pour effectuer la bonne navigation.
|
||||||
|
class NotificationPayloadParser {
|
||||||
|
/// Construit une action de navigation à partir d'un payload FCM.
|
||||||
|
///
|
||||||
|
/// [data] peut contenir des valeurs hétérogènes; elles sont converties en
|
||||||
|
/// `String` de manière défensive. Si `type` est absent, des règles de
|
||||||
|
/// fallback basées sur les clés présentes (`invitationId`, `groupId`, `tripId`)
|
||||||
|
/// sont appliquées.
|
||||||
|
static NotificationAction parse(Map<String, dynamic> data) {
|
||||||
|
final normalized = data.map(
|
||||||
|
(key, value) => MapEntry(key, value?.toString()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final explicitType = normalized['type'];
|
||||||
|
final inferredType = _inferType(normalized);
|
||||||
|
final type = explicitType ?? inferredType;
|
||||||
|
|
||||||
|
return NotificationAction(
|
||||||
|
type: type,
|
||||||
|
tripId: normalized['tripId'],
|
||||||
|
groupId: normalized['groupId'],
|
||||||
|
activityId: normalized['activityId'],
|
||||||
|
invitationId: normalized['invitationId'],
|
||||||
|
inviterName: normalized['inviterName'],
|
||||||
|
tripTitle: normalized['tripTitle'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Déduit un type de notification quand `type` est absent du payload.
|
||||||
|
///
|
||||||
|
/// Priorité: invitation > message > voyage.
|
||||||
|
static String _inferType(Map<String, String?> data) {
|
||||||
|
if (data['invitationId'] != null) {
|
||||||
|
return 'trip_invitation';
|
||||||
|
}
|
||||||
|
if (data['groupId'] != null) {
|
||||||
|
return 'message';
|
||||||
|
}
|
||||||
|
if (data['tripId'] != null) {
|
||||||
|
return 'trip';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Représente une action de navigation dérivée d'une notification.
|
||||||
|
class NotificationAction {
|
||||||
|
/// Type normalisé de la notification (message, expense, activity, etc.).
|
||||||
|
final String type;
|
||||||
|
|
||||||
|
/// Identifiant de voyage éventuellement présent dans le payload.
|
||||||
|
final String? tripId;
|
||||||
|
|
||||||
|
/// Identifiant de groupe éventuellement présent dans le payload.
|
||||||
|
final String? groupId;
|
||||||
|
|
||||||
|
/// Identifiant d'activité éventuellement présent dans le payload.
|
||||||
|
final String? activityId;
|
||||||
|
|
||||||
|
/// Identifiant d'invitation de voyage éventuellement présent.
|
||||||
|
final String? invitationId;
|
||||||
|
|
||||||
|
/// Nom de l'invitant, utilisé pour le texte du popup d'invitation.
|
||||||
|
final String? inviterName;
|
||||||
|
|
||||||
|
/// Titre du voyage, utilisé pour enrichir le popup d'invitation.
|
||||||
|
final String? tripTitle;
|
||||||
|
|
||||||
|
/// Crée une action de notification.
|
||||||
|
const NotificationAction({
|
||||||
|
required this.type,
|
||||||
|
this.tripId,
|
||||||
|
this.groupId,
|
||||||
|
this.activityId,
|
||||||
|
this.invitationId,
|
||||||
|
this.inviterName,
|
||||||
|
this.tripTitle,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
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: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:flutter/material.dart';
|
||||||
import 'package:travel_mate/services/error_service.dart';
|
import 'package:flutter_local_notifications/flutter_local_notifications.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:travel_mate/components/account/group_expenses_page.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')
|
@pragma('vm:entry-point')
|
||||||
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
@@ -27,15 +33,24 @@ class NotificationService {
|
|||||||
late final FlutterLocalNotificationsPlugin _localNotifications =
|
late final FlutterLocalNotificationsPlugin _localNotifications =
|
||||||
FlutterLocalNotificationsPlugin();
|
FlutterLocalNotificationsPlugin();
|
||||||
|
|
||||||
bool _isInitialized = false;
|
final TripRepository _tripRepository = TripRepository();
|
||||||
|
final GroupRepository _groupRepository = GroupRepository();
|
||||||
|
final AccountRepository _accountRepository = AccountRepository();
|
||||||
|
final TripInvitationRepository _tripInvitationRepository =
|
||||||
|
TripInvitationRepository();
|
||||||
|
|
||||||
|
bool _isInitialized = false;
|
||||||
|
bool _isInvitationDialogOpen = false;
|
||||||
|
|
||||||
|
/// Initialise les permissions et listeners de notifications.
|
||||||
|
///
|
||||||
|
/// Cette méthode est idempotente: les initialisations déjà faites ne sont
|
||||||
|
/// pas rejouées.
|
||||||
Future<void> initialize() async {
|
Future<void> initialize() async {
|
||||||
if (_isInitialized) return;
|
if (_isInitialized) return;
|
||||||
|
|
||||||
// Request permissions
|
|
||||||
await _requestPermissions();
|
await _requestPermissions();
|
||||||
|
|
||||||
// Initialize local notifications
|
|
||||||
const androidSettings = AndroidInitializationSettings(
|
const androidSettings = AndroidInitializationSettings(
|
||||||
'@mipmap/ic_launcher',
|
'@mipmap/ic_launcher',
|
||||||
);
|
);
|
||||||
@@ -48,54 +63,42 @@ class NotificationService {
|
|||||||
await _localNotifications.initialize(
|
await _localNotifications.initialize(
|
||||||
initSettings,
|
initSettings,
|
||||||
onDidReceiveNotificationResponse: (details) {
|
onDidReceiveNotificationResponse: (details) {
|
||||||
// Handle notification tap
|
|
||||||
LoggerService.info('Notification tapped: ${details.payload}');
|
LoggerService.info('Notification tapped: ${details.payload}');
|
||||||
if (details.payload != null) {
|
if (details.payload == null || details.payload!.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final data = json.decode(details.payload!) as Map<String, dynamic>;
|
final data = json.decode(details.payload!) as Map<String, dynamic>;
|
||||||
_handleNotificationTap(data);
|
_handleNotificationTap(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
LoggerService.error('Error parsing notification payload', error: e);
|
LoggerService.error('Error parsing notification payload', error: e);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle foreground messages
|
|
||||||
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
|
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
|
||||||
|
|
||||||
// Handle token refresh
|
|
||||||
FirebaseMessaging.instance.onTokenRefresh.listen(_onTokenRefresh);
|
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;
|
_isInitialized = true;
|
||||||
LoggerService.info('NotificationService initialized');
|
LoggerService.info('NotificationService initialized');
|
||||||
|
|
||||||
// Print current token for debugging
|
|
||||||
final token = await getFCMToken();
|
final token = await getFCMToken();
|
||||||
LoggerService.info('Current FCM Token: $token');
|
LoggerService.info('Current FCM Token: $token');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets up the background message listener.
|
/// Démarre l'écoute des interactions de notifications en arrière-plan.
|
||||||
/// Should be called when the app is ready to handle navigation.
|
///
|
||||||
|
/// À appeler quand l'arbre de navigation est prêt.
|
||||||
void startListening() {
|
void startListening() {
|
||||||
// Handle any interaction when the app is in the background via a
|
|
||||||
// Stream listener
|
|
||||||
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
|
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
|
||||||
_handleNotificationTap(message.data);
|
_handleNotificationTap(message.data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks for an initial message (app opened from terminated state)
|
/// Traite une notification ayant ouvert l'app depuis l'état terminé.
|
||||||
/// and handles it if present.
|
|
||||||
Future<void> handleInitialMessage() async {
|
Future<void> handleInitialMessage() async {
|
||||||
// Get any messages which caused the application to open from
|
final initialMessage = await _firebaseMessaging.getInitialMessage();
|
||||||
// a terminated state.
|
|
||||||
RemoteMessage? initialMessage = await _firebaseMessaging
|
|
||||||
.getInitialMessage();
|
|
||||||
|
|
||||||
if (initialMessage != null) {
|
if (initialMessage != null) {
|
||||||
LoggerService.info('Found initial message: ${initialMessage.data}');
|
LoggerService.info('Found initial message: ${initialMessage.data}');
|
||||||
@@ -103,56 +106,57 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Exécute l'action métier/navigation associée à un payload push.
|
||||||
|
///
|
||||||
|
/// Le routage se base en priorité sur `type`, puis sur un fallback par clés.
|
||||||
Future<void> _handleNotificationTap(Map<String, dynamic> data) async {
|
Future<void> _handleNotificationTap(Map<String, dynamic> data) async {
|
||||||
LoggerService.info('Handling notification tap with data: $data');
|
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 {
|
try {
|
||||||
if (type == 'message') {
|
switch (action.type) {
|
||||||
final groupId = data['groupId'];
|
case 'message':
|
||||||
if (groupId != null) {
|
if (action.groupId != null) {
|
||||||
final groupRepository = GroupRepository();
|
await _openGroupChat(action.groupId!);
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
} else {
|
return;
|
||||||
LoggerService.error('Missing groupId in payload');
|
case 'expense':
|
||||||
// ErrorService().showError(message: 'Payload invalide: groupId manquant');
|
if (action.tripId != null) {
|
||||||
|
await _openExpenses(action.tripId!);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} else if (type == 'expense') {
|
if (action.groupId != null) {
|
||||||
final tripId = data['tripId'];
|
final tripId = await _resolveTripIdFromGroup(action.groupId!);
|
||||||
if (tripId != null) {
|
if (tripId != null) {
|
||||||
final accountRepository = AccountRepository();
|
await _openExpenses(tripId);
|
||||||
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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
return;
|
||||||
LoggerService.info('Unknown notification type: $type');
|
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) {
|
} catch (e) {
|
||||||
LoggerService.error('Error handling notification tap: $e');
|
LoggerService.error('Error handling notification tap: $e');
|
||||||
@@ -160,17 +164,162 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onTokenRefresh(String newToken) async {
|
/// Ouvre la discussion du groupe ciblé.
|
||||||
LoggerService.info('FCM Token refreshed: $newToken');
|
///
|
||||||
// We need the user ID to save the token.
|
/// Ne fait rien si le groupe n'existe plus ou n'est pas accessible.
|
||||||
// Since this service is a singleton, we might not have direct access to the user ID here
|
Future<void> _openGroupChat(String groupId) async {
|
||||||
// without injecting the repository or bloc.
|
final group = await _groupRepository.getGroupById(groupId);
|
||||||
// For now, we rely on the AuthBloc to update the token on login/start.
|
if (group == null) {
|
||||||
// Ideally, we should save it here if we have the user ID.
|
LoggerService.error('Group not found: $groupId');
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ErrorService.navigatorKey.currentState?.push(
|
||||||
|
MaterialPageRoute(builder: (context) => ChatGroupContent(group: group)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ouvre la page des dépenses pour un voyage.
|
||||||
|
///
|
||||||
|
/// Nécessite que le groupe et le compte du voyage soient disponibles.
|
||||||
|
Future<void> _openExpenses(String tripId) async {
|
||||||
|
final account = await _accountRepository.getAccountByTripId(tripId);
|
||||||
|
final group = await _groupRepository.getGroupByTripId(tripId);
|
||||||
|
|
||||||
|
if (account == null || group == null) {
|
||||||
|
LoggerService.error('Account or Group not found for trip: $tripId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorService.navigatorKey.currentState?.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => GroupExpensesPage(account: account, group: group),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ouvre la page de détail d'un voyage.
|
||||||
|
///
|
||||||
|
/// Retourne silencieusement si le voyage n'existe plus.
|
||||||
|
Future<void> _openTripDetails(String tripId) async {
|
||||||
|
final trip = await _tripRepository.getTripById(tripId);
|
||||||
|
if (trip == null) {
|
||||||
|
LoggerService.error('Trip not found: $tripId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorService.navigatorKey.currentState?.push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => ShowTripDetailsContent(trip: trip),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ouvre la page des activités du voyage ciblé.
|
||||||
|
///
|
||||||
|
/// Cette navigation est utilisée pour les notifications d'ajout d'activité.
|
||||||
|
Future<void> _openActivities(String tripId) async {
|
||||||
|
final trip = await _tripRepository.getTripById(tripId);
|
||||||
|
if (trip == null) {
|
||||||
|
LoggerService.error('Trip not found for activities: $tripId');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrorService.navigatorKey.currentState?.push(
|
||||||
|
MaterialPageRoute(builder: (context) => ActivitiesPage(trip: trip)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Affiche un popup d'invitation de voyage et traite la réponse utilisateur.
|
||||||
|
///
|
||||||
|
/// Si [action.invitationId] est absent, aucun popup n'est affiché.
|
||||||
|
Future<void> _showTripInvitationDialog(NotificationAction action) async {
|
||||||
|
if (_isInvitationDialogOpen || action.invitationId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final context = ErrorService.navigatorKey.currentContext;
|
||||||
|
if (context == null) {
|
||||||
|
LoggerService.error('Cannot show invitation dialog: missing context');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isInvitationDialogOpen = true;
|
||||||
|
|
||||||
|
final inviterName = action.inviterName ?? 'Quelqu\'un';
|
||||||
|
final tripTitle = action.tripTitle ?? 'ce voyage';
|
||||||
|
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Invitation de voyage'),
|
||||||
|
content: Text('$inviterName vous invite à rejoindre "$tripTitle".'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
await _tripInvitationRepository.respondToInvitation(
|
||||||
|
invitationId: action.invitationId!,
|
||||||
|
isAccepted: false,
|
||||||
|
);
|
||||||
|
ErrorService().showSnackbar(
|
||||||
|
message: 'Invitation refusée',
|
||||||
|
isError: false,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Refuser'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
await _tripInvitationRepository.respondToInvitation(
|
||||||
|
invitationId: action.invitationId!,
|
||||||
|
isAccepted: true,
|
||||||
|
);
|
||||||
|
ErrorService().showSnackbar(
|
||||||
|
message: 'Invitation acceptée',
|
||||||
|
isError: false,
|
||||||
|
);
|
||||||
|
if (action.tripId != null) {
|
||||||
|
await _openTripDetails(action.tripId!);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Text('Rejoindre'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_isInvitationDialogOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Résout un `tripId` à partir d'un `groupId`.
|
||||||
|
///
|
||||||
|
/// Retourne `null` si le groupe est introuvable.
|
||||||
|
Future<String?> _resolveTripIdFromGroup(String groupId) async {
|
||||||
|
final group = await _groupRepository.getGroupById(groupId);
|
||||||
|
return group?.tripId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callback lors du refresh d'un token FCM.
|
||||||
|
///
|
||||||
|
/// Quand un utilisateur est connecté, le nouveau token est persisté
|
||||||
|
/// immédiatement pour éviter toute perte de notifications.
|
||||||
|
Future<void> _onTokenRefresh(String newToken) async {
|
||||||
|
LoggerService.info('FCM Token refreshed: $newToken');
|
||||||
|
final userId = FirebaseAuth.instance.currentUser?.uid;
|
||||||
|
if (userId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await saveTokenToFirestore(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Demande les permissions système de notification.
|
||||||
Future<void> _requestPermissions() async {
|
Future<void> _requestPermissions() async {
|
||||||
NotificationSettings settings = await _firebaseMessaging.requestPermission(
|
final settings = await _firebaseMessaging.requestPermission(
|
||||||
alert: true,
|
alert: true,
|
||||||
badge: true,
|
badge: true,
|
||||||
sound: true,
|
sound: true,
|
||||||
@@ -181,6 +330,9 @@ class NotificationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Retourne le token FCM courant de l'appareil.
|
||||||
|
///
|
||||||
|
/// Sur iOS, un token APNS valide est attendu avant de récupérer le token FCM.
|
||||||
Future<String?> getFCMToken() async {
|
Future<String?> getFCMToken() async {
|
||||||
try {
|
try {
|
||||||
if (Platform.isIOS) {
|
if (Platform.isIOS) {
|
||||||
@@ -209,12 +361,14 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sauvegarde le token FCM de l'utilisateur courant dans Firestore.
|
||||||
Future<void> saveTokenToFirestore(String userId) async {
|
Future<void> saveTokenToFirestore(String userId) async {
|
||||||
try {
|
try {
|
||||||
final token = await getFCMToken();
|
final token = await getFCMToken();
|
||||||
if (token != null) {
|
if (token != null) {
|
||||||
await FirebaseFirestore.instance.collection('users').doc(userId).set({
|
await FirebaseFirestore.instance.collection('users').doc(userId).set({
|
||||||
'fcmToken': token,
|
'fcmToken': token,
|
||||||
|
'fcmTokens': FieldValue.arrayUnion([token]),
|
||||||
}, SetOptions(merge: true));
|
}, SetOptions(merge: true));
|
||||||
LoggerService.info('FCM Token saved to Firestore for user: $userId');
|
LoggerService.info('FCM Token saved to Firestore for user: $userId');
|
||||||
}
|
}
|
||||||
@@ -223,16 +377,20 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Affiche une notification locale lorsque le message arrive en foreground.
|
||||||
|
///
|
||||||
|
/// Le payload FCM est conservé pour permettre le deep link au clic local.
|
||||||
Future<void> _handleForegroundMessage(RemoteMessage message) async {
|
Future<void> _handleForegroundMessage(RemoteMessage message) async {
|
||||||
LoggerService.info('Got a message whilst in the foreground!');
|
LoggerService.info('Got a message whilst in the foreground!');
|
||||||
LoggerService.info('Message data: ${message.data}');
|
LoggerService.info('Message data: ${message.data}');
|
||||||
|
|
||||||
if (message.notification != null) {
|
final title = message.notification?.title ?? message.data['title'];
|
||||||
LoggerService.info(
|
final body = message.notification?.body ?? message.data['body'];
|
||||||
'Message also contained a notification: ${message.notification}',
|
|
||||||
);
|
if (title == null && body == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Show local notification
|
|
||||||
const androidDetails = AndroidNotificationDetails(
|
const androidDetails = AndroidNotificationDetails(
|
||||||
'high_importance_channel',
|
'high_importance_channel',
|
||||||
'High Importance Notifications',
|
'High Importance Notifications',
|
||||||
@@ -247,10 +405,10 @@ class NotificationService {
|
|||||||
|
|
||||||
await _localNotifications.show(
|
await _localNotifications.show(
|
||||||
message.hashCode,
|
message.hashCode,
|
||||||
message.notification?.title,
|
title,
|
||||||
message.notification?.body,
|
body,
|
||||||
details,
|
details,
|
||||||
|
payload: json.encode(message.data),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "4.3.0"
|
version: "4.3.0"
|
||||||
firebase_core_platform_interface:
|
firebase_core_platform_interface:
|
||||||
dependency: transitive
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: firebase_core_platform_interface
|
name: firebase_core_platform_interface
|
||||||
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
|
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
|
||||||
|
|||||||
@@ -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
|
# 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
|
# 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.
|
# 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:
|
environment:
|
||||||
sdk: ^3.9.2
|
sdk: ^3.9.2
|
||||||
@@ -78,6 +78,7 @@ dev_dependencies:
|
|||||||
mockito: ^5.4.4
|
mockito: ^5.4.4
|
||||||
build_runner: ^2.4.8
|
build_runner: ^2.4.8
|
||||||
bloc_test: ^10.0.0
|
bloc_test: ^10.0.0
|
||||||
|
firebase_core_platform_interface: ^6.0.2
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
|
|||||||
@@ -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/repositories/auth_repository.dart';
|
||||||
import 'package:travel_mate/models/user.dart';
|
import 'package:travel_mate/models/user.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
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/notification_service.dart';
|
||||||
|
import 'package:travel_mate/services/analytics_service.dart';
|
||||||
|
|
||||||
import 'auth_bloc_test.mocks.dart';
|
import 'auth_bloc_test.mocks.dart';
|
||||||
|
|
||||||
@GenerateMocks([AuthRepository, NotificationService])
|
@GenerateMocks([AuthRepository, NotificationService])
|
||||||
void main() {
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
group('AuthBloc', () {
|
group('AuthBloc', () {
|
||||||
late MockAuthRepository mockAuthRepository;
|
late MockAuthRepository mockAuthRepository;
|
||||||
late MockNotificationService mockNotificationService;
|
late MockNotificationService mockNotificationService;
|
||||||
|
late FakeAnalyticsService fakeAnalyticsService;
|
||||||
late AuthBloc authBloc;
|
late AuthBloc authBloc;
|
||||||
|
|
||||||
final user = User(
|
final user = User(
|
||||||
@@ -28,12 +34,19 @@ void main() {
|
|||||||
platform: 'email',
|
platform: 'email',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
setupFirebaseCoreMocks();
|
||||||
|
await Firebase.initializeApp();
|
||||||
|
});
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
mockAuthRepository = MockAuthRepository();
|
mockAuthRepository = MockAuthRepository();
|
||||||
mockNotificationService = MockNotificationService();
|
mockNotificationService = MockNotificationService();
|
||||||
|
fakeAnalyticsService = FakeAnalyticsService();
|
||||||
authBloc = AuthBloc(
|
authBloc = AuthBloc(
|
||||||
authRepository: mockAuthRepository,
|
authRepository: mockAuthRepository,
|
||||||
notificationService: mockNotificationService,
|
notificationService: mockNotificationService,
|
||||||
|
analyticsService: fakeAnalyticsService,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Default stub for saveTokenToFirestore to avoid strict mock errors
|
// 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});
|
MockFirebaseUser({required this.uid, this.email});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class FakeAnalyticsService extends AnalyticsService {
|
||||||
|
@override
|
||||||
|
Future<void> logEvent({
|
||||||
|
required String name,
|
||||||
|
Map<String, Object>? parameters,
|
||||||
|
}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> setUserId(String? id) async {}
|
||||||
|
}
|
||||||
|
|||||||
49
test/models/trip_invitation_test.dart
Normal file
49
test/models/trip_invitation_test.dart
Normal file
@@ -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<Timestamp>());
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
124
test/pages/trip_invitations_page_test.dart
Normal file
124
test/pages/trip_invitations_page_test.dart
Normal file
@@ -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<List<TripInvitation>> _controller =
|
||||||
|
StreamController<List<TripInvitation>>();
|
||||||
|
|
||||||
|
bool acceptedCalled = false;
|
||||||
|
bool rejectedCalled = false;
|
||||||
|
|
||||||
|
void emitInvitations(List<TripInvitation> invitations) {
|
||||||
|
_controller.add(invitations);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> respondToInvitation({
|
||||||
|
required String invitationId,
|
||||||
|
required bool isAccepted,
|
||||||
|
}) async {
|
||||||
|
if (isAccepted) {
|
||||||
|
acceptedCalled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectedCalled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<TripInvitation>> watchInvitationsForUser(String userId) {
|
||||||
|
return _controller.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> createInvitation({
|
||||||
|
required String tripId,
|
||||||
|
required String tripTitle,
|
||||||
|
required String inviterId,
|
||||||
|
required String inviterName,
|
||||||
|
required String inviteeId,
|
||||||
|
required String inviteeEmail,
|
||||||
|
}) async {}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<TripInvitation?> getInvitationById(String invitationId) async {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<TripInvitation?> getPendingInvitation({
|
||||||
|
required String tripId,
|
||||||
|
required String inviteeId,
|
||||||
|
}) async {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<TripInvitation>> watchPendingInvitationsForUser(String userId) {
|
||||||
|
return _controller.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
42
test/services/notification_payload_parser_test.dart
Normal file
42
test/services/notification_payload_parser_test.dart
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user