Compare commits
4 Commits
release
...
7ed90db7a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ed90db7a8 | ||
|
|
d305364328 | ||
|
|
9b08b2896c | ||
|
|
3215a990d1 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -56,3 +56,4 @@ storage.rules
|
||||
.vscode
|
||||
.VSCodeCounter
|
||||
|
||||
AGENTS.md
|
||||
@@ -33,5 +33,9 @@
|
||||
"*.local"
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
"firestore": {
|
||||
"rules": "firestore.rules",
|
||||
"indexes": "firestore.indexes.json"
|
||||
}
|
||||
}
|
||||
|
||||
31
firestore.indexes.json
Normal file
31
firestore.indexes.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"indexes": [
|
||||
{
|
||||
"collectionGroup": "tripInvitations",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "inviteeId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "status", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "tripInvitations",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "inviteeId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "createdAt", "order": "DESCENDING" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"collectionGroup": "tripInvitations",
|
||||
"queryScope": "COLLECTION",
|
||||
"fields": [
|
||||
{ "fieldPath": "tripId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "inviteeId", "order": "ASCENDING" },
|
||||
{ "fieldPath": "status", "order": "ASCENDING" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"fieldOverrides": []
|
||||
}
|
||||
@@ -1,87 +1,252 @@
|
||||
const crypto = require("crypto");
|
||||
const functions = require("firebase-functions/v1");
|
||||
const admin = require("firebase-admin");
|
||||
const nodemailer = require("nodemailer");
|
||||
const { extractUserFcmTokens } = require("./notification_tokens");
|
||||
|
||||
admin.initializeApp();
|
||||
|
||||
// Helper function to send notifications to a list of users
|
||||
async function sendNotificationToUsers(userIds, title, body, excludeUserId, data = {}) {
|
||||
console.log(`Starting sendNotificationToUsers. Total users: ${userIds.length}, Exclude: ${excludeUserId}`);
|
||||
try {
|
||||
const tokens = [];
|
||||
let mailTransporter;
|
||||
|
||||
for (const userId of userIds) {
|
||||
if (userId === excludeUserId) {
|
||||
console.log(`Skipping user ${userId} (sender)`);
|
||||
continue;
|
||||
/**
|
||||
* Retourne un transporteur SMTP nodemailer initialisé avec les variables d'environnement.
|
||||
*
|
||||
* La configuration repose sur les variables du fichier `.env` dans le dossier `functions`.
|
||||
*/
|
||||
function getMailTransporter() {
|
||||
if (mailTransporter) {
|
||||
return mailTransporter;
|
||||
}
|
||||
|
||||
mailTransporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT || 587),
|
||||
secure: String(process.env.SMTP_SECURE || "false") === "true",
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
requireTLS: String(process.env.SMTP_REQUIRE_TLS || "false") === "true",
|
||||
tls: {
|
||||
rejectUnauthorized:
|
||||
String(process.env.SMTP_TLS_REJECT_UNAUTHORIZED || "true") === "true",
|
||||
},
|
||||
});
|
||||
|
||||
return mailTransporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise une adresse email pour les comparaisons et le stockage.
|
||||
*/
|
||||
function normalizeEmail(email) {
|
||||
return String(email || "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Génère un code numérique de vérification sur 6 chiffres.
|
||||
*/
|
||||
function generateAuthCode() {
|
||||
return String(Math.floor(100000 + Math.random() * 900000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash un code d'authentification avec SHA-256.
|
||||
*/
|
||||
function hashCode(code) {
|
||||
return crypto.createHash("sha256").update(String(code)).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une notification à une liste d'utilisateurs.
|
||||
*/
|
||||
async function sendNotificationToUsers(userIds, title, body, excludeUserId, data = {}) {
|
||||
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();
|
||||
if (userDoc.exists) {
|
||||
const userData = userDoc.data();
|
||||
if (userData.fcmToken) {
|
||||
console.log(`Found token for user ${userId}`);
|
||||
tokens.push(userData.fcmToken);
|
||||
} else {
|
||||
console.log(`No FCM token found for user ${userId}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`User document not found for ${userId}`);
|
||||
}
|
||||
if (!userDoc.exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
// De-duplicate tokens
|
||||
const uniqueTokens = [...new Set(tokens)];
|
||||
console.log(`Total unique tokens to send: ${uniqueTokens.length} (from ${tokens.length} found)`);
|
||||
const tokens = extractUserFcmTokens(userDoc.data() || {});
|
||||
await sendPushToTokens(tokens, title, body, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un push multicast à une liste de tokens FCM.
|
||||
*/
|
||||
async function sendPushToTokens(tokens, title, body, data = {}) {
|
||||
const uniqueTokens = [...new Set((tokens || []).filter(Boolean))];
|
||||
if (uniqueTokens.length === 0) {
|
||||
console.log("No tokens found, skipping notification send.");
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedData = Object.fromEntries(
|
||||
Object.entries(data).map(([key, value]) => [key, value == null ? "" : String(value)])
|
||||
);
|
||||
|
||||
if (uniqueTokens.length > 0) {
|
||||
const message = {
|
||||
notification: {
|
||||
title: title,
|
||||
body: body,
|
||||
},
|
||||
notification: { title, body },
|
||||
tokens: uniqueTokens,
|
||||
data: {
|
||||
click_action: "FLUTTER_NOTIFICATION_CLICK",
|
||||
...data
|
||||
...normalizedData,
|
||||
},
|
||||
android: {
|
||||
priority: "high",
|
||||
notification: {
|
||||
channelId: "high_importance_channel",
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const response = await admin.messaging().sendEachForMulticast(message);
|
||||
console.log(`${response.successCount} messages were sent successfully`);
|
||||
if (response.failureCount > 0) {
|
||||
console.log('Failed notifications:', response.responses.filter(r => !r.success));
|
||||
}
|
||||
} else {
|
||||
console.log("No tokens found, skipping notification send.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending notification:", error);
|
||||
console.log("Failed notifications:", response.responses.filter((r) => !r.success));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie un code d'authentification par email via SMTP.
|
||||
*/
|
||||
exports.sendEmailAuthCode = functions.https.onCall(async (data) => {
|
||||
const email = normalizeEmail(data?.email);
|
||||
if (!email || !email.includes("@")) {
|
||||
throw new functions.https.HttpsError("invalid-argument", "Adresse email invalide.");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const oneMinuteAgo = admin.firestore.Timestamp.fromMillis(now - 60 * 1000);
|
||||
|
||||
const recentRequest = await admin
|
||||
.firestore()
|
||||
.collection("emailAuthCodes")
|
||||
.where("email", "==", email)
|
||||
.where("createdAt", ">=", oneMinuteAgo)
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (!recentRequest.empty) {
|
||||
throw new functions.https.HttpsError(
|
||||
"resource-exhausted",
|
||||
"Un code a déjà été envoyé récemment. Réessayez dans une minute."
|
||||
);
|
||||
}
|
||||
|
||||
const code = generateAuthCode();
|
||||
const codeHash = hashCode(code);
|
||||
const expiresAt = admin.firestore.Timestamp.fromMillis(now + 10 * 60 * 1000);
|
||||
|
||||
await admin.firestore().collection("emailAuthCodes").add({
|
||||
email,
|
||||
codeHash,
|
||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
expiresAt,
|
||||
used: false,
|
||||
attempts: 0,
|
||||
});
|
||||
|
||||
const transporter = getMailTransporter();
|
||||
await transporter.sendMail({
|
||||
from: process.env.SMTP_FROM || process.env.SMTP_USER,
|
||||
to: email,
|
||||
subject: "Votre code de connexion Travel Mate",
|
||||
text: `Votre code d'authentification est: ${code}. Il expire dans 10 minutes.`,
|
||||
html: `<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
|
||||
.document("activities/{activityId}")
|
||||
.onCreate(async (snapshot, context) => {
|
||||
console.log(`onActivityCreated triggered for ${context.params.activityId}`);
|
||||
const activity = snapshot.data();
|
||||
const activityId = context.params.activityId;
|
||||
const tripId = activity.tripId;
|
||||
const createdBy = activity.createdBy || "Unknown";
|
||||
|
||||
if (!tripId) {
|
||||
console.log("No tripId found in activity");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch trip to get participants
|
||||
const tripDoc = await admin.firestore().collection("trips").doc(tripId).get();
|
||||
if (!tripDoc.exists) {
|
||||
console.log(`Trip ${tripId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -91,9 +256,6 @@ exports.onActivityCreated = functions.firestore
|
||||
participants.push(trip.createdBy);
|
||||
}
|
||||
|
||||
console.log(`Found trip participants: ${JSON.stringify(participants)}`);
|
||||
|
||||
// Fetch creator name
|
||||
let creatorName = "Quelqu'un";
|
||||
if (createdBy !== "Unknown") {
|
||||
const userDoc = await admin.firestore().collection("users").doc(createdBy).get();
|
||||
@@ -104,78 +266,200 @@ exports.onActivityCreated = functions.firestore
|
||||
|
||||
await sendNotificationToUsers(
|
||||
participants,
|
||||
"Nouvelle activité !",
|
||||
`${creatorName} a ajouté une nouvelle activité : ${activity.name || activity.title}`,
|
||||
"Nouvelle activité",
|
||||
`${creatorName} a ajouté : ${activity.name || activity.title || "Activité"}`,
|
||||
createdBy,
|
||||
{ tripId: tripId }
|
||||
{ type: "activity", tripId, activityId }
|
||||
);
|
||||
});
|
||||
|
||||
exports.onMessageCreated = functions.firestore
|
||||
.document("groups/{groupId}/messages/{messageId}")
|
||||
.onCreate(async (snapshot, context) => {
|
||||
console.log(`onMessageCreated triggered for ${context.params.messageId} in group ${context.params.groupId}`);
|
||||
const message = snapshot.data();
|
||||
const groupId = context.params.groupId;
|
||||
const senderId = message.senderId;
|
||||
|
||||
// Fetch group to get members
|
||||
const groupDoc = await admin.firestore().collection("groups").doc(groupId).get();
|
||||
if (!groupDoc.exists) {
|
||||
console.log(`Group ${groupId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const group = groupDoc.data();
|
||||
const memberIds = group.memberIds || [];
|
||||
console.log(`Found group members: ${JSON.stringify(memberIds)}`);
|
||||
|
||||
let senderName = message.senderName || "Quelqu'un";
|
||||
const tripId = group.tripId || "";
|
||||
const senderName = message.senderName || "Quelqu'un";
|
||||
|
||||
await sendNotificationToUsers(
|
||||
memberIds,
|
||||
"Nouveau message",
|
||||
`${senderName} : ${message.text}`,
|
||||
`${senderName}: ${message.text}`,
|
||||
senderId,
|
||||
{ groupId: groupId }
|
||||
{ type: "message", groupId, tripId }
|
||||
);
|
||||
});
|
||||
|
||||
exports.onExpenseCreated = functions.firestore
|
||||
.document("expenses/{expenseId}")
|
||||
.onCreate(async (snapshot, context) => {
|
||||
console.log(`onExpenseCreated triggered for ${context.params.expenseId}`);
|
||||
.onCreate(async (snapshot) => {
|
||||
const expense = snapshot.data();
|
||||
const groupId = expense.groupId;
|
||||
const paidBy = expense.paidById || expense.paidBy;
|
||||
|
||||
if (!groupId) {
|
||||
console.log("No groupId found in expense");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch group to get members
|
||||
const groupDoc = await admin.firestore().collection("groups").doc(groupId).get();
|
||||
if (!groupDoc.exists) {
|
||||
console.log(`Group ${groupId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const group = groupDoc.data();
|
||||
const memberIds = group.memberIds || [];
|
||||
console.log(`Found group members: ${JSON.stringify(memberIds)}`);
|
||||
|
||||
let payerName = expense.paidByName || "Quelqu'un";
|
||||
const tripId = group.tripId || "";
|
||||
const payerName = expense.paidByName || "Quelqu'un";
|
||||
|
||||
await sendNotificationToUsers(
|
||||
memberIds,
|
||||
"Nouvelle dépense",
|
||||
`${payerName} a ajouté une dépense : ${expense.amount} ${expense.currency || '€'}`,
|
||||
`${payerName} a ajouté une dépense : ${expense.amount} ${expense.currency || "€"}`,
|
||||
paidBy,
|
||||
{ groupId: groupId }
|
||||
{ type: "expense", groupId, tripId }
|
||||
);
|
||||
});
|
||||
|
||||
exports.onTripInvitationCreated = functions.firestore
|
||||
.document("tripInvitations/{invitationId}")
|
||||
.onCreate(async (snapshot, context) => {
|
||||
const invitationId = context.params.invitationId;
|
||||
const invitation = snapshot.data();
|
||||
|
||||
if (!invitation?.inviteeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await sendNotificationToUser(
|
||||
invitation.inviteeId,
|
||||
"Invitation de voyage",
|
||||
`${invitation.inviterName || "Quelqu'un"} vous invite à rejoindre ${invitation.tripTitle || "un voyage"}`,
|
||||
{
|
||||
type: "trip_invitation",
|
||||
invitationId,
|
||||
tripId: invitation.tripId,
|
||||
inviterName: invitation.inviterName || "Quelqu'un",
|
||||
tripTitle: invitation.tripTitle || "Voyage",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
exports.onTripInvitationUpdated = functions.firestore
|
||||
.document("tripInvitations/{invitationId}")
|
||||
.onUpdate(async (change) => {
|
||||
const before = change.before.data();
|
||||
const after = change.after.data();
|
||||
|
||||
if (before.status === after.status || before.status !== "pending") {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = after.status;
|
||||
const tripId = after.tripId;
|
||||
const inviteeId = after.inviteeId;
|
||||
|
||||
if (!tripId || !inviteeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "accepted") {
|
||||
await admin.firestore().runTransaction(async (transaction) => {
|
||||
const userRef = admin.firestore().collection("users").doc(inviteeId);
|
||||
const userDoc = await transaction.get(userRef);
|
||||
if (!userDoc.exists) {
|
||||
throw new Error("Utilisateur invité introuvable");
|
||||
}
|
||||
const user = userDoc.data();
|
||||
|
||||
const tripRef = admin.firestore().collection("trips").doc(tripId);
|
||||
transaction.update(tripRef, {
|
||||
participants: admin.firestore.FieldValue.arrayUnion(inviteeId),
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
});
|
||||
|
||||
const groupSnapshot = await admin
|
||||
.firestore()
|
||||
.collection("groups")
|
||||
.where("tripId", "==", tripId)
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (!groupSnapshot.empty) {
|
||||
const groupRef = groupSnapshot.docs[0].ref;
|
||||
const memberRef = groupRef.collection("members").doc(inviteeId);
|
||||
|
||||
transaction.set(
|
||||
memberRef,
|
||||
{
|
||||
userId: inviteeId,
|
||||
firstName: user.prenom || "",
|
||||
lastName: user.nom || "",
|
||||
pseudo: user.prenom || "Voyageur",
|
||||
profilePictureUrl: user.profilePictureUrl || null,
|
||||
joinedAt: Date.now(),
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
|
||||
transaction.update(groupRef, {
|
||||
memberIds: admin.firestore.FieldValue.arrayUnion(inviteeId),
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
const accountSnapshot = await admin
|
||||
.firestore()
|
||||
.collection("accounts")
|
||||
.where("tripId", "==", tripId)
|
||||
.limit(1)
|
||||
.get();
|
||||
|
||||
if (!accountSnapshot.empty) {
|
||||
const accountRef = accountSnapshot.docs[0].ref;
|
||||
const accountMemberRef = accountRef.collection("members").doc(inviteeId);
|
||||
transaction.set(
|
||||
accountMemberRef,
|
||||
{
|
||||
userId: inviteeId,
|
||||
firstName: user.prenom || "",
|
||||
lastName: user.nom || "",
|
||||
pseudo: user.prenom || "Voyageur",
|
||||
profilePictureUrl: user.profilePictureUrl || null,
|
||||
joinedAt: Date.now(),
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await sendNotificationToUser(
|
||||
after.inviterId,
|
||||
"Invitation acceptée",
|
||||
`${after.inviteeEmail || "L'utilisateur"} a accepté votre invitation.`,
|
||||
{ type: "trip_invitation_response", tripId }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "rejected") {
|
||||
await sendNotificationToUser(
|
||||
after.inviterId,
|
||||
"Invitation refusée",
|
||||
`${after.inviteeEmail || "L'utilisateur"} a refusé votre invitation.`,
|
||||
{ type: "trip_invitation_response", tripId }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
exports.callbacks_signInWithApple = functions.https.onRequest((req, res) => {
|
||||
const code = req.body.code;
|
||||
const state = req.body.state;
|
||||
@@ -183,13 +467,13 @@ exports.callbacks_signInWithApple = functions.https.onRequest((req, res) => {
|
||||
const user = req.body.user;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (code) params.append('code', code);
|
||||
if (state) params.append('state', state);
|
||||
if (id_token) params.append('id_token', id_token);
|
||||
if (user) params.append('user', user);
|
||||
if (code) params.append("code", code);
|
||||
if (state) params.append("state", state);
|
||||
if (id_token) params.append("id_token", id_token);
|
||||
if (user) params.append("user", user);
|
||||
|
||||
const qs = params.toString();
|
||||
const packageName = 'be.devdayronvl.travel_mate';
|
||||
const packageName = "be.devdayronvl.travel_mate";
|
||||
const redirectUrl = `intent://callback?${qs}#Intent;package=${packageName};scheme=signinwithapple;end`;
|
||||
|
||||
res.redirect(302, redirectUrl);
|
||||
|
||||
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",
|
||||
"dependencies": {
|
||||
"firebase-admin": "^13.6.0",
|
||||
"firebase-functions": "^7.0.0"
|
||||
"firebase-functions": "^7.0.0",
|
||||
"nodemailer": "^6.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"firebase-functions-test": "^3.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "24"
|
||||
"node": "20"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -5209,6 +5210,15 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"firebase-admin": "^13.6.0",
|
||||
"firebase-functions": "^7.0.0"
|
||||
"firebase-functions": "^7.0.0",
|
||||
"nodemailer": "^6.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"firebase-functions-test": "^3.4.1"
|
||||
|
||||
@@ -111,7 +111,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
|
||||
if (user != null) {
|
||||
// Save FCM Token
|
||||
await NotificationService().saveTokenToFirestore(user.id!);
|
||||
await _notificationService.saveTokenToFirestore(user.id!);
|
||||
await _analyticsService.setUserId(user.id);
|
||||
await _analyticsService.logEvent(
|
||||
name: 'login',
|
||||
@@ -147,7 +147,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
|
||||
if (user != null) {
|
||||
// Save FCM Token
|
||||
await NotificationService().saveTokenToFirestore(user.id!);
|
||||
await _notificationService.saveTokenToFirestore(user.id!);
|
||||
await _analyticsService.setUserId(user.id);
|
||||
await _analyticsService.logEvent(
|
||||
name: 'sign_up',
|
||||
@@ -177,7 +177,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
|
||||
if (user != null) {
|
||||
// Save FCM Token
|
||||
await NotificationService().saveTokenToFirestore(user.id!);
|
||||
await _notificationService.saveTokenToFirestore(user.id!);
|
||||
await _analyticsService.setUserId(user.id);
|
||||
await _analyticsService.logEvent(
|
||||
name: 'login',
|
||||
@@ -268,7 +268,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
|
||||
if (user != null) {
|
||||
// Save FCM Token
|
||||
await NotificationService().saveTokenToFirestore(user.id!);
|
||||
await _notificationService.saveTokenToFirestore(user.id!);
|
||||
await _analyticsService.setUserId(user.id);
|
||||
await _analyticsService.logEvent(
|
||||
name: 'login',
|
||||
|
||||
@@ -92,6 +92,7 @@ class UserBloc extends Bloc<event.UserEvent, state.UserState> {
|
||||
LoggerService.info('UserBloc - Updating FCM token in Firestore');
|
||||
await _firestore.collection('users').doc(currentUser.uid).set({
|
||||
'fcmToken': fcmToken,
|
||||
'fcmTokens': FieldValue.arrayUnion([fcmToken]),
|
||||
}, SetOptions(merge: true));
|
||||
LoggerService.info('UserBloc - FCM token updated');
|
||||
} else {
|
||||
|
||||
@@ -926,7 +926,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Activité éloignée : ${distanceInKm!.toStringAsFixed(0)} km du lieu du voyage',
|
||||
'Activité éloignée : ${distanceInKm.toStringAsFixed(0)} km du lieu du voyage',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:io';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -20,7 +19,6 @@ import '../../services/user_service.dart';
|
||||
import '../../repositories/group_repository.dart';
|
||||
import '../../repositories/account_repository.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../../services/place_image_service.dart';
|
||||
import '../../services/trip_geocoding_service.dart';
|
||||
import '../../services/logger_service.dart';
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../blocs/trip/trip_state.dart';
|
||||
import '../../blocs/trip/trip_event.dart';
|
||||
import '../../models/trip.dart';
|
||||
import '../../services/error_service.dart';
|
||||
import '../../services/trip_end_service.dart';
|
||||
|
||||
/// Home content widget for the main application dashboard.
|
||||
///
|
||||
@@ -45,6 +46,12 @@ class _HomeContentState extends State<HomeContent>
|
||||
/// Service pour charger les images manquantes
|
||||
final TripImageService _tripImageService = TripImageService();
|
||||
|
||||
/// Service pour détecter les voyages terminés
|
||||
final TripEndService _tripEndService = TripEndService();
|
||||
|
||||
// ignore: prefer_final_fields
|
||||
bool _hasCheckedFinishedTrips = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -91,6 +98,9 @@ class _HomeContentState extends State<HomeContent>
|
||||
message: 'Voyage en cours de création...',
|
||||
isError: false,
|
||||
);
|
||||
} else if (tripState is TripLoaded && !_hasCheckedFinishedTrips) {
|
||||
_hasCheckedFinishedTrips = true;
|
||||
_checkFinishedTrips(tripState.trips);
|
||||
}
|
||||
},
|
||||
builder: (context, tripState) {
|
||||
@@ -257,6 +267,88 @@ class _HomeContentState extends State<HomeContent>
|
||||
});
|
||||
}
|
||||
|
||||
/// Vérifie les voyages terminés et affiche le dialog de suppression si besoin.
|
||||
Future<void> _checkFinishedTrips(List<Trip> trips) async {
|
||||
final userState = context.read<UserBloc>().state;
|
||||
if (userState is! UserLoaded) return;
|
||||
final currentUserId = userState.user.id;
|
||||
|
||||
final finished = await _tripEndService.getFinishedTripsNotYetPrompted(trips);
|
||||
for (final trip in finished) {
|
||||
if (!mounted) return;
|
||||
await _showTripEndDialog(trip, currentUserId);
|
||||
}
|
||||
}
|
||||
|
||||
/// Affiche le dialog demandant si l'utilisateur veut supprimer le voyage terminé.
|
||||
Future<void> _showTripEndDialog(Trip trip, String currentUserId) async {
|
||||
final tripId = trip.id!;
|
||||
await _tripEndService.markTripAsPrompted(tripId);
|
||||
|
||||
final isCreator = trip.createdBy == currentUserId;
|
||||
|
||||
// Si l'utilisateur n'est pas le créateur, afficher uniquement une info
|
||||
if (!isCreator) {
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Voyage terminé'),
|
||||
content: Text(
|
||||
'Le voyage "${trip.title}" est terminé. Seul le créateur du voyage peut le supprimer.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final settled = await _tripEndService.areAccountsSettled(tripId);
|
||||
if (!mounted) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Voyage terminé'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Le voyage "${trip.title}" est terminé.'),
|
||||
const SizedBox(height: 12),
|
||||
if (settled)
|
||||
const Text('Tous les comptes sont réglés. Voulez-vous supprimer ce voyage ?')
|
||||
else
|
||||
const Text(
|
||||
'⚠️ Des comptes ne sont pas encore réglés. Voulez-vous quand même supprimer ce voyage ?',
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Non, garder'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
context.read<TripBloc>().add(TripDeleteRequested(tripId: tripId));
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to trip details page
|
||||
Future<void> _showTripDetails(Trip trip) async {
|
||||
final result = await Navigator.push(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,9 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_maps_flutter/google_maps_flutter.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import '../../firebase_options.dart';
|
||||
import '../../services/error_service.dart';
|
||||
import '../../services/map_navigation_service.dart';
|
||||
|
||||
100
lib/models/album_photo.dart
Normal file
100
lib/models/album_photo.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Represents a shared photo entry in the trip album.
|
||||
///
|
||||
/// Stores a remote [url], optional [caption], and the uploader identifier for
|
||||
/// basic attribution. Persistence is local/offline via JSON helpers.
|
||||
class AlbumPhoto {
|
||||
/// Unique identifier for the photo entry.
|
||||
final String id;
|
||||
|
||||
/// Public or signed URL of the photo.
|
||||
final String url;
|
||||
|
||||
/// Optional caption provided by the user.
|
||||
final String? caption;
|
||||
|
||||
/// Name or ID of the uploader for display.
|
||||
final String? uploadedBy;
|
||||
|
||||
/// Creation timestamp.
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Creates an album photo.
|
||||
const AlbumPhoto({
|
||||
required this.id,
|
||||
required this.url,
|
||||
required this.createdAt,
|
||||
this.caption,
|
||||
this.uploadedBy,
|
||||
});
|
||||
|
||||
/// Convenience builder for a new entry.
|
||||
factory AlbumPhoto.newPhoto({
|
||||
required String id,
|
||||
required String url,
|
||||
String? caption,
|
||||
String? uploadedBy,
|
||||
}) {
|
||||
return AlbumPhoto(
|
||||
id: id,
|
||||
url: url,
|
||||
caption: caption,
|
||||
uploadedBy: uploadedBy,
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Copy with updates.
|
||||
AlbumPhoto copyWith({
|
||||
String? id,
|
||||
String? url,
|
||||
String? caption,
|
||||
String? uploadedBy,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return AlbumPhoto(
|
||||
id: id ?? this.id,
|
||||
url: url ?? this.url,
|
||||
caption: caption ?? this.caption,
|
||||
uploadedBy: uploadedBy ?? this.uploadedBy,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Serialize to JSON.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'url': url,
|
||||
'caption': caption,
|
||||
'uploadedBy': uploadedBy,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Deserialize from JSON.
|
||||
factory AlbumPhoto.fromJson(Map<String, dynamic> json) {
|
||||
return AlbumPhoto(
|
||||
id: json['id'] as String,
|
||||
url: json['url'] as String,
|
||||
caption: json['caption'] as String?,
|
||||
uploadedBy: json['uploadedBy'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// Encode list to string.
|
||||
static String encodeList(List<AlbumPhoto> photos) {
|
||||
return json.encode(photos.map((p) => p.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Decode list from string.
|
||||
static List<AlbumPhoto> decodeList(String raw) {
|
||||
final decoded = json.decode(raw) as List<dynamic>;
|
||||
return decoded
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(AlbumPhoto.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
106
lib/models/budget_category.dart
Normal file
106
lib/models/budget_category.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Represents a budget envelope per category with currency awareness.
|
||||
class BudgetCategory {
|
||||
/// Unique identifier of the category entry.
|
||||
final String id;
|
||||
|
||||
/// Category name (hébergement, transport, food, activités...).
|
||||
final String name;
|
||||
|
||||
/// Planned amount.
|
||||
final double planned;
|
||||
|
||||
/// Currency code (ISO 4217) used for the amount.
|
||||
final String currency;
|
||||
|
||||
/// Amount actually spent (to be filled by expenses sync later).
|
||||
final double spent;
|
||||
|
||||
/// Creation timestamp for ordering.
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Creates a budget category entry.
|
||||
const BudgetCategory({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.planned,
|
||||
required this.currency,
|
||||
required this.spent,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
/// Convenience constructor for new envelope.
|
||||
factory BudgetCategory.newCategory({
|
||||
required String id,
|
||||
required String name,
|
||||
required double planned,
|
||||
required String currency,
|
||||
}) {
|
||||
return BudgetCategory(
|
||||
id: id,
|
||||
name: name,
|
||||
planned: planned,
|
||||
currency: currency,
|
||||
spent: 0,
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a copy with updated fields.
|
||||
BudgetCategory copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
double? planned,
|
||||
String? currency,
|
||||
double? spent,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return BudgetCategory(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
planned: planned ?? this.planned,
|
||||
currency: currency ?? this.currency,
|
||||
spent: spent ?? this.spent,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON serialization.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'planned': planned,
|
||||
'currency': currency,
|
||||
'spent': spent,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// JSON deserialization.
|
||||
factory BudgetCategory.fromJson(Map<String, dynamic> json) {
|
||||
return BudgetCategory(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
planned: (json['planned'] as num).toDouble(),
|
||||
currency: json['currency'] as String,
|
||||
spent: (json['spent'] as num).toDouble(),
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// Encodes a list to JSON string.
|
||||
static String encodeList(List<BudgetCategory> categories) {
|
||||
return json.encode(categories.map((c) => c.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Decodes a list from JSON string.
|
||||
static List<BudgetCategory> decodeList(String raw) {
|
||||
final decoded = json.decode(raw) as List<dynamic>;
|
||||
return decoded
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(BudgetCategory.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
104
lib/models/checklist_item.dart
Normal file
104
lib/models/checklist_item.dart
Normal file
@@ -0,0 +1,104 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Data model representing a single checklist item for a trip.
|
||||
///
|
||||
/// Each item stores a unique [id], the [label] to display, its completion
|
||||
/// status via [isDone], and optional timestamps to support ordering or
|
||||
/// future reminders. The model includes JSON helpers to simplify
|
||||
/// persistence with `SharedPreferences`.
|
||||
class ChecklistItem {
|
||||
/// Unique identifier of the checklist item.
|
||||
final String id;
|
||||
|
||||
/// Human‑readable text describing the task to complete.
|
||||
final String label;
|
||||
|
||||
/// Indicates whether the task has been completed.
|
||||
final bool isDone;
|
||||
|
||||
/// Creation timestamp used to keep a stable order in the list UI.
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Optional due date for the task; can be leveraged by reminders later.
|
||||
final DateTime? dueDate;
|
||||
|
||||
/// Creates a checklist item.
|
||||
const ChecklistItem({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.isDone,
|
||||
required this.createdAt,
|
||||
this.dueDate,
|
||||
});
|
||||
|
||||
/// Builds a new item in the pending state with the current timestamp.
|
||||
factory ChecklistItem.newItem({
|
||||
required String id,
|
||||
required String label,
|
||||
DateTime? dueDate,
|
||||
}) {
|
||||
return ChecklistItem(
|
||||
id: id,
|
||||
label: label,
|
||||
isDone: false,
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
dueDate: dueDate,
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a copy with updated fields while keeping immutability.
|
||||
ChecklistItem copyWith({
|
||||
String? id,
|
||||
String? label,
|
||||
bool? isDone,
|
||||
DateTime? createdAt,
|
||||
DateTime? dueDate,
|
||||
}) {
|
||||
return ChecklistItem(
|
||||
id: id ?? this.id,
|
||||
label: label ?? this.label,
|
||||
isDone: isDone ?? this.isDone,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
dueDate: dueDate ?? this.dueDate,
|
||||
);
|
||||
}
|
||||
|
||||
/// Serializes the item to JSON for storage.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'label': label,
|
||||
'isDone': isDone,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'dueDate': dueDate?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Deserializes a checklist item from JSON.
|
||||
factory ChecklistItem.fromJson(Map<String, dynamic> json) {
|
||||
return ChecklistItem(
|
||||
id: json['id'] as String,
|
||||
label: json['label'] as String,
|
||||
isDone: json['isDone'] as bool? ?? false,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
dueDate: json['dueDate'] != null
|
||||
? DateTime.tryParse(json['dueDate'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/// Encodes a list of checklist items to a JSON string.
|
||||
static String encodeList(List<ChecklistItem> items) {
|
||||
final jsonList = items.map((item) => item.toJson()).toList();
|
||||
return json.encode(jsonList);
|
||||
}
|
||||
|
||||
/// Decodes a list of checklist items from a JSON string.
|
||||
static List<ChecklistItem> decodeList(String jsonString) {
|
||||
final decoded = json.decode(jsonString) as List<dynamic>;
|
||||
return decoded
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(ChecklistItem.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
100
lib/models/emergency_contact.dart
Normal file
100
lib/models/emergency_contact.dart
Normal file
@@ -0,0 +1,100 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Represents an emergency contact for a trip (person or service).
|
||||
///
|
||||
/// Stores basic contact details and optional notes for quick access during
|
||||
/// critical situations.
|
||||
class EmergencyContact {
|
||||
/// Unique identifier for the contact entry.
|
||||
final String id;
|
||||
|
||||
/// Display name (ex: "Ambassade", "Marie", "Assurance Europ").
|
||||
final String name;
|
||||
|
||||
/// Phone number in international format when possible.
|
||||
final String phone;
|
||||
|
||||
/// Optional description or role (ex: "Assistance médicale", "Famille").
|
||||
final String? note;
|
||||
|
||||
/// Creation timestamp for stable ordering.
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Creates an emergency contact entry.
|
||||
const EmergencyContact({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.phone,
|
||||
required this.createdAt,
|
||||
this.note,
|
||||
});
|
||||
|
||||
/// Builds a new contact with current timestamp.
|
||||
factory EmergencyContact.newContact({
|
||||
required String id,
|
||||
required String name,
|
||||
required String phone,
|
||||
String? note,
|
||||
}) {
|
||||
return EmergencyContact(
|
||||
id: id,
|
||||
name: name,
|
||||
phone: phone,
|
||||
note: note,
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a copy with updated fields.
|
||||
EmergencyContact copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? phone,
|
||||
String? note,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return EmergencyContact(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
phone: phone ?? this.phone,
|
||||
note: note ?? this.note,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Serializes contact to JSON.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'note': note,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Deserializes contact from JSON.
|
||||
factory EmergencyContact.fromJson(Map<String, dynamic> json) {
|
||||
return EmergencyContact(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
phone: json['phone'] as String,
|
||||
note: json['note'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// Encodes list to JSON string.
|
||||
static String encodeList(List<EmergencyContact> contacts) {
|
||||
return json.encode(contacts.map((c) => c.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Decodes list from JSON string.
|
||||
static List<EmergencyContact> decodeList(String raw) {
|
||||
final decoded = json.decode(raw) as List<dynamic>;
|
||||
return decoded
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(EmergencyContact.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
99
lib/models/packing_item.dart
Normal file
99
lib/models/packing_item.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Represents an item in the shared packing list for a trip.
|
||||
///
|
||||
/// Each item stores a [label], completion flag [isPacked], and an optional
|
||||
/// [assignee] to indicate who prend en charge l'item.
|
||||
class PackingItem {
|
||||
/// Unique identifier for the packing entry.
|
||||
final String id;
|
||||
|
||||
/// Text displayed in the list (ex: "Adaptateur US", "Pharmacie").
|
||||
final String label;
|
||||
|
||||
/// Whether the item is already packed.
|
||||
final bool isPacked;
|
||||
|
||||
/// Optional assignee (user id or name) for accountability.
|
||||
final String? assignee;
|
||||
|
||||
/// Creation timestamp for ordering.
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Creates a packing item.
|
||||
const PackingItem({
|
||||
required this.id,
|
||||
required this.label,
|
||||
required this.isPacked,
|
||||
required this.createdAt,
|
||||
this.assignee,
|
||||
});
|
||||
|
||||
/// Factory to create a new item in not-packed state.
|
||||
factory PackingItem.newItem({
|
||||
required String id,
|
||||
required String label,
|
||||
String? assignee,
|
||||
}) {
|
||||
return PackingItem(
|
||||
id: id,
|
||||
label: label,
|
||||
assignee: assignee,
|
||||
isPacked: false,
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a copy with modifications.
|
||||
PackingItem copyWith({
|
||||
String? id,
|
||||
String? label,
|
||||
bool? isPacked,
|
||||
String? assignee,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return PackingItem(
|
||||
id: id ?? this.id,
|
||||
label: label ?? this.label,
|
||||
isPacked: isPacked ?? this.isPacked,
|
||||
assignee: assignee ?? this.assignee,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON serialization helper.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'label': label,
|
||||
'isPacked': isPacked,
|
||||
'assignee': assignee,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// JSON deserialization helper.
|
||||
factory PackingItem.fromJson(Map<String, dynamic> json) {
|
||||
return PackingItem(
|
||||
id: json['id'] as String,
|
||||
label: json['label'] as String,
|
||||
isPacked: json['isPacked'] as bool? ?? false,
|
||||
assignee: json['assignee'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// Encodes list to JSON string.
|
||||
static String encodeList(List<PackingItem> items) {
|
||||
return json.encode(items.map((i) => i.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Decodes list from JSON string.
|
||||
static List<PackingItem> decodeList(String raw) {
|
||||
final decoded = json.decode(raw) as List<dynamic>;
|
||||
return decoded
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(PackingItem.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
106
lib/models/reminder_item.dart
Normal file
106
lib/models/reminder_item.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Represents a dated reminder or to-do for the trip.
|
||||
class ReminderItem {
|
||||
/// Unique identifier.
|
||||
final String id;
|
||||
|
||||
/// Text to display.
|
||||
final String title;
|
||||
|
||||
/// Optional detailed note.
|
||||
final String? note;
|
||||
|
||||
/// Due date/time (UTC) for the reminder.
|
||||
final DateTime dueAt;
|
||||
|
||||
/// Completion flag.
|
||||
final bool isDone;
|
||||
|
||||
/// Creation timestamp.
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Creates a reminder item.
|
||||
const ReminderItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.dueAt,
|
||||
required this.isDone,
|
||||
required this.createdAt,
|
||||
this.note,
|
||||
});
|
||||
|
||||
/// Convenience builder for new pending reminder.
|
||||
factory ReminderItem.newItem({
|
||||
required String id,
|
||||
required String title,
|
||||
required DateTime dueAt,
|
||||
String? note,
|
||||
}) {
|
||||
return ReminderItem(
|
||||
id: id,
|
||||
title: title,
|
||||
note: note,
|
||||
dueAt: dueAt,
|
||||
isDone: false,
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Copy with changes.
|
||||
ReminderItem copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? note,
|
||||
DateTime? dueAt,
|
||||
bool? isDone,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return ReminderItem(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
note: note ?? this.note,
|
||||
dueAt: dueAt ?? this.dueAt,
|
||||
isDone: isDone ?? this.isDone,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// JSON serialization.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'note': note,
|
||||
'dueAt': dueAt.toIso8601String(),
|
||||
'isDone': isDone,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// JSON deserialization.
|
||||
factory ReminderItem.fromJson(Map<String, dynamic> json) {
|
||||
return ReminderItem(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
note: json['note'] as String?,
|
||||
dueAt: DateTime.parse(json['dueAt'] as String),
|
||||
isDone: json['isDone'] as bool? ?? false,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// Encodes list.
|
||||
static String encodeList(List<ReminderItem> reminders) {
|
||||
return json.encode(reminders.map((r) => r.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Decodes list.
|
||||
static List<ReminderItem> decodeList(String raw) {
|
||||
final decoded = json.decode(raw) as List<dynamic>;
|
||||
return decoded
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(ReminderItem.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
179
lib/models/transport_segment.dart
Normal file
179
lib/models/transport_segment.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Represents a transport segment (vol/train/bus) tied to a trip.
|
||||
///
|
||||
/// Includes identifiers (PNR/train number), schedule times, status, carrier
|
||||
/// and station/airport codes for display and potential real-time tracking.
|
||||
class TransportSegment {
|
||||
/// Unique identifier for this segment entry.
|
||||
final String id;
|
||||
|
||||
/// Segment type: `flight`, `train`, `bus` (extendable).
|
||||
final String type;
|
||||
|
||||
/// Carrier code (e.g., AF, SN, TGV, OUIGO).
|
||||
final String carrier;
|
||||
|
||||
/// Public number (e.g., AF763, TGV 8401).
|
||||
final String number;
|
||||
|
||||
/// Booking reference / PNR if available.
|
||||
final String? pnr;
|
||||
|
||||
/// Departure code (IATA/CRS) or station name.
|
||||
final String departureCode;
|
||||
|
||||
/// Arrival code (IATA/CRS) or station name.
|
||||
final String arrivalCode;
|
||||
|
||||
/// Planned departure time (UTC).
|
||||
final DateTime departureUtc;
|
||||
|
||||
/// Planned arrival time (UTC).
|
||||
final DateTime arrivalUtc;
|
||||
|
||||
/// Current status string (scheduled/delayed/cancelled/boarding/in_air etc.).
|
||||
final String status;
|
||||
|
||||
/// Gate/platform when known.
|
||||
final String? gate;
|
||||
|
||||
/// Seat assignment if provided.
|
||||
final String? seat;
|
||||
|
||||
/// Created-at timestamp for ordering.
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Creates a transport segment entry.
|
||||
const TransportSegment({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.carrier,
|
||||
required this.number,
|
||||
required this.departureCode,
|
||||
required this.arrivalCode,
|
||||
required this.departureUtc,
|
||||
required this.arrivalUtc,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
this.pnr,
|
||||
this.gate,
|
||||
this.seat,
|
||||
});
|
||||
|
||||
/// Helper to instantiate a new scheduled segment with defaults.
|
||||
factory TransportSegment.newSegment({
|
||||
required String id,
|
||||
required String type,
|
||||
required String carrier,
|
||||
required String number,
|
||||
required String departureCode,
|
||||
required String arrivalCode,
|
||||
required DateTime departureUtc,
|
||||
required DateTime arrivalUtc,
|
||||
String? pnr,
|
||||
String? gate,
|
||||
String? seat,
|
||||
}) {
|
||||
return TransportSegment(
|
||||
id: id,
|
||||
type: type,
|
||||
carrier: carrier,
|
||||
number: number,
|
||||
pnr: pnr,
|
||||
departureCode: departureCode,
|
||||
arrivalCode: arrivalCode,
|
||||
departureUtc: departureUtc,
|
||||
arrivalUtc: arrivalUtc,
|
||||
gate: gate,
|
||||
seat: seat,
|
||||
status: 'scheduled',
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a copy with updated fields.
|
||||
TransportSegment copyWith({
|
||||
String? id,
|
||||
String? type,
|
||||
String? carrier,
|
||||
String? number,
|
||||
String? pnr,
|
||||
String? departureCode,
|
||||
String? arrivalCode,
|
||||
DateTime? departureUtc,
|
||||
DateTime? arrivalUtc,
|
||||
String? status,
|
||||
String? gate,
|
||||
String? seat,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return TransportSegment(
|
||||
id: id ?? this.id,
|
||||
type: type ?? this.type,
|
||||
carrier: carrier ?? this.carrier,
|
||||
number: number ?? this.number,
|
||||
pnr: pnr ?? this.pnr,
|
||||
departureCode: departureCode ?? this.departureCode,
|
||||
arrivalCode: arrivalCode ?? this.arrivalCode,
|
||||
departureUtc: departureUtc ?? this.departureUtc,
|
||||
arrivalUtc: arrivalUtc ?? this.arrivalUtc,
|
||||
status: status ?? this.status,
|
||||
gate: gate ?? this.gate,
|
||||
seat: seat ?? this.seat,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Serializes the segment to JSON for persistence.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'type': type,
|
||||
'carrier': carrier,
|
||||
'number': number,
|
||||
'pnr': pnr,
|
||||
'departureCode': departureCode,
|
||||
'arrivalCode': arrivalCode,
|
||||
'departureUtc': departureUtc.toIso8601String(),
|
||||
'arrivalUtc': arrivalUtc.toIso8601String(),
|
||||
'status': status,
|
||||
'gate': gate,
|
||||
'seat': seat,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Deserializes a segment from JSON.
|
||||
factory TransportSegment.fromJson(Map<String, dynamic> json) {
|
||||
return TransportSegment(
|
||||
id: json['id'] as String,
|
||||
type: json['type'] as String,
|
||||
carrier: json['carrier'] as String,
|
||||
number: json['number'] as String,
|
||||
pnr: json['pnr'] as String?,
|
||||
departureCode: json['departureCode'] as String,
|
||||
arrivalCode: json['arrivalCode'] as String,
|
||||
departureUtc: DateTime.parse(json['departureUtc'] as String),
|
||||
arrivalUtc: DateTime.parse(json['arrivalUtc'] as String),
|
||||
status: json['status'] as String,
|
||||
gate: json['gate'] as String?,
|
||||
seat: json['seat'] as String?,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// Encodes a list of segments to JSON string.
|
||||
static String encodeList(List<TransportSegment> segments) {
|
||||
return json.encode(segments.map((s) => s.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Decodes a list of segments from JSON string.
|
||||
static List<TransportSegment> decodeList(String raw) {
|
||||
final decoded = json.decode(raw) as List<dynamic>;
|
||||
return decoded
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(TransportSegment.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
123
lib/models/trip_document.dart
Normal file
123
lib/models/trip_document.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// Represents a document attached to a trip (billet, passeport, assurance, etc.).
|
||||
///
|
||||
/// The model stores a human-friendly [title], a [category] to filter in the UI,
|
||||
/// an optional [downloadUrl] when the file is hosted remotely, and an optional
|
||||
/// [expiresAt] date for reminders (ex: passeport ou ESTA).
|
||||
class TripDocument {
|
||||
/// Unique identifier for the document entry.
|
||||
final String id;
|
||||
|
||||
/// Display name chosen by the user (ex: « Billet retour AF763 »).
|
||||
final String title;
|
||||
|
||||
/// Type/category (ex: `billet`, `passeport`, `assurance`, `hebergement`).
|
||||
final String category;
|
||||
|
||||
/// Optional URL to open/download the document (cloud storage or external).
|
||||
final String? downloadUrl;
|
||||
|
||||
/// Optional local file path when offline-only; kept for future sync.
|
||||
final String? localPath;
|
||||
|
||||
/// Optional expiration date to trigger reminders.
|
||||
final DateTime? expiresAt;
|
||||
|
||||
/// Creation timestamp used for stable ordering.
|
||||
final DateTime createdAt;
|
||||
|
||||
/// Creates a trip document entry.
|
||||
const TripDocument({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.category,
|
||||
required this.createdAt,
|
||||
this.downloadUrl,
|
||||
this.localPath,
|
||||
this.expiresAt,
|
||||
});
|
||||
|
||||
/// Builds a new entry with defaults.
|
||||
factory TripDocument.newEntry({
|
||||
required String id,
|
||||
required String title,
|
||||
required String category,
|
||||
String? downloadUrl,
|
||||
String? localPath,
|
||||
DateTime? expiresAt,
|
||||
}) {
|
||||
return TripDocument(
|
||||
id: id,
|
||||
title: title,
|
||||
category: category,
|
||||
downloadUrl: downloadUrl,
|
||||
localPath: localPath,
|
||||
expiresAt: expiresAt,
|
||||
createdAt: DateTime.now().toUtc(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns a copy with updated fields.
|
||||
TripDocument copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
String? category,
|
||||
String? downloadUrl,
|
||||
String? localPath,
|
||||
DateTime? expiresAt,
|
||||
DateTime? createdAt,
|
||||
}) {
|
||||
return TripDocument(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
category: category ?? this.category,
|
||||
downloadUrl: downloadUrl ?? this.downloadUrl,
|
||||
localPath: localPath ?? this.localPath,
|
||||
expiresAt: expiresAt ?? this.expiresAt,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Serializes the entry to JSON for persistence.
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'title': title,
|
||||
'category': category,
|
||||
'downloadUrl': downloadUrl,
|
||||
'localPath': localPath,
|
||||
'expiresAt': expiresAt?.toIso8601String(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Deserializes a trip document from JSON.
|
||||
factory TripDocument.fromJson(Map<String, dynamic> json) {
|
||||
return TripDocument(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
category: json['category'] as String,
|
||||
downloadUrl: json['downloadUrl'] as String?,
|
||||
localPath: json['localPath'] as String?,
|
||||
expiresAt: json['expiresAt'] != null
|
||||
? DateTime.tryParse(json['expiresAt'] as String)
|
||||
: null,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// Encodes a list of documents to a JSON string.
|
||||
static String encodeList(List<TripDocument> docs) {
|
||||
return json.encode(docs.map((d) => d.toJson()).toList());
|
||||
}
|
||||
|
||||
/// Decodes a list of documents from a JSON string.
|
||||
static List<TripDocument> decodeList(String raw) {
|
||||
final decoded = json.decode(raw) as List<dynamic>;
|
||||
return decoded
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map(TripDocument.fromJson)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
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/whats_new_service.dart';
|
||||
import '../components/whats_new_dialog.dart';
|
||||
import 'trip_invitations_page.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
@@ -227,6 +228,19 @@ class _HomePageState extends State<HomePage> {
|
||||
title: "Comptes",
|
||||
index: 4,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.mail_outline),
|
||||
title: const Text('Invitations'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TripInvitationsPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.logout, color: Colors.red),
|
||||
|
||||
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) {
|
||||
await _firestore.collection('users').doc(userId).set({
|
||||
'fcmToken': token,
|
||||
'fcmTokens': FieldValue.arrayUnion([token]),
|
||||
}, SetOptions(merge: true));
|
||||
}
|
||||
} 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
lib/services/activity_suggestion_service.dart
Normal file
39
lib/services/activity_suggestion_service.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'dart:math';
|
||||
|
||||
/// Provides lightweight, offline activity suggestions using heuristics.
|
||||
class ActivitySuggestionService {
|
||||
/// Returns a list of suggestion strings based on [city] and [weatherCode].
|
||||
///
|
||||
/// [weatherCode] is a simple tag: `sunny`, `rain`, `cold`, `default`.
|
||||
List<String> suggestions({
|
||||
required String city,
|
||||
String weatherCode = 'default',
|
||||
}) {
|
||||
final base = <String>[
|
||||
'Free walking tour de $city',
|
||||
'Spot photo au coucher du soleil',
|
||||
'Café local pour travailler/charger',
|
||||
'Parc ou rooftop tranquille',
|
||||
];
|
||||
|
||||
if (weatherCode == 'rain') {
|
||||
base.addAll([
|
||||
'Musée immanquable de $city',
|
||||
'Escape game ou activité indoor',
|
||||
'Food court couvert pour goûter local',
|
||||
]);
|
||||
} else if (weatherCode == 'cold') {
|
||||
base.addAll(['Spa / bains chauds', 'Visite guidée en intérieur']);
|
||||
} else {
|
||||
base.addAll([
|
||||
'Balade vélo ou trottinette',
|
||||
'Pique-nique au parc central',
|
||||
'Vue panoramique / rooftop',
|
||||
]);
|
||||
}
|
||||
|
||||
// Shuffle slightly for variation.
|
||||
base.shuffle(Random(city.hashCode));
|
||||
return base.take(6).toList();
|
||||
}
|
||||
}
|
||||
59
lib/services/ai_activity_service.dart
Normal file
59
lib/services/ai_activity_service.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Provides AI-powered activity suggestions using an external endpoint.
|
||||
///
|
||||
/// The endpoint is expected to accept POST JSON:
|
||||
/// { "city": "...", "interests": ["food","art"], "budget": "low|mid|high" }
|
||||
/// and return { "suggestions": [ { "title": "...", "detail": "..." }, ... ] }
|
||||
///
|
||||
/// This class is a thin client and can be wired to a custom backend that
|
||||
/// proxies LLM calls (to avoid shipping secrets in the app).
|
||||
class AiActivityService {
|
||||
/// Base URL of the AI suggestion endpoint.
|
||||
final String baseUrl;
|
||||
|
||||
/// Optional API key if your backend requires it.
|
||||
final String? apiKey;
|
||||
|
||||
/// Creates an AI activity service client.
|
||||
const AiActivityService({required this.baseUrl, this.apiKey});
|
||||
|
||||
/// Fetches suggestions for the given [city] with optional [interests] and [budget].
|
||||
///
|
||||
/// Returns a list of string suggestions. In case of error, returns an empty list
|
||||
/// to keep the UI responsive.
|
||||
Future<List<String>> fetchSuggestions({
|
||||
required String city,
|
||||
List<String> interests = const [],
|
||||
String budget = 'mid',
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/ai/suggestions');
|
||||
try {
|
||||
final response = await http.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (apiKey != null) 'Authorization': 'Bearer $apiKey',
|
||||
},
|
||||
body: json.encode({
|
||||
'city': city,
|
||||
'interests': interests,
|
||||
'budget': budget,
|
||||
}),
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final data = json.decode(response.body) as Map<String, dynamic>;
|
||||
final list = (data['suggestions'] as List<dynamic>? ?? [])
|
||||
.cast<Map<String, dynamic>>();
|
||||
return list
|
||||
.map((item) => item['title'] as String? ?? 'Suggestion')
|
||||
.toList();
|
||||
}
|
||||
return const [];
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
}
|
||||
42
lib/services/album_service.dart
Normal file
42
lib/services/album_service.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/album_photo.dart';
|
||||
|
||||
/// Stores shared album photos per trip locally for offline access.
|
||||
class AlbumService {
|
||||
/// Loads photos for the given trip.
|
||||
Future<List<AlbumPhoto>> loadPhotos(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return AlbumPhoto.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves photo list.
|
||||
Future<void> savePhotos(String tripId, List<AlbumPhoto> photos) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), AlbumPhoto.encodeList(photos));
|
||||
}
|
||||
|
||||
/// Adds one photo.
|
||||
Future<List<AlbumPhoto>> addPhoto(String tripId, AlbumPhoto photo) async {
|
||||
final current = await loadPhotos(tripId);
|
||||
final updated = [...current, photo];
|
||||
await savePhotos(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes a photo.
|
||||
Future<List<AlbumPhoto>> deletePhoto(String tripId, String photoId) async {
|
||||
final current = await loadPhotos(tripId);
|
||||
final updated = current.where((p) => p.id != photoId).toList();
|
||||
await savePhotos(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'album_$tripId';
|
||||
}
|
||||
72
lib/services/budget_service.dart
Normal file
72
lib/services/budget_service.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/budget_category.dart';
|
||||
|
||||
/// Service to manage per-trip budget envelopes (multi-devise) locally.
|
||||
///
|
||||
/// Stores envelopes in `SharedPreferences` under `budget_<tripId>` so they
|
||||
/// remain available offline. Integration with expense data can later update
|
||||
/// the [spent] field.
|
||||
class BudgetService {
|
||||
/// Loads budget categories for the trip.
|
||||
Future<List<BudgetCategory>> loadBudgets(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return BudgetCategory.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists full list.
|
||||
Future<void> saveBudgets(
|
||||
String tripId,
|
||||
List<BudgetCategory> categories,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), BudgetCategory.encodeList(categories));
|
||||
}
|
||||
|
||||
/// Adds an envelope.
|
||||
Future<List<BudgetCategory>> addBudget(
|
||||
String tripId,
|
||||
BudgetCategory category,
|
||||
) async {
|
||||
final current = await loadBudgets(tripId);
|
||||
final updated = [...current, category];
|
||||
await saveBudgets(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes an envelope.
|
||||
Future<List<BudgetCategory>> deleteBudget(
|
||||
String tripId,
|
||||
String categoryId,
|
||||
) async {
|
||||
final current = await loadBudgets(tripId);
|
||||
final updated = current.where((c) => c.id != categoryId).toList();
|
||||
await saveBudgets(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Updates spent amount for a category (used later by expense sync).
|
||||
Future<List<BudgetCategory>> updateSpent(
|
||||
String tripId,
|
||||
String categoryId,
|
||||
double spent,
|
||||
) async {
|
||||
final current = await loadBudgets(tripId);
|
||||
final updated = current
|
||||
.map((c) {
|
||||
if (c.id != categoryId) return c;
|
||||
return c.copyWith(spent: spent);
|
||||
})
|
||||
.toList(growable: false);
|
||||
await saveBudgets(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'budget_$tripId';
|
||||
}
|
||||
55
lib/services/emergency_service.dart
Normal file
55
lib/services/emergency_service.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/emergency_contact.dart';
|
||||
|
||||
/// Stores emergency contacts per trip for offline access.
|
||||
///
|
||||
/// Data is persisted locally in `SharedPreferences` under the key
|
||||
/// `emergency_<tripId>`. Corrupted payloads are cleaned up automatically to
|
||||
/// avoid crashing the UI during critical usage.
|
||||
class EmergencyService {
|
||||
/// Loads contacts for [tripId]. Returns an empty list if none or corrupted.
|
||||
Future<List<EmergencyContact>> loadContacts(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return EmergencyContact.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the complete contact list.
|
||||
Future<void> saveContacts(
|
||||
String tripId,
|
||||
List<EmergencyContact> contacts,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), EmergencyContact.encodeList(contacts));
|
||||
}
|
||||
|
||||
/// Adds a contact and returns updated list.
|
||||
Future<List<EmergencyContact>> addContact(
|
||||
String tripId,
|
||||
EmergencyContact contact,
|
||||
) async {
|
||||
final current = await loadContacts(tripId);
|
||||
final updated = [...current, contact];
|
||||
await saveContacts(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes a contact.
|
||||
Future<List<EmergencyContact>> deleteContact(
|
||||
String tripId,
|
||||
String contactId,
|
||||
) async {
|
||||
final current = await loadContacts(tripId);
|
||||
final updated = current.where((c) => c.id != contactId).toList();
|
||||
await saveContacts(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'emergency_$tripId';
|
||||
}
|
||||
18
lib/services/guest_flag_service.dart
Normal file
18
lib/services/guest_flag_service.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Stores a simple read-only guest mode flag per trip.
|
||||
class GuestFlagService {
|
||||
/// Returns whether guest mode is enabled for [tripId].
|
||||
Future<bool> isGuestEnabled(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_key(tripId)) ?? false;
|
||||
}
|
||||
|
||||
/// Sets guest mode flag for [tripId].
|
||||
Future<void> setGuestEnabled(String tripId, bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_key(tripId), enabled);
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'guest_$tripId';
|
||||
}
|
||||
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,24 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:travel_mate/services/logger_service.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:travel_mate/services/error_service.dart';
|
||||
import 'package:travel_mate/repositories/group_repository.dart';
|
||||
import 'package:travel_mate/repositories/account_repository.dart';
|
||||
import 'package:travel_mate/components/group/chat_group_content.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:travel_mate/components/account/group_expenses_page.dart';
|
||||
import 'package:travel_mate/components/activities/activities_page.dart';
|
||||
import 'package:travel_mate/components/group/chat_group_content.dart';
|
||||
import 'package:travel_mate/components/home/show_trip_details_content.dart';
|
||||
import 'package:travel_mate/repositories/account_repository.dart';
|
||||
import 'package:travel_mate/repositories/group_repository.dart';
|
||||
import 'package:travel_mate/repositories/trip_invitation_repository.dart';
|
||||
import 'package:travel_mate/repositories/trip_repository.dart';
|
||||
import 'package:travel_mate/services/error_service.dart';
|
||||
import 'package:travel_mate/services/logger_service.dart';
|
||||
import 'package:travel_mate/services/notification_payload_parser.dart';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
@@ -27,15 +35,26 @@ class NotificationService {
|
||||
late final FlutterLocalNotificationsPlugin _localNotifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
bool _isInitialized = false;
|
||||
final TripRepository _tripRepository = TripRepository();
|
||||
final GroupRepository _groupRepository = GroupRepository();
|
||||
final AccountRepository _accountRepository = AccountRepository();
|
||||
final TripInvitationRepository _tripInvitationRepository =
|
||||
TripInvitationRepository();
|
||||
|
||||
bool _isInitialized = false;
|
||||
bool _isInvitationDialogOpen = false;
|
||||
|
||||
/// Initialise les permissions et listeners de notifications.
|
||||
///
|
||||
/// Cette méthode est idempotente: les initialisations déjà faites ne sont
|
||||
/// pas rejouées.
|
||||
Future<void> initialize() async {
|
||||
if (_isInitialized) return;
|
||||
|
||||
// Request permissions
|
||||
await _requestPermissions();
|
||||
tz.initializeTimeZones();
|
||||
tz.setLocalLocation(tz.getLocation('UTC'));
|
||||
|
||||
// Initialize local notifications
|
||||
const androidSettings = AndroidInitializationSettings(
|
||||
'@mipmap/ic_launcher',
|
||||
);
|
||||
@@ -48,54 +67,42 @@ class NotificationService {
|
||||
await _localNotifications.initialize(
|
||||
initSettings,
|
||||
onDidReceiveNotificationResponse: (details) {
|
||||
// Handle notification tap
|
||||
LoggerService.info('Notification tapped: ${details.payload}');
|
||||
if (details.payload != null) {
|
||||
if (details.payload == null || details.payload!.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final data = json.decode(details.payload!) as Map<String, dynamic>;
|
||||
_handleNotificationTap(data);
|
||||
} catch (e) {
|
||||
LoggerService.error('Error parsing notification payload', error: e);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Handle foreground messages
|
||||
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
|
||||
|
||||
// Handle token refresh
|
||||
FirebaseMessaging.instance.onTokenRefresh.listen(_onTokenRefresh);
|
||||
|
||||
// Setup interacted message (Deep Linking)
|
||||
// We don't call this here anymore, it will be called from HomePage
|
||||
// await setupInteractedMessage();
|
||||
|
||||
_isInitialized = true;
|
||||
LoggerService.info('NotificationService initialized');
|
||||
|
||||
// Print current token for debugging
|
||||
final token = await getFCMToken();
|
||||
LoggerService.info('Current FCM Token: $token');
|
||||
}
|
||||
|
||||
/// Sets up the background message listener.
|
||||
/// Should be called when the app is ready to handle navigation.
|
||||
/// Démarre l'écoute des interactions de notifications en arrière-plan.
|
||||
///
|
||||
/// À appeler quand l'arbre de navigation est prêt.
|
||||
void startListening() {
|
||||
// Handle any interaction when the app is in the background via a
|
||||
// Stream listener
|
||||
FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) {
|
||||
_handleNotificationTap(message.data);
|
||||
});
|
||||
}
|
||||
|
||||
/// Checks for an initial message (app opened from terminated state)
|
||||
/// and handles it if present.
|
||||
/// Traite une notification ayant ouvert l'app depuis l'état terminé.
|
||||
Future<void> handleInitialMessage() async {
|
||||
// Get any messages which caused the application to open from
|
||||
// a terminated state.
|
||||
RemoteMessage? initialMessage = await _firebaseMessaging
|
||||
.getInitialMessage();
|
||||
final initialMessage = await _firebaseMessaging.getInitialMessage();
|
||||
|
||||
if (initialMessage != null) {
|
||||
LoggerService.info('Found initial message: ${initialMessage.data}');
|
||||
@@ -103,56 +110,57 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exécute l'action métier/navigation associée à un payload push.
|
||||
///
|
||||
/// Le routage se base en priorité sur `type`, puis sur un fallback par clés.
|
||||
Future<void> _handleNotificationTap(Map<String, dynamic> data) async {
|
||||
LoggerService.info('Handling notification tap with data: $data');
|
||||
// DEBUG: Show snackbar to verify payload
|
||||
// ErrorService().showSnackbar(message: 'Debug: Payload $data', isError: false);
|
||||
|
||||
final type = data['type'];
|
||||
final action = NotificationPayloadParser.parse(data);
|
||||
|
||||
try {
|
||||
if (type == 'message') {
|
||||
final groupId = data['groupId'];
|
||||
if (groupId != null) {
|
||||
final groupRepository = GroupRepository();
|
||||
final group = await groupRepository.getGroupById(groupId);
|
||||
if (group != null) {
|
||||
ErrorService.navigatorKey.currentState?.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ChatGroupContent(group: group),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
LoggerService.error('Group not found: $groupId');
|
||||
// ErrorService().showError(message: 'Groupe introuvable: $groupId');
|
||||
switch (action.type) {
|
||||
case 'message':
|
||||
if (action.groupId != null) {
|
||||
await _openGroupChat(action.groupId!);
|
||||
}
|
||||
} else {
|
||||
LoggerService.error('Missing groupId in payload');
|
||||
// ErrorService().showError(message: 'Payload invalide: groupId manquant');
|
||||
return;
|
||||
case 'expense':
|
||||
if (action.tripId != null) {
|
||||
await _openExpenses(action.tripId!);
|
||||
return;
|
||||
}
|
||||
} else if (type == 'expense') {
|
||||
final tripId = data['tripId'];
|
||||
if (action.groupId != null) {
|
||||
final tripId = await _resolveTripIdFromGroup(action.groupId!);
|
||||
if (tripId != null) {
|
||||
final accountRepository = AccountRepository();
|
||||
final groupRepository = GroupRepository();
|
||||
|
||||
final account = await accountRepository.getAccountByTripId(tripId);
|
||||
final group = await groupRepository.getGroupByTripId(tripId);
|
||||
|
||||
if (account != null && group != null) {
|
||||
ErrorService.navigatorKey.currentState?.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) =>
|
||||
GroupExpensesPage(account: account, group: group),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
LoggerService.error('Account or Group not found for trip: $tripId');
|
||||
// ErrorService().showError(message: 'Compte ou Groupe introuvable');
|
||||
await _openExpenses(tripId);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LoggerService.info('Unknown notification type: $type');
|
||||
return;
|
||||
case 'activity':
|
||||
if (action.tripId != null) {
|
||||
await _openActivities(action.tripId!);
|
||||
}
|
||||
return;
|
||||
case 'trip_invitation':
|
||||
await _showTripInvitationDialog(action);
|
||||
return;
|
||||
case 'trip_invitation_response':
|
||||
case 'trip':
|
||||
if (action.tripId != null) {
|
||||
await _openTripDetails(action.tripId!);
|
||||
}
|
||||
return;
|
||||
default:
|
||||
if (action.groupId != null) {
|
||||
await _openGroupChat(action.groupId!);
|
||||
return;
|
||||
}
|
||||
if (action.tripId != null) {
|
||||
await _openTripDetails(action.tripId!);
|
||||
return;
|
||||
}
|
||||
LoggerService.info('Unknown notification payload: $data');
|
||||
}
|
||||
} catch (e) {
|
||||
LoggerService.error('Error handling notification tap: $e');
|
||||
@@ -160,17 +168,162 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onTokenRefresh(String newToken) async {
|
||||
LoggerService.info('FCM Token refreshed: $newToken');
|
||||
// We need the user ID to save the token.
|
||||
// Since this service is a singleton, we might not have direct access to the user ID here
|
||||
// without injecting the repository or bloc.
|
||||
// For now, we rely on the AuthBloc to update the token on login/start.
|
||||
// Ideally, we should save it here if we have the user ID.
|
||||
/// Ouvre la discussion du groupe ciblé.
|
||||
///
|
||||
/// Ne fait rien si le groupe n'existe plus ou n'est pas accessible.
|
||||
Future<void> _openGroupChat(String groupId) async {
|
||||
final group = await _groupRepository.getGroupById(groupId);
|
||||
if (group == null) {
|
||||
LoggerService.error('Group not found: $groupId');
|
||||
return;
|
||||
}
|
||||
|
||||
ErrorService.navigatorKey.currentState?.push(
|
||||
MaterialPageRoute(builder: (context) => ChatGroupContent(group: group)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Ouvre la page des dépenses pour un voyage.
|
||||
///
|
||||
/// Nécessite que le groupe et le compte du voyage soient disponibles.
|
||||
Future<void> _openExpenses(String tripId) async {
|
||||
final account = await _accountRepository.getAccountByTripId(tripId);
|
||||
final group = await _groupRepository.getGroupByTripId(tripId);
|
||||
|
||||
if (account == null || group == null) {
|
||||
LoggerService.error('Account or Group not found for trip: $tripId');
|
||||
return;
|
||||
}
|
||||
|
||||
ErrorService.navigatorKey.currentState?.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GroupExpensesPage(account: account, group: group),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Ouvre la page de détail d'un voyage.
|
||||
///
|
||||
/// Retourne silencieusement si le voyage n'existe plus.
|
||||
Future<void> _openTripDetails(String tripId) async {
|
||||
final trip = await _tripRepository.getTripById(tripId);
|
||||
if (trip == null) {
|
||||
LoggerService.error('Trip not found: $tripId');
|
||||
return;
|
||||
}
|
||||
|
||||
ErrorService.navigatorKey.currentState?.push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ShowTripDetailsContent(trip: trip),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Ouvre la page des activités du voyage ciblé.
|
||||
///
|
||||
/// Cette navigation est utilisée pour les notifications d'ajout d'activité.
|
||||
Future<void> _openActivities(String tripId) async {
|
||||
final trip = await _tripRepository.getTripById(tripId);
|
||||
if (trip == null) {
|
||||
LoggerService.error('Trip not found for activities: $tripId');
|
||||
return;
|
||||
}
|
||||
|
||||
ErrorService.navigatorKey.currentState?.push(
|
||||
MaterialPageRoute(builder: (context) => ActivitiesPage(trip: trip)),
|
||||
);
|
||||
}
|
||||
|
||||
/// Affiche un popup d'invitation de voyage et traite la réponse utilisateur.
|
||||
///
|
||||
/// Si [action.invitationId] est absent, aucun popup n'est affiché.
|
||||
Future<void> _showTripInvitationDialog(NotificationAction action) async {
|
||||
if (_isInvitationDialogOpen || action.invitationId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final context = ErrorService.navigatorKey.currentContext;
|
||||
if (context == null) {
|
||||
LoggerService.error('Cannot show invitation dialog: missing context');
|
||||
return;
|
||||
}
|
||||
|
||||
_isInvitationDialogOpen = true;
|
||||
|
||||
final inviterName = action.inviterName ?? 'Quelqu\'un';
|
||||
final tripTitle = action.tripTitle ?? 'ce voyage';
|
||||
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Invitation de voyage'),
|
||||
content: Text('$inviterName vous invite à rejoindre "$tripTitle".'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(dialogContext).pop();
|
||||
await _tripInvitationRepository.respondToInvitation(
|
||||
invitationId: action.invitationId!,
|
||||
isAccepted: false,
|
||||
);
|
||||
ErrorService().showSnackbar(
|
||||
message: 'Invitation refusée',
|
||||
isError: false,
|
||||
);
|
||||
},
|
||||
child: const Text('Refuser'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
Navigator.of(dialogContext).pop();
|
||||
await _tripInvitationRepository.respondToInvitation(
|
||||
invitationId: action.invitationId!,
|
||||
isAccepted: true,
|
||||
);
|
||||
ErrorService().showSnackbar(
|
||||
message: 'Invitation acceptée',
|
||||
isError: false,
|
||||
);
|
||||
if (action.tripId != null) {
|
||||
await _openTripDetails(action.tripId!);
|
||||
}
|
||||
},
|
||||
child: const Text('Rejoindre'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
_isInvitationDialogOpen = false;
|
||||
}
|
||||
|
||||
/// Résout un `tripId` à partir d'un `groupId`.
|
||||
///
|
||||
/// Retourne `null` si le groupe est introuvable.
|
||||
Future<String?> _resolveTripIdFromGroup(String groupId) async {
|
||||
final group = await _groupRepository.getGroupById(groupId);
|
||||
return group?.tripId;
|
||||
}
|
||||
|
||||
/// Callback lors du refresh d'un token FCM.
|
||||
///
|
||||
/// Quand un utilisateur est connecté, le nouveau token est persisté
|
||||
/// immédiatement pour éviter toute perte de notifications.
|
||||
Future<void> _onTokenRefresh(String newToken) async {
|
||||
LoggerService.info('FCM Token refreshed: $newToken');
|
||||
final userId = FirebaseAuth.instance.currentUser?.uid;
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
await saveTokenToFirestore(userId);
|
||||
}
|
||||
|
||||
/// Demande les permissions système de notification.
|
||||
Future<void> _requestPermissions() async {
|
||||
NotificationSettings settings = await _firebaseMessaging.requestPermission(
|
||||
final settings = await _firebaseMessaging.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
@@ -181,6 +334,9 @@ class NotificationService {
|
||||
);
|
||||
}
|
||||
|
||||
/// Retourne le token FCM courant de l'appareil.
|
||||
///
|
||||
/// Sur iOS, un token APNS valide est attendu avant de récupérer le token FCM.
|
||||
Future<String?> getFCMToken() async {
|
||||
try {
|
||||
if (Platform.isIOS) {
|
||||
@@ -209,12 +365,14 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Sauvegarde le token FCM de l'utilisateur courant dans Firestore.
|
||||
Future<void> saveTokenToFirestore(String userId) async {
|
||||
try {
|
||||
final token = await getFCMToken();
|
||||
if (token != null) {
|
||||
await FirebaseFirestore.instance.collection('users').doc(userId).set({
|
||||
'fcmToken': token,
|
||||
'fcmTokens': FieldValue.arrayUnion([token]),
|
||||
}, SetOptions(merge: true));
|
||||
LoggerService.info('FCM Token saved to Firestore for user: $userId');
|
||||
}
|
||||
@@ -223,16 +381,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 {
|
||||
LoggerService.info('Got a message whilst in the foreground!');
|
||||
LoggerService.info('Message data: ${message.data}');
|
||||
|
||||
if (message.notification != null) {
|
||||
LoggerService.info(
|
||||
'Message also contained a notification: ${message.notification}',
|
||||
);
|
||||
final title = message.notification?.title ?? message.data['title'];
|
||||
final body = message.notification?.body ?? message.data['body'];
|
||||
|
||||
if (title == null && body == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show local notification
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'high_importance_channel',
|
||||
'High Importance Notifications',
|
||||
@@ -247,10 +409,81 @@ class NotificationService {
|
||||
|
||||
await _localNotifications.show(
|
||||
message.hashCode,
|
||||
message.notification?.title,
|
||||
message.notification?.body,
|
||||
title,
|
||||
body,
|
||||
details,
|
||||
payload: json.encode(message.data),
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a local push notification with a custom [title] and [body].
|
||||
///
|
||||
/// Used for recap/reminder notifications when on-device scheduling is desired.
|
||||
Future<void> showLocalRecap({
|
||||
required String title,
|
||||
required String body,
|
||||
}) async {
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'recap_channel',
|
||||
'Recap quotidien',
|
||||
channelDescription: 'Notifications de récap voyage',
|
||||
importance: Importance.max,
|
||||
priority: Priority.high,
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _localNotifications.show(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
title,
|
||||
body,
|
||||
details,
|
||||
);
|
||||
}
|
||||
|
||||
/// Schedules a local reminder notification at [dueAt] with [title]/[body].
|
||||
Future<void> scheduleReminder({
|
||||
required String id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime dueAt,
|
||||
}) async {
|
||||
try {
|
||||
final int notifId = id.hashCode & 0x7fffffff;
|
||||
final scheduled = tz.TZDateTime.from(dueAt, tz.local);
|
||||
|
||||
const androidDetails = AndroidNotificationDetails(
|
||||
'reminder_channel',
|
||||
'Rappels voyage',
|
||||
channelDescription: 'Notifications des rappels/to-dos',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
const iosDetails = DarwinNotificationDetails();
|
||||
const details = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
await _localNotifications.zonedSchedule(
|
||||
notifId,
|
||||
title,
|
||||
body,
|
||||
scheduled,
|
||||
details,
|
||||
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
||||
);
|
||||
} catch (e) {
|
||||
LoggerService.error('Failed to schedule reminder', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels a scheduled reminder notification by [id].
|
||||
Future<void> cancelReminder(String id) async {
|
||||
final notifId = id.hashCode & 0x7fffffff;
|
||||
await _localNotifications.cancel(notifId);
|
||||
}
|
||||
}
|
||||
|
||||
18
lib/services/offline_flag_service.dart
Normal file
18
lib/services/offline_flag_service.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Stores a per-trip offline toggle to trigger background caching later.
|
||||
class OfflineFlagService {
|
||||
/// Returns whether offline caching is enabled for [tripId].
|
||||
Future<bool> isOfflineEnabled(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getBool(_key(tripId)) ?? false;
|
||||
}
|
||||
|
||||
/// Persists the offline toggle.
|
||||
Future<void> setOfflineEnabled(String tripId, bool enabled) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_key(tripId), enabled);
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'offline_trip_$tripId';
|
||||
}
|
||||
78
lib/services/packing_service.dart
Normal file
78
lib/services/packing_service.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/packing_item.dart';
|
||||
|
||||
/// Service handling shared packing lists per trip.
|
||||
///
|
||||
/// Uses local `SharedPreferences` for fast offline access. The list can later
|
||||
/// be synced remotely without changing the calling code.
|
||||
class PackingService {
|
||||
/// Loads packing items for a trip. Returns empty list if none/corrupted.
|
||||
Future<List<PackingItem>> loadItems(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return PackingItem.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves complete list.
|
||||
Future<void> saveItems(String tripId, List<PackingItem> items) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), PackingItem.encodeList(items));
|
||||
}
|
||||
|
||||
/// Adds one item.
|
||||
Future<List<PackingItem>> addItem(String tripId, PackingItem item) async {
|
||||
final current = await loadItems(tripId);
|
||||
final updated = [...current, item];
|
||||
await saveItems(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Toggles packed flag.
|
||||
Future<List<PackingItem>> toggleItem(String tripId, String itemId) async {
|
||||
final current = await loadItems(tripId);
|
||||
final updated = current
|
||||
.map((i) {
|
||||
if (i.id != itemId) return i;
|
||||
return i.copyWith(isPacked: !i.isPacked);
|
||||
})
|
||||
.toList(growable: false);
|
||||
await saveItems(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes an item.
|
||||
Future<List<PackingItem>> deleteItem(String tripId, String itemId) async {
|
||||
final current = await loadItems(tripId);
|
||||
final updated = current.where((i) => i.id != itemId).toList();
|
||||
await saveItems(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Suggests a starter template based on duration/weather (simplified here).
|
||||
List<String> suggestedItems({required int nights, required bool cold}) {
|
||||
final base = [
|
||||
'Passeport/ID',
|
||||
'Billets / PNR',
|
||||
'Chargeurs et adaptateurs',
|
||||
'Trousse de secours',
|
||||
'Assurance voyage',
|
||||
];
|
||||
if (cold) {
|
||||
base.addAll(['Veste chaude', 'Gants', 'Bonnet', 'Chaussettes chaudes']);
|
||||
} else {
|
||||
base.addAll(['Crème solaire', 'Lunettes de soleil', 'Maillot de bain']);
|
||||
}
|
||||
if (nights > 4) {
|
||||
base.add('Lessive/ziplock');
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'packing_$tripId';
|
||||
}
|
||||
64
lib/services/reminder_service.dart
Normal file
64
lib/services/reminder_service.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/reminder_item.dart';
|
||||
|
||||
/// Persists dated reminders/to-dos per trip locally for offline use.
|
||||
class ReminderService {
|
||||
/// Loads reminders for [tripId].
|
||||
Future<List<ReminderItem>> loadReminders(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return ReminderItem.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves full list.
|
||||
Future<void> saveReminders(
|
||||
String tripId,
|
||||
List<ReminderItem> reminders,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), ReminderItem.encodeList(reminders));
|
||||
}
|
||||
|
||||
/// Adds a reminder.
|
||||
Future<List<ReminderItem>> addReminder(
|
||||
String tripId,
|
||||
ReminderItem reminder,
|
||||
) async {
|
||||
final current = await loadReminders(tripId);
|
||||
final updated = [...current, reminder];
|
||||
await saveReminders(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Toggles done state.
|
||||
Future<List<ReminderItem>> toggleReminder(
|
||||
String tripId,
|
||||
String reminderId,
|
||||
) async {
|
||||
final current = await loadReminders(tripId);
|
||||
final updated = current
|
||||
.map((r) => r.id == reminderId ? r.copyWith(isDone: !r.isDone) : r)
|
||||
.toList(growable: false);
|
||||
await saveReminders(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes a reminder.
|
||||
Future<List<ReminderItem>> deleteReminder(
|
||||
String tripId,
|
||||
String reminderId,
|
||||
) async {
|
||||
final current = await loadReminders(tripId);
|
||||
final updated = current.where((r) => r.id != reminderId).toList();
|
||||
await saveReminders(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'reminders_$tripId';
|
||||
}
|
||||
54
lib/services/sos_service.dart
Normal file
54
lib/services/sos_service.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
/// Service in charge of dispatching SOS events to a backend endpoint.
|
||||
///
|
||||
/// The backend is expected to accept POST JSON payloads like:
|
||||
/// {
|
||||
/// "tripId": "...",
|
||||
/// "lat": 0.0,
|
||||
/// "lng": 0.0,
|
||||
/// "message": "...",
|
||||
/// }
|
||||
class SosService {
|
||||
/// Base URL of the backend (e.g. https://api.example.com/sos).
|
||||
final String baseUrl;
|
||||
|
||||
/// Optional API key header.
|
||||
final String? apiKey;
|
||||
|
||||
/// Optional injected HTTP client (useful for testing).
|
||||
final http.Client _client;
|
||||
|
||||
/// Creates a new SOS service.
|
||||
SosService({required this.baseUrl, this.apiKey, http.Client? client})
|
||||
: _client = client ?? http.Client();
|
||||
|
||||
/// Sends an SOS event. Returns true on HTTP 200.
|
||||
Future<bool> sendSos({
|
||||
required String tripId,
|
||||
required double lat,
|
||||
required double lng,
|
||||
String message = 'SOS déclenché',
|
||||
}) async {
|
||||
final uri = Uri.parse('$baseUrl/sos');
|
||||
try {
|
||||
final response = await _client.post(
|
||||
uri,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (apiKey != null) 'Authorization': 'Bearer $apiKey',
|
||||
},
|
||||
body: json.encode({
|
||||
'tripId': tripId,
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
'message': message,
|
||||
}),
|
||||
);
|
||||
return response.statusCode == 200;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,43 @@ class StorageService {
|
||||
: _storage = storage ?? FirebaseStorage.instance,
|
||||
_errorService = errorService ?? ErrorService();
|
||||
|
||||
/// Uploads an album image for a trip with compression.
|
||||
///
|
||||
/// Saves the file under `album/<tripId>/` with a unique name, and returns
|
||||
/// the download URL. Uses the same compression pipeline as receipts.
|
||||
Future<String> uploadAlbumImage(String tripId, File imageFile) async {
|
||||
try {
|
||||
_validateImageFile(imageFile);
|
||||
final compressedImage = await _compressImage(imageFile);
|
||||
final fileName =
|
||||
'album_${DateTime.now().millisecondsSinceEpoch}_${path.basename(imageFile.path)}';
|
||||
final storageRef = _storage.ref().child('album/$tripId/$fileName');
|
||||
|
||||
final metadata = SettableMetadata(
|
||||
contentType: 'image/jpeg',
|
||||
customMetadata: {
|
||||
'tripId': tripId,
|
||||
'uploadedAt': DateTime.now().toIso8601String(),
|
||||
'compressed': 'true',
|
||||
},
|
||||
);
|
||||
|
||||
final uploadTask = storageRef.putData(compressedImage, metadata);
|
||||
|
||||
final snapshot = await uploadTask;
|
||||
final downloadUrl = await snapshot.ref.getDownloadURL();
|
||||
|
||||
_errorService.logSuccess(
|
||||
'StorageService',
|
||||
'Album image uploaded: $fileName',
|
||||
);
|
||||
return downloadUrl;
|
||||
} catch (e) {
|
||||
_errorService.logError('StorageService', 'Error uploading album image: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads a receipt image for an expense with automatic compression.
|
||||
///
|
||||
/// Validates the image file, compresses it to JPEG format with 85% quality,
|
||||
|
||||
72
lib/services/transport_service.dart
Normal file
72
lib/services/transport_service.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/transport_segment.dart';
|
||||
|
||||
/// Service that stores per-trip transport segments locally for offline access.
|
||||
///
|
||||
/// Uses `SharedPreferences` keyed by `trip_transport_<tripId>` to keep
|
||||
/// creation/edit quick without round-trips. Real-time status can later be
|
||||
/// updated by a background job hitting external APIs.
|
||||
class TransportService {
|
||||
/// Loads stored transport segments for [tripId].
|
||||
Future<List<TransportSegment>> loadSegments(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return TransportSegment.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the full list of segments for [tripId].
|
||||
Future<void> saveSegments(
|
||||
String tripId,
|
||||
List<TransportSegment> segments,
|
||||
) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), TransportSegment.encodeList(segments));
|
||||
}
|
||||
|
||||
/// Adds a segment entry.
|
||||
Future<List<TransportSegment>> addSegment(
|
||||
String tripId,
|
||||
TransportSegment segment,
|
||||
) async {
|
||||
final current = await loadSegments(tripId);
|
||||
final updated = [...current, segment];
|
||||
await saveSegments(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes a segment by [segmentId].
|
||||
Future<List<TransportSegment>> deleteSegment(
|
||||
String tripId,
|
||||
String segmentId,
|
||||
) async {
|
||||
final current = await loadSegments(tripId);
|
||||
final updated = current.where((s) => s.id != segmentId).toList();
|
||||
await saveSegments(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Updates the status of a segment (e.g., delayed/boarding/in_air).
|
||||
Future<List<TransportSegment>> updateStatus(
|
||||
String tripId,
|
||||
String segmentId,
|
||||
String status,
|
||||
) async {
|
||||
final current = await loadSegments(tripId);
|
||||
final updated = current
|
||||
.map((s) {
|
||||
if (s.id != segmentId) return s;
|
||||
return s.copyWith(status: status);
|
||||
})
|
||||
.toList(growable: false);
|
||||
await saveSegments(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'trip_transport_$tripId';
|
||||
}
|
||||
77
lib/services/trip_checklist_service.dart
Normal file
77
lib/services/trip_checklist_service.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/checklist_item.dart';
|
||||
|
||||
/// Service responsible for storing and retrieving per-trip checklists.
|
||||
///
|
||||
/// Persistence relies on `SharedPreferences` with one JSON string per trip
|
||||
/// key. All methods are resilient to corrupted payloads and return empty
|
||||
/// lists rather than throwing to keep the UI responsive.
|
||||
class TripChecklistService {
|
||||
/// Loads the checklist items for the given [tripId].
|
||||
///
|
||||
/// Returns an empty list if no data exists or if deserialization fails.
|
||||
Future<List<ChecklistItem>> loadChecklist(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
|
||||
if (raw == null) {
|
||||
return const [];
|
||||
}
|
||||
|
||||
try {
|
||||
return ChecklistItem.decodeList(raw);
|
||||
} catch (_) {
|
||||
// Corrupted payload: clear it to avoid persisting errors.
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the provided [items] list for [tripId].
|
||||
///
|
||||
/// This method overrides the previously stored list; use helpers like
|
||||
/// [addItem], [toggleItem], or [deleteItem] for incremental updates.
|
||||
Future<void> saveChecklist(String tripId, List<ChecklistItem> items) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), ChecklistItem.encodeList(items));
|
||||
}
|
||||
|
||||
/// Adds a new [item] to the checklist for [tripId].
|
||||
///
|
||||
/// Items are appended in creation order; the updated list is persisted.
|
||||
Future<List<ChecklistItem>> addItem(String tripId, ChecklistItem item) async {
|
||||
final current = await loadChecklist(tripId);
|
||||
final updated = [...current, item];
|
||||
await saveChecklist(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Toggles the completion state of the item matching [itemId].
|
||||
///
|
||||
/// Returns the updated list. If the item is not found, the list is
|
||||
/// returned unchanged to keep UI state consistent.
|
||||
Future<List<ChecklistItem>> toggleItem(String tripId, String itemId) async {
|
||||
final current = await loadChecklist(tripId);
|
||||
final updated = current
|
||||
.map((item) {
|
||||
if (item.id != itemId) {
|
||||
return item;
|
||||
}
|
||||
return item.copyWith(isDone: !item.isDone);
|
||||
})
|
||||
.toList(growable: false);
|
||||
|
||||
await saveChecklist(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes the item matching [itemId] and persists the change.
|
||||
Future<List<ChecklistItem>> deleteItem(String tripId, String itemId) async {
|
||||
final current = await loadChecklist(tripId);
|
||||
final updated = current.where((item) => item.id != itemId).toList();
|
||||
await saveChecklist(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'checklist_$tripId';
|
||||
}
|
||||
49
lib/services/trip_document_service.dart
Normal file
49
lib/services/trip_document_service.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/trip_document.dart';
|
||||
|
||||
/// Service that persists per-trip documents metadata locally.
|
||||
///
|
||||
/// Documents are stored as JSON in `SharedPreferences` to keep the UI
|
||||
/// responsive offline. Each trip key is `trip_docs_<tripId>`. The service is
|
||||
/// tolerant to corrupted payloads and resets gracefully to an empty list.
|
||||
class TripDocumentService {
|
||||
/// Loads documents for the given [tripId]. Returns an empty list when none.
|
||||
Future<List<TripDocument>> loadDocuments(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final raw = prefs.getString(_key(tripId));
|
||||
if (raw == null) return const [];
|
||||
try {
|
||||
return TripDocument.decodeList(raw);
|
||||
} catch (_) {
|
||||
await prefs.remove(_key(tripId));
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves the full document list for [tripId].
|
||||
Future<void> saveDocuments(String tripId, List<TripDocument> docs) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_key(tripId), TripDocument.encodeList(docs));
|
||||
}
|
||||
|
||||
/// Adds a new document entry and persists the updated list.
|
||||
Future<List<TripDocument>> addDocument(
|
||||
String tripId,
|
||||
TripDocument doc,
|
||||
) async {
|
||||
final current = await loadDocuments(tripId);
|
||||
final updated = [...current, doc];
|
||||
await saveDocuments(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/// Deletes a document by [docId] and persists the change.
|
||||
Future<List<TripDocument>> deleteDocument(String tripId, String docId) async {
|
||||
final current = await loadDocuments(tripId);
|
||||
final updated = current.where((d) => d.id != docId).toList();
|
||||
await saveDocuments(tripId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
String _key(String tripId) => 'trip_docs_$tripId';
|
||||
}
|
||||
55
lib/services/trip_end_service.dart
Normal file
55
lib/services/trip_end_service.dart
Normal file
@@ -0,0 +1,55 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/trip.dart';
|
||||
import '../repositories/account_repository.dart';
|
||||
import '../repositories/balance_repository.dart';
|
||||
import '../repositories/expense_repository.dart';
|
||||
import 'logger_service.dart';
|
||||
|
||||
/// Service qui détecte les voyages terminés et vérifie si les comptes sont réglés.
|
||||
class TripEndService {
|
||||
static const String _prefixKey = 'trip_end_prompted_';
|
||||
|
||||
final AccountRepository _accountRepository;
|
||||
final BalanceRepository _balanceRepository;
|
||||
|
||||
TripEndService()
|
||||
: _accountRepository = AccountRepository(),
|
||||
_balanceRepository = BalanceRepository(
|
||||
expenseRepository: ExpenseRepository(),
|
||||
);
|
||||
|
||||
/// Retourne les voyages terminés pour lesquels l'utilisateur n'a pas encore été invité à supprimer.
|
||||
Future<List<Trip>> getFinishedTripsNotYetPrompted(List<Trip> trips) async {
|
||||
final now = DateTime.now();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
return trips.where((trip) {
|
||||
if (trip.id == null) return false;
|
||||
final isFinished = trip.endDate.isBefore(now);
|
||||
final alreadyPrompted = prefs.getBool('$_prefixKey${trip.id}') ?? false;
|
||||
return isFinished && !alreadyPrompted;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Marque un voyage comme déjà invité (ne plus afficher le dialog).
|
||||
Future<void> markTripAsPrompted(String tripId) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool('$_prefixKey$tripId', true);
|
||||
}
|
||||
|
||||
/// Vérifie si tous les comptes du voyage sont réglés.
|
||||
///
|
||||
/// Retourne `true` si aucune dépense n'est enregistrée ou si tous les soldes sont nuls.
|
||||
Future<bool> areAccountsSettled(String tripId) async {
|
||||
try {
|
||||
final account = await _accountRepository.getAccountByTripId(tripId);
|
||||
if (account == null) return true;
|
||||
|
||||
final balance = await _balanceRepository.calculateGroupBalance(account.id);
|
||||
return balance.settlements.isEmpty;
|
||||
} catch (e) {
|
||||
LoggerService.error('TripEndService: Erreur vérification comptes: $e');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,7 +458,7 @@ packages:
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
|
||||
@@ -1558,7 +1558,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.6.11"
|
||||
timezone:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: timezone
|
||||
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 2026.1.3+1
|
||||
version: 2026.3.14
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
@@ -30,6 +30,7 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
timezone: ^0.10.1
|
||||
provider: ^6.1.1
|
||||
shared_preferences: ^2.2.2
|
||||
path_provider: ^2.1.1
|
||||
@@ -78,6 +79,7 @@ dev_dependencies:
|
||||
mockito: ^5.4.4
|
||||
build_runner: ^2.4.8
|
||||
bloc_test: ^10.0.0
|
||||
firebase_core_platform_interface: ^6.0.2
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
@@ -8,16 +8,22 @@ import 'package:travel_mate/blocs/auth/auth_state.dart';
|
||||
import 'package:travel_mate/repositories/auth_repository.dart';
|
||||
import 'package:travel_mate/models/user.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_core_platform_interface/test.dart';
|
||||
|
||||
import 'package:travel_mate/services/notification_service.dart';
|
||||
import 'package:travel_mate/services/analytics_service.dart';
|
||||
|
||||
import 'auth_bloc_test.mocks.dart';
|
||||
|
||||
@GenerateMocks([AuthRepository, NotificationService])
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('AuthBloc', () {
|
||||
late MockAuthRepository mockAuthRepository;
|
||||
late MockNotificationService mockNotificationService;
|
||||
late FakeAnalyticsService fakeAnalyticsService;
|
||||
late AuthBloc authBloc;
|
||||
|
||||
final user = User(
|
||||
@@ -28,12 +34,19 @@ void main() {
|
||||
platform: 'email',
|
||||
);
|
||||
|
||||
setUpAll(() async {
|
||||
setupFirebaseCoreMocks();
|
||||
await Firebase.initializeApp();
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mockAuthRepository = MockAuthRepository();
|
||||
mockNotificationService = MockNotificationService();
|
||||
fakeAnalyticsService = FakeAnalyticsService();
|
||||
authBloc = AuthBloc(
|
||||
authRepository: mockAuthRepository,
|
||||
notificationService: mockNotificationService,
|
||||
analyticsService: fakeAnalyticsService,
|
||||
);
|
||||
|
||||
// Default stub for saveTokenToFirestore to avoid strict mock errors
|
||||
@@ -128,3 +141,14 @@ class MockFirebaseUser extends Mock implements firebase_auth.User {
|
||||
|
||||
MockFirebaseUser({required this.uid, this.email});
|
||||
}
|
||||
|
||||
class FakeAnalyticsService extends AnalyticsService {
|
||||
@override
|
||||
Future<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();
|
||||
});
|
||||
});
|
||||
}
|
||||
39
test/services/album_service_test.dart
Normal file
39
test/services/album_service_test.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/album_photo.dart';
|
||||
import 'package:travel_mate/services/album_service.dart';
|
||||
|
||||
void main() {
|
||||
const tripId = 'trip-album-1';
|
||||
late AlbumService service;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
service = AlbumService();
|
||||
});
|
||||
|
||||
test('adds and loads photos', () async {
|
||||
final photo = AlbumPhoto.newPhoto(
|
||||
id: 'p1',
|
||||
url: 'https://example.com/img.jpg',
|
||||
caption: 'Coucher de soleil',
|
||||
uploadedBy: 'Alice',
|
||||
);
|
||||
await service.addPhoto(tripId, photo);
|
||||
final loaded = await service.loadPhotos(tripId);
|
||||
expect(loaded.single.url, contains('example.com'));
|
||||
});
|
||||
|
||||
test('deletes photo and clears corrupted payload', () async {
|
||||
final p = AlbumPhoto.newPhoto(id: 'p1', url: 'u', caption: null);
|
||||
await service.addPhoto(tripId, p);
|
||||
var updated = await service.deletePhoto(tripId, 'p1');
|
||||
expect(updated, isEmpty);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('album_$tripId', 'oops');
|
||||
updated = await service.loadPhotos(tripId);
|
||||
expect(updated, isEmpty);
|
||||
expect(prefs.getString('album_$tripId'), isNull);
|
||||
});
|
||||
}
|
||||
51
test/services/budget_service_test.dart
Normal file
51
test/services/budget_service_test.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/budget_category.dart';
|
||||
import 'package:travel_mate/services/budget_service.dart';
|
||||
|
||||
void main() {
|
||||
const tripId = 'trip-budget-1';
|
||||
late BudgetService service;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
service = BudgetService();
|
||||
});
|
||||
|
||||
test('adds and deletes budget envelopes', () async {
|
||||
final cat = BudgetCategory.newCategory(
|
||||
id: 'food',
|
||||
name: 'Food',
|
||||
planned: 300,
|
||||
currency: 'EUR',
|
||||
);
|
||||
await service.addBudget(tripId, cat);
|
||||
|
||||
var loaded = await service.loadBudgets(tripId);
|
||||
expect(loaded.single.name, 'Food');
|
||||
|
||||
loaded = await service.deleteBudget(tripId, 'food');
|
||||
expect(loaded, isEmpty);
|
||||
});
|
||||
|
||||
test('updates spent amount', () async {
|
||||
final cat = BudgetCategory.newCategory(
|
||||
id: 'transport',
|
||||
name: 'Transport',
|
||||
planned: 200,
|
||||
currency: 'USD',
|
||||
);
|
||||
await service.addBudget(tripId, cat);
|
||||
|
||||
final updated = await service.updateSpent(tripId, 'transport', 55.5);
|
||||
expect(updated.first.spent, 55.5);
|
||||
});
|
||||
|
||||
test('corrupted payload cleared', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('budget_$tripId', 'oops');
|
||||
final loaded = await service.loadBudgets(tripId);
|
||||
expect(loaded, isEmpty);
|
||||
expect(prefs.getString('budget_$tripId'), isNull);
|
||||
});
|
||||
}
|
||||
54
test/services/emergency_service_test.dart
Normal file
54
test/services/emergency_service_test.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/emergency_contact.dart';
|
||||
import 'package:travel_mate/services/emergency_service.dart';
|
||||
|
||||
void main() {
|
||||
const tripId = 'trip-emergency-1';
|
||||
late EmergencyService service;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
service = EmergencyService();
|
||||
});
|
||||
|
||||
test('adds and loads contacts', () async {
|
||||
final contact = EmergencyContact.newContact(
|
||||
id: 'c1',
|
||||
name: 'Assistance',
|
||||
phone: '+33123456789',
|
||||
note: 'Assurance',
|
||||
);
|
||||
await service.addContact(tripId, contact);
|
||||
|
||||
final loaded = await service.loadContacts(tripId);
|
||||
expect(loaded.single.phone, '+33123456789');
|
||||
});
|
||||
|
||||
test('deletes contact', () async {
|
||||
final a = EmergencyContact.newContact(
|
||||
id: 'a',
|
||||
name: 'Ambassade',
|
||||
phone: '+321234',
|
||||
);
|
||||
final b = EmergencyContact.newContact(
|
||||
id: 'b',
|
||||
name: 'Marie',
|
||||
phone: '+33999',
|
||||
);
|
||||
await service.addContact(tripId, a);
|
||||
await service.addContact(tripId, b);
|
||||
|
||||
final updated = await service.deleteContact(tripId, 'a');
|
||||
expect(updated.map((c) => c.id), contains('b'));
|
||||
expect(updated.length, 1);
|
||||
});
|
||||
|
||||
test('corrupted payload cleared', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('emergency_$tripId', 'oops');
|
||||
final loaded = await service.loadContacts(tripId);
|
||||
expect(loaded, isEmpty);
|
||||
expect(prefs.getString('emergency_$tripId'), isNull);
|
||||
});
|
||||
}
|
||||
19
test/services/guest_flag_service_test.dart
Normal file
19
test/services/guest_flag_service_test.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/services/guest_flag_service.dart';
|
||||
|
||||
void main() {
|
||||
const tripId = 'trip-guest-1';
|
||||
late GuestFlagService service;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
service = GuestFlagService();
|
||||
});
|
||||
|
||||
test('sets and reads guest mode flag', () async {
|
||||
expect(await service.isGuestEnabled(tripId), isFalse);
|
||||
await service.setGuestEnabled(tripId, true);
|
||||
expect(await service.isGuestEnabled(tripId), isTrue);
|
||||
});
|
||||
}
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
46
test/services/packing_service_test.dart
Normal file
46
test/services/packing_service_test.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/packing_item.dart';
|
||||
import 'package:travel_mate/services/packing_service.dart';
|
||||
|
||||
void main() {
|
||||
const tripId = 'trip-pack-1';
|
||||
late PackingService service;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
service = PackingService();
|
||||
});
|
||||
|
||||
test('adds, toggles and deletes packing items', () async {
|
||||
final item = PackingItem.newItem(id: '1', label: 'Adaptateur US');
|
||||
await service.addItem(tripId, item);
|
||||
|
||||
var loaded = await service.loadItems(tripId);
|
||||
expect(loaded.first.isPacked, isFalse);
|
||||
|
||||
loaded = await service.toggleItem(tripId, '1');
|
||||
expect(loaded.first.isPacked, isTrue);
|
||||
|
||||
loaded = await service.deleteItem(tripId, '1');
|
||||
expect(loaded, isEmpty);
|
||||
});
|
||||
|
||||
test('suggested items react to cold and duration', () {
|
||||
final cold = service.suggestedItems(nights: 5, cold: true);
|
||||
expect(cold, contains('Veste chaude'));
|
||||
expect(cold, contains('Lessive/ziplock'));
|
||||
|
||||
final warm = service.suggestedItems(nights: 2, cold: false);
|
||||
expect(warm, contains('Crème solaire'));
|
||||
expect(warm, isNot(contains('Lessive/ziplock')));
|
||||
});
|
||||
|
||||
test('handles corrupted payload', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('packing_$tripId', 'oops');
|
||||
final items = await service.loadItems(tripId);
|
||||
expect(items, isEmpty);
|
||||
expect(prefs.getString('packing_$tripId'), isNull);
|
||||
});
|
||||
}
|
||||
45
test/services/reminder_service_test.dart
Normal file
45
test/services/reminder_service_test.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/reminder_item.dart';
|
||||
import 'package:travel_mate/services/reminder_service.dart';
|
||||
|
||||
void main() {
|
||||
const tripId = 'trip-reminders-1';
|
||||
late ReminderService service;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
service = ReminderService();
|
||||
});
|
||||
|
||||
test('adds and toggles reminders', () async {
|
||||
final r = ReminderItem.newItem(
|
||||
id: 'r1',
|
||||
title: 'Check-in en ligne',
|
||||
dueAt: DateTime.utc(2026, 4, 10, 7),
|
||||
);
|
||||
await service.addReminder(tripId, r);
|
||||
var list = await service.loadReminders(tripId);
|
||||
expect(list.single.isDone, isFalse);
|
||||
|
||||
list = await service.toggleReminder(tripId, 'r1');
|
||||
expect(list.single.isDone, isTrue);
|
||||
});
|
||||
|
||||
test('deletes and clears corrupted payload', () async {
|
||||
final r = ReminderItem.newItem(
|
||||
id: 'r1',
|
||||
title: 'Acheter métro pass',
|
||||
dueAt: DateTime.utc(2026, 4, 1),
|
||||
);
|
||||
await service.addReminder(tripId, r);
|
||||
var list = await service.deleteReminder(tripId, 'r1');
|
||||
expect(list, isEmpty);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('reminders_$tripId', 'oops');
|
||||
list = await service.loadReminders(tripId);
|
||||
expect(list, isEmpty);
|
||||
expect(prefs.getString('reminders_$tripId'), isNull);
|
||||
});
|
||||
}
|
||||
37
test/services/sos_service_test.dart
Normal file
37
test/services/sos_service_test.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/testing.dart';
|
||||
import 'package:travel_mate/services/sos_service.dart';
|
||||
|
||||
void main() {
|
||||
test('sendSos returns true on 200', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
expect(request.url.toString(), 'https://api.example.com/sos');
|
||||
final body = json.decode(request.body) as Map<String, dynamic>;
|
||||
expect(body['tripId'], 't1');
|
||||
return http.Response('{}', 200);
|
||||
});
|
||||
|
||||
final service = SosService(
|
||||
baseUrl: 'https://api.example.com',
|
||||
client: mockClient,
|
||||
);
|
||||
|
||||
final result = await service.sendSos(tripId: 't1', lat: 1, lng: 2);
|
||||
expect(result, isTrue);
|
||||
});
|
||||
|
||||
test('sendSos returns false on error', () async {
|
||||
final mockClient = MockClient((request) async {
|
||||
return http.Response('fail', 500);
|
||||
});
|
||||
final service = SosService(
|
||||
baseUrl: 'https://api.example.com',
|
||||
client: mockClient,
|
||||
);
|
||||
|
||||
final result = await service.sendSos(tripId: 't1', lat: 0, lng: 0);
|
||||
expect(result, isFalse);
|
||||
});
|
||||
}
|
||||
75
test/services/transport_service_test.dart
Normal file
75
test/services/transport_service_test.dart
Normal file
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/transport_segment.dart';
|
||||
import 'package:travel_mate/services/transport_service.dart';
|
||||
|
||||
void main() {
|
||||
const tripId = 'trip-transport-1';
|
||||
late TransportService service;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
service = TransportService();
|
||||
});
|
||||
|
||||
test('adds and loads a segment', () async {
|
||||
final seg = TransportSegment.newSegment(
|
||||
id: 'seg1',
|
||||
type: 'flight',
|
||||
carrier: 'AF',
|
||||
number: '763',
|
||||
departureCode: 'CDG',
|
||||
arrivalCode: 'JFK',
|
||||
departureUtc: DateTime.utc(2026, 4, 10, 7, 0),
|
||||
arrivalUtc: DateTime.utc(2026, 4, 10, 11, 0),
|
||||
pnr: 'ABC123',
|
||||
);
|
||||
|
||||
await service.addSegment(tripId, seg);
|
||||
final loaded = await service.loadSegments(tripId);
|
||||
|
||||
expect(loaded, hasLength(1));
|
||||
expect(loaded.first.number, '763');
|
||||
expect(loaded.first.status, 'scheduled');
|
||||
});
|
||||
|
||||
test('updates status', () async {
|
||||
final seg = TransportSegment.newSegment(
|
||||
id: 'seg1',
|
||||
type: 'train',
|
||||
carrier: 'TGV',
|
||||
number: '8401',
|
||||
departureCode: 'PAR',
|
||||
arrivalCode: 'BRU',
|
||||
departureUtc: DateTime.utc(2026, 5, 1, 8, 30),
|
||||
arrivalUtc: DateTime.utc(2026, 5, 1, 10, 30),
|
||||
);
|
||||
await service.addSegment(tripId, seg);
|
||||
|
||||
final updated = await service.updateStatus(tripId, 'seg1', 'delayed');
|
||||
|
||||
expect(updated.first.status, 'delayed');
|
||||
});
|
||||
|
||||
test('deletes segment and handles corrupted payload', () async {
|
||||
final seg = TransportSegment.newSegment(
|
||||
id: 'seg1',
|
||||
type: 'bus',
|
||||
carrier: 'FLX',
|
||||
number: '12',
|
||||
departureCode: 'AMS',
|
||||
arrivalCode: 'BRU',
|
||||
departureUtc: DateTime.utc(2026, 6, 1, 9, 0),
|
||||
arrivalUtc: DateTime.utc(2026, 6, 1, 11, 30),
|
||||
);
|
||||
await service.addSegment(tripId, seg);
|
||||
await service.deleteSegment(tripId, 'seg1');
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('trip_transport_$tripId', 'bad-json');
|
||||
|
||||
final loaded = await service.loadSegments(tripId);
|
||||
expect(loaded, isEmpty);
|
||||
expect(prefs.getString('trip_transport_$tripId'), isNull);
|
||||
});
|
||||
}
|
||||
63
test/services/trip_checklist_service_test.dart
Normal file
63
test/services/trip_checklist_service_test.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/checklist_item.dart';
|
||||
import 'package:travel_mate/services/trip_checklist_service.dart';
|
||||
|
||||
void main() {
|
||||
late TripChecklistService service;
|
||||
const tripId = 'trip-123';
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
service = TripChecklistService();
|
||||
});
|
||||
|
||||
test('adds and loads checklist items', () async {
|
||||
final created = ChecklistItem.newItem(id: '1', label: 'Préparer passeport');
|
||||
await service.addItem(tripId, created);
|
||||
|
||||
final result = await service.loadChecklist(tripId);
|
||||
|
||||
expect(result, hasLength(1));
|
||||
expect(result.first.label, 'Préparer passeport');
|
||||
expect(result.first.isDone, isFalse);
|
||||
});
|
||||
|
||||
test('toggles completion state', () async {
|
||||
final item = ChecklistItem.newItem(id: '1', label: 'Acheter billets');
|
||||
await service.addItem(tripId, item);
|
||||
|
||||
final toggled = await service.toggleItem(tripId, '1');
|
||||
|
||||
expect(toggled.first.isDone, isTrue);
|
||||
|
||||
final toggledBack = await service.toggleItem(tripId, '1');
|
||||
expect(toggledBack.first.isDone, isFalse);
|
||||
});
|
||||
|
||||
test('deletes items and keeps list consistent', () async {
|
||||
final itemA = ChecklistItem.newItem(id: '1', label: 'Adapter prise');
|
||||
final itemB = ChecklistItem.newItem(id: '2', label: 'Chargeur');
|
||||
await service.addItem(tripId, itemA);
|
||||
await service.addItem(tripId, itemB);
|
||||
|
||||
final afterDelete = await service.deleteItem(tripId, '1');
|
||||
|
||||
expect(afterDelete, hasLength(1));
|
||||
expect(afterDelete.first.id, '2');
|
||||
|
||||
final persisted = await service.loadChecklist(tripId);
|
||||
expect(persisted, hasLength(1));
|
||||
expect(persisted.first.id, '2');
|
||||
});
|
||||
|
||||
test('handles corrupted payload gracefully', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('checklist_$tripId', 'not-json');
|
||||
|
||||
final items = await service.loadChecklist(tripId);
|
||||
|
||||
expect(items, isEmpty);
|
||||
expect(prefs.getString('checklist_$tripId'), isNull);
|
||||
});
|
||||
}
|
||||
60
test/services/trip_document_service_test.dart
Normal file
60
test/services/trip_document_service_test.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:travel_mate/models/trip_document.dart';
|
||||
import 'package:travel_mate/services/trip_document_service.dart';
|
||||
|
||||
void main() {
|
||||
const tripId = 'trip-docs-1';
|
||||
late TripDocumentService service;
|
||||
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
service = TripDocumentService();
|
||||
});
|
||||
|
||||
test('adds and loads documents', () async {
|
||||
final doc = TripDocument.newEntry(
|
||||
id: 'doc1',
|
||||
title: 'Billet Aller',
|
||||
category: 'billet',
|
||||
downloadUrl: 'https://example.com/billet.pdf',
|
||||
);
|
||||
|
||||
await service.addDocument(tripId, doc);
|
||||
final loaded = await service.loadDocuments(tripId);
|
||||
|
||||
expect(loaded, hasLength(1));
|
||||
expect(loaded.first.title, 'Billet Aller');
|
||||
expect(loaded.first.category, 'billet');
|
||||
});
|
||||
|
||||
test('deletes a document', () async {
|
||||
final a = TripDocument.newEntry(
|
||||
id: 'a',
|
||||
title: 'Passeport',
|
||||
category: 'passeport',
|
||||
);
|
||||
final b = TripDocument.newEntry(
|
||||
id: 'b',
|
||||
title: 'Assurance',
|
||||
category: 'assurance',
|
||||
);
|
||||
await service.addDocument(tripId, a);
|
||||
await service.addDocument(tripId, b);
|
||||
|
||||
final afterDelete = await service.deleteDocument(tripId, 'a');
|
||||
|
||||
expect(afterDelete, hasLength(1));
|
||||
expect(afterDelete.first.id, 'b');
|
||||
});
|
||||
|
||||
test('handles corrupted payload gracefully', () async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('trip_docs_$tripId', 'oops');
|
||||
|
||||
final docs = await service.loadDocuments(tripId);
|
||||
|
||||
expect(docs, isEmpty);
|
||||
expect(prefs.getString('trip_docs_$tripId'), isNull);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user