Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb94badafe | ||
|
|
30d90a05fd | ||
|
|
68fa2b4587 | ||
|
|
9e3169b225 | ||
|
|
671136ac4b | ||
|
|
adb0a2e7c9 | ||
|
|
69a65d83f2 | ||
|
|
cb35ddac22 | ||
|
|
21d7bc8b87 | ||
|
|
8c01a21728 | ||
|
|
20c44cfb8b | ||
|
|
0744665fe2 | ||
|
|
555629760d | ||
|
|
d52d40ad74 | ||
|
|
1d825c0233 | ||
|
|
55cdd168dc | ||
|
|
61b74f7465 | ||
|
|
1bf5c8061f | ||
|
|
e14b333a67 | ||
|
|
4d18956abe | ||
|
|
d9cd251bb7 | ||
|
|
faff06e4df | ||
|
|
64a9fe382a | ||
|
|
fb740d97a3 |
@@ -149,3 +149,4 @@ app.*.symbols
|
|||||||
.python-version
|
.python-version
|
||||||
.gclient_previous_custom_vars
|
.gclient_previous_custom_vars
|
||||||
.gclient_previous_sync_commits
|
.gclient_previous_sync_commits
|
||||||
|
em2rp/lib/config/env.dart
|
||||||
|
|||||||
@@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63
|
|||||||
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
|
assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc
|
||||||
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
|
assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde
|
||||||
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
|
assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d
|
||||||
version.json,1779800968600,00f600f01984c1e371af870e40f78fd44ba53d05596f2f92f9b4fc56a85f52b6
|
version.json,1779802456392,7626a7c596308bd2eb1add2ed984cd6dda5d4a3f0dedb3338244d2ae45c496cf
|
||||||
index.html,1779800974065,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
index.html,1779802461141,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10
|
||||||
flutter_service_worker.js,1779801065196,879f42a05578f24ea45dc23326fdda6246d38dc59de0824ef8d4edfa4715e571
|
flutter_service_worker.js,1779802555396,fadec2c9a1e8e16c22e332aef080b0b2aacc3998c4e260e5821b79afb9e000da
|
||||||
flutter_bootstrap.js,1779800974054,977a20d5caac8da21af648cae8fa7dba00a5cd959fd2fafa7ef538a012fe87c3
|
flutter_bootstrap.js,1779802461128,ad20054b92acf16bb75fbffd65f81c63c6d3cb6d752f799230dca5f2118af783
|
||||||
assets/FontManifest.json,1779801061892,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
assets/FontManifest.json,1779802551869,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5
|
||||||
assets/AssetManifest.json,1779801061891,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
assets/AssetManifest.json,1779802551869,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6
|
||||||
assets/AssetManifest.bin.json,1779801061892,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
assets/AssetManifest.bin.json,1779802551869,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53
|
||||||
assets/AssetManifest.bin,1779801061891,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
assets/AssetManifest.bin,1779802551869,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907
|
||||||
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779801064441,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
assets/shaders/ink_sparkle.frag,1779802552052,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
||||||
assets/shaders/ink_sparkle.frag,1779801062097,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406
|
assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1779802554433,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb
|
||||||
assets/fonts/MaterialIcons-Regular.otf,1779801064446,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6
|
assets/fonts/MaterialIcons-Regular.otf,1779802554440,710dc8fc35289048b52970355f64206fb1b2c5e67c71ae77a46b53f0e2daecd6
|
||||||
assets/NOTICES,1779801061893,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
|
assets/NOTICES,1779802551871,5522e1307c65771d1fbf26fcd9dc0548c751413f42196c4acaba5ee674eede1e
|
||||||
main.dart.js,1779801060964,e87fb4dfca93c3384b5cb63d627186e48b0c15d78ed63bc7f5e61544ef292dd9
|
main.dart.js,1779802550741,ab892e930c97940c1ea4ff33079922082c7f688047d307acad0644a78cfda2d7
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
Toutes les modifications notables de ce projet seront documentées dans ce fichier.
|
||||||
|
|
||||||
|
## 27/05/2026
|
||||||
|
Ajout de la fonction retour en arriere pour la validation du chargement des equipements et des conteneurs.
|
||||||
|
|
||||||
## 26/05/2026
|
## 26/05/2026
|
||||||
Optimisation des perfomance de l'application, amélioration de la gestion des données et refonte visuelle de la page de gestion des équipements.
|
Optimisation des perfomance de l'application, amélioration de la gestion des données et refonte visuelle de la page de gestion des équipements.
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ service cloud.firestore {
|
|||||||
allow read, write: if false;
|
allow read, write: if false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Autoriser l'accès aux collections de configuration de l'application
|
||||||
|
match /depots/{document=**} {
|
||||||
|
allow read, write: if request.auth != null;
|
||||||
|
}
|
||||||
|
match /vehicles/{document=**} {
|
||||||
|
allow read, write: if request.auth != null;
|
||||||
|
}
|
||||||
|
match /app_config/{document=**} {
|
||||||
|
allow read, write: if request.auth != null;
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// EXCEPTIONS OPTIONNELLES pour les listeners temps réel
|
// EXCEPTIONS OPTIONNELLES pour les listeners temps réel
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ SMTP_PASS="aL8@Rx8xqFrNij$a"
|
|||||||
APP_URL="https://app.em2events.fr"
|
APP_URL="https://app.em2events.fr"
|
||||||
|
|
||||||
GEMINI_API_KEY="AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc"
|
GEMINI_API_KEY="AIzaSyB0hOvBjWeWjdrxVARzfErZ_uGuArlvmQc"
|
||||||
|
API_MAPS="AIzaSyDt2d-T9YRmHO3-QEq1uWomdqVbJqXfO04"
|
||||||
|
|||||||
@@ -1,60 +1,22 @@
|
|||||||
const {onRequest} = require("firebase-functions/v2/https");
|
const {onCall} = require("firebase-functions/v2/https");
|
||||||
const admin = require("firebase-admin");
|
const admin = require("firebase-admin");
|
||||||
const nodemailer = require("nodemailer");
|
const nodemailer = require("nodemailer");
|
||||||
const logger = require("firebase-functions/logger");
|
const logger = require("firebase-functions/logger");
|
||||||
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
|
const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
|
||||||
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require("./utils/emailTemplates");
|
const {renderTemplate, getEmailSubject, getAlertTitle, prepareTemplateData, checkAlertPreference} = require("./utils/emailTemplates");
|
||||||
const auth = require("./utils/auth");
|
|
||||||
|
|
||||||
// Configuration CORS
|
|
||||||
const setCorsHeaders = (res, req) => {
|
|
||||||
// Utiliser l'origin de la requête pour permettre les credentials
|
|
||||||
const origin = req.headers.origin || "*";
|
|
||||||
|
|
||||||
res.set("Access-Control-Allow-Origin", origin);
|
|
||||||
|
|
||||||
// N'autoriser les credentials que si on a un origin spécifique (pas '*')
|
|
||||||
if (origin !== "*") {
|
|
||||||
res.set("Access-Control-Allow-Credentials", "true");
|
|
||||||
}
|
|
||||||
|
|
||||||
res.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
|
|
||||||
res.set("Access-Control-Allow-Headers", "Authorization, Content-Type, Accept, Origin, X-Requested-With");
|
|
||||||
res.set("Access-Control-Max-Age", "3600");
|
|
||||||
};
|
|
||||||
|
|
||||||
const withCors = (handler) => {
|
|
||||||
return async (req, res) => {
|
|
||||||
setCorsHeaders(res, req);
|
|
||||||
// Gérer les requêtes preflight OPTIONS immédiatement
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
res.status(204).send("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await handler(req, res);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Unhandled error:", error);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: error.message});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Crée une alerte et envoie les notifications
|
* Crée une alerte et envoie les notifications
|
||||||
* Gère tout le processus côté backend de A à Z
|
* Gère tout le processus côté backend de A à Z
|
||||||
*/
|
*/
|
||||||
exports.createAlert = onRequest({
|
const handler = async (request) => {
|
||||||
cors: false,
|
|
||||||
invoker: "public",
|
|
||||||
region: "europe-west9",
|
|
||||||
}, withCors(async (req, res) => {
|
|
||||||
try {
|
try {
|
||||||
|
const {auth, data} = request;
|
||||||
|
|
||||||
// Vérifier l'authentification
|
// Vérifier l'authentification
|
||||||
const decodedToken = await auth.authenticateUser(req);
|
if (!auth) {
|
||||||
const data = req.body.data || req.body;
|
throw new Error("L'utilisateur doit être authentifié");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -96,7 +58,7 @@ exports.createAlert = onRequest({
|
|||||||
metadata: metadata || {},
|
metadata: metadata || {},
|
||||||
assignedTo: userIds,
|
assignedTo: userIds,
|
||||||
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
createdAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
createdBy: decodedToken.uid,
|
createdBy: auth.uid,
|
||||||
isRead: false,
|
isRead: false,
|
||||||
emailSent: false,
|
emailSent: false,
|
||||||
status: "ACTIVE",
|
status: "ACTIVE",
|
||||||
@@ -117,17 +79,17 @@ exports.createAlert = onRequest({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
alertId: alertRef.id,
|
alertId: alertRef.id,
|
||||||
usersNotified: userIds.length,
|
usersNotified: userIds.length,
|
||||||
emailsSent: Object.values(emailResults).filter((v) => v).length,
|
emailsSent: Object.values(emailResults).filter((v) => v).length,
|
||||||
});
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("[createAlert] Erreur:", error);
|
logger.error("[createAlert] Erreur:", error);
|
||||||
res.status(500).json({error: `Erreur lors de la création de l'alerte: ${error.message}`});
|
throw error;
|
||||||
}
|
}
|
||||||
}));
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Détermine les utilisateurs à notifier selon le type d'alerte
|
* Détermine les utilisateurs à notifier selon le type d'alerte
|
||||||
@@ -189,7 +151,7 @@ async function determineTargetUsers(alertType, severity, eventId) {
|
|||||||
*/
|
*/
|
||||||
async function sendAlertEmails(alertId, alertData, userIds) {
|
async function sendAlertEmails(alertId, alertData, userIds) {
|
||||||
const results = {};
|
const results = {};
|
||||||
const transporter = nodemailer.createTransporter(getSmtpConfig());
|
const transporter = nodemailer.createTransport(getSmtpConfig());
|
||||||
|
|
||||||
// Envoyer les emails en parallèle (batch de 5)
|
// Envoyer les emails en parallèle (batch de 5)
|
||||||
const batches = [];
|
const batches = [];
|
||||||
@@ -269,3 +231,10 @@ async function sendSingleEmail(transporter, alertId, alertData, userId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.createAlert = onCall({
|
||||||
|
cors: true,
|
||||||
|
region: "europe-west9",
|
||||||
|
}, handler);
|
||||||
|
|
||||||
|
exports.handler = handler;
|
||||||
|
|
||||||
|
|||||||
+215
-19
@@ -190,14 +190,6 @@ exports.getEventWithDetails = onRequest(httpOptions, withCors((req, res) => {
|
|||||||
return require("./src/events").getEventWithDetails(req, res);
|
return require("./src/events").getEventWithDetails(req, res);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
exports.validateEquipmentPreparation = onRequest(httpOptions, withCors((req, res) => {
|
|
||||||
return require("./src/events").validateEquipmentPreparation(req, res);
|
|
||||||
}));
|
|
||||||
|
|
||||||
exports.validateAllPreparation = onRequest(httpOptions, withCors((req, res) => {
|
|
||||||
return require("./src/events").validateAllPreparation(req, res);
|
|
||||||
}));
|
|
||||||
|
|
||||||
exports.validateEquipmentLoading = onRequest(httpOptions, withCors((req, res) => {
|
exports.validateEquipmentLoading = onRequest(httpOptions, withCors((req, res) => {
|
||||||
return require("./src/events").validateEquipmentLoading(req, res);
|
return require("./src/events").validateEquipmentLoading(req, res);
|
||||||
}));
|
}));
|
||||||
@@ -214,14 +206,6 @@ exports.validateAllUnloading = onRequest(httpOptions, withCors((req, res) => {
|
|||||||
return require("./src/events").validateAllUnloading(req, res);
|
return require("./src/events").validateAllUnloading(req, res);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
exports.validateEquipmentReturn = onRequest(httpOptions, withCors((req, res) => {
|
|
||||||
return require("./src/events").validateEquipmentReturn(req, res);
|
|
||||||
}));
|
|
||||||
|
|
||||||
exports.validateAllReturn = onRequest(httpOptions, withCors((req, res) => {
|
|
||||||
return require("./src/events").validateAllReturn(req, res);
|
|
||||||
}));
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// MAINTENANCES
|
// MAINTENANCES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -379,15 +363,19 @@ exports.aiEquipmentProposal = onRequest(aiHttpOptions, withCors((req, res) => {
|
|||||||
// CALLABLE EMAIL & VALIDATION (LEGACY LAZY WRAPPERS)
|
// CALLABLE EMAIL & VALIDATION (LEGACY LAZY WRAPPERS)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
exports.sendAlertEmail = onCall({region: "europe-west9", cors: true}, (request) => {
|
exports.sendAlertEmail = onCall({region: "europe-west9", cors: true}, (request) => {
|
||||||
return require("./sendAlertEmail").sendAlertEmail(request);
|
return require("./sendAlertEmail").handler(request);
|
||||||
});
|
});
|
||||||
|
|
||||||
exports.createAlert = onCall({region: "europe-west9", cors: true}, (request) => {
|
exports.createAlert = onCall({region: "europe-west9", cors: true}, (request) => {
|
||||||
return require("./createAlert").createAlert(request);
|
return require("./createAlert").handler(request);
|
||||||
});
|
});
|
||||||
|
|
||||||
exports.processEquipmentValidation = onCall({region: "europe-west9", cors: true}, (request) => {
|
exports.processEquipmentValidation = onCall({region: "europe-west9", cors: true}, (request) => {
|
||||||
return require("./processEquipmentValidation").processEquipmentValidation(request);
|
return require("./processEquipmentValidation").handler(request);
|
||||||
|
});
|
||||||
|
|
||||||
|
exports.rollbackEventStep = onCall({region: "europe-west9", cors: true}, (request) => {
|
||||||
|
return require("./rollbackEventStep").handler(request);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -515,3 +503,211 @@ exports.onAlertCreated = onDocumentCreated({
|
|||||||
logger.error("[onAlertCreated] Erreur:", error);
|
logger.error("[onAlertCreated] Erreur:", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
exports.onEventReturnCompleted = onDocumentUpdated({
|
||||||
|
document: "events/{eventId}",
|
||||||
|
region: "europe-west9",
|
||||||
|
}, async (event) => {
|
||||||
|
const before = event.data.before.data();
|
||||||
|
const after = event.data.after.data();
|
||||||
|
const eventId = event.params.eventId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const beforeReturnStatus = (before.returnStatus || "").toString().toUpperCase();
|
||||||
|
const afterReturnStatus = (after.returnStatus || "").toString().toUpperCase();
|
||||||
|
|
||||||
|
if (afterReturnStatus === "COMPLETED" && beforeReturnStatus !== "COMPLETED") {
|
||||||
|
logger.info(`[onEventReturnCompleted] Event ${eventId} returnStatus completed. Resetting assigned equipments...`);
|
||||||
|
|
||||||
|
const eventRef = db.collection("events").doc(eventId);
|
||||||
|
|
||||||
|
await db.runTransaction(async (transaction) => {
|
||||||
|
const currentEventDoc = await transaction.get(eventRef);
|
||||||
|
if (!currentEventDoc.exists) {
|
||||||
|
logger.warn(`[onEventReturnCompleted] Event doc ${eventId} not found during transaction.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentEventData = currentEventDoc.data();
|
||||||
|
if (currentEventData.stocksRestored === true) {
|
||||||
|
logger.info(`[onEventReturnCompleted] Stocks already restored for event ${eventId}, skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignedEquipment = currentEventData.assignedEquipment || [];
|
||||||
|
if (assignedEquipment.length === 0) {
|
||||||
|
logger.info(`[onEventReturnCompleted] No assigned equipment for event ${eventId}.`);
|
||||||
|
transaction.update(eventRef, {
|
||||||
|
stocksRestored: true,
|
||||||
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all unique equipment docs in the transaction
|
||||||
|
const equipmentIds = Array.from(new Set(assignedEquipment.map((eq) => eq.equipmentId).filter(Boolean)));
|
||||||
|
const equipmentDocsMap = {};
|
||||||
|
|
||||||
|
for (const eqId of equipmentIds) {
|
||||||
|
const eqRef = db.collection("equipments").doc(eqId);
|
||||||
|
const eqDoc = await transaction.get(eqRef);
|
||||||
|
if (eqDoc.exists) {
|
||||||
|
equipmentDocsMap[eqId] = eqDoc.data();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update equipment statuses and quantities
|
||||||
|
for (const eq of assignedEquipment) {
|
||||||
|
const eqId = eq.equipmentId;
|
||||||
|
const equipmentData = equipmentDocsMap[eqId];
|
||||||
|
if (!equipmentData) continue;
|
||||||
|
|
||||||
|
const hasQuantity = equipmentData.hasQuantity === true ||
|
||||||
|
equipmentData.category === "CABLE" ||
|
||||||
|
equipmentData.category === "CONSUMABLE";
|
||||||
|
|
||||||
|
const eqRef = db.collection("equipments").doc(eqId);
|
||||||
|
if (!hasQuantity) {
|
||||||
|
// Non-consumable: reset to AVAILABLE
|
||||||
|
transaction.update(eqRef, {
|
||||||
|
status: "AVAILABLE",
|
||||||
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
logger.info(`[onEventReturnCompleted] Set status to AVAILABLE for equipment ${eqId}`);
|
||||||
|
} else if (hasQuantity && eq.quantityAtReturn !== undefined && eq.quantityAtReturn !== null) {
|
||||||
|
// Consumable: increment availableQuantity
|
||||||
|
const currentAvailable = Number(equipmentData.availableQuantity) || 0;
|
||||||
|
const returnedQty = Number(eq.quantityAtReturn) || 0;
|
||||||
|
transaction.update(eqRef, {
|
||||||
|
availableQuantity: currentAvailable + returnedQty,
|
||||||
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
logger.info(`[onEventReturnCompleted] Restored ${returnedQty} items for consumable ${eqId} (new available: ${currentAvailable + returnedQty})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark event as stocksRestored
|
||||||
|
transaction.update(eventRef, {
|
||||||
|
stocksRestored: true,
|
||||||
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
logger.info(`[onEventReturnCompleted] Transaction completed successfully for event ${eventId}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[onEventReturnCompleted] Error resetting equipment statuses for event ${eventId}:`, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SEARCH - Recherche unifiée avec autocomplétion
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche rapide d'équipements et containers pour l'autocomplétion
|
||||||
|
* Retourne un nombre limité de résultats pour des performances optimales
|
||||||
|
*/
|
||||||
|
exports.quickSearch = onRequest(httpOptions, withCors(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const decodedToken = await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
// Vérifier les permissions
|
||||||
|
const canView = await auth.hasPermission(decodedToken.uid, 'view_equipment');
|
||||||
|
|
||||||
|
if (!canView) {
|
||||||
|
res.status(403).json({ error: 'Forbidden: Requires view_equipment permission' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = req.method === 'GET' ? req.query : (req.body?.data || {});
|
||||||
|
const searchQuery = params.query?.toLowerCase() || '';
|
||||||
|
const limit = Math.min(parseInt(params.limit) || 10, 50);
|
||||||
|
const includeEquipments = params.includeEquipments !== 'false';
|
||||||
|
const includeContainers = params.includeContainers !== 'false';
|
||||||
|
|
||||||
|
if (!searchQuery || searchQuery.length < 2) {
|
||||||
|
res.status(200).json({ results: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
|
||||||
|
// Rechercher dans les équipements
|
||||||
|
if (includeEquipments) {
|
||||||
|
const equipmentSnapshot = await db.collection('equipments')
|
||||||
|
.orderBy('id')
|
||||||
|
.limit(limit * 2) // Récupérer plus pour filtrer ensuite
|
||||||
|
.get();
|
||||||
|
|
||||||
|
equipmentSnapshot.docs.forEach(doc => {
|
||||||
|
const data = doc.data();
|
||||||
|
const searchableText = [
|
||||||
|
data.name || '',
|
||||||
|
doc.id || '',
|
||||||
|
data.model || '',
|
||||||
|
data.brand || ''
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
|
||||||
|
if (searchableText.includes(searchQuery)) {
|
||||||
|
results.push({
|
||||||
|
type: 'equipment',
|
||||||
|
id: doc.id,
|
||||||
|
name: data.name,
|
||||||
|
category: data.category,
|
||||||
|
model: data.model,
|
||||||
|
brand: data.brand
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rechercher dans les containers
|
||||||
|
if (includeContainers) {
|
||||||
|
const containerSnapshot = await db.collection('containers')
|
||||||
|
.orderBy('id')
|
||||||
|
.limit(limit * 2)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
containerSnapshot.docs.forEach(doc => {
|
||||||
|
const data = doc.data();
|
||||||
|
const searchableText = [
|
||||||
|
data.name || '',
|
||||||
|
doc.id || ''
|
||||||
|
].join(' ').toLowerCase();
|
||||||
|
|
||||||
|
if (searchableText.includes(searchQuery)) {
|
||||||
|
results.push({
|
||||||
|
type: 'container',
|
||||||
|
id: doc.id,
|
||||||
|
name: data.name,
|
||||||
|
containerType: data.type
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limiter et trier les résultats
|
||||||
|
const limitedResults = results
|
||||||
|
.sort((a, b) => {
|
||||||
|
// Prioriser les correspondances exactes au début
|
||||||
|
const aStarts = a.id.toLowerCase().startsWith(searchQuery);
|
||||||
|
const bStarts = b.id.toLowerCase().startsWith(searchQuery);
|
||||||
|
if (aStarts && !bStarts) return -1;
|
||||||
|
if (!aStarts && bStarts) return 1;
|
||||||
|
return 0;
|
||||||
|
})
|
||||||
|
.slice(0, limit);
|
||||||
|
|
||||||
|
res.status(200).json({ results: limitedResults });
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error in quick search:", error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// GOOGLE MAPS & TRAVEL (Proxies CORS + Calcul d'itinéraires)
|
||||||
|
// ============================================================================
|
||||||
|
const travel = require('./src/travel');
|
||||||
|
exports.googleMapsAutocomplete = onRequest(httpOptions, withCors(travel.googleMapsAutocomplete));
|
||||||
|
exports.googleMapsComputeRoute = onRequest(httpOptions, withCors(travel.googleMapsComputeRoute));
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"@google-cloud/storage": "^7.18.0",
|
"@google-cloud/storage": "^7.18.0",
|
||||||
"@google-cloud/text-to-speech": "^5.4.0",
|
"@google-cloud/text-to-speech": "^5.4.0",
|
||||||
"@google/generative-ai": "^0.21.0",
|
"@google/generative-ai": "^0.21.0",
|
||||||
|
"@mapbox/polyline": "^1.2.1",
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
"csv-parser": "^3.2.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"envdot": "^0.0.3",
|
"envdot": "^0.0.3",
|
||||||
"firebase-admin": "^12.6.0",
|
"firebase-admin": "^12.6.0",
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
|
|||||||
* Appelée par le client lors du chargement/déchargement
|
* Appelée par le client lors du chargement/déchargement
|
||||||
* Crée automatiquement les alertes nécessaires
|
* Crée automatiquement les alertes nécessaires
|
||||||
*/
|
*/
|
||||||
exports.processEquipmentValidation = onCall({
|
const handler = async (request) => {
|
||||||
cors: true,
|
|
||||||
region: "europe-west9",
|
|
||||||
}, async (request) => {
|
|
||||||
try {
|
try {
|
||||||
// L'authentification est automatique avec onCall
|
// L'authentification est automatique avec onCall
|
||||||
const {auth, data} = request;
|
const {auth, data} = request;
|
||||||
@@ -140,9 +137,8 @@ exports.processEquipmentValidation = onCall({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Mettre à jour les équipements de l'événement
|
// 3. Mettre à jour les équipements de l'événement (uniquement lastValidation, assignedEquipment est déjà mis à jour par le client)
|
||||||
await eventRef.update({
|
await eventRef.update({
|
||||||
equipment: equipmentList,
|
|
||||||
lastValidation: {
|
lastValidation: {
|
||||||
type: validationType,
|
type: validationType,
|
||||||
timestamp: admin.firestore.FieldValue.serverTimestamp(),
|
timestamp: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
@@ -461,3 +457,10 @@ function parseFirestoreDate(value) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.processEquipmentValidation = onCall({
|
||||||
|
cors: true,
|
||||||
|
region: "europe-west9",
|
||||||
|
}, handler);
|
||||||
|
|
||||||
|
exports.handler = handler;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
const {onCall} = require("firebase-functions/v2/https");
|
||||||
|
const admin = require("firebase-admin");
|
||||||
|
const logger = require("firebase-functions/logger");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts the validation progress of an event to a target step.
|
||||||
|
* Resets subsequent step statuses and validation flags of assigned equipment.
|
||||||
|
* If rolling back from a completed return, it decrements the consumable stock quantities
|
||||||
|
* that were restored during return validation.
|
||||||
|
*/
|
||||||
|
const handler = async (request) => {
|
||||||
|
try {
|
||||||
|
const {auth, data} = request;
|
||||||
|
if (!auth) {
|
||||||
|
throw new Error("L'utilisateur doit être authentifié");
|
||||||
|
}
|
||||||
|
|
||||||
|
const {eventId, targetStep} = data;
|
||||||
|
if (!eventId || !targetStep) {
|
||||||
|
throw new Error("eventId et targetStep sont requis");
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = admin.firestore();
|
||||||
|
const eventRef = db.collection("events").doc(eventId);
|
||||||
|
|
||||||
|
await db.runTransaction(async (transaction) => {
|
||||||
|
const eventDoc = await transaction.get(eventRef);
|
||||||
|
if (!eventDoc.exists) {
|
||||||
|
throw new Error("Événement introuvable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = eventDoc.data();
|
||||||
|
const assignedEquipment = event.assignedEquipment || [];
|
||||||
|
|
||||||
|
// Si le retour était complété et qu'on revient en arrière, on doit annuler la restauration des stocks
|
||||||
|
const shouldRevertStocks = event.stocksRestored === true;
|
||||||
|
|
||||||
|
if (shouldRevertStocks) {
|
||||||
|
// Charger tous les équipements uniques de l'événement pour ajuster leur stock
|
||||||
|
const equipmentIds = Array.from(new Set(assignedEquipment.map((eq) => eq.equipmentId).filter(Boolean)));
|
||||||
|
const equipmentDocsMap = {};
|
||||||
|
|
||||||
|
for (const eqId of equipmentIds) {
|
||||||
|
const eqRef = db.collection("equipments").doc(eqId);
|
||||||
|
const eqDoc = await transaction.get(eqRef);
|
||||||
|
if (eqDoc.exists) {
|
||||||
|
equipmentDocsMap[eqId] = eqDoc.data();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const eq of assignedEquipment) {
|
||||||
|
const eqId = eq.equipmentId;
|
||||||
|
const equipmentData = equipmentDocsMap[eqId];
|
||||||
|
if (!equipmentData) continue;
|
||||||
|
|
||||||
|
const hasQuantity = equipmentData.hasQuantity === true ||
|
||||||
|
equipmentData.category === "CABLE" ||
|
||||||
|
equipmentData.category === "CONSUMABLE";
|
||||||
|
|
||||||
|
if (hasQuantity) {
|
||||||
|
// C'est un consommable, on doit déduire la quantité qui avait été restaurée
|
||||||
|
const qtyAtRet = Number(eq.quantityAtReturn) || 0;
|
||||||
|
if (qtyAtRet > 0) {
|
||||||
|
const eqRef = db.collection("equipments").doc(eqId);
|
||||||
|
const currentAvailable = Number(equipmentData.availableQuantity) || 0;
|
||||||
|
// S'assurer de ne pas descendre en dessous de 0 (ou autoriser le négatif si stock virtuel)
|
||||||
|
const newAvailable = Math.max(0, currentAvailable - qtyAtRet);
|
||||||
|
transaction.update(eqRef, {
|
||||||
|
availableQuantity: newAvailable,
|
||||||
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
logger.info(`[rollbackEventStep] Annulé la restauration de ${qtyAtRet} pour ${eqId}. Ancien stock: ${currentAvailable}, Nouveau stock: ${newAvailable}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Préparer les nouvelles valeurs des étapes
|
||||||
|
let prepStatus = event.preparationStatus;
|
||||||
|
let loadStatus = event.loadingStatus;
|
||||||
|
let unloadStatus = event.unloadingStatus;
|
||||||
|
let retStatus = event.returnStatus;
|
||||||
|
|
||||||
|
if (targetStep === 'PREPARATION') {
|
||||||
|
prepStatus = 'IN_PROGRESS';
|
||||||
|
loadStatus = 'NOT_STARTED';
|
||||||
|
unloadStatus = 'NOT_STARTED';
|
||||||
|
retStatus = 'NOT_STARTED';
|
||||||
|
} else if (targetStep === 'LOADING') {
|
||||||
|
loadStatus = 'IN_PROGRESS';
|
||||||
|
unloadStatus = 'NOT_STARTED';
|
||||||
|
retStatus = 'NOT_STARTED';
|
||||||
|
} else if (targetStep === 'UNLOADING') {
|
||||||
|
unloadStatus = 'IN_PROGRESS';
|
||||||
|
retStatus = 'NOT_STARTED';
|
||||||
|
} else if (targetStep === 'RETURN') {
|
||||||
|
retStatus = 'IN_PROGRESS';
|
||||||
|
} else {
|
||||||
|
throw new Error("targetStep invalide. Doit être PREPARATION, LOADING, UNLOADING ou RETURN");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nettoyer les champs de validation des équipements pour les étapes annulées
|
||||||
|
const updatedEquipment = assignedEquipment.map((eq) => {
|
||||||
|
let isPrepared = eq.isPrepared;
|
||||||
|
let isMissingAtPreparation = eq.isMissingAtPreparation;
|
||||||
|
let quantityAtPreparation = eq.quantityAtPreparation;
|
||||||
|
|
||||||
|
let isLoaded = eq.isLoaded;
|
||||||
|
let isMissingAtLoading = eq.isMissingAtLoading;
|
||||||
|
let quantityAtLoading = eq.quantityAtLoading;
|
||||||
|
|
||||||
|
let isUnloaded = eq.isUnloaded;
|
||||||
|
let isMissingAtUnloading = eq.isMissingAtUnloading;
|
||||||
|
let quantityAtUnloading = eq.quantityAtUnloading;
|
||||||
|
|
||||||
|
let isReturned = eq.isReturned;
|
||||||
|
let isMissingAtReturn = eq.isMissingAtReturn;
|
||||||
|
let quantityAtReturn = eq.quantityAtReturn;
|
||||||
|
|
||||||
|
if (targetStep === 'PREPARATION') {
|
||||||
|
isLoaded = false;
|
||||||
|
isMissingAtLoading = false;
|
||||||
|
quantityAtLoading = null;
|
||||||
|
isUnloaded = false;
|
||||||
|
isMissingAtUnloading = false;
|
||||||
|
quantityAtUnloading = null;
|
||||||
|
isReturned = false;
|
||||||
|
isMissingAtReturn = false;
|
||||||
|
quantityAtReturn = null;
|
||||||
|
} else if (targetStep === 'LOADING') {
|
||||||
|
isUnloaded = false;
|
||||||
|
isMissingAtUnloading = false;
|
||||||
|
quantityAtUnloading = null;
|
||||||
|
isReturned = false;
|
||||||
|
isMissingAtReturn = false;
|
||||||
|
quantityAtReturn = null;
|
||||||
|
} else if (targetStep === 'UNLOADING') {
|
||||||
|
isReturned = false;
|
||||||
|
isMissingAtReturn = false;
|
||||||
|
quantityAtReturn = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...eq,
|
||||||
|
isPrepared,
|
||||||
|
isMissingAtPreparation,
|
||||||
|
quantityAtPreparation,
|
||||||
|
isLoaded,
|
||||||
|
isMissingAtLoading,
|
||||||
|
quantityAtLoading,
|
||||||
|
isUnloaded,
|
||||||
|
isMissingAtUnloading,
|
||||||
|
quantityAtUnloading,
|
||||||
|
isReturned,
|
||||||
|
isMissingAtReturn,
|
||||||
|
quantityAtReturn,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mettre à jour le document de l'événement
|
||||||
|
transaction.update(eventRef, {
|
||||||
|
preparationStatus: prepStatus,
|
||||||
|
loadingStatus: loadStatus,
|
||||||
|
unloadingStatus: unloadStatus,
|
||||||
|
returnStatus: retStatus,
|
||||||
|
assignedEquipment: updatedEquipment,
|
||||||
|
stocksRestored: false,
|
||||||
|
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(`[rollbackEventStep] Événement ${eventId} réinitialisé avec succès à l'étape ${targetStep}`);
|
||||||
|
return {success: true};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("[rollbackEventStep] Erreur:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.rollbackEventStep = onCall({
|
||||||
|
cors: true,
|
||||||
|
region: "europe-west9",
|
||||||
|
}, handler);
|
||||||
|
|
||||||
|
exports.handler = handler;
|
||||||
@@ -10,10 +10,7 @@ const {getSmtpConfig, EMAIL_CONFIG} = require("./utils/emailConfig");
|
|||||||
* Envoie un email d'alerte à un utilisateur
|
* Envoie un email d'alerte à un utilisateur
|
||||||
* Appelé par le client Dart via callable function
|
* Appelé par le client Dart via callable function
|
||||||
*/
|
*/
|
||||||
exports.sendAlertEmail = onCall({
|
const handler = async (request) => {
|
||||||
region: "europe-west9",
|
|
||||||
cors: true,
|
|
||||||
}, async (request) => {
|
|
||||||
// Vérifier l'authentification
|
// Vérifier l'authentification
|
||||||
if (!request.auth) {
|
if (!request.auth) {
|
||||||
throw new Error("L'utilisateur doit être authentifié");
|
throw new Error("L'utilisateur doit être authentifié");
|
||||||
@@ -75,7 +72,7 @@ exports.sendAlertEmail = onCall({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Configurer le transporteur SMTP
|
// Configurer le transporteur SMTP
|
||||||
const transporter = nodemailer.createTransporter(getSmtpConfig());
|
const transporter = nodemailer.createTransport(getSmtpConfig());
|
||||||
|
|
||||||
// Envoyer l'email
|
// Envoyer l'email
|
||||||
const info = await transporter.sendMail({
|
const info = await transporter.sendMail({
|
||||||
@@ -263,3 +260,10 @@ async function renderTemplate(templateName, data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
exports.sendAlertEmail = onCall({
|
||||||
|
region: "europe-west9",
|
||||||
|
cors: true,
|
||||||
|
}, handler);
|
||||||
|
|
||||||
|
exports.handler = handler;
|
||||||
|
|
||||||
|
|||||||
@@ -470,7 +470,7 @@ exports.findAlternativeEquipment = async (req, res) => {
|
|||||||
const alternatives = [];
|
const alternatives = [];
|
||||||
equipmentsSnapshot.docs.forEach((doc) => {
|
equipmentsSnapshot.docs.forEach((doc) => {
|
||||||
const data = doc.data();
|
const data = doc.data();
|
||||||
if (!conflictingEquipmentIds.has(doc.id) && data.status === "available") {
|
if (!conflictingEquipmentIds.has(doc.id) && data.status === "AVAILABLE") {
|
||||||
alternatives.push({
|
alternatives.push({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
...helpers.serializeTimestamps(data, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
|
...helpers.serializeTimestamps(data, ["purchaseDate", "nextMaintenanceDate", "lastMaintenanceDate", "createdAt", "updatedAt"]),
|
||||||
@@ -560,15 +560,15 @@ exports.calculateEquipmentStatuses = async (req, res) => {
|
|||||||
let calculatedStatus = equipmentData.status;
|
let calculatedStatus = equipmentData.status;
|
||||||
|
|
||||||
// Si l'équipement est perdu ou HS, garder ce statut
|
// Si l'équipement est perdu ou HS, garder ce statut
|
||||||
if (equipmentData.status === "lost" || equipmentData.status === "outOfService") {
|
if (equipmentData.status === "LOST" || equipmentData.status === "OUT_OF_SERVICE") {
|
||||||
calculatedStatus = equipmentData.status;
|
calculatedStatus = equipmentData.status;
|
||||||
} else if (equipmentIdsInUse.has(equipmentId)) {
|
} else if (equipmentIdsInUse.has(equipmentId)) {
|
||||||
calculatedStatus = "inUse";
|
calculatedStatus = "IN_USE";
|
||||||
} else if (equipmentData.status === "maintenance" ||
|
} else if (equipmentData.status === "MAINTENANCE" ||
|
||||||
equipmentData.status === "rented") {
|
equipmentData.status === "RENTED") {
|
||||||
calculatedStatus = equipmentData.status;
|
calculatedStatus = equipmentData.status;
|
||||||
} else {
|
} else {
|
||||||
calculatedStatus = "available";
|
calculatedStatus = "AVAILABLE";
|
||||||
}
|
}
|
||||||
|
|
||||||
statuses[equipmentId] = calculatedStatus;
|
statuses[equipmentId] = calculatedStatus;
|
||||||
|
|||||||
@@ -651,121 +651,7 @@ exports.getEventWithDetails = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper: Mettre à jour le statut d'un équipement
|
|
||||||
async function updateEquipmentStatus(equipmentId, status) {
|
|
||||||
try {
|
|
||||||
const doc = await db.collection("equipments").doc(equipmentId).get();
|
|
||||||
if (!doc.exists) {
|
|
||||||
logger.warn(`Equipment ${equipmentId} does not exist, skipping status update`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.collection("equipments").doc(equipmentId).update({
|
|
||||||
status: status,
|
|
||||||
updatedAt: admin.firestore.Timestamp.now(),
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Error updating equipment status for ${equipmentId}:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Valider un équipement individuel en préparation
|
|
||||||
exports.validateEquipmentPreparation = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const decodedToken = await auth.authenticateUser(req);
|
|
||||||
const canManage = await auth.hasPermission(decodedToken.uid, "manage_events");
|
|
||||||
if (!canManage) {
|
|
||||||
res.status(403).json({error: "Forbidden: Requires manage_events permission"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {eventId, equipmentId} = req.body.data;
|
|
||||||
if (!eventId || !equipmentId) {
|
|
||||||
res.status(400).json({error: "eventId and equipmentId are required"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventDoc = await db.collection("events").doc(eventId).get();
|
|
||||||
if (!eventDoc.exists) {
|
|
||||||
res.status(404).json({error: "Event not found"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventData = eventDoc.data();
|
|
||||||
const assignedEquipment = eventData.assignedEquipment || [];
|
|
||||||
|
|
||||||
// Mettre à jour le statut de l'équipement
|
|
||||||
const updatedEquipment = assignedEquipment.map((eq) => {
|
|
||||||
if (eq.equipmentId === equipmentId) {
|
|
||||||
return {...eq, isPrepared: true};
|
|
||||||
}
|
|
||||||
return eq;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Vérifier si tous sont préparés
|
|
||||||
const allPrepared = updatedEquipment.every((eq) => eq.isPrepared);
|
|
||||||
|
|
||||||
const updateData = {
|
|
||||||
assignedEquipment: updatedEquipment,
|
|
||||||
preparationStatus: allPrepared ? "completed" : "inProgress",
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.collection("events").doc(eventId).update(updateData);
|
|
||||||
|
|
||||||
res.status(200).json({success: true, allPrepared});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error validating equipment preparation:", error);
|
|
||||||
res.status(500).json({error: error.message});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Valider tous les équipements en préparation
|
|
||||||
exports.validateAllPreparation = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const decodedToken = await auth.authenticateUser(req);
|
|
||||||
const canManage = await auth.hasPermission(decodedToken.uid, "manage_events");
|
|
||||||
if (!canManage) {
|
|
||||||
res.status(403).json({error: "Forbidden: Requires manage_events permission"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {eventId} = req.body.data;
|
|
||||||
if (!eventId) {
|
|
||||||
res.status(400).json({error: "eventId is required"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventDoc = await db.collection("events").doc(eventId).get();
|
|
||||||
if (!eventDoc.exists) {
|
|
||||||
res.status(404).json({error: "Event not found"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventData = eventDoc.data();
|
|
||||||
const assignedEquipment = eventData.assignedEquipment || [];
|
|
||||||
|
|
||||||
// Marquer tous comme préparés
|
|
||||||
const updatedEquipment = assignedEquipment.map((eq) => ({
|
|
||||||
...eq,
|
|
||||||
isPrepared: true,
|
|
||||||
}));
|
|
||||||
|
|
||||||
await db.collection("events").doc(eventId).update({
|
|
||||||
assignedEquipment: updatedEquipment,
|
|
||||||
preparationStatus: "completed",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mettre à jour le statut des équipements à "inUse"
|
|
||||||
for (const equipment of assignedEquipment) {
|
|
||||||
await updateEquipmentStatus(equipment.equipmentId, "inUse");
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({success: true});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error validating all preparation:", error);
|
|
||||||
res.status(500).json({error: error.message});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Valider un équipement individuel pour le chargement
|
// Valider un équipement individuel pour le chargement
|
||||||
exports.validateEquipmentLoading = async (req, res) => {
|
exports.validateEquipmentLoading = async (req, res) => {
|
||||||
@@ -947,136 +833,4 @@ exports.validateAllUnloading = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Valider un équipement individuel pour le retour
|
|
||||||
exports.validateEquipmentReturn = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const decodedToken = await auth.authenticateUser(req);
|
|
||||||
const canManage = await auth.hasPermission(decodedToken.uid, "manage_events");
|
|
||||||
if (!canManage) {
|
|
||||||
res.status(403).json({error: "Forbidden: Requires manage_events permission"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {eventId, equipmentId, returnedQuantity} = req.body.data;
|
|
||||||
if (!eventId || !equipmentId) {
|
|
||||||
res.status(400).json({error: "eventId and equipmentId are required"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventDoc = await db.collection("events").doc(eventId).get();
|
|
||||||
if (!eventDoc.exists) {
|
|
||||||
res.status(404).json({error: "Event not found"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventData = eventDoc.data();
|
|
||||||
const assignedEquipment = eventData.assignedEquipment || [];
|
|
||||||
|
|
||||||
const updatedEquipment = assignedEquipment.map((eq) => {
|
|
||||||
if (eq.equipmentId === equipmentId) {
|
|
||||||
return {
|
|
||||||
...eq,
|
|
||||||
isReturned: true,
|
|
||||||
returnedQuantity: returnedQuantity !== undefined ? returnedQuantity : eq.returnedQuantity,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return eq;
|
|
||||||
});
|
|
||||||
|
|
||||||
const allReturned = updatedEquipment.every((eq) => eq.isReturned);
|
|
||||||
|
|
||||||
const updateData = {
|
|
||||||
assignedEquipment: updatedEquipment,
|
|
||||||
returnStatus: allReturned ? "completed" : "inProgress",
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.collection("events").doc(eventId).update(updateData);
|
|
||||||
|
|
||||||
// Mettre à jour le stock si c'est un consommable
|
|
||||||
if (returnedQuantity !== undefined) {
|
|
||||||
const equipmentDoc = await db.collection("equipments").doc(equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
const equipmentData = equipmentDoc.data();
|
|
||||||
if (equipmentData.hasQuantity) {
|
|
||||||
const currentAvailable = equipmentData.availableQuantity || 0;
|
|
||||||
await db.collection("equipments").doc(equipmentId).update({
|
|
||||||
availableQuantity: currentAvailable + returnedQuantity,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({success: true, allReturned});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error validating equipment return:", error);
|
|
||||||
res.status(500).json({error: error.message});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Valider tous les retours
|
|
||||||
exports.validateAllReturn = async (req, res) => {
|
|
||||||
try {
|
|
||||||
const decodedToken = await auth.authenticateUser(req);
|
|
||||||
const canManage = await auth.hasPermission(decodedToken.uid, "manage_events");
|
|
||||||
if (!canManage) {
|
|
||||||
res.status(403).json({error: "Forbidden: Requires manage_events permission"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {eventId, returnedQuantities} = req.body.data;
|
|
||||||
if (!eventId) {
|
|
||||||
res.status(400).json({error: "eventId is required"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventDoc = await db.collection("events").doc(eventId).get();
|
|
||||||
if (!eventDoc.exists) {
|
|
||||||
res.status(404).json({error: "Event not found"});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const eventData = eventDoc.data();
|
|
||||||
const assignedEquipment = eventData.assignedEquipment || [];
|
|
||||||
|
|
||||||
const updatedEquipment = assignedEquipment.map((eq) => {
|
|
||||||
const returnedQty = returnedQuantities?.[eq.equipmentId] || eq.returnedQuantity || eq.quantity;
|
|
||||||
return {
|
|
||||||
...eq,
|
|
||||||
isReturned: true,
|
|
||||||
returnedQuantity: returnedQty,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.collection("events").doc(eventId).update({
|
|
||||||
assignedEquipment: updatedEquipment,
|
|
||||||
returnStatus: "completed",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mettre à jour le statut des équipements à "available" et gérer les stocks
|
|
||||||
for (const equipment of updatedEquipment) {
|
|
||||||
const equipmentDoc = await db.collection("equipments").doc(equipment.equipmentId).get();
|
|
||||||
if (equipmentDoc.exists) {
|
|
||||||
const equipmentData = equipmentDoc.data();
|
|
||||||
|
|
||||||
// Mettre à jour le statut uniquement pour les équipements non quantifiables
|
|
||||||
if (!equipmentData.hasQuantity) {
|
|
||||||
await updateEquipmentStatus(equipment.equipmentId, "available");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restaurer le stock pour les consommables
|
|
||||||
if (equipmentData.hasQuantity && equipment.returnedQuantity) {
|
|
||||||
const currentAvailable = equipmentData.availableQuantity || 0;
|
|
||||||
await db.collection("equipments").doc(equipment.equipmentId).update({
|
|
||||||
availableQuantity: currentAvailable + equipment.returnedQuantity,
|
|
||||||
updatedAt: admin.firestore.Timestamp.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({success: true});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error validating all return:", error);
|
|
||||||
res.status(500).json({error: error.message});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const axios = require('axios');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const csv = require('csv-parser');
|
||||||
|
const polylineLib = require('@mapbox/polyline');
|
||||||
|
const auth = require('../utils/auth');
|
||||||
|
const logger = require('firebase-functions/logger');
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Chargement du CSV des gares de péage (cache mémoire)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
let _tollStations = null;
|
||||||
|
|
||||||
|
function loadTollStations() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (_tollStations) return resolve(_tollStations);
|
||||||
|
const csvPath = path.join(__dirname, '../travel/gares_peage_export.csv');
|
||||||
|
if (!fs.existsSync(csvPath)) {
|
||||||
|
logger.warn('[Travel] CSV not found at ' + csvPath);
|
||||||
|
_tollStations = [];
|
||||||
|
return resolve(_tollStations);
|
||||||
|
}
|
||||||
|
const results = [];
|
||||||
|
fs.createReadStream(csvPath)
|
||||||
|
.pipe(csv())
|
||||||
|
.on('data', (row) => {
|
||||||
|
if (row.id_gare && row.lat && row.lon) {
|
||||||
|
results.push({
|
||||||
|
id: row.id_gare,
|
||||||
|
operatorId: row.id_gare.substring(0, 2),
|
||||||
|
tollId: row.id_gare.substring(2, 5),
|
||||||
|
name: row.nom || '',
|
||||||
|
lat: parseFloat(row.lat),
|
||||||
|
lon: parseFloat(row.lon),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.on('end', () => { _tollStations = results; resolve(results); })
|
||||||
|
.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Ulys — Détection des péages sur un tracé
|
||||||
|
// POST https://api-ulys.azure-api.net/placemark/v2/legs?precision=6&includeLayersIds=GaresPeage
|
||||||
|
// Body = la polyline encodée (string brute, pas de JSON wrapper)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
async function getUlysTollLegs(encodedPolyline) {
|
||||||
|
try {
|
||||||
|
const polylineCoords = polylineLib.decode(encodedPolyline);
|
||||||
|
const ulysUrl = 'https://api-ulys.azure-api.net/placemark/v2/legs?precision=5&includeLayersIds=GaresPeage';
|
||||||
|
let finalPolyline = encodedPolyline;
|
||||||
|
|
||||||
|
// OPTION 1 : Mapbox Route Recreation
|
||||||
|
if (process.env.MAPBOX_API_KEY && polylineCoords.length > 2) {
|
||||||
|
logger.info('[Travel] MAPBOX_API_KEY is present. Recreating route with Mapbox for Ulys precision...');
|
||||||
|
try {
|
||||||
|
// Envoyer uniquement le point de départ et le point d'arrivée
|
||||||
|
// Mapbox s'occupe de recréer l'itinéraire complet de la meilleure façon
|
||||||
|
const waypoints = [polylineCoords[0], polylineCoords[polylineCoords.length - 1]];
|
||||||
|
|
||||||
|
// Mapbox expects longitude,latitude
|
||||||
|
const coordinatesString = waypoints.map(p => `${p[1]},${p[0]}`).join(';');
|
||||||
|
const mapboxUrl = `https://api.mapbox.com/directions/v5/mapbox/driving/${coordinatesString}?geometries=polyline&overview=full&access_token=${process.env.MAPBOX_API_KEY}`;
|
||||||
|
|
||||||
|
const mapboxRes = await axios.get(mapboxUrl);
|
||||||
|
if (mapboxRes.data && mapboxRes.data.routes && mapboxRes.data.routes.length > 0) {
|
||||||
|
finalPolyline = mapboxRes.data.routes[0].geometry;
|
||||||
|
logger.info('[Travel] Mapbox route recreation successful.');
|
||||||
|
}
|
||||||
|
} catch (mapboxErr) {
|
||||||
|
logger.error('[Travel] Mapbox API error:', mapboxErr.response ? mapboxErr.response.data : mapboxErr.message);
|
||||||
|
// Fallback to Google Maps polyline if Mapbox fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Appeler Ulys /legs
|
||||||
|
const res = await axios.post(
|
||||||
|
ulysUrl,
|
||||||
|
JSON.stringify(finalPolyline),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Host': 'api-ulys.azure-api.net'
|
||||||
|
},
|
||||||
|
timeout: 10000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn('[Travel] Ulys /legs failed:', e.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Ulys — Tarif pour un segment (entrée → sortie)
|
||||||
|
// POST https://api-ulys.azure-api.net/tollstation/v1/rate
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
async function getUlysRate(vehicleCategory, passages) {
|
||||||
|
try {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const payload = {
|
||||||
|
vehicleCategory: String(vehicleCategory),
|
||||||
|
paymentOption: 2,
|
||||||
|
tollPassages: passages.map((p) => ({
|
||||||
|
toll: { operatorId: p.operatorId, tollId: p.tollId },
|
||||||
|
passageDate: now,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const body = JSON.stringify(payload);
|
||||||
|
const res = await axios.post(
|
||||||
|
'https://api-ulys.azure-api.net/tollstation/v1/rate',
|
||||||
|
payload,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Content-Length': Buffer.byteLength(body).toString(),
|
||||||
|
'Host': 'api-ulys.azure-api.net',
|
||||||
|
},
|
||||||
|
timeout: 8000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const data = res.data;
|
||||||
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
|
if (passages.length === 2) {
|
||||||
|
// We expect a single closed system response
|
||||||
|
if (data.length === 1 && data[0].entranceToll && data[0].exitToll && data[0].price > 0) {
|
||||||
|
return data[0].price;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} else if (passages.length === 1) {
|
||||||
|
if (data.length === 1 && data[0].price > 0) {
|
||||||
|
return data[0].price;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// Calcul du total de péage via Ulys /legs puis /rate
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
async function calculateTollCost(encodedPolyline, vehicleCategory) {
|
||||||
|
try {
|
||||||
|
// 1. Demander à Ulys les gares sur le tracé
|
||||||
|
const legsData = await getUlysTollLegs(encodedPolyline);
|
||||||
|
const features = Array.isArray(legsData) ? legsData : (legsData && legsData.features ? legsData.features : []);
|
||||||
|
|
||||||
|
if (features && features.length > 0) {
|
||||||
|
// Extraire les gares dans l'ordre du tracé
|
||||||
|
const tollGates = [];
|
||||||
|
for (const feature of features) {
|
||||||
|
const props = feature.properties || feature.Placemark || feature.placemark || {};
|
||||||
|
|
||||||
|
// La réponse Ulys peut utiliser différents noms de champs
|
||||||
|
// On cherche l'identifiant de la gare dans tous les champs connus
|
||||||
|
let id =
|
||||||
|
props.id_gare ||
|
||||||
|
props.idGare ||
|
||||||
|
props.id ||
|
||||||
|
props.gareId ||
|
||||||
|
props.gare_id ||
|
||||||
|
props.tollStationId;
|
||||||
|
|
||||||
|
if (!id && props.Code) {
|
||||||
|
id = props.Code.split('_')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!id) continue;
|
||||||
|
const idStr = String(id);
|
||||||
|
if (idStr.length < 5) continue;
|
||||||
|
|
||||||
|
tollGates.push({
|
||||||
|
id: idStr,
|
||||||
|
operatorId: idStr.substring(0, 2),
|
||||||
|
tollId: idStr.substring(2, 5),
|
||||||
|
name: props.nom || props.name || props.label || idStr,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`[Travel] Ulys /legs found ${tollGates.length} toll gates`);
|
||||||
|
if (tollGates.length === 0) return 0;
|
||||||
|
|
||||||
|
// Greedy: trouver les segments fermés + barrières ouvertes
|
||||||
|
return await _computeTollFromGates(tollGates, vehicleCategory);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback : pas de résultat Ulys /legs, retourner 0
|
||||||
|
logger.info('[Travel] Ulys /legs returned no toll gates for this route');
|
||||||
|
return 0;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('[Travel] calculateTollCost error:', e);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _computeTollFromGates(gates, vehicleCategory) {
|
||||||
|
let total = 0;
|
||||||
|
let i = 0;
|
||||||
|
while (i < gates.length) {
|
||||||
|
let found = false;
|
||||||
|
// Essayer le segment fermé le plus long possible (greedy backward)
|
||||||
|
for (let j = gates.length - 1; j > i; j--) {
|
||||||
|
const price = await getUlysRate(vehicleCategory, [gates[i], gates[j]]);
|
||||||
|
if (price !== null && price > 0) {
|
||||||
|
total += price;
|
||||||
|
i = j;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
// Barrière ouverte : tarif unitaire
|
||||||
|
const price = await getUlysRate(vehicleCategory, [gates[i]]);
|
||||||
|
if (price !== null && price > 0 && price < 20) {
|
||||||
|
total += price;
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// EXPORT: Google Maps Autocomplete (proxy CORS)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
exports.googleMapsAutocomplete = async (req, res) => {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.status(204).send('');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
const body = req.body.data || req.body;
|
||||||
|
const query = body.query || req.query.query;
|
||||||
|
if (!query) return res.status(400).json({ error: 'query is required' });
|
||||||
|
|
||||||
|
const apiKey = process.env.API_MAPS;
|
||||||
|
if (!apiKey) return res.status(500).json({ error: 'API_MAPS not configured in .env' });
|
||||||
|
|
||||||
|
const url = new URL('https://maps.googleapis.com/maps/api/place/autocomplete/json');
|
||||||
|
url.searchParams.set('input', query);
|
||||||
|
url.searchParams.set('key', apiKey);
|
||||||
|
url.searchParams.set('language', 'fr');
|
||||||
|
url.searchParams.set('components', 'country:fr');
|
||||||
|
url.searchParams.set('types', 'address');
|
||||||
|
|
||||||
|
const gRes = await axios.get(url.toString(), { timeout: 5000 });
|
||||||
|
return res.status(200).json(gRes.data);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('[Travel] googleMapsAutocomplete error:', e.message);
|
||||||
|
return res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
// EXPORT: Google Maps Compute Route (2 itinéraires + péages Ulys)
|
||||||
|
// ─────────────────────────────────────────────
|
||||||
|
exports.googleMapsComputeRoute = async (req, res) => {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return res.status(204).send('');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await auth.authenticateUser(req);
|
||||||
|
|
||||||
|
const body = req.body.data || req.body;
|
||||||
|
const { origin, destination, vehicleTollCategory = 2 } = body;
|
||||||
|
|
||||||
|
if (!origin || !destination) {
|
||||||
|
return res.status(400).json({ error: 'origin and destination are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.API_MAPS;
|
||||||
|
if (!apiKey) return res.status(500).json({ error: 'API_MAPS not configured in .env' });
|
||||||
|
|
||||||
|
const routesUrl = 'https://routes.googleapis.com/directions/v2:computeRoutes';
|
||||||
|
const fieldMask = [
|
||||||
|
'routes.distanceMeters',
|
||||||
|
'routes.duration',
|
||||||
|
'routes.polyline.encodedPolyline',
|
||||||
|
'routes.travelAdvisory.tollInfo',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const commonPayload = {
|
||||||
|
travelMode: 'DRIVE',
|
||||||
|
routingPreference: 'TRAFFIC_AWARE',
|
||||||
|
origin: { address: origin },
|
||||||
|
destination: { address: destination },
|
||||||
|
};
|
||||||
|
|
||||||
|
const [resToll, resNoToll] = await Promise.all([
|
||||||
|
axios.post(routesUrl, { ...commonPayload, routeModifiers: { avoidTolls: false } }, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Goog-Api-Key': apiKey,
|
||||||
|
'X-Goog-FieldMask': fieldMask,
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
}),
|
||||||
|
axios.post(routesUrl, { ...commonPayload, routeModifiers: { avoidTolls: true } }, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Goog-Api-Key': apiKey,
|
||||||
|
'X-Goog-FieldMask': fieldMask,
|
||||||
|
},
|
||||||
|
timeout: 15000,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const routes = [];
|
||||||
|
|
||||||
|
console.log("resToll.data.routes length:", resToll.data.routes ? resToll.data.routes.length : 0);
|
||||||
|
if (!resToll.data.routes) console.log("resToll.data:", JSON.stringify(resToll.data, null, 2));
|
||||||
|
|
||||||
|
// --- Route avec péage ---
|
||||||
|
if (resToll.data.routes && resToll.data.routes.length > 0) {
|
||||||
|
const r = resToll.data.routes[0];
|
||||||
|
const poly = r.polyline?.encodedPolyline || '';
|
||||||
|
let tollCost = 0;
|
||||||
|
if (poly) {
|
||||||
|
tollCost = await calculateTollCost(poly, vehicleTollCategory);
|
||||||
|
}
|
||||||
|
routes.push({
|
||||||
|
routeType: 'TOLL',
|
||||||
|
distanceMeters: r.distanceMeters || 0,
|
||||||
|
durationSeconds: _parseDuration(r.duration),
|
||||||
|
encodedPolyline: poly,
|
||||||
|
tollCost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Route sans péage ---
|
||||||
|
if (resNoToll.data.routes && resNoToll.data.routes.length > 0) {
|
||||||
|
const r = resNoToll.data.routes[0];
|
||||||
|
const poly = r.polyline?.encodedPolyline || '';
|
||||||
|
|
||||||
|
// N'ajouter que si différente de la route avec péage
|
||||||
|
const isDifferent = routes.length === 0 ||
|
||||||
|
r.distanceMeters !== routes[0].distanceMeters ||
|
||||||
|
Math.abs(_parseDuration(r.duration) - routes[0].durationSeconds) > 60;
|
||||||
|
|
||||||
|
if (isDifferent) {
|
||||||
|
routes.push({
|
||||||
|
routeType: 'TOLL_FREE',
|
||||||
|
distanceMeters: r.distanceMeters || 0,
|
||||||
|
durationSeconds: _parseDuration(r.duration),
|
||||||
|
encodedPolyline: poly,
|
||||||
|
tollCost: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({ routes });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('[Travel] googleMapsComputeRoute error:', e.message, e.response?.data);
|
||||||
|
return res.status(500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function _parseDuration(durationStr) {
|
||||||
|
if (!durationStr) return 0;
|
||||||
|
if (typeof durationStr === 'number') return durationStr;
|
||||||
|
// Format: "1234s"
|
||||||
|
const match = String(durationStr).match(/^(\d+)s?$/);
|
||||||
|
return match ? parseInt(match[1]) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.getUlysTollLegs = getUlysTollLegs;
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,643 @@
|
|||||||
|
id_gare,nom,lat,lon
|
||||||
|
09242,BEAUPONT,46.44094,5.26993
|
||||||
|
10157,BELLEGARDE,46.11602,5.79506
|
||||||
|
09147,SYLANS,46.15853,5.6484
|
||||||
|
09146,ST MARTIN-DU-FRESNE,46.13243,5.54164
|
||||||
|
09156,LA CROIX-CHALON,46.17772,5.5589
|
||||||
|
09145,BOURG SUD,46.14563,5.28633
|
||||||
|
09144,VIRIAT,46.23576,5.27355
|
||||||
|
09149,SAINT GENIS,46.27197,5.01576
|
||||||
|
09143,BOURG NORD,46.2599,5.16695
|
||||||
|
09142,REPLONGES,46.30076,4.89584
|
||||||
|
09141,FEILLENS,46.32883,4.88241
|
||||||
|
09150,BEYNOST,45.82045,4.99411
|
||||||
|
09153,LA BOISSE,45.82704,5.03146
|
||||||
|
09253,LA COTIERE,45.83409,5.0324
|
||||||
|
09251,LA BOISSE - MONTLUEL,45.8407,5.04702
|
||||||
|
09422,MIONNAY,45.883,4.89889
|
||||||
|
09151,BALAN,45.85092,5.09938
|
||||||
|
09152,PEROUGES,45.86086,5.1813
|
||||||
|
09154,AMBERIEU,45.97765,5.3127
|
||||||
|
09155,PONT D AIN,46.04812,5.32788
|
||||||
|
09443,CROTTET,46.28678,4.87001
|
||||||
|
07144,CHATEAU-THIERRY,49.07832,3.39881
|
||||||
|
07141,MONTREUIL,49.01321,3.15531
|
||||||
|
07146,DORMANS,49.15379,3.71689
|
||||||
|
07032,ST QUENTIN SUD,49.81227,3.29479
|
||||||
|
07031,ST QUENTIN NORD,49.86139,3.24072
|
||||||
|
07033,LA FERE,49.6872,3.46533
|
||||||
|
07034,LAON,49.6078,3.66102
|
||||||
|
07136,REIMS,49.32216,3.98417
|
||||||
|
07135,GUIGNICOURT,49.42699,3.92801
|
||||||
|
09062,FORET DE TRONCAIS,46.50647,2.63033
|
||||||
|
09064,MONTMARAULT,46.32764,2.96645
|
||||||
|
06060,MANOSQUE,43.80781,5.81289
|
||||||
|
06059,ST PAUL LES DURANCE,43.70249,5.7346
|
||||||
|
06061,LA BRILLANNE,43.92395,5.89373
|
||||||
|
06063,PEYRUIS,44.03919,5.96354
|
||||||
|
06065,AUBIGNOSC,44.12443,5.98428
|
||||||
|
06064,AUBIGNOSC OUEST,44.12513,5.97854
|
||||||
|
06067,SISTERON SUD,44.17168,5.95542
|
||||||
|
06068,SISTERON NORD,44.2236,5.91579
|
||||||
|
06014,ANTIBES OUEST,43.6028,7.07248
|
||||||
|
06024,SOPHIA,43.60275,7.08124
|
||||||
|
06022,ANTIBES PV NORD,43.60463,7.06645
|
||||||
|
06023,ANTIBES EST SORTIE,43.60421,7.08636
|
||||||
|
06017,CAGNES OUEST SUD,43.64321,7.13404
|
||||||
|
06016,CAGNES EST,43.65933,7.14863
|
||||||
|
06015,CAGNES OUEST,43.64804,7.13906
|
||||||
|
06013,ANTIBES EST,43.60301,7.08397
|
||||||
|
06019,ST ISIDORE ECH OUEST,43.70408,7.19005
|
||||||
|
06025,MONACO,43.74513,7.37264
|
||||||
|
06026,LA TURBIE ECH.,43.74346,7.37835
|
||||||
|
04358,PAMIERS,43.15415,1.61603
|
||||||
|
04357,MAZERES SAVERDUN,43.23448,1.63595
|
||||||
|
09173,SAINT THIBAULT,48.22341,4.12962
|
||||||
|
09174,TORVILLIERS,48.2844,3.96602
|
||||||
|
09183,THENNELIERES,48.28933,4.16302
|
||||||
|
09172,MAGNANT,48.17907,4.44056
|
||||||
|
09171,VILLE SOUS LAFERTE,48.11496,4.78555
|
||||||
|
09170,CHAUMONT-SEMOUTIERS,48.04283,5.05935
|
||||||
|
07186,VALLEE DE L'AUBE,48.52551,4.18556
|
||||||
|
07185,CHARMONT-S/BARBUISE,48.41039,4.14267
|
||||||
|
04339,NARBONNE-SUD,43.16443,2.99012
|
||||||
|
04338,NARBONNE-EST,43.17904,3.03496
|
||||||
|
04340,SIGEAN,43.03314,2.95619
|
||||||
|
04341,LEUCATE,42.93912,2.97304
|
||||||
|
04350,CASTELNAUDARY,43.29079,1.94671
|
||||||
|
04349,BRAM,43.23897,2.10001
|
||||||
|
04348,CARCASSONNE-O,43.19993,2.31004
|
||||||
|
04347,CARCASSONNE-E,43.20205,2.4182
|
||||||
|
04346,LEZIGNAN,43.17187,2.74107
|
||||||
|
04213,CAVAILLON,43.81988,5.02777
|
||||||
|
04214,SENAS,43.74201,5.08971
|
||||||
|
04215,SALON NORD,43.65943,5.10259
|
||||||
|
04279,A8: AIX-EN-PROVENCE,43.55106,5.23769
|
||||||
|
04278,COUDOUX,43.55294,5.23863
|
||||||
|
06001,CANET DE MEYREUIL,43.49465,5.52496
|
||||||
|
06035,CASSIS,43.22517,5.58151
|
||||||
|
06034,LA CIOTAT ECH.,43.20208,5.59476
|
||||||
|
06054,PERTUIS SUD,43.66218,5.49758
|
||||||
|
06038,PAS DE TRETS,43.3863,5.6018
|
||||||
|
06036,PONT DE L ETOILE,43.32437,5.59801
|
||||||
|
04267,GRANS,43.62304,5.08354
|
||||||
|
04266,SALON OUEST,43.6364,5.02274
|
||||||
|
04219,SALON SUD,43.62598,5.1015
|
||||||
|
08612,DOZULE FL ECH,49.22992,-0.08247
|
||||||
|
08621,TROARN FL,49.1778,-0.20296
|
||||||
|
08631,CAGNY FL,49.16833,-0.24512
|
||||||
|
04536,ST-JEAN D'ANGELY,45.96286,-0.54589
|
||||||
|
04537,SAINTES,45.75224,-0.66399
|
||||||
|
04538,PONS,45.5745,-0.59558
|
||||||
|
04540,SAINT-AUBIN,45.25434,-0.5478
|
||||||
|
04539,MIRAMBEAU,45.37766,-0.57955
|
||||||
|
04545,TONNAY-CHARENTE,45.95515,-0.88097
|
||||||
|
05056,VIERZON-EST,47.2118,2.11835
|
||||||
|
05055,VIERZON-NORD,47.24397,2.06412
|
||||||
|
05057,BOURGES,47.04492,2.34171
|
||||||
|
09061,ST AMAND-MONTROND,46.722,2.45831
|
||||||
|
04118,MANSAC,45.15247,1.37821
|
||||||
|
04123,TULLE NORD,45.32901,1.76396
|
||||||
|
04124,TULLE EST,45.31812,1.85046
|
||||||
|
04125,EGLETONS,45.40739,2.02119
|
||||||
|
04126,USSEL OUEST,45.51246,2.25888
|
||||||
|
04127,USSEL EST,45.58897,2.41459
|
||||||
|
09111,BIERRE-LES-SEMUR,47.43663,4.30944
|
||||||
|
09110,AVALLON,47.50932,3.99198
|
||||||
|
09114,BEAUNE SUD,47.0024,4.85476
|
||||||
|
09129,BEAUNE NORD,47.04201,4.85075
|
||||||
|
09124,NUITS-ST-GEORGES,47.13099,4.96743
|
||||||
|
09160,DIJON-ARC S/TILLE,47.34461,5.15933
|
||||||
|
09126,DIJON-CRIMOLOIS,47.27707,5.13804
|
||||||
|
09161,TIL CHATEL,47.53859,5.19472
|
||||||
|
09139,SEURRE,47.02937,5.17033
|
||||||
|
09130,SOIRANS,47.20534,5.305
|
||||||
|
04104,MONTPON,44.98597,0.15622
|
||||||
|
04105,MUSSIDAN SUD,45.01073,0.37757
|
||||||
|
04107,MUSSIDAN EST,45.06515,0.43001
|
||||||
|
04106,MUSSIDAN BARRIERE,45.06529,0.42986
|
||||||
|
04114,THENON EST,45.15161,1.16785
|
||||||
|
04112,THENON,45.15124,1.16747
|
||||||
|
04116,LA BACHELLERIE SUD,45.14813,1.17472
|
||||||
|
09128,L ISLE-S/LE-DOUBS,47.41251,6.58553
|
||||||
|
09133,BAUME-LES-DAMES,47.37102,6.37965
|
||||||
|
09131,BESANCON EST,47.33224,6.1513
|
||||||
|
09134,BESANCON NORD,47.27603,5.98798
|
||||||
|
09135,BESANCON OUEST,47.23478,5.89813
|
||||||
|
04204,VALENCE-N,44.97018,4.88768
|
||||||
|
04203,TAIN,45.06844,4.8685
|
||||||
|
04205,VALENCE-S,44.90511,4.88009
|
||||||
|
04206,LORIOL,44.75681,4.79136
|
||||||
|
04207,MONTELIMAR-N,44.66896,4.79483
|
||||||
|
04208,MONTELIMAR-S,44.48123,4.76353
|
||||||
|
03092,LA BAUME D'HOSTUN,45.06467,5.20806
|
||||||
|
03091,CHATUZANGE BARRIERE,45.02608,5.0969
|
||||||
|
08532,HEUDEBOUVILLE FL ECH PARIS,49.19492,1.23192
|
||||||
|
08551,BOURG ACHARD FL ECH,49.36642,0.81813
|
||||||
|
08571,BOURNEVILLE FL ECH,49.37708,0.62672
|
||||||
|
08581,TOUTAINVILLE FL ECH,49.36423,0.46766
|
||||||
|
08592,BEUZEVILLE FL ECH PARIS,49.33757,0.36939
|
||||||
|
12030,BROGLIE/ORBEC SENS 2,49.03165,0.44483
|
||||||
|
12031,BROGLIE/ORBEC SENS 1,49.03719,0.4416
|
||||||
|
12040,BERNAY,49.13768,0.57809
|
||||||
|
12050,BRIONNE,49.24206,0.77249
|
||||||
|
05306,ARTENAY,48.0843,1.8557
|
||||||
|
05304,ALLAINES,48.20533,1.84601
|
||||||
|
05605,CHARTRES-EST,48.45632,1.53784
|
||||||
|
05607,THIVARS,48.36159,1.44648
|
||||||
|
05609,LUIGNY,48.23459,1.03313
|
||||||
|
05608,ILLIERS-COMBRAY,48.29444,1.27031
|
||||||
|
04222,REMOULINS,43.93725,4.5982
|
||||||
|
04221,ROQUEMAURE,44.02563,4.73546
|
||||||
|
04224,NIMES-O,43.81363,4.34275
|
||||||
|
04223,NIMES-E,43.85615,4.42069
|
||||||
|
04275,LUNEL,43.70284,4.11962
|
||||||
|
04225,GALLARGUES,43.7229,4.18087
|
||||||
|
04260,NIMES CENTRE,43.80757,4.37448
|
||||||
|
04261,GARONS,43.76132,4.42753
|
||||||
|
04356,NAILLOUX,43.38237,1.61688
|
||||||
|
04352,MONTGISCARD,43.46088,1.58831
|
||||||
|
04351,VILLEFRANCHE,43.39858,1.6962
|
||||||
|
04458,SAINT-JORY,43.71824,1.39796
|
||||||
|
04455,EUROCENTRE,43.76503,1.38044
|
||||||
|
04646,MONTREJEAU,43.10017,0.59546
|
||||||
|
04644,LANNEMEZAN,43.09782,0.39023
|
||||||
|
04648,ST GAUDENS,43.11568,0.7566
|
||||||
|
04650,LESTELLE,43.12029,0.89548
|
||||||
|
04651,LESTELLE ST MARTORY,43.11773,0.89297
|
||||||
|
04470,L'UNION,43.64518,1.49797
|
||||||
|
04467,PODENSAC,44.60754,-0.36681
|
||||||
|
04466,LANGON,44.54422,-0.26201
|
||||||
|
04465,LA REOLE,44.51137,-0.04954
|
||||||
|
04464,MARMANDE,44.43479,0.13293
|
||||||
|
04101,ARVEYRES,44.88514,-0.26839
|
||||||
|
04102,LIBOURNE NORD,44.95703,-0.24522
|
||||||
|
04103,COUTRAS,45.012,-0.09983
|
||||||
|
04276,BAILLARGUES,43.67053,4.01301
|
||||||
|
04333,SETE,43.47755,3.68525
|
||||||
|
04331,MONTPELLIER ST-JEAN,43.56153,3.8305
|
||||||
|
04335,AGDE-PEZENAS,43.37414,3.41816
|
||||||
|
04334,BEZIERS CABRIALS,43.3433,3.28907
|
||||||
|
04337,BEZIERS-OUEST,43.30445,3.2191
|
||||||
|
05318,AMBOISE CH.RENAULT,47.54281,0.98489
|
||||||
|
05320,TOURS-C/MONNAIE,47.49058,0.81927
|
||||||
|
05486,TOURS-NORD,47.45219,0.73926
|
||||||
|
05490,CHAMBRAY,47.34906,0.70388
|
||||||
|
05485,LA THIBAUDIERE,47.33078,0.68772
|
||||||
|
05484,MONTS - SORIGNY,47.25456,0.67091
|
||||||
|
05524,SAINTE MAURE,47.10728,0.58766
|
||||||
|
05522,TOURS-C/SORIGNY,47.21845,0.65771
|
||||||
|
05478,NEUILLE PONT PIERRE,47.55541,0.59565
|
||||||
|
05967,BOURGUEIL,47.25319,0.16665
|
||||||
|
05960,VIVY,47.31056,-0.03177
|
||||||
|
05014,BLERE,47.28654,0.98447
|
||||||
|
04216,AUBERIVES,45.3898,4.80535
|
||||||
|
04202,CHANAS,45.32115,4.80972
|
||||||
|
03022,CROLLES BRIGNOUD,45.2716,5.90105
|
||||||
|
03023,LE TOUVET,45.34745,5.96371
|
||||||
|
03021,CROLLES BARRIERE,45.27169,5.90089
|
||||||
|
03024,PONTCHARRA,45.42579,5.99511
|
||||||
|
03071,CHESNES,45.65572,5.09788
|
||||||
|
03002,ST QUENTIN FAL. BRETELLE,45.64785,5.11979
|
||||||
|
03062,VILLEFONTAINE,45.62843,5.16432
|
||||||
|
03003,ISLE D'ABEAU CENTRE,45.60516,5.2344
|
||||||
|
03004,BOURGOIN,45.58235,5.30027
|
||||||
|
03005,LA TOUR DU PIN,45.56194,5.42886
|
||||||
|
03072,LA TOUR DU PIN EST,45.55669,5.46452
|
||||||
|
03006,LES ABRETS,45.57134,5.60416
|
||||||
|
03061,SAINT GENIX SUR GUIERS,45.57277,5.65913
|
||||||
|
03085,RIVES,45.38415,5.47313
|
||||||
|
03086,VOIRON,45.34763,5.56633
|
||||||
|
03083,MOIRANS NORD,45.32415,5.60512
|
||||||
|
03087,VOREPPE BARRIERE,45.28323,5.622
|
||||||
|
03084,MOIRANS,45.32035,5.60783
|
||||||
|
03095,TULLINS,45.287,5.52175
|
||||||
|
03093,SAINT MARCELLIN,45.13824,5.32621
|
||||||
|
03094,VINAY,45.19954,5.41944
|
||||||
|
09136,GENDREY,47.18353,5.70894
|
||||||
|
09137,DOLE,47.1361,5.50619
|
||||||
|
09138,CHOISEY,47.06457,5.44674
|
||||||
|
09238,ARLAY,46.77782,5.51859
|
||||||
|
09240,BEAUREPAIRE,46.66637,5.41894
|
||||||
|
04907,BENESSE,43.62393,-1.40031
|
||||||
|
04906,CAPBRETON,43.63235,-1.39224
|
||||||
|
04908,ONDRES,43.54151,-1.4356
|
||||||
|
04624,PEYREHORADE,43.51704,-1.10384
|
||||||
|
04687,SALIES,43.5098,-0.92187
|
||||||
|
05314,MER,47.72856,1.50862
|
||||||
|
05312,MEUNG SUR LOIRE,47.8328,1.669
|
||||||
|
05316,BLOIS,47.62149,1.34635
|
||||||
|
05053,LAMOTTE-BEUVRON,47.58173,1.99103
|
||||||
|
05054,SALBRIS,47.41849,2.0257
|
||||||
|
05013,ST ROMAIN SUR CHER,47.30652,1.35312
|
||||||
|
05011,CHEMERY,47.32467,1.50014
|
||||||
|
05010,VILLEFRANCHE S/ CHER,47.32452,1.76561
|
||||||
|
04178,MONTBRISON,45.63736,4.19611
|
||||||
|
04177,FEURS,45.73754,4.18636
|
||||||
|
04174,NOIRETABLE,45.84935,3.79423
|
||||||
|
04175,ST GERMAIN L.,45.86092,4.04202
|
||||||
|
04180,BALBIGNY,45.84111,4.16441
|
||||||
|
05246,ANCENIS/NANTES,47.40254,-1.19352
|
||||||
|
05245,ANGERS/ANCENIS,47.40243,-1.1935
|
||||||
|
05248,NANTES/ANCENIS,47.39927,-1.19276
|
||||||
|
04556,AIGREFEUILLE,47.0693,-1.43729
|
||||||
|
04557,BIGNON,47.11481,-1.49175
|
||||||
|
05309,GIDY,47.96698,1.85157
|
||||||
|
05308,ORLEANS-NORD,47.94948,1.85462
|
||||||
|
14380,SAVIGNY /CLAIRIS,48.05598,3.08802
|
||||||
|
14375,ST HILAIRE,48.03677,3.02262
|
||||||
|
14365,GONDREVILLE A77/S,48.06084,2.66766
|
||||||
|
14370,FONTENAY /LOING,48.0642,2.76711
|
||||||
|
14360,GONDREVILLE A77/N,48.0608,2.66765
|
||||||
|
14355,AUXY,48.08539,2.47274
|
||||||
|
14350,ESCRENNES,48.11633,2.19111
|
||||||
|
05052,OLIVET,47.84093,1.86936
|
||||||
|
05050,ORLEANS-CENTRE,47.89819,1.85323
|
||||||
|
09404,LE TOURNEAU,47.99293,2.67768
|
||||||
|
04408,MARTEL,44.99329,1.53144
|
||||||
|
04406,SOUILLAC,44.90066,1.50424
|
||||||
|
04405,LABASTIDE MURAT,44.69812,1.58057
|
||||||
|
04404,CAHORS NORD,44.53225,1.50626
|
||||||
|
04403,CAHORS SUD,44.34273,1.49406
|
||||||
|
04463,AIGUILLON,44.28452,0.26986
|
||||||
|
04469,Agen Ouest,44.1875,0.54602
|
||||||
|
04462,AGEN,44.16498,0.60573
|
||||||
|
04783,SEICHES,47.56645,-0.32803
|
||||||
|
04782,DURTAL,47.66779,-0.25964
|
||||||
|
04784,CORZE,47.53933,-0.3408
|
||||||
|
05280,ST JEAN DE LINIERES,47.46658,-0.68606
|
||||||
|
05274,SAINT GERMAIN,47.43168,-0.81633
|
||||||
|
05958,BEAUFORT,47.46812,-0.19348
|
||||||
|
05959,LONGUE,47.40448,-0.11597
|
||||||
|
04561,THOUARCE,47.3295,-0.59635
|
||||||
|
04563,CHEMILLE,47.23593,-0.72764
|
||||||
|
04564,CHOLET NORD,47.08333,-0.82795
|
||||||
|
04565,CHOLET SUD,47.01894,-0.88073
|
||||||
|
07190,REIMS EST,49.21144,4.07834
|
||||||
|
07154,REIMS SUD,49.20521,4.00643
|
||||||
|
07191,CHALONS LA VEUVE,49.04479,4.32266
|
||||||
|
07189,ST GIBRIEN,48.97356,4.28868
|
||||||
|
07192,CHALONS MOURMELON,49.04094,4.32093
|
||||||
|
07193,ST ETIENNE AU TEMPLE,49.03349,4.43586
|
||||||
|
07194,STE MENEHOULD,49.0764,4.88366
|
||||||
|
07616,CLERMONT EN ARGONNE,49.09443,5.10176
|
||||||
|
07137,LA NEUVILLETTE,49.29793,3.99801
|
||||||
|
07188,MONT CHOISY,48.91073,4.28809
|
||||||
|
07187,SOMMESOUS,48.73061,4.22984
|
||||||
|
07633,VATRY,48.76782,4.24012
|
||||||
|
09162,LANGRES SUD,47.79236,5.22621
|
||||||
|
09163,LANGRES NORD,47.93512,5.28682
|
||||||
|
09164,MONTIGNY-LE-ROI,47.99736,5.51194
|
||||||
|
05821,VAIGES,48.05551,-0.48728
|
||||||
|
05823,LAVAL-EST,48.10762,-0.73857
|
||||||
|
05825,LAVAL-OUEST,48.10462,-0.83436
|
||||||
|
07198,JARNY,49.19991,5.90167
|
||||||
|
07199,BEAUMONT,49.19882,5.92401
|
||||||
|
09168,COLOMBEY-LES-BELLES,48.54066,5.90884
|
||||||
|
07195,VOIE SACREE,49.09382,5.27836
|
||||||
|
07196,VERDUN,49.11096,5.41385
|
||||||
|
07197,FRESNES EN WOEVRE,49.12909,5.62133
|
||||||
|
07177,STE-MARIE,49.19333,5.98902
|
||||||
|
07716,BOULAY,49.1411,6.46657
|
||||||
|
07719,ST AVOLD,49.13582,6.71291
|
||||||
|
07721,FAREBERSVILLER,49.10816,6.85335
|
||||||
|
07707,PUTTELANGE,49.06986,6.91814
|
||||||
|
07701,LOUPERSHOUSE,49.07553,6.89666
|
||||||
|
07702,SARREGUEMINES,49.04476,7.02883
|
||||||
|
07704,PHALSBOURG,48.77206,7.24069
|
||||||
|
07705,SAVERNE,48.76172,7.38999
|
||||||
|
07029,MARQUION,50.20249,3.10689
|
||||||
|
07017,CAMBRAI,50.17563,3.19272
|
||||||
|
07030,MASNIERES,50.06855,3.17244
|
||||||
|
07005,SENLIS BONSECOURS,49.20703,2.60921
|
||||||
|
07006,SENLIS,49.2154,2.62813
|
||||||
|
07007,PONT STE MAXENCE,49.32069,2.69478
|
||||||
|
07008,COMPIEGNE OUEST,49.39898,2.69922
|
||||||
|
07009,RESSONS,49.52169,2.71574
|
||||||
|
07414,MERU,49.21073,2.15071
|
||||||
|
07415,BEAUVAIS CENTRE,49.39922,2.12564
|
||||||
|
07416,BEAUVAIS NORD,49.43377,2.12615
|
||||||
|
07417,HARDIVILLERS,49.60999,2.20284
|
||||||
|
05142,ALENCON NORD,48.45298,0.12411
|
||||||
|
17111,Entrée SEES,48.63813,0.18495
|
||||||
|
12210,ARGENTAN,48.63284,0.1909
|
||||||
|
12020,GACE SENS 2,48.77175,0.30896
|
||||||
|
12021,GACE SENS 1,48.77694,0.30347
|
||||||
|
07012,ALBERT,49.96568,2.86338
|
||||||
|
07013,BAPAUME,50.1042,2.8679
|
||||||
|
07014,ARRAS,50.2694,2.86387
|
||||||
|
07434,BERCK,50.41161,1.6899
|
||||||
|
07436,ETAPLES-LE TOUQUET,50.50672,1.68025
|
||||||
|
07437,NEUFCHATEL HARDELOT,50.61112,1.64983
|
||||||
|
07438,BOULOGNE SUD,50.67396,1.65981
|
||||||
|
07021,VALLEE DE LA HEM,50.82097,2.06379
|
||||||
|
07022,ST-OMER B,50.7229,2.16749
|
||||||
|
07020,CALAIS,50.71905,2.17249
|
||||||
|
07024,AIRE SUR LA LYS,50.66821,2.25608
|
||||||
|
07023,ST-OMER,50.71935,2.1729
|
||||||
|
07025,LILLERS,50.55304,2.46281
|
||||||
|
07026,BETHUNE,50.514,2.61776
|
||||||
|
07060,NOEUX LES MINES,50.4869,2.68389
|
||||||
|
07027,LIEVIN,50.43782,2.70728
|
||||||
|
07015,DOURGES,50.32504,2.9107
|
||||||
|
07028,ARRAS,50.34517,2.7875
|
||||||
|
09076,COMBRONDE,45.99599,3.10472
|
||||||
|
09077,RIOM,45.89546,3.14816
|
||||||
|
09078,GERZAT-VILLE,45.84223,3.15962
|
||||||
|
04128,ST JULIEN SANCY,45.66634,2.68254
|
||||||
|
04129,VULCANIA BROMONT,45.8339,2.82261
|
||||||
|
04130,MANZAT,45.95393,2.98285
|
||||||
|
04171,LEZOUX,45.84864,3.38363
|
||||||
|
04172,THIERS-OUEST,45.86003,3.50381
|
||||||
|
04173,THIERS-EST,45.87882,3.62491
|
||||||
|
04905,BAYONNE SUD,43.4623,-1.49849
|
||||||
|
04903,BIARRITZ,43.45046,-1.55445
|
||||||
|
04982,ST JEAN DE LUZ NORD,43.37193,-1.67494
|
||||||
|
04902,ST JEAN DE LUZ SUD,43.37196,-1.67775
|
||||||
|
04620,GUICHE,43.51222,-1.22266
|
||||||
|
04689,ORTHEZ,43.46716,-0.74861
|
||||||
|
04691,LESCAR,43.34524,-0.4188
|
||||||
|
04690,ARTIX,43.39508,-0.55123
|
||||||
|
04692,PAU CENTRE,43.33051,-0.35045
|
||||||
|
04695,TARBES OUEST,43.22081,0.02358
|
||||||
|
04638,TARBES EST,43.21372,0.10822
|
||||||
|
04640,TOURNAY,43.17743,0.23992
|
||||||
|
04642,CAPVERN,43.10327,0.34273
|
||||||
|
04342,PERPIGNAN-NORD,42.7818,2.89739
|
||||||
|
04343,PERPIGNAN-SUD,42.66674,2.85891
|
||||||
|
04344,LE BOULOU,42.52366,2.81767
|
||||||
|
07703,SARRE UNION,48.91392,7.12449
|
||||||
|
07708,HOCHFELDEN OUEST,48.76926,7.60208
|
||||||
|
09221,VILLEFRANCHE-NORD,46.02229,4.72208
|
||||||
|
09120,BELLEVILLE S/SAONE,46.1038,4.75047
|
||||||
|
09121,VILLEFRANCHE-VILLE,45.97728,4.73366
|
||||||
|
04268,CONDRIEU ENTREE,45.50581,4.84228
|
||||||
|
04269,CONDRIEU SORTIE,45.50521,4.83904
|
||||||
|
09421,GENAY,45.90029,4.81942
|
||||||
|
04181,TARARE OUEST,45.89156,4.4031
|
||||||
|
04183,TARARE EST F,45.87151,4.50903
|
||||||
|
04184,TARARE EST ENTREE O,45.87624,4.52202
|
||||||
|
09115,CHALON CENTRE,46.80244,4.82981
|
||||||
|
09116,CHALON SUD,46.75343,4.83299
|
||||||
|
09117,TOURNUS,46.57911,4.90124
|
||||||
|
09140,MACON CENTRE,46.3382,4.84688
|
||||||
|
09118,MACON NORD,46.36589,4.83918
|
||||||
|
09119,MACON SUD,46.28308,4.79296
|
||||||
|
09241,LE MIROIR,46.54741,5.32655
|
||||||
|
05611,LA FERTE BERNARD,48.14989,0.68724
|
||||||
|
05612,CONNERRE,48.07532,0.47932
|
||||||
|
05617,LE MANS-OUEST,48.0217,0.12745
|
||||||
|
05615,LE MANS NORD,48.05045,0.17391
|
||||||
|
04780,LE MANS SUD,47.97373,0.05757
|
||||||
|
04781,SABLE LA FLECHE,47.77553,-0.20865
|
||||||
|
05169,MONTABON,47.68608,0.3714
|
||||||
|
05168,ECOMMOY,47.82037,0.30169
|
||||||
|
05153,PARIGNE L'EVEQUE,47.95569,0.32017
|
||||||
|
05151,AUVOURS,48.0047,0.31254
|
||||||
|
05131,MARESCHE,48.1967,0.16871
|
||||||
|
05133,ROUESSE FONTAINE,48.31156,0.13349
|
||||||
|
05143,ALENCON SUD,48.39788,0.1047
|
||||||
|
05819,JOUE EN CHARNIE,48.00434,-0.21729
|
||||||
|
03008,CHAMBERY NORD,45.60265,5.88708
|
||||||
|
03009,AIX SUD,45.65367,5.92169
|
||||||
|
03007,AIGUEBELETTE,45.57691,5.79935
|
||||||
|
03027,CHIGNIN BRETELLE,45.5092,6.00634
|
||||||
|
03025,CHIGNIN LES MARCHES,45.50898,6.00623
|
||||||
|
03031,MONTMELIAN,45.49492,6.05763
|
||||||
|
03032,SAINT PIERRE D'ALBIGNY,45.54852,6.1603
|
||||||
|
03033,AITON,45.55604,6.24815
|
||||||
|
03034,STE HELENE BARRIERE,45.61976,6.31206
|
||||||
|
02050,ST PIERRE DE BELLEVILLE,45.46979,6.28709
|
||||||
|
02051,STE MARIE DE CUINES,45.34596,6.30541
|
||||||
|
02052,HERMILLON,45.29886,6.35492
|
||||||
|
02053,ST JULIEN MONTDENIS,45.25162,6.39687
|
||||||
|
02054,ST MICHEL ECHANGEUR,45.21844,6.45893
|
||||||
|
02057,ST MICHEL-MODANE,45.21651,6.46586
|
||||||
|
10002,CLUSES AMONT,46.04652,6.59638
|
||||||
|
10003,CLUSES AVAL,46.04893,6.58956
|
||||||
|
10004,SCIONZIER,46.06839,6.55374
|
||||||
|
10012,BONNEVILLE OUEST,46.07345,6.38294
|
||||||
|
10158,ELOISE,46.06521,5.8639
|
||||||
|
03011,RUMILLY,45.81591,6.00686
|
||||||
|
03020,SEYNOD SUD,45.84596,6.05773
|
||||||
|
03012,ANNECY CENTRE,45.89783,6.09258
|
||||||
|
03013,ANNECY NORD,45.93882,6.11694
|
||||||
|
03014,ALLONZIER,45.98984,6.12869
|
||||||
|
03016,CRUSEILLES A 410,45.99337,6.12816
|
||||||
|
08311,ST ROMAIN SO,49.55165,0.33583
|
||||||
|
08322,ST ROMAIN SF,49.55101,0.33873
|
||||||
|
08341,BOLBEC,49.58133,0.44307
|
||||||
|
08351,FECAMP,49.63236,0.64
|
||||||
|
08361,YVETOT,49.62746,0.80626
|
||||||
|
08371,YERVILLE,49.64775,0.84262
|
||||||
|
08381,BEAUTOT,49.6353,1.05065
|
||||||
|
08391,COTTEVRARD,49.64675,1.24268
|
||||||
|
07443,AUMALE OUEST,49.75625,1.69842
|
||||||
|
07444,AUMALE EST,49.75939,1.7031
|
||||||
|
07172,ST-JEAN LES 2 JUMEAU,48.9469,3.03972
|
||||||
|
07174,MONTREUIL AUX LIONS (19),49.01316,3.1554
|
||||||
|
09178,CHATILLON-LABORDE,48.54222,2.79737
|
||||||
|
09179,ST-GERMAIN-LAXIS,48.58811,2.72483
|
||||||
|
09176,MAROLLES-SUR-SEINE,48.38015,3.02023
|
||||||
|
09177,FORGES,48.42142,2.94341
|
||||||
|
09102,URY,48.33869,2.59548
|
||||||
|
09104,NEMOURS,48.26951,2.7133
|
||||||
|
09103,FONTAINEBLEAU,48.28882,2.68315
|
||||||
|
09201,VAL DE LOING-SOUPPES,48.17632,2.76729
|
||||||
|
09403,DORDIVES,48.17139,2.76706
|
||||||
|
05198,DOURDAN,48.56902,1.99025
|
||||||
|
05302,ALLAINVILLE,48.45643,1.90827
|
||||||
|
05603,ABLIS,48.52877,1.83225
|
||||||
|
05601,LA FOLIE-B/PARIS,48.55324,1.93062
|
||||||
|
08511,CHAMBOURCY FL,48.9118,2.04672
|
||||||
|
04533,SOUDAN,46.4255,-0.08208
|
||||||
|
04534,NIORT EST,46.35524,-0.33118
|
||||||
|
04547,VOUILLE,46.30743,-0.36642
|
||||||
|
04535,NIORT-S,46.244,-0.46061
|
||||||
|
04548,NIORT NORD,46.41932,-0.39707
|
||||||
|
07010,ROYE,49.70606,2.76919
|
||||||
|
07053,GARE TGV,49.85555,2.83067
|
||||||
|
07011,PERONNE,49.87697,2.83976
|
||||||
|
07418,ESSERTAUX,49.73973,2.22877
|
||||||
|
07422,SALOUEL,49.85977,2.21381
|
||||||
|
07420,AMIENS SUD,49.85416,2.25022
|
||||||
|
07425,AMIENS OUEST,49.89176,2.23839
|
||||||
|
07426,AMIENS NORD,49.93383,2.24381
|
||||||
|
07428,FLIXECOURT,50.02951,2.06974
|
||||||
|
07431,ABBEVILLE NORD,50.13538,1.81102
|
||||||
|
07430,ABBEVILLE EST,50.09981,1.86941
|
||||||
|
07432,COTE PICARDE,50.25441,1.74658
|
||||||
|
07446,POIX-DE-PICARDIE,49.80846,1.96876
|
||||||
|
07052,VILLERS BRETONNEUX,49.85496,2.52207
|
||||||
|
07054,ATHIES,49.83871,2.98978
|
||||||
|
04402,CAUSSADE,44.14993,1.51564
|
||||||
|
04461,VALENCE D'AGEN,44.06418,0.86653
|
||||||
|
04460,CASTELSARRASIN,44.0558,1.09731
|
||||||
|
04459,MONTAUBAN,43.92898,1.31427
|
||||||
|
06003,POURRIERES,43.47756,5.75573
|
||||||
|
06004,ST.MAXIMIN,43.44938,5.87706
|
||||||
|
06007,LE MUY,43.46068,6.55084
|
||||||
|
06008,PUGET ECHANGEUR,43.45693,6.68943
|
||||||
|
06049,FREJUS OUEST,43.46935,6.72917
|
||||||
|
06010,FREJUS,43.47221,6.74342
|
||||||
|
06032,BANDOL ECH.,43.14438,5.76866
|
||||||
|
06042,PUGET VILLE,43.25791,6.12263
|
||||||
|
06046,CARNOULES,43.29345,6.20046
|
||||||
|
06006,CANNET DES MAURES,43.39342,6.35218
|
||||||
|
04209,BOLLENE,44.29026,4.75111
|
||||||
|
04217,ORANGE-N,44.16422,4.76458
|
||||||
|
04210,ORANGE,44.13527,4.79569
|
||||||
|
04218,ORANGE-S,44.11089,4.84525
|
||||||
|
04211,AVIGNON-N,43.9819,4.88828
|
||||||
|
04212,AVIGNON-S,43.89289,4.91565
|
||||||
|
04555,MONTAIGU,46.9596,-1.35294
|
||||||
|
04554,LES ESSARTS,46.79043,-1.19453
|
||||||
|
04553,CHANTONNAY,46.62728,-1.15449
|
||||||
|
04552,STE HERMINE,46.5336,-1.07897
|
||||||
|
04550,FONTENAY CENTRE,46.43595,-0.82151
|
||||||
|
04570,FONTENAY OUEST,46.46462,-0.87667
|
||||||
|
04549,NIORT OUEST,46.38784,-0.64788
|
||||||
|
04566,LA VERRIE,46.94473,-0.9765
|
||||||
|
04567,LES HERBIERS,46.90208,-1.04676
|
||||||
|
05528,CHATELLERAULT-SUD,46.77866,0.50452
|
||||||
|
05526,CHATELLERAULT-NORD,46.83697,0.53082
|
||||||
|
05530,POITIERS-NORD,46.62136,0.34394
|
||||||
|
05529,FUTUROSCOPE,46.67011,0.35987
|
||||||
|
05532,POITIERS-SUD,46.54907,0.28938
|
||||||
|
09265,ROBECOURT,48.14478,5.68904
|
||||||
|
09166,BULGNEVILLE,48.21578,5.8325
|
||||||
|
09167,CHATENOIS,48.29947,5.85293
|
||||||
|
09175,VULAINES,48.23784,3.60136
|
||||||
|
09184,ST-DENIS-LES-SENS,48.23696,3.26209
|
||||||
|
09105,COURTENAY,48.0598,3.09537
|
||||||
|
09106,JOIGNY,47.9397,3.24406
|
||||||
|
09107,AUXERRE NORD,47.85162,3.54878
|
||||||
|
09108,AUXERRE SUD,47.79676,3.65284
|
||||||
|
09109,NITRY,47.65906,3.87958
|
||||||
|
09181,VILLENEUVE-DONDAGRE,48.14968,3.17133
|
||||||
|
07002,CHANTILLY,49.08425,2.55161
|
||||||
|
07018,THUN L'EVEQUE,50.22928,3.27218
|
||||||
|
07171,COUTEVROULT,48.85356,2.83874
|
||||||
|
07140,MONTREUIL AUX LIONS,49.00953,3.14581
|
||||||
|
07718,ST AVOLD,49.1362,6.70162
|
||||||
|
09180,LES EPRUNES,48.58892,2.65679
|
||||||
|
09101,FLEURY-EN-BIERE,48.42582,2.53979
|
||||||
|
09112,POUILLY-EN-AUXOIS,47.25241,4.56116
|
||||||
|
04288,VIENNE SUD,45.47464,4.83439
|
||||||
|
04201,VIENNE,45.47693,4.83243
|
||||||
|
04220,LANCON,43.59398,5.17255
|
||||||
|
06002,LA BARQUE,43.48327,5.53839
|
||||||
|
04345,LE PERTHUS,42.52788,2.82113
|
||||||
|
04390,LE BOULOU (O),42.52367,2.81752
|
||||||
|
04541,VIRSAC,45.02368,-0.43507
|
||||||
|
08521,BUCHELAY FL,48.99097,1.64331
|
||||||
|
08611,DOZULE FL,49.22841,-0.08116
|
||||||
|
08501,MONTESSON FL,48.91429,2.15109
|
||||||
|
07413,AMBLAINVILLE,49.20513,2.1689
|
||||||
|
07439,HERQUELINGUE,50.68885,1.64206
|
||||||
|
04401,MONTAUBAN NORD,44.05229,1.41021
|
||||||
|
05177,ST CHRISTOPHE,47.63838,0.49703
|
||||||
|
12010,SEES,48.63297,0.19109
|
||||||
|
12060,ROUMOIS,49.35396,0.83522
|
||||||
|
08601,QUETTEVILLE FL,49.32057,0.31114
|
||||||
|
07451,JULES VERNE,49.85868,2.39867
|
||||||
|
09169,GYE,48.62975,5.88358
|
||||||
|
09431,FONTAINE-LARIVIERE,47.6758,6.98183
|
||||||
|
09132,SAINT MAURICE,47.4255,6.67126
|
||||||
|
10011,NANGY,46.15316,6.29518
|
||||||
|
10159,VIRY,46.12023,6.00951
|
||||||
|
06070,LA SAULCE,44.44035,6.0286
|
||||||
|
03041,LE CROZET,45.04556,5.67876
|
||||||
|
06037,AURIOL,43.36689,5.64395
|
||||||
|
04262,ARLES,43.69045,4.5449
|
||||||
|
04265,SAINT MARTIN DE CRAU,43.63771,4.85783
|
||||||
|
04355,TOULOUSE-SUD/EST,43.5449,1.50046
|
||||||
|
04468,SAINT-SELVE,44.65901,-0.45426
|
||||||
|
04457,TOULOUSE-NORD/OUEST,43.65838,1.42803
|
||||||
|
04456,TOULOUSE-NORD/EST,43.65805,1.42699
|
||||||
|
19001,BPV SAUGNAC,44.34693,-0.85998
|
||||||
|
19003,BPV CASTETS,43.83527,-1.18061
|
||||||
|
04901,BIRIATOU,43.34098,-1.74938
|
||||||
|
04622,SAMES,43.52955,-1.18678
|
||||||
|
04476,MURET,43.50526,1.35223
|
||||||
|
18001,BAZAS,44.44527,-0.24235
|
||||||
|
18002,CAPTIEUX,44.28603,-0.22806
|
||||||
|
18003,ROQUEFORT,44.04489,-0.34321
|
||||||
|
18004,MONT DE MARSAN,43.94698,-0.39392
|
||||||
|
18006,AIRE SUR L'ADOUR N,43.72216,-0.27088
|
||||||
|
18007,AIRE SUR L'ADOUR S,43.66454,-0.27668
|
||||||
|
18008,GARLIN,43.56528,-0.29261
|
||||||
|
18009,THEZE,43.47137,-0.32105
|
||||||
|
04472,TOULOUSE EST,43.64715,1.50782
|
||||||
|
09063,MONTLUCON,46.39634,2.71132
|
||||||
|
04179,VEAUCHETTE,45.56095,4.24203
|
||||||
|
13001,VIADUC DE MILLAU,44.13397,3.02535
|
||||||
|
09405,MYENNES,47.43731,2.94081
|
||||||
|
05827,LA GRAVELLE VITRE,48.08263,-1.02758
|
||||||
|
05968,RESTIGNE,47.27017,0.25391
|
||||||
|
05016,VEIGNE,47.31191,0.7353
|
||||||
|
05015,ESVRES,47.30717,0.79607
|
||||||
|
05713,VELIZY,48.7828,2.15728
|
||||||
|
05712,VAUCRESSON,48.83241,2.14747
|
||||||
|
05711,RUEIL,48.86991,2.15805
|
||||||
|
04562,BEAULIEU SUR LAYON,47.32641,-0.60428
|
||||||
|
04568,LA ROCHE SUR YON,46.67234,-1.34583
|
||||||
|
17112,Sortie SEES,48.63603,0.1844
|
||||||
|
17114,RONAI vers SEES,48.81632,-0.12945
|
||||||
|
17113,RONAI vers FALAISE,48.8164,-0.12919
|
||||||
|
17116,Sortie NECY,48.82346,-0.13664
|
||||||
|
04122,ST GERMAIN LES VERGN,45.28384,1.61737
|
||||||
|
04170,LES MARTRES ARTIERE,45.8278,3.24112
|
||||||
|
08561,BOURNEVILLE FL,49.38496,0.60381
|
||||||
|
20001,BOUVILLE,49.54825,0.92179
|
||||||
|
08541,INCARVILLE FL,49.24809,1.17938
|
||||||
|
09125,DIJON SUD,47.26023,5.03208
|
||||||
|
07152,REIMS OUEST(THILLOIS,49.24947,3.95591
|
||||||
|
09239,BERSAILLIN,46.84932,5.57458
|
||||||
|
09165,GROISSIAT,46.22366,5.6142
|
||||||
|
09065,GANNAT,46.0981,3.13554
|
||||||
|
09465,VICHY,46.13841,3.3513
|
||||||
|
04544,CABARIOT,45.94391,-0.84935
|
||||||
|
08211,PONT DE TANCARVILLE,49.46365,0.47404
|
||||||
|
04801,PYRENEES ORIENTALES,42.53958,1.82401
|
||||||
|
08222,PONT DE NORMANDIE,49.45001,0.2717
|
||||||
|
06021,ST.ISIDORE ECH. EST,43.70655,7.19071
|
||||||
|
06028,LAGHET,43.7454,7.37858
|
||||||
|
06055,MEYRARGUES,43.66118,5.50356
|
||||||
|
06056,PERTUIS NORD,43.66162,5.5034
|
||||||
|
06039,BELCODENE,43.41783,5.5752
|
||||||
|
08593,BEUZEVILLE FL ECH CAEN,49.33906,0.36807
|
||||||
|
05247,ANCENIS/ANGERS,47.39936,-1.19277
|
||||||
|
05273,VIEILLEVILLE,47.29114,-1.4804
|
||||||
|
07722,HOCHFELDEN,48.76874,7.60218
|
||||||
|
03010,AIX NORD,45.71614,5.92225
|
||||||
|
10014,BONNEVILLE EST,46.07018,6.42445
|
||||||
|
08321,EPRETOT BPV,49.55172,0.3355
|
||||||
|
06005,BRIGNOLES,43.4191,6.06505
|
||||||
|
06011,LES ADRETS,43.54408,6.81245
|
||||||
|
07001,ROISSY CDG,49.21556,2.62796
|
||||||
|
07706,SCHWINDRATZHEIM,48.76953,7.60203
|
||||||
|
09122,VILLEFRANCHE-LIMAS,45.97344,4.73193
|
||||||
|
06009,CAPITOU,43.46857,6.72924
|
||||||
|
06012,ANTIBES P/V,43.60255,7.0783
|
||||||
|
06027,LA TURBIE P/V,43.74367,7.37827
|
||||||
|
06020,ST.ISIDORE P/V,43.70752,7.19138
|
||||||
|
05270,ANCENIS BARRIERE,47.40069,-1.19587
|
||||||
|
08531,HEUDEBOUVILLE FL,49.19512,1.23011
|
||||||
|
08591,BEUZEVILLE FL,49.33815,0.3678
|
||||||
|
08533,HEUDEBOUVILLE FL ECH CAEN,49.19667,1.22974
|
||||||
|
04407,GIGNAC,44.99076,1.52646
|
||||||
|
07441,HAUDRICOURT,49.75948,1.70294
|
||||||
|
10001,CLUSES,46.04695,6.5966
|
||||||
|
03026,CHIGNIN BARRIERE,45.51256,6.00209
|
||||||
|
02056,ST MICHEL BARRIERE,45.21869,6.45903
|
||||||
|
03001,ST QUENTIN FAL. BARRIERE,45.64866,5.12021
|
||||||
|
06033,LA CIOTAT P/V,43.20485,5.59054
|
||||||
|
06031,BANDOL P/V,43.14628,5.77188
|
||||||
|
04354,TOULOUSE-SUD/OUEST,43.54469,1.50019
|
||||||
|
04904,LA NEGRESSE,43.44839,-1.55338
|
||||||
|
09079,CLERMONT-BARRIERE,45.84133,3.16053
|
||||||
|
17115,Entrée NECY,48.8235,-0.13463
|
||||||
|
04182,ST ROMAIN POPEY,45.87177,4.50891
|
||||||
|
07150,REIMS NORD,49.24633,3.96251
|
||||||
|
03015,ST MARTIN BELLEVUE A410,45.98981,6.12837
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
/// Configuration de la version de l'application
|
/// Configuration de la version de l'application
|
||||||
class AppVersion {
|
class AppVersion {
|
||||||
static const String version = '1.2.3';
|
static const String version = '1.2.4';
|
||||||
|
|
||||||
/// Retourne la version complète de l'application
|
/// Retourne la version complète de l'application
|
||||||
static String get fullVersion => 'v$version';
|
static String get fullVersion => 'v$version';
|
||||||
|
|||||||
@@ -256,6 +256,20 @@ class EventFormController extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ajoute ou met à jour l'option FRAIS_KM avec le prix calculé.
|
||||||
|
/// L'option est au format attendu par Firestore : { id: "FRAIS_KM", price: <valeur> }
|
||||||
|
void addTravelCostOption(double price) {
|
||||||
|
// Retirer l'éventuelle option FRAIS_KM existante
|
||||||
|
_selectedOptions.removeWhere((opt) => opt['id'] == 'FRAIS_KM');
|
||||||
|
// Ajouter la nouvelle
|
||||||
|
_selectedOptions.add({
|
||||||
|
'id': 'FRAIS_KM',
|
||||||
|
'price': double.parse(price.toStringAsFixed(2)),
|
||||||
|
});
|
||||||
|
_onAnyFieldChanged();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
|
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
|
||||||
_assignedEquipment = equipment;
|
_assignedEquipment = equipment;
|
||||||
_assignedContainers = containers;
|
_assignedContainers = containers;
|
||||||
@@ -433,6 +447,11 @@ class EventFormController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> submitAsConfirmed(BuildContext context) async {
|
||||||
|
_selectedStatus = EventStatus.confirmed;
|
||||||
|
return await submitForm(context);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> deleteEvent(BuildContext context, String eventId) async {
|
Future<bool> deleteEvent(BuildContext context, String eventId) async {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_error = null;
|
_error = null;
|
||||||
|
|||||||
+54
-34
@@ -39,46 +39,66 @@ void main() {
|
|||||||
// Ne pas effectuer d'initialisations asynchrones lourdes ici.
|
// Ne pas effectuer d'initialisations asynchrones lourdes ici.
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
runApp(
|
if (kReleaseMode) {
|
||||||
MultiProvider(
|
debugPrint = (String? message, {int? wrapWidth}) {};
|
||||||
providers: [
|
}
|
||||||
// Fournisseur d'initialisation de l'application (initialise Firebase et cache en tâche de fond)
|
|
||||||
ChangeNotifierProvider<AppInitializer>(
|
|
||||||
create: (_) => AppInitializer(),
|
|
||||||
),
|
|
||||||
// LocalUserProvider pour la gestion de l'authentification
|
|
||||||
ChangeNotifierProvider<LocalUserProvider>(
|
|
||||||
create: (context) => LocalUserProvider()),
|
|
||||||
|
|
||||||
// UsersProvider migré vers l'API
|
runZonedGuarded(
|
||||||
ChangeNotifierProvider<UsersProvider>(
|
() {
|
||||||
create: (context) => UsersProvider(),
|
runApp(
|
||||||
),
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
// Fournisseur d'initialisation de l'application (initialise Firebase et cache en tâche de fond)
|
||||||
|
ChangeNotifierProvider<AppInitializer>(
|
||||||
|
create: (_) => AppInitializer(),
|
||||||
|
),
|
||||||
|
// LocalUserProvider pour la gestion de l'authentification
|
||||||
|
ChangeNotifierProvider<LocalUserProvider>(
|
||||||
|
create: (context) => LocalUserProvider()),
|
||||||
|
|
||||||
// EventProvider migré vers l'API
|
// UsersProvider migré vers l'API
|
||||||
ChangeNotifierProvider<EventProvider>(
|
ChangeNotifierProvider<UsersProvider>(
|
||||||
create: (context) => EventProvider(),
|
create: (context) => UsersProvider(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// EquipmentProvider migré vers l'API
|
// EventProvider migré vers l'API
|
||||||
ChangeNotifierProvider<EquipmentProvider>(
|
ChangeNotifierProvider<EventProvider>(
|
||||||
create: (context) => EquipmentProvider(),
|
create: (context) => EventProvider(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ContainerProvider migré vers l'API
|
// EquipmentProvider migré vers l'API
|
||||||
ChangeNotifierProvider<ContainerProvider>(
|
ChangeNotifierProvider<EquipmentProvider>(
|
||||||
create: (context) => ContainerProvider(),
|
create: (context) => EquipmentProvider(),
|
||||||
),
|
),
|
||||||
|
|
||||||
// MaintenanceProvider migré vers l'API
|
// ContainerProvider migré vers l'API
|
||||||
ChangeNotifierProvider<MaintenanceProvider>(
|
ChangeNotifierProvider<ContainerProvider>(
|
||||||
create: (context) => MaintenanceProvider(),
|
create: (context) => ContainerProvider(),
|
||||||
|
),
|
||||||
|
|
||||||
|
// MaintenanceProvider migré vers l'API
|
||||||
|
ChangeNotifierProvider<MaintenanceProvider>(
|
||||||
|
create: (context) => MaintenanceProvider(),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider<AlertProvider>(
|
||||||
|
create: (context) => AlertProvider(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const MyApp(),
|
||||||
),
|
),
|
||||||
ChangeNotifierProvider<AlertProvider>(
|
);
|
||||||
create: (context) => AlertProvider(),
|
},
|
||||||
),
|
(error, stackTrace) {
|
||||||
],
|
if (kDebugMode) {
|
||||||
child: const MyApp(),
|
print('Uncaught error: $error\n$stackTrace');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
zoneSpecification: ZoneSpecification(
|
||||||
|
print: (Zone self, ZoneDelegate parent, Zone zone, String line) {
|
||||||
|
if (!kReleaseMode) {
|
||||||
|
parent.print(zone, line);
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,34 +242,55 @@ class ContainerModel {
|
|||||||
|
|
||||||
/// Factory depuis Firestore
|
/// Factory depuis Firestore
|
||||||
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
factory ContainerModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
// Fonction helper pour convertir de manière sécurisée en double
|
||||||
|
double? parseDouble(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is num) return value.toDouble();
|
||||||
|
if (value is String) return double.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO ou int epoch en DateTime
|
||||||
DateTime? parseDate(dynamic value) {
|
DateTime? parseDate(dynamic value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value);
|
if (value is String) return DateTime.tryParse(value);
|
||||||
|
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
// Gestion sécurisée de la liste d'IDs d'équipements
|
||||||
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
final List<String> equipmentIds = [];
|
||||||
|
if (map['equipmentIds'] is List) {
|
||||||
|
for (final e in map['equipmentIds'] as List) {
|
||||||
|
if (e != null) {
|
||||||
|
equipmentIds.add(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final List<dynamic> historyRaw = map['history'] ?? [];
|
// Gestion sécurisée de l'historique
|
||||||
final List<ContainerHistoryEntry> history = historyRaw
|
final List<ContainerHistoryEntry> history = [];
|
||||||
.map((e) => ContainerHistoryEntry.fromMap(e as Map<String, dynamic>))
|
if (map['history'] is List) {
|
||||||
.toList();
|
for (final e in map['history'] as List) {
|
||||||
|
if (e is Map<String, dynamic>) {
|
||||||
|
history.add(ContainerHistoryEntry.fromMap(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return ContainerModel(
|
return ContainerModel(
|
||||||
id: id,
|
id: id,
|
||||||
name: map['name'] ?? '',
|
name: (map['name'] ?? '').toString(),
|
||||||
type: containerTypeFromString(map['type']),
|
type: containerTypeFromString(map['type']?.toString()),
|
||||||
status: equipmentStatusFromString(map['status']),
|
status: equipmentStatusFromString(map['status']?.toString()),
|
||||||
weight: map['weight']?.toDouble(),
|
weight: parseDouble(map['weight']),
|
||||||
length: map['length']?.toDouble(),
|
length: parseDouble(map['length']),
|
||||||
width: map['width']?.toDouble(),
|
width: parseDouble(map['width']),
|
||||||
height: map['height']?.toDouble(),
|
height: parseDouble(map['height']),
|
||||||
equipmentIds: equipmentIds,
|
equipmentIds: equipmentIds,
|
||||||
eventId: map['eventId'],
|
eventId: map['eventId']?.toString(),
|
||||||
notes: map['notes'],
|
notes: map['notes']?.toString(),
|
||||||
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
history: history,
|
history: history,
|
||||||
@@ -355,16 +376,17 @@ class ContainerHistoryEntry {
|
|||||||
if (value == null) return DateTime.now();
|
if (value == null) return DateTime.now();
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
if (value is String) return DateTime.tryParse(value) ?? DateTime.now();
|
||||||
|
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
|
||||||
return DateTime.now();
|
return DateTime.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
return ContainerHistoryEntry(
|
return ContainerHistoryEntry(
|
||||||
timestamp: parseDate(map['timestamp']),
|
timestamp: parseDate(map['timestamp']),
|
||||||
action: map['action'] ?? '',
|
action: (map['action'] ?? '').toString(),
|
||||||
equipmentId: map['equipmentId'],
|
equipmentId: map['equipmentId']?.toString(),
|
||||||
previousValue: map['previousValue'],
|
previousValue: map['previousValue']?.toString(),
|
||||||
newValue: map['newValue'],
|
newValue: map['newValue']?.toString(),
|
||||||
userId: map['userId'],
|
userId: map['userId']?.toString(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
class DepotModel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String address;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
const DepotModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.address,
|
||||||
|
this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DepotModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
return DepotModel(
|
||||||
|
id: id,
|
||||||
|
name: (map['name'] ?? '').toString(),
|
||||||
|
address: (map['address'] ?? '').toString(),
|
||||||
|
createdAt: map['createdAt'] is Timestamp
|
||||||
|
? (map['createdAt'] as Timestamp).toDate()
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory DepotModel.fromFirestore(DocumentSnapshot doc) {
|
||||||
|
return DepotModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'address': address,
|
||||||
|
'createdAt': createdAt != null
|
||||||
|
? Timestamp.fromDate(createdAt!)
|
||||||
|
: FieldValue.serverTimestamp(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
DepotModel copyWith({String? id, String? name, String? address}) {
|
||||||
|
return DepotModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
address: address ?? this.address,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -387,40 +387,64 @@ class EquipmentModel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
|
factory EquipmentModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
// Fonction helper pour convertir de manière sécurisée en double
|
||||||
|
double? parseDouble(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is num) return value.toDouble();
|
||||||
|
if (value is String) return double.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction helper pour convertir de manière sécurisée en int
|
||||||
|
int? parseInt(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
if (value is String) return int.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO ou int epoch en DateTime
|
||||||
DateTime? parseDate(dynamic value) {
|
DateTime? parseDate(dynamic value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value);
|
if (value is String) return DateTime.tryParse(value);
|
||||||
|
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestion des listes
|
// Gestion sécurisée des listes d'IDs de maintenance
|
||||||
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
|
final List<String> maintenanceIds = [];
|
||||||
final List<String> maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList();
|
if (map['maintenanceIds'] is List) {
|
||||||
|
for (final e in map['maintenanceIds'] as List) {
|
||||||
|
if (e != null) {
|
||||||
|
maintenanceIds.add(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return EquipmentModel(
|
return EquipmentModel(
|
||||||
id: id,
|
id: id,
|
||||||
name: map['name'] ?? '',
|
name: (map['name'] ?? '').toString(),
|
||||||
brand: map['brand'],
|
brand: map['brand']?.toString(),
|
||||||
model: map['model'],
|
model: map['model']?.toString(),
|
||||||
category: equipmentCategoryFromString(map['category']),
|
category: equipmentCategoryFromString(map['category']?.toString()),
|
||||||
subCategory: map['subCategory'],
|
subCategory: map['subCategory']?.toString(),
|
||||||
status: equipmentStatusFromString(map['status']),
|
status: equipmentStatusFromString(map['status']?.toString()),
|
||||||
purchasePrice: map['purchasePrice']?.toDouble(),
|
purchasePrice: parseDouble(map['purchasePrice']),
|
||||||
rentalPrice: map['rentalPrice']?.toDouble(),
|
rentalPrice: parseDouble(map['rentalPrice']),
|
||||||
totalQuantity: map['totalQuantity']?.toInt(),
|
totalQuantity: parseInt(map['totalQuantity']),
|
||||||
availableQuantity: map['availableQuantity']?.toInt(),
|
availableQuantity: parseInt(map['availableQuantity']),
|
||||||
criticalThreshold: map['criticalThreshold']?.toInt(),
|
criticalThreshold: parseInt(map['criticalThreshold']),
|
||||||
weight: map['weight']?.toDouble(),
|
weight: parseDouble(map['weight']),
|
||||||
length: map['length']?.toDouble(),
|
length: parseDouble(map['length']),
|
||||||
width: map['width']?.toDouble(),
|
width: parseDouble(map['width']),
|
||||||
height: map['height']?.toDouble(),
|
height: parseDouble(map['height']),
|
||||||
purchaseDate: parseDate(map['purchaseDate']),
|
purchaseDate: parseDate(map['purchaseDate']),
|
||||||
|
lastMaintenanceDate: parseDate(map['lastMaintenanceDate']),
|
||||||
nextMaintenanceDate: parseDate(map['nextMaintenanceDate']),
|
nextMaintenanceDate: parseDate(map['nextMaintenanceDate']),
|
||||||
maintenanceIds: maintenanceIds,
|
maintenanceIds: maintenanceIds,
|
||||||
imageUrl: map['imageUrl'],
|
imageUrl: map['imageUrl']?.toString(),
|
||||||
notes: map['notes'],
|
notes: map['notes']?.toString(),
|
||||||
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,29 +60,44 @@ class MaintenanceModel {
|
|||||||
});
|
});
|
||||||
|
|
||||||
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
|
factory MaintenanceModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
// Fonction helper pour convertir Timestamp ou String ISO en DateTime
|
// Fonction helper pour convertir de manière sécurisée en double
|
||||||
|
double? parseDouble(dynamic value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
if (value is num) return value.toDouble();
|
||||||
|
if (value is String) return double.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction helper pour convertir Timestamp ou String ISO ou int epoch en DateTime
|
||||||
DateTime? parseDate(dynamic value) {
|
DateTime? parseDate(dynamic value) {
|
||||||
if (value == null) return null;
|
if (value == null) return null;
|
||||||
if (value is Timestamp) return value.toDate();
|
if (value is Timestamp) return value.toDate();
|
||||||
if (value is String) return DateTime.tryParse(value);
|
if (value is String) return DateTime.tryParse(value);
|
||||||
|
if (value is int) return DateTime.fromMillisecondsSinceEpoch(value);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gestion de la liste des équipements
|
// Gestion de la liste des équipements
|
||||||
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
|
final List<String> equipmentIds = [];
|
||||||
final List<String> equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList();
|
if (map['equipmentIds'] is List) {
|
||||||
|
for (final e in map['equipmentIds'] as List) {
|
||||||
|
if (e != null) {
|
||||||
|
equipmentIds.add(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return MaintenanceModel(
|
return MaintenanceModel(
|
||||||
id: id,
|
id: id,
|
||||||
equipmentIds: equipmentIds,
|
equipmentIds: equipmentIds,
|
||||||
type: maintenanceTypeFromString(map['type']),
|
type: maintenanceTypeFromString(map['type']?.toString()),
|
||||||
scheduledDate: parseDate(map['scheduledDate']) ?? DateTime.now(),
|
scheduledDate: parseDate(map['scheduledDate']) ?? DateTime.now(),
|
||||||
completedDate: parseDate(map['completedDate']),
|
completedDate: parseDate(map['completedDate']),
|
||||||
name: map['name'] ?? '',
|
name: (map['name'] ?? '').toString(),
|
||||||
description: map['description'] ?? '',
|
description: (map['description'] ?? '').toString(),
|
||||||
performedBy: map['performedBy'],
|
performedBy: map['performedBy']?.toString(),
|
||||||
cost: map['cost']?.toDouble(),
|
cost: parseDouble(map['cost']),
|
||||||
notes: map['notes'],
|
notes: map['notes']?.toString(),
|
||||||
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
createdAt: parseDate(map['createdAt']) ?? DateTime.now(),
|
||||||
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
updatedAt: parseDate(map['updatedAt']) ?? DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/// Résultat d'un itinéraire calculé par Google Maps + Ulys.
|
||||||
|
class RouteResult {
|
||||||
|
/// 'TOLL' ou 'TOLL_FREE'
|
||||||
|
final String routeType;
|
||||||
|
final int distanceMeters;
|
||||||
|
final int durationSeconds;
|
||||||
|
final String encodedPolyline;
|
||||||
|
final double tollCost;
|
||||||
|
|
||||||
|
const RouteResult({
|
||||||
|
required this.routeType,
|
||||||
|
required this.distanceMeters,
|
||||||
|
required this.durationSeconds,
|
||||||
|
required this.encodedPolyline,
|
||||||
|
required this.tollCost,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory RouteResult.fromMap(Map<String, dynamic> map) {
|
||||||
|
return RouteResult(
|
||||||
|
routeType: (map['routeType'] ?? 'TOLL').toString(),
|
||||||
|
distanceMeters: _parseInt(map['distanceMeters'] ?? 0),
|
||||||
|
durationSeconds: _parseInt(map['durationSeconds'] ?? 0),
|
||||||
|
encodedPolyline: (map['encodedPolyline'] ?? '').toString(),
|
||||||
|
tollCost: _parseDouble(map['tollCost'] ?? 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isTollFree => routeType == 'TOLL_FREE';
|
||||||
|
|
||||||
|
double get distanceKm => distanceMeters / 1000.0;
|
||||||
|
double get durationMinutes => durationSeconds / 60.0;
|
||||||
|
double get durationHours => durationSeconds / 3600.0;
|
||||||
|
|
||||||
|
/// Calcule le coût carburant.
|
||||||
|
/// [consumptionPer100km] : L/100km (ou kWh/100km si électrique)
|
||||||
|
/// [fuelPricePerLiter] : €/L ou €/kWh
|
||||||
|
/// [freeZoneKm] : km gratuits à déduire (zone de gratuité)
|
||||||
|
double fuelCost({
|
||||||
|
required double consumptionPer100km,
|
||||||
|
required double fuelPricePerLiter,
|
||||||
|
double freeZoneKm = 0,
|
||||||
|
}) {
|
||||||
|
final effectiveKm = (distanceKm - freeZoneKm).clamp(0, double.infinity);
|
||||||
|
return (effectiveKm / 100.0) * consumptionPer100km * fuelPricePerLiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule le coût de maintenance.
|
||||||
|
double maintenanceCost({
|
||||||
|
required double costPerKm,
|
||||||
|
double freeZoneKm = 0,
|
||||||
|
}) {
|
||||||
|
final effectiveKm = (distanceKm - freeZoneKm).clamp(0, double.infinity);
|
||||||
|
return effectiveKm * costPerKm;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule le coût de main-d'œuvre (techniciens).
|
||||||
|
/// [freeZoneMinutes] : minutes gratuites à déduire (zone de gratuité)
|
||||||
|
double laborCost({
|
||||||
|
required int nbTechnicians,
|
||||||
|
required double hourlyRate,
|
||||||
|
double freeZoneMinutes = 0,
|
||||||
|
}) {
|
||||||
|
final effectiveMinutes =
|
||||||
|
(durationMinutes - freeZoneMinutes).clamp(0, double.infinity);
|
||||||
|
return (effectiveMinutes / 60.0) * nbTechnicians * hourlyRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule le coût total pour un aller simple.
|
||||||
|
double totalCost({
|
||||||
|
required double consumptionPer100km,
|
||||||
|
required double fuelPricePerLiter,
|
||||||
|
required double maintenanceCostPerKm,
|
||||||
|
required int nbTechnicians,
|
||||||
|
required double hourlyRate,
|
||||||
|
bool applyFreeZone = false,
|
||||||
|
}) {
|
||||||
|
const freeKm = 20.0;
|
||||||
|
const freeMinutes = 20.0;
|
||||||
|
|
||||||
|
return fuelCost(
|
||||||
|
consumptionPer100km: consumptionPer100km,
|
||||||
|
fuelPricePerLiter: fuelPricePerLiter,
|
||||||
|
freeZoneKm: applyFreeZone ? freeKm : 0,
|
||||||
|
) +
|
||||||
|
maintenanceCost(
|
||||||
|
costPerKm: maintenanceCostPerKm,
|
||||||
|
freeZoneKm: applyFreeZone ? freeKm : 0,
|
||||||
|
) +
|
||||||
|
laborCost(
|
||||||
|
nbTechnicians: nbTechnicians,
|
||||||
|
hourlyRate: hourlyRate,
|
||||||
|
freeZoneMinutes: applyFreeZone ? freeMinutes : 0,
|
||||||
|
) +
|
||||||
|
tollCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _parseDouble(dynamic v) {
|
||||||
|
if (v is double) return v;
|
||||||
|
if (v is int) return v.toDouble();
|
||||||
|
if (v is String) return double.tryParse(v) ?? 0.0;
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _parseInt(dynamic v) {
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is double) return v.toInt();
|
||||||
|
if (v is String) return int.tryParse(v) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prix des carburants (stocké dans Firestore app_config/fuel_prices)
|
||||||
|
class FuelPrices {
|
||||||
|
final double diesel; // €/L
|
||||||
|
final double essence; // €/L
|
||||||
|
final double electricite; // €/kWh
|
||||||
|
|
||||||
|
const FuelPrices({
|
||||||
|
this.diesel = 1.60,
|
||||||
|
this.essence = 1.75,
|
||||||
|
this.electricite = 0.22,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FuelPrices.fromMap(Map<String, dynamic> map) {
|
||||||
|
return FuelPrices(
|
||||||
|
diesel: _parseDouble(map['diesel'] ?? 1.60),
|
||||||
|
essence: _parseDouble(map['essence'] ?? 1.75),
|
||||||
|
electricite: _parseDouble(map['electricite'] ?? 0.22),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() => {
|
||||||
|
'diesel': diesel,
|
||||||
|
'essence': essence,
|
||||||
|
'electricite': electricite,
|
||||||
|
};
|
||||||
|
|
||||||
|
double priceForFuelType(String fuelType) {
|
||||||
|
switch (fuelType.toLowerCase()) {
|
||||||
|
case 'diesel':
|
||||||
|
return diesel;
|
||||||
|
case 'essence':
|
||||||
|
return essence;
|
||||||
|
case 'electrique':
|
||||||
|
case 'électrique':
|
||||||
|
return electricite;
|
||||||
|
default:
|
||||||
|
return diesel;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _parseDouble(dynamic v) {
|
||||||
|
if (v is double) return v;
|
||||||
|
if (v is int) return v.toDouble();
|
||||||
|
if (v is String) return double.tryParse(v) ?? 0.0;
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
|
class VehicleModel {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final double consumptionPer100km; // L/100km (ou kWh/100km si électrique)
|
||||||
|
final String fuelType; // 'Diesel', 'Essence', 'Electrique'
|
||||||
|
final double maintenanceCostPerKm; // €/km
|
||||||
|
final int tollCategoryId; // 1 à 5 (catégorie Ulys)
|
||||||
|
final DateTime? createdAt;
|
||||||
|
|
||||||
|
const VehicleModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.consumptionPer100km,
|
||||||
|
required this.fuelType,
|
||||||
|
required this.maintenanceCostPerKm,
|
||||||
|
required this.tollCategoryId,
|
||||||
|
this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory VehicleModel.fromMap(Map<String, dynamic> map, String id) {
|
||||||
|
return VehicleModel(
|
||||||
|
id: id,
|
||||||
|
name: (map['name'] ?? '').toString(),
|
||||||
|
consumptionPer100km: _parseDouble(map['consumptionPer100km'] ?? 0),
|
||||||
|
fuelType: (map['fuelType'] ?? 'Diesel').toString(),
|
||||||
|
maintenanceCostPerKm: _parseDouble(map['maintenanceCostPerKm'] ?? 0),
|
||||||
|
tollCategoryId: _parseInt(map['tollCategoryId'] ?? 2),
|
||||||
|
createdAt: map['createdAt'] is Timestamp
|
||||||
|
? (map['createdAt'] as Timestamp).toDate()
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory VehicleModel.fromFirestore(DocumentSnapshot doc) {
|
||||||
|
return VehicleModel.fromMap(
|
||||||
|
doc.data() as Map<String, dynamic>,
|
||||||
|
doc.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toMap() {
|
||||||
|
return {
|
||||||
|
'name': name,
|
||||||
|
'consumptionPer100km': consumptionPer100km,
|
||||||
|
'fuelType': fuelType,
|
||||||
|
'maintenanceCostPerKm': maintenanceCostPerKm,
|
||||||
|
'tollCategoryId': tollCategoryId,
|
||||||
|
'createdAt': createdAt != null
|
||||||
|
? Timestamp.fromDate(createdAt!)
|
||||||
|
: FieldValue.serverTimestamp(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
VehicleModel copyWith({
|
||||||
|
String? id,
|
||||||
|
String? name,
|
||||||
|
double? consumptionPer100km,
|
||||||
|
String? fuelType,
|
||||||
|
double? maintenanceCostPerKm,
|
||||||
|
int? tollCategoryId,
|
||||||
|
}) {
|
||||||
|
return VehicleModel(
|
||||||
|
id: id ?? this.id,
|
||||||
|
name: name ?? this.name,
|
||||||
|
consumptionPer100km: consumptionPer100km ?? this.consumptionPer100km,
|
||||||
|
fuelType: fuelType ?? this.fuelType,
|
||||||
|
maintenanceCostPerKm: maintenanceCostPerKm ?? this.maintenanceCostPerKm,
|
||||||
|
tollCategoryId: tollCategoryId ?? this.tollCategoryId,
|
||||||
|
createdAt: createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Label lisible pour l'unité de consommation
|
||||||
|
String get consumptionUnit {
|
||||||
|
if (fuelType == 'Electrique') return 'kWh/100km';
|
||||||
|
return 'L/100km';
|
||||||
|
}
|
||||||
|
|
||||||
|
static double _parseDouble(dynamic v) {
|
||||||
|
if (v is double) return v;
|
||||||
|
if (v is int) return v.toDouble();
|
||||||
|
if (v is String) return double.tryParse(v) ?? 0.0;
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _parseInt(dynamic v) {
|
||||||
|
if (v is int) return v;
|
||||||
|
if (v is double) return v.toInt();
|
||||||
|
if (v is String) return int.tryParse(v) ?? 2;
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ class EquipmentProvider extends ChangeNotifier {
|
|||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
|
bool _isFullListLoaded = false;
|
||||||
|
|
||||||
// Mode de chargement (pagination vs full)
|
// Mode de chargement (pagination vs full)
|
||||||
bool _usePagination = false;
|
bool _usePagination = false;
|
||||||
@@ -48,6 +49,7 @@ class EquipmentProvider extends ChangeNotifier {
|
|||||||
bool get isLoadingMore => _isLoadingMore;
|
bool get isLoadingMore => _isLoadingMore;
|
||||||
bool get hasMore => _hasMore;
|
bool get hasMore => _hasMore;
|
||||||
bool get isInitialized => _isInitialized;
|
bool get isInitialized => _isInitialized;
|
||||||
|
bool get isFullListLoaded => _isFullListLoaded;
|
||||||
bool get usePagination => _usePagination;
|
bool get usePagination => _usePagination;
|
||||||
|
|
||||||
/// S'assure que les équipements sont chargés (charge si nécessaire)
|
/// S'assure que les équipements sont chargés (charge si nécessaire)
|
||||||
@@ -58,16 +60,8 @@ class EquipmentProvider extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si initialisé MAIS _equipment est vide, forcer le rechargement
|
// Si déjà initialisé avec le cache complet des données, ne rien faire
|
||||||
if (_isInitialized && _equipment.isEmpty) {
|
if (_isFullListLoaded) {
|
||||||
print('[EquipmentProvider] Equipment marked as initialized but _equipment is empty! Force reloading...');
|
|
||||||
_isInitialized = false; // Réinitialiser le flag
|
|
||||||
await loadEquipments();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si déjà initialisé avec des données, ne rien faire
|
|
||||||
if (_isInitialized) {
|
|
||||||
print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...');
|
print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -120,7 +114,7 @@ class EquipmentProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
// Extraire les modèles et marques uniques
|
// Extraire les modèles et marques uniques
|
||||||
_extractUniqueValues();
|
_extractUniqueValues();
|
||||||
|
_isFullListLoaded = true;
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -436,6 +430,8 @@ class EquipmentProvider extends ChangeNotifier {
|
|||||||
Future<void> deleteEquipment(String equipmentId, {bool forceDelete = false}) async {
|
Future<void> deleteEquipment(String equipmentId, {bool forceDelete = false}) async {
|
||||||
try {
|
try {
|
||||||
await _dataService.deleteEquipment(equipmentId, forceDelete: forceDelete);
|
await _dataService.deleteEquipment(equipmentId, forceDelete: forceDelete);
|
||||||
|
_isFullListLoaded = false;
|
||||||
|
_equipment.clear();
|
||||||
if (_usePagination) {
|
if (_usePagination) {
|
||||||
await reload();
|
await reload();
|
||||||
} else {
|
} else {
|
||||||
@@ -451,6 +447,8 @@ class EquipmentProvider extends ChangeNotifier {
|
|||||||
Future<void> addEquipment(EquipmentModel equipment) async {
|
Future<void> addEquipment(EquipmentModel equipment) async {
|
||||||
try {
|
try {
|
||||||
await _dataService.createEquipment(equipment.id, equipment.toMap());
|
await _dataService.createEquipment(equipment.id, equipment.toMap());
|
||||||
|
_isFullListLoaded = false;
|
||||||
|
_equipment.clear();
|
||||||
if (_usePagination) {
|
if (_usePagination) {
|
||||||
await reload();
|
await reload();
|
||||||
} else {
|
} else {
|
||||||
@@ -466,6 +464,8 @@ class EquipmentProvider extends ChangeNotifier {
|
|||||||
Future<void> updateEquipment(EquipmentModel equipment) async {
|
Future<void> updateEquipment(EquipmentModel equipment) async {
|
||||||
try {
|
try {
|
||||||
await _dataService.updateEquipment(equipment.id, equipment.toMap());
|
await _dataService.updateEquipment(equipment.id, equipment.toMap());
|
||||||
|
_isFullListLoaded = false;
|
||||||
|
_equipment.clear();
|
||||||
if (_usePagination) {
|
if (_usePagination) {
|
||||||
await reload();
|
await reload();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import 'package:em2rp/services/equipment_status_calculator.dart';
|
|
||||||
import 'package:em2rp/services/api_service.dart';
|
|
||||||
|
|
||||||
class EventPreparationService {
|
class EventPreparationService {
|
||||||
final ApiService _apiService = apiService;
|
|
||||||
|
|
||||||
/// Retourne true si l'équipement était absent du flux événementiel.
|
/// Retourne true si l'équipement était absent du flux événementiel.
|
||||||
///
|
///
|
||||||
@@ -42,35 +38,7 @@ class EventPreparationService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// === PRÉPARATION ===
|
|
||||||
|
|
||||||
/// Valider un équipement individuel en préparation
|
|
||||||
Future<void> validateEquipmentPreparation(String eventId, String equipmentId) async {
|
|
||||||
try {
|
|
||||||
await _apiService.call('validateEquipmentPreparation', {
|
|
||||||
'eventId': eventId,
|
|
||||||
'equipmentId': equipmentId,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
print('Error validating equipment preparation: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Valider tous les équipements en préparation
|
|
||||||
Future<void> validateAllPreparation(String eventId) async {
|
|
||||||
try {
|
|
||||||
await _apiService.call('validateAllPreparation', {
|
|
||||||
'eventId': eventId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invalider le cache des statuts d'équipement
|
|
||||||
EquipmentStatusCalculator.invalidateGlobalCache();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error validating all preparation: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ces méthodes ne sont plus utilisées et devraient être remplacées par des Cloud Functions
|
// Ces méthodes ne sont plus utilisées et devraient être remplacées par des Cloud Functions
|
||||||
// si nécessaire dans le futur
|
// si nécessaire dans le futur
|
||||||
@@ -85,46 +53,8 @@ class EventPreparationService {
|
|||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// === RETOUR ===
|
|
||||||
|
|
||||||
|
|
||||||
/// Valider le retour d'un équipement individuel
|
|
||||||
Future<void> validateEquipmentReturn(
|
|
||||||
String eventId,
|
|
||||||
String equipmentId, {
|
|
||||||
int? returnedQuantity,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
await _apiService.call('validateEquipmentReturn', {
|
|
||||||
'eventId': eventId,
|
|
||||||
'equipmentId': equipmentId,
|
|
||||||
if (returnedQuantity != null) 'returnedQuantity': returnedQuantity,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
print('Error validating equipment return: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Valider tous les retours
|
|
||||||
Future<void> validateAllReturn(
|
|
||||||
String eventId, [
|
|
||||||
Map<String, int>? returnedQuantities,
|
|
||||||
]) async {
|
|
||||||
try {
|
|
||||||
await _apiService.call('validateAllReturn', {
|
|
||||||
'eventId': eventId,
|
|
||||||
if (returnedQuantities != null) 'returnedQuantities': returnedQuantities,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invalider le cache des statuts d'équipement
|
|
||||||
EquipmentStatusCalculator.invalidateGlobalCache();
|
|
||||||
} catch (e) {
|
|
||||||
print('Error validating all return: $e');
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@Deprecated('Use Cloud Functions instead')
|
@Deprecated('Use Cloud Functions instead')
|
||||||
Future<void> completeReturnWithMissing(
|
Future<void> completeReturnWithMissing(
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:em2rp/config/api_config.dart';
|
||||||
|
import 'package:em2rp/models/depot_model.dart';
|
||||||
|
import 'package:em2rp/models/route_result_model.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
|
class TravelService {
|
||||||
|
final FirebaseFirestore _db = FirebaseFirestore.instance;
|
||||||
|
|
||||||
|
// ─── Auth token ───────────────────────────────────────────
|
||||||
|
Future<String?> _getToken() async {
|
||||||
|
final user = FirebaseAuth.instance.currentUser;
|
||||||
|
return await user?.getIdToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, String>> _headers() async {
|
||||||
|
final token = await _getToken();
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
if (token != null) 'Authorization': 'Bearer $token',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Autocomplétion d'adresses ────────────────────────────
|
||||||
|
Future<List<String>> autocompleteAddress(String query) async {
|
||||||
|
if (query.trim().length < 3) return [];
|
||||||
|
try {
|
||||||
|
final headers = await _headers();
|
||||||
|
final url = Uri.parse('${ApiConfig.baseUrl}/googleMapsAutocomplete');
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({'data': {'query': query}}),
|
||||||
|
);
|
||||||
|
if (response.statusCode != 200) return [];
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final predictions = data['predictions'] as List<dynamic>? ?? [];
|
||||||
|
return predictions
|
||||||
|
.map((p) => (p['description'] ?? '').toString())
|
||||||
|
.where((s) => s.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[Travel] autocompleteAddress error', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Calcul des itinéraires ───────────────────────────────
|
||||||
|
Future<List<RouteResult>> computeRoutes({
|
||||||
|
required String origin,
|
||||||
|
required String destination,
|
||||||
|
int vehicleTollCategory = 2,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final headers = await _headers();
|
||||||
|
final url = Uri.parse('${ApiConfig.baseUrl}/googleMapsComputeRoute');
|
||||||
|
final response = await http.post(
|
||||||
|
url,
|
||||||
|
headers: headers,
|
||||||
|
body: jsonEncode({
|
||||||
|
'data': {
|
||||||
|
'origin': origin,
|
||||||
|
'destination': destination,
|
||||||
|
'vehicleTollCategory': vehicleTollCategory,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
final err = jsonDecode(response.body);
|
||||||
|
throw Exception('googleMapsComputeRoute: ${err['error']}');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
final routes = data['routes'] as List<dynamic>? ?? [];
|
||||||
|
return routes
|
||||||
|
.map((r) => RouteResult.fromMap(r as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
DebugLog.error('[Travel] computeRoutes error', e);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Prix des carburants ───────────────────────────────────
|
||||||
|
Future<FuelPrices> getFuelPrices() async {
|
||||||
|
try {
|
||||||
|
final doc = await _db.collection('app_config').doc('fuel_prices').get();
|
||||||
|
if (!doc.exists) return const FuelPrices();
|
||||||
|
return FuelPrices.fromMap(doc.data()!);
|
||||||
|
} catch (e) {
|
||||||
|
return const FuelPrices();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> saveFuelPrices(FuelPrices prices) async {
|
||||||
|
await _db.collection('app_config').doc('fuel_prices').set(prices.toMap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Dépôts ───────────────────────────────────────────────
|
||||||
|
Future<List<DepotModel>> getDepots() async {
|
||||||
|
final snap = await _db.collection('depots').orderBy('name').get();
|
||||||
|
return snap.docs.map((d) => DepotModel.fromFirestore(d)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Stream<List<DepotModel>> watchDepots() {
|
||||||
|
return _db
|
||||||
|
.collection('depots')
|
||||||
|
.orderBy('name')
|
||||||
|
.snapshots()
|
||||||
|
.map((s) => s.docs.map((d) => DepotModel.fromFirestore(d)).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> addDepot(DepotModel depot) async {
|
||||||
|
final ref = await _db.collection('depots').add(depot.toMap());
|
||||||
|
return ref.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateDepot(DepotModel depot) async {
|
||||||
|
final map = depot.toMap();
|
||||||
|
map.remove('createdAt');
|
||||||
|
await _db.collection('depots').doc(depot.id).update(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteDepot(String depotId) async {
|
||||||
|
await _db.collection('depots').doc(depotId).delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Instance singleton
|
||||||
|
final travelService = TravelService();
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:em2rp/models/vehicle_model.dart';
|
||||||
|
|
||||||
|
class VehicleService {
|
||||||
|
final FirebaseFirestore _db = FirebaseFirestore.instance;
|
||||||
|
static const String _collection = 'vehicles';
|
||||||
|
|
||||||
|
/// Récupère tous les véhicules, triés par nom.
|
||||||
|
Future<List<VehicleModel>> getVehicles() async {
|
||||||
|
final snapshot = await _db
|
||||||
|
.collection(_collection)
|
||||||
|
.orderBy('name')
|
||||||
|
.get();
|
||||||
|
return snapshot.docs
|
||||||
|
.map((doc) => VehicleModel.fromFirestore(doc))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream en temps réel
|
||||||
|
Stream<List<VehicleModel>> watchVehicles() {
|
||||||
|
return _db
|
||||||
|
.collection(_collection)
|
||||||
|
.orderBy('name')
|
||||||
|
.snapshots()
|
||||||
|
.map((snap) =>
|
||||||
|
snap.docs.map((d) => VehicleModel.fromFirestore(d)).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ajoute un véhicule
|
||||||
|
Future<String> addVehicle(VehicleModel vehicle) async {
|
||||||
|
final ref = await _db.collection(_collection).add(vehicle.toMap());
|
||||||
|
return ref.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modifie un véhicule existant
|
||||||
|
Future<void> updateVehicle(VehicleModel vehicle) async {
|
||||||
|
final map = vehicle.toMap();
|
||||||
|
map.remove('createdAt'); // Ne pas écraser la date de création
|
||||||
|
await _db.collection(_collection).doc(vehicle.id).update(map);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supprime un véhicule
|
||||||
|
Future<void> deleteVehicle(String vehicleId) async {
|
||||||
|
await _db.collection(_collection).doc(vehicleId).delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,9 @@ import 'package:provider/provider.dart';
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import '../views/login_page.dart';
|
import '../views/login_page.dart';
|
||||||
import '../utils/colors.dart';
|
|
||||||
import '../providers/local_user_provider.dart';
|
import '../providers/local_user_provider.dart';
|
||||||
|
import '../views/widgets/common/startup_splash_screen.dart';
|
||||||
|
|
||||||
/// Gate de démarrage qui attend la restauration Firebase Auth avant
|
/// Gate de démarrage qui attend la restauration Firebase Auth avant
|
||||||
/// d'afficher soit le contenu connecté, soit la page de connexion.
|
/// d'afficher soit le contenu connecté, soit la page de connexion.
|
||||||
@@ -43,14 +44,14 @@ class AppStartGate extends StatelessWidget {
|
|||||||
stream: safeAuthStream,
|
stream: safeAuthStream,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
return const _StartupSplashScreen();
|
return const StartupSplashScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.hasError) {
|
if (snapshot.hasError) {
|
||||||
// En théorie handleError évite d'arriver ici, mais on garde
|
// En théorie handleError évite d'arriver ici, mais on garde
|
||||||
// une protection supplémentaire.
|
// une protection supplémentaire.
|
||||||
debugPrint('[AppStartGate] snapshot error: ${snapshot.error}');
|
debugPrint('[AppStartGate] snapshot error: ${snapshot.error}');
|
||||||
return const _StartupSplashScreen(message: 'Erreur de connexion');
|
return const StartupSplashScreen(message: 'Erreur de connexion');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (snapshot.data != null) {
|
if (snapshot.data != null) {
|
||||||
@@ -101,44 +102,9 @@ class _AuthenticatedBootstrapState extends State<_AuthenticatedBootstrap> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const _StartupSplashScreen();
|
return const StartupSplashScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StartupSplashScreen extends StatelessWidget {
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
const _StartupSplashScreen({this.message = 'Démarrage...'});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
body: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Image.asset(
|
|
||||||
'assets/logos/RectangleLogoBlack.png',
|
|
||||||
width: 160,
|
|
||||||
height: 160,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
errorBuilder: (context, error, stackTrace) {
|
|
||||||
return const Icon(
|
|
||||||
Icons.event_available,
|
|
||||||
size: 72,
|
|
||||||
color: AppColors.rouge,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
const CircularProgressIndicator(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(message),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:em2rp/views/login_page.dart';
|
|||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/startup_splash_screen.dart';
|
||||||
|
|
||||||
class AuthGuard extends StatelessWidget {
|
class AuthGuard extends StatelessWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@@ -33,12 +34,7 @@ class AuthGuard extends StatelessWidget {
|
|||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
return const Scaffold(
|
return const StartupSplashScreen(message: 'Chargement du profil...');
|
||||||
backgroundColor: Colors.white,
|
|
||||||
body: Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si l'utilisateur n'est pas connecté
|
// Si l'utilisateur n'est pas connecté
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
|
||||||
|
List<LatLng> safeDecodePolyline(String encoded) {
|
||||||
|
if (encoded.isEmpty) return [];
|
||||||
|
try {
|
||||||
|
List<LatLng> poly = [];
|
||||||
|
int index = 0, len = encoded.length;
|
||||||
|
int lat = 0, lng = 0;
|
||||||
|
|
||||||
|
while (index < len) {
|
||||||
|
int b, shift = 0, result = 0;
|
||||||
|
do {
|
||||||
|
if (index >= len) break;
|
||||||
|
b = encoded.codeUnitAt(index++) - 63;
|
||||||
|
result |= (b & 0x1f) << shift;
|
||||||
|
shift += 5;
|
||||||
|
} while (b >= 0x20);
|
||||||
|
|
||||||
|
// Dart Web bitwise operations (~ and >>) can cause 32-bit unsigned wrap-around
|
||||||
|
// Using arithmetic avoids the issue where lat becomes 42995.xxxx (offset by 2^32)
|
||||||
|
int dlat = (result & 1) != 0 ? -((result >> 1) + 1) : (result >> 1);
|
||||||
|
lat += dlat;
|
||||||
|
// Correction manuelle au cas où un wrap unsigned 32-bit s'est produit
|
||||||
|
if (lat > 2147483647) lat -= 4294967296;
|
||||||
|
|
||||||
|
shift = 0;
|
||||||
|
result = 0;
|
||||||
|
do {
|
||||||
|
if (index >= len) break;
|
||||||
|
b = encoded.codeUnitAt(index++) - 63;
|
||||||
|
result |= (b & 0x1f) << shift;
|
||||||
|
shift += 5;
|
||||||
|
} while (b >= 0x20);
|
||||||
|
|
||||||
|
int dlng = (result & 1) != 0 ? -((result >> 1) + 1) : (result >> 1);
|
||||||
|
lng += dlng;
|
||||||
|
if (lng > 2147483647) lng -= 4294967296;
|
||||||
|
|
||||||
|
double finalLat = lat / 1e5;
|
||||||
|
double finalLng = lng / 1e5;
|
||||||
|
|
||||||
|
poly.add(LatLng(finalLat, finalLng));
|
||||||
|
}
|
||||||
|
|
||||||
|
return poly;
|
||||||
|
} catch (e) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[POLYLINE] Erreur décodage: $e');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import 'package:em2rp/utils/performance_monitor.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/startup_splash_screen.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:table_calendar/table_calendar.dart';
|
import 'package:table_calendar/table_calendar.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
@@ -864,11 +865,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (eventProvider.isLoading) {
|
if (eventProvider.isLoading) {
|
||||||
return const Scaffold(
|
return const StartupSplashScreen(message: 'Chargement des événements...');
|
||||||
body: Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:em2rp/utils/id_generator.dart';
|
|||||||
import 'package:em2rp/services/data_service.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:em2rp/utils/debouncer.dart';
|
import 'package:em2rp/utils/debouncer.dart';
|
||||||
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
|
|
||||||
class ContainerFormPage extends StatefulWidget {
|
class ContainerFormPage extends StatefulWidget {
|
||||||
final ContainerModel? container;
|
final ContainerModel? container;
|
||||||
@@ -90,369 +91,436 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCard({
|
||||||
|
required String title,
|
||||||
|
required IconData icon,
|
||||||
|
required List<Widget> children,
|
||||||
|
}) {
|
||||||
|
return Card(
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: Colors.black12,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
side: BorderSide(color: Colors.grey.shade200, width: 1),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: AppColors.rouge, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: AppColors.noir,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 24, thickness: 1),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: CustomAppBar(
|
||||||
title: Text(_isEditing ? 'Modifier boite' : 'Nouvelle boite'),
|
title: _isEditing ? 'Modifier boîte' : 'Nouvelle boîte',
|
||||||
backgroundColor: AppColors.rouge,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
),
|
),
|
||||||
body: Form(
|
body: Center(
|
||||||
key: _formKey,
|
child: ConstrainedBox(
|
||||||
child: ListView(
|
constraints: const BoxConstraints(maxWidth: 800),
|
||||||
padding: const EdgeInsets.all(24),
|
child: SingleChildScrollView(
|
||||||
children: [
|
padding: const EdgeInsets.all(24),
|
||||||
// Nom
|
child: Form(
|
||||||
TextFormField(
|
key: _formKey,
|
||||||
controller: _nameController,
|
child: Column(
|
||||||
decoration: const InputDecoration(
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
labelText: 'Nom du container *',
|
children: [
|
||||||
hintText: 'ex: Flight Case Beam 7R',
|
// Card 1: Informations Générales
|
||||||
border: OutlineInputBorder(),
|
_buildCard(
|
||||||
prefixIcon: Icon(Icons.label),
|
title: 'Informations générales',
|
||||||
),
|
icon: Icons.info_outline,
|
||||||
onChanged: (_) => _updateIdFromName(),
|
children: [
|
||||||
validator: (value) {
|
// Nom
|
||||||
if (value == null || value.isEmpty) {
|
TextFormField(
|
||||||
return 'Veuillez entrer un nom';
|
controller: _nameController,
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// ID
|
|
||||||
ValueListenableBuilder<bool>(
|
|
||||||
valueListenable: _autoGenerateIdNotifier,
|
|
||||||
builder: (context, autoGenerateId, child) {
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _idController,
|
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Identifiant *',
|
labelText: 'Nom du container *',
|
||||||
hintText: 'ex: FLIGHTCASE_BEAM',
|
hintText: 'ex: Flight Case Beam 7R',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
prefixIcon: Icon(Icons.qr_code),
|
prefixIcon: Icon(Icons.label),
|
||||||
),
|
),
|
||||||
enabled: !autoGenerateId || _isEditing,
|
onChanged: (_) => _updateIdFromName(),
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return 'Veuillez entrer un identifiant';
|
return 'Veuillez entrer un nom';
|
||||||
}
|
}
|
||||||
final validation = IdGenerator.validateContainerId(value);
|
return null;
|
||||||
return validation;
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
if (!_isEditing) ...[
|
|
||||||
const SizedBox(width: 8),
|
// ID
|
||||||
IconButton(
|
ValueListenableBuilder<bool>(
|
||||||
icon: Icon(
|
valueListenable: _autoGenerateIdNotifier,
|
||||||
autoGenerateId ? Icons.lock : Icons.lock_open,
|
builder: (context, autoGenerateId, child) {
|
||||||
color: autoGenerateId ? AppColors.rouge : Colors.grey,
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _idController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Identifiant *',
|
||||||
|
hintText: 'ex: FLIGHTCASE_BEAM',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.qr_code),
|
||||||
|
),
|
||||||
|
enabled: !autoGenerateId || _isEditing,
|
||||||
|
validator: (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return 'Veuillez entrer un identifiant';
|
||||||
|
}
|
||||||
|
final validation = IdGenerator.validateContainerId(value);
|
||||||
|
return validation;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_isEditing) ...[
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
autoGenerateId ? Icons.lock : Icons.lock_open,
|
||||||
|
color: autoGenerateId ? AppColors.rouge : Colors.grey,
|
||||||
|
),
|
||||||
|
tooltip: autoGenerateId
|
||||||
|
? 'Génération automatique'
|
||||||
|
: 'Saisie manuelle',
|
||||||
|
onPressed: () {
|
||||||
|
_autoGenerateIdNotifier.value = !autoGenerateId;
|
||||||
|
if (_autoGenerateIdNotifier.value) {
|
||||||
|
_updateIdFromName();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Type
|
||||||
|
DropdownButtonFormField<ContainerType>(
|
||||||
|
initialValue: _selectedType,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Type de container *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.category),
|
||||||
),
|
),
|
||||||
tooltip: autoGenerateId
|
items: ContainerType.values.map((type) {
|
||||||
? 'Génération automatique'
|
return DropdownMenuItem(
|
||||||
: 'Saisie manuelle',
|
value: type,
|
||||||
onPressed: () {
|
child: Row(
|
||||||
_autoGenerateIdNotifier.value = !autoGenerateId;
|
children: [
|
||||||
if (_autoGenerateIdNotifier.value) {
|
type.getIcon(size: 20, color: AppColors.rouge),
|
||||||
_updateIdFromName();
|
const SizedBox(width: 8),
|
||||||
|
Text(type.label),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedType = value;
|
||||||
|
_updateIdFromType();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Statut
|
||||||
|
DropdownButtonFormField<EquipmentStatus>(
|
||||||
|
initialValue: _selectedStatus,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Statut *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.info_outline),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
EquipmentStatus.available,
|
||||||
|
EquipmentStatus.inUse,
|
||||||
|
EquipmentStatus.maintenance,
|
||||||
|
EquipmentStatus.outOfService,
|
||||||
|
].map((status) {
|
||||||
|
String label;
|
||||||
|
switch (status) {
|
||||||
|
case EquipmentStatus.available:
|
||||||
|
label = 'Disponible';
|
||||||
|
break;
|
||||||
|
case EquipmentStatus.inUse:
|
||||||
|
label = 'En prestation';
|
||||||
|
break;
|
||||||
|
case EquipmentStatus.maintenance:
|
||||||
|
label = 'En maintenance';
|
||||||
|
break;
|
||||||
|
case EquipmentStatus.outOfService:
|
||||||
|
label = 'Hors service';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
label = 'Autre';
|
||||||
|
}
|
||||||
|
return DropdownMenuItem(
|
||||||
|
value: status,
|
||||||
|
child: Text(label),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
setState(() {
|
||||||
|
_selectedStatus = value;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Type
|
|
||||||
DropdownButtonFormField<ContainerType>(
|
|
||||||
initialValue: _selectedType,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Type de container *',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(Icons.category),
|
|
||||||
),
|
|
||||||
items: ContainerType.values.map((type) {
|
|
||||||
return DropdownMenuItem(
|
|
||||||
value: type,
|
|
||||||
child: Text(type.label),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
setState(() {
|
|
||||||
_selectedType = value;
|
|
||||||
_updateIdFromType();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Statut
|
|
||||||
DropdownButtonFormField<EquipmentStatus>(
|
|
||||||
initialValue: _selectedStatus,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Statut *',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(Icons.info),
|
|
||||||
),
|
|
||||||
items: [
|
|
||||||
EquipmentStatus.available,
|
|
||||||
EquipmentStatus.inUse,
|
|
||||||
EquipmentStatus.maintenance,
|
|
||||||
EquipmentStatus.outOfService,
|
|
||||||
].map((status) {
|
|
||||||
String label;
|
|
||||||
switch (status) {
|
|
||||||
case EquipmentStatus.available:
|
|
||||||
label = 'Disponible';
|
|
||||||
break;
|
|
||||||
case EquipmentStatus.inUse:
|
|
||||||
label = 'En prestation';
|
|
||||||
break;
|
|
||||||
case EquipmentStatus.maintenance:
|
|
||||||
label = 'En maintenance';
|
|
||||||
break;
|
|
||||||
case EquipmentStatus.outOfService:
|
|
||||||
label = 'Hors service';
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
label = 'Autre';
|
|
||||||
}
|
|
||||||
return DropdownMenuItem(
|
|
||||||
value: status,
|
|
||||||
child: Text(label),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
onChanged: (value) {
|
|
||||||
if (value != null) {
|
|
||||||
setState(() {
|
|
||||||
_selectedStatus = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Section Caractéristiques physiques
|
|
||||||
Text(
|
|
||||||
'Caractéristiques physiques',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
const Divider(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// Poids
|
// Card 2: Caractéristiques Physiques
|
||||||
TextFormField(
|
_buildCard(
|
||||||
controller: _weightController,
|
title: 'Caractéristiques physiques',
|
||||||
decoration: const InputDecoration(
|
icon: Icons.scale_outlined,
|
||||||
labelText: 'Poids à vide (kg)',
|
children: [
|
||||||
hintText: 'ex: 15.5',
|
// Poids
|
||||||
border: OutlineInputBorder(),
|
TextFormField(
|
||||||
prefixIcon: Icon(Icons.scale),
|
controller: _weightController,
|
||||||
),
|
decoration: const InputDecoration(
|
||||||
keyboardType:
|
labelText: 'Poids à vide (kg)',
|
||||||
const TextInputType.numberWithOptions(decimal: true),
|
hintText: 'ex: 15.5',
|
||||||
validator: (value) {
|
border: OutlineInputBorder(),
|
||||||
if (value != null && value.isNotEmpty) {
|
prefixIcon: Icon(Icons.scale),
|
||||||
if (double.tryParse(value) == null) {
|
),
|
||||||
return 'Veuillez entrer un nombre valide';
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
}
|
validator: (value) {
|
||||||
}
|
if (value != null && value.isNotEmpty) {
|
||||||
return null;
|
if (double.tryParse(value) == null) {
|
||||||
},
|
return 'Veuillez entrer un nombre valide';
|
||||||
),
|
}
|
||||||
const SizedBox(height: 16),
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Dimensions
|
// Dimensions
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
controller: _lengthController,
|
controller: _lengthController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Longueur (cm)',
|
labelText: 'Longueur (cm)',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
),
|
),
|
||||||
keyboardType:
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
TextInputType.numberWithOptions(decimal: true),
|
validator: (value) {
|
||||||
validator: (value) {
|
if (value != null && value.isNotEmpty) {
|
||||||
if (value != null && value.isNotEmpty) {
|
if (double.tryParse(value) == null) {
|
||||||
if (double.tryParse(value) == null) {
|
return 'Nombre invalide';
|
||||||
return 'Nombre invalide';
|
}
|
||||||
}
|
}
|
||||||
}
|
return null;
|
||||||
return null;
|
},
|
||||||
},
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _widthController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Largeur (cm)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
validator: (value) {
|
||||||
|
if (value != null && value.isNotEmpty) {
|
||||||
|
if (double.tryParse(value) == null) {
|
||||||
|
return 'Nombre invalide';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _heightController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Hauteur (cm)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
validator: (value) {
|
||||||
|
if (value != null && value.isNotEmpty) {
|
||||||
|
if (double.tryParse(value) == null) {
|
||||||
|
return 'Nombre invalide';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _widthController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Largeur (cm)',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
keyboardType:
|
|
||||||
TextInputType.numberWithOptions(decimal: true),
|
|
||||||
validator: (value) {
|
|
||||||
if (value != null && value.isNotEmpty) {
|
|
||||||
if (double.tryParse(value) == null) {
|
|
||||||
return 'Nombre invalide';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _heightController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Hauteur (cm)',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
keyboardType:
|
|
||||||
TextInputType.numberWithOptions(decimal: true),
|
|
||||||
validator: (value) {
|
|
||||||
if (value != null && value.isNotEmpty) {
|
|
||||||
if (double.tryParse(value) == null) {
|
|
||||||
return 'Nombre invalide';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Section Équipements
|
// Card 3: Équipements dans ce container
|
||||||
Text(
|
_buildCard(
|
||||||
'Équipements dans ce container',
|
title: 'Équipements dans ce container',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
icon: Icons.inventory_2_outlined,
|
||||||
fontWeight: FontWeight.bold,
|
children: [
|
||||||
),
|
if (_selectedEquipmentIds.isNotEmpty)
|
||||||
),
|
Container(
|
||||||
const Divider(),
|
padding: const EdgeInsets.all(12),
|
||||||
const SizedBox(height: 16),
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade200),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${_selectedEquipmentIds.length} équipement(s) sélectionné(s) :',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: _selectedEquipmentIds.map((id) {
|
||||||
|
return Chip(
|
||||||
|
label: Text(
|
||||||
|
id,
|
||||||
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
deleteIcon: const Icon(Icons.close, size: 16),
|
||||||
|
onDeleted: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedEquipmentIds.remove(id);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
backgroundColor: AppColors.rouge.withValues(alpha: 0.08),
|
||||||
|
side: BorderSide(color: AppColors.rouge.withValues(alpha: 0.2)),
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade200),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
color: Colors.grey.shade50,
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Text(
|
||||||
|
'Aucun équipement sélectionné',
|
||||||
|
style: TextStyle(color: Colors.grey),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Liste des équipements sélectionnés
|
// Bouton pour ajouter des équipements
|
||||||
if (_selectedEquipmentIds.isNotEmpty)
|
OutlinedButton.icon(
|
||||||
Container(
|
onPressed: _selectEquipment,
|
||||||
padding: const EdgeInsets.all(12),
|
icon: const Icon(Icons.add),
|
||||||
decoration: BoxDecoration(
|
label: const Text('Ajouter des équipements'),
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
style: OutlinedButton.styleFrom(
|
||||||
borderRadius: BorderRadius.circular(8),
|
minimumSize: const Size(double.infinity, 48),
|
||||||
),
|
side: BorderSide(color: AppColors.rouge),
|
||||||
child: Column(
|
foregroundColor: AppColors.rouge,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
),
|
||||||
children: [
|
),
|
||||||
Text(
|
],
|
||||||
'${_selectedEquipmentIds.length} équipement(s) sélectionné(s)',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: _selectedEquipmentIds.map((id) {
|
|
||||||
return Chip(
|
|
||||||
label: Text(id),
|
|
||||||
deleteIcon: const Icon(Icons.close, size: 18),
|
|
||||||
onDeleted: () {
|
|
||||||
setState(() {
|
|
||||||
_selectedEquipmentIds.remove(id);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
color: Colors.grey.shade50,
|
|
||||||
),
|
|
||||||
child: const Center(
|
|
||||||
child: Text(
|
|
||||||
'Aucun équipement sélectionné',
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 20),
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// Bouton pour ajouter des équipements
|
// Card 4: Notes
|
||||||
OutlinedButton.icon(
|
_buildCard(
|
||||||
onPressed: _selectEquipment,
|
title: 'Notes & Remarques',
|
||||||
icon: const Icon(Icons.add),
|
icon: Icons.notes_outlined,
|
||||||
label: const Text('Ajouter des équipements'),
|
children: [
|
||||||
style: OutlinedButton.styleFrom(
|
TextFormField(
|
||||||
minimumSize: const Size(double.infinity, 48),
|
controller: _notesController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Notes complémentaires',
|
||||||
|
hintText: 'Informations additionnelles...',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.edit_note),
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Annuler', style: TextStyle(fontSize: 16)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _saveContainer,
|
||||||
|
icon: const Icon(Icons.save, color: Colors.white),
|
||||||
|
label: Text(
|
||||||
|
_isEditing ? 'Enregistrer' : 'Créer',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
|
|
||||||
// Notes
|
|
||||||
TextFormField(
|
|
||||||
controller: _notesController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Notes',
|
|
||||||
hintText: 'Informations additionnelles...',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(Icons.notes),
|
|
||||||
),
|
|
||||||
maxLines: 3,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// Boutons
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: _saveContainer,
|
|
||||||
icon: const Icon(Icons.save, color: Colors.white),
|
|
||||||
label: Text(
|
|
||||||
_isEditing ? 'Mettre à jour' : 'Créer',
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: AppColors.rouge,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 24,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import 'package:em2rp/views/widgets/data_management/event_types_management.dart'
|
|||||||
import 'package:em2rp/views/widgets/data_management/options_management.dart';
|
import 'package:em2rp/views/widgets/data_management/options_management.dart';
|
||||||
import 'package:em2rp/views/widgets/data_management/events_export.dart';
|
import 'package:em2rp/views/widgets/data_management/events_export.dart';
|
||||||
import 'package:em2rp/views/widgets/data_management/event_statistics_tab.dart';
|
import 'package:em2rp/views/widgets/data_management/event_statistics_tab.dart';
|
||||||
|
import 'package:em2rp/views/widgets/data_management/depot_management.dart';
|
||||||
|
import 'package:em2rp/views/widgets/data_management/vehicles_management.dart';
|
||||||
|
import 'package:em2rp/views/widgets/data_management/fuel_prices_management.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||||
import 'package:em2rp/utils/permission_gate.dart';
|
import 'package:em2rp/utils/permission_gate.dart';
|
||||||
@@ -50,6 +53,21 @@ class _DataManagementPageState extends State<DataManagementPage> {
|
|||||||
child: EventStatisticsTab(),
|
child: EventStatisticsTab(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
DataCategory(
|
||||||
|
title: 'Dépôts',
|
||||||
|
icon: Icons.warehouse_outlined,
|
||||||
|
widget: const DepotManagement(),
|
||||||
|
),
|
||||||
|
DataCategory(
|
||||||
|
title: 'Véhicules',
|
||||||
|
icon: Icons.directions_car_outlined,
|
||||||
|
widget: const VehiclesManagement(),
|
||||||
|
),
|
||||||
|
DataCategory(
|
||||||
|
title: 'Prix carburants',
|
||||||
|
icon: Icons.local_gas_station,
|
||||||
|
widget: const FuelPricesManagement(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -923,17 +923,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
try {
|
try {
|
||||||
// Récupérer les équipements sélectionnés
|
// Récupérer les équipements sélectionnés
|
||||||
final provider = context.read<EquipmentProvider>();
|
final provider = context.read<EquipmentProvider>();
|
||||||
final List<EquipmentModel> selectedEquipment = [];
|
final List<EquipmentModel> selectedEquipment =
|
||||||
|
await provider.getEquipmentsByIds(selectedIds.toList());
|
||||||
// On doit récupérer les équipements depuis le stream
|
|
||||||
await for (final equipmentList in provider.equipmentStream.take(1)) {
|
|
||||||
for (final equipment in equipmentList) {
|
|
||||||
if (isItemSelected(equipment.id)) {
|
|
||||||
selectedEquipment.add(equipment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fermer l'indicateur de chargement
|
// Fermer l'indicateur de chargement
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
|
|||||||
@@ -142,6 +142,54 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCard({
|
||||||
|
required String title,
|
||||||
|
required IconData icon,
|
||||||
|
required List<Widget> children,
|
||||||
|
}) {
|
||||||
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
|
color: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: Colors.grey.shade200, width: 1),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFD32F2F).withOpacity(0.1), // AppColors.rouge
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: const Color(0xFFD32F2F), size: 22),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||||
@@ -158,29 +206,23 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
|
backgroundColor: Colors.grey.shade50,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(
|
title: Text(
|
||||||
isEditMode ? 'Modifier un événement' : 'Créer un événement'),
|
isEditMode ? 'Modifier un événement' : 'Créer un événement'),
|
||||||
|
elevation: 0,
|
||||||
),
|
),
|
||||||
body: Center(
|
body: SingleChildScrollView(
|
||||||
child: SingleChildScrollView(
|
child: Center(
|
||||||
child: (isMobile
|
child: ConstrainedBox(
|
||||||
? Padding(
|
constraints: const BoxConstraints(maxWidth: 1200),
|
||||||
padding: const EdgeInsets.symmetric(
|
child: Padding(
|
||||||
horizontal: 16, vertical: 12),
|
padding: EdgeInsets.symmetric(
|
||||||
child: _buildFormContent(isMobile),
|
horizontal: isMobile ? 16 : 32,
|
||||||
)
|
vertical: 32),
|
||||||
: Card(
|
child: _buildFormContent(isMobile),
|
||||||
elevation: 6,
|
),
|
||||||
margin: const EdgeInsets.all(24),
|
),
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(18)),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 32, vertical: 32),
|
|
||||||
child: _buildFormContent(isMobile),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -197,38 +239,43 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
EventBasicInfoSection(
|
_buildCard(
|
||||||
nameController: controller.nameController,
|
title: 'Informations générales',
|
||||||
basePriceController: controller.basePriceController,
|
icon: Icons.event_note,
|
||||||
eventTypes: controller.eventTypes,
|
children: [
|
||||||
isLoadingEventTypes: controller.isLoadingEventTypes,
|
EventBasicInfoSection(
|
||||||
selectedEventTypeId: controller.selectedEventTypeId,
|
nameController: controller.nameController,
|
||||||
startDateTime: controller.startDateTime,
|
basePriceController: controller.basePriceController,
|
||||||
endDateTime: controller.endDateTime,
|
eventTypes: controller.eventTypes,
|
||||||
onEventTypeChanged: (typeId) =>
|
isLoadingEventTypes: controller.isLoadingEventTypes,
|
||||||
controller.onEventTypeChanged(typeId, context),
|
selectedEventTypeId: controller.selectedEventTypeId,
|
||||||
onStartDateTimeChanged: controller.setStartDateTime,
|
startDateTime: controller.startDateTime,
|
||||||
onEndDateTimeChanged: controller.setEndDateTime,
|
endDateTime: controller.endDateTime,
|
||||||
onAnyFieldChanged:
|
selectedOptions: controller.selectedOptions,
|
||||||
() {}, // Géré automatiquement par le contrôleur
|
onEventTypeChanged: (typeId) =>
|
||||||
|
controller.onEventTypeChanged(typeId, context),
|
||||||
|
onStartDateTimeChanged: controller.setStartDateTime,
|
||||||
|
onEndDateTimeChanged: controller.setEndDateTime,
|
||||||
|
onAnyFieldChanged: () {},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OptionSelectorWidget(
|
||||||
|
eventType: controller.selectedEventTypeId,
|
||||||
|
selectedOptions: controller.selectedOptions,
|
||||||
|
onChanged: controller.setSelectedOptions,
|
||||||
|
onRemove: (optionId) {
|
||||||
|
final newOptions = List<Map<String, dynamic>>.from(
|
||||||
|
controller.selectedOptions);
|
||||||
|
newOptions.removeWhere((o) => o['id'] == optionId);
|
||||||
|
controller.setSelectedOptions(newOptions);
|
||||||
|
},
|
||||||
|
eventTypeRequired: controller.selectedEventTypeId == null,
|
||||||
|
isMobile: isMobile,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 24),
|
||||||
OptionSelectorWidget(
|
// Section Matériel Assigné (gère sa propre carte pour inclure les boutons d'action dans le header)
|
||||||
eventType: controller
|
|
||||||
.selectedEventTypeId, // Utilise l'ID au lieu du nom
|
|
||||||
selectedOptions: controller.selectedOptions,
|
|
||||||
onChanged: controller.setSelectedOptions,
|
|
||||||
onRemove: (optionId) {
|
|
||||||
final newOptions = List<Map<String, dynamic>>.from(
|
|
||||||
controller.selectedOptions);
|
|
||||||
newOptions.removeWhere((o) => o['id'] == optionId);
|
|
||||||
controller.setSelectedOptions(newOptions);
|
|
||||||
},
|
|
||||||
eventTypeRequired: controller.selectedEventTypeId == null,
|
|
||||||
isMobile: isMobile,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
// Section Matériel Assigné
|
|
||||||
EventAssignedEquipmentSection(
|
EventAssignedEquipmentSection(
|
||||||
assignedEquipment: controller.assignedEquipment,
|
assignedEquipment: controller.assignedEquipment,
|
||||||
assignedContainers: controller.assignedContainers,
|
assignedContainers: controller.assignedContainers,
|
||||||
@@ -238,50 +285,104 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
eventId: widget.event?.id,
|
eventId: widget.event?.id,
|
||||||
eventTypeId: controller.selectedEventTypeId,
|
eventTypeId: controller.selectedEventTypeId,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 24),
|
||||||
EventDetailsSection(
|
_buildCard(
|
||||||
descriptionController: controller.descriptionController,
|
title: 'Détails & Logistique',
|
||||||
installationController: controller.installationController,
|
icon: Icons.location_on_outlined,
|
||||||
disassemblyController: controller.disassemblyController,
|
children: [
|
||||||
addressController: controller.addressController,
|
EventDetailsSection(
|
||||||
jaugeController: controller.jaugeController,
|
descriptionController: controller.descriptionController,
|
||||||
contactEmailController: controller.contactEmailController,
|
installationController: controller.installationController,
|
||||||
contactPhoneController: controller.contactPhoneController,
|
disassemblyController: controller.disassemblyController,
|
||||||
isMobile: isMobile,
|
addressController: controller.addressController,
|
||||||
onAnyFieldChanged:
|
jaugeController: controller.jaugeController,
|
||||||
() {}, // Géré automatiquement par le contrôleur
|
contactEmailController: controller.contactEmailController,
|
||||||
|
contactPhoneController: controller.contactPhoneController,
|
||||||
|
isMobile: isMobile,
|
||||||
|
onAnyFieldChanged: () {}, // Géré automatiquement par le contrôleur
|
||||||
|
onTravelCostSelected: (price) {
|
||||||
|
controller.addTravelCostOption(price);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Frais de déplacement ajoutés : ${price.toStringAsFixed(2)} €'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
EventStaffAndDocumentsSection(
|
const SizedBox(height: 24),
|
||||||
allUsers: controller.allUsers,
|
_buildCard(
|
||||||
selectedUserIds: controller.selectedUserIds,
|
title: 'Personnel & Documents',
|
||||||
onUserSelectionChanged: controller.setSelectedUserIds,
|
icon: Icons.group_outlined,
|
||||||
isLoadingUsers: controller.isLoadingUsers,
|
children: [
|
||||||
uploadedFiles: controller.uploadedFiles,
|
EventStaffAndDocumentsSection(
|
||||||
onFilesChanged: controller.setUploadedFiles,
|
allUsers: controller.allUsers,
|
||||||
isLoading: controller.isLoading,
|
selectedUserIds: controller.selectedUserIds,
|
||||||
error: controller.error,
|
onUserSelectionChanged: controller.setSelectedUserIds,
|
||||||
success: controller.success,
|
isLoadingUsers: controller.isLoadingUsers,
|
||||||
isMobile: isMobile,
|
uploadedFiles: controller.uploadedFiles,
|
||||||
onPickAndUploadFiles: controller.pickAndUploadFiles,
|
onFilesChanged: controller.setUploadedFiles,
|
||||||
|
isLoading: controller.isLoading,
|
||||||
|
error: controller.error,
|
||||||
|
success: controller.success,
|
||||||
|
isMobile: isMobile,
|
||||||
|
onPickAndUploadFiles: controller.pickAndUploadFiles,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (controller.error != null)
|
if (controller.error != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 16.0),
|
padding: const EdgeInsets.only(top: 24.0),
|
||||||
child: Text(
|
child: Container(
|
||||||
controller.error!,
|
padding: const EdgeInsets.all(12),
|
||||||
style: const TextStyle(color: Colors.red),
|
decoration: BoxDecoration(
|
||||||
textAlign: TextAlign.center,
|
color: Colors.red.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.red.shade200)
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, color: Colors.red.shade700),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
controller.error!,
|
||||||
|
style: TextStyle(color: Colors.red.shade700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (controller.success != null)
|
if (controller.success != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 16.0),
|
padding: const EdgeInsets.only(top: 24.0),
|
||||||
child: Text(
|
child: Container(
|
||||||
controller.success!,
|
padding: const EdgeInsets.all(12),
|
||||||
style: const TextStyle(color: Colors.green),
|
decoration: BoxDecoration(
|
||||||
textAlign: TextAlign.center,
|
color: Colors.green.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.green.shade200)
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle_outline, color: Colors.green.shade700),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
controller.success!,
|
||||||
|
style: TextStyle(color: Colors.green.shade700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
EventFormActions(
|
EventFormActions(
|
||||||
isLoading: controller.isLoading,
|
isLoading: controller.isLoading,
|
||||||
isEditMode: isEditMode,
|
isEditMode: isEditMode,
|
||||||
@@ -292,11 +393,15 @@ class _EventAddEditPageState extends State<EventAddEditPage> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSubmit: _submit,
|
onSubmit: _submit,
|
||||||
onSetConfirmed: !isEditMode ? () {} : null,
|
onSetConfirmed: !isEditMode ? () async {
|
||||||
onDelete: isEditMode
|
final success = await controller.submitAsConfirmed(context);
|
||||||
? _deleteEvent
|
if (success && context.mounted) {
|
||||||
: null, // Ajout du callback de suppression
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
} : null,
|
||||||
|
onDelete: isEditMode ? _deleteEvent : null,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 48), // Padding bottom for scrolling
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -88,22 +88,22 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
|
|
||||||
// Logique stricte : on avance étape par étape
|
// Logique stricte : on avance étape par étape
|
||||||
// 1. Préparation dépôt
|
// 1. Préparation dépôt
|
||||||
if (prep != PreparationStatus.completed) {
|
if (prep != PreparationStatus.completed && prep != PreparationStatus.completedWithMissing) {
|
||||||
return PreparationStep.preparation;
|
return PreparationStep.preparation;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Chargement aller (après préparation complète)
|
// 2. Chargement aller (après préparation complète)
|
||||||
if (loading != LoadingStatus.completed) {
|
if (loading != LoadingStatus.completed && loading != LoadingStatus.completedWithMissing) {
|
||||||
return PreparationStep.loadingOutbound;
|
return PreparationStep.loadingOutbound;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Chargement retour (après chargement aller complet)
|
// 3. Chargement retour (après chargement aller complet)
|
||||||
if (unloading != UnloadingStatus.completed) {
|
if (unloading != UnloadingStatus.completed && unloading != UnloadingStatus.completedWithMissing) {
|
||||||
return PreparationStep.unloadingReturn;
|
return PreparationStep.unloadingReturn;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Retour dépôt (après déchargement complet)
|
// 4. Retour dépôt (après déchargement complet)
|
||||||
if (returnStatus != ReturnStatus.completed) {
|
if (returnStatus != ReturnStatus.completed && returnStatus != ReturnStatus.completedWithMissing) {
|
||||||
return PreparationStep.return_;
|
return PreparationStep.return_;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +132,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_isCurrentStepCompleted()) {
|
if (_isCurrentStepCompleted()) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
const SnackBar(
|
||||||
content: Text('Cette étape est déjà terminée'),
|
content: Text('Cette étape est déjà terminée'),
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: Colors.orange,
|
||||||
),
|
),
|
||||||
@@ -141,6 +141,17 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!_isPreviousStepCompleted()) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('L\'étape précédente n\'est pas terminée. Impossible d\'accéder à cette étape.'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Charger les équipements après le premier frame pour éviter setState pendant build
|
// Charger les équipements après le premier frame pour éviter setState pendant build
|
||||||
_loadEquipmentAndContainers();
|
_loadEquipmentAndContainers();
|
||||||
});
|
});
|
||||||
@@ -150,13 +161,34 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
bool _isCurrentStepCompleted() {
|
bool _isCurrentStepCompleted() {
|
||||||
switch (_currentStep) {
|
switch (_currentStep) {
|
||||||
case PreparationStep.preparation:
|
case PreparationStep.preparation:
|
||||||
return (_currentEvent.preparationStatus ?? PreparationStatus.notStarted) == PreparationStatus.completed;
|
final status = _currentEvent.preparationStatus ?? PreparationStatus.notStarted;
|
||||||
|
return status == PreparationStatus.completed || status == PreparationStatus.completedWithMissing;
|
||||||
case PreparationStep.loadingOutbound:
|
case PreparationStep.loadingOutbound:
|
||||||
return (_currentEvent.loadingStatus ?? LoadingStatus.notStarted) == LoadingStatus.completed;
|
final status = _currentEvent.loadingStatus ?? LoadingStatus.notStarted;
|
||||||
|
return status == LoadingStatus.completed || status == LoadingStatus.completedWithMissing;
|
||||||
case PreparationStep.unloadingReturn:
|
case PreparationStep.unloadingReturn:
|
||||||
return (_currentEvent.unloadingStatus ?? UnloadingStatus.notStarted) == UnloadingStatus.completed;
|
final status = _currentEvent.unloadingStatus ?? UnloadingStatus.notStarted;
|
||||||
|
return status == UnloadingStatus.completed || status == UnloadingStatus.completedWithMissing;
|
||||||
case PreparationStep.return_:
|
case PreparationStep.return_:
|
||||||
return (_currentEvent.returnStatus ?? ReturnStatus.notStarted) == ReturnStatus.completed;
|
final status = _currentEvent.returnStatus ?? ReturnStatus.notStarted;
|
||||||
|
return status == ReturnStatus.completed || status == ReturnStatus.completedWithMissing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si l'étape précédente est bien complétée
|
||||||
|
bool _isPreviousStepCompleted() {
|
||||||
|
switch (_currentStep) {
|
||||||
|
case PreparationStep.preparation:
|
||||||
|
return true; // Première étape, toujours OK
|
||||||
|
case PreparationStep.loadingOutbound:
|
||||||
|
final prep = _currentEvent.preparationStatus ?? PreparationStatus.notStarted;
|
||||||
|
return prep == PreparationStatus.completed || prep == PreparationStatus.completedWithMissing;
|
||||||
|
case PreparationStep.unloadingReturn:
|
||||||
|
final loading = _currentEvent.loadingStatus ?? LoadingStatus.notStarted;
|
||||||
|
return loading == LoadingStatus.completed || loading == LoadingStatus.completedWithMissing;
|
||||||
|
case PreparationStep.return_:
|
||||||
|
final unloading = _currentEvent.unloadingStatus ?? UnloadingStatus.notStarted;
|
||||||
|
return unloading == UnloadingStatus.completed || unloading == UnloadingStatus.completedWithMissing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,10 +271,15 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_quantitiesAtPreparation[eq.equipmentId] = eq.quantityAtPreparation ?? eq.quantity;
|
||||||
|
_quantitiesAtLoading[eq.equipmentId] = eq.quantityAtLoading ?? eq.quantityAtPreparation ?? eq.quantity;
|
||||||
|
_quantitiesAtUnloading[eq.equipmentId] = eq.quantityAtUnloading ?? eq.quantityAtLoading ?? eq.quantityAtPreparation ?? eq.quantity;
|
||||||
|
_quantitiesAtReturn[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantityAtUnloading ?? eq.quantityAtLoading ?? eq.quantityAtPreparation ?? eq.quantity;
|
||||||
|
|
||||||
if ((_currentStep == PreparationStep.return_ ||
|
if ((_currentStep == PreparationStep.return_ ||
|
||||||
_currentStep == PreparationStep.unloadingReturn) &&
|
_currentStep == PreparationStep.unloadingReturn) &&
|
||||||
(equipmentItem?.hasQuantity ?? false)) {
|
(equipmentItem?.hasQuantity ?? false)) {
|
||||||
_returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantity;
|
_returnedQuantities[eq.equipmentId] = eq.quantityAtReturn ?? eq.quantityAtUnloading ?? eq.quantityAtLoading ?? eq.quantityAtPreparation ?? eq.quantity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,9 +455,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
returnStatus: updateData['returnStatus'],
|
returnStatus: updateData['returnStatus'],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mettre à jour les statuts des équipements si nécessaire
|
// Mettre à jour les statuts des équipements si nécessaire (uniquement pour la préparation, le retour étant géré par le trigger Firestore Cloud Function)
|
||||||
if (_currentStep == PreparationStep.preparation ||
|
if (_currentStep == PreparationStep.preparation) {
|
||||||
(_currentStep == PreparationStep.unloadingReturn && _loadSimultaneously)) {
|
|
||||||
await _updateEquipmentStatuses(updatedEquipment);
|
await _updateEquipmentStatuses(updatedEquipment);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,6 +541,8 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _updateEquipmentStatuses(List<EventEquipment> equipment) async {
|
Future<void> _updateEquipmentStatuses(List<EventEquipment> equipment) async {
|
||||||
|
final List<String> failedUpdates = [];
|
||||||
|
|
||||||
for (var eq in equipment) {
|
for (var eq in equipment) {
|
||||||
try {
|
try {
|
||||||
final equipmentData = _equipmentCache[eq.equipmentId];
|
final equipmentData = _equipmentCache[eq.equipmentId];
|
||||||
@@ -513,7 +551,9 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
// Déterminer le nouveau statut
|
// Déterminer le nouveau statut
|
||||||
EquipmentStatus newStatus;
|
EquipmentStatus newStatus;
|
||||||
if (eq.isReturned) {
|
if (eq.isReturned) {
|
||||||
newStatus = EquipmentStatus.available;
|
// Note : Le retour est géré par le trigger Firestore Cloud Function en tâche de fond.
|
||||||
|
// On évite les conflits d'écritures client/serveur et les double-restaurations de stock.
|
||||||
|
continue;
|
||||||
} else if (eq.isPrepared || eq.isLoaded) {
|
} else if (eq.isPrepared || eq.isLoaded) {
|
||||||
newStatus = EquipmentStatus.inUse;
|
newStatus = EquipmentStatus.inUse;
|
||||||
} else {
|
} else {
|
||||||
@@ -527,19 +567,22 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
status: equipmentStatusToString(newStatus),
|
status: equipmentStatusToString(newStatus),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gérer les stocks pour les consommables
|
|
||||||
if (equipmentData.hasQuantity && eq.isReturned && eq.quantityAtReturn != null) {
|
|
||||||
final currentAvailable = equipmentData.availableQuantity ?? 0;
|
|
||||||
await _dataService.updateEquipmentStatusOnly(
|
|
||||||
equipmentId: eq.equipmentId,
|
|
||||||
availableQuantity: currentAvailable + eq.quantityAtReturn!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Erreur silencieuse pour ne pas bloquer le processus
|
DebugLog.error('[EventPreparationPage] Échec de la mise à jour du statut pour l\'équipement ${eq.equipmentId}', e);
|
||||||
|
failedUpdates.add(eq.equipmentId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (failedUpdates.isNotEmpty && mounted) {
|
||||||
|
final names = failedUpdates.map((id) => _equipmentCache[id]?.name ?? id).join(', ');
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Attention : Échec de mise à jour du statut en base pour : $names. Le matériel a tout de même été validé pour l\'événement.'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
duration: const Duration(seconds: 6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getSuccessMessage() {
|
String _getSuccessMessage() {
|
||||||
@@ -895,26 +938,28 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
_quantitiesAtReturn.addAll(quantities);
|
_quantitiesAtReturn.addAll(quantities);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mettre à jour `_currentEvent.assignedEquipment` pour que l'UI se reconstruise avec les bonnes valeurs
|
||||||
|
final updatedList = _currentEvent.assignedEquipment.map((eq) {
|
||||||
|
final qty = quantities[eq.equipmentId];
|
||||||
|
if (qty != null) {
|
||||||
|
switch (_currentStep) {
|
||||||
|
case PreparationStep.preparation:
|
||||||
|
return eq.copyWith(quantityAtPreparation: qty);
|
||||||
|
case PreparationStep.loadingOutbound:
|
||||||
|
return eq.copyWith(quantityAtLoading: qty);
|
||||||
|
case PreparationStep.unloadingReturn:
|
||||||
|
return eq.copyWith(quantityAtUnloading: qty);
|
||||||
|
case PreparationStep.return_:
|
||||||
|
return eq.copyWith(quantityAtReturn: qty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return eq;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
_currentEvent = _currentEvent.copyWith(assignedEquipment: updatedList);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Obtenir la quantité requise selon l'étape (nouvelle logique)
|
|
||||||
int _getTargetQuantity(EventEquipment eventEquipment) {
|
|
||||||
switch (_currentStep) {
|
|
||||||
case PreparationStep.preparation:
|
|
||||||
return eventEquipment.quantity; // Quantité initiale
|
|
||||||
case PreparationStep.loadingOutbound:
|
|
||||||
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
|
|
||||||
case PreparationStep.unloadingReturn:
|
|
||||||
return eventEquipment.quantityAtLoading ??
|
|
||||||
eventEquipment.quantityAtPreparation ??
|
|
||||||
eventEquipment.quantity;
|
|
||||||
case PreparationStep.return_:
|
|
||||||
return eventEquipment.quantityAtUnloading ??
|
|
||||||
eventEquipment.quantityAtLoading ??
|
|
||||||
eventEquipment.quantityAtPreparation ??
|
|
||||||
eventEquipment.quantity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Afficher un message de succès
|
/// Afficher un message de succès
|
||||||
void _showSuccessFeedback(String message) {
|
void _showSuccessFeedback(String message) {
|
||||||
@@ -1020,20 +1065,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
/// Mettre à jour la quantité d'un équipement à l'étape actuelle
|
/// Mettre à jour la quantité d'un équipement à l'étape actuelle
|
||||||
void _updateEquipmentQuantity(String equipmentId, int newQuantity) {
|
void _updateEquipmentQuantity(String equipmentId, int newQuantity) {
|
||||||
setState(() {
|
setState(() {
|
||||||
switch (_currentStep) {
|
_updateQuantitiesMap({equipmentId: newQuantity});
|
||||||
case PreparationStep.preparation:
|
|
||||||
_quantitiesAtPreparation[equipmentId] = newQuantity;
|
|
||||||
break;
|
|
||||||
case PreparationStep.loadingOutbound:
|
|
||||||
_quantitiesAtLoading[equipmentId] = newQuantity;
|
|
||||||
break;
|
|
||||||
case PreparationStep.unloadingReturn:
|
|
||||||
_quantitiesAtUnloading[equipmentId] = newQuantity;
|
|
||||||
break;
|
|
||||||
case PreparationStep.return_:
|
|
||||||
_quantitiesAtReturn[equipmentId] = newQuantity;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+107
-30
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
class EventDetailsDescription extends StatelessWidget {
|
class EventDetailsDescription extends StatelessWidget {
|
||||||
final EventModel event;
|
final EventModel event;
|
||||||
@@ -45,17 +47,48 @@ class EventDetailsDescription extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
SelectableText(
|
Row(
|
||||||
event.address,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: Theme.of(context).textTheme.bodyLarge,
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SelectableText(
|
||||||
|
event.address,
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
if (event.latitude != 0.0 || event.longitude != 0.0) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
SelectableText(
|
||||||
|
'${event.latitude}° N, ${event.longitude}° E',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.map, color: AppColors.rouge),
|
||||||
|
tooltip: 'Ouvrir dans Maps',
|
||||||
|
onPressed: () async {
|
||||||
|
final query = event.address;
|
||||||
|
if (query.isEmpty) return;
|
||||||
|
|
||||||
|
final url = Uri.parse('https://www.google.com/maps/search/?api=1&query=${Uri.encodeComponent(query)}');
|
||||||
|
if (await canLaunchUrl(url)) {
|
||||||
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
|
} else {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Impossible d\'ouvrir l\'application de carte')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
if (event.latitude != 0.0 || event.longitude != 0.0) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
SelectableText(
|
|
||||||
'${event.latitude}° N, ${event.longitude}° E',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,12 +101,22 @@ class EventDetailsDescription extends StatelessWidget {
|
|||||||
_buildInfoRow(context, Icons.people, 'Jauge', '${event.jauge} personnes'),
|
_buildInfoRow(context, Icons.people, 'Jauge', '${event.jauge} personnes'),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
if (event.contactEmail != null) ...[
|
if (event.contactEmail != null && event.contactEmail!.isNotEmpty) ...[
|
||||||
_buildInfoRow(context, Icons.email, 'Email', event.contactEmail!),
|
_buildInfoRow(context, Icons.email, 'Email', event.contactEmail!, onTap: () async {
|
||||||
|
final url = Uri.parse('mailto:${event.contactEmail!}');
|
||||||
|
if (await canLaunchUrl(url)) {
|
||||||
|
await launchUrl(url);
|
||||||
|
}
|
||||||
|
}),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
if (event.contactPhone != null) ...[
|
if (event.contactPhone != null && event.contactPhone!.isNotEmpty) ...[
|
||||||
_buildInfoRow(context, Icons.phone, 'Téléphone', event.contactPhone!),
|
_buildInfoRow(context, Icons.phone, 'Téléphone', event.contactPhone!, onTap: () async {
|
||||||
|
final url = Uri.parse('tel:${event.contactPhone!}');
|
||||||
|
if (await canLaunchUrl(url)) {
|
||||||
|
await launchUrl(url);
|
||||||
|
}
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -86,15 +129,25 @@ class EventDetailsDescription extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (event.jauge != null)
|
if (event.jauge != null)
|
||||||
_buildInfoChip(context, Icons.people, 'Jauge', '${event.jauge} personnes'),
|
_buildInfoChip(context, Icons.people, 'Jauge', '${event.jauge} personnes'),
|
||||||
if (event.contactEmail != null)
|
if (event.contactEmail != null && event.contactEmail!.isNotEmpty)
|
||||||
_buildInfoChip(context, Icons.email, 'Email', event.contactEmail!),
|
_buildInfoChip(context, Icons.email, 'Email', event.contactEmail!, onTap: () async {
|
||||||
if (event.contactPhone != null)
|
final url = Uri.parse('mailto:${event.contactEmail!}');
|
||||||
_buildInfoChip(context, Icons.phone, 'Téléphone', event.contactPhone!),
|
if (await canLaunchUrl(url)) {
|
||||||
|
await launchUrl(url);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
if (event.contactPhone != null && event.contactPhone!.isNotEmpty)
|
||||||
|
_buildInfoChip(context, Icons.phone, 'Téléphone', event.contactPhone!, onTap: () async {
|
||||||
|
final url = Uri.parse('tel:${event.contactPhone!}');
|
||||||
|
if (await canLaunchUrl(url)) {
|
||||||
|
await launchUrl(url);
|
||||||
|
}
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoRow(BuildContext context, IconData icon, String label, String value) {
|
Widget _buildInfoRow(BuildContext context, IconData icon, String label, String value, {VoidCallback? onTap}) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, size: 20, color: Theme.of(context).primaryColor),
|
Icon(icon, size: 20, color: Theme.of(context).primaryColor),
|
||||||
@@ -109,12 +162,24 @@ class EventDetailsDescription extends StatelessWidget {
|
|||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SelectableText(
|
onTap == null
|
||||||
value,
|
? SelectableText(
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
value,
|
||||||
fontWeight: FontWeight.w500,
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: SelectableText.rich(
|
||||||
|
TextSpan(
|
||||||
|
text: value,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.rouge,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
recognizer: TapGestureRecognizer()..onTap = onTap,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -122,7 +187,7 @@ class EventDetailsDescription extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInfoChip(BuildContext context, IconData icon, String label, String value) {
|
Widget _buildInfoChip(BuildContext context, IconData icon, String label, String value, {VoidCallback? onTap}) {
|
||||||
final primaryColor = Theme.of(context).primaryColor;
|
final primaryColor = Theme.of(context).primaryColor;
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
@@ -146,12 +211,24 @@ class EventDetailsDescription extends StatelessWidget {
|
|||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SelectableText(
|
onTap == null
|
||||||
value,
|
? SelectableText(
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
value,
|
||||||
fontWeight: FontWeight.w500,
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: SelectableText.rich(
|
||||||
|
TextSpan(
|
||||||
|
text: value,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.rouge,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
recognizer: TapGestureRecognizer()..onTap = onTap,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
+176
-6
@@ -2,7 +2,9 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/providers/event_provider.dart';
|
import 'package:em2rp/providers/event_provider.dart';
|
||||||
|
import 'package:em2rp/providers/local_user_provider.dart';
|
||||||
import 'package:em2rp/views/event_preparation_page.dart';
|
import 'package:em2rp/views/event_preparation_page.dart';
|
||||||
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
/// Boutons de préparation et retour d'événement
|
/// Boutons de préparation et retour d'événement
|
||||||
@@ -52,16 +54,16 @@ class _EventPreparationButtonsState extends State<EventPreparationButtons> {
|
|||||||
IconData buttonIcon;
|
IconData buttonIcon;
|
||||||
bool isCompleted = false;
|
bool isCompleted = false;
|
||||||
|
|
||||||
if (prep != PreparationStatus.completed) {
|
if (prep != PreparationStatus.completed && prep != PreparationStatus.completedWithMissing) {
|
||||||
buttonText = 'Préparation dépôt';
|
buttonText = 'Préparation dépôt';
|
||||||
buttonIcon = Icons.inventory_2;
|
buttonIcon = Icons.inventory_2;
|
||||||
} else if (loading != LoadingStatus.completed) {
|
} else if (loading != LoadingStatus.completed && loading != LoadingStatus.completedWithMissing) {
|
||||||
buttonText = 'Chargement aller';
|
buttonText = 'Chargement aller';
|
||||||
buttonIcon = Icons.local_shipping;
|
buttonIcon = Icons.local_shipping;
|
||||||
} else if (unloading != UnloadingStatus.completed) {
|
} else if (unloading != UnloadingStatus.completed && unloading != UnloadingStatus.completedWithMissing) {
|
||||||
buttonText = 'Chargement retour';
|
buttonText = 'Chargement retour';
|
||||||
buttonIcon = Icons.unarchive;
|
buttonIcon = Icons.unarchive;
|
||||||
} else if (returnStatus != ReturnStatus.completed) {
|
} else if (returnStatus != ReturnStatus.completed && returnStatus != ReturnStatus.completedWithMissing) {
|
||||||
buttonText = 'Retour dépôt';
|
buttonText = 'Retour dépôt';
|
||||||
buttonIcon = Icons.assignment_return;
|
buttonIcon = Icons.assignment_return;
|
||||||
} else {
|
} else {
|
||||||
@@ -131,9 +133,9 @@ class _EventPreparationButtonsState extends State<EventPreparationButtons> {
|
|||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: Colors.green, width: 1),
|
border: Border.all(color: Colors.green, width: 1),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: const Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: const [
|
children: [
|
||||||
Icon(Icons.check_circle, color: Colors.green, size: 20),
|
Icon(Icons.check_circle, color: Colors.green, size: 20),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
@@ -147,9 +149,177 @@ class _EventPreparationButtonsState extends State<EventPreparationButtons> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// Bouton de retour en arrière si au moins une étape est commencée/validée
|
||||||
|
if (prep != PreparationStatus.notStarted) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Center(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxWidth: 400),
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _showRollbackDialog(context, event),
|
||||||
|
icon: const Icon(Icons.undo, size: 18),
|
||||||
|
label: const Text('Revenir à une étape précédente'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.orange[800],
|
||||||
|
side: BorderSide(color: Colors.orange[300]!),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showRollbackDialog(BuildContext context, EventModel event) async {
|
||||||
|
final prep = event.preparationStatus ?? PreparationStatus.notStarted;
|
||||||
|
final loading = event.loadingStatus ?? LoadingStatus.notStarted;
|
||||||
|
final unloading = event.unloadingStatus ?? UnloadingStatus.notStarted;
|
||||||
|
final returnStatus = event.returnStatus ?? ReturnStatus.notStarted;
|
||||||
|
|
||||||
|
final List<Map<String, String>> steps = [];
|
||||||
|
|
||||||
|
if (prep == PreparationStatus.completed || prep == PreparationStatus.completedWithMissing) {
|
||||||
|
steps.add({'key': 'PREPARATION', 'label': 'Préparation dépôt'});
|
||||||
|
}
|
||||||
|
if (loading == LoadingStatus.completed || loading == LoadingStatus.completedWithMissing) {
|
||||||
|
steps.add({'key': 'LOADING', 'label': 'Chargement aller'});
|
||||||
|
}
|
||||||
|
if (unloading == UnloadingStatus.completed || unloading == UnloadingStatus.completedWithMissing) {
|
||||||
|
steps.add({'key': 'UNLOADING', 'label': 'Chargement retour'});
|
||||||
|
}
|
||||||
|
if (returnStatus == ReturnStatus.completed || returnStatus == ReturnStatus.completedWithMissing) {
|
||||||
|
steps.add({'key': 'RETURN', 'label': 'Retour dépôt'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (steps.isEmpty) return;
|
||||||
|
|
||||||
|
final String? selectedStep = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.undo, color: Colors.orange),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Revenir en arrière'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Sélectionnez l\'étape à laquelle vous souhaitez revenir :'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...steps.map((step) {
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.arrow_back, color: AppColors.rouge),
|
||||||
|
title: Text(step['label']!),
|
||||||
|
onTap: () => Navigator.of(context).pop(step['key']),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedStep != null && context.mounted) {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Confirmer le retour en arrière'),
|
||||||
|
content: const Text(
|
||||||
|
'Êtes-vous sûr de vouloir revenir à cette étape ?\n\n'
|
||||||
|
'Toutes les validations des étapes ultérieures seront effacées, '
|
||||||
|
'et si le retour était terminé, les stocks restaurés seront annulés.'
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
),
|
||||||
|
child: const Text('Confirmer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirm == true && context.mounted) {
|
||||||
|
// Utiliser le rootNavigator pour s'assurer qu'on gère la bonne pile de navigation
|
||||||
|
final navigator = Navigator.of(context, rootNavigator: true);
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
useRootNavigator: true,
|
||||||
|
builder: (BuildContext dialogContext) {
|
||||||
|
return const PopScope(
|
||||||
|
canPop: false,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final apiService = FirebaseFunctionsApiService();
|
||||||
|
await apiService.call('rollbackEventStep', {
|
||||||
|
'eventId': event.id,
|
||||||
|
'targetStep': selectedStep,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attendre un tout petit peu pour s'assurer que le showDialog a eu le temps
|
||||||
|
// de faire son animation d'entrée si l'API a répondu trop vite.
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||||
|
final userProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
||||||
|
if (userProvider.currentUser != null) {
|
||||||
|
await eventProvider.refreshEvents(userProvider.currentUser!.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.pop(); // Fermer le loader
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Retour en arrière effectué avec succès'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
navigator.pop(); // Fermer le loader
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur : $e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,12 +36,15 @@ class MonthView extends StatelessWidget {
|
|||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final rowCount = _computeRowCount(focusedDay);
|
final rowCount = _computeRowCount(focusedDay);
|
||||||
|
// TableCalendar has internal vertical padding and margins (approx 16px) that cause overflow
|
||||||
|
// if not accounted for. We subtract an extra 16.0 pixels to be safe.
|
||||||
final availableHeight = constraints.maxHeight -
|
final availableHeight = constraints.maxHeight -
|
||||||
(_calendarPadding * 2) -
|
(_calendarPadding * 2) -
|
||||||
_headerHeight -
|
_headerHeight -
|
||||||
_headerVerticalPadding -
|
_headerVerticalPadding -
|
||||||
_daysOfWeekHeight;
|
_daysOfWeekHeight -
|
||||||
final rowHeight = availableHeight / rowCount;
|
16.0;
|
||||||
|
final rowHeight = (availableHeight > 0 ? availableHeight : 200.0) / rowCount;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: constraints.maxHeight,
|
height: constraints.maxHeight,
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
|
import 'package:latlong2/latlong.dart';
|
||||||
|
import 'package:em2rp/models/route_result_model.dart';
|
||||||
|
import '../../../utils/polyline_utils.dart';
|
||||||
|
|
||||||
|
/// Affiche 1 ou 2 itinéraires sur une carte OpenStreetMap.
|
||||||
|
/// Route TOLL = bleu, Route TOLL_FREE = vert.
|
||||||
|
class RouteMapWidget extends StatelessWidget {
|
||||||
|
final List<RouteResult> routes;
|
||||||
|
final RouteResult? selectedRoute;
|
||||||
|
|
||||||
|
const RouteMapWidget({
|
||||||
|
super.key,
|
||||||
|
required this.routes,
|
||||||
|
this.selectedRoute,
|
||||||
|
});
|
||||||
|
|
||||||
|
List<LatLng> _decode(String encoded) {
|
||||||
|
final pts = safeDecodePolyline(encoded);
|
||||||
|
// DEBUG: afficher dans la console du navigateur
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[MAP DEBUG] encoded length=${encoded.length}, decoded ${pts.length} points');
|
||||||
|
if (pts.isNotEmpty) {
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('[MAP DEBUG] first=${pts.first.latitude},${pts.first.longitude} last=${pts.last.latitude},${pts.last.longitude}');
|
||||||
|
}
|
||||||
|
return pts;
|
||||||
|
}
|
||||||
|
|
||||||
|
LatLngBounds? _computeBounds(List<List<LatLng>> allPoints) {
|
||||||
|
final flat = allPoints.expand((e) => e).cast<LatLng>().toList();
|
||||||
|
if (flat.isEmpty) return null;
|
||||||
|
return LatLngBounds.fromPoints(flat);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final allPolylines = <Polyline>[];
|
||||||
|
final allPointGroups = <List<LatLng>>[];
|
||||||
|
|
||||||
|
for (final route in routes) {
|
||||||
|
final pts = _decode(route.encodedPolyline);
|
||||||
|
if (pts.isEmpty) continue;
|
||||||
|
allPointGroups.add(pts);
|
||||||
|
|
||||||
|
final isSelected =
|
||||||
|
selectedRoute == null || selectedRoute!.routeType == route.routeType;
|
||||||
|
final isToll = route.routeType == 'TOLL';
|
||||||
|
|
||||||
|
allPolylines.add(Polyline(
|
||||||
|
points: pts,
|
||||||
|
strokeWidth: isSelected ? 5.0 : 3.0,
|
||||||
|
color: isToll
|
||||||
|
? (isSelected
|
||||||
|
? const Color(0xFF1565C0)
|
||||||
|
: const Color(0xFF1565C0).withValues(alpha: 0.4))
|
||||||
|
: (isSelected
|
||||||
|
? const Color(0xFF2E7D32)
|
||||||
|
: const Color(0xFF2E7D32).withValues(alpha: 0.4)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
final bounds = _computeBounds(allPointGroups);
|
||||||
|
final mapController = MapController();
|
||||||
|
|
||||||
|
// Marqueurs de départ / arrivée
|
||||||
|
final markers = <Marker>[];
|
||||||
|
for (final group in allPointGroups) {
|
||||||
|
if (group.isEmpty) continue;
|
||||||
|
// Départ
|
||||||
|
markers.add(Marker(
|
||||||
|
point: group.first,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
child: const Icon(Icons.circle, color: Colors.green, size: 20),
|
||||||
|
));
|
||||||
|
// Arrivée
|
||||||
|
markers.add(Marker(
|
||||||
|
point: group.last,
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
child: const Icon(Icons.location_pin, color: Colors.red, size: 32),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return FlutterMap(
|
||||||
|
mapController: mapController,
|
||||||
|
options: MapOptions(
|
||||||
|
initialCameraFit: bounds != null
|
||||||
|
? CameraFit.bounds(
|
||||||
|
bounds: bounds,
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
)
|
||||||
|
: CameraFit.bounds(
|
||||||
|
bounds: LatLngBounds.fromPoints([LatLng(46.2276, 2.2137)]),
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
),
|
||||||
|
interactionOptions: const InteractionOptions(
|
||||||
|
flags: InteractiveFlag.all,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TileLayer(
|
||||||
|
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
userAgentPackageName: 'com.em2events.em2rp',
|
||||||
|
),
|
||||||
|
PolylineLayer(polylines: allPolylines),
|
||||||
|
MarkerLayer(markers: markers),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/models/depot_model.dart';
|
||||||
|
import 'package:em2rp/services/travel_service.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
import 'package:em2rp/views/widgets/inputs/address_autocomplete_field.dart';
|
||||||
|
|
||||||
|
class DepotManagement extends StatefulWidget {
|
||||||
|
const DepotManagement({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DepotManagement> createState() => _DepotManagementState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DepotManagementState extends State<DepotManagement> {
|
||||||
|
final _service = TravelService();
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
void _showDepotDialog({DepotModel? depot}) {
|
||||||
|
final nameCtrl = TextEditingController(text: depot?.name ?? '');
|
||||||
|
final addressCtrl = TextEditingController(text: depot?.address ?? '');
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: Text(depot == null ? 'Ajouter un dépôt' : 'Modifier le dépôt'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 420,
|
||||||
|
child: Form(
|
||||||
|
key: formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: nameCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nom du dépôt *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.warehouse_outlined),
|
||||||
|
hintText: 'ex: Dépôt principal',
|
||||||
|
),
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.trim().isEmpty ? 'Requis' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
AddressAutocompleteField(
|
||||||
|
controller: addressCtrl,
|
||||||
|
label: 'Adresse du dépôt *',
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.trim().isEmpty ? 'Requis' : null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
if (!formKey.currentState!.validate()) return;
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
if (depot == null) {
|
||||||
|
await _service.addDepot(DepotModel(
|
||||||
|
id: '',
|
||||||
|
name: nameCtrl.text.trim(),
|
||||||
|
address: addressCtrl.text.trim(),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
await _service.updateDepot(depot.copyWith(
|
||||||
|
name: nameCtrl.text.trim(),
|
||||||
|
address: addressCtrl.text.trim(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(depot == null ? 'Ajouter' : 'Enregistrer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(DepotModel depot) async {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Supprimer le dépôt'),
|
||||||
|
content: Text('Supprimer "${depot.name}" ?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirm == true) {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
await _service.deleteDepot(depot.id);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warehouse_outlined, color: AppColors.rouge, size: 28),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Dépôts',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.headlineSmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Ajouter un dépôt'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () => _showDepotDialog(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Définissez les adresses de départ pour le calcul des frais de déplacement.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
||||||
|
Expanded(
|
||||||
|
child: StreamBuilder<List<DepotModel>>(
|
||||||
|
stream: _service.watchDepots(),
|
||||||
|
builder: (context, snap) {
|
||||||
|
if (snap.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final depots = snap.data ?? [];
|
||||||
|
if (depots.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.warehouse_outlined,
|
||||||
|
size: 64, color: Colors.grey[300]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Aucun dépôt configuré',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium
|
||||||
|
?.copyWith(color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Ajoutez un dépôt pour commencer.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: depots.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, i) {
|
||||||
|
final d = depots[i];
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: AppColors.rouge.withValues(alpha: 0.1),
|
||||||
|
child: Icon(Icons.warehouse_outlined, color: AppColors.rouge),
|
||||||
|
),
|
||||||
|
title: Text(d.name,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: Text(d.address),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
tooltip: 'Modifier',
|
||||||
|
onPressed: () => _showDepotDialog(depot: d),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline, color: Colors.red),
|
||||||
|
tooltip: 'Supprimer',
|
||||||
|
onPressed: () => _delete(d),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:em2rp/models/route_result_model.dart';
|
||||||
|
import 'package:em2rp/services/travel_service.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
|
class FuelPricesManagement extends StatefulWidget {
|
||||||
|
const FuelPricesManagement({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FuelPricesManagement> createState() => _FuelPricesManagementState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FuelPricesManagementState extends State<FuelPricesManagement> {
|
||||||
|
final _service = TravelService();
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _dieselCtrl = TextEditingController();
|
||||||
|
final _essenceCtrl = TextEditingController();
|
||||||
|
final _electriqueCtrl = TextEditingController();
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _isSaving = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
final prices = await _service.getFuelPrices();
|
||||||
|
_dieselCtrl.text = prices.diesel.toStringAsFixed(3);
|
||||||
|
_essenceCtrl.text = prices.essence.toStringAsFixed(3);
|
||||||
|
_electriqueCtrl.text = prices.electricite.toStringAsFixed(3);
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
setState(() => _isSaving = true);
|
||||||
|
try {
|
||||||
|
final prices = FuelPrices(
|
||||||
|
diesel: double.parse(_dieselCtrl.text.trim()),
|
||||||
|
essence: double.parse(_essenceCtrl.text.trim()),
|
||||||
|
electricite: double.parse(_electriqueCtrl.text.trim()),
|
||||||
|
);
|
||||||
|
await _service.saveFuelPrices(prices);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Prix mis à jour ✓'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isSaving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_dieselCtrl.dispose();
|
||||||
|
_essenceCtrl.dispose();
|
||||||
|
_electriqueCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPriceField({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required String label,
|
||||||
|
required String unit,
|
||||||
|
required IconData icon,
|
||||||
|
required Color color,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: color.withValues(alpha: 0.1),
|
||||||
|
child: Icon(icon, color: color),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
suffixText: unit,
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,3}'))
|
||||||
|
],
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.isEmpty) return 'Requis';
|
||||||
|
final parsed = double.tryParse(v);
|
||||||
|
if (parsed == null || parsed <= 0) return 'Valeur invalide';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.local_gas_station, color: AppColors.rouge, size: 28),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Prix des carburants',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.headlineSmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Ces prix sont utilisés pour calculer automatiquement le coût en carburant des déplacements.',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium
|
||||||
|
?.copyWith(color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
if (_isLoading)
|
||||||
|
const Center(child: CircularProgressIndicator())
|
||||||
|
else
|
||||||
|
Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildPriceField(
|
||||||
|
controller: _dieselCtrl,
|
||||||
|
label: 'Prix du Diesel',
|
||||||
|
unit: '€/L',
|
||||||
|
icon: Icons.local_gas_station,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildPriceField(
|
||||||
|
controller: _essenceCtrl,
|
||||||
|
label: 'Prix de l\'Essence',
|
||||||
|
unit: '€/L',
|
||||||
|
icon: Icons.local_gas_station,
|
||||||
|
color: Colors.orange,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildPriceField(
|
||||||
|
controller: _electriqueCtrl,
|
||||||
|
label: 'Prix de l\'Électricité',
|
||||||
|
unit: '€/kWh',
|
||||||
|
icon: Icons.electric_bolt,
|
||||||
|
color: Colors.amber,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: _isSaving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.save_outlined),
|
||||||
|
label: Text(_isSaving ? 'Enregistrement...' : 'Enregistrer les prix'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
onPressed: _isSaving ? null : _save,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:em2rp/models/vehicle_model.dart';
|
||||||
|
import 'package:em2rp/services/vehicle_service.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
|
class VehiclesManagement extends StatefulWidget {
|
||||||
|
const VehiclesManagement({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<VehiclesManagement> createState() => _VehiclesManagementState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VehiclesManagementState extends State<VehiclesManagement> {
|
||||||
|
final _service = VehicleService();
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
|
static const _fuelTypes = ['Diesel', 'Essence', 'Electrique'];
|
||||||
|
|
||||||
|
void _showVehicleDialog({VehicleModel? vehicle}) {
|
||||||
|
final formKey = GlobalKey<FormState>();
|
||||||
|
final nameCtrl =
|
||||||
|
TextEditingController(text: vehicle?.name ?? '');
|
||||||
|
final consoCtrl = TextEditingController(
|
||||||
|
text: vehicle?.consumptionPer100km.toString() ?? '');
|
||||||
|
final maintCtrl = TextEditingController(
|
||||||
|
text: vehicle?.maintenanceCostPerKm.toString() ?? '');
|
||||||
|
String fuelType = vehicle?.fuelType ?? 'Diesel';
|
||||||
|
int tollCategory = vehicle?.tollCategoryId ?? 2;
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => StatefulBuilder(builder: (ctx, setDlg) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text(vehicle == null ? 'Ajouter un véhicule' : 'Modifier le véhicule'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 480,
|
||||||
|
child: Form(
|
||||||
|
key: formKey,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Nom
|
||||||
|
TextFormField(
|
||||||
|
controller: nameCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Nom du véhicule *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.directions_car_outlined),
|
||||||
|
hintText: 'ex: Renault Master',
|
||||||
|
),
|
||||||
|
validator: (v) =>
|
||||||
|
v == null || v.trim().isEmpty ? 'Requis' : null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
// Type de carburant
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: fuelType,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Type de carburant *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.local_gas_station_outlined),
|
||||||
|
),
|
||||||
|
items: _fuelTypes
|
||||||
|
.map((f) => DropdownMenuItem(value: f, child: Text(f)))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setDlg(() => fuelType = v!),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
// Consommation
|
||||||
|
TextFormField(
|
||||||
|
controller: consoCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: fuelType == 'Electrique'
|
||||||
|
? 'Consommation (kWh/100km) *'
|
||||||
|
: 'Consommation (L/100km) *',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
prefixIcon: const Icon(Icons.speed_outlined),
|
||||||
|
),
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(
|
||||||
|
RegExp(r'^\d+\.?\d{0,2}'))
|
||||||
|
],
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.isEmpty) return 'Requis';
|
||||||
|
if (double.tryParse(v) == null) return 'Nombre invalide';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
// Coût maintenance
|
||||||
|
TextFormField(
|
||||||
|
controller: maintCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Coût maintenance (€/km) *',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.build_outlined),
|
||||||
|
hintText: 'ex: 0.08',
|
||||||
|
),
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(
|
||||||
|
RegExp(r'^\d+\.?\d{0,3}'))
|
||||||
|
],
|
||||||
|
validator: (v) {
|
||||||
|
if (v == null || v.isEmpty) return 'Requis';
|
||||||
|
if (double.tryParse(v) == null) return 'Nombre invalide';
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
// Catégorie péage
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Catégorie de péage : $tollCategory',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: List.generate(5, (i) {
|
||||||
|
final cat = i + 1;
|
||||||
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => setDlg(() => tollCategory = cat),
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: tollCategory == cat
|
||||||
|
? AppColors.rouge
|
||||||
|
: Colors.grey[200],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'$cat',
|
||||||
|
style: TextStyle(
|
||||||
|
color: tollCategory == cat
|
||||||
|
? Colors.white
|
||||||
|
: Colors.black87,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Classe 1 – Véhicules légers\n'
|
||||||
|
'Classe 2 – Véhicules intermédiaires\n'
|
||||||
|
'Classe 3 – Poids lourds, autocars et autres véhicules à 2 essieux\n'
|
||||||
|
'Classe 4 - Poids lourds et autres véhicules à 3 essieux et plus\n'
|
||||||
|
'Classe 5 – Motos, side-cars et trikes',
|
||||||
|
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
if (!formKey.currentState!.validate()) return;
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
final v = VehicleModel(
|
||||||
|
id: vehicle?.id ?? '',
|
||||||
|
name: nameCtrl.text.trim(),
|
||||||
|
fuelType: fuelType,
|
||||||
|
consumptionPer100km:
|
||||||
|
double.parse(consoCtrl.text.trim()),
|
||||||
|
maintenanceCostPerKm:
|
||||||
|
double.parse(maintCtrl.text.trim()),
|
||||||
|
tollCategoryId: tollCategory,
|
||||||
|
);
|
||||||
|
if (vehicle == null) {
|
||||||
|
await _service.addVehicle(v);
|
||||||
|
} else {
|
||||||
|
await _service.updateVehicle(v);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('Erreur: $e'),
|
||||||
|
backgroundColor: Colors.red),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(vehicle == null ? 'Ajouter' : 'Enregistrer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(VehicleModel v) async {
|
||||||
|
final confirm = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Supprimer le véhicule'),
|
||||||
|
content: Text('Supprimer "${v.name}" ?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('Annuler')),
|
||||||
|
ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red, foregroundColor: Colors.white),
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('Supprimer'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirm == true) {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
try {
|
||||||
|
await _service.deleteVehicle(v.id);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon _fuelIcon(String fuelType) {
|
||||||
|
switch (fuelType) {
|
||||||
|
case 'Electrique':
|
||||||
|
return const Icon(Icons.electric_bolt, color: Colors.amber);
|
||||||
|
case 'Essence':
|
||||||
|
return const Icon(Icons.local_gas_station, color: Colors.green);
|
||||||
|
default:
|
||||||
|
return const Icon(Icons.local_gas_station, color: Colors.orange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.directions_car_outlined,
|
||||||
|
color: AppColors.rouge, size: 28),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
'Véhicules',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.headlineSmall
|
||||||
|
?.copyWith(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Ajouter un véhicule'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () => _showVehicleDialog(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Paramétrez la flotte de véhicules pour le calcul automatique des frais de déplacement.',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.bodyMedium
|
||||||
|
?.copyWith(color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
if (_isLoading) const LinearProgressIndicator(),
|
||||||
|
Expanded(
|
||||||
|
child: StreamBuilder<List<VehicleModel>>(
|
||||||
|
stream: _service.watchVehicles(),
|
||||||
|
builder: (context, snap) {
|
||||||
|
if (snap.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final vehicles = snap.data ?? [];
|
||||||
|
if (vehicles.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.directions_car_outlined,
|
||||||
|
size: 64, color: Colors.grey[300]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Aucun véhicule',
|
||||||
|
style: Theme.of(context)
|
||||||
|
.textTheme
|
||||||
|
.titleMedium
|
||||||
|
?.copyWith(color: Colors.grey[500]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('Ajoutez des véhicules pour commencer.'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
itemCount: vehicles.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final v = vehicles[i];
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: Colors.grey[100],
|
||||||
|
child: _fuelIcon(v.fuelType),
|
||||||
|
),
|
||||||
|
title: Text(v.name,
|
||||||
|
style:
|
||||||
|
const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
subtitle: Text(
|
||||||
|
'${v.consumptionPer100km} ${v.consumptionUnit} • Maint. ${v.maintenanceCostPerKm} €/km • Classe péage ${v.tollCategoryId}',
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Chip(
|
||||||
|
label: Text(v.fuelType),
|
||||||
|
backgroundColor: v.fuelType == 'Electrique'
|
||||||
|
? Colors.amber[50]
|
||||||
|
: v.fuelType == 'Essence'
|
||||||
|
? Colors.green[50]
|
||||||
|
: Colors.orange[50],
|
||||||
|
side: BorderSide.none,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.edit_outlined),
|
||||||
|
tooltip: 'Modifier',
|
||||||
|
onPressed: () => _showVehicleDialog(vehicle: v),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline,
|
||||||
|
color: Colors.red),
|
||||||
|
tooltip: 'Supprimer',
|
||||||
|
onPressed: () => _delete(v),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,7 +40,9 @@ class _EquipmentAssociatedEventsSectionState
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadEvents() async {
|
Future<void> _loadEvents() async {
|
||||||
setState(() => _isLoading = true);
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Récupérer TOUS les événements via l'API
|
// Récupérer TOUS les événements via l'API
|
||||||
@@ -128,12 +130,16 @@ class _EquipmentAssociatedEventsSectionState
|
|||||||
// Trier par date
|
// Trier par date
|
||||||
filteredEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
filteredEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_events = filteredEvents;
|
setState(() {
|
||||||
_isLoading = false;
|
_events = filteredEvents;
|
||||||
});
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _isLoading = false);
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
@@ -207,7 +213,9 @@ class _EquipmentAssociatedEventsSectionState
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
onSelected: (filter) {
|
onSelected: (filter) {
|
||||||
setState(() => _selectedFilter = filter);
|
if (mounted) {
|
||||||
|
setState(() => _selectedFilter = filter);
|
||||||
|
}
|
||||||
_loadEvents();
|
_loadEvents();
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => EventFilter.values.map((filter) {
|
itemBuilder: (context) => EventFilter.values.map((filter) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ enum ChecklistStep {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Widget pour afficher un équipement dans une checklist de préparation/retour
|
/// Widget pour afficher un équipement dans une checklist de préparation/retour
|
||||||
class EquipmentChecklistItem extends StatelessWidget {
|
class EquipmentChecklistItem extends StatefulWidget {
|
||||||
final EquipmentModel equipment;
|
final EquipmentModel equipment;
|
||||||
final EventEquipment eventEquipment;
|
final EventEquipment eventEquipment;
|
||||||
final ChecklistStep step;
|
final ChecklistStep step;
|
||||||
@@ -34,92 +34,120 @@ class EquipmentChecklistItem extends StatelessWidget {
|
|||||||
this.wasMissingBefore = false,
|
this.wasMissingBefore = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Retourne la quantité actuelle selon l'étape
|
@override
|
||||||
|
State<EquipmentChecklistItem> createState() => _EquipmentChecklistItemState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EquipmentChecklistItemState extends State<EquipmentChecklistItem> {
|
||||||
|
late TextEditingController _quantityController;
|
||||||
|
|
||||||
int _getCurrentQuantity() {
|
int _getCurrentQuantity() {
|
||||||
switch (step) {
|
switch (widget.step) {
|
||||||
case ChecklistStep.preparation:
|
case ChecklistStep.preparation:
|
||||||
return eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
|
return widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
|
||||||
case ChecklistStep.loading:
|
case ChecklistStep.loading:
|
||||||
return eventEquipment.quantityAtLoading ?? eventEquipment.quantityAtPreparation ?? eventEquipment.quantity;
|
return widget.eventEquipment.quantityAtLoading ?? widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
|
||||||
case ChecklistStep.unloading:
|
case ChecklistStep.unloading:
|
||||||
return eventEquipment.quantityAtUnloading ?? eventEquipment.quantityAtLoading ?? eventEquipment.quantity;
|
return widget.eventEquipment.quantityAtUnloading ?? widget.eventEquipment.quantityAtLoading ?? widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
|
||||||
case ChecklistStep.return_:
|
case ChecklistStep.return_:
|
||||||
return eventEquipment.quantityAtReturn ?? eventEquipment.quantityAtUnloading ?? eventEquipment.quantity;
|
return widget.eventEquipment.quantityAtReturn ?? widget.eventEquipment.quantityAtUnloading ?? widget.eventEquipment.quantityAtLoading ?? widget.eventEquipment.quantityAtPreparation ?? widget.eventEquipment.quantity;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_quantityController = TextEditingController(text: _getCurrentQuantity().toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant EquipmentChecklistItem oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
final currentQty = _getCurrentQuantity();
|
||||||
|
final controllerQty = int.tryParse(_quantityController.text);
|
||||||
|
if (controllerQty != currentQty) {
|
||||||
|
_quantityController.text = currentQty.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_quantityController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hasQuantity = equipment.hasQuantity;
|
final hasQuantity = widget.equipment.hasQuantity;
|
||||||
|
|
||||||
// Déterminer la quantité actuelle selon l'étape
|
// Déterminer la quantité actuelle selon l'étape
|
||||||
final int currentQuantity = _getCurrentQuantity();
|
final int currentQuantity = _getCurrentQuantity();
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
left: isChild ? 32.0 : 0.0, // Indentation pour les enfants
|
left: widget.isChild ? 32.0 : 0.0, // Indentation pour les enfants
|
||||||
top: 4.0,
|
top: 4.0,
|
||||||
bottom: 4.0,
|
bottom: 4.0,
|
||||||
),
|
),
|
||||||
child: Card(
|
child: Card(
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
elevation: isChild ? 0 : 1, // Pas d'élévation pour les enfants
|
elevation: widget.isChild ? 0 : 1, // Pas d'élévation pour les enfants
|
||||||
color: wasMissingBefore
|
color: widget.wasMissingBefore
|
||||||
? Colors.orange.shade50
|
? Colors.orange.shade50
|
||||||
: (isChild ? Colors.grey.shade50 : Colors.white),
|
: (widget.isChild ? Colors.grey.shade50 : Colors.white),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: wasMissingBefore
|
color: widget.wasMissingBefore
|
||||||
? Colors.orange
|
? Colors.orange
|
||||||
: (isValidated ? Colors.green : Colors.grey.shade300),
|
: (widget.isValidated ? Colors.green : Colors.grey.shade300),
|
||||||
width: (isValidated || wasMissingBefore) ? 2 : 1,
|
width: (widget.isValidated || widget.wasMissingBefore) ? 2 : 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
dense: isChild, // Plus compact pour les enfants
|
dense: widget.isChild, // Plus compact pour les enfants
|
||||||
contentPadding: EdgeInsets.symmetric(
|
contentPadding: EdgeInsets.symmetric(
|
||||||
horizontal: isChild ? 8.0 : 16.0,
|
horizontal: widget.isChild ? 8.0 : 16.0,
|
||||||
vertical: isChild ? 4.0 : 8.0,
|
vertical: widget.isChild ? 4.0 : 8.0,
|
||||||
),
|
),
|
||||||
leading: Container(
|
leading: Container(
|
||||||
width: isChild ? 32 : 40,
|
width: widget.isChild ? 32 : 40,
|
||||||
height: isChild ? 32 : 40,
|
height: widget.isChild ? 32 : 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: wasMissingBefore
|
color: widget.wasMissingBefore
|
||||||
? Colors.orange.shade100
|
? Colors.orange.shade100
|
||||||
: (isValidated ? Colors.green.shade100 : Colors.grey.shade100),
|
: (widget.isValidated ? Colors.green.shade100 : Colors.grey.shade100),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
wasMissingBefore
|
widget.wasMissingBefore
|
||||||
? Icons.warning
|
? Icons.warning
|
||||||
: (isValidated ? Icons.check_circle : Icons.radio_button_unchecked),
|
: (widget.isValidated ? Icons.check_circle : Icons.radio_button_unchecked),
|
||||||
color: wasMissingBefore
|
color: widget.wasMissingBefore
|
||||||
? Colors.orange
|
? Colors.orange
|
||||||
: (isValidated ? Colors.green : Colors.grey),
|
: (widget.isValidated ? Colors.green : Colors.grey),
|
||||||
size: isChild ? 18 : 24,
|
size: widget.isChild ? 18 : 24,
|
||||||
),
|
),
|
||||||
onPressed: onToggle,
|
onPressed: widget.onToggle,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
equipment.name,
|
widget.equipment.name,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: isChild ? FontWeight.w500 : FontWeight.w600,
|
fontWeight: widget.isChild ? FontWeight.w500 : FontWeight.w600,
|
||||||
fontSize: isChild ? 13 : 15,
|
fontSize: widget.isChild ? 13 : 15,
|
||||||
decoration: isValidated ? TextDecoration.lineThrough : null,
|
decoration: widget.isValidated ? TextDecoration.lineThrough : null,
|
||||||
color: isValidated ? Colors.grey : null,
|
color: widget.isValidated ? Colors.grey : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: Column(
|
subtitle: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (equipment.model != null)
|
if (widget.equipment.model != null)
|
||||||
Text(
|
Text(
|
||||||
equipment.model!,
|
widget.equipment.model!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Colors.grey.shade600,
|
color: Colors.grey.shade600,
|
||||||
@@ -127,12 +155,12 @@ class EquipmentChecklistItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Indicateur si manquant à l'étape précédente
|
// Indicateur si manquant à l'étape précédente
|
||||||
if (wasMissingBefore)
|
if (widget.wasMissingBefore)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4.0),
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.warning_amber, size: 14, color: Colors.orange),
|
const Icon(Icons.warning_amber, size: 14, color: Colors.orange),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'Était manquant à l\'étape précédente',
|
'Était manquant à l\'étape précédente',
|
||||||
@@ -151,7 +179,7 @@ class EquipmentChecklistItem extends StatelessWidget {
|
|||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
const Text(
|
||||||
'Quantité : ',
|
'Quantité : ',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -159,11 +187,11 @@ class EquipmentChecklistItem extends StatelessWidget {
|
|||||||
color: AppColors.bleuFonce,
|
color: AppColors.bleuFonce,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (onQuantityChanged != null)
|
if (widget.onQuantityChanged != null)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 60,
|
width: 60,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
initialValue: currentQuantity.toString(),
|
controller: _quantityController,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
style: const TextStyle(fontSize: 12),
|
style: const TextStyle(fontSize: 12),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
@@ -175,14 +203,14 @@ class EquipmentChecklistItem extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
final qty = int.tryParse(value) ?? currentQuantity;
|
final qty = int.tryParse(value) ?? currentQuantity;
|
||||||
onQuantityChanged!(qty);
|
widget.onQuantityChanged!(qty);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Text(
|
Text(
|
||||||
currentQuantity.toString(),
|
currentQuantity.toString(),
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.bleuFonce,
|
color: AppColors.bleuFonce,
|
||||||
@@ -193,16 +221,16 @@ class EquipmentChecklistItem extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: isValidated
|
trailing: widget.isValidated
|
||||||
? Container(
|
? Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.green.shade100,
|
color: Colors.green.shade100,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: const Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: const [
|
children: [
|
||||||
Icon(Icons.check, size: 16, color: Colors.green),
|
Icon(Icons.check, size: 16, color: Colors.green),
|
||||||
SizedBox(width: 4),
|
SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -34,7 +34,9 @@ class _EquipmentCurrentEventsSectionState
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadCurrentEvents() async {
|
Future<void> _loadCurrentEvents() async {
|
||||||
setState(() => _isLoading = true);
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Récupérer TOUS les événements via l'API
|
// Récupérer TOUS les événements via l'API
|
||||||
@@ -106,12 +108,16 @@ class _EquipmentCurrentEventsSectionState
|
|||||||
// Trier par date
|
// Trier par date
|
||||||
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||||
|
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_events = events;
|
setState(() {
|
||||||
_isLoading = false;
|
_events = events;
|
||||||
});
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() => _isLoading = false);
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class _EventAssignedEquipmentSectionState
|
|||||||
final Map<String, EquipmentModel> _equipmentCache = {};
|
final Map<String, EquipmentModel> _equipmentCache = {};
|
||||||
final Map<String, ContainerModel> _containerCache = {};
|
final Map<String, ContainerModel> _containerCache = {};
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -50,6 +51,12 @@ class _EventAssignedEquipmentSectionState
|
|||||||
_loadEquipmentAndContainers();
|
_loadEquipmentAndContainers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(EventAssignedEquipmentSection oldWidget) {
|
void didUpdateWidget(EventAssignedEquipmentSection oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
@@ -394,74 +401,93 @@ class _EventAssignedEquipmentSectionState
|
|||||||
widget.assignedEquipment.length + widget.assignedContainers.length;
|
widget.assignedEquipment.length + widget.assignedContainers.length;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
elevation: 2,
|
elevation: 0,
|
||||||
|
color: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: Colors.grey.shade200, width: 1),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// En-tête
|
// En-tête
|
||||||
Row(
|
SizedBox(
|
||||||
children: [
|
width: double.infinity,
|
||||||
Container(
|
child: Wrap(
|
||||||
padding: const EdgeInsets.all(8),
|
spacing: 16,
|
||||||
decoration: BoxDecoration(
|
runSpacing: 16,
|
||||||
color: AppColors.rouge.withValues(alpha: 0.1),
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
borderRadius: BorderRadius.circular(8),
|
alignment: WrapAlignment.spaceBetween,
|
||||||
),
|
children: [
|
||||||
child: const Icon(
|
Row(
|
||||||
Icons.inventory_2,
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: AppColors.rouge,
|
|
||||||
size: 24,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
Container(
|
||||||
'Matériel assigné',
|
padding: const EdgeInsets.all(10),
|
||||||
style: TextStyle(
|
decoration: BoxDecoration(
|
||||||
fontSize: 18,
|
color: AppColors.rouge.withOpacity(0.1),
|
||||||
fontWeight: FontWeight.bold,
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.inventory_2,
|
||||||
|
color: AppColors.rouge,
|
||||||
|
size: 22,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
const SizedBox(width: 16),
|
||||||
'$totalItems élément(s)',
|
Column(
|
||||||
style: TextStyle(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
fontSize: 14,
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: Colors.grey.shade600,
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Matériel assigné',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'$totalItems élément(s)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ActionChip(
|
||||||
|
onPressed: _canAddMaterial ? _openAiAssistantDialog : null,
|
||||||
|
avatar: const Icon(Icons.auto_fix_high, size: 18),
|
||||||
|
label: const Text('Assistant IA'),
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _canAddMaterial ? _openSelectionDialog : null,
|
||||||
|
icon: Icon(Icons.add,
|
||||||
|
color: _canAddMaterial ? Colors.white : Colors.grey),
|
||||||
|
label: Text(
|
||||||
|
'Ajouter',
|
||||||
|
style: TextStyle(
|
||||||
|
color: _canAddMaterial ? Colors.white : Colors.grey),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _canAddMaterial
|
||||||
|
? AppColors.rouge
|
||||||
|
: Colors.grey.shade300,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
ActionChip(
|
),
|
||||||
onPressed: _canAddMaterial ? _openAiAssistantDialog : null,
|
|
||||||
avatar: const Icon(Icons.auto_fix_high, size: 18),
|
|
||||||
label: const Text('Assistant IA'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
onPressed: _canAddMaterial ? _openSelectionDialog : null,
|
|
||||||
icon: Icon(Icons.add,
|
|
||||||
color: _canAddMaterial ? Colors.white : Colors.grey),
|
|
||||||
label: Text(
|
|
||||||
'Ajouter',
|
|
||||||
style: TextStyle(
|
|
||||||
color: _canAddMaterial ? Colors.white : Colors.grey),
|
|
||||||
),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: _canAddMaterial
|
|
||||||
? AppColors.rouge
|
|
||||||
: Colors.grey.shade300,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// Message si dates non sélectionnées
|
// Message si dates non sélectionnées
|
||||||
@@ -522,42 +548,83 @@ class _EventAssignedEquipmentSectionState
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
Column(
|
Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
constraints: const BoxConstraints(maxHeight: 400),
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
// Conteneurs
|
border: Border.all(color: Colors.grey.shade200),
|
||||||
if (widget.assignedContainers.isNotEmpty) ...[
|
borderRadius: BorderRadius.circular(8),
|
||||||
Text(
|
color: Colors.grey.shade50,
|
||||||
'Boîtes (${widget.assignedContainers.length})',
|
),
|
||||||
style: const TextStyle(
|
child: SingleChildScrollView(
|
||||||
fontWeight: FontWeight.bold,
|
controller: _scrollController,
|
||||||
fontSize: 16,
|
padding: const EdgeInsets.all(12),
|
||||||
),
|
child: LayoutBuilder(
|
||||||
),
|
builder: (context, constraints) {
|
||||||
const SizedBox(height: 8),
|
final hasContainers = widget.assignedContainers.isNotEmpty;
|
||||||
...widget.assignedContainers.map((containerId) {
|
final hasEquipment = _getStandaloneEquipment().isNotEmpty;
|
||||||
final container = _containerCache[containerId];
|
|
||||||
return _buildContainerItem(container);
|
|
||||||
}),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Équipements directs (qui ne sont PAS dans un conteneur assigné)
|
final containersContent = Column(
|
||||||
if (_getStandaloneEquipment().isNotEmpty) ...[
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text(
|
children: [
|
||||||
'Équipements (${_getStandaloneEquipment().length})',
|
if (hasContainers) ...[
|
||||||
style: const TextStyle(
|
Text(
|
||||||
fontWeight: FontWeight.bold,
|
'Boîtes (${widget.assignedContainers.length})',
|
||||||
fontSize: 16,
|
style: const TextStyle(
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
),
|
fontSize: 16,
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
..._getStandaloneEquipment().map((eq) {
|
),
|
||||||
final equipment = _equipmentCache[eq.equipmentId];
|
const SizedBox(height: 8),
|
||||||
return _buildEquipmentItem(equipment, eq);
|
...widget.assignedContainers.map((containerId) {
|
||||||
}),
|
final container = _containerCache[containerId];
|
||||||
],
|
return _buildContainerItem(container);
|
||||||
],
|
}),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
final equipmentContent = Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (hasEquipment) ...[
|
||||||
|
Text(
|
||||||
|
'Équipements (${_getStandaloneEquipment().length})',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
..._getStandaloneEquipment().map((eq) {
|
||||||
|
final equipment = _equipmentCache[eq.equipmentId];
|
||||||
|
return _buildEquipmentItem(equipment, eq);
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (constraints.maxWidth > 600 && hasContainers && hasEquipment) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: containersContent),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
Expanded(child: equipmentContent),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
containersContent,
|
||||||
|
equipmentContent,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class EventBasicInfoSection extends StatelessWidget {
|
|||||||
final String? selectedEventTypeId;
|
final String? selectedEventTypeId;
|
||||||
final DateTime? startDateTime;
|
final DateTime? startDateTime;
|
||||||
final DateTime? endDateTime;
|
final DateTime? endDateTime;
|
||||||
|
final List<Map<String, dynamic>> selectedOptions;
|
||||||
final Function(String?) onEventTypeChanged;
|
final Function(String?) onEventTypeChanged;
|
||||||
final Function(DateTime?) onStartDateTimeChanged;
|
final Function(DateTime?) onStartDateTimeChanged;
|
||||||
final Function(DateTime?) onEndDateTimeChanged;
|
final Function(DateTime?) onEndDateTimeChanged;
|
||||||
@@ -25,6 +26,7 @@ class EventBasicInfoSection extends StatelessWidget {
|
|||||||
required this.selectedEventTypeId,
|
required this.selectedEventTypeId,
|
||||||
required this.startDateTime,
|
required this.startDateTime,
|
||||||
required this.endDateTime,
|
required this.endDateTime,
|
||||||
|
required this.selectedOptions,
|
||||||
required this.onEventTypeChanged,
|
required this.onEventTypeChanged,
|
||||||
required this.onStartDateTimeChanged,
|
required this.onStartDateTimeChanged,
|
||||||
required this.onEndDateTimeChanged,
|
required this.onEndDateTimeChanged,
|
||||||
@@ -36,7 +38,6 @@ class EventBasicInfoSection extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionTitle('Informations principales'),
|
|
||||||
TextFormField(
|
TextFormField(
|
||||||
controller: nameController,
|
controller: nameController,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
@@ -50,113 +51,104 @@ class EventBasicInfoSection extends StatelessWidget {
|
|||||||
if (isLoadingEventTypes)
|
if (isLoadingEventTypes)
|
||||||
const Center(child: CircularProgressIndicator())
|
const Center(child: CircularProgressIndicator())
|
||||||
else
|
else
|
||||||
Row(
|
DropdownButtonFormField<String>(
|
||||||
mainAxisSize: MainAxisSize.min,
|
initialValue: selectedEventTypeId,
|
||||||
children: [
|
items: eventTypes
|
||||||
Flexible(
|
.map((type) => DropdownMenuItem<String>(
|
||||||
fit: FlexFit.loose,
|
value: type.id,
|
||||||
child: DropdownButtonFormField<String>(
|
child: Text(type.name),
|
||||||
initialValue: selectedEventTypeId,
|
))
|
||||||
items: eventTypes
|
.toList(),
|
||||||
.map((type) => DropdownMenuItem<String>(
|
onChanged: onEventTypeChanged,
|
||||||
value: type.id,
|
decoration: const InputDecoration(
|
||||||
child: Text(type.name),
|
labelText: 'Type d\'événement*',
|
||||||
))
|
border: OutlineInputBorder(),
|
||||||
.toList(),
|
prefixIcon: Icon(Icons.category),
|
||||||
onChanged: onEventTypeChanged,
|
),
|
||||||
decoration: const InputDecoration(
|
validator: (v) => v == null ? 'Sélectionnez un type' : null,
|
||||||
labelText: 'Type d\'événement*',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(Icons.category),
|
|
||||||
),
|
|
||||||
validator: (v) => v == null ? 'Sélectionnez un type' : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Flexible(
|
|
||||||
fit: FlexFit.loose,
|
|
||||||
child: _buildDateTimeRow(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
_buildDateTimeRow(context),
|
||||||
|
const SizedBox(height: 16),
|
||||||
PriceHtTtcFields(
|
PriceHtTtcFields(
|
||||||
basePriceController: basePriceController,
|
basePriceController: basePriceController,
|
||||||
onPriceChanged: onAnyFieldChanged,
|
onPriceChanged: onAnyFieldChanged,
|
||||||
|
selectedOptions: selectedOptions,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSectionTitle(String title) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 0.0, bottom: 4.0),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDateTimeRow(BuildContext context) {
|
Widget _buildDateTimeRow(BuildContext context) {
|
||||||
return Row(
|
final startField = GestureDetector(
|
||||||
children: [
|
onTap: () => _selectStartDateTime(context),
|
||||||
Expanded(
|
child: AbsorbPointer(
|
||||||
child: GestureDetector(
|
child: TextFormField(
|
||||||
onTap: () => _selectStartDateTime(context),
|
readOnly: true,
|
||||||
child: AbsorbPointer(
|
decoration: const InputDecoration(
|
||||||
child: TextFormField(
|
labelText: 'Début*',
|
||||||
readOnly: true,
|
border: OutlineInputBorder(),
|
||||||
decoration: const InputDecoration(
|
suffixIcon: Icon(Icons.edit_calendar),
|
||||||
labelText: 'Début*',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(Icons.calendar_today),
|
|
||||||
suffixIcon: Icon(Icons.edit_calendar),
|
|
||||||
),
|
|
||||||
controller: TextEditingController(
|
|
||||||
text: startDateTime == null
|
|
||||||
? ''
|
|
||||||
: DateFormat('dd/MM/yyyy HH:mm').format(startDateTime!),
|
|
||||||
),
|
|
||||||
validator: (v) => startDateTime == null ? 'Champ requis' : null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
controller: TextEditingController(
|
||||||
const SizedBox(width: 16),
|
text: startDateTime == null
|
||||||
Expanded(
|
? ''
|
||||||
child: GestureDetector(
|
: DateFormat('dd/MM/yyyy HH:mm').format(startDateTime!),
|
||||||
onTap: startDateTime == null ? null : () => _selectEndDateTime(context),
|
|
||||||
child: AbsorbPointer(
|
|
||||||
child: TextFormField(
|
|
||||||
readOnly: true,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Fin*',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(Icons.calendar_today),
|
|
||||||
suffixIcon: Icon(Icons.edit_calendar),
|
|
||||||
),
|
|
||||||
controller: TextEditingController(
|
|
||||||
text: endDateTime == null
|
|
||||||
? ''
|
|
||||||
: DateFormat('dd/MM/yyyy HH:mm').format(endDateTime!),
|
|
||||||
),
|
|
||||||
validator: (v) => endDateTime == null
|
|
||||||
? 'Champ requis'
|
|
||||||
: (startDateTime != null &&
|
|
||||||
endDateTime != null &&
|
|
||||||
(endDateTime!.isBefore(startDateTime!) ||
|
|
||||||
endDateTime!.isAtSameMomentAs(startDateTime!)))
|
|
||||||
? 'La date de fin doit être après la date de début'
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
validator: (v) => startDateTime == null ? 'Champ requis' : null,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final endField = GestureDetector(
|
||||||
|
onTap: startDateTime == null ? null : () => _selectEndDateTime(context),
|
||||||
|
child: AbsorbPointer(
|
||||||
|
child: TextFormField(
|
||||||
|
readOnly: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Fin*',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
suffixIcon: Icon(Icons.edit_calendar),
|
||||||
|
),
|
||||||
|
controller: TextEditingController(
|
||||||
|
text: endDateTime == null
|
||||||
|
? ''
|
||||||
|
: DateFormat('dd/MM/yyyy HH:mm').format(endDateTime!),
|
||||||
|
),
|
||||||
|
validator: (v) => endDateTime == null
|
||||||
|
? 'Champ requis'
|
||||||
|
: (startDateTime != null &&
|
||||||
|
endDateTime != null &&
|
||||||
|
(endDateTime!.isBefore(startDateTime!) ||
|
||||||
|
endDateTime!.isAtSameMomentAs(startDateTime!)))
|
||||||
|
? 'La date de fin doit être après la date de début'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
if (constraints.maxWidth > 500) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: startField),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(child: endField),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
startField,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
endField,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/views/widgets/inputs/int_stepper_field.dart';
|
import 'package:em2rp/views/widgets/inputs/int_stepper_field.dart';
|
||||||
|
import 'package:em2rp/views/widgets/inputs/address_autocomplete_field.dart';
|
||||||
|
import 'package:em2rp/views/widgets/event_form/travel_cost_dialog.dart';
|
||||||
|
|
||||||
class EventDetailsSection extends StatefulWidget {
|
class EventDetailsSection extends StatefulWidget {
|
||||||
final TextEditingController descriptionController;
|
final TextEditingController descriptionController;
|
||||||
@@ -11,6 +13,9 @@ class EventDetailsSection extends StatefulWidget {
|
|||||||
final TextEditingController contactPhoneController;
|
final TextEditingController contactPhoneController;
|
||||||
final bool isMobile;
|
final bool isMobile;
|
||||||
final VoidCallback onAnyFieldChanged;
|
final VoidCallback onAnyFieldChanged;
|
||||||
|
/// Callback appelé quand l'utilisateur sélectionne un itinéraire
|
||||||
|
/// avec le prix total calculé (pour ajouter l'option FRAIS_KM)
|
||||||
|
final void Function(double price)? onTravelCostSelected;
|
||||||
|
|
||||||
const EventDetailsSection({
|
const EventDetailsSection({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -23,6 +28,7 @@ class EventDetailsSection extends StatefulWidget {
|
|||||||
required this.contactPhoneController,
|
required this.contactPhoneController,
|
||||||
required this.isMobile,
|
required this.isMobile,
|
||||||
required this.onAnyFieldChanged,
|
required this.onAnyFieldChanged,
|
||||||
|
this.onTravelCostSelected,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -56,8 +62,6 @@ class _EventDetailsSectionState extends State<EventDetailsSection> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionTitle('Détails'),
|
|
||||||
|
|
||||||
// Description et champs de contact
|
// Description et champs de contact
|
||||||
widget.isMobile
|
widget.isMobile
|
||||||
? _buildMobileLayout()
|
? _buildMobileLayout()
|
||||||
@@ -87,17 +91,47 @@ class _EventDetailsSectionState extends State<EventDetailsSection> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
_buildSectionTitle('Adresse*'),
|
AddressAutocompleteField(
|
||||||
TextFormField(
|
|
||||||
controller: widget.addressController,
|
controller: widget.addressController,
|
||||||
decoration: const InputDecoration(
|
label: 'Adresse*',
|
||||||
labelText: 'Adresse*',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
prefixIcon: Icon(Icons.location_on),
|
|
||||||
),
|
|
||||||
validator: (v) => v == null || v.isEmpty ? 'Champ requis' : null,
|
validator: (v) => v == null || v.isEmpty ? 'Champ requis' : null,
|
||||||
onChanged: (_) => widget.onAnyFieldChanged(),
|
onSelected: (_) => widget.onAnyFieldChanged(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// Bouton calcul frais déplacement
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.route_outlined),
|
||||||
|
label: const Text('Calculer les frais de déplacement'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.blueGrey[700],
|
||||||
|
side: BorderSide(color: Colors.blueGrey[300]!),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
final address = widget.addressController.text.trim();
|
||||||
|
if (address.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Veuillez d\'abord renseigner l\'adresse de l\'événement.'),
|
||||||
|
backgroundColor: Colors.orange,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final price = await showTravelCostDialog(
|
||||||
|
context: context,
|
||||||
|
eventAddress: address,
|
||||||
|
);
|
||||||
|
if (price != null && widget.onTravelCostSelected != null) {
|
||||||
|
widget.onTravelCostSelected!(price);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -196,17 +230,4 @@ class _EventDetailsSectionState extends State<EventDetailsSection> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSectionTitle(String title) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 16.0, bottom: 8.0),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,67 +20,64 @@ class EventFormActions extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Padding(
|
||||||
children: [
|
padding: const EdgeInsets.only(top: 24.0),
|
||||||
const SizedBox(height: 24),
|
child: Wrap(
|
||||||
Row(
|
alignment: WrapAlignment.spaceBetween,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
spacing: 16,
|
||||||
// Bouton de suppression en mode édition
|
runSpacing: 16,
|
||||||
if (isEditMode && onDelete != null)
|
children: [
|
||||||
ElevatedButton.icon(
|
// Bouton de suppression en mode édition
|
||||||
icon: const Icon(Icons.delete, color: Colors.white),
|
if (isEditMode && onDelete != null)
|
||||||
label: const Text('Supprimer'),
|
ElevatedButton.icon(
|
||||||
style: ElevatedButton.styleFrom(
|
icon: const Icon(Icons.delete, color: Colors.white),
|
||||||
backgroundColor: Colors.red,
|
label: const Text('Supprimer'),
|
||||||
foregroundColor: Colors.white,
|
style: ElevatedButton.styleFrom(
|
||||||
),
|
backgroundColor: Colors.red,
|
||||||
onPressed: isLoading ? null : onDelete,
|
foregroundColor: Colors.white,
|
||||||
)
|
|
||||||
else
|
|
||||||
const SizedBox.shrink(), // Espace vide si pas en mode édition
|
|
||||||
|
|
||||||
// Boutons Annuler et Enregistrer/Créer
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: isLoading ? null : onCancel,
|
|
||||||
child: const Text('Annuler'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
ElevatedButton.icon(
|
|
||||||
icon: const Icon(Icons.check),
|
|
||||||
onPressed: isLoading ? null : onSubmit,
|
|
||||||
label: isLoading
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: Text(isEditMode ? 'Enregistrer' : 'Créer'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (!isEditMode && onSetConfirmed != null)
|
|
||||||
Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 16.0),
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
icon: const Icon(Icons.check_circle, color: Colors.white),
|
|
||||||
label: const Text('Définir cet événement comme confirmé'),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
textStyle: const TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
onPressed: onSetConfirmed,
|
|
||||||
),
|
),
|
||||||
),
|
onPressed: isLoading ? null : onDelete,
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox.shrink(),
|
||||||
|
|
||||||
|
// Boutons Annuler, Enregistrer/Créer et Valider
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
alignment: WrapAlignment.end,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: isLoading ? null : onCancel,
|
||||||
|
child: const Text('Annuler'),
|
||||||
|
),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.check),
|
||||||
|
onPressed: isLoading ? null : onSubmit,
|
||||||
|
label: isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: Text(isEditMode ? 'Enregistrer' : 'Créer'),
|
||||||
|
),
|
||||||
|
if (!isEditMode && onSetConfirmed != null)
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.check_circle, color: Colors.white),
|
||||||
|
label: const Text('Créer et valider'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: isLoading ? null : onSetConfirmed,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,16 +36,22 @@ class EventStaffAndDocumentsSection extends StatelessWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionTitle('Personnel'),
|
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: UserMultiSelectWidget(
|
child: Column(
|
||||||
allUsers: allUsers,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
selectedUserIds: selectedUserIds,
|
children: [
|
||||||
onChanged: onUserSelectionChanged,
|
const Text('Personnel', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
isLoading: isLoadingUsers,
|
const SizedBox(height: 8),
|
||||||
|
UserMultiSelectWidget(
|
||||||
|
allUsers: allUsers,
|
||||||
|
selectedUserIds: selectedUserIds,
|
||||||
|
onChanged: onUserSelectionChanged,
|
||||||
|
isLoading: isLoadingUsers,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
@@ -53,7 +59,8 @@ class EventStaffAndDocumentsSection extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionTitle('Documents'),
|
const Text('Documents', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
if (isMobile)
|
if (isMobile)
|
||||||
_buildMobileDocumentUpload()
|
_buildMobileDocumentUpload()
|
||||||
else
|
else
|
||||||
@@ -73,19 +80,6 @@ class EventStaffAndDocumentsSection extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSectionTitle(String title) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 16.0, bottom: 8.0),
|
|
||||||
child: Align(
|
|
||||||
alignment: Alignment.centerLeft,
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildMobileDocumentUpload() {
|
Widget _buildMobileDocumentUpload() {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ class PriceHtTtcFields extends StatefulWidget {
|
|||||||
final TextEditingController basePriceController;
|
final TextEditingController basePriceController;
|
||||||
final VoidCallback onPriceChanged;
|
final VoidCallback onPriceChanged;
|
||||||
final double taxRate;
|
final double taxRate;
|
||||||
|
final List<Map<String, dynamic>> selectedOptions;
|
||||||
|
|
||||||
const PriceHtTtcFields({
|
const PriceHtTtcFields({
|
||||||
super.key,
|
super.key,
|
||||||
required this.basePriceController,
|
required this.basePriceController,
|
||||||
required this.onPriceChanged,
|
required this.onPriceChanged,
|
||||||
this.taxRate = 0.20,
|
this.taxRate = 0.20,
|
||||||
|
this.selectedOptions = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -133,7 +135,7 @@ class _PriceHtTtcFieldsState extends State<PriceHtTtcFields> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'Prix',
|
'Prix Base',
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -205,7 +207,7 @@ class _PriceHtTtcFieldsState extends State<PriceHtTtcFields> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
// Affichage du montant de TVA (en plus petit)
|
// Affichage du montant de TVA (en plus petit) et du Prix Total
|
||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final htText = _htController.text.replaceAll(',', '.');
|
final htText = _htController.text.replaceAll(',', '.');
|
||||||
@@ -213,16 +215,72 @@ class _PriceHtTtcFieldsState extends State<PriceHtTtcFields> {
|
|||||||
|
|
||||||
if (htValue != null) {
|
if (htValue != null) {
|
||||||
final taxAmount = PriceHelpers.calculateTax(htValue, taxRate: widget.taxRate);
|
final taxAmount = PriceHelpers.calculateTax(htValue, taxRate: widget.taxRate);
|
||||||
return Padding(
|
final ttcValue = PriceHelpers.calculateTTC(htValue, taxRate: widget.taxRate);
|
||||||
padding: const EdgeInsets.only(left: 8.0, top: 4.0),
|
|
||||||
child: Text(
|
return Column(
|
||||||
'TVA (${(widget.taxRate * 100).toStringAsFixed(0)}%) : ${taxAmount.toStringAsFixed(2)} €',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(
|
children: [
|
||||||
fontSize: 11,
|
Padding(
|
||||||
color: Colors.grey[600],
|
padding: const EdgeInsets.only(left: 8.0, top: 4.0),
|
||||||
fontStyle: FontStyle.italic,
|
child: Text(
|
||||||
|
'TVA (${(widget.taxRate * 100).toStringAsFixed(0)}%) : ${taxAmount.toStringAsFixed(2)} €',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (widget.selectedOptions.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
double optionsTotalTTC = 0.0;
|
||||||
|
for (var opt in widget.selectedOptions) {
|
||||||
|
// Les options sont stockées en TTC ou HT ?
|
||||||
|
// Généralement l'application utilise TTC.
|
||||||
|
final optPrice = opt['price'] is double ? opt['price'] : double.tryParse(opt['price'].toString()) ?? 0.0;
|
||||||
|
final optQuantity = opt['quantity'] is int ? opt['quantity'] : int.tryParse(opt['quantity'].toString()) ?? 1;
|
||||||
|
optionsTotalTTC += optPrice * optQuantity;
|
||||||
|
}
|
||||||
|
|
||||||
|
final totalTTC = ttcValue + optionsTotalTTC;
|
||||||
|
final totalHT = PriceHelpers.calculateHT(totalTTC, taxRate: widget.taxRate);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blue.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.blue.shade100),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Prix Total (Base + Options)',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${totalHT.toStringAsFixed(2)} € HT',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.blue.shade800),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${totalTTC.toStringAsFixed(2)} € TTC',
|
||||||
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Colors.blue.shade900),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
|
|||||||
@@ -0,0 +1,659 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:em2rp/models/vehicle_model.dart';
|
||||||
|
import 'package:em2rp/models/depot_model.dart';
|
||||||
|
import 'package:em2rp/models/route_result_model.dart';
|
||||||
|
import 'package:em2rp/services/vehicle_service.dart';
|
||||||
|
import 'package:em2rp/services/travel_service.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
import 'package:em2rp/views/widgets/common/route_map_widget.dart';
|
||||||
|
|
||||||
|
/// Dialog complète de calcul des frais de déplacement.
|
||||||
|
/// Retourne le prix calculé si l'utilisateur sélectionne un itinéraire,
|
||||||
|
/// null sinon.
|
||||||
|
Future<double?> showTravelCostDialog({
|
||||||
|
required BuildContext context,
|
||||||
|
required String eventAddress,
|
||||||
|
}) {
|
||||||
|
return showDialog<double>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (ctx) => TravelCostDialog(eventAddress: eventAddress),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TravelCostDialog extends StatefulWidget {
|
||||||
|
final String eventAddress;
|
||||||
|
|
||||||
|
const TravelCostDialog({super.key, required this.eventAddress});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TravelCostDialog> createState() => _TravelCostDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TravelCostDialogState extends State<TravelCostDialog> {
|
||||||
|
final _vehicleService = VehicleService();
|
||||||
|
final _travelService = TravelService();
|
||||||
|
|
||||||
|
// Données chargées
|
||||||
|
List<VehicleModel> _vehicles = [];
|
||||||
|
List<DepotModel> _depots = [];
|
||||||
|
FuelPrices _fuelPrices = const FuelPrices();
|
||||||
|
|
||||||
|
// Sélections
|
||||||
|
VehicleModel? _selectedVehicle;
|
||||||
|
DepotModel? _selectedDepot;
|
||||||
|
int _nbTechnicians = 2;
|
||||||
|
double _hourlyRate = 25.0;
|
||||||
|
bool _applyFreeZone = false;
|
||||||
|
|
||||||
|
// Résultats
|
||||||
|
List<RouteResult> _routes = [];
|
||||||
|
RouteResult? _selectedRoute;
|
||||||
|
|
||||||
|
// État
|
||||||
|
bool _isLoadingData = true;
|
||||||
|
bool _isCalculating = false;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
final _hourlyCtrl = TextEditingController(text: '25');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_hourlyCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadData() async {
|
||||||
|
setState(() => _isLoadingData = true);
|
||||||
|
try {
|
||||||
|
final results = await Future.wait([
|
||||||
|
_vehicleService.getVehicles(),
|
||||||
|
_travelService.getDepots(),
|
||||||
|
_travelService.getFuelPrices(),
|
||||||
|
]);
|
||||||
|
final vehicles = results[0] as List<VehicleModel>;
|
||||||
|
final depots = results[1] as List<DepotModel>;
|
||||||
|
final prices = results[2] as FuelPrices;
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_vehicles = vehicles;
|
||||||
|
_depots = depots;
|
||||||
|
_fuelPrices = prices;
|
||||||
|
_selectedVehicle = vehicles.isNotEmpty ? vehicles.first : null;
|
||||||
|
_selectedDepot = depots.isNotEmpty ? depots.first : null;
|
||||||
|
_isLoadingData = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Erreur lors du chargement: $e';
|
||||||
|
_isLoadingData = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _calculate() async {
|
||||||
|
if (_selectedVehicle == null || _selectedDepot == null) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isCalculating = true;
|
||||||
|
_error = null;
|
||||||
|
_routes = [];
|
||||||
|
_selectedRoute = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final routes = await _travelService.computeRoutes(
|
||||||
|
origin: _selectedDepot!.address,
|
||||||
|
destination: widget.eventAddress,
|
||||||
|
vehicleTollCategory: _selectedVehicle!.tollCategoryId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_routes = routes;
|
||||||
|
_isCalculating = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_error = 'Erreur de calcul: $e';
|
||||||
|
_isCalculating = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectRoute(RouteResult route) {
|
||||||
|
final total = route.totalCost(
|
||||||
|
consumptionPer100km: _selectedVehicle!.consumptionPer100km,
|
||||||
|
fuelPricePerLiter: _fuelPrices.priceForFuelType(_selectedVehicle!.fuelType),
|
||||||
|
maintenanceCostPerKm: _selectedVehicle!.maintenanceCostPerKm,
|
||||||
|
nbTechnicians: _nbTechnicians,
|
||||||
|
hourlyRate: _hourlyRate,
|
||||||
|
applyFreeZone: _applyFreeZone,
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop(total);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(int seconds) {
|
||||||
|
final h = seconds ~/ 3600;
|
||||||
|
final m = (seconds % 3600) ~/ 60;
|
||||||
|
if (h == 0) return '${m}min';
|
||||||
|
return '${h}h${m.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDistance(int meters) {
|
||||||
|
final km = meters / 1000.0;
|
||||||
|
return '${km.toStringAsFixed(0)} km';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final screenW = MediaQuery.of(context).size.width;
|
||||||
|
final isWide = screenW > 900;
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: isWide ? 900 : 600,
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
|
Flexible(child: _buildBody()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.rouge,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.route, color: Colors.white, size: 24),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Calculer les frais de déplacement',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
widget.eventAddress,
|
||||||
|
style: const TextStyle(color: Colors.white70, fontSize: 12),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.of(context).pop(null),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (_isLoadingData) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.all(48),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_routes.isEmpty) {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: _buildConfigPanel(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Résultats
|
||||||
|
final screenW = MediaQuery.of(context).size.width;
|
||||||
|
final isWide = screenW > 900;
|
||||||
|
|
||||||
|
if (isWide) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 3,
|
||||||
|
child: RouteMapWidget(routes: _routes, selectedRoute: _selectedRoute),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _buildResultsPanel(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 260,
|
||||||
|
child: RouteMapWidget(routes: _routes, selectedRoute: _selectedRoute),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _buildResultsPanel(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildConfigPanel() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
if (_error != null)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red[50],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.red[200]!),
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
const Icon(Icons.error_outline, color: Colors.red),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: Text(_error!, style: const TextStyle(color: Colors.red))),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Dépôt de départ
|
||||||
|
_sectionTitle('Dépôt de départ'),
|
||||||
|
if (_depots.isEmpty)
|
||||||
|
_emptyHint(
|
||||||
|
'Aucun dépôt configuré. Ajoutez-en un dans Gestion des données → Dépôts.')
|
||||||
|
else
|
||||||
|
DropdownButtonFormField<DepotModel>(
|
||||||
|
value: _selectedDepot,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.warehouse_outlined),
|
||||||
|
),
|
||||||
|
items: _depots
|
||||||
|
.map((d) => DropdownMenuItem(
|
||||||
|
value: d,
|
||||||
|
child: Text(
|
||||||
|
'${d.name} — ${d.address}',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => _selectedDepot = v),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Véhicule
|
||||||
|
_sectionTitle('Véhicule'),
|
||||||
|
if (_vehicles.isEmpty)
|
||||||
|
_emptyHint(
|
||||||
|
'Aucun véhicule configuré. Ajoutez-en un dans Gestion des données → Véhicules.')
|
||||||
|
else
|
||||||
|
DropdownButtonFormField<VehicleModel>(
|
||||||
|
value: _selectedVehicle,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
prefixIcon: Icon(Icons.directions_car_outlined),
|
||||||
|
),
|
||||||
|
items: _vehicles
|
||||||
|
.map((v) => DropdownMenuItem(
|
||||||
|
value: v,
|
||||||
|
child: Text(
|
||||||
|
'${v.name} — ${v.consumptionPer100km} ${v.consumptionUnit} | Cat. péage ${v.tollCategoryId}',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
onChanged: (v) => setState(() => _selectedVehicle = v),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// Techniciens + taux horaire
|
||||||
|
_sectionTitle('Main-d\'œuvre'),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('Nb techniciens',
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
|
onPressed: _nbTechnicians > 1
|
||||||
|
? () => setState(() => _nbTechnicians--)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'$_nbTechnicians',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
|
onPressed: () => setState(() => _nbTechnicians++),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _hourlyCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Taux horaire',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
suffixText: '€/h',
|
||||||
|
),
|
||||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
|
inputFormatters: [
|
||||||
|
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
final parsed = double.tryParse(v);
|
||||||
|
if (parsed != null) setState(() => _hourlyRate = parsed);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Zone de gratuité
|
||||||
|
CheckboxListTile(
|
||||||
|
value: _applyFreeZone,
|
||||||
|
onChanged: (v) => setState(() => _applyFreeZone = v ?? false),
|
||||||
|
activeColor: AppColors.rouge,
|
||||||
|
title: const Text('Appliquer la zone de gratuité'),
|
||||||
|
subtitle:
|
||||||
|
const Text('Déduit 20 km (carburant + maintenance) et 20 min (main-d\'œuvre)'),
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Bouton Calculer
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: _isCalculating
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2, color: Colors.white),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.calculate_outlined),
|
||||||
|
label: Text(_isCalculating ? 'Calcul en cours...' : 'Calculer le trajet'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.rouge,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
textStyle: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
onPressed: (_isCalculating ||
|
||||||
|
_selectedVehicle == null ||
|
||||||
|
_selectedDepot == null)
|
||||||
|
? null
|
||||||
|
: _calculate,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildResultsPanel() {
|
||||||
|
final vehicle = _selectedVehicle!;
|
||||||
|
final fuelPrice = _fuelPrices.priceForFuelType(vehicle.fuelType);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_sectionTitle('Itinéraires'),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.refresh, size: 16),
|
||||||
|
label: const Text('Recalculer'),
|
||||||
|
onPressed: () => setState(() {
|
||||||
|
_routes = [];
|
||||||
|
_selectedRoute = null;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Légende
|
||||||
|
Row(children: [
|
||||||
|
_legendDot(const Color(0xFF1565C0)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Text('Avec péage', style: TextStyle(fontSize: 12)),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
_legendDot(const Color(0xFF2E7D32)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Text('Sans péage', style: TextStyle(fontSize: 12)),
|
||||||
|
]),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
..._routes.map((route) {
|
||||||
|
final total = route.totalCost(
|
||||||
|
consumptionPer100km: vehicle.consumptionPer100km,
|
||||||
|
fuelPricePerLiter: fuelPrice,
|
||||||
|
maintenanceCostPerKm: vehicle.maintenanceCostPerKm,
|
||||||
|
nbTechnicians: _nbTechnicians,
|
||||||
|
hourlyRate: _hourlyRate,
|
||||||
|
applyFreeZone: _applyFreeZone,
|
||||||
|
);
|
||||||
|
final fuel = route.fuelCost(
|
||||||
|
consumptionPer100km: vehicle.consumptionPer100km,
|
||||||
|
fuelPricePerLiter: fuelPrice,
|
||||||
|
freeZoneKm: _applyFreeZone ? 20 : 0,
|
||||||
|
);
|
||||||
|
final maint = route.maintenanceCost(
|
||||||
|
costPerKm: vehicle.maintenanceCostPerKm,
|
||||||
|
freeZoneKm: _applyFreeZone ? 20 : 0,
|
||||||
|
);
|
||||||
|
final labor = route.laborCost(
|
||||||
|
nbTechnicians: _nbTechnicians,
|
||||||
|
hourlyRate: _hourlyRate,
|
||||||
|
freeZoneMinutes: _applyFreeZone ? 20 : 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
final isToll = route.routeType == 'TOLL';
|
||||||
|
final color = isToll
|
||||||
|
? const Color(0xFF1565C0)
|
||||||
|
: const Color(0xFF2E7D32);
|
||||||
|
|
||||||
|
return _buildRouteCard(
|
||||||
|
route: route,
|
||||||
|
color: color,
|
||||||
|
label: isToll ? '🚗 Route la plus rapide' : '🛣️ Route sans péage',
|
||||||
|
total: total,
|
||||||
|
fuel: fuel,
|
||||||
|
maint: maint,
|
||||||
|
labor: labor,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRouteCard({
|
||||||
|
required RouteResult route,
|
||||||
|
required Color color,
|
||||||
|
required String label,
|
||||||
|
required double total,
|
||||||
|
required double fuel,
|
||||||
|
required double maint,
|
||||||
|
required double labor,
|
||||||
|
}) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: color, width: 2),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// En-tête
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.08),
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(10)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
_formatDistance(route.distanceMeters),
|
||||||
|
style: TextStyle(color: color, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
_formatDuration(route.durationSeconds),
|
||||||
|
style: TextStyle(color: color),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Détail des coûts
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_costRow('⛽ Carburant', fuel),
|
||||||
|
_costRow('🔧 Maintenance', maint),
|
||||||
|
_costRow('👷 Main-d\'œuvre (×$_nbTechnicians)', labor),
|
||||||
|
if (route.tollCost > 0) _costRow('🛣️ Péage', route.tollCost),
|
||||||
|
const Divider(),
|
||||||
|
_costRow('Total', total, bold: true, large: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Bouton Sélectionner
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 0, 14, 14),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: color,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
),
|
||||||
|
onPressed: () => _selectRoute(route),
|
||||||
|
child: Text(
|
||||||
|
'Sélectionner — ${total.toStringAsFixed(2)} €',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _costRow(String label, double amount,
|
||||||
|
{bool bold = false, bool large = false}) {
|
||||||
|
final style = TextStyle(
|
||||||
|
fontWeight: bold ? FontWeight.bold : FontWeight.normal,
|
||||||
|
fontSize: large ? 15 : 13,
|
||||||
|
);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(label, style: style),
|
||||||
|
Text('${amount.toStringAsFixed(2)} €',
|
||||||
|
style: style.copyWith(
|
||||||
|
color: bold ? AppColors.rouge : Colors.black87)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _legendDot(Color color) => Container(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _sectionTitle(String title) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(title,
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
||||||
|
);
|
||||||
|
|
||||||
|
Widget _emptyHint(String msg) => Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange[50],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.orange[200]!),
|
||||||
|
),
|
||||||
|
child:
|
||||||
|
Row(children: [const Icon(Icons.info_outline, color: Colors.orange), const SizedBox(width: 8), Expanded(child: Text(msg, style: const TextStyle(fontSize: 12)))]),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/services/travel_service.dart';
|
||||||
|
|
||||||
|
/// Champ texte avec autocomplétion d'adresses via Google Places.
|
||||||
|
/// Remplace le TextFormField standard pour le champ adresse.
|
||||||
|
class AddressAutocompleteField extends StatefulWidget {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final String label;
|
||||||
|
final String? Function(String?)? validator;
|
||||||
|
final void Function(String)? onSelected;
|
||||||
|
final InputDecoration? decoration;
|
||||||
|
|
||||||
|
const AddressAutocompleteField({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
this.label = 'Adresse',
|
||||||
|
this.validator,
|
||||||
|
this.onSelected,
|
||||||
|
this.decoration,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AddressAutocompleteField> createState() =>
|
||||||
|
_AddressAutocompleteFieldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddressAutocompleteFieldState extends State<AddressAutocompleteField> {
|
||||||
|
final _service = TravelService();
|
||||||
|
final _focusNode = FocusNode();
|
||||||
|
final _overlayKey = GlobalKey();
|
||||||
|
|
||||||
|
List<String> _suggestions = [];
|
||||||
|
Timer? _debounce;
|
||||||
|
OverlayEntry? _overlayEntry;
|
||||||
|
final _layerLink = LayerLink();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.controller.addListener(_onTextChanged);
|
||||||
|
_focusNode.addListener(() {
|
||||||
|
if (!_focusNode.hasFocus) {
|
||||||
|
Future.delayed(const Duration(milliseconds: 200), () {
|
||||||
|
if (mounted && !_focusNode.hasFocus) {
|
||||||
|
_removeOverlay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTextChanged() {
|
||||||
|
_debounce?.cancel();
|
||||||
|
final text = widget.controller.text;
|
||||||
|
if (text.length < 3) {
|
||||||
|
_removeOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_debounce = Timer(const Duration(milliseconds: 400), () async {
|
||||||
|
final results = await _service.autocompleteAddress(text);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _suggestions = results);
|
||||||
|
if (results.isNotEmpty && _focusNode.hasFocus) {
|
||||||
|
_showSuggestions();
|
||||||
|
} else {
|
||||||
|
_removeOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSuggestions() {
|
||||||
|
_removeOverlay();
|
||||||
|
final overlay = Overlay.of(context);
|
||||||
|
_overlayEntry = OverlayEntry(
|
||||||
|
builder: (ctx) => Positioned(
|
||||||
|
width: _getFieldWidth(),
|
||||||
|
child: CompositedTransformFollower(
|
||||||
|
link: _layerLink,
|
||||||
|
showWhenUnlinked: false,
|
||||||
|
offset: const Offset(0, 56),
|
||||||
|
child: Material(
|
||||||
|
elevation: 6,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 220),
|
||||||
|
child: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: _suggestions.length,
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
const Divider(height: 1, indent: 16),
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onPanDown: (_) {
|
||||||
|
widget.controller.text = _suggestions[i];
|
||||||
|
widget.controller.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: _suggestions[i].length),
|
||||||
|
);
|
||||||
|
widget.onSelected?.call(_suggestions[i]);
|
||||||
|
_removeOverlay();
|
||||||
|
_focusNode.unfocus();
|
||||||
|
},
|
||||||
|
child: ListTile(
|
||||||
|
dense: true,
|
||||||
|
leading: const Icon(Icons.location_on_outlined, size: 18),
|
||||||
|
title: Text(
|
||||||
|
_suggestions[i],
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
overlay.insert(_overlayEntry!);
|
||||||
|
}
|
||||||
|
|
||||||
|
double _getFieldWidth() {
|
||||||
|
final rb = context.findRenderObject() as RenderBox?;
|
||||||
|
return rb?.size.width ?? 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeOverlay() {
|
||||||
|
_overlayEntry?.remove();
|
||||||
|
_overlayEntry = null;
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_debounce?.cancel();
|
||||||
|
widget.controller.removeListener(_onTextChanged);
|
||||||
|
_focusNode.dispose();
|
||||||
|
_removeOverlay();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return CompositedTransformTarget(
|
||||||
|
link: _layerLink,
|
||||||
|
child: TextFormField(
|
||||||
|
key: _overlayKey,
|
||||||
|
controller: widget.controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
decoration: widget.decoration ??
|
||||||
|
InputDecoration(
|
||||||
|
labelText: widget.label,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
prefixIcon: const Icon(Icons.location_on_outlined),
|
||||||
|
suffixIcon: widget.controller.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear, size: 18),
|
||||||
|
onPressed: () {
|
||||||
|
widget.controller.clear();
|
||||||
|
_removeOverlay();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
validator: widget.validator,
|
||||||
|
onChanged: (_) {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ dependencies:
|
|||||||
universal_io: ^2.2.2
|
universal_io: ^2.2.2
|
||||||
flutter_dotenv: ^6.0.0
|
flutter_dotenv: ^6.0.0
|
||||||
|
|
||||||
|
# Map
|
||||||
|
flutter_map: ^8.3.0
|
||||||
|
latlong2: ^0.9.1
|
||||||
|
|
||||||
# Sharing & Launch
|
# Sharing & Launch
|
||||||
url_launcher: ^6.2.2
|
url_launcher: ^6.2.2
|
||||||
share_plus: ^12.0.1
|
share_plus: ^12.0.1
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,215 @@
|
|||||||
|
file:///C:/Users/paulf/flutter/bin/cache/dart-sdk/lib/_internal/dart2js_platform.dill
|
||||||
|
file:///C:/Users/paulf/flutter/bin/cache/dart-sdk/lib/libraries.json
|
||||||
|
file:///C:/src/EM2RP/em2rp/.dart_tool/package_config.json
|
||||||
|
file:///C:/src/EM2RP/em2rp/scratch/test_user_route.dart
|
||||||
|
org-dartlang-sdk:///lib/_http/crypto.dart
|
||||||
|
org-dartlang-sdk:///lib/_http/embedder_config.dart
|
||||||
|
org-dartlang-sdk:///lib/_http/http.dart
|
||||||
|
org-dartlang-sdk:///lib/_http/http_date.dart
|
||||||
|
org-dartlang-sdk:///lib/_http/http_headers.dart
|
||||||
|
org-dartlang-sdk:///lib/_http/http_impl.dart
|
||||||
|
org-dartlang-sdk:///lib/_http/http_parser.dart
|
||||||
|
org-dartlang-sdk:///lib/_http/http_session.dart
|
||||||
|
org-dartlang-sdk:///lib/_http/http_testing.dart
|
||||||
|
org-dartlang-sdk:///lib/_http/overrides.dart
|
||||||
|
org-dartlang-sdk:///lib/_http/websocket.dart
|
||||||
|
org-dartlang-sdk:///lib/_http/websocket_impl.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/annotations.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/async_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/bigint_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/collection_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/constant_map.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/convert_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/core_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/dart2js_only.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/dart2js_runtime_metrics.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/developer_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/foreign_helper.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/instantiation.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/interceptors.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/internal_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/io_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/isolate_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/js_allow_interop_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/js_array.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/js_helper.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/js_names.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/js_number.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/js_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/js_primitives.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/js_string.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/late_helper.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/linked_hash_map.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/math_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/native_helper.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/native_typed_data.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/records.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/regexp_helper.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/string_helper.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/synced/array_flags.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/synced/embedded_names.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/synced/invocation_mirror_constants.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_runtime/lib/typed_data_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_shared/lib/convert_utf_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_shared/lib/date_time_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_shared/lib/js_interop_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_shared/lib/js_interop_unsafe_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_shared/lib/js_types.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_shared/lib/js_util_patch.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_shared/lib/rti.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_shared/lib/synced/async_status_codes.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_shared/lib/synced/embedded_names.dart
|
||||||
|
org-dartlang-sdk:///lib/_internal/js_shared/lib/synced/recipe_syntax.dart
|
||||||
|
org-dartlang-sdk:///lib/async/async.dart
|
||||||
|
org-dartlang-sdk:///lib/async/async_error.dart
|
||||||
|
org-dartlang-sdk:///lib/async/broadcast_stream_controller.dart
|
||||||
|
org-dartlang-sdk:///lib/async/deferred_load.dart
|
||||||
|
org-dartlang-sdk:///lib/async/future.dart
|
||||||
|
org-dartlang-sdk:///lib/async/future_extensions.dart
|
||||||
|
org-dartlang-sdk:///lib/async/future_impl.dart
|
||||||
|
org-dartlang-sdk:///lib/async/schedule_microtask.dart
|
||||||
|
org-dartlang-sdk:///lib/async/stream.dart
|
||||||
|
org-dartlang-sdk:///lib/async/stream_controller.dart
|
||||||
|
org-dartlang-sdk:///lib/async/stream_impl.dart
|
||||||
|
org-dartlang-sdk:///lib/async/stream_pipe.dart
|
||||||
|
org-dartlang-sdk:///lib/async/stream_transformers.dart
|
||||||
|
org-dartlang-sdk:///lib/async/timer.dart
|
||||||
|
org-dartlang-sdk:///lib/async/zone.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/collection.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/collections.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/hash_map.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/hash_set.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/iterable.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/iterator.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/linked_hash_map.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/linked_hash_set.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/linked_list.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/list.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/maps.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/queue.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/set.dart
|
||||||
|
org-dartlang-sdk:///lib/collection/splay_tree.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/ascii.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/base64.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/byte_conversion.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/chunked_conversion.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/codec.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/convert.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/converter.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/encoding.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/html_escape.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/json.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/latin1.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/line_splitter.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/string_conversion.dart
|
||||||
|
org-dartlang-sdk:///lib/convert/utf.dart
|
||||||
|
org-dartlang-sdk:///lib/core/annotations.dart
|
||||||
|
org-dartlang-sdk:///lib/core/bigint.dart
|
||||||
|
org-dartlang-sdk:///lib/core/bool.dart
|
||||||
|
org-dartlang-sdk:///lib/core/comparable.dart
|
||||||
|
org-dartlang-sdk:///lib/core/core.dart
|
||||||
|
org-dartlang-sdk:///lib/core/date_time.dart
|
||||||
|
org-dartlang-sdk:///lib/core/double.dart
|
||||||
|
org-dartlang-sdk:///lib/core/duration.dart
|
||||||
|
org-dartlang-sdk:///lib/core/enum.dart
|
||||||
|
org-dartlang-sdk:///lib/core/errors.dart
|
||||||
|
org-dartlang-sdk:///lib/core/exceptions.dart
|
||||||
|
org-dartlang-sdk:///lib/core/function.dart
|
||||||
|
org-dartlang-sdk:///lib/core/identical.dart
|
||||||
|
org-dartlang-sdk:///lib/core/int.dart
|
||||||
|
org-dartlang-sdk:///lib/core/invocation.dart
|
||||||
|
org-dartlang-sdk:///lib/core/iterable.dart
|
||||||
|
org-dartlang-sdk:///lib/core/iterator.dart
|
||||||
|
org-dartlang-sdk:///lib/core/list.dart
|
||||||
|
org-dartlang-sdk:///lib/core/map.dart
|
||||||
|
org-dartlang-sdk:///lib/core/null.dart
|
||||||
|
org-dartlang-sdk:///lib/core/num.dart
|
||||||
|
org-dartlang-sdk:///lib/core/object.dart
|
||||||
|
org-dartlang-sdk:///lib/core/pattern.dart
|
||||||
|
org-dartlang-sdk:///lib/core/print.dart
|
||||||
|
org-dartlang-sdk:///lib/core/record.dart
|
||||||
|
org-dartlang-sdk:///lib/core/regexp.dart
|
||||||
|
org-dartlang-sdk:///lib/core/set.dart
|
||||||
|
org-dartlang-sdk:///lib/core/sink.dart
|
||||||
|
org-dartlang-sdk:///lib/core/stacktrace.dart
|
||||||
|
org-dartlang-sdk:///lib/core/stopwatch.dart
|
||||||
|
org-dartlang-sdk:///lib/core/string.dart
|
||||||
|
org-dartlang-sdk:///lib/core/string_buffer.dart
|
||||||
|
org-dartlang-sdk:///lib/core/string_sink.dart
|
||||||
|
org-dartlang-sdk:///lib/core/symbol.dart
|
||||||
|
org-dartlang-sdk:///lib/core/type.dart
|
||||||
|
org-dartlang-sdk:///lib/core/uri.dart
|
||||||
|
org-dartlang-sdk:///lib/core/weak.dart
|
||||||
|
org-dartlang-sdk:///lib/developer/developer.dart
|
||||||
|
org-dartlang-sdk:///lib/developer/extension.dart
|
||||||
|
org-dartlang-sdk:///lib/developer/http_profiling.dart
|
||||||
|
org-dartlang-sdk:///lib/developer/profiler.dart
|
||||||
|
org-dartlang-sdk:///lib/developer/service.dart
|
||||||
|
org-dartlang-sdk:///lib/developer/timeline.dart
|
||||||
|
org-dartlang-sdk:///lib/html/dart2js/html_dart2js.dart
|
||||||
|
org-dartlang-sdk:///lib/html/html_common/conversions.dart
|
||||||
|
org-dartlang-sdk:///lib/html/html_common/conversions_dart2js.dart
|
||||||
|
org-dartlang-sdk:///lib/html/html_common/css_class_set.dart
|
||||||
|
org-dartlang-sdk:///lib/html/html_common/device.dart
|
||||||
|
org-dartlang-sdk:///lib/html/html_common/filtered_element_list.dart
|
||||||
|
org-dartlang-sdk:///lib/html/html_common/html_common_dart2js.dart
|
||||||
|
org-dartlang-sdk:///lib/html/html_common/lists.dart
|
||||||
|
org-dartlang-sdk:///lib/html/html_common/metadata.dart
|
||||||
|
org-dartlang-sdk:///lib/indexed_db/dart2js/indexed_db_dart2js.dart
|
||||||
|
org-dartlang-sdk:///lib/internal/async_cast.dart
|
||||||
|
org-dartlang-sdk:///lib/internal/bytes_builder.dart
|
||||||
|
org-dartlang-sdk:///lib/internal/cast.dart
|
||||||
|
org-dartlang-sdk:///lib/internal/errors.dart
|
||||||
|
org-dartlang-sdk:///lib/internal/internal.dart
|
||||||
|
org-dartlang-sdk:///lib/internal/iterable.dart
|
||||||
|
org-dartlang-sdk:///lib/internal/linked_list.dart
|
||||||
|
org-dartlang-sdk:///lib/internal/list.dart
|
||||||
|
org-dartlang-sdk:///lib/internal/patch.dart
|
||||||
|
org-dartlang-sdk:///lib/internal/print.dart
|
||||||
|
org-dartlang-sdk:///lib/internal/sort.dart
|
||||||
|
org-dartlang-sdk:///lib/internal/symbol.dart
|
||||||
|
org-dartlang-sdk:///lib/io/common.dart
|
||||||
|
org-dartlang-sdk:///lib/io/data_transformer.dart
|
||||||
|
org-dartlang-sdk:///lib/io/directory.dart
|
||||||
|
org-dartlang-sdk:///lib/io/directory_impl.dart
|
||||||
|
org-dartlang-sdk:///lib/io/embedder_config.dart
|
||||||
|
org-dartlang-sdk:///lib/io/eventhandler.dart
|
||||||
|
org-dartlang-sdk:///lib/io/file.dart
|
||||||
|
org-dartlang-sdk:///lib/io/file_impl.dart
|
||||||
|
org-dartlang-sdk:///lib/io/file_system_entity.dart
|
||||||
|
org-dartlang-sdk:///lib/io/io.dart
|
||||||
|
org-dartlang-sdk:///lib/io/io_resource_info.dart
|
||||||
|
org-dartlang-sdk:///lib/io/io_service.dart
|
||||||
|
org-dartlang-sdk:///lib/io/io_sink.dart
|
||||||
|
org-dartlang-sdk:///lib/io/link.dart
|
||||||
|
org-dartlang-sdk:///lib/io/namespace_impl.dart
|
||||||
|
org-dartlang-sdk:///lib/io/network_profiling.dart
|
||||||
|
org-dartlang-sdk:///lib/io/overrides.dart
|
||||||
|
org-dartlang-sdk:///lib/io/platform.dart
|
||||||
|
org-dartlang-sdk:///lib/io/platform_impl.dart
|
||||||
|
org-dartlang-sdk:///lib/io/process.dart
|
||||||
|
org-dartlang-sdk:///lib/io/secure_server_socket.dart
|
||||||
|
org-dartlang-sdk:///lib/io/secure_socket.dart
|
||||||
|
org-dartlang-sdk:///lib/io/security_context.dart
|
||||||
|
org-dartlang-sdk:///lib/io/service_object.dart
|
||||||
|
org-dartlang-sdk:///lib/io/socket.dart
|
||||||
|
org-dartlang-sdk:///lib/io/stdio.dart
|
||||||
|
org-dartlang-sdk:///lib/io/string_transformer.dart
|
||||||
|
org-dartlang-sdk:///lib/io/sync_socket.dart
|
||||||
|
org-dartlang-sdk:///lib/isolate/capability.dart
|
||||||
|
org-dartlang-sdk:///lib/isolate/isolate.dart
|
||||||
|
org-dartlang-sdk:///lib/js/_js.dart
|
||||||
|
org-dartlang-sdk:///lib/js/_js_annotations.dart
|
||||||
|
org-dartlang-sdk:///lib/js/_js_client.dart
|
||||||
|
org-dartlang-sdk:///lib/js/js.dart
|
||||||
|
org-dartlang-sdk:///lib/js_interop/js_interop.dart
|
||||||
|
org-dartlang-sdk:///lib/js_interop_unsafe/js_interop_unsafe.dart
|
||||||
|
org-dartlang-sdk:///lib/js_util/js_util.dart
|
||||||
|
org-dartlang-sdk:///lib/math/math.dart
|
||||||
|
org-dartlang-sdk:///lib/math/point.dart
|
||||||
|
org-dartlang-sdk:///lib/math/random.dart
|
||||||
|
org-dartlang-sdk:///lib/math/rectangle.dart
|
||||||
|
org-dartlang-sdk:///lib/svg/dart2js/svg_dart2js.dart
|
||||||
|
org-dartlang-sdk:///lib/typed_data/typed_data.dart
|
||||||
|
org-dartlang-sdk:///lib/web_audio/dart2js/web_audio_dart2js.dart
|
||||||
|
org-dartlang-sdk:///lib/web_gl/dart2js/web_gl_dart2js.dart
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -24,10 +24,8 @@
|
|||||||
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
||||||
<meta http-equiv="Pragma" content="no-cache">
|
<meta http-equiv="Pragma" content="no-cache">
|
||||||
<meta http-equiv="Expires" content="0">
|
<meta http-equiv="Expires" content="0">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
||||||
|
|
||||||
<!-- iOS meta tags & icons -->
|
<!-- iOS meta tags & icons -->
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
<meta name="apple-mobile-web-app-title" content="EM2RP">
|
<meta name="apple-mobile-web-app-title" content="EM2RP">
|
||||||
<link rel="apple-touch-icon" href="favicon.jpg">
|
<link rel="apple-touch-icon" href="favicon.jpg">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": "1.2.3",
|
"version": "1.2.4",
|
||||||
"updateUrl": "https://app.em2events.fr",
|
"updateUrl": "https://app.em2events.fr",
|
||||||
"forceUpdate": true,
|
"forceUpdate": true,
|
||||||
"releaseNotes": "Optimisation des perfomance de l'application, amélioration de la gestion des données et refonte visuelle de la page de gestion des équipements.",
|
"releaseNotes": "Ajout de la fonction retour en arriere pour la validation du chargement des equipements et des conteneurs.",
|
||||||
"timestamp": "2026-05-26T13:34:16.390Z"
|
"timestamp": "2026-05-27T20:00:59.431Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user